mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +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
Lint & build / lint_and_build (latest) (push) Has been cancelled
Dev Environment Test With Custom Base Port / restart-dev-and-test-with-custom-base-port (push) Has been cancelled
Dev Environment Test / restart-dev-and-test (push) Has been cancelled
Run setup tests with custom base port / setup-tests-with-custom-base-port (push) Has been cancelled
Run setup tests / setup-tests (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
### Context We didn't have an easy place for a user to see their domain statistics and track their sent emails, either overall or by draft. Additionally, there was scope creep with the sidebar, where we were supporting more pages. Our emails landing page was also rather confusing, especially toggling/ working with different email server types. So, we decide to add a "sent" page, to track email logs and email statistics, as well as let users temporarily override their sending limits if need be. Additionally, a user may want to see a particular email in more detail: what stage is it in? How did it proceed through time? How can I pause the sending of this email or change the scheduled time or edit the code? We allow for that to happen. ### Summary of Changes #### New Pages 1. **Sent Page:** A Domain Reputation card lets you track how many of your sent emails were bounced or marked as spam as well as how much capacity you have left. We also provide a temporary override, where you can use up to 4 times your capacity for a limited period of time. Additionally, we provide an email log that lets you see the recently sent emails. You can also toggle this view from a "list all emails" to "group by template/draft" which shows stats for each template/draft id (i.e a bar showing how many emails were sent, are pending, were marked as spam, were bounced etc, and the total number of emails sent with that template or draft). Clicking on an email in the list all view takes you to the "email-viewer" endpoint for that email (see below). Clicking on a template/draft in the group by view takes you to a page where you can see the statistics for that template/draft in more detail (the "send" stage view for that template/draft, as referenced below). 2. **Settings Page:** This is a new page we created because the old "emails" landing page wasn't doing its job. This page is to track all the email settings. Currently, we put in 2 sections. A "theme settings" card where users can see their active theme and click on a button to be navigated to the themes page. This is necessary as we remove themes from the sidebar. The other section is a card for email server and domain configuration - you can change your server type and adjust the settings or send a test email. It's cleaner and less noisy. 3. **Drafts Page**: There are a lot of changes here. On the landing page, we actually separate out the drafts into "active drafts" and "draft history" because drafts are meant to be fire-and-forget, not reusable. We also add the functionality to create a draft from a template. This was tricky to manage because templates rely on template variables which sent to the backend along with the code and injected during render time. We deal with this by having AI rewrite the template source code to remove any references to template variables and to make the draft standalone. The drafts page has been separated into a stepper-controlled multi stage process: draft->recipients->schedule->sent. Sent is a read only view that shows you the statistics of the emails sent using that draft, as mentioned earlier. You can also see the sent view of a historical draft. You can also bulk pause/cancel any unsent emails from the sent view of the drafts. 4. **Sidebar Updates**: The email sidebar now doesn't show "themes" or "emails" (the old landing page), but it does show "settings" and "sent", and the default landing page for emails is "sent". 5. **Email Viewer**: When you click on an individual email, you get navigated here. This has a timeline showing the progress of the email on the right, and some optional info for the user that's toggleable on the right bottom, while having either a preview of the email if it's sent or a way to edit it. You can also change the scheduledAt date of an email if it hasn't already been sent. #### Bug Fixes 1. **Search in `TeamMemberSearchTable`**: This was broken. Every time you tried to enter or remove a character, it would trigger skeleton loading that overlapped the search bar too, preventing you from adding/removing more. This was caused because the `useUser` hook eventually ended up calling a `use` hook, which throws a promise that triggers a suspense. This, coupled with the fact that the implementation of `TeamMemberSearchTable` involved a prop-drilling/ dependency inversion approach to passing down its toolbar to a base table component, meant the suspense would cover the toolbar too and couldn't be scoped to just the table. A refactor has gotten rid of the need for those base components while fixing tables in `payments/customers`, `teams/team_id`, and `payments/transactions` on top of the existing use in email drafts recipients stage. We also dedupped some code. 2. **Stale draft fetches on draft landing page**: `useEmailDrafts` uses an asyncCache to cache the fetched drafts. It is used on the drafts landing page to render the drafts. When a draft is sent, its `sentAt` is marked versus when it is still active, it is marked as null. The cache was stale and so navigating to the landing page after firing off a draft would errorneously represent that draft as still active and indeed, even allow you to edit it and fire it again. This violated the principle of drafts being fire and forget. This has been dealt with by adding functionality to refresh the draft cache upon firing off a draft. #### Other Changes 1. We bumped up the base time for the exponential send attempt retry backoff in `email-queue-step` to 20 seconds. The previous base was two seconds, and this effectively just made it wait until the next iteration of the `email-queue-step` cron job or at most an iteration that wasn't too far away. When an outage with our provider happens, it may take a while for it to be resolved, so a longer backoff is justified 2. We transitioned the themes page and the templates page to using the new components, though deeper UI refactors for them were out of scope for this ticket. 3. We implement a "temporarily increase capacity" button, that bumps up the throughput/ capacity limit fourfold for a user for a given period of time. It works like this: > Clicking the button sets a boost expiredat time. > When this time is set and still valid, the capacity rate is multiplied by 4. > When the button is clicked, trigger a loading spinner until the route finishes processing. > When the timer runs out, we reset the button back to its original state. > We dont need to wrap the onclick with runAsyncWithAlert because the component does that already. 4. We add a new default theme: a colorful theme with a lavender base. This was mainly done so we could have three times in a theme showcase in the settings page. ### UI Demos **Sent Page Demo:** https://github.com/user-attachments/assets/19294a90-bb65-4f00-9a97-111f6c08287f **Drafts Page Demo** https://github.com/user-attachments/assets/847609ef-d699-470c-a699-297bb9e17f04 **Settings Page Demo** https://github.com/user-attachments/assets/190a3829-036a-4f57-89c0-a873bef5a7ce **Email Viewer Page Demo** https://github.com/user-attachments/assets/3bc50159-4acb-4865-a4dd-830c84ee4235 --------- Co-authored-by: Konstantin Wohlwend <n2d4xc@gmail.com>
689 lines
23 KiB
TypeScript
689 lines
23 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { renderEmailsForTenancyBatched, renderEmailWithTemplate, type RenderEmailRequestForTenancy } from './email-rendering';
|
|
|
|
describe('renderEmailsForTenancyBatched', () => {
|
|
const createSimpleTemplateSource = (content: string) => `
|
|
export const variablesSchema = (v: any) => v;
|
|
export function EmailTemplate({ variables, user, project }: any) {
|
|
return (
|
|
<>
|
|
<div className="content">${content}</div>
|
|
<div className="user">{user.displayName}</div>
|
|
<div className="project">{project.displayName}</div>
|
|
{variables && <div className="variables">{JSON.stringify(variables)}</div>}
|
|
</>
|
|
);
|
|
}
|
|
`;
|
|
|
|
const createTemplateWithSubject = (subject: string, content: string) => `
|
|
import { Subject } from "@stackframe/emails";
|
|
export const variablesSchema = (v: any) => v;
|
|
export function EmailTemplate({ variables, user, project }: any) {
|
|
return (
|
|
<>
|
|
<Subject value="${subject}" />
|
|
<div className="content">${content}</div>
|
|
<div className="user">{user.displayName}</div>
|
|
</>
|
|
);
|
|
}
|
|
`;
|
|
|
|
const createTemplateWithNotificationCategory = (category: string, content: string) => `
|
|
import { NotificationCategory } from "@stackframe/emails";
|
|
export const variablesSchema = (v: any) => v;
|
|
export function EmailTemplate({ variables, user, project }: any) {
|
|
return (
|
|
<>
|
|
<NotificationCategory value="${category}" />
|
|
<div className="content">${content}</div>
|
|
</>
|
|
);
|
|
}
|
|
`;
|
|
|
|
const createSimpleThemeSource = () => `
|
|
export function EmailTheme({ children, unsubscribeLink }: any) {
|
|
return (
|
|
<div className="email-theme">
|
|
<header>Email Header</header>
|
|
<main>{children}</main>
|
|
{unsubscribeLink && <footer><a href={unsubscribeLink}>Unsubscribe</a></footer>}
|
|
</div>
|
|
);
|
|
}
|
|
`;
|
|
|
|
const createMockRequest = (
|
|
index: number,
|
|
overrides?: Partial<RenderEmailRequestForTenancy>
|
|
): RenderEmailRequestForTenancy => ({
|
|
templateSource: overrides?.templateSource ?? createSimpleTemplateSource(`Template content ${index}`),
|
|
themeSource: overrides?.themeSource ?? createSimpleThemeSource(),
|
|
input: {
|
|
user: { displayName: overrides?.input?.user.displayName ?? `User ${index}` },
|
|
project: { displayName: overrides?.input?.project.displayName ?? `Project ${index}` },
|
|
variables: overrides?.input ? overrides.input.variables : undefined,
|
|
unsubscribeLink: overrides?.input ? overrides.input.unsubscribeLink : `https://example.com/unsubscribe/${index}`,
|
|
},
|
|
});
|
|
|
|
describe('empty array input', () => {
|
|
it('should return empty array for empty requests', async () => {
|
|
const result = await renderEmailsForTenancyBatched([]);
|
|
|
|
expect(result.status).toBe('ok');
|
|
if (result.status === 'ok') {
|
|
expect(result.data).toEqual([]);
|
|
expect(result.data).toHaveLength(0);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('single request', () => {
|
|
it('should successfully render email for single request', async () => {
|
|
const request = createMockRequest(1);
|
|
const result = await renderEmailsForTenancyBatched([request]);
|
|
|
|
expect(result.status).toBe('ok');
|
|
if (result.status === 'ok') {
|
|
expect(result.data).toHaveLength(1);
|
|
expect(result.data[0].html).toBeDefined();
|
|
expect(result.data[0].text).toBeDefined();
|
|
expect(result.data[0].html).toContain('Template content 1');
|
|
expect(result.data[0].html).toContain('User 1');
|
|
expect(result.data[0].html).toContain('Project 1');
|
|
expect(result.data[0].html).toContain('Email Header');
|
|
expect(result.data[0].html).toContain('Unsubscribe');
|
|
expect(result.data[0].text).toContain('User 1');
|
|
}
|
|
});
|
|
|
|
it('should render email with subject when specified', async () => {
|
|
const request = createMockRequest(1, {
|
|
templateSource: createTemplateWithSubject('Test Subject', 'Email body content'),
|
|
});
|
|
const result = await renderEmailsForTenancyBatched([request]);
|
|
|
|
expect(result.status).toBe('ok');
|
|
if (result.status === 'ok') {
|
|
expect(result.data).toHaveLength(1);
|
|
expect(result.data[0].subject).toBe('Test Subject');
|
|
expect(result.data[0].html).toContain('Email body content');
|
|
}
|
|
});
|
|
|
|
it('should render email with notification category when specified', async () => {
|
|
const request = createMockRequest(1, {
|
|
templateSource: createTemplateWithNotificationCategory('Transactional', 'Transaction email'),
|
|
});
|
|
const result = await renderEmailsForTenancyBatched([request]);
|
|
|
|
expect(result.status).toBe('ok');
|
|
if (result.status === 'ok') {
|
|
expect(result.data).toHaveLength(1);
|
|
expect(result.data[0].notificationCategory).toBe('Transactional');
|
|
expect(result.data[0].html).toContain('Transaction email');
|
|
}
|
|
});
|
|
|
|
it('should handle request without variables', async () => {
|
|
const request = createMockRequest(1, {
|
|
input: {
|
|
user: { displayName: 'John Doe' },
|
|
project: { displayName: 'My Project' },
|
|
},
|
|
});
|
|
const result = await renderEmailsForTenancyBatched([request]);
|
|
|
|
expect(result.status).toBe('ok');
|
|
if (result.status === 'ok') {
|
|
expect(result.data).toHaveLength(1);
|
|
expect(result.data[0].html).toContain('John Doe');
|
|
expect(result.data[0].html).toContain('My Project');
|
|
}
|
|
});
|
|
|
|
it('should handle request with variables', async () => {
|
|
const request = createMockRequest(1, {
|
|
input: {
|
|
user: { displayName: 'Jane Doe' },
|
|
project: { displayName: 'Test Project' },
|
|
variables: { greeting: 'Hello', name: 'World' },
|
|
},
|
|
});
|
|
const result = await renderEmailsForTenancyBatched([request]);
|
|
|
|
expect(result.status).toBe('ok');
|
|
if (result.status === 'ok') {
|
|
expect(result.data).toHaveLength(1);
|
|
expect(result.data[0].html).toContain('Jane Doe');
|
|
expect(result.data[0].html).toContain('Test Project');
|
|
}
|
|
});
|
|
|
|
it('should handle request without unsubscribe link', async () => {
|
|
const request = createMockRequest(1, {
|
|
input: {
|
|
user: { displayName: 'User 1' },
|
|
project: { displayName: 'Project 1' },
|
|
},
|
|
});
|
|
const result = await renderEmailsForTenancyBatched([request]);
|
|
|
|
expect(result.status).toBe('ok');
|
|
if (result.status === 'ok') {
|
|
expect(result.data).toHaveLength(1);
|
|
expect(result.data[0].html).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it('should handle user with null displayName', async () => {
|
|
const request = createMockRequest(1, {
|
|
input: {
|
|
user: { displayName: null },
|
|
project: { displayName: 'Project 1' },
|
|
},
|
|
});
|
|
const result = await renderEmailsForTenancyBatched([request]);
|
|
|
|
expect(result.status).toBe('ok');
|
|
if (result.status === 'ok') {
|
|
expect(result.data).toHaveLength(1);
|
|
expect(result.data[0].html).toBeDefined();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('multiple requests', () => {
|
|
it('should successfully render emails for multiple requests', async () => {
|
|
const requests = [
|
|
createMockRequest(1),
|
|
createMockRequest(2),
|
|
createMockRequest(3),
|
|
];
|
|
const result = await renderEmailsForTenancyBatched(requests);
|
|
|
|
expect(result.status).toBe('ok');
|
|
if (result.status === 'ok') {
|
|
expect(result.data).toHaveLength(3);
|
|
|
|
expect(result.data[0].html).toContain('Template content 1');
|
|
expect(result.data[0].html).toContain('User 1');
|
|
expect(result.data[0].html).toContain('Project 1');
|
|
|
|
expect(result.data[1].html).toContain('Template content 2');
|
|
expect(result.data[1].html).toContain('User 2');
|
|
expect(result.data[1].html).toContain('Project 2');
|
|
|
|
expect(result.data[2].html).toContain('Template content 3');
|
|
expect(result.data[2].html).toContain('User 3');
|
|
expect(result.data[2].html).toContain('Project 3');
|
|
}
|
|
});
|
|
|
|
it('should handle requests with different templates and themes', async () => {
|
|
const requests = [
|
|
createMockRequest(1, {
|
|
templateSource: createSimpleTemplateSource('Custom Template 1'),
|
|
themeSource: `
|
|
export function EmailTheme({ children }: any) {
|
|
return <div className="custom-theme-1">{children}</div>;
|
|
}
|
|
`,
|
|
}),
|
|
createMockRequest(2, {
|
|
templateSource: createSimpleTemplateSource('Custom Template 2'),
|
|
themeSource: `
|
|
export function EmailTheme({ children }: any) {
|
|
return <div className="custom-theme-2">{children}</div>;
|
|
}
|
|
`,
|
|
}),
|
|
];
|
|
const result = await renderEmailsForTenancyBatched(requests);
|
|
|
|
expect(result.status).toBe('ok');
|
|
if (result.status === 'ok') {
|
|
expect(result.data).toHaveLength(2);
|
|
expect(result.data[0].html).toContain('Custom Template 1');
|
|
expect(result.data[0].html).toContain('custom-theme-1');
|
|
expect(result.data[1].html).toContain('Custom Template 2');
|
|
expect(result.data[1].html).toContain('custom-theme-2');
|
|
}
|
|
});
|
|
|
|
it('should handle mixed requests with and without subjects', async () => {
|
|
const requests = [
|
|
createMockRequest(1, {
|
|
templateSource: createTemplateWithSubject('Subject 1', 'Content 1'),
|
|
}),
|
|
createMockRequest(2, {
|
|
templateSource: createSimpleTemplateSource('Content 2'),
|
|
}),
|
|
createMockRequest(3, {
|
|
templateSource: createTemplateWithSubject('Subject 3', 'Content 3'),
|
|
}),
|
|
];
|
|
const result = await renderEmailsForTenancyBatched(requests);
|
|
|
|
expect(result.status).toBe('ok');
|
|
if (result.status === 'ok') {
|
|
expect(result.data).toHaveLength(3);
|
|
expect(result.data[0].subject).toBe('Subject 1');
|
|
expect(result.data[1].subject).toBeUndefined();
|
|
expect(result.data[2].subject).toBe('Subject 3');
|
|
}
|
|
});
|
|
|
|
it('should handle requests with different users and projects', async () => {
|
|
const requests = [
|
|
createMockRequest(1, {
|
|
input: {
|
|
user: { displayName: 'Alice' },
|
|
project: { displayName: 'Project A' },
|
|
},
|
|
}),
|
|
createMockRequest(2, {
|
|
input: {
|
|
user: { displayName: null },
|
|
project: { displayName: 'Project B' },
|
|
},
|
|
}),
|
|
createMockRequest(3, {
|
|
input: {
|
|
user: { displayName: 'Charlie' },
|
|
project: { displayName: 'Project C' },
|
|
},
|
|
}),
|
|
];
|
|
const result = await renderEmailsForTenancyBatched(requests);
|
|
|
|
expect(result.status).toBe('ok');
|
|
if (result.status === 'ok') {
|
|
expect(result.data).toHaveLength(3);
|
|
expect(result.data[0].html).toContain('Alice');
|
|
expect(result.data[0].html).toContain('Project A');
|
|
expect(result.data[1].html).toContain('Project B');
|
|
expect(result.data[2].html).toContain('Charlie');
|
|
expect(result.data[2].html).toContain('Project C');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('error handling', () => {
|
|
it('bundling error: invalid syntax', async () => {
|
|
const request = createMockRequest(1, {
|
|
templateSource: 'invalid syntax {{{ not jsx',
|
|
});
|
|
const result = await renderEmailsForTenancyBatched([request]);
|
|
|
|
expect(result.status).toBe('error');
|
|
if (result.status === 'error') {
|
|
expect(result.error).toBeDefined();
|
|
expect(typeof result.error).toBe('string');
|
|
}
|
|
});
|
|
|
|
it('bundling error: missing required export', async () => {
|
|
const request = createMockRequest(1, {
|
|
templateSource: `
|
|
export function WrongName() {
|
|
return <div>Wrong function name</div>;
|
|
}
|
|
`,
|
|
});
|
|
const result = await renderEmailsForTenancyBatched([request]);
|
|
|
|
expect(result.status).toBe('error');
|
|
if (result.status === 'error') {
|
|
expect(result.error).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it('runtime error: component throws (returns JSON with message and stack)', async () => {
|
|
const request = createMockRequest(1, {
|
|
templateSource: `
|
|
export const variablesSchema = (v: any) => v;
|
|
export function EmailTemplate() {
|
|
throw new Error('Template render failed');
|
|
}
|
|
`,
|
|
});
|
|
const result = await renderEmailsForTenancyBatched([request]);
|
|
|
|
expect(result.status).toBe('error');
|
|
if (result.status === 'error') {
|
|
expect(result.error).toContain('Template render failed');
|
|
// Verify error is JSON with stack trace
|
|
const parsed = JSON.parse(result.error);
|
|
expect(parsed.message).toContain('Template render failed');
|
|
expect(parsed.stack).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it('runtime error: arktype validation fails', async () => {
|
|
const request = createMockRequest(1, {
|
|
templateSource: `
|
|
import { type } from "arktype";
|
|
export const variablesSchema = type({ requiredField: "string" });
|
|
export function EmailTemplate({ variables }: any) {
|
|
return <div>{variables.requiredField}</div>;
|
|
}
|
|
`,
|
|
input: {
|
|
user: { displayName: 'User 1' },
|
|
project: { displayName: 'Project 1' },
|
|
variables: { wrongField: 'value' },
|
|
},
|
|
});
|
|
const result = await renderEmailsForTenancyBatched([request]);
|
|
|
|
expect(result.status).toBe('error');
|
|
if (result.status === 'error') {
|
|
expect(result.error).toContain('requiredField');
|
|
}
|
|
});
|
|
|
|
it('batch behavior: single failure fails entire batch', async () => {
|
|
const requests = [
|
|
createMockRequest(1),
|
|
createMockRequest(2, {
|
|
templateSource: `
|
|
export const variablesSchema = (v: any) => v;
|
|
export function EmailTemplate() {
|
|
throw new Error('Second template error');
|
|
}
|
|
`,
|
|
}),
|
|
createMockRequest(3),
|
|
];
|
|
const result = await renderEmailsForTenancyBatched(requests);
|
|
|
|
expect(result.status).toBe('error');
|
|
if (result.status === 'error') {
|
|
expect(result.error).toContain('Second template error');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('text rendering', () => {
|
|
it('should render plain text version of email', async () => {
|
|
const request = createMockRequest(1, {
|
|
templateSource: createSimpleTemplateSource('Plain text content'),
|
|
});
|
|
const result = await renderEmailsForTenancyBatched([request]);
|
|
|
|
expect(result.status).toBe('ok');
|
|
if (result.status === 'ok') {
|
|
expect(result.data[0].text).toBeDefined();
|
|
expect(result.data[0].text).toContain('Plain text content');
|
|
expect(result.data[0].text).toContain('User 1');
|
|
}
|
|
});
|
|
|
|
it('should render text for multiple emails', async () => {
|
|
const requests = [
|
|
createMockRequest(1),
|
|
createMockRequest(2),
|
|
];
|
|
const result = await renderEmailsForTenancyBatched(requests);
|
|
|
|
expect(result.status).toBe('ok');
|
|
if (result.status === 'ok') {
|
|
expect(result.data[0].text).toBeDefined();
|
|
expect(result.data[1].text).toBeDefined();
|
|
expect(result.data[0].text).not.toBe(result.data[1].text);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('unsubscribe link handling', () => {
|
|
it('should include unsubscribe link when provided', async () => {
|
|
const request = createMockRequest(1, {
|
|
input: {
|
|
user: { displayName: 'User 1' },
|
|
project: { displayName: 'Project 1' },
|
|
unsubscribeLink: 'https://example.com/unsubscribe/abc123',
|
|
},
|
|
});
|
|
const result = await renderEmailsForTenancyBatched([request]);
|
|
|
|
expect(result.status).toBe('ok');
|
|
if (result.status === 'ok') {
|
|
expect(result.data[0].html).toContain('https://example.com/unsubscribe/abc123');
|
|
}
|
|
});
|
|
|
|
it('should handle missing unsubscribe link gracefully', async () => {
|
|
const customTheme = `
|
|
export function EmailTheme({ children, unsubscribeLink }: any) {
|
|
return (
|
|
<div>
|
|
<main>{children}</main>
|
|
{unsubscribeLink ? <footer><a href={unsubscribeLink}>Unsubscribe</a></footer> : null}
|
|
</div>
|
|
);
|
|
}
|
|
`;
|
|
const request = createMockRequest(1, {
|
|
themeSource: customTheme,
|
|
input: {
|
|
user: { displayName: 'User 1' },
|
|
project: { displayName: 'Project 1' },
|
|
},
|
|
});
|
|
const result = await renderEmailsForTenancyBatched([request]);
|
|
|
|
expect(result.status).toBe('ok');
|
|
if (result.status === 'ok') {
|
|
expect(result.data[0].html).toBeDefined();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('large batch', () => {
|
|
it('should handle rendering 10 emails in a single batch', async () => {
|
|
const requests = Array.from({ length: 10 }, (_, i) => createMockRequest(i + 1));
|
|
const result = await renderEmailsForTenancyBatched(requests);
|
|
|
|
expect(result.status).toBe('ok');
|
|
if (result.status === 'ok') {
|
|
expect(result.data).toHaveLength(10);
|
|
result.data.forEach((email, i) => {
|
|
expect(email.html).toContain(`User ${i + 1}`);
|
|
expect(email.html).toContain(`Project ${i + 1}`);
|
|
expect(email.text).toBeDefined();
|
|
});
|
|
}
|
|
}, 30000); // Extended timeout for large batch
|
|
});
|
|
});
|
|
|
|
describe('renderEmailWithTemplate', () => {
|
|
const simpleTemplate = `
|
|
export const variablesSchema = (v: any) => v;
|
|
export function EmailTemplate({ user, project }: any) {
|
|
return (
|
|
<div>
|
|
<span className="user">{user.displayName}</span>
|
|
<span className="project">{project.displayName}</span>
|
|
</div>
|
|
);
|
|
}
|
|
`;
|
|
|
|
const simpleTheme = `
|
|
export function EmailTheme({ children }: any) {
|
|
return <div className="theme">{children}</div>;
|
|
}
|
|
`;
|
|
|
|
const editableTemplate = `
|
|
export const variablesSchema = (v: any) => v;
|
|
export function EmailTemplate() {
|
|
return <div>Hello Template</div>;
|
|
}
|
|
`;
|
|
|
|
const editableTheme = `
|
|
export function EmailTheme({ children }: any) {
|
|
return <div>Theme Wrapper {children}</div>;
|
|
}
|
|
`;
|
|
|
|
it('preview mode: uses default user and project when not provided', async () => {
|
|
const result = await renderEmailWithTemplate(simpleTemplate, simpleTheme, {
|
|
previewMode: true,
|
|
});
|
|
|
|
expect(result.status).toBe('ok');
|
|
if (result.status === 'ok') {
|
|
expect(result.data.html).toContain('John Doe');
|
|
expect(result.data.html).toContain('My Project');
|
|
}
|
|
});
|
|
|
|
it('preview mode: merges PreviewVariables from template', async () => {
|
|
const templateWithPreviewVars = `
|
|
import { type } from "arktype";
|
|
export const variablesSchema = type({ greeting: "string" });
|
|
export function EmailTemplate({ variables }: any) {
|
|
return <div className="greeting">{variables.greeting}</div>;
|
|
}
|
|
EmailTemplate.PreviewVariables = { greeting: "Hello from preview!" };
|
|
`;
|
|
|
|
const result = await renderEmailWithTemplate(templateWithPreviewVars, simpleTheme, {
|
|
previewMode: true,
|
|
});
|
|
|
|
expect(result.status).toBe('ok');
|
|
if (result.status === 'ok') {
|
|
expect(result.data.html).toContain('Hello from preview!');
|
|
}
|
|
});
|
|
|
|
it('editable markers: disabled by default', async () => {
|
|
const result = await renderEmailWithTemplate(editableTemplate, editableTheme, {
|
|
previewMode: true,
|
|
});
|
|
|
|
expect(result.status).toBe('ok');
|
|
if (result.status === 'ok') {
|
|
expect(result.data.editableRegions).toBeUndefined();
|
|
expect(result.data.html).not.toContain('STACK_EDITABLE_START');
|
|
}
|
|
});
|
|
|
|
it('editable markers: template only', async () => {
|
|
const result = await renderEmailWithTemplate(editableTemplate, editableTheme, {
|
|
previewMode: true,
|
|
editableMarkers: true,
|
|
editableSource: 'template',
|
|
});
|
|
|
|
expect(result).toMatchInlineSnapshot(`
|
|
{
|
|
"data": {
|
|
"editableRegions": {
|
|
"t0": {
|
|
"id": "t0",
|
|
"jsxPath": [
|
|
"EmailTemplate",
|
|
"div",
|
|
],
|
|
"loc": {
|
|
"column": 18,
|
|
"end": 121,
|
|
"line": 4,
|
|
"start": 107,
|
|
},
|
|
"occurrenceCount": 1,
|
|
"occurrenceIndex": 1,
|
|
"originalText": "Hello Template",
|
|
"parentElement": {
|
|
"props": {},
|
|
"tagName": "div",
|
|
},
|
|
"siblingIndex": 0,
|
|
"sourceContext": {
|
|
"after": " }
|
|
",
|
|
"before": "
|
|
export const variablesSchema = (v: any) => v;
|
|
export function EmailTemplate() {",
|
|
},
|
|
"sourceFile": "template",
|
|
"textHash": "94fa2ad62c98a1d3",
|
|
},
|
|
},
|
|
"html": "<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><!--$--><div>Theme Wrapper <div><!-- STACK_EDITABLE_START t0 --><!-- -->Hello Template<!-- --><!-- STACK_EDITABLE_END t0 --></div></div><!--/$-->",
|
|
"text": "Theme Wrapper
|
|
⟦STACK_EDITABLE_START:t0⟧Hello Template⟦STACK_EDITABLE_END:t0⟧",
|
|
},
|
|
"status": "ok",
|
|
}
|
|
`);
|
|
});
|
|
|
|
it('editable markers: theme only', async () => {
|
|
const templateWithoutText = `
|
|
export const variablesSchema = (v: any) => v;
|
|
export function EmailTemplate() {
|
|
return <div>{null}</div>;
|
|
}
|
|
`;
|
|
|
|
const result = await renderEmailWithTemplate(templateWithoutText, editableTheme, {
|
|
previewMode: true,
|
|
editableMarkers: true,
|
|
editableSource: 'theme',
|
|
});
|
|
|
|
expect(result).toMatchInlineSnapshot(`
|
|
{
|
|
"data": {
|
|
"editableRegions": {
|
|
"h0": {
|
|
"id": "h0",
|
|
"jsxPath": [
|
|
"EmailTheme",
|
|
"div",
|
|
],
|
|
"loc": {
|
|
"column": 18,
|
|
"end": 85,
|
|
"line": 3,
|
|
"start": 71,
|
|
},
|
|
"occurrenceCount": 1,
|
|
"occurrenceIndex": 1,
|
|
"originalText": "Theme Wrapper ",
|
|
"parentElement": {
|
|
"props": {},
|
|
"tagName": "div",
|
|
},
|
|
"siblingIndex": 0,
|
|
"sourceContext": {
|
|
"after": " }
|
|
",
|
|
"before": "
|
|
export function EmailTheme({ children }: any) {",
|
|
},
|
|
"sourceFile": "theme",
|
|
"textHash": "c6990020d33ca275",
|
|
},
|
|
},
|
|
"html": "<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><!--$--><div><!-- STACK_EDITABLE_START h0 --><!-- -->Theme Wrapper <!-- --><!-- STACK_EDITABLE_END h0 --><div></div></div><!--/$-->",
|
|
"text": "⟦STACK_EDITABLE_START:h0⟧Theme Wrapper ⟦STACK_EDITABLE_END:h0⟧
|
|
",
|
|
},
|
|
"status": "ok",
|
|
}
|
|
`);
|
|
});
|
|
});
|
|
|