feat(emails): arbitrary-recipient send + Emailable on managed domains (#1681)

## Summary

Two related email changes:

### 1. Send to arbitrary email addresses
The `sendEmail` SDK function and `POST /api/v1/emails/send-email` now
accept a third recipient selector, **`emails: string[]`**, mutually
exclusive (XOR) with `user_ids` and `all_users`. These recipients don't
have to belong to a user — they're mapped to the existing internal
`custom-emails` recipient type (no associated user object, can't
unsubscribe, so intended for transactional mail).

- Response `results` items are now `{ user_id?, email? }`; the `emails`
path returns `{ email }`.
- Validation message updated to `Exactly one of user_ids, all_users, or
emails must be provided`.
- Recipient addresses are validated with the shared `emailSchema`.

### 2. Run Emailable on managed custom domains
`LowLevelEmailConfig.type` is widened `'shared' | 'standard'` →
`'shared' | 'managed' | 'standard'`, and `getEmailConfig` returns
`'managed'` for the managed-Resend branch. The Emailable deliverability
check now runs for **shared** and **managed**, and is skipped for
**`standard`** (a customer's own SMTP server or Resend API key).

**Why:** managed domains send through our single Resend *account*
(per-domain scoped keys, but shared account → account-level bounce
penalties are collateral across managed customers), so we own that
reputation. With a custom SMTP server or the customer's own Resend key,
the customer owns their deliverability, so we don't second-guess their
recipients or spend Emailable checks on their volume. The shared
dev-email wrapper remains `shared`-only.

## Tests
- `send-email.test.ts`: added arbitrary-recipient send
(single/multiple), empty-array, and validation cases (both-selectors,
no-selector, invalid email); updated the exactly-one-of snapshot.
**24/24 pass.**
- New `emails/deliverability-gating.test.ts`: shared & managed skip an
undeliverable address (`LIKELY_NOT_DELIVERABLE`), custom SMTP still
sends. **3/3 pass.**

> Note: `email-queue.test.ts` has 2 pre-existing snapshot failures (a
`margin:0rem`→`margin:0` CSS normalization in rendered email HTML) that
are unrelated to this change — confirmed by reproducing them on `dev`
with these changes stashed.

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Adds support for sending emails to arbitrary addresses and enables
Emailable checks for managed domains to protect shared sending
reputation. The API/SDK now accept `emails: string[]` as a recipient
selector, and deliverability checks run on shared and managed sending.

- New Features
- API/SDK: `emails: string[]` recipient selector (XOR with
`user_ids`/`all_users`) in `sendEmail` and POST
/api/v1/emails/send-email. Results return `{ email }` for this path;
addresses validated via `emailSchema`. Recipients map to
transactional-only “custom-emails” (no user, no unsubscribe).
- Deliverability: email config adds `'managed'`; `getEmailConfig`
returns it for managed Resend domains. Emailable check runs for `shared`
and `managed`; skipped for `standard` (custom SMTP or customer Resend
key).

<sup>Written for commit e48cf2ddda.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/hexclave/hexclave/pull/1681?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>

<!-- End of auto-generated description by cubic. -->



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Emails can now be sent to arbitrary email addresses, not just existing
users.
* Email sending APIs and client options now accept an email list as a
recipient choice.

* **Bug Fixes**
* Improved recipient validation so exactly one target type must be
chosen.
* Fixed response results to consistently show either a user ID or an
email address.
* Corrected deliverability checks for managed email setups and updated
skip behavior for undeliverable addresses.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
BilalG1 2026-06-30 11:16:25 -07:00 committed by GitHub
parent 4cd766e406
commit 81433e5181
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 372 additions and 35 deletions

View File

@ -1,17 +1,20 @@
import { getEmailDraft, themeModeToTemplateThemeId } from "@/lib/email-drafts";
import { createTemplateComponentFromHtml } from "@/lib/email-rendering";
import { sendEmailToMany } from "@/lib/emails";
import { EmailOutboxRecipient, sendEmailToMany } from "@/lib/emails";
import { getNotificationCategoryByName } from "@/lib/notification-categories";
import { getPrismaClientForTenancy } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { KnownErrors } from "@hexclave/shared";
import { adaptSchema, jsonSchema, serverOrHigherAuthTypeSchema, templateThemeIdSchema, yupArray, yupBoolean, yupNumber, yupObject, yupRecord, yupString, yupUnion } from "@hexclave/shared/dist/schema-fields";
import { adaptSchema, emailSchema, jsonSchema, serverOrHigherAuthTypeSchema, templateThemeIdSchema, yupArray, yupBoolean, yupNumber, yupObject, yupRecord, yupString, yupUnion } from "@hexclave/shared/dist/schema-fields";
import { getEnvVariable } from "@hexclave/shared/dist/utils/env";
import { StatusError, throwErr } from "@hexclave/shared/dist/utils/errors";
const bodyBase = yupObject({
user_ids: yupArray(yupString().defined()).optional(),
all_users: yupBoolean().oneOf([true]).optional(),
emails: yupArray(emailSchema.defined()).optional().meta({
openapiField: { description: "Send the email to arbitrary email addresses that don't have to belong to a user. Recipients have no associated user object and cannot unsubscribe, so this should only be used for transactional emails." }
}),
subject: yupString().optional(),
notification_category_name: yupString().optional(),
theme_id: templateThemeIdSchema.nullable().meta({
@ -28,7 +31,7 @@ const bodyBase = yupObject({
export const POST = createSmartRouteHandler({
metadata: {
summary: "Send email",
description: "Send an email to a list of users. The content field should contain either {html} for HTML emails, {template_id, variables} for template-based emails, or {draft_id} for a draft email.",
description: "Send an email to a list of users (user_ids), all users (all_users), or arbitrary email addresses (emails). The content field should contain either {html} for HTML emails, {template_id, variables} for template-based emails, or {draft_id} for a draft email.",
tags: ["Emails"],
},
request: yupObject({
@ -55,7 +58,8 @@ export const POST = createSmartRouteHandler({
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
results: yupArray(yupObject({
user_id: yupString().defined(),
user_id: yupString().optional(),
email: yupString().optional(),
})).defined(),
}).defined(),
}),
@ -63,8 +67,9 @@ export const POST = createSmartRouteHandler({
if (!getEnvVariable("STACK_FREESTYLE_API_KEY")) {
throw new StatusError(500, "STACK_FREESTYLE_API_KEY is not set");
}
if ((body.user_ids && body.all_users) || (!body.user_ids && !body.all_users)) {
throw new KnownErrors.SchemaError("Exactly one of user_ids or all_users must be provided");
const providedRecipientSelectors = [body.user_ids, body.all_users, body.emails].filter(selector => selector !== undefined);
if (providedRecipientSelectors.length !== 1) {
throw new KnownErrors.SchemaError("Exactly one of user_ids, all_users, or emails must be provided");
}
// We have this check in the email queue step as well, but to give the user a better error message already in the send-email endpoint we already do it here
@ -125,33 +130,46 @@ export const POST = createSmartRouteHandler({
throw new KnownErrors.SchemaError("Either template_id, html, or draft_id must be provided");
}
const requestedUserIds = body.all_users ? (await prisma.projectUser.findMany({
where: {
tenancyId: auth.tenancy.id,
},
select: {
projectUserId: true,
},
})).map(user => user.projectUserId) : body.user_ids ?? throwErr("user_ids must be provided if all_users is false");
let recipients: EmailOutboxRecipient[];
let results: { user_id?: string, email?: string }[];
// Sanity check that the user IDs are valid so the user gets an error here instead of only once the email is rendered.
if (!body.all_users && body.user_ids) {
const uniqueUserIds = [...new Set(body.user_ids)];
const users = await prisma.projectUser.findMany({
if (body.emails) {
// Send to arbitrary email addresses that don't have to belong to a user. These have no associated user object,
// so they can't unsubscribe and there's nothing to sanity-check against the project's users.
recipients = body.emails.map(email => ({ type: "custom-emails", emails: [email] }));
results = body.emails.map(email => ({ email }));
} else {
const requestedUserIds = body.all_users ? (await prisma.projectUser.findMany({
where: {
tenancyId: auth.tenancy.id,
projectUserId: { in: uniqueUserIds },
},
select: {
projectUserId: true,
},
});
if (users.length !== uniqueUserIds.length) {
const foundUserIds = new Set(users.map(u => u.projectUserId));
const missingUserId = uniqueUserIds.find(id => !foundUserIds.has(id));
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
throw new KnownErrors.UserIdDoesNotExist(missingUserId!);
})).map(user => user.projectUserId) : body.user_ids ?? throwErr("user_ids must be provided if all_users is false");
// Sanity check that the user IDs are valid so the user gets an error here instead of only once the email is rendered.
if (!body.all_users && body.user_ids) {
const uniqueUserIds = [...new Set(body.user_ids)];
const users = await prisma.projectUser.findMany({
where: {
tenancyId: auth.tenancy.id,
projectUserId: { in: uniqueUserIds },
},
select: {
projectUserId: true,
},
});
if (users.length !== uniqueUserIds.length) {
const foundUserIds = new Set(users.map(u => u.projectUserId));
const missingUserId = uniqueUserIds.find(id => !foundUserIds.has(id));
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
throw new KnownErrors.UserIdDoesNotExist(missingUserId!);
}
}
recipients = requestedUserIds.map(userId => ({ type: "user-primary-email", userId }));
results = requestedUserIds.map(userId => ({ user_id: userId }));
}
const scheduledAt = body.scheduled_at_millis ? new Date(body.scheduled_at_millis) : new Date();
@ -159,7 +177,7 @@ export const POST = createSmartRouteHandler({
await sendEmailToMany({
createdWith: createdWith,
tenancy: auth.tenancy,
recipients: requestedUserIds.map(userId => ({ type: "user-primary-email", userId })),
recipients: recipients,
tsxSource: tsxSource,
extraVariables: variables,
themeId: selectedThemeId === null ? null : (selectedThemeId === undefined ? auth.tenancy.config.emails.selectedThemeId : selectedThemeId),
@ -187,7 +205,7 @@ export const POST = createSmartRouteHandler({
statusCode: 200,
bodyType: 'json',
body: {
results: requestedUserIds.map(userId => ({ user_id: userId })),
results: results,
},
};
},

View File

@ -65,10 +65,13 @@ const emailQueueFirstRunKey = Symbol.for("__hexclave_email_queue_first_run_compl
async function verifyEmailDeliverability(
email: string,
shouldSkipDeliverabilityCheck: boolean,
emailConfigType: "shared" | "standard"
emailConfigType: "shared" | "managed" | "standard"
): Promise<EmailableCheckResult> {
// Skip deliverability check if requested or using non-shared email config
if (shouldSkipDeliverabilityCheck || emailConfigType !== "shared") {
// We run the Emailable deliverability check whenever the email goes out through infrastructure whose sending
// reputation we own: our shared server ("shared") and custom domains we provision & send on the user's behalf
// ("managed", Resend under our account). We skip it for "standard" (the user's own SMTP server or Resend API key),
// where the user owns their own deliverability, and whenever the caller explicitly opts out.
if (shouldSkipDeliverabilityCheck || (emailConfigType !== "shared" && emailConfigType !== "managed")) {
return { status: "deliverable", emailableScore: null };
}

View File

@ -34,7 +34,11 @@ export type LowLevelEmailConfig = {
senderEmail: string,
senderName: string,
secure: boolean,
type: 'shared' | 'standard',
// 'shared': Hexclave's shared email server. 'managed': a custom domain we provision & send through on the user's
// behalf (Resend under our account). 'standard': the user's own SMTP server or Resend API key. We run the Emailable
// deliverability check for 'shared' and 'managed' (where bad recipients hurt our own sending reputation), but not
// for 'standard' (the user owns their own deliverability there).
type: 'shared' | 'managed' | 'standard',
}
export type LowLevelSendEmailOptions = {

View File

@ -161,7 +161,7 @@ export async function getEmailConfig(tenancy: Tenancy): Promise<LowLevelEmailCon
senderEmail: `${projectEmailConfig.managedSenderLocalPart}@${projectEmailConfig.managedSubdomain}`,
senderName: tenancy.project.display_name,
secure: true,
type: "standard",
type: "managed",
};
}

View File

@ -0,0 +1,120 @@
import { wait } from "@hexclave/shared/dist/utils/promises";
import { describe } from "vitest";
import { it } from "../../../../../helpers";
import { withPortPrefix } from "../../../../../helpers/ports";
import { Project, niceBackendFetch, waitForOutboxEmailWithStatus } from "../../../../backend-helpers";
// The Emailable wrapper always reports this exact domain as undeliverable, regardless of API key, so we can exercise
// the deliverability gate deterministically without hitting the real Emailable API.
const NOT_DELIVERABLE_EMAIL = "recipient@emailable-not-deliverable.example.com";
const customSmtpConfig = {
type: "standard",
host: "localhost",
port: Number(withPortPrefix("29")),
username: "test",
password: "test",
sender_name: "Test Project",
sender_email: "test@example.com",
} as const;
async function sendToNotDeliverableAddress(subject: string) {
return await niceBackendFetch("/api/v1/emails/send-email", {
method: "POST",
accessType: "server",
body: {
emails: [NOT_DELIVERABLE_EMAIL],
html: "<p>Deliverability gating test</p>",
subject,
},
});
}
// Sets up and applies a managed custom domain on the current project using the mock Resend onboarding flow.
async function applyManagedEmailDomain(options: { subdomain: string, senderLocalPart: string }) {
const setupResponse = await niceBackendFetch("/api/v1/internal/emails/managed-onboarding/setup", {
method: "POST",
accessType: "admin",
body: {
subdomain: options.subdomain,
sender_local_part: options.senderLocalPart,
},
});
if (setupResponse.status !== 200) {
throw new Error(`Managed onboarding setup failed: ${JSON.stringify(setupResponse.body)}`);
}
const domainId = setupResponse.body.domain_id as string;
// The mock onboarding flips the domain to "verified" asynchronously, mirroring the real Resend webhook.
const deadline = performance.now() + 10_000;
while (performance.now() < deadline) {
const checkResponse = await niceBackendFetch("/api/v1/internal/emails/managed-onboarding/check", {
method: "POST",
accessType: "admin",
body: {
domain_id: domainId,
subdomain: options.subdomain,
sender_local_part: options.senderLocalPart,
},
});
if (checkResponse.status === 200 && checkResponse.body.status === "verified") {
break;
}
await wait(250);
}
const applyResponse = await niceBackendFetch("/api/v1/internal/emails/managed-onboarding/apply", {
method: "POST",
accessType: "admin",
body: {
domain_id: domainId,
},
});
if (applyResponse.status !== 200 || applyResponse.body.status !== "applied") {
throw new Error(`Managed onboarding apply failed: ${JSON.stringify(applyResponse.body)}`);
}
}
describe("emailable deliverability gating", () => {
it("runs the deliverability check on the shared email server (undeliverable address is skipped)", async ({ expect }) => {
// A fresh project defaults to the shared email server.
await Project.createAndSwitch({ display_name: "Shared Deliverability Project" });
const subject = "Shared Deliverability Gating Test";
const response = await sendToNotDeliverableAddress(subject);
expect(response.status).toBe(200);
const emails = await waitForOutboxEmailWithStatus(subject, "skipped");
expect(emails[0].skipped_reason).toBe("LIKELY_NOT_DELIVERABLE");
});
it("runs the deliverability check on a managed custom domain (undeliverable address is skipped)", async ({ expect }) => {
await Project.createAndSwitch({ display_name: "Managed Deliverability Project" });
await applyManagedEmailDomain({ subdomain: "mail.example.com", senderLocalPart: "noreply" });
const subject = "Managed Deliverability Gating Test";
const response = await sendToNotDeliverableAddress(subject);
expect(response.status).toBe(200);
const emails = await waitForOutboxEmailWithStatus(subject, "skipped");
expect(emails[0].skipped_reason).toBe("LIKELY_NOT_DELIVERABLE");
});
it("does NOT run the deliverability check on a custom SMTP server (undeliverable address is still sent)", async ({ expect }) => {
// A custom SMTP server (and likewise a custom Resend API key) is the user's own infrastructure, so we leave
// deliverability to them and never call Emailable. The email therefore proceeds to send instead of being skipped.
await Project.createAndSwitch({
display_name: "Custom SMTP Deliverability Project",
config: {
email_config: customSmtpConfig,
},
});
const subject = "Custom SMTP Deliverability Gating Test";
const response = await sendToNotDeliverableAddress(subject);
expect(response.status).toBe(200);
const emails = await waitForOutboxEmailWithStatus(subject, "sent");
expect(emails[0].status).toBe("sent");
});
});

View File

@ -542,8 +542,8 @@ describe("all users", () => {
"status": 400,
"body": {
"code": "SCHEMA_ERROR",
"details": { "message": "Exactly one of user_ids or all_users must be provided" },
"error": "Exactly one of user_ids or all_users must be provided",
"details": { "message": "Exactly one of user_ids, all_users, or emails must be provided" },
"error": "Exactly one of user_ids, all_users, or emails must be provided",
},
"headers": Headers {
"x-stack-known-error": "SCHEMA_ERROR",
@ -770,3 +770,190 @@ describe("notification categories", () => {
await backendContext.value.mailbox.waitForMessagesWithSubject("Default Category Test Subject");
});
});
describe("arbitrary email addresses", () => {
it("should send to an arbitrary email address that does not belong to a user", async ({ expect }) => {
await Project.createAndSwitch({
display_name: "Test Arbitrary Emails Project",
config: {
email_config: testEmailConfig,
},
});
const mailbox = await bumpEmailAddress();
const response = await niceBackendFetch(
"/api/v1/emails/send-email",
{
method: "POST",
accessType: "server",
body: {
emails: [mailbox.emailAddress],
html: "<p>Hello arbitrary recipient</p>",
subject: "Arbitrary Email Subject",
}
}
);
expect(response.status).toBe(200);
// The response echoes back the email addresses (no user_id, since there is no user).
expect(response.body.results).toEqual([{ email: mailbox.emailAddress }]);
const messages = await mailbox.waitForMessagesWithSubject("Arbitrary Email Subject");
expect(messages.length).toBeGreaterThanOrEqual(1);
expect(messages[0].body?.html ?? "").toContain("Hello arbitrary recipient");
});
it("should send to multiple arbitrary email addresses", async ({ expect }) => {
await Project.createAndSwitch({
display_name: "Test Multiple Arbitrary Emails Project",
config: {
email_config: testEmailConfig,
},
});
const mailbox1 = await bumpEmailAddress();
const mailbox2 = await bumpEmailAddress();
const subject = "Multiple Arbitrary Emails Subject";
const response = await niceBackendFetch(
"/api/v1/emails/send-email",
{
method: "POST",
accessType: "server",
body: {
emails: [mailbox1.emailAddress, mailbox2.emailAddress],
html: "<p>Broadcast to arbitrary recipients</p>",
subject,
}
}
);
expect(response.status).toBe(200);
expect(response.body.results).toEqual([
{ email: mailbox1.emailAddress },
{ email: mailbox2.emailAddress },
]);
await mailbox1.waitForMessagesWithSubject(subject);
await mailbox2.waitForMessagesWithSubject(subject);
});
it("should return 200 with empty results when emails is an empty array", async ({ expect }) => {
await Project.createAndSwitch({
display_name: "Test Empty Emails Project",
config: {
email_config: testEmailConfig,
},
});
const response = await niceBackendFetch(
"/api/v1/emails/send-email",
{
method: "POST",
accessType: "server",
body: {
emails: [],
html: "<p>Test email</p>",
subject: "Test Subject",
}
}
);
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": { "results": [] },
"headers": Headers { <some fields may have been hidden> },
}
`);
});
it("should return 400 when both user_ids and emails are provided", async ({ expect }) => {
await Project.createAndSwitch({
display_name: "Test Both user_ids and emails Project",
config: {
email_config: testEmailConfig,
},
});
const user = await User.create();
const response = await niceBackendFetch(
"/api/v1/emails/send-email",
{
method: "POST",
accessType: "server",
body: {
user_ids: [user.userId],
emails: ["someone@example.com"],
html: "<p>Test email</p>",
subject: "Test Subject",
}
}
);
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "SCHEMA_ERROR",
"details": { "message": "Exactly one of user_ids, all_users, or emails must be provided" },
"error": "Exactly one of user_ids, all_users, or emails must be provided",
},
"headers": Headers {
"x-stack-known-error": "SCHEMA_ERROR",
<some fields may have been hidden>,
},
}
`);
});
it("should return 400 when none of user_ids, all_users, or emails are provided", async ({ expect }) => {
await Project.createAndSwitch({
display_name: "Test No Recipient Selector Project",
config: {
email_config: testEmailConfig,
},
});
const response = await niceBackendFetch(
"/api/v1/emails/send-email",
{
method: "POST",
accessType: "server",
body: {
html: "<p>Test email</p>",
subject: "Test Subject",
}
}
);
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "SCHEMA_ERROR",
"details": { "message": "Exactly one of user_ids, all_users, or emails must be provided" },
"error": "Exactly one of user_ids, all_users, or emails must be provided",
},
"headers": Headers {
"x-stack-known-error": "SCHEMA_ERROR",
<some fields may have been hidden>,
},
}
`);
});
it("should return 400 when an invalid email address is provided", async ({ expect }) => {
await Project.createAndSwitch({
display_name: "Test Invalid Email Project",
config: {
email_config: testEmailConfig,
},
});
const response = await niceBackendFetch(
"/api/v1/emails/send-email",
{
method: "POST",
accessType: "server",
body: {
emails: ["not-an-email"],
html: "<p>Test email</p>",
subject: "Test Subject",
}
}
);
expect(response.status).toBe(400);
expect(response.body.code).toBe("SCHEMA_ERROR");
});
});

View File

@ -949,6 +949,7 @@ export class HexclaveServerInterface extends HexclaveClientInterface {
async sendEmail(options: {
userIds?: string[],
allUsers?: true,
emails?: string[],
themeId?: string | null | false,
html?: string,
subject?: string,
@ -968,6 +969,7 @@ export class HexclaveServerInterface extends HexclaveClientInterface {
body: JSON.stringify({
user_ids: options.userIds,
all_users: options.allUsers,
emails: options.emails,
theme_id: options.themeId,
html: options.html,
subject: options.subject,

View File

@ -241,7 +241,10 @@ type SendEmailOptionsBase = {
export type SendEmailOptions = SendEmailOptionsBase
& XOR<[
{ userIds: string[] },
{ allUsers: true }
{ allUsers: true },
// Send to arbitrary email addresses that don't have to belong to a user. These recipients have no associated user
// object and cannot unsubscribe, so this should only be used for transactional emails.
{ emails: string[] }
]>
& XOR<[
{ html: string },