stack/apps/e2e/tests/backend/endpoints/api/v1/emails/email-queue.test.ts
BilalG1 609579abab
Some checks failed
all-good: Did all the other checks pass? / all-good (push) Has been cancelled
Ensure Prisma migrations are in sync with the schema / check_prisma_migrations (22.x) (push) Has been cancelled
DB migration compat / Check if migrations changed (push) Has been cancelled
Docker Server Build and Push / Docker Build and Push Server (push) Has been cancelled
Docker Server Build and Run / docker (push) Has been cancelled
Runs E2E API Tests (Local Emulator) / E2E Tests (Local Emulator, Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (mock, 22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (prod, 22.x) (push) Has been cancelled
Runs E2E API Tests with custom port prefix / build (22.x) (push) Has been cancelled
Runs E2E Fallback Tests / E2E Fallback Tests (Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Lint & build / lint_and_build (24) (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled
DB migration compat / Back-compat — Current branch migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code (push) Has been cancelled
DB migration compat / Forward-compat — Current branch code with ${{ needs.check-migrations-changed.outputs.base_branch }} branch migrations (push) Has been cancelled
DB migration compat / No migration changes (skipped) (push) Has been cancelled
feat(hexclave): PR 3 — native @hexclave/* source rename + delete dual-publish wiring (#1482)
2026-05-29 15:21:59 -07:00

2206 lines
84 KiB
TypeScript

import { HexclaveAssertionError, throwErr } from "@hexclave/shared/dist/utils/errors";
import { wait } from "@hexclave/shared/dist/utils/promises";
import { deindent, nicify } from "@hexclave/shared/dist/utils/strings";
import beautify from "js-beautify";
import * as net from "net";
import { afterAll, beforeAll, describe } from "vitest";
import { it, logIfTestFails } from "../../../../../helpers";
import { withPortPrefix } from "../../../../../helpers/ports";
import { Auth, InternalApiKey, OutboxEmail, Project, User, backendContext, bumpEmailAddress, getOutboxEmails, niceBackendFetch, waitForOutboxEmailWithStatus } from "../../../../backend-helpers";
const testEmailConfig = {
type: "standard",
host: "localhost",
port: Number(withPortPrefix("29")),
username: "test",
password: "test",
sender_name: "Test Project",
sender_email: "test@example.com",
} as const;
// A template that is slow to render, giving us time to remove the primary email
const slowTemplate = deindent`
import { Container } from "@react-email/components";
import { Subject, NotificationCategory, Props } from "@stackframe/emails";
// Artificial delay to make the email slow to render
const startTime = performance.now();
while (performance.now() - startTime < 100) {
// Busy wait
}
export function EmailTemplate({ user, project }) {
return (
<Container>
<Subject value="Slow Render Test Email" />
<NotificationCategory value="Marketing" />
<div>Test email</div>
</Container>
);
}
`;
describe("email queue edge cases", () => {
it("should skip email when user is deleted after email is queued", async ({ expect }) => {
await Project.createAndSwitch({
display_name: "Test User Deletion Project",
config: {
email_config: testEmailConfig,
},
});
// Create a user with primary email
const mailbox = backendContext.value.mailbox;
const createUserResponse = await niceBackendFetch("/api/v1/users", {
method: "POST",
accessType: "server",
body: {
primary_email: mailbox.emailAddress,
primary_email_verified: true,
},
});
expect(createUserResponse.status).toBe(201);
const userId = createUserResponse.body.id;
const createDraftResponse = await niceBackendFetch("/api/v1/internal/email-drafts", {
method: "POST",
accessType: "admin",
body: {
display_name: "Slow Render Draft",
tsx_source: slowTemplate,
theme_id: false,
},
});
expect(createDraftResponse.status).toBe(200);
const draftId = createDraftResponse.body.id;
// Since we're essentially testing a race condition here, make sure that the DELETE endpoint is already compiled by the time we call it, so the race condition is consistent
const deleteEndpointResponse = await niceBackendFetch(`/api/v1/users/01234567-89ab-cdef-0123-456789abcdef`, {
method: "DELETE",
accessType: "server",
});
expect(deleteEndpointResponse.status).toBe(400);
// Send an email using the slow-rendering template
const sendResponse = await niceBackendFetch("/api/v1/emails/send-email", {
method: "POST",
accessType: "server",
body: {
user_ids: [userId],
draft_id: draftId,
},
});
expect(sendResponse.status).toBe(200);
// Delete the user immediately
const deleteResponse = await niceBackendFetch(`/api/v1/users/${userId}`, {
method: "DELETE",
accessType: "server",
});
expect(deleteResponse.status).toBe(200);
// Poll until outbox shows SKIPPED with USER_ACCOUNT_DELETED
const outboxEmails = await waitForOutboxEmailWithStatus("Slow Render Test Email", "skipped");
expect(outboxEmails.length).toBe(1);
expect(outboxEmails[0].skipped_reason).toBe("USER_ACCOUNT_DELETED");
// Verify no email was received (user was deleted)
const messages = await mailbox.fetchMessages();
const testEmails = messages.filter(m => m.subject === "Slow Render Test Email");
expect(testEmails).toHaveLength(0);
});
it("should skip email when user removes primary email after email is queued", async ({ expect }) => {
await Project.createAndSwitch({
display_name: "Test Remove Email Project",
config: {
email_config: testEmailConfig,
},
});
const mailbox = await bumpEmailAddress();
const { userId } = await Auth.Password.signUpWithEmail();
// Get the contact channel ID
const contactChannelsResponse = await niceBackendFetch(`/api/v1/contact-channels?user_id=${userId}`, {
method: "GET",
accessType: "server",
});
expect(contactChannelsResponse.status).toBe(200);
const contactChannelId = contactChannelsResponse.body.items[0].id;
const createDraftResponse = await niceBackendFetch("/api/v1/internal/email-drafts", {
method: "POST",
accessType: "admin",
body: {
display_name: "Slow Render Draft",
tsx_source: slowTemplate,
theme_id: false,
},
});
expect(createDraftResponse.status).toBe(200);
const draftId = createDraftResponse.body.id;
// Since we're essentially testing a race condition here, make sure that the DELETE endpoint is already compiled by the time we call it, so the race condition is consistent
const deleteEndpointResponse = await niceBackendFetch(`/api/v1/contact-channels/01234567-89ab-cdef-0123-456789abcdef/01234567-89ab-cdef-0123-456789abcdef`, {
method: "DELETE",
accessType: "server",
});
expect(deleteEndpointResponse.status).toBe(400);
// Send an email using the slow-rendering template
const sendResponse = await niceBackendFetch("/api/v1/emails/send-email", {
method: "POST",
accessType: "server",
body: {
user_ids: [userId],
draft_id: draftId,
},
});
expect(sendResponse.status).toBe(200);
// Remove the primary email while the email is still rendering
const deleteChannelResponse = await niceBackendFetch(`/api/v1/contact-channels/${userId}/${contactChannelId}`, {
method: "DELETE",
accessType: "server",
});
expect(deleteChannelResponse.status).toBe(200);
// Poll until outbox shows SKIPPED with USER_HAS_NO_PRIMARY_EMAIL
const outboxEmails = await waitForOutboxEmailWithStatus("Slow Render Test Email", "skipped");
expect(outboxEmails.length).toBe(1);
expect(outboxEmails[0].skipped_reason).toBe("USER_HAS_NO_PRIMARY_EMAIL");
// Verify no email with our subject was received (primary email was removed before sending)
const messages = await mailbox.fetchMessages();
const testEmails = messages.filter(m => m.subject === "Slow Render Test Email");
expect(testEmails).toHaveLength(0);
});
it("should skip email when user unsubscribes after email is queued", async ({ expect }) => {
await Project.createAndSwitch({
display_name: "Test Unsubscribe After Queue Project",
config: {
email_config: testEmailConfig,
},
});
const { userId } = await Auth.Password.signUpWithEmail();
const createDraftResponse = await niceBackendFetch("/api/v1/internal/email-drafts", {
method: "POST",
accessType: "admin",
body: {
display_name: "Slow Render Draft",
tsx_source: slowTemplate,
theme_id: false,
},
});
expect(createDraftResponse.status).toBe(200);
const draftId = createDraftResponse.body.id;
// Since we're essentially testing a race condition here, make sure that the PATCH endpoint is already compiled by the time we call it, so the race condition is consistent
const patchEndpointResponse = await niceBackendFetch(`/api/v1/emails/notification-preference/01234567-89ab-cdef-0123-456789abcdef/4f6f8873-3d04-46bd-8bef-18338b1a1b4c`, {
method: "PATCH",
accessType: "server",
});
expect(patchEndpointResponse.status).toBe(400);
// Send an email using the slow-rendering template
const sendResponse = await niceBackendFetch("/api/v1/emails/send-email", {
method: "POST",
accessType: "server",
body: {
user_ids: [userId],
draft_id: draftId,
},
});
expect(sendResponse.status).toBe(200);
// Unsubscribe the user from Marketing category immediately
const unsubscribeResponse = await niceBackendFetch(
`/api/v1/emails/notification-preference/${userId}/4f6f8873-3d04-46bd-8bef-18338b1a1b4c`,
{
method: "PATCH",
accessType: "server",
body: {
enabled: false,
},
}
);
expect(unsubscribeResponse.status).toBe(200);
// Poll until outbox shows SKIPPED with USER_UNSUBSCRIBED
const outboxEmails = await waitForOutboxEmailWithStatus("Slow Render Test Email", "skipped");
expect(outboxEmails.length).toBe(1);
expect(outboxEmails[0].skipped_reason).toBe("USER_UNSUBSCRIBED");
// Verify no email with our subject was received (user unsubscribed)
const messages = await backendContext.value.mailbox.fetchMessages();
const testEmails = messages.filter(m => m.subject === "Slow Render Test Email");
expect(testEmails).toHaveLength(0);
});
it("should NOT skip transactional email even when user unsubscribes from marketing", async ({ expect }) => {
await Project.createAndSwitch({
display_name: "Test Transactional Not Skipped Project",
config: {
email_config: testEmailConfig,
},
});
const { userId } = await Auth.Password.signUpWithEmail();
// Unsubscribe from Marketing first
await niceBackendFetch(
`/api/v1/emails/notification-preference/${userId}/4f6f8873-3d04-46bd-8bef-18338b1a1b4c`,
{
method: "PATCH",
accessType: "server",
body: {
enabled: false,
},
}
);
// Send a transactional email
const sendResponse = await niceBackendFetch("/api/v1/emails/send-email", {
method: "POST",
accessType: "server",
body: {
user_ids: [userId],
html: "<p>Important transactional email</p>",
subject: "Transactional Not Skipped Test",
notification_category_name: "Transactional",
},
});
expect(sendResponse.status).toBe(200);
// Verify the email was received (transactional emails can't be unsubscribed)
const messages = await backendContext.value.mailbox.waitForMessagesWithSubject("Transactional Not Skipped Test");
expect(messages.length).toBeGreaterThanOrEqual(1);
// Verify outbox shows sent (not skipped despite user being unsubscribed from marketing)
const outboxEmails = await getOutboxEmails({ subject: "Transactional Not Skipped Test" });
expect(outboxEmails.length).toBe(1);
expect(outboxEmails[0].status).toBe("sent");
expect(outboxEmails[0].is_transactional).toBe(true);
});
it("should skip email with USER_HAS_NO_PRIMARY_EMAIL reason when email is sent to user's primary email but user has no primary email", async ({ expect }) => {
await Project.createAndSwitch({
display_name: "Test No Email Provided Project",
config: {
email_config: testEmailConfig,
},
});
// Create a user without a primary email
const { userId } = await User.create();
// Send an email to the user (who has no primary email)
const sendResponse = await niceBackendFetch("/api/v1/emails/send-email", {
method: "POST",
accessType: "server",
body: {
user_ids: [userId],
html: "<p>Test email</p>",
subject: "No Email Provided Test",
notification_category_name: "Transactional",
},
});
expect(sendResponse.status).toBe(200);
// Wait for the email to be processed and reach "skipped" status
const outboxEmails = await waitForOutboxEmailWithStatus("No Email Provided Test", "skipped");
// Verify outbox shows skipped with USER_HAS_NO_PRIMARY_EMAIL
expect(outboxEmails.length).toBe(1);
expect(outboxEmails[0].skipped_reason).toBe("USER_HAS_NO_PRIMARY_EMAIL");
// Verify no email was actually sent to the mailbox
const messages = await backendContext.value.mailbox.fetchMessages();
const testEmails = messages.filter(m => m.subject === "No Email Provided Test");
expect(testEmails).toHaveLength(0);
});
it.todo("should return an error when email is sent to a custom email but no custom emails are provided", async ({ expect }) => {
await Project.createAndSwitch({
display_name: "Test No Custom Emails Project",
config: {
email_config: testEmailConfig,
},
});
const { userId } = await Auth.Password.signUpWithEmail();
const sendResponse = await niceBackendFetch("/api/v1/emails/send-email", {
method: "POST",
accessType: "server",
body: {
emails: [],
html: "<p>Test email</p>",
subject: "No Custom Emails Test",
notification_category_name: "Transactional",
},
});
expect(sendResponse).toMatchInlineSnapshot(`
todo
`);
});
});
describe("send email to all users", () => {
it("should send email to all users in the project", async ({ expect }) => {
await Project.createAndSwitch({
display_name: "Test All Users Project",
config: {
email_config: testEmailConfig,
},
});
const mailbox1 = await bumpEmailAddress();
const user1Response = await niceBackendFetch("/api/v1/users", {
method: "POST",
accessType: "server",
body: {
primary_email: mailbox1.emailAddress,
primary_email_verified: true,
},
});
expect(user1Response.status).toBe(201);
const mailbox2 = await bumpEmailAddress();
const user2Response = await niceBackendFetch("/api/v1/users", {
method: "POST",
accessType: "server",
body: {
primary_email: mailbox2.emailAddress,
primary_email_verified: true,
},
});
expect(user2Response.status).toBe(201);
// Send email to all users
const sendResponse = await niceBackendFetch("/api/v1/emails/send-email", {
method: "POST",
accessType: "server",
body: {
all_users: true,
html: "<p>All users test</p>",
subject: "All Users Test",
notification_category_name: "Transactional",
},
});
expect(sendResponse.status).toBe(200);
expect(sendResponse.body.results).toHaveLength(2);
// Verify both users received the email
const messages1 = await mailbox1.waitForMessagesWithSubject("All Users Test");
expect(messages1.length).toBeGreaterThanOrEqual(1);
const messages2 = await mailbox2.waitForMessagesWithSubject("All Users Test");
expect(messages2.length).toBeGreaterThanOrEqual(1);
// Verify outbox shows both emails as sent
const outboxResponse = await niceBackendFetch("/api/v1/emails/outbox?status=sent", {
method: "GET",
accessType: "server",
});
expect(outboxResponse.status).toBe(200);
const allUsersEmails = outboxResponse.body.items.filter((e: any) => e.subject === "All Users Test");
expect(allUsersEmails).toMatchInlineSnapshot(`
[
{
"can_have_delivery_info": false,
"created_at_millis": <stripped field 'created_at_millis'>,
"created_with": "programmatic-call",
"delivered_at_millis": <stripped field 'delivered_at_millis'>,
"email_draft_id": null,
"email_programmatic_call_template_id": null,
"has_delivered": true,
"has_rendered": true,
"html": "<!DOCTYPE html PUBLIC \\"-//W3C//DTD XHTML 1.0 Transitional//EN\\" \\"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\\"><html dir=\\"ltr\\" lang=\\"en\\"><head><meta content=\\"text/html; charset=UTF-8\\" http-equiv=\\"Content-Type\\"/><meta name=\\"x-apple-disable-message-reformatting\\"/></head><body style=\\"background-color:rgb(250,251,251);margin:0\\"><!--$--><table border=\\"0\\" width=\\"100%\\" cellPadding=\\"0\\" cellSpacing=\\"0\\" role=\\"presentation\\" align=\\"center\\"><tbody><tr><td style=\\"background-color:rgb(250,251,251);font-family:ui-sans-serif,system-ui,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot;,&quot;Segoe UI Symbol&quot;,&quot;Noto Color Emoji&quot;;font-size:1rem;line-height:1.5;margin:0rem;padding:0rem;overflow-x:hidden\\"><div style=\\"padding-bottom:2rem;padding-top:2rem;padding-right:1rem;padding-left:1rem;display:flex;justify-content:center\\"><table align=\\"center\\" width=\\"100%\\" border=\\"0\\" cellPadding=\\"0\\" cellSpacing=\\"0\\" role=\\"presentation\\" style=\\"max-width:600px;background-color:rgb(255,255,255);padding:45px;border-radius:0.5rem;box-shadow:0 0 rgb(0,0,0,0),0 0 rgb(0,0,0,0),0 0 rgb(0,0,0,0),0 0 rgb(0,0,0,0),0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)),0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));margin-right:auto;margin-left:auto;width:100%\\"><tbody><tr style=\\"width:100%\\"><td><div><p>All users test</p></div></td></tr></tbody></table></div></td></tr></tbody></table><!--7--><!--/$--></body></html>",
"id": "<stripped UUID>",
"is_high_priority": false,
"is_paused": false,
"is_transactional": true,
"next_send_retry_at_millis": null,
"notification_category_id": "<stripped UUID>",
"rendered_at_millis": <stripped field 'rendered_at_millis'>,
"scheduled_at_millis": <stripped field 'scheduled_at_millis'>,
"send_attempt_errors": null,
"send_retries": 0,
"simple_status": "ok",
"skip_deliverability_check": false,
"started_rendering_at_millis": <stripped field 'started_rendering_at_millis'>,
"started_sending_at_millis": <stripped field 'started_sending_at_millis'>,
"status": "sent",
"subject": "All Users Test",
"text": "All users test",
"theme_id": "<stripped UUID>",
"to": {
"type": "user-primary-email",
"user_id": "<stripped UUID>",
},
"tsx_source": deindent\`
export const variablesSchema = v => v;
export function EmailTemplate() {
return <>
<div dangerouslySetInnerHTML={{ __html: "<p>All users test</p>"}} />
</>
};
\`,
"updated_at_millis": <stripped field 'updated_at_millis'>,
"variables": {},
},
{
"can_have_delivery_info": false,
"created_at_millis": <stripped field 'created_at_millis'>,
"created_with": "programmatic-call",
"delivered_at_millis": <stripped field 'delivered_at_millis'>,
"email_draft_id": null,
"email_programmatic_call_template_id": null,
"has_delivered": true,
"has_rendered": true,
"html": "<!DOCTYPE html PUBLIC \\"-//W3C//DTD XHTML 1.0 Transitional//EN\\" \\"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\\"><html dir=\\"ltr\\" lang=\\"en\\"><head><meta content=\\"text/html; charset=UTF-8\\" http-equiv=\\"Content-Type\\"/><meta name=\\"x-apple-disable-message-reformatting\\"/></head><body style=\\"background-color:rgb(250,251,251);margin:0\\"><!--$--><table border=\\"0\\" width=\\"100%\\" cellPadding=\\"0\\" cellSpacing=\\"0\\" role=\\"presentation\\" align=\\"center\\"><tbody><tr><td style=\\"background-color:rgb(250,251,251);font-family:ui-sans-serif,system-ui,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot;,&quot;Segoe UI Symbol&quot;,&quot;Noto Color Emoji&quot;;font-size:1rem;line-height:1.5;margin:0rem;padding:0rem;overflow-x:hidden\\"><div style=\\"padding-bottom:2rem;padding-top:2rem;padding-right:1rem;padding-left:1rem;display:flex;justify-content:center\\"><table align=\\"center\\" width=\\"100%\\" border=\\"0\\" cellPadding=\\"0\\" cellSpacing=\\"0\\" role=\\"presentation\\" style=\\"max-width:600px;background-color:rgb(255,255,255);padding:45px;border-radius:0.5rem;box-shadow:0 0 rgb(0,0,0,0),0 0 rgb(0,0,0,0),0 0 rgb(0,0,0,0),0 0 rgb(0,0,0,0),0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)),0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));margin-right:auto;margin-left:auto;width:100%\\"><tbody><tr style=\\"width:100%\\"><td><div><p>All users test</p></div></td></tr></tbody></table></div></td></tr></tbody></table><!--7--><!--/$--></body></html>",
"id": "<stripped UUID>",
"is_high_priority": false,
"is_paused": false,
"is_transactional": true,
"next_send_retry_at_millis": null,
"notification_category_id": "<stripped UUID>",
"rendered_at_millis": <stripped field 'rendered_at_millis'>,
"scheduled_at_millis": <stripped field 'scheduled_at_millis'>,
"send_attempt_errors": null,
"send_retries": 0,
"simple_status": "ok",
"skip_deliverability_check": false,
"started_rendering_at_millis": <stripped field 'started_rendering_at_millis'>,
"started_sending_at_millis": <stripped field 'started_sending_at_millis'>,
"status": "sent",
"subject": "All Users Test",
"text": "All users test",
"theme_id": "<stripped UUID>",
"to": {
"type": "user-primary-email",
"user_id": "<stripped UUID>",
},
"tsx_source": deindent\`
export const variablesSchema = v => v;
export function EmailTemplate() {
return <>
<div dangerouslySetInnerHTML={{ __html: "<p>All users test</p>"}} />
</>
};
\`,
"updated_at_millis": <stripped field 'updated_at_millis'>,
"variables": {},
},
]
`);
});
});
describe("template rendering edge cases", () => {
it("should use subject from template when no subject override is provided", async ({ expect }) => {
await Project.createAndSwitch({
display_name: "Test Template Subject Project",
config: {
email_config: testEmailConfig,
},
});
const { userId } = await Auth.Password.signUpWithEmail();
// Create a draft with a subject in the template
const templateWithSubject = `import { Container } from "@react-email/components";
import { Subject, NotificationCategory, Props } from "@stackframe/emails";
export function EmailTemplate({ user, project }: Props) {
return (
<Container>
<Subject value="Template Subject From Export" />
<NotificationCategory value="Transactional" />
<div>Hello!</div>
</Container>
);
}`;
const createDraftResponse = await niceBackendFetch("/api/v1/internal/email-drafts", {
method: "POST",
accessType: "admin",
body: {
display_name: "Template Subject Draft",
tsx_source: templateWithSubject,
theme_id: false,
},
});
expect(createDraftResponse.status).toBe(200);
const draftId = createDraftResponse.body.id;
// Send email without subject override
const sendResponse = await niceBackendFetch("/api/v1/emails/send-email", {
method: "POST",
accessType: "server",
body: {
user_ids: [userId],
draft_id: draftId,
},
});
expect(sendResponse.status).toBe(200);
// Verify the email used the template's subject
const messages = await backendContext.value.mailbox.waitForMessagesWithSubject("Template Subject From Export");
expect(messages.length).toBeGreaterThanOrEqual(1);
// Verify outbox shows the subject from template
const outboxEmails = await getOutboxEmails({ subject: "Template Subject From Export" });
expect(outboxEmails.length).toBe(1);
expect(outboxEmails[0].subject).toBe("Template Subject From Export");
});
it("should use override subject when both template subject and override are provided", async ({ expect }) => {
await Project.createAndSwitch({
display_name: "Test Subject Override Project",
config: {
email_config: testEmailConfig,
},
});
const { userId } = await Auth.Password.signUpWithEmail();
// Create a draft with a subject in the template
const templateWithSubject = `import { Container } from "@react-email/components";
import { Subject, NotificationCategory, Props } from "@stackframe/emails";
export function EmailTemplate({ user, project }: Props) {
return (
<Container>
<Subject value="Template Subject Should Be Overridden" />
<NotificationCategory value="Transactional" />
<div>Hello!</div>
</Container>
);
}`;
const createDraftResponse = await niceBackendFetch("/api/v1/internal/email-drafts", {
method: "POST",
accessType: "admin",
body: {
display_name: "Subject Override Draft",
tsx_source: templateWithSubject,
theme_id: false,
},
});
expect(createDraftResponse.status).toBe(200);
const draftId = createDraftResponse.body.id;
// Send email with subject override
const sendResponse = await niceBackendFetch("/api/v1/emails/send-email", {
method: "POST",
accessType: "server",
body: {
user_ids: [userId],
draft_id: draftId,
subject: "Override Subject Takes Priority",
},
});
expect(sendResponse.status).toBe(200);
// Verify the email used the override subject
const messages = await backendContext.value.mailbox.waitForMessagesWithSubject("Override Subject Takes Priority");
expect(messages.length).toBeGreaterThanOrEqual(1);
// Verify outbox shows the override subject
const outboxEmails = await getOutboxEmails({ subject: "Override Subject Takes Priority" });
expect(outboxEmails.length).toBe(1);
expect(outboxEmails[0].subject).toBe("Override Subject Takes Priority");
});
it("should handle template without notification category export (defaults to no unsubscribe link)", async ({ expect }) => {
await Project.createAndSwitch({
display_name: "Test No Category Export Project",
config: {
email_config: testEmailConfig,
},
});
const { userId } = await Auth.Password.signUpWithEmail();
// Create a draft without NotificationCategory export
const templateWithoutCategory = `import { Container } from "@react-email/components";
import { Subject, Props } from "@stackframe/emails";
export function EmailTemplate({ user, project }: Props) {
return (
<Container>
<Subject value="No Category Export Test" />
<div>Hello! This template has no NotificationCategory export.</div>
</Container>
);
}`;
const createDraftResponse = await niceBackendFetch("/api/v1/internal/email-drafts", {
method: "POST",
accessType: "admin",
body: {
display_name: "No Category Draft",
tsx_source: templateWithoutCategory,
},
});
expect(createDraftResponse.status).toBe(200);
const draftId = createDraftResponse.body.id;
// Send email
const sendResponse = await niceBackendFetch("/api/v1/emails/send-email", {
method: "POST",
accessType: "server",
body: {
user_ids: [userId],
draft_id: draftId,
},
});
expect(sendResponse.status).toBe(200);
// Verify the email was sent without unsubscribe link (no category = no unsubscribe)
const messages = await backendContext.value.mailbox.waitForMessagesWithSubject("No Category Export Test");
expect(messages.length).toBeGreaterThanOrEqual(1);
// Without a category, there should be no unsubscribe link
expect(messages[0].body?.html).not.toContain("unsubscribe");
});
});
describe("user display name in emails", () => {
it("should include user display name in email template variables", async ({ expect }) => {
await Project.createAndSwitch({
display_name: "Test Display Name Project",
config: {
email_config: testEmailConfig,
},
});
const mailbox = backendContext.value.mailbox;
const createUserResponse = await niceBackendFetch("/api/v1/users", {
method: "POST",
accessType: "server",
body: {
primary_email: mailbox.emailAddress,
primary_email_verified: true,
display_name: "John Doe",
},
});
expect(createUserResponse.status).toBe(201);
const userId = createUserResponse.body.id;
// Create a draft that uses user display name
const templateWithDisplayName = `import { Container } from "@react-email/components";
import { Subject, NotificationCategory, Props } from "@stackframe/emails";
export function EmailTemplate({ user, project }: Props) {
return (
<Container>
<Subject value="Display Name Test" />
<NotificationCategory value="Transactional" />
<div>Hello {user.displayName || "there"}!</div>
</Container>
);
}`;
const createDraftResponse = await niceBackendFetch("/api/v1/internal/email-drafts", {
method: "POST",
accessType: "admin",
body: {
display_name: "Display Name Draft",
tsx_source: templateWithDisplayName,
theme_id: false,
},
});
expect(createDraftResponse.status).toBe(200);
const draftId = createDraftResponse.body.id;
// Send email
const sendResponse = await niceBackendFetch("/api/v1/emails/send-email", {
method: "POST",
accessType: "server",
body: {
user_ids: [userId],
draft_id: draftId,
},
});
expect(sendResponse.status).toBe(200);
// Verify the email contains the user's display name
const messages = await mailbox.waitForMessagesWithSubject("Display Name Test");
expect(messages.length).toBeGreaterThanOrEqual(1);
expect(messages[0].body?.text).toContain("John Doe");
});
});
describe("multiple recipients", () => {
it("should send separate emails to multiple users", async ({ expect }) => {
await Project.createAndSwitch({
display_name: "Test Multiple Recipients Project",
config: {
email_config: testEmailConfig,
},
});
const mailbox1 = await bumpEmailAddress();
const user1Response = await niceBackendFetch("/api/v1/users", {
method: "POST",
accessType: "server",
body: {
primary_email: mailbox1.emailAddress,
primary_email_verified: true,
display_name: "User One",
},
});
expect(user1Response.status).toBe(201);
const mailbox2 = await bumpEmailAddress();
const user2Response = await niceBackendFetch("/api/v1/users", {
method: "POST",
accessType: "server",
body: {
primary_email: mailbox2.emailAddress,
primary_email_verified: true,
display_name: "User Two",
},
});
expect(user2Response.status).toBe(201);
// Send email to both users
const sendResponse = await niceBackendFetch("/api/v1/emails/send-email", {
method: "POST",
accessType: "server",
body: {
user_ids: [user1Response.body.id, user2Response.body.id],
html: "<p>Multi-recipient test</p>",
subject: "Multiple Recipients Test",
notification_category_name: "Transactional",
},
});
expect(sendResponse.status).toBe(200);
expect(sendResponse.body.results).toHaveLength(2);
// Verify both users received the email
const messages1 = await mailbox1.waitForMessagesWithSubject("Multiple Recipients Test");
expect(messages1.length).toBeGreaterThanOrEqual(1);
const messages2 = await mailbox2.waitForMessagesWithSubject("Multiple Recipients Test");
expect(messages2.length).toBeGreaterThanOrEqual(1);
});
});
describe("template variables", () => {
it("should support various variable types (strings, numbers, booleans, arrays, objects)", async ({ expect }) => {
await Project.createAndSwitch({
display_name: "Test Variables Project",
config: {
email_config: testEmailConfig,
},
});
const mailbox = backendContext.value.mailbox;
const { userId } = await User.create({ primary_email: mailbox.emailAddress, primary_email_verified: true });
// Create a template that uses different variable types
// Note: We need default values and a variablesSchema for preview rendering
// Variable keys use snake_case as required by the API
const templateWithVariables = deindent`
import { type } from "arktype";
import { Container } from "@react-email/components";
import { Subject, NotificationCategory, Props } from "@stackframe/emails";
// Define the variables schema - this allows variables to be passed to the template
export const variablesSchema = type({
string_var: "string",
number_var: "number",
boolean_var: "boolean",
array_var: "string[]",
object_var: {
nested_key: {
deep_key: "string"
}
},
null_var: "null",
});
export function EmailTemplate({ user, project, variables }: Props<typeof variablesSchema.infer>) {
return (
<Container>
<Subject value="Variables Test Email" />
<NotificationCategory value="Transactional" />
<div data-testid="string">String: {variables.string_var}</div>
<div data-testid="number">Number: {variables.number_var}</div>
<div data-testid="boolean">Boolean: {variables.boolean_var ? "true" : "false"}</div>
<div data-testid="array">Array: {variables.array_var.join(", ")}</div>
<div data-testid="object">Object: {variables.object_var.nested_key.deep_key}</div>
<div data-testid="null">Null: {variables.null_var === null ? "is null" : "not null"}</div>
</Container>
);
}
// Preview variables for template editing/testing
EmailTemplate.PreviewVariables = {
string_var: "preview string",
number_var: 0,
boolean_var: false,
array_var: [],
object_var: { nested_key: { deep_key: "preview" } },
null_var: null,
} satisfies typeof variablesSchema.infer;
`;
// Create a template using the internal API
const createTemplateResponse = await niceBackendFetch("/api/v1/internal/email-templates", {
method: "POST",
accessType: "admin",
body: {
display_name: "Variables Template",
},
});
expect(createTemplateResponse.status).toBe(200);
const templateId = createTemplateResponse.body.id;
// Update the template with our custom source
const updateTemplateResponse = await niceBackendFetch(`/api/v1/internal/email-templates/${templateId}`, {
method: "PATCH",
accessType: "admin",
body: {
tsx_source: templateWithVariables,
},
});
expect(updateTemplateResponse.status).toBe(200);
// Send email with various variable types (using snake_case keys)
const sendResponse = await niceBackendFetch("/api/v1/emails/send-email", {
method: "POST",
accessType: "server",
body: {
user_ids: [userId],
template_id: templateId,
variables: {
string_var: "hello world",
number_var: 42,
boolean_var: true,
array_var: ["apple", "banana", "cherry"],
object_var: { nested_key: { deep_key: "deeply nested value" } },
null_var: null,
},
},
});
expect(sendResponse.status).toBe(200);
// Verify the email contains all variable values
const messages = await mailbox.waitForMessagesWithSubject("Variables Test Email");
expect(messages.length).toBeGreaterThanOrEqual(1);
const html = messages[0].body?.html ?? "";
expect(beautify.html(html)).toMatchInlineSnapshot(`
deindent\`
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html dir="ltr" lang="en">
<head>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
<meta name="x-apple-disable-message-reformatting" />
</head>
<body style="background-color:rgb(250,251,251);margin:0"><!--$-->
<table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center">
<tbody>
<tr>
<td style="background-color:rgb(250,251,251);font-family:ui-sans-serif,system-ui,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot;,&quot;Segoe UI Symbol&quot;,&quot;Noto Color Emoji&quot;;font-size:1rem;line-height:1.5;margin:0rem;padding:0rem;overflow-x:hidden">
<div style="padding-bottom:2rem;padding-top:2rem;padding-right:1rem;padding-left:1rem;display:flex;justify-content:center">
<table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:600px;background-color:rgb(255,255,255);padding:45px;border-radius:0.5rem;box-shadow:0 0 rgb(0,0,0,0),0 0 rgb(0,0,0,0),0 0 rgb(0,0,0,0),0 0 rgb(0,0,0,0),0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)),0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));margin-right:auto;margin-left:auto;width:100%">
<tbody>
<tr style="width:100%">
<td>
<table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em">
<tbody>
<tr style="width:100%">
<td>
<div data-testid="string">String: <!-- -->hello world</div>
<div data-testid="number">Number: <!-- -->42</div>
<div data-testid="boolean">Boolean: <!-- -->true</div>
<div data-testid="array">Array: <!-- -->apple, banana, cherry</div>
<div data-testid="object">Object: <!-- -->deeply nested value</div>
<div data-testid="null">Null: <!-- -->is null</div>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
</td>
</tr>
</tbody>
</table><!--7--><!--/$-->
</body>
</html>
\`
`);
});
it("should reject non-object variables field", async ({ expect }) => {
await Project.createAndSwitch({
display_name: "Test Non-Object Variables Project",
config: {
email_config: testEmailConfig,
},
});
const { userId } = await Auth.Password.signUpWithEmail();
// Create a simple template
const simpleTemplate = deindent`
import { Container } from "@react-email/components";
import { Subject, NotificationCategory, Props } from "@stackframe/emails";
export function EmailTemplate({ user, project }: Props) {
return (
<Container>
<Subject value="Non-Object Variables Test" />
<NotificationCategory value="Transactional" />
<div>Test</div>
</Container>
);
}
`;
// Create a template
const createTemplateResponse = await niceBackendFetch("/api/v1/internal/email-templates", {
method: "POST",
accessType: "admin",
body: {
display_name: "Non-Object Template",
},
});
expect(createTemplateResponse.status).toBe(200);
const templateId = createTemplateResponse.body.id;
// Update with our source
const updateTemplateResponse = await niceBackendFetch(`/api/v1/internal/email-templates/${templateId}`, {
method: "PATCH",
accessType: "admin",
body: {
tsx_source: simpleTemplate,
},
});
expect(updateTemplateResponse.status).toBe(200);
// Try to send email with variables as an array instead of object
const sendResponse = await niceBackendFetch("/api/v1/emails/send-email", {
method: "POST",
accessType: "server",
body: {
user_ids: [userId],
template_id: templateId,
variables: ["not", "an", "object"],
},
});
expect(sendResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "SCHEMA_ERROR",
"details": {
"message": deindent\`
Request validation failed on POST /api/v1/emails/send-email:
- body is not matched by any of the provided schemas:
Schema 0:
body.html must be defined
body contains unknown properties: template_id, variables
body contains unknown properties: template_id, variables
Schema 1:
body.variables must be a \\\`object\\\` type, but the final value was: \\\`[
"\\\\"not\\\\"",
"\\\\"an\\\\"",
"\\\\"object\\\\""
]\\\`.
Schema 2:
body.draft_id must be defined
body contains unknown properties: template_id, variables
body contains unknown properties: template_id, variables
\`,
},
"error": deindent\`
Request validation failed on POST /api/v1/emails/send-email:
- body is not matched by any of the provided schemas:
Schema 0:
body.html must be defined
body contains unknown properties: template_id, variables
body contains unknown properties: template_id, variables
Schema 1:
body.variables must be a \\\`object\\\` type, but the final value was: \\\`[
"\\\\"not\\\\"",
"\\\\"an\\\\"",
"\\\\"object\\\\""
]\\\`.
Schema 2:
body.draft_id must be defined
body contains unknown properties: template_id, variables
body contains unknown properties: template_id, variables
\`,
},
"headers": Headers {
"x-stack-known-error": "SCHEMA_ERROR",
<some fields may have been hidden>,
},
}
`);
});
it("should reject variables as a primitive value", async ({ expect }) => {
await Project.createAndSwitch({
display_name: "Test Primitive Variables Project",
config: {
email_config: testEmailConfig,
},
});
const { userId } = await Auth.Password.signUpWithEmail();
// Create a simple template
const simpleTemplate = deindent`
import { Container } from "@react-email/components";
import { Subject, NotificationCategory, Props } from "@stackframe/emails";
export function EmailTemplate({ user, project }: Props) {
return (
<Container>
<Subject value="Primitive Variables Test" />
<NotificationCategory value="Transactional" />
<div>Test</div>
</Container>
);
}
`;
// Create a template
const createTemplateResponse = await niceBackendFetch("/api/v1/internal/email-templates", {
method: "POST",
accessType: "admin",
body: {
display_name: "Primitive Template",
},
});
expect(createTemplateResponse.status).toBe(200);
const templateId = createTemplateResponse.body.id;
// Update with our source
const updateTemplateResponse = await niceBackendFetch(`/api/v1/internal/email-templates/${templateId}`, {
method: "PATCH",
accessType: "admin",
body: {
tsx_source: simpleTemplate,
},
});
expect(updateTemplateResponse.status).toBe(200);
// Try to send email with variables as a string
const sendResponse = await niceBackendFetch("/api/v1/emails/send-email", {
method: "POST",
accessType: "server",
body: {
user_ids: [userId],
template_id: templateId,
variables: "not an object",
},
});
expect(sendResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "SCHEMA_ERROR",
"details": {
"message": deindent\`
Request validation failed on POST /api/v1/emails/send-email:
- body is not matched by any of the provided schemas:
Schema 0:
body.html must be defined
body contains unknown properties: template_id, variables
body contains unknown properties: template_id, variables
Schema 1:
body.variables must be a \\\`object\\\` type, but the final value was: \\\`"not an object"\\\`.
Schema 2:
body.draft_id must be defined
body contains unknown properties: template_id, variables
body contains unknown properties: template_id, variables
\`,
},
"error": deindent\`
Request validation failed on POST /api/v1/emails/send-email:
- body is not matched by any of the provided schemas:
Schema 0:
body.html must be defined
body contains unknown properties: template_id, variables
body contains unknown properties: template_id, variables
Schema 1:
body.variables must be a \\\`object\\\` type, but the final value was: \\\`"not an object"\\\`.
Schema 2:
body.draft_id must be defined
body contains unknown properties: template_id, variables
body contains unknown properties: template_id, variables
\`,
},
"headers": Headers {
"x-stack-known-error": "SCHEMA_ERROR",
<some fields may have been hidden>,
},
}
`);
});
});
describe("project logos in email themes", () => {
it("should include project logo in rendered email when using a theme that references it", async ({ expect }) => {
// Create a project with email config
await Project.createAndSwitch({
display_name: "Test Logo Project",
config: {
email_config: testEmailConfig,
},
});
// Update project to have a logo URL (logo_url is at top level, not inside config)
const updateProjectResponse = await niceBackendFetch("/api/v1/internal/projects/current", {
method: "PATCH",
accessType: "admin",
body: {
logo_url: "https://example.com/test-logo.png",
},
});
expect(updateProjectResponse.status).toBe(200);
const mailbox = backendContext.value.mailbox;
const { userId } = await User.create({ primary_email: mailbox.emailAddress, primary_email_verified: true });
// Create a custom theme that uses ProjectLogo
const themeWithLogo = `import { Container, Img } from "@react-email/components";
import { ProjectLogo } from "@stackframe/emails";
export function EmailTheme({ children, projectLogos }) {
return (
<Container>
<div data-testid="logo-container">
<ProjectLogo data={projectLogos} mode="light" />
</div>
{children}
</Container>
);
}`;
// Create the theme (POST creates with default source)
const createThemeResponse = await niceBackendFetch("/api/v1/internal/email-themes", {
method: "POST",
accessType: "admin",
body: {
display_name: "Logo Theme",
},
});
expect(createThemeResponse.status).toBe(200);
const themeId = createThemeResponse.body.id;
// Update the theme with our custom source (PATCH updates tsx_source)
const updateThemeResponse = await niceBackendFetch(`/api/v1/internal/email-themes/${themeId}`, {
method: "PATCH",
accessType: "admin",
body: {
tsx_source: themeWithLogo,
},
});
expect(updateThemeResponse.status).toBe(200);
// Create a draft that uses the theme
const templateSource = `import { Container } from "@react-email/components";
import { Subject, NotificationCategory } from "@stackframe/emails";
export function EmailTemplate({ user, project }) {
return (
<Container>
<Subject value="Logo Test Email" />
<NotificationCategory value="Transactional" />
<div>Hello! This email should have a logo.</div>
</Container>
);
}`;
const createDraftResponse = await niceBackendFetch("/api/v1/internal/email-drafts", {
method: "POST",
accessType: "admin",
body: {
display_name: "Logo Test Draft",
tsx_source: templateSource,
theme_id: themeId,
},
});
expect(createDraftResponse.status).toBe(200);
const draftId = createDraftResponse.body.id;
// Send email
const sendResponse = await niceBackendFetch("/api/v1/emails/send-email", {
method: "POST",
accessType: "server",
body: {
user_ids: [userId],
draft_id: draftId,
},
});
expect(sendResponse.status).toBe(200);
// Verify the email contains the logo URL
const messages = await mailbox.waitForMessagesWithSubject("Logo Test Email");
expect(messages).toMatchInlineSnapshot(`
[
MailboxMessage {
"attachments": [],
"body": {
"html": "<!DOCTYPE html PUBLIC \\"-//W3C//DTD XHTML 1.0 Transitional//EN\\" \\"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\\"><link rel=\\"preload\\" as=\\"image\\" href=\\"https://example.com/test-logo.png\\"/><!--$--><table align=\\"center\\" width=\\"100%\\" border=\\"0\\" cellPadding=\\"0\\" cellSpacing=\\"0\\" role=\\"presentation\\" style=\\"max-width:37.5em\\"><tbody><tr style=\\"width:100%\\"><td><div data-testid=\\"logo-container\\"><div class=\\"flex gap-2 items-center\\"><img class=\\"h-8\\" alt=\\"Logo\\" src=\\"https://example.com/test-logo.png\\" style=\\"display:block;outline:none;border:none;text-decoration:none\\"/></div></div><table align=\\"center\\" width=\\"100%\\" border=\\"0\\" cellPadding=\\"0\\" cellSpacing=\\"0\\" role=\\"presentation\\" style=\\"max-width:37.5em\\"><tbody><tr style=\\"width:100%\\"><td><div>Hello! This email should have a logo.</div></td></tr></tbody></table></td></tr></tbody></table><!--/$-->",
"text": "Hello! This email should have a logo.",
},
"from": "Test Project <test@example.com>",
"subject": "Logo Test Email",
"to": ["<default-mailbox--<stripped UUID>@stack-generated.example.com>"],
<some fields may have been hidden>,
},
]
`);
expect(messages.length).toBeGreaterThanOrEqual(1);
// The logo URL should appear in the rendered HTML
expect(messages[0].body?.html).toContain("https://example.com/test-logo.png");
});
});
describe("theme and template deletion after scheduling", () => {
// A custom theme that wraps content in a distinctive div
const customThemeSource = deindent`
import { Container } from "@react-email/components";
export function EmailTheme({ children }) {
return (
<Container>
<div data-testid="custom-theme-wrapper" style={{ border: "5px solid #ff0000" }}>
{children}
</div>
</Container>
);
}
`;
// A simple template for testing
const simpleTemplateForTest = deindent`
import { Container } from "@react-email/components";
import { Subject, NotificationCategory, Props } from "@stackframe/emails";
export function EmailTemplate({ user, project }) {
return (
<Container>
<Subject value="Theme Fallback Test Email" />
<NotificationCategory value="Transactional" />
<div data-testid="template-content">Email content for theme test</div>
</Container>
);
}
`;
it("should render email with fallback active theme when the scheduled email's theme_id references a non-existent theme (simulating deletion)", async ({ expect }) => {
// This test verifies that when a theme is deleted after an email is scheduled
// but before it is rendered, the email is rendered using the project's active theme
// instead of failing.
//
// We simulate theme deletion by:
// 1. Scheduling an email with is_paused=true so it doesn't render immediately
// 2. Updating the outbox entry to set theme_id to a non-existent UUID
// 3. Unpausing the email and waiting for it to render and send
// 4. Verifying that the email was sent with the default theme (not the non-existent one)
await Project.createAndSwitch({
display_name: "Test Theme Deletion Project",
config: {
email_config: testEmailConfig,
},
});
const mailbox = backendContext.value.mailbox;
const { userId } = await User.create({ primary_email: mailbox.emailAddress, primary_email_verified: true });
// Create a draft with the simple template (no theme - uses default)
const createDraftResponse = await niceBackendFetch("/api/v1/internal/email-drafts", {
method: "POST",
accessType: "admin",
body: {
display_name: "Theme Deletion Test Draft",
tsx_source: simpleTemplateForTest,
theme_id: false, // Start with no theme
},
});
expect(createDraftResponse.status).toBe(200);
const draftId = createDraftResponse.body.id;
// Send email with the draft, but paused so we can modify the outbox entry
const sendResponse = await niceBackendFetch("/api/v1/emails/send-email", {
method: "POST",
accessType: "server",
body: {
user_ids: [userId],
draft_id: draftId,
},
});
expect(sendResponse.status).toBe(200);
// Get the outbox entry
let outboxEmails = await getOutboxEmails({ subject: "Theme Fallback Test Email" });
expect(outboxEmails.length).toBe(0);
// Note: The email might have already been processed by the time we check.
// If it's already sent, that's also fine - we just document the expected behavior.
// For a proper test, we'd need to pause the email, but the send-email endpoint
// doesn't support is_paused directly.
// Poll until email is received (waitForMessagesWithSubject already does polling)
const messages = await mailbox.waitForMessagesWithSubject("Theme Fallback Test Email");
expect(messages.length).toBeGreaterThanOrEqual(1);
// Verify outbox shows sent
outboxEmails = await getOutboxEmails({ subject: "Theme Fallback Test Email" });
expect(outboxEmails.length).toBe(1);
expect(outboxEmails[0].status).toBe("sent");
// The email should have been rendered with the default theme
// This documents the expected behavior - even if theme_id points to a deleted theme,
// the email will be rendered with the project's active theme
});
it("should use project's active theme when outbox entry has a non-existent theme_id (via PATCH)", async ({ expect }) => {
// This test explicitly verifies the fallback behavior when a theme ID
// in the outbox doesn't exist in the project's theme list (simulating deletion).
//
// Test approach:
// 1. Create a custom theme and send an email using it (paused)
// 2. Update the outbox entry to reference a non-existent theme UUID
// 3. Unpause and verify the email is sent with the default theme (fallback)
await Project.createAndSwitch({
display_name: "Test Non-existent Theme ID Project",
config: {
email_config: testEmailConfig,
},
});
const mailbox = backendContext.value.mailbox;
const { userId } = await User.create({ primary_email: mailbox.emailAddress, primary_email_verified: true });
// Create a draft that we'll use for testing
const createDraftResponse = await niceBackendFetch("/api/v1/internal/email-drafts", {
method: "POST",
accessType: "admin",
body: {
display_name: "Non-existent Theme Test Draft",
tsx_source: simpleTemplateForTest,
theme_id: false, // Start with no theme
},
});
expect(createDraftResponse.status).toBe(200);
const draftId = createDraftResponse.body.id;
// Send email using the draft
const sendResponse = await niceBackendFetch("/api/v1/emails/send-email", {
method: "POST",
accessType: "server",
body: {
user_ids: [userId],
draft_id: draftId,
},
});
expect(sendResponse.status).toBe(200);
// Get the outbox entry before it's processed
// We'll try to update the theme_id to a non-existent UUID
const outboxEmailsBefore = await getOutboxEmails({ subject: "Theme Fallback Test Email" });
if (outboxEmailsBefore.length > 0 && outboxEmailsBefore[0].status !== "sent") {
const outboxId = outboxEmailsBefore[0].id;
// Try to update the theme_id to a non-existent UUID
// This simulates what would happen if a theme was deleted after scheduling
const fakeThemeId = "00000000-0000-0000-0000-000000000001";
const updateResponse = await niceBackendFetch(`/api/v1/emails/outbox/${outboxId}`, {
method: "PATCH",
accessType: "server",
body: {
theme_id: fakeThemeId,
},
});
// The update might fail if the email was already sent/rendered
// That's okay - we're documenting the behavior
if (updateResponse.status === 200) {
// Theme ID was updated successfully
expect(updateResponse.body.theme_id).toBe(fakeThemeId);
}
}
// Poll until email is received (waitForMessagesWithSubject already does polling)
const messages = await mailbox.waitForMessagesWithSubject("Theme Fallback Test Email");
expect(messages.length).toBeGreaterThanOrEqual(1);
// Verify outbox shows sent
const outboxEmails = await getOutboxEmails({ subject: "Theme Fallback Test Email" });
expect(outboxEmails.length).toBe(1);
expect(outboxEmails[0].status).toBe("sent");
// The email should have been sent even though the theme_id was set to a non-existent value.
// The getEmailThemeForThemeId function falls back to the project's active theme
// when the theme_id is not found in the theme list.
});
it("should still send email when template is deleted after scheduling (source stored in outbox)", async ({ expect }) => {
// This test verifies that deleting a template after an email is scheduled
// does NOT affect the email delivery, because the template source code
// is stored directly in the outbox when the email is scheduled.
//
// This documents the architectural decision: templates are copied to the outbox
// at scheduling time, so template deletion is safe.
await Project.createAndSwitch({
display_name: "Test Template Deletion Project",
config: {
email_config: testEmailConfig,
},
});
const mailbox = backendContext.value.mailbox;
const { userId } = await User.create({ primary_email: mailbox.emailAddress, primary_email_verified: true });
// Create a template
const createTemplateResponse = await niceBackendFetch("/api/v1/internal/email-templates", {
method: "POST",
accessType: "admin",
body: {
display_name: "Template To Delete",
},
});
expect(createTemplateResponse.status).toBe(200);
const templateId = createTemplateResponse.body.id;
// Update the template with our test source
const templateSource = deindent`
import { Container } from "@react-email/components";
import { Subject, NotificationCategory, Props } from "@stackframe/emails";
export function EmailTemplate({ user, project }) {
return (
<Container>
<Subject value="Template Deletion Test Email" />
<NotificationCategory value="Transactional" />
<div>Content from template that will be deleted</div>
</Container>
);
}
`;
const updateTemplateResponse = await niceBackendFetch(`/api/v1/internal/email-templates/${templateId}`, {
method: "PATCH",
accessType: "admin",
body: {
tsx_source: templateSource,
},
});
expect(updateTemplateResponse.status).toBe(200);
// Send email using the template
const sendResponse = await niceBackendFetch("/api/v1/emails/send-email", {
method: "POST",
accessType: "server",
body: {
user_ids: [userId],
template_id: templateId,
},
});
expect(sendResponse.status).toBe(200);
// At this point, the email is scheduled and the template source is stored in the outbox.
// Even if we delete the template now, the email should still be sent because
// the outbox stores the tsxSource directly, not a reference to the template.
// Note: There's no DELETE endpoint for templates, but this test documents
// that the architecture is designed to handle template deletion safely
// because the source is copied to the outbox at scheduling time.
// Poll until email is received (waitForMessagesWithSubject already does polling)
const messages = await mailbox.waitForMessagesWithSubject("Template Deletion Test Email");
expect(messages.length).toBeGreaterThanOrEqual(1);
expect(messages[0].body?.html).toContain("Content from template that will be deleted");
// Verify outbox shows sent and contains the template source
const outboxEmails = await getOutboxEmails({ subject: "Template Deletion Test Email" });
expect(outboxEmails.length).toBe(1);
expect(outboxEmails[0].status).toBe("sent");
// The outbox stores the template source directly, not a reference to the template
expect(outboxEmails[0].tsx_source).toContain("Content from template that will be deleted");
});
it("should render email correctly with custom theme when theme exists", async ({ expect }) => {
// This is a baseline test to verify that custom themes work correctly.
// It provides a comparison point for the fallback behavior tests.
await Project.createAndSwitch({
display_name: "Test Custom Theme Project",
config: {
email_config: testEmailConfig,
},
});
const mailbox = backendContext.value.mailbox;
const { userId } = await User.create({ primary_email: mailbox.emailAddress, primary_email_verified: true });
// Create a custom theme
const createThemeResponse = await niceBackendFetch("/api/v1/internal/email-themes", {
method: "POST",
accessType: "admin",
body: {
display_name: "Custom Theme For Baseline Test",
},
});
expect(createThemeResponse.status).toBe(200);
const customThemeId = createThemeResponse.body.id;
// Update the custom theme to have distinctive styling
const updateThemeResponse = await niceBackendFetch(`/api/v1/internal/email-themes/${customThemeId}`, {
method: "PATCH",
accessType: "admin",
body: {
tsx_source: customThemeSource,
},
});
expect(updateThemeResponse.status).toBe(200);
// Create a draft that uses the custom theme
const customThemeTemplateSource = deindent`
import { Container } from "@react-email/components";
import { Subject, NotificationCategory, Props } from "@stackframe/emails";
export function EmailTemplate({ user, project }) {
return (
<Container>
<Subject value="Custom Theme Baseline Test Email" />
<NotificationCategory value="Transactional" />
<div data-testid="template-content">Content with custom theme</div>
</Container>
);
}
`;
const createDraftResponse = await niceBackendFetch("/api/v1/internal/email-drafts", {
method: "POST",
accessType: "admin",
body: {
display_name: "Custom Theme Baseline Draft",
tsx_source: customThemeTemplateSource,
theme_id: customThemeId,
},
});
expect(createDraftResponse.status).toBe(200);
const draftId = createDraftResponse.body.id;
// Send email using the custom theme (via the draft)
const sendResponse = await niceBackendFetch("/api/v1/emails/send-email", {
method: "POST",
accessType: "server",
body: {
user_ids: [userId],
draft_id: draftId,
},
});
expect(sendResponse.status).toBe(200);
// Poll until email is received (waitForMessagesWithSubject already does polling)
const messages = await mailbox.waitForMessagesWithSubject("Custom Theme Baseline Test Email");
expect(messages.length).toBeGreaterThanOrEqual(1);
// The email should contain the custom theme wrapper
expect(messages[0].body?.html).toContain("custom-theme-wrapper");
// Verify outbox shows sent
const outboxEmails = await getOutboxEmails({ subject: "Custom Theme Baseline Test Email" });
expect(outboxEmails.length).toBe(1);
expect(outboxEmails[0].status).toBe("sent");
});
});
describe("email outbox pagination", () => {
it("should paginate email outbox results with cursor", async ({ expect }) => {
await Project.createAndSwitch({
display_name: "Test Pagination Project",
config: {
email_config: testEmailConfig,
},
});
// Create a draft for sending emails
const templateSource = deindent`
import { Container } from "@react-email/components";
import { Subject, NotificationCategory, Props } from "@stackframe/emails";
export function EmailTemplate({ user, project }) {
return (
<Container>
<Subject value="Pagination Test Email" />
<NotificationCategory value="Transactional" />
<div>Test</div>
</Container>
);
}
`;
const createDraftResponse = await niceBackendFetch("/api/v1/internal/email-drafts", {
method: "POST",
accessType: "admin",
body: {
display_name: "Pagination Draft",
tsx_source: templateSource,
theme_id: false,
},
});
expect(createDraftResponse.status).toBe(200);
const draftId = createDraftResponse.body.id;
// Create 5 users
const mailboxes = await Promise.all(Array.from({ length: 5 }, async () => await bumpEmailAddress()));
const userIds: string[] = [];
for (let i = 0; i < 5; i++) {
const email = mailboxes[i].emailAddress;
const createUserResponse = await niceBackendFetch("/api/v1/users", {
method: "POST",
accessType: "server",
body: {
primary_email: email,
primary_email_verified: true,
},
});
expect(createUserResponse.status).toBe(201);
userIds.push(createUserResponse.body.id);
}
// Send emails to all users
const sendResponse = await niceBackendFetch("/api/v1/emails/send-email", {
method: "POST",
accessType: "server",
body: {
user_ids: userIds,
draft_id: draftId,
},
});
expect(sendResponse.status).toBe(200);
// Wait until all emails are sent
for (const mailbox of mailboxes) {
await mailbox.waitForMessagesWithSubject("Pagination Test Email");
}
// Ensure there are 5 emails in the outbox
const allResponse = await niceBackendFetch("/api/v1/emails/outbox", {
method: "GET",
accessType: "server",
});
logIfTestFails("allResponse", nicify(allResponse));
expect(allResponse.status).toBe(200);
expect(allResponse.body.items.length).toBe(5);
// Test pagination with limit=2
const page1Response = await niceBackendFetch("/api/v1/emails/outbox?limit=2", {
method: "GET",
accessType: "server",
});
logIfTestFails("page1Response", nicify(page1Response));
expect(page1Response.status).toBe(200);
expect(page1Response.body.items.length).toBe(2);
expect(page1Response.body.is_paginated).toBe(true);
expect(page1Response.body.pagination.next_cursor).not.toBeNull();
// Get next page using cursor
const cursor = page1Response.body.pagination.next_cursor;
const page2Response = await niceBackendFetch(`/api/v1/emails/outbox?limit=2&cursor=${cursor}`, {
method: "GET",
accessType: "server",
});
logIfTestFails("page2Response", nicify(page2Response));
expect(page2Response.status).toBe(200);
expect(page2Response.body.items.length).toBe(2);
// Verify items on page 2 are different from page 1
const page1Ids = new Set(page1Response.body.items.map((e: { id: string }) => e.id));
const page2Ids = page2Response.body.items.map((e: { id: string }) => e.id);
for (const id of page2Ids) {
expect(page1Ids.has(id)).toBe(false);
}
// Get page 3
const cursor2 = page2Response.body.pagination.next_cursor;
const page3Response = await niceBackendFetch(`/api/v1/emails/outbox?limit=2&cursor=${cursor2}`, {
method: "GET",
accessType: "server",
});
logIfTestFails("page3Response", nicify(page3Response));
expect(page3Response.status).toBe(200);
expect(page3Response.body.items.length).toBe(1); // Only 1 remaining
expect(page3Response.body.pagination.next_cursor).toBeNull(); // No more pages
});
it("should reject limit greater than 100", async ({ expect }) => {
await Project.createAndSwitch({
display_name: "Test Limit Project",
config: {
email_config: testEmailConfig,
},
});
const response = await niceBackendFetch("/api/v1/emails/outbox?limit=101", {
method: "GET",
accessType: "server",
});
expect(response.status).toBe(400);
});
it("should order emails by createdAt descending (newest first)", async ({ expect }) => {
await Project.createAndSwitch({
display_name: "Test Ordering Project",
config: {
email_config: testEmailConfig,
},
});
const templateSource = deindent`
import { Container } from "@react-email/components";
import { Subject, NotificationCategory } from "@stackframe/emails";
export function EmailTemplate({ user, project }) {
return (
<Container>
<Subject value="Ordering Test Email" />
<NotificationCategory value="Transactional" />
<div>Test</div>
</Container>
);
}
`;
const createDraftResponse = await niceBackendFetch("/api/v1/internal/email-drafts", {
method: "POST",
accessType: "admin",
body: {
display_name: "Ordering Draft",
tsx_source: templateSource,
theme_id: false,
},
});
expect(createDraftResponse.status).toBe(200);
const draftId = createDraftResponse.body.id;
// Create user
const mailbox = backendContext.value.mailbox;
const createUserResponse = await niceBackendFetch("/api/v1/users", {
method: "POST",
accessType: "server",
body: {
primary_email: mailbox.emailAddress,
primary_email_verified: true,
},
});
expect(createUserResponse.status).toBe(201);
const userId = createUserResponse.body.id;
// Send 3 emails sequentially (need distinct timestamps for ordering test)
for (let i = 0; i < 3; i++) {
const sendResponse = await niceBackendFetch("/api/v1/emails/send-email", {
method: "POST",
accessType: "server",
body: {
user_ids: [userId],
draft_id: draftId,
},
});
expect(sendResponse.status).toBe(200);
}
// Poll until all 3 emails appear in outbox (wait up to 12s, matching waitForOutboxEmailWithStatus)
const maxAttempts = 24;
const pollInterval = 500;
let emails: Array<{ subject?: string, created_at_millis: number }> = [];
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const listResponse = await niceBackendFetch("/api/v1/emails/outbox", {
method: "GET",
accessType: "server",
});
expect(listResponse.status).toBe(200);
emails = listResponse.body.items.filter((e: { subject?: string }) =>
e.subject === "Ordering Test Email"
);
if (emails.length >= 3) break;
await wait(pollInterval);
}
// Verify we have our emails
expect(emails.length).toBeGreaterThanOrEqual(3);
// Check ordering: emails should be ordered by createdAt descending (newest first)
for (let i = 0; i < emails.length - 1; i++) {
const current = emails[i];
const next = emails[i + 1];
expect(current.created_at_millis).toBeGreaterThanOrEqual(next.created_at_millis);
}
}, 60_000);
});
// Invalid SMTP config - causes HOST_NOT_FOUND (non-retryable error)
const brokenSmtpConfig = {
type: "standard",
host: "this-host-does-not-exist.invalid",
port: 25,
username: "test",
password: "test",
sender_name: "Test Project",
sender_email: "test@example.com",
} as const;
// SMTP server that responds with 450 (temporary failure) - retryable and fast
let tempFailServer: net.Server | null = null;
let tempFailPort: number | null = null;
async function startTempFailSmtpServer(): Promise<number> {
if (tempFailServer) {
return tempFailPort ?? throwErr("tempFailServer not initialized");
}
return await new Promise((resolve, reject) => {
tempFailServer = net.createServer((socket) => {
// Send SMTP greeting
socket.write('220 localhost SMTP Test Server\r\n');
socket.on('data', (data) => {
const command = data.toString().trim().toUpperCase();
// Respond with 450 (temporary failure) to all commands
// This is a retryable error that happens immediately
if (command.startsWith('EHLO') || command.startsWith('HELO')) {
socket.write('250 localhost Hello\r\n');
} else if (command.startsWith('MAIL FROM')) {
// Temporary failure - "mailbox unavailable, try again later"
socket.write('450 Requested mail action not taken: mailbox unavailable (test)\r\n');
socket.end();
} else if (command.startsWith('QUIT')) {
socket.write('221 Bye\r\n');
socket.end();
} else {
socket.write('450 Temporary failure (test)\r\n');
}
});
socket.on('error', () => {
// Ignore errors (client may disconnect)
});
});
tempFailServer.listen(0, '127.0.0.1', () => {
const address = (tempFailServer ?? throwErr("tempFailServer unexpectedly null in listen callback")).address();
if (typeof address === 'object' && address !== null) {
tempFailPort = address.port;
resolve(tempFailPort);
} else {
reject(new Error('Failed to get server address'));
}
});
tempFailServer.on('error', reject);
});
}
function stopTempFailSmtpServer(): void {
if (tempFailServer) {
tempFailServer.close();
tempFailServer = null;
tempFailPort = null;
}
}
// Factory function to create temp-fail SMTP config with dynamic port
function createTempFailSmtpConfig(port: number) {
return {
type: "standard",
host: "127.0.0.1",
port: port,
username: "test",
password: "test",
sender_name: "Test Project",
sender_email: "test@example.com",
} as const;
}
// Helper type for send attempt error entries
type SendAttemptErrorEntry = {
attempt_number: number,
timestamp: string,
external_message: string,
external_details: Record<string, unknown>,
internal_message: string,
internal_details: Record<string, unknown>,
};
// Helper type for email outbox items with retry fields
type OutboxEmailWithRetryFields = OutboxEmail & {
send_retries: number,
next_send_retry_at_millis: number | null,
send_attempt_errors: SendAttemptErrorEntry[] | null,
};
// Helper to get detailed email from the outbox
async function getOutboxEmailById(emailId: string): Promise<OutboxEmailWithRetryFields> {
const response = await niceBackendFetch(`/api/v1/emails/outbox/${emailId}`, {
method: "GET",
accessType: "server",
});
if (response.status !== 200) {
throw new HexclaveAssertionError(`Failed to get email ${emailId}: status ${response.status}`, { response });
}
return response.body;
}
// Helper to poll until an email with the given subject appears in the outbox
async function waitForOutboxEmail(subject: string, timeoutMs = 30000): Promise<OutboxEmailWithRetryFields> {
const startTime = performance.now();
while (performance.now() - startTime < timeoutMs) {
const emails = await getOutboxEmails({ subject });
if (emails.length > 0) {
return await getOutboxEmailById(emails[0].id);
}
await wait(500);
}
throw new HexclaveAssertionError(
`Timeout waiting for email with subject "${subject}" to appear in outbox`,
{ subject }
);
}
// Helper to poll until the email has reached a specific send_retries
// Note: Status may be "queued" or "sending" due to race conditions - that's expected
async function waitForAttemptCount(emailId: string, attemptCount: number, timeoutMs = 60000): Promise<OutboxEmailWithRetryFields> {
const startTime = performance.now();
while (performance.now() - startTime < timeoutMs) {
const email = await getOutboxEmailById(emailId);
if (email.send_retries >= attemptCount) {
return email;
}
// Terminal state - no more retries will happen
if (email.status === "server-error") {
return email;
}
await wait(500);
}
const finalEmail = await getOutboxEmailById(emailId);
throw new HexclaveAssertionError(
`Timeout waiting for email ${emailId} to reach send_retries >= ${attemptCount}`,
{ emailId, attemptCount, finalState: { count: finalEmail.send_retries, status: finalEmail.status } }
);
}
describe("email queue deferred retry logic", () => {
it("should immediately mark non-retryable errors as server-error without retrying", async ({ expect }) => {
// brokenSmtpConfig causes HOST_NOT_FOUND which has canRetry: false
await Project.createAndSwitch({
display_name: "Test Non-Retryable Error Project",
config: {
email_config: brokenSmtpConfig,
},
});
const mailbox = backendContext.value.mailbox;
const createUserResponse = await niceBackendFetch("/api/v1/users", {
method: "POST",
accessType: "server",
body: {
primary_email: mailbox.emailAddress,
primary_email_verified: true,
},
});
expect(createUserResponse.status).toBe(201);
const userId = createUserResponse.body.id;
const sendResponse = await niceBackendFetch("/api/v1/emails/send-email", {
method: "POST",
accessType: "server",
body: {
user_ids: [userId],
html: "Test email for non-retryable error",
subject: "Non-Retryable Error Test",
},
});
expect(sendResponse.status).toBe(200);
// Wait for the email to appear in the outbox and reach server-error state
const initialEmail = await waitForOutboxEmail("Non-Retryable Error Test");
const emailId = initialEmail.id;
// Wait for the email to reach server-error status
const maxWaitMs = 30000;
const startTime = performance.now();
let email = initialEmail;
while (performance.now() - startTime < maxWaitMs && email.status !== "server-error") {
await wait(500);
email = await getOutboxEmailById(emailId);
}
// Non-retryable errors should go directly to server-error
expect(email.send_retries).toBe(1);
expect(email.next_send_retry_at_millis).toBeNull();
expect(email.send_attempt_errors).not.toBeNull();
expect(email.send_attempt_errors?.length).toBe(1);
expect(email.status).toBe("server-error");
logIfTestFails("Email after non-retryable error", email);
});
describe("retryable errors (using temp-fail SMTP server)", () => {
// These tests use a local SMTP server that responds with 450 (temporary failure).
// This is fast (immediate response) and produces retryable errors.
let tempFailSmtpConfig: ReturnType<typeof createTempFailSmtpConfig>;
beforeAll(async () => {
const port = await startTempFailSmtpServer();
tempFailSmtpConfig = createTempFailSmtpConfig(port);
});
afterAll(() => {
stopTempFailSmtpServer();
});
it("should schedule retry on retryable failure and release email for next iteration", { timeout: 60000 }, async ({ expect }) => {
await Project.createAndSwitch({
display_name: "Test Deferred Retry Project",
config: {
email_config: tempFailSmtpConfig,
},
});
// Create API keys that don't expire (JWT admin tokens expire in 60s which is too short for retry tests)
await InternalApiKey.createAndSetProjectKeys();
const mailbox = backendContext.value.mailbox;
const createUserResponse = await niceBackendFetch("/api/v1/users", {
method: "POST",
accessType: "server",
body: {
primary_email: mailbox.emailAddress,
primary_email_verified: true,
},
});
expect(createUserResponse.status).toBe(201);
const userId = createUserResponse.body.id;
const sendResponse = await niceBackendFetch("/api/v1/emails/send-email", {
method: "POST",
accessType: "server",
body: {
user_ids: [userId],
html: "Test email for retry logic",
subject: "Retry Test Email",
},
});
expect(sendResponse.status).toBe(200);
// Wait for the email to appear in the outbox
const initialEmail = await waitForOutboxEmail("Retry Test Email");
const emailId = initialEmail.id;
// Wait for first send attempt to complete (450 response is immediate)
const emailAfterFirstAttempt = await waitForAttemptCount(emailId, 1, 30000);
// Verify the email was released for a DIFFERENT queue iteration to pick up
// - status should NOT be server-error (retries remaining)
expect(emailAfterFirstAttempt.send_retries).toBe(1);
expect(emailAfterFirstAttempt.send_attempt_errors).not.toBeNull();
expect(emailAfterFirstAttempt.send_attempt_errors?.length).toBe(1);
expect(emailAfterFirstAttempt.send_attempt_errors?.[0].attempt_number).toBe(1);
expect(emailAfterFirstAttempt.send_attempt_errors?.[0].external_message).toContain("450");
expect(emailAfterFirstAttempt.status).not.toBe("server-error");
// Status could be:
// - "scheduled": isQueued=false, waiting for nextSendRetryAt to pass
// - "queued": queueReadyEmails() ran, isQueued=true, waiting to be claimed
// - "sending": next iteration already picked it up
expect(["scheduled", "queued", "sending"]).toContain(emailAfterFirstAttempt.status);
logIfTestFails("Email after first retry attempt", emailAfterFirstAttempt);
});
// Skipped: with SEND_RETRY_BACKOFF_BASE_MS at 20s, exponential backoff across 5 attempts
// can take 300-900s+ which is too slow for CI. The retry scheduling logic is covered by
// the "should schedule retry on retryable failure" test above. Note that the test passes with a smaller backoff base.
it.skip("should retry emails until max attempts exhausted, then mark as server-error", { timeout: 660000 }, async ({ expect }) => {
await Project.createAndSwitch({
display_name: "Test Retry Exhaustion Project",
config: {
email_config: tempFailSmtpConfig,
},
});
// Create API keys that don't expire (JWT admin tokens expire in 60s which is too short for retry tests)
await InternalApiKey.createAndSetProjectKeys();
const mailbox = backendContext.value.mailbox;
const createUserResponse = await niceBackendFetch("/api/v1/users", {
method: "POST",
accessType: "server",
body: {
primary_email: mailbox.emailAddress,
primary_email_verified: true,
},
});
expect(createUserResponse.status).toBe(201);
const userId = createUserResponse.body.id;
const sendResponse = await niceBackendFetch("/api/v1/emails/send-email", {
method: "POST",
accessType: "server",
body: {
user_ids: [userId],
html: "Test email for retry exhaustion",
subject: "Retry Exhaustion Test",
},
});
expect(sendResponse.status).toBe(200);
// Wait for the email to appear in the outbox
const initialEmail = await waitForOutboxEmail("Retry Exhaustion Test");
const emailId = initialEmail.id;
// Wait for all retries to exhaust (MAX_SEND_ATTEMPTS = 5)
// With 450 errors (immediate) + exponential backoff (20s base * 2^attempt)
const maxWaitMs = 600000;
const startTime = performance.now();
let email = await getOutboxEmailById(emailId);
while (performance.now() - startTime < maxWaitMs && email.status !== "server-error") {
await wait(1000);
email = await getOutboxEmailById(emailId);
}
// Log the email object to help debug undefined status issues
logIfTestFails("Email after retry exhaustion loop", email);
expect(email.status).toBe("server-error");
expect(email.send_retries).toBe(5); // MAX_SEND_ATTEMPTS
expect(email.send_attempt_errors?.length).toBe(5);
// No more retries scheduled
expect(email.next_send_retry_at_millis).toBeNull();
});
});
});