stack/apps/e2e/tests/js/email.test.ts
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

289 lines
8.5 KiB
TypeScript

import { KnownErrors } from "@hexclave/shared";
import { DEFAULT_EMAIL_THEME_ID, DEFAULT_TEMPLATE_IDS } from "@hexclave/shared/dist/helpers/emails";
import { wait } from "@hexclave/shared/dist/utils/promises";
import { it } from "../helpers";
import { withPortPrefix } from "../helpers/ports";
import { createApp } from "./js-helpers";
async function setupEmailServer(adminApp: any) {
const project = await adminApp.getProject();
await project.updateConfig({
emails: {
server: {
isShared: false,
host: "localhost",
port: Number(withPortPrefix("29")),
username: "test",
password: "test",
senderEmail: "test@example.com",
senderName: "Test User",
},
},
});
}
it("should successfully send email with HTML content", async ({ expect }) => {
const { adminApp, serverApp } = await createApp();
await setupEmailServer(adminApp);
const user = await serverApp.createUser({
primaryEmail: "test@example.com",
primaryEmailVerified: true,
});
await expect(serverApp.sendEmail({
userIds: [user.id],
html: "<h1>Test Email</h1><p>This is a test email with HTML content.</p>",
subject: "Test Subject",
})).resolves.not.toThrow();
});
it("should successfully send email with template", async ({ expect }) => {
const { adminApp, serverApp } = await createApp();
await setupEmailServer(adminApp);
const user = await serverApp.createUser({
primaryEmail: "test@example.com",
primaryEmailVerified: true,
});
await expect(serverApp.sendEmail({
userIds: [user.id],
templateId: DEFAULT_TEMPLATE_IDS.sign_in_invitation,
variables: {
teamDisplayName: "Test Team",
signInInvitationLink: "https://example.com",
},
subject: "Welcome!",
})).resolves.not.toThrow();
});
it("should successfully send email to multiple users", async ({ expect }) => {
const { adminApp, serverApp } = await createApp();
await setupEmailServer(adminApp);
const user1 = await serverApp.createUser({
primaryEmail: "test1@example.com",
primaryEmailVerified: true,
});
const user2 = await serverApp.createUser({
primaryEmail: "test2@example.com",
primaryEmailVerified: true,
});
await expect(serverApp.sendEmail({
userIds: [user1.id, user2.id],
html: "<p>Bulk email test</p>",
subject: "Bulk Email Test",
})).resolves.not.toThrow();
});
it("should send email with theme customization", async ({ expect }) => {
const { adminApp, serverApp } = await createApp();
await setupEmailServer(adminApp);
const user = await serverApp.createUser({
primaryEmail: "test@example.com",
primaryEmailVerified: true,
});
await expect(serverApp.sendEmail({
userIds: [user.id],
html: "<p>Themed email test</p>",
subject: "Themed Email",
themeId: DEFAULT_EMAIL_THEME_ID,
})).resolves.not.toThrow();
});
it("should send email with notification category", async ({ expect }) => {
const { adminApp, serverApp } = await createApp();
await setupEmailServer(adminApp);
const user = await serverApp.createUser({
primaryEmail: "test@example.com",
primaryEmailVerified: true,
});
await expect(serverApp.sendEmail({
userIds: [user.id],
html: "<p>Notification email test</p>",
subject: "Notification Email",
notificationCategoryName: "Transactional",
})).resolves.not.toThrow();
});
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({
primaryEmail: "test@example.com",
primaryEmailVerified: true,
});
await expect(serverApp.sendEmail({
userIds: [user.id],
html: "<p>This now sends over the shared server</p>",
subject: "Test Email",
})).resolves.not.toThrow();
});
it("should handle non-existent user IDs", async ({ expect }) => {
const { adminApp, serverApp } = await createApp();
await setupEmailServer(adminApp);
// Use a properly formatted UUID that doesn't exist
await expect(serverApp.sendEmail({
userIds: ["123e4567-e89b-12d3-a456-426614174000"],
html: "<p>Non-existent user test</p>",
subject: "Test Email",
})).rejects.toThrow(KnownErrors.UserIdDoesNotExist);
});
it("should handle missing required email content", async ({ expect }) => {
const { adminApp, serverApp } = await createApp();
await setupEmailServer(adminApp);
const user = await serverApp.createUser({
primaryEmail: "test@example.com",
primaryEmailVerified: true,
});
await expect(serverApp.sendEmail({
userIds: [user.id],
subject: "Test Email",
} as any)).rejects.toThrow(KnownErrors.SchemaError);
});
it("should handle html and templateId at the same time", async ({ expect }) => {
const { adminApp, serverApp } = await createApp();
await setupEmailServer(adminApp);
const user = await serverApp.createUser({
primaryEmail: "test@example.com",
primaryEmailVerified: true,
});
await expect(serverApp.sendEmail({
userIds: [user.id],
html: "<p>Test Email</p>",
templateId: DEFAULT_TEMPLATE_IDS.sign_in_invitation,
subject: "Test Email",
} as any)).rejects.toThrow(KnownErrors.SchemaError);
});
it("should provide delivery statistics", async ({ expect }) => {
const { adminApp, serverApp } = await createApp();
await setupEmailServer(adminApp);
const user = await serverApp.createUser({
primaryEmail: "stats@example.com",
primaryEmailVerified: true,
});
// Give Bulldozer's pg_cron tick time to materialise the billing team's
// `emails_per_month` quota from its freshly-granted free plan. Without
// this wait the first email gets quota-blocked into a permanent
// server-error terminal and `stats.hour.sent` never reaches 1.
await wait(2000);
await serverApp.sendEmail({
userIds: [user.id],
html: "<p>Stats</p>",
subject: "Stats",
});
let info;
for (let i = 0; ; i++) {
info = await serverApp.getEmailDeliveryStats();
if (info.stats.hour.sent >= 1) break;
if (i >= 50) {
throw new Error(`Timed out waiting for email delivery stats to reflect sent email: ${JSON.stringify(info)}`);
}
await wait(500);
}
expect(info).toMatchInlineSnapshot(`
{
"capacity": {
"boost_expires_at": null,
"boost_multiplier": 1,
"is_boost_active": false,
"penalty_factor": 1,
"rate_per_second": 27.777779320987655,
},
"stats": {
"day": {
"bounced": 0,
"marked_as_spam": 0,
"sent": 1,
},
"hour": {
"bounced": 0,
"marked_as_spam": 0,
"sent": 1,
},
"month": {
"bounced": 0,
"marked_as_spam": 0,
"sent": 1,
},
"week": {
"bounced": 0,
"marked_as_spam": 0,
"sent": 1,
},
},
}
`);
});
it("should send test email with custom SMTP configuration", async ({ expect }) => {
const { adminApp } = await createApp();
// First configure the email server
await setupEmailServer(adminApp);
// Get the project to access the email config
const project = await adminApp.getProject();
const config = await project.getConfig();
// Verify config is not shared
expect(config.emails.server.isShared).toBe(false);
// Give Bulldozer's pg_cron tick time to materialise the billing team's
// `emails_per_month` quota from its freshly-granted free plan; otherwise
// the `tryDecreaseQuantity` inside the route rejects with
// ItemQuantityInsufficientAmount before SMTP is ever dialled.
await wait(2000);
// Send a test email
const result = await adminApp.sendTestEmail({
recipientEmail: "test-recipient@example.com",
emailConfig: {
host: config.emails.server.host!,
port: config.emails.server.port!,
username: config.emails.server.username!,
password: config.emails.server.password!,
senderEmail: config.emails.server.senderEmail!,
senderName: config.emails.server.senderName!,
}
});
expect(result.status).toBe('ok');
});
it("should fail to send test email with shared server configuration", async ({ expect }) => {
const { adminApp } = await createApp();
// Don't configure custom email server, so it defaults to shared
const project = await adminApp.getProject();
const config = await project.getConfig();
// Verify config is shared
expect(config.emails.server.isShared).toBe(true);
// Attempting to send test email with shared config should fail in the UI
// (This test documents the expected behavior in the dashboard UI)
});