feat(emails): allow custom emails on shared server with dev wrapper (#1673)

## 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 `<Subject>`) 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.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
BilalG1 2026-06-26 15:44:44 -07:00 committed by GitHub
parent c868ec31bc
commit 74471d8d30
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 122 additions and 48 deletions

View File

@ -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");
}

View File

@ -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;

View File

@ -98,6 +98,48 @@ export async function sendEmailFromDefaultTemplate(options: {
});
}
const DEFAULT_TEMPLATE_ID_SET: ReadonlySet<string> = 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 = `<div style="margin:0 0 16px 0;padding:12px 16px;border:1px solid #f0c000;border-radius:8px;background:#fff8e1;color:#5b4a00;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-size:13px;line-height:1.5;">`
+ `<strong>This is a development email from an app built on Hexclave.</strong><br />`
+ `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.`
+ `</div>`;
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 <body> so it stays valid markup for full HTML documents; fall back to prepending for fragments.
function injectDevNoticeIntoHtml(html: string, noticeHtml: string): string {
const bodyOpenTag = /<body[^>]*>/i;
if (bodyOpenTag.test(html)) {
return html.replace(bodyOpenTag, (match) => match + noticeHtml);
}
return noticeHtml + html;
}
export async function getEmailConfig(tenancy: Tenancy): Promise<LowLevelEmailConfig> {
const projectEmailConfig = tenancy.config.emails.server;

View File

@ -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: "<p>Test email</p>",
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",
<some fields may have been hidden>,
},
}
`);
});
it("should return 400 when invalid notification category name is provided", async ({ expect }) => {
await Project.createAndSwitch({
display_name: "Test Successful Email Project",
@ -291,6 +255,68 @@ it("should return 200 and send email successfully", async ({ expect }) => {
`);
});
describe("shared email server", () => {
it("should send custom emails over the shared server wrapped as Hexclave dev emails", async ({ expect }) => {
await Project.createAndSwitch({ display_name: "Shared Server Email Project" });
const mailbox = await bumpEmailAddress();
const user = await User.create({ primary_email: mailbox.emailAddress, primary_email_verified: true });
const response = await niceBackendFetch(
"/api/v1/emails/send-email",
{
method: "POST",
accessType: "server",
body: {
user_ids: [user.userId],
html: "<p>Original custom email body</p>",
subject: "Original Subject",
notification_category_name: "Transactional",
}
}
);
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": { "results": [{ "user_id": "<stripped UUID>" }] },
"headers": Headers { <some fields may have been hidden> },
}
`);
const messages = await mailbox.waitForMessagesWithSubject("[Hexclave dev email] Original Subject");
expect(messages.length).toBeGreaterThanOrEqual(1);
const html = messages[0].body?.html ?? "";
expect(html).toContain("set up a custom email server in your Hexclave dashboard");
expect(html).toContain("Original custom email body");
// The notice must be injected inside <body>, not before the document, to keep the markup valid.
expect(html.indexOf("set up a custom email server in your Hexclave dashboard")).toBeGreaterThan(html.indexOf("<body"));
});
it("should NOT wrap Hexclave default-template emails sent over the shared server", async ({ expect }) => {
await Project.createAndSwitch({ display_name: "Shared Default Template Project" });
const mailbox = await bumpEmailAddress();
const user = await User.create({ primary_email: mailbox.emailAddress, primary_email_verified: true });
const response = await niceBackendFetch(
"/api/v1/emails/send-email",
{
method: "POST",
accessType: "server",
body: {
user_ids: [user.userId],
template_id: DEFAULT_TEMPLATE_IDS.sign_in_invitation,
variables: { teamDisplayName: "My Team", signInInvitationLink: "https://example.com" },
}
}
);
expect(response.status).toBe(200);
const messages = await mailbox.waitForMessagesWithSubject("You have been invited to sign in to Shared Default Template Project");
expect(messages.length).toBeGreaterThanOrEqual(1);
expect(messages[0].subject).not.toContain("[Hexclave dev email]");
expect(messages[0].body?.html ?? "").not.toContain("set up a custom email server in your Hexclave dashboard");
});
});
it("should handle user that does not exist", async ({ expect }) => {
await Project.createAndSwitch({
display_name: "Test Mixed Results Project",

View File

@ -113,7 +113,7 @@ it("should send email with notification category", async ({ expect }) => {
})).resolves.not.toThrow();
});
it("should throw RequiresCustomEmailServer error when email server is not configured", async ({ expect }) => {
it("should send custom emails over the shared server (wrapped as dev emails) when no custom server is configured", async ({ expect }) => {
const { serverApp } = await createApp();
const user = await serverApp.createUser({
@ -123,9 +123,9 @@ it("should throw RequiresCustomEmailServer error when email server is not config
await expect(serverApp.sendEmail({
userIds: [user.id],
html: "<p>This should fail</p>",
html: "<p>This now sends over the shared server</p>",
subject: "Test Email",
})).rejects.toThrow(KnownErrors.RequiresCustomEmailServer);
})).resolves.not.toThrow();
});
it("should handle non-existent user IDs", async ({ expect }) => {

View File

@ -309,7 +309,7 @@ const server = createServer(async (req, res) => {
let workDir = null;
try {
const url = new URL(req.url, `http://${req.headers.host}`);
const isValidEndpoint = req.method === "POST" && (url.pathname === "/execute/v1/script" || url.pathname === "/execute/v2/script");
const isValidEndpoint = req.method === "POST" && (url.pathname === "/execute/v1/script" || url.pathname === "/execute/v2/script" || url.pathname === "/execute/v3/script");
if (!isValidEndpoint) {
res.writeHead(404);