From e41e5eb5c1b1d0094911b9bb3340bfb6859459ec Mon Sep 17 00:00:00 2001 From: Jeff Escalante Date: Thu, 10 Sep 2026 15:32:56 -0400 Subject: [PATCH 1/3] feat(backend): add transactional email batches and message options --- .changeset/tidy-email-batches.md | 7 + .../src/api/__tests__/EmailApi.test.ts | 129 ++++++++++++++++++ .../backend/src/api/endpoints/EmailApi.ts | 106 +++++++++++--- 3 files changed, 224 insertions(+), 18 deletions(-) create mode 100644 .changeset/tidy-email-batches.md diff --git a/.changeset/tidy-email-batches.md b/.changeset/tidy-email-batches.md new file mode 100644 index 00000000000..21f2001cfda --- /dev/null +++ b/.changeset/tidy-email-batches.md @@ -0,0 +1,7 @@ +--- +'@clerk/backend': minor +--- + +Add experimental `emails.createBatch()` for submitting up to 100 transactional emails. Each message supports its own idempotency key and returns an independent success or error result. + +Transactional messages now support sender and reply-to display names, cross-domain Reply-To addresses, CC/BCC recipients, attachments, and custom headers. These options require a backend deployment that supports them. diff --git a/packages/backend/src/api/__tests__/EmailApi.test.ts b/packages/backend/src/api/__tests__/EmailApi.test.ts index 38370ff6561..25278c439e7 100644 --- a/packages/backend/src/api/__tests__/EmailApi.test.ts +++ b/packages/backend/src/api/__tests__/EmailApi.test.ts @@ -28,6 +28,135 @@ describe('EmailApi', () => { suppression_reason: null, }; + it('submits a batch with per-message keys and returns typed mixed results', async () => { + server.use( + http.post('https://api.clerk.test/v1/email/batch', async ({ request }) => { + expect(request.headers.has('Idempotency-Key')).toBe(false); + expect(await request.json()).toEqual({ + messages: [ + { + to: { user_id: 'user_123' }, + from: { address: 'notify@acme.com' }, + reply_to: { address: 'support@acme.com' }, + subject: 'Update', + text: 'Done', + idempotency_key: 'update_123', + }, + { + to: { address: 'second@acme.com' }, + from: { address: 'notify@acme.com' }, + subject: 'Update', + text: 'Done', + }, + ], + }); + return HttpResponse.json({ + data: [ + { index: 0, email: mockEmail, status_code: 200 }, + { + index: 1, + status_code: 429, + retry_after_seconds: 30, + errors: [{ code: 'rate_limit_exceeded', message: 'Too many requests', long_message: 'Try again later' }], + }, + ], + }); + }), + ); + const results = await apiClient.emails.createBatch([ + { + to: { userId: 'user_123' }, + from: { address: 'notify@acme.com' }, + replyTo: { address: 'support@acme.com' }, + subject: 'Update', + text: 'Done', + idempotencyKey: 'update_123', + }, + { + to: { address: 'second@acme.com' }, + from: { address: 'notify@acme.com' }, + subject: 'Update', + text: 'Done', + }, + ]); + expect(results[0].email?.toEmailAddress).toBe('admin@acme.com'); + expect(results[0].email?.deliveredByClerk).toBe(true); + expect(results[1].statusCode).toBe(429); + expect(results[1].retryAfterSeconds).toBe(30); + expect(results[1].errors?.[0].longMessage).toBe('Try again later'); + }); + + it.each(['single', 'batch'] as const)('preserves rich message fields in a %s request', async mode => { + const params = { + to: { userId: 'user_123' }, + from: { address: 'updates@roadmap.clerk.app', name: 'Roadmap' }, + replyTo: { address: 'support@clerk.com', name: 'Clerk Support' }, + subject: 'Your report', + text: 'See attachment', + cc: ['copy@example.com'], + bcc: ['blind@example.com'], + headers: { 'In-Reply-To': '', 'X-Ticket-ID': 'ticket_123' }, + attachments: [{ filename: 'report.txt', content: 'aGVsbG8=' }], + }; + const { replyTo, ...rest } = params; + const expected = { ...rest, to: { user_id: 'user_123' }, reply_to: replyTo }; + server.use( + http.post(`https://api.clerk.test/v1/email${mode === 'batch' ? '/batch' : ''}`, async ({ request }) => { + expect(await request.json()).toEqual(mode === 'batch' ? { messages: [expected] } : expected); + return HttpResponse.json( + mode === 'batch' ? { data: [{ index: 0, email: mockEmail, status_code: 200 }] } : mockEmail, + ); + }), + ); + if (mode === 'batch') { + await apiClient.emails.createBatch([params]); + } else { + await apiClient.emails.create(params); + } + }); + + it('rejects an invalid batch key before submitting any messages', async () => { + let requests = 0; + server.use( + http.post('https://api.clerk.test/v1/email/batch', () => { + requests++; + return HttpResponse.json({ data: [] }); + }), + ); + await expect( + apiClient.emails.createBatch([ + { + to: { address: 'admin@acme.com' }, + from: { address: 'notify@acme.com' }, + subject: 'Update', + text: 'Done', + idempotencyKey: 'invalid:key', + }, + ]), + ).rejects.toThrow('Idempotency key must contain'); + expect(requests).toBe(0); + }); + + it('rejects an empty batch', async () => { + await expect(apiClient.emails.createBatch([])).rejects.toThrow('between 1 and 100'); + }); + + it('does not retry a batch POST automatically', async () => { + let requests = 0; + server.use( + http.post('https://api.clerk.test/v1/email/batch', () => { + requests++; + return HttpResponse.json({ errors: [{ code: 'internal_error', message: 'Unavailable' }] }, { status: 503 }); + }), + ); + await expect( + apiClient.emails.createBatch([ + { to: { address: 'admin@acme.com' }, from: { address: 'notify@acme.com' }, subject: 'Update', text: 'Done' }, + ]), + ).rejects.toThrow(); + expect(requests).toBe(1); + }); + it('sends a transactional email and snake_cases the body', async () => { server.use( http.post( diff --git a/packages/backend/src/api/endpoints/EmailApi.ts b/packages/backend/src/api/endpoints/EmailApi.ts index d1c752b548d..ca135af62b3 100644 --- a/packages/backend/src/api/endpoints/EmailApi.ts +++ b/packages/backend/src/api/endpoints/EmailApi.ts @@ -1,4 +1,8 @@ -import type { Email } from '../resources/Email'; +import { parseError } from '@clerk/shared/error'; +import type { ClerkAPIError, ClerkAPIErrorJSON } from '@clerk/shared/types'; + +import { Email } from '../resources/Email'; +import type { EmailJSON } from '../resources/JSON'; import { AbstractAPI } from './AbstractApi'; const basePath = '/email'; @@ -14,6 +18,8 @@ type Mailbox = { * The `addr-spec` of the mailbox, i.e. the email address itself. */ address: string; + /** Optional display name, up to 200 characters. */ + name?: string; }; /** @@ -74,7 +80,7 @@ type EmailContent = export type CreateEmailParams = { /** - * The recipient of the email. Currently only a single recipient is supported. + * The primary recipient of the email. Use `cc` and `bcc` for additional recipients. * Provide either an `address` or the `userId` of a * Clerk user; the two forms are mutually exclusive. */ @@ -87,13 +93,20 @@ export type CreateEmailParams = { from: Mailbox; /** - * (Optional) The mailbox to include in the `reply-to` header. Its domain must - * exactly match the same verified production domain as `from`. + * (Optional) The mailbox to include in the `reply-to` header. It may use a + * different domain from the verified sender. Receiving mail is not provided. */ replyTo?: Mailbox; /** Maximum 998 characters. */ subject: string; + /** Additional recipients. Up to 50 total across to, cc, and bcc, without duplicates. */ + cc?: string[]; + bcc?: string[]; + /** Threading, unsubscribe, or custom X-* headers. Provider-control headers are prohibited. */ + headers?: Record; + /** Base64-encoded content. Up to 10 attachments and 1 MiB of combined decoded content. */ + attachments?: { filename: string; content: string }[]; } & EmailContent; export type CreateEmailOptions = { @@ -109,6 +122,41 @@ export type CreateEmailOptions = { idempotencyKey?: string; }; +export type CreateBatchEmailParams = CreateEmailParams & CreateEmailOptions; + +export type BatchEmailResult = + | { index: number; email: Email; errors?: never; statusCode: number; retryAfterSeconds?: never } + | { index: number; email?: never; errors: ClerkAPIError[]; statusCode: number; retryAfterSeconds?: number }; + +type BatchEmailResultJSON = { + index: number; + email?: EmailJSON; + errors?: ClerkAPIErrorJSON[]; + status_code: number; + retry_after_seconds?: number; +}; + +function validateIdempotencyKey(idempotencyKey: string | undefined) { + if ( + idempotencyKey !== undefined && + (typeof idempotencyKey !== 'string' || !idempotencyKeyPattern.test(idempotencyKey)) + ) { + throw new Error( + 'Idempotency key must contain only ASCII letters, digits, underscores, and hyphens and cannot exceed 255 characters.', + ); + } +} + +function emailBody(params: CreateEmailParams) { + const { to, replyTo, ...rest } = params; + const { userId, ...recipient } = to; + return { + ...rest, + to: { ...recipient, ...(userId !== undefined ? { user_id: userId } : {}) }, + ...(replyTo !== undefined ? { reply_to: replyTo } : {}), + }; +} + export class EmailApi extends AbstractAPI { /** * @experimental This method calls an internal, not-yet-public endpoint and is @@ -136,27 +184,49 @@ export class EmailApi extends AbstractAPI { */ public async create(params: CreateEmailParams, options: CreateEmailOptions = {}): Promise { const { idempotencyKey } = options; - if ( - idempotencyKey !== undefined && - (typeof idempotencyKey !== 'string' || !idempotencyKeyPattern.test(idempotencyKey)) - ) { - throw new Error( - 'Idempotency key must contain only ASCII letters, digits, underscores, and hyphens and cannot exceed 255 characters.', - ); - } + validateIdempotencyKey(idempotencyKey); return this.request({ method: 'POST', path: basePath, - bodyParams: params, + bodyParams: emailBody(params), ...(idempotencyKey !== undefined ? { headerParams: { 'Idempotency-Key': idempotencyKey } } : {}), - options: { - // Snakecase nested keys too, so a `to: { userId }` recipient is sent as - // `to: { user_id }` on the wire (the default only snakecases top-level - // keys, which would leave the nested `userId` untouched). - deepSnakecaseBodyParamKeys: true, + }); + } + + /** + * @experimental Submit 1–100 emails, returning one result per input in order. + * Each message commits independently. Use a stable `idempotencyKey` on each + * item to safely retry an interrupted batch or retry through `emails.create`. + * Item errors are returned alongside successes; request-level errors throw. + */ + public async createBatch(messages: CreateBatchEmailParams[]): Promise { + if (messages.length < 1 || messages.length > 100) { + throw new Error('A batch must contain between 1 and 100 messages.'); + } + for (const message of messages) { + validateIdempotencyKey(message.idempotencyKey); + } + const results = await this.request({ + method: 'POST', + path: `${basePath}/batch`, + bodyParams: { + messages: messages.map(({ idempotencyKey, ...params }) => ({ + ...emailBody(params), + ...(idempotencyKey !== undefined ? { idempotency_key: idempotencyKey } : {}), + })), }, }); + return results.map(result => + result.email + ? { index: result.index, email: Email.fromJSON(result.email), statusCode: result.status_code } + : { + index: result.index, + errors: (result.errors || []).map(parseError), + statusCode: result.status_code, + retryAfterSeconds: result.retry_after_seconds, + }, + ); } /** From e5f7f63c46160e79c11a909d9f630ce488292cb2 Mon Sep 17 00:00:00 2001 From: Jeff Escalante Date: Fri, 11 Sep 2026 12:12:53 -0400 Subject: [PATCH 2/3] test(backend): cover email batch limits and document results --- .../src/api/__tests__/EmailApi.test.ts | 19 +++++++++++-- .../backend/src/api/endpoints/EmailApi.ts | 27 +++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/api/__tests__/EmailApi.test.ts b/packages/backend/src/api/__tests__/EmailApi.test.ts index 25278c439e7..ef0498218d5 100644 --- a/packages/backend/src/api/__tests__/EmailApi.test.ts +++ b/packages/backend/src/api/__tests__/EmailApi.test.ts @@ -79,6 +79,7 @@ describe('EmailApi', () => { text: 'Done', }, ]); + expect(results.map(result => result.index)).toEqual([0, 1]); expect(results[0].email?.toEmailAddress).toBe('admin@acme.com'); expect(results[0].email?.deliveredByClerk).toBe(true); expect(results[1].statusCode).toBe(429); @@ -137,8 +138,22 @@ describe('EmailApi', () => { expect(requests).toBe(0); }); - it('rejects an empty batch', async () => { - await expect(apiClient.emails.createBatch([])).rejects.toThrow('between 1 and 100'); + it.each([0, 101])('rejects a batch of %i messages before sending a request', async count => { + let requests = 0; + server.use( + http.post('https://api.clerk.test/v1/email/batch', () => { + requests++; + return HttpResponse.json({ data: [] }); + }), + ); + const messages = Array.from({ length: count }, () => ({ + to: { address: 'admin@acme.com' }, + from: { address: 'notify@acme.com' }, + subject: 'Update', + text: 'Done', + })); + await expect(apiClient.emails.createBatch(messages)).rejects.toThrow('between 1 and 100'); + expect(requests).toBe(0); }); it('does not retry a batch POST automatically', async () => { diff --git a/packages/backend/src/api/endpoints/EmailApi.ts b/packages/backend/src/api/endpoints/EmailApi.ts index ca135af62b3..f497154c724 100644 --- a/packages/backend/src/api/endpoints/EmailApi.ts +++ b/packages/backend/src/api/endpoints/EmailApi.ts @@ -122,8 +122,10 @@ export type CreateEmailOptions = { idempotencyKey?: string; }; +/** One independently processed email and its optional idempotency key. */ export type CreateBatchEmailParams = CreateEmailParams & CreateEmailOptions; +/** The email or errors for one batch item, identified by its zero-based input index. */ export type BatchEmailResult = | { index: number; email: Email; errors?: never; statusCode: number; retryAfterSeconds?: never } | { index: number; email?: never; errors: ClerkAPIError[]; statusCode: number; retryAfterSeconds?: number }; @@ -198,7 +200,32 @@ export class EmailApi extends AbstractAPI { * @experimental Submit 1–100 emails, returning one result per input in order. * Each message commits independently. Use a stable `idempotencyKey` on each * item to safely retry an interrupted batch or retry through `emails.create`. + * Reuse a key only with identical message parameters. The SDK does not retry + * the batch automatically. * Item errors are returned alongside successes; request-level errors throw. + * + * @param messages - The emails to send, each with an optional idempotency key. + * @returns One success or error result per input, in input order. + * @throws If the batch size or an idempotency key is invalid, or the request fails. + * @example + * ```ts + * const results = await clerkClient.emails.createBatch([ + * { + * to: { address: 'customer@example.com' }, + * from: { address: 'support@example.com' }, + * subject: 'Your receipt', + * text: 'Thanks for your order.', + * idempotencyKey: 'order_123_receipt', + * }, + * ]); + * for (const result of results) { + * if (result.email) { + * console.log(result.index, result.email.id); + * } else { + * console.error(result.index, result.statusCode, result.errors); + * } + * } + * ``` */ public async createBatch(messages: CreateBatchEmailParams[]): Promise { if (messages.length < 1 || messages.length > 100) { From 8df42ae541d834b64c177f85e02ec6417c3fc57c Mon Sep 17 00:00:00 2001 From: Jeff Escalante Date: Fri, 11 Sep 2026 12:56:30 -0400 Subject: [PATCH 3/3] test(backend): cover full email batches and network interruptions --- .../src/api/__tests__/EmailApi.test.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/packages/backend/src/api/__tests__/EmailApi.test.ts b/packages/backend/src/api/__tests__/EmailApi.test.ts index ef0498218d5..375b6e2ac87 100644 --- a/packages/backend/src/api/__tests__/EmailApi.test.ts +++ b/packages/backend/src/api/__tests__/EmailApi.test.ts @@ -156,6 +156,49 @@ describe('EmailApi', () => { expect(requests).toBe(0); }); + it('accepts a batch of 100 messages in one request', async () => { + const messages = Array.from({ length: 100 }, (_, index) => ({ + to: { address: `recipient${index}@acme.com` }, + from: { address: 'notify@acme.com' }, + subject: 'Update', + text: 'Done', + })); + let requests = 0; + server.use( + http.post('https://api.clerk.test/v1/email/batch', async ({ request }) => { + requests++; + expect(await request.json()).toEqual({ messages }); + return HttpResponse.json({ + data: messages.map((message, index) => ({ + index, + email: { ...mockEmail, id: `ema_${index}`, to_email_address: message.to.address }, + status_code: 200, + })), + }); + }), + ); + const results = await apiClient.emails.createBatch(messages); + expect(requests).toBe(1); + expect(results).toHaveLength(100); + expect(results.map(result => result.email?.id)).toEqual(messages.map((_, index) => `ema_${index}`)); + }); + + it('does not retry a batch POST after a network interruption', async () => { + let requests = 0; + server.use( + http.post('https://api.clerk.test/v1/email/batch', () => { + requests++; + return HttpResponse.error(); + }), + ); + await expect( + apiClient.emails.createBatch([ + { to: { address: 'admin@acme.com' }, from: { address: 'notify@acme.com' }, subject: 'Update', text: 'Done' }, + ]), + ).rejects.toMatchObject({ errors: [expect.objectContaining({ code: 'unexpected_error' })] }); + expect(requests).toBe(1); + }); + it('does not retry a batch POST automatically', async () => { let requests = 0; server.use(