diff --git a/.changeset/clerk-email-send.md b/.changeset/clerk-email-send.md new file mode 100644 index 00000000000..7569e92c59c --- /dev/null +++ b/.changeset/clerk-email-send.md @@ -0,0 +1,7 @@ +--- +'@clerk/backend': minor +--- + +Add an experimental `clerkClient.emails.create()` method for sending transactional emails. It accepts address- or user-based recipients, supports optional `replyTo`, `subject`, and HTML and/or text content, and returns the created `Email` resource. + +This method is marked `@experimental` and may change in a future release. diff --git a/packages/backend/src/api/__tests__/EmailApi.test.ts b/packages/backend/src/api/__tests__/EmailApi.test.ts new file mode 100644 index 00000000000..c02a8db753b --- /dev/null +++ b/packages/backend/src/api/__tests__/EmailApi.test.ts @@ -0,0 +1,130 @@ +import { http, HttpResponse } from 'msw'; +import { describe, expect, it } from 'vitest'; + +import { server, validateHeaders } from '../../mock-server'; +import { createBackendApiClient } from '../factory'; + +describe('EmailApi', () => { + const apiClient = createBackendApiClient({ + apiUrl: 'https://api.clerk.test', + secretKey: 'deadbeef', + }); + + const mockEmail = { + object: 'email', + id: 'ema_123', + slug: null, + from_email_name: 'noreply', + reply_to_email_name: null, + to_email_address: 'admin@acme.com', + email_address_id: null, + user_id: null, + subject: 'Hello', + body: '
hi
', + body_plain: null, + status: 'queued', + data: null, + delivered_by_clerk: true, + }; + + it('sends a transactional email and snake_cases the 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' }, + reply_to: { address: 'support@acme.com' }, + subject: 'Hello', + html: 'hi
', + }); + return HttpResponse.json(mockEmail); + }), + ), + ); + + const response = await apiClient.emails.create({ + to: { address: 'admin@acme.com' }, + from: { address: 'noreply@acme.com' }, + replyTo: { address: 'support@acme.com' }, + subject: 'Hello', + html: 'hi
', + }); + + expect(response.id).toBe('ema_123'); + expect(response.toEmailAddress).toBe('admin@acme.com'); + expect(response.status).toBe('queued'); + 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( + 'https://api.clerk.test/v1/email', + validateHeaders(async ({ request }) => { + const body = await request.json(); + // The nested `userId` must be snake_cased to `user_id` on the wire. + expect(body).toEqual({ + to: { user_id: 'user_123' }, + from: { address: 'noreply@acme.com' }, + subject: 'Hello', + html: 'hi
', + }); + return HttpResponse.json({ + ...mockEmail, + to_email_address: 'member@acme.com', + email_address_id: 'idn_123', + user_id: 'user_123', + }); + }), + ), + ); + + const response = await apiClient.emails.create({ + to: { userId: 'user_123' }, + from: { address: 'noreply@acme.com' }, + subject: 'Hello', + html: 'hi
', + }); + + expect(response.toEmailAddress).toBe('member@acme.com'); + expect(response.emailAddressId).toBe('idn_123'); + expect(response.userId).toBe('user_123'); + }); +}); diff --git a/packages/backend/src/api/endpoints/EmailApi.ts b/packages/backend/src/api/endpoints/EmailApi.ts new file mode 100644 index 00000000000..16a7960effb --- /dev/null +++ b/packages/backend/src/api/endpoints/EmailApi.ts @@ -0,0 +1,130 @@ +import type { Email } from '../resources/Email'; +import { AbstractAPI } from './AbstractApi'; + +const basePath = '/email'; + +/** + * 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`. + * + * @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. + */ + address: string; +}; + +/** + * 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 `userId`: the ID of a Clerk user whose primary email address Clerk + * resolves server-side, from the instance the secret key belongs to. + */ +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; + } + | { + /** + * The ID of the Clerk user to send to. Clerk resolves the user's primary + * email address from the instance context. Mutually exclusive with + * `address`. + */ + 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. + */ +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. + * Provide either an `address` (with an optional `name`) 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. + */ + from: Mailbox; + + /** + * (Optional) The mailbox to include in the `reply-to` header of the email. + */ + replyTo?: Mailbox; + + subject: string; +} & EmailContent; + +export class EmailApi extends AbstractAPI { + /** + * @experimental This method calls an internal, not-yet-public endpoint and is + * subject to change. It is advised to [pin](https://clerk.com/docs/pinning) + * the SDK version to avoid breaking changes. + * + * Sends a transactional email. + */ + public async create(params: CreateEmailParams) { + return this.request