stack/apps/backend/src/lib/emails.tsx
BilalG1 74471d8d30
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 -->
2026-06-26 15:44:44 -07:00

228 lines
11 KiB
TypeScript

import { globalPrismaClient } from '@/prisma-client';
import { runAsynchronouslyAndWaitUntil } from '@/utils/background-tasks';
import { EmailOutboxCreatedWith } from '@/generated/prisma/client';
import { DEFAULT_TEMPLATE_IDS } from '@hexclave/shared/dist/helpers/emails';
import { UsersCrud } from '@hexclave/shared/dist/interface/crud/users';
import { getEnvBoolean, getEnvVariable } from '@hexclave/shared/dist/utils/env';
import { HexclaveAssertionError } from '@hexclave/shared/dist/utils/errors';
import { Json } from '@hexclave/shared/dist/utils/json';
import { runEmailQueueStep, serializeRecipient } from './email-queue-step';
import { LowLevelEmailConfig, isSecureEmailPort } from './emails-low-level';
import { Tenancy } from './tenancies';
/**
* Describes where an email should be delivered. Each outbox entry targets exactly one recipient entity.
*
* user-primary-email: the email is being sent to the primary email address of a user (determined at the time of sending, NOT the time of creation/rendering). if the user unsubscribes, they will not receive the email.
* user-custom-emails: the email is being sent to a list of custom emails, but if the user unsubscribes, they will no longer receive the email.
* custom-emails: the email is being sent to a list of custom emails. there is no associated user object and the recipient cannot unsubscribe. cannot be used to send non-transactional emails.
*/
export type EmailOutboxRecipient =
| { type: "user-primary-email", userId: string }
| { type: "user-custom-emails", userId: string, emails: string[] }
| { type: "custom-emails", emails: string[] };
function getDefaultEmailTemplate(tenancy: Tenancy, type: keyof typeof DEFAULT_TEMPLATE_IDS) {
const templateList = new Map(Object.entries(tenancy.config.emails.templates));
const defaultTemplateIdsMap = new Map(Object.entries(DEFAULT_TEMPLATE_IDS));
const defaultTemplateId = defaultTemplateIdsMap.get(type);
if (defaultTemplateId) {
const template = templateList.get(defaultTemplateId);
if (!template) {
throw new HexclaveAssertionError(`Default email template not found: ${type}`);
}
return template;
}
throw new HexclaveAssertionError(`Unknown email template type: ${type}`);
}
export async function sendEmailToMany(options: {
tenancy: Tenancy,
recipients: EmailOutboxRecipient[],
tsxSource: string,
extraVariables: Record<string, Json>,
themeId: string | null,
isHighPriority: boolean,
shouldSkipDeliverabilityCheck: boolean,
scheduledAt: Date,
createdWith: { type: "draft", draftId: string } | { type: "programmatic-call", templateId: string | null },
overrideSubject?: string,
overrideNotificationCategoryId?: string,
}) {
await globalPrismaClient.emailOutbox.createMany({
data: options.recipients.map(recipient => ({
tenancyId: options.tenancy.id,
tsxSource: options.tsxSource,
themeId: options.themeId,
isHighPriority: options.isHighPriority,
createdWith: options.createdWith.type === "draft" ? EmailOutboxCreatedWith.DRAFT : EmailOutboxCreatedWith.PROGRAMMATIC_CALL,
emailDraftId: options.createdWith.type === "draft" ? options.createdWith.draftId : undefined,
emailProgrammaticCallTemplateId: options.createdWith.type === "programmatic-call" ? options.createdWith.templateId : undefined,
to: serializeRecipient(recipient)!,
extraRenderVariables: options.extraVariables,
scheduledAt: options.scheduledAt,
shouldSkipDeliverabilityCheck: options.shouldSkipDeliverabilityCheck,
overrideSubject: options.overrideSubject,
overrideNotificationCategoryId: options.overrideNotificationCategoryId,
})),
});
if (!getEnvBoolean("STACK_EMAIL_BRANCHING_DISABLE_QUEUE_AUTO_TRIGGER")) {
// The cron job should run runEmailQueueStep() to process the emails, but we call it here again for those self-hosters
// who didn't set up the cron job correctly, and also just in case something happens to the cron job.
runAsynchronouslyAndWaitUntil(runEmailQueueStep());
}
}
export async function sendEmailFromDefaultTemplate(options: {
tenancy: Tenancy,
user: UsersCrud["Admin"]["Read"] | null,
email: string,
templateType: keyof typeof DEFAULT_TEMPLATE_IDS,
extraVariables: Record<string, Json>,
shouldSkipDeliverabilityCheck: boolean,
}) {
const template = getDefaultEmailTemplate(options.tenancy, options.templateType);
await sendEmailToMany({
tenancy: options.tenancy,
recipients: [options.user ? { type: "user-custom-emails", userId: options.user.id, emails: [options.email] } : { type: "custom-emails", emails: [options.email] }],
tsxSource: template.tsxSource,
extraVariables: options.extraVariables,
themeId: template.themeId === false ? null : (template.themeId ?? options.tenancy.config.emails.selectedThemeId),
createdWith: { type: "programmatic-call", templateId: DEFAULT_TEMPLATE_IDS[options.templateType] },
isHighPriority: true, // always make emails sent via default template high priority
shouldSkipDeliverabilityCheck: options.shouldSkipDeliverabilityCheck,
scheduledAt: new Date(),
});
}
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;
if (projectEmailConfig.isShared) {
return await getSharedEmailConfig(tenancy.project.display_name);
} else {
if (projectEmailConfig.provider === "managed") {
if (!projectEmailConfig.password || !projectEmailConfig.managedSubdomain || !projectEmailConfig.managedSenderLocalPart) {
throw new HexclaveAssertionError("Managed email config is incomplete despite provider being managed", {
projectId: tenancy.id,
emailConfig: projectEmailConfig,
});
}
return {
host: "smtp.resend.com",
port: 465,
username: "resend",
password: projectEmailConfig.password,
senderEmail: `${projectEmailConfig.managedSenderLocalPart}@${projectEmailConfig.managedSubdomain}`,
senderName: tenancy.project.display_name,
secure: true,
type: "standard",
};
}
if (!projectEmailConfig.host || !projectEmailConfig.port || !projectEmailConfig.username || !projectEmailConfig.password || !projectEmailConfig.senderEmail || !projectEmailConfig.senderName) {
throw new HexclaveAssertionError("Email config is not complete despite not being shared. This should never happen?", { projectId: tenancy.id, emailConfig: projectEmailConfig });
}
return {
host: projectEmailConfig.host,
port: projectEmailConfig.port,
username: projectEmailConfig.username,
password: projectEmailConfig.password,
senderEmail: projectEmailConfig.senderEmail,
senderName: projectEmailConfig.senderName,
secure: isSecureEmailPort(projectEmailConfig.port),
type: 'standard',
};
}
}
export async function getSharedEmailConfig(displayName: string): Promise<LowLevelEmailConfig> {
return {
host: getEnvVariable('STACK_EMAIL_HOST'),
port: parseInt(getEnvVariable('STACK_EMAIL_PORT')),
username: getEnvVariable('STACK_EMAIL_USERNAME'),
password: getEnvVariable('STACK_EMAIL_PASSWORD'),
senderEmail: getEnvVariable('STACK_EMAIL_SENDER'),
senderName: displayName,
secure: isSecureEmailPort(getEnvVariable('STACK_EMAIL_PORT')),
type: 'shared',
};
}
export function normalizeEmail(email: string): string {
if (typeof email !== 'string') {
throw new TypeError('normalize-email expects a string');
}
const emailLower = email.trim().toLowerCase();
const emailParts = emailLower.split(/@/);
if (emailParts.length !== 2) {
throw new HexclaveAssertionError('Invalid email address', { email });
}
let [username, domain] = emailParts;
return `${username}@${domain}`;
}
import.meta.vitest?.test('normalizeEmail(...)', async ({ expect }) => {
expect(normalizeEmail('Example.Test@gmail.com')).toBe('example.test@gmail.com');
expect(normalizeEmail('Example.Test+123@gmail.com')).toBe('example.test+123@gmail.com');
expect(normalizeEmail('exampletest@gmail.com')).toBe('exampletest@gmail.com');
expect(normalizeEmail('EXAMPLETEST@gmail.com')).toBe('exampletest@gmail.com');
expect(normalizeEmail('user@example.com')).toBe('user@example.com');
expect(normalizeEmail('user.name+tag@example.com')).toBe('user.name+tag@example.com');
expect(() => normalizeEmail('test@multiple@domains.com')).toThrow();
expect(() => normalizeEmail('invalid.email')).toThrow();
});