Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/tidy-email-batches.md
Original file line number Diff line number Diff line change
@@ -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.
187 changes: 187 additions & 0 deletions packages/backend/src/api/__tests__/EmailApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,193 @@ 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.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);
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': '<parent@example.com>', '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.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);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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(
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();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
expect(requests).toBe(1);
});

it('sends a transactional email and snake_cases the body', async () => {
server.use(
http.post(
Expand Down
133 changes: 115 additions & 18 deletions packages/backend/src/api/endpoints/EmailApi.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
};

/**
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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<string, string>;
/** Base64-encoded content. Up to 10 attachments and 1 MiB of combined decoded content. */
attachments?: { filename: string; content: string }[];
} & EmailContent;

export type CreateEmailOptions = {
Expand All @@ -109,6 +122,43 @@ 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 };
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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
Expand Down Expand Up @@ -136,27 +186,74 @@ export class EmailApi extends AbstractAPI {
*/
public async create(params: CreateEmailParams, options: CreateEmailOptions = {}): Promise<Email> {
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<Email>({
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`.
* 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<BatchEmailResult[]> {
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<BatchEmailResultJSON[]>({
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,
},
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/**
Expand Down
Loading