mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-21 21:09:49 +08:00
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
2206 lines
84 KiB
TypeScript
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,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";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,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";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,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";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();
|
|
});
|
|
});
|
|
});
|
|
|