From 3a84533af49623223dfc3a8e75962ff917b58d5b Mon Sep 17 00:00:00 2001 From: cbnsndwch <8313760+cbnsndwch@users.noreply.github.com> Date: Fri, 26 Jun 2026 01:30:27 +0000 Subject: [PATCH] fix(backend): require html or text in emails.create at the type level --- .changeset/email-cr-fixes.md | 2 + .../src/api/__tests__/EmailApi.test.ts | 34 +++++++++++++++ .../backend/src/api/endpoints/EmailApi.ts | 43 +++++++++++++------ 3 files changed, 66 insertions(+), 13 deletions(-) create mode 100644 .changeset/email-cr-fixes.md diff --git a/.changeset/email-cr-fixes.md b/.changeset/email-cr-fixes.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/email-cr-fixes.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/backend/src/api/__tests__/EmailApi.test.ts b/packages/backend/src/api/__tests__/EmailApi.test.ts index f78154917e0..c02a8db753b 100644 --- a/packages/backend/src/api/__tests__/EmailApi.test.ts +++ b/packages/backend/src/api/__tests__/EmailApi.test.ts @@ -59,6 +59,40 @@ describe('EmailApi', () => { expect(response.deliveredByClerk).toBe(true); }); + it('sends a transactional email with a text body', async () => { + server.use( + http.post( + 'https://api.clerk.test/v1/email', + validateHeaders(async ({ request }) => { + const body = await request.json(); + expect(body).toEqual({ + to: { address: 'admin@acme.com' }, + from: { address: 'noreply@acme.com' }, + subject: 'Hello', + text: 'hi', + }); + return HttpResponse.json({ + ...mockEmail, + body: null, + body_plain: 'hi', + }); + }), + ), + ); + + const response = await apiClient.emails.create({ + to: { address: 'admin@acme.com' }, + from: { address: 'noreply@acme.com' }, + subject: 'Hello', + text: 'hi', + }); + + expect(response.id).toBe('ema_123'); + expect(response.body).toBeNull(); + expect(response.bodyPlain).toBe('hi'); + expect(response.status).toBe('queued'); + }); + it('sends a transactional email addressed by userId', async () => { server.use( http.post( diff --git a/packages/backend/src/api/endpoints/EmailApi.ts b/packages/backend/src/api/endpoints/EmailApi.ts index a4f8d35d18b..16a7960effb 100644 --- a/packages/backend/src/api/endpoints/EmailApi.ts +++ b/packages/backend/src/api/endpoints/EmailApi.ts @@ -55,6 +55,35 @@ type EmailRecipient = name?: never; }; +/** + * The body of the email. At least one of `html` and `text` must be provided; if + * both are provided, the `html` version takes precedence. Encoded as a union so + * that omitting both is a compile-time error rather than a server-side one. + */ +type EmailContent = + | { + /** + * The HTML body of the email. Takes precedence over `text` when both are + * provided. + */ + html: string; + /** + * (Optional) The plain text body of the email. + */ + text?: string; + } + | { + /** + * (Optional) The HTML body of the email. Takes precedence over `text` + * when both are provided. + */ + html?: string; + /** + * The plain text body of the email. + */ + text: string; + }; + export type CreateEmailParams = { /** * The recipient of the email. Currently only a single recipient is supported. @@ -75,19 +104,7 @@ export type CreateEmailParams = { replyTo?: Mailbox; subject: string; - - /** - * The HTML body of the email. At least one of `html` and `text` must be - * provided. If both are provided, the `html` version will take precedence. - */ - html?: string; - - /** - * The plain text body of the email. At least one of `html` and `text` must be - * provided. If both are provided, the `html` version will take precedence. - */ - text?: string; -}; +} & EmailContent; export class EmailApi extends AbstractAPI { /**