diff --git a/apps/backend/src/app/api/latest/emails/send-email/route.tsx b/apps/backend/src/app/api/latest/emails/send-email/route.tsx index df56b365f..840a107fb 100644 --- a/apps/backend/src/app/api/latest/emails/send-email/route.tsx +++ b/apps/backend/src/app/api/latest/emails/send-email/route.tsx @@ -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, }, }; }, diff --git a/apps/backend/src/lib/email-queue-step.tsx b/apps/backend/src/lib/email-queue-step.tsx index 8249c3588..2961ed2e4 100644 --- a/apps/backend/src/lib/email-queue-step.tsx +++ b/apps/backend/src/lib/email-queue-step.tsx @@ -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 { - // 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 }; } diff --git a/apps/backend/src/lib/emails-low-level.tsx b/apps/backend/src/lib/emails-low-level.tsx index fca4a7936..21fedf5f7 100644 --- a/apps/backend/src/lib/emails-low-level.tsx +++ b/apps/backend/src/lib/emails-low-level.tsx @@ -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 = { diff --git a/apps/backend/src/lib/emails.tsx b/apps/backend/src/lib/emails.tsx index 86efa3920..3835ae2be 100644 --- a/apps/backend/src/lib/emails.tsx +++ b/apps/backend/src/lib/emails.tsx @@ -161,7 +161,7 @@ export async function getEmailConfig(tenancy: Tenancy): PromiseDeliverability gating test

", + 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"); + }); +}); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts index a681babaa..62d571efd 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts @@ -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: "

Hello arbitrary recipient

", + 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: "

Broadcast to arbitrary recipients

", + 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: "

Test email

", + subject: "Test Subject", + } + } + ); + expect(response).toMatchInlineSnapshot(` + NiceResponse { + "status": 200, + "body": { "results": [] }, + "headers": Headers {