diff --git a/.changeset/quiet-mails-arrive.md b/.changeset/quiet-mails-arrive.md new file mode 100644 index 00000000000..9e3eae9c0d6 --- /dev/null +++ b/.changeset/quiet-mails-arrive.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': minor +--- + +Add experimental methods for sending and retrieving internal transactional emails, including Clerk suppression state and reasons. diff --git a/packages/backend/src/api/__tests__/EmailApi.test.ts b/packages/backend/src/api/__tests__/EmailApi.test.ts index c02a8db753b..38370ff6561 100644 --- a/packages/backend/src/api/__tests__/EmailApi.test.ts +++ b/packages/backend/src/api/__tests__/EmailApi.test.ts @@ -25,6 +25,7 @@ describe('EmailApi', () => { status: 'queued', data: null, delivered_by_clerk: true, + suppression_reason: null, }; it('sends a transactional email and snake_cases the body', async () => { @@ -59,6 +60,107 @@ describe('EmailApi', () => { expect(response.deliveredByClerk).toBe(true); }); + it('sends an idempotency key without adding it to the body', async () => { + server.use( + http.post( + 'https://api.clerk.test/v1/email', + validateHeaders(async ({ request }) => { + expect(request.headers.get('Idempotency-Key')).toBe('campaign-123-contact-456'); + const body = await request.json(); + expect(body).not.toHaveProperty('idempotency_key'); + return HttpResponse.json(mockEmail); + }), + ), + ); + + await apiClient.emails.create( + { + to: { address: 'admin@acme.com' }, + from: { address: 'noreply@acme.com' }, + subject: 'Hello', + html: '

hi

', + }, + { idempotencyKey: 'campaign-123-contact-456' }, + ); + }); + + it.each([ + ['an empty value', ''], + ['a null value', null], + ['a numeric value', 123], + ['unsupported characters', 'campaign:123'], + ['more than 255 characters', 'a'.repeat(256)], + ])('rejects idempotency keys with %s before sending a request', async (_, idempotencyKey) => { + let requestCount = 0; + server.use( + http.post('https://api.clerk.test/v1/email', () => { + requestCount += 1; + return HttpResponse.json(mockEmail); + }), + ); + + await expect( + apiClient.emails.create( + { + to: { address: 'admin@acme.com' }, + from: { address: 'noreply@acme.com' }, + subject: 'Hello', + html: '

hi

', + }, + // Exercise the runtime boundary that exists for JavaScript consumers. + { idempotencyKey: idempotencyKey as string }, + ), + ).rejects.toThrow('Idempotency key must contain only ASCII letters, digits, underscores, and hyphens'); + expect(requestCount).toBe(0); + }); + + it('gets the stored provider-acceptance status', async () => { + server.use( + http.get( + 'https://api.clerk.test/v1/email/ema_123', + validateHeaders(() => HttpResponse.json({ ...mockEmail, status: 'accepted' })), + ), + ); + + const response = await apiClient.emails.get('ema_123'); + expect(response.id).toBe('ema_123'); + expect(response.status).toBe('accepted'); + }); + + it('surfaces transactional suppression state', async () => { + server.use( + http.get( + 'https://api.clerk.test/v1/email/ema_123', + validateHeaders(() => + HttpResponse.json({ + ...mockEmail, + status: 'suppressed', + delivered_by_clerk: false, + suppression_reason: 'application_communication_lock', + }), + ), + ), + ); + + const response = await apiClient.emails.get('ema_123'); + expect(response.status).toBe('suppressed'); + expect(response.deliveredByClerk).toBe(false); + expect(response.suppressionReason).toBe('application_communication_lock'); + }); + + it('rejects an empty email ID before sending a request', async () => { + let requestCount = 0; + server.use( + http.get('https://api.clerk.test/v1/email/:emailId', () => { + requestCount += 1; + return HttpResponse.json(mockEmail); + }), + ); + + await expect(apiClient.emails.get('')).rejects.toThrow('A valid resource ID is required.'); + expect(requestCount).toBe(0); + }); + it('sends a transactional email with a text 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 16a7960effb..d1c752b548d 100644 --- a/packages/backend/src/api/endpoints/EmailApi.ts +++ b/packages/backend/src/api/endpoints/EmailApi.ts @@ -2,21 +2,14 @@ import type { Email } from '../resources/Email'; import { AbstractAPI } from './AbstractApi'; const basePath = '/email'; +const idempotencyKeyPattern = /^[a-zA-Z0-9_-]{1,255}$/; /** - * A subset of mailbox object as specified in RFC 5322 ยง3.4. Specifically, a - * `name-addr` with an optional `display-name` and a required `addr-spec`. + * A mailbox address as specified by RFC 5322's `addr-spec`. * * @see {@link https://datatracker.ietf.org/doc/html/rfc5322#section-3.4} */ type Mailbox = { - /** - * (Optional) Display name for the mailbox. Currently accepted by the API but - * not yet rendered server-side, so it has no effect on the delivered email - * for now. - */ - name?: string; - /** * The `addr-spec` of the mailbox, i.e. the email address itself. */ @@ -27,7 +20,7 @@ type Mailbox = { * The recipient of the email. Provide exactly one of the two mutually exclusive * forms: * - * - a literal mailbox: an `address` (plus an optional `name`), or + * - a literal mailbox: an `address`, or * - a `userId`: the ID of a Clerk user whose primary email address Clerk * resolves server-side, from the instance the secret key belongs to. */ @@ -37,11 +30,6 @@ type EmailRecipient = * The `addr-spec` of the recipient mailbox, i.e. the email address itself. */ address: string; - /** - * (Optional) Display name for the recipient mailbox. Currently accepted - * by the API but not yet rendered server-side. - */ - name?: string; userId?: never; } | { @@ -52,13 +40,13 @@ type EmailRecipient = */ userId: string; address?: never; - 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. + * both are provided, the `html` version takes precedence. Their combined UTF-8 + * encoding is limited to 50,000 bytes. Encoded as a union so that omitting both + * is a compile-time error rather than a server-side one. */ type EmailContent = | { @@ -87,25 +75,40 @@ type EmailContent = export type CreateEmailParams = { /** * The recipient of the email. Currently only a single recipient is supported. - * Provide either an `address` (with an optional `name`) or the `userId` of a + * Provide either an `address` or the `userId` of a * Clerk user; the two forms are mutually exclusive. */ to: EmailRecipient; /** - * The sender of the email. See {@link Mailbox} for the accepted format. Note - * that the API does not yet render the `name` field of the `from` mailbox. + * The sender of the email. Its domain must exactly match the instance's + * verified production sending domain. */ from: Mailbox; /** - * (Optional) The mailbox to include in the `reply-to` header of the email. + * (Optional) The mailbox to include in the `reply-to` header. Its domain must + * exactly match the same verified production domain as `from`. */ replyTo?: Mailbox; + /** Maximum 998 characters. */ subject: string; } & EmailContent; +export type CreateEmailOptions = { + /** + * Deduplicates retries of the same logical send. Reuse a key only when the + * recipient and content are identical; use one stable key per recipient when + * fanning out a batch. Clerk durably returns the original email for the same + * key and request, and returns a conflict if the key is reused with different + * parameters. Without a key, each call is a distinct send and the SDK does + * not retry an ambiguous POST. Keys may contain only ASCII letters, digits, + * underscores, and hyphens, up to 255 characters. + */ + idempotencyKey?: string; +}; + export class EmailApi extends AbstractAPI { /** * @experimental This method calls an internal, not-yet-public endpoint and is @@ -113,12 +116,40 @@ export class EmailApi extends AbstractAPI { * the SDK version to avoid breaking changes. * * Sends a transactional email. + * + * @param params - The recipient, sender, subject, and content of the email. + * @param options - Optional request settings, including an idempotency key. + * @returns The stored email and its current send status. + * @throws If the idempotency key does not match the supported format. + * @example + * ```ts + * const email = await clerkClient.emails.create( + * { + * to: { address: 'customer@example.com' }, + * from: { address: 'support@example.com' }, + * subject: 'Your receipt', + * html: '

Thanks for your order.

', + * }, + * { idempotencyKey: 'order_123_receipt' }, + * ); + * ``` */ - public async create(params: CreateEmailParams) { + 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.', + ); + } + return this.request({ method: 'POST', path: basePath, bodyParams: 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 @@ -127,4 +158,24 @@ export class EmailApi extends AbstractAPI { }, }); } + + /** + * Returns Clerk's stored send state for a transactional email. `accepted` + * means the provider accepted the request; it does not prove delivery. + * + * @param emailId - The ID returned when the email was created. + * @returns The stored email and its current send status. + * @throws If `emailId` is empty. + * @example + * ```ts + * const email = await clerkClient.emails.get('ema_123'); + * ``` + */ + public async get(emailId: string): Promise { + this.requireId(emailId); + return this.request({ + method: 'GET', + path: `${basePath}/${emailId}`, + }); + } } diff --git a/packages/backend/src/api/resources/Email.ts b/packages/backend/src/api/resources/Email.ts index 4258adaa6c1..7db0b313270 100644 --- a/packages/backend/src/api/resources/Email.ts +++ b/packages/backend/src/api/resources/Email.ts @@ -14,6 +14,7 @@ export class Email { readonly data?: Record | null, readonly deliveredByClerk?: boolean, readonly userId?: string | null, + readonly suppressionReason?: string | null, ) {} static fromJSON(data: EmailJSON): Email { @@ -30,6 +31,7 @@ export class Email { data.data, data.delivered_by_clerk, data.user_id, + data.suppression_reason, ); } } diff --git a/packages/backend/src/api/resources/JSON.ts b/packages/backend/src/api/resources/JSON.ts index e1ff98e2ee1..4c486b2a202 100644 --- a/packages/backend/src/api/resources/JSON.ts +++ b/packages/backend/src/api/resources/JSON.ts @@ -197,6 +197,7 @@ export interface EmailJSON extends ClerkResourceJSON { status?: string; data?: Record | null; delivered_by_clerk: boolean; + suppression_reason?: string | null; } export interface EmailAddressJSON extends ClerkResourceJSON {