From 74471d8d30deeedfd431d9f23ab0b420fa8d9b87 Mon Sep 17 00:00:00 2001 From: BilalG1 Date: Fri, 26 Jun 2026 15:44:44 -0700 Subject: [PATCH] feat(emails): allow custom emails on shared server with dev wrapper (#1673) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Custom emails / templates / drafts sent through Hexclave's **shared (development) email server** are no longer blocked with `RequiresCustomEmailServer`. They are now allowed, but their **subject and body are wrapped** at send time with a notice that this is a development email from Hexclave, so unexpected recipients know they can safely ignore it. The wrapper only applies to **project-defined content addressed to the project's own users**. Hexclave's own default-template emails (verification, password reset, magic link, etc.) and system notifications (credential-scanning alerts, internal feedback) are sent **verbatim**. ## How - **[send-email/route.tsx](apps/backend/src/app/api/latest/emails/send-email/route.tsx)** — removed the `RequiresCustomEmailServer` throw that blocked the shared server. - **[emails.tsx](apps/backend/src/lib/emails.tsx)** — added `wrapSharedDevEmail()` (prefixes the subject with `[Hexclave dev email]` and prepends a notice banner to HTML/text) and `isCustomEmailForSharedServer(recipient, createdWith, templateId)`. - **[email-queue-step.tsx](apps/backend/src/lib/email-queue-step.tsx)** — applies the wrapper at send time, gated on `emailConfig.type === "shared"` **and** the email being project-defined custom content. Applying it at send time reliably wraps both the subject (from `overrideSubject` or the template's ``) and the rendered HTML. ### What counts as "wrap-eligible" `isCustomEmailForSharedServer` returns true only when **all** hold: 1. the email is addressed to one of the project's own users (recipient type is not `custom-emails`), **and** 2. it is a draft, a custom template, or raw HTML — i.e. **not** one of the built-in `DEFAULT_TEMPLATE_IDS`. Condition (1) exempts Hexclave's own system senders (credential-scanning revoke, internal feedback) which send raw HTML to bare addresses via `custom-emails` and would otherwise be mis-classified as project content. This was a bug caught in review — a leaked-API-key security alert to a shared-server customer would have been prefixed `[Hexclave dev email]` with a "you can safely ignore it" banner. The recipient type is already persisted on the outbox row, so no schema change was needed. ## Tests - **send-email.test.ts** — replaced the old "400 on shared config" test with two new tests: (a) a custom email on the shared server is delivered with the `[Hexclave dev email]` subject prefix + notice banner, and (b) a **default template** (`sign_in_invitation`) on the shared server is delivered **verbatim** (no prefix, no banner) — pinning the core safety contract. - **js/email.test.ts** — flipped the "throws RequiresCustomEmailServer" test to assert the send now resolves. Verified locally against a full stack: - ✅ `send-email.test.ts` — 18/18 - ✅ `js/email.test.ts` — 12/12 - ✅ `password/send-reset-code.test.ts` — passes (default templates on shared server stay unwrapped) ## Known limitations (intentional scope) - **Template CRUD still blocked on the shared server.** `internal/email-templates` routes still throw `RequiresCustomEmailServer`, so a shared-server project can send raw HTML / a default template via the API but cannot create or edit a *saved* custom template. Sending arbitrary HTML is unaffected; only the saved-template editor remains gated. - **A project can send a (project-edited) default template unwrapped** by calling `send-email` with a `template_id` equal to a built-in `DEFAULT_TEMPLATE_IDS` value. Low impact (requires a server key, limited upside), noted for awareness. ## Note: freestyle-mock fix included [freestyle-mock/Dockerfile](docker/dependencies/freestyle-mock/Dockerfile) now also accepts `/execute/v3/script`. The `freestyle` SDK bump in #1654 moved to `/v3`, but the mock only served `/v1`+`/v2`, so **all** local email rendering 404'd (pre-existing `dev` breakage, not from this feature). The v3 request/response is identical to v2. Happy to split this into its own PR if preferred. Out of scope: `emails/email-queue.test.ts` has 2 pre-existing snapshot failures (`margin:0` vs recorded `margin:0rem`, a `@react-email/components` version drift in the mock) — those tests use a custom email server, so this PR's shared-only code path never runs for them. ## Summary by CodeRabbit * **New Features** * Email sending can now proceed when using a shared email server. * Development-style wrapping is applied to eligible shared-server custom email content, including HTML notice injection. * **Bug Fixes** * Removed the previous blocking “requires custom email server” behavior for shared-server configurations. * Default-template emails over the shared server are no longer wrapped. * **Tests** * Updated end-to-end and JS email tests to validate both wrapped custom-email behavior and unwrapped default-template behavior. --- .../api/latest/emails/send-email/route.tsx | 3 - apps/backend/src/lib/email-queue-step.tsx | 17 ++- apps/backend/src/lib/emails.tsx | 42 ++++++++ .../endpoints/api/v1/send-email.test.ts | 100 +++++++++++------- apps/e2e/tests/js/email.test.ts | 6 +- docker/dependencies/freestyle-mock/Dockerfile | 2 +- 6 files changed, 122 insertions(+), 48 deletions(-) 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 55ab4aae3..df56b365f 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 @@ -63,9 +63,6 @@ export const POST = createSmartRouteHandler({ if (!getEnvVariable("STACK_FREESTYLE_API_KEY")) { throw new StatusError(500, "STACK_FREESTYLE_API_KEY is not set"); } - if (auth.tenancy.config.emails.server.isShared) { - throw new KnownErrors.RequiresCustomEmailServer(); - } 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"); } diff --git a/apps/backend/src/lib/email-queue-step.tsx b/apps/backend/src/lib/email-queue-step.tsx index 7d26c5ead..f9584c1cc 100644 --- a/apps/backend/src/lib/email-queue-step.tsx +++ b/apps/backend/src/lib/email-queue-step.tsx @@ -1,7 +1,7 @@ import { EmailOutbox, EmailOutboxSkippedReason, Prisma } from "@/generated/prisma/client"; import { calculateCapacityRate, getEmailCapacityBoostExpiresAt, getEmailDeliveryStatsForTenancy } from "@/lib/email-delivery-stats"; import { getEmailThemeForThemeId, renderEmailsForTenancyBatched } from "@/lib/email-rendering"; -import { EmailOutboxRecipient, getEmailConfig, } from "@/lib/emails"; +import { EmailOutboxRecipient, getEmailConfig, isCustomEmailForSharedServer, wrapSharedDevEmail, } from "@/lib/emails"; import { generateUnsubscribeLink, getNotificationCategoryById, hasNotificationEnabled, listNotificationCategories } from "@/lib/notification-categories"; import { arePlanLimitsEnforced, getBillingTeamId } from "@/lib/plan-entitlements"; import { getHexclaveServerApp } from "@/hexclave"; @@ -735,15 +735,24 @@ async function processSingleEmail(context: TenancyProcessingContext, row: EmailO return; } } + const baseContent = { + subject: row.renderedSubject ?? "", + html: row.renderedHtml ?? undefined, + text: row.renderedText ?? undefined, + }; + const emailContent = context.emailConfig.type === "shared" && isCustomEmailForSharedServer(recipient, row.createdWith, row.emailProgrammaticCallTemplateId) + ? wrapSharedDevEmail(baseContent) + : baseContent; + const result = getEnvBoolean("STACK_EMAIL_BRANCHING_DISABLE_QUEUE_SENDING") ? Result.error({ errorType: "email-sending-disabled", canRetry: false, message: "Email sending is disabled", rawError: new Error("Email sending is disabled") }) : await lowLevelSendEmailDirectWithoutRetries({ tenancyId: context.tenancy.id, emailConfig: context.emailConfig, to: resolution.emails, - subject: row.renderedSubject ?? "", - html: row.renderedHtml ?? undefined, - text: row.renderedText ?? undefined, + subject: emailContent.subject, + html: emailContent.html, + text: emailContent.text, }); if (result.status === "error") { const newAttemptCount = row.sendRetries + 1; diff --git a/apps/backend/src/lib/emails.tsx b/apps/backend/src/lib/emails.tsx index d0557a6dc..86efa3920 100644 --- a/apps/backend/src/lib/emails.tsx +++ b/apps/backend/src/lib/emails.tsx @@ -98,6 +98,48 @@ export async function sendEmailFromDefaultTemplate(options: { }); } +const DEFAULT_TEMPLATE_ID_SET: ReadonlySet = new Set(Object.values(DEFAULT_TEMPLATE_IDS)); + +/** Whether an outbox email is project-defined custom content that should get the shared-server dev wrapper. */ +export function isCustomEmailForSharedServer(recipient: EmailOutboxRecipient, createdWith: EmailOutboxCreatedWith, programmaticCallTemplateId: string | null): boolean { + // Hexclave's own system notifications (credential-scanning alerts, internal feedback) send raw HTML to bare + // "custom-emails" addresses and must go out verbatim; the send-email API always targets the project's users. + if (recipient.type === "custom-emails") { + return false; + } + if (createdWith === EmailOutboxCreatedWith.DRAFT) { + return true; + } + return programmaticCallTemplateId === null || !DEFAULT_TEMPLATE_ID_SET.has(programmaticCallTemplateId); +} + +export function wrapSharedDevEmail(content: { subject: string, html?: string, text?: string }): { subject: string, html?: string, text?: string } { + const wrappedSubject = `[Hexclave dev email] ${content.subject}`; + + const noticeHtml = `
` + + `This is a development email from an app built on Hexclave.
` + + `The app hasn't configured its own email server yet, so it was sent through Hexclave's shared development email server. If this is your app, set up a custom email server in your Hexclave dashboard to send emails from your own domain. If you don't recognize this app, you can ignore this email.` + + `
`; + + const noticeText = `[Development email from an app built on Hexclave]\n` + + `The app hasn't configured its own email server yet, so it was sent through Hexclave's shared development email server. If this is your app, set up a custom email server in your Hexclave dashboard to send emails from your own domain. If you don't recognize this app, you can ignore this email.\n\n`; + + return { + subject: wrappedSubject, + html: content.html === undefined ? undefined : injectDevNoticeIntoHtml(content.html, noticeHtml), + text: content.text === undefined ? undefined : noticeText + content.text, + }; +} + +// Insert the notice just inside so it stays valid markup for full HTML documents; fall back to prepending for fragments. +function injectDevNoticeIntoHtml(html: string, noticeHtml: string): string { + const bodyOpenTag = /]*>/i; + if (bodyOpenTag.test(html)) { + return html.replace(bodyOpenTag, (match) => match + noticeHtml); + } + return noticeHtml + html; +} + export async function getEmailConfig(tenancy: Tenancy): Promise { const projectEmailConfig = tenancy.config.emails.server; 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 9c6999c6d..a681babaa 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 @@ -1,3 +1,4 @@ +import { DEFAULT_TEMPLATE_IDS } from "@hexclave/shared/dist/helpers/emails"; import { randomUUID } from "crypto"; import { describe } from "vitest"; import { it } from "../../../../helpers"; @@ -80,43 +81,6 @@ describe("invalid requests", () => { `); }); - it("should return 400 when using shared email config", async ({ expect }) => { - await Project.createAndSwitch(); - const createUserResponse = await niceBackendFetch("/api/v1/users", { - method: "POST", - accessType: "server", - body: { - primary_email: "test@example.com", - }, - }); - const response = await niceBackendFetch( - "/api/v1/emails/send-email", - { - method: "POST", - accessType: "server", - body: { - user_ids: [createUserResponse.body.id], - html: "

Test email

", - subject: "Test Subject", - notification_category_name: "Marketing", - } - } - ); - expect(response).toMatchInlineSnapshot(` - NiceResponse { - "status": 400, - "body": { - "code": "REQUIRES_CUSTOM_EMAIL_SERVER", - "error": "This action requires a custom SMTP server. Please edit your email server configuration and try again.", - }, - "headers": Headers { - "x-stack-known-error": "REQUIRES_CUSTOM_EMAIL_SERVER", -