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({ + method: 'POST', + path: basePath, + bodyParams: params, + 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, + }, + }); + } +} diff --git a/packages/backend/src/api/endpoints/index.ts b/packages/backend/src/api/endpoints/index.ts index ebbd1990b56..32e19b906f5 100644 --- a/packages/backend/src/api/endpoints/index.ts +++ b/packages/backend/src/api/endpoints/index.ts @@ -9,6 +9,7 @@ export * from './BlocklistIdentifierApi'; export * from './ClientApi'; export * from './DomainApi'; export * from './EmailAddressApi'; +export * from './EmailApi'; export * from './EnterpriseConnectionApi'; export * from './IdPOAuthAccessTokenApi'; export * from './InstanceApi'; diff --git a/packages/backend/src/api/factory.ts b/packages/backend/src/api/factory.ts index 1ad4d2fd236..c9b6f700f0f 100644 --- a/packages/backend/src/api/factory.ts +++ b/packages/backend/src/api/factory.ts @@ -9,6 +9,7 @@ import { ClientAPI, DomainAPI, EmailAddressAPI, + EmailApi, EnterpriseConnectionAPI, IdPOAuthAccessTokenApi, InstanceAPI, @@ -71,6 +72,12 @@ export function createBackendApiClient(options: CreateBackendApiOptions) { clients: new ClientAPI(request), domains: new DomainAPI(request), emailAddresses: new EmailAddressAPI(request), + /** + * @experimental This calls an internal, not-yet-public endpoint for sending + * transactional emails and is subject to change. It is advised to + * [pin](https://clerk.com/docs/pinning) the SDK version to avoid breaking changes. + */ + emails: new EmailApi(request), enterpriseConnections: new EnterpriseConnectionAPI(request), idPOAuthAccessToken: new IdPOAuthAccessTokenApi( buildRequest({ diff --git a/packages/backend/src/api/resources/Email.ts b/packages/backend/src/api/resources/Email.ts index 7e2cc8810e6..4258adaa6c1 100644 --- a/packages/backend/src/api/resources/Email.ts +++ b/packages/backend/src/api/resources/Email.ts @@ -13,6 +13,7 @@ export class Email { readonly slug?: string | null, readonly data?: Record | null, readonly deliveredByClerk?: boolean, + readonly userId?: string | null, ) {} static fromJSON(data: EmailJSON): Email { @@ -28,6 +29,7 @@ export class Email { data.slug, data.data, data.delivered_by_clerk, + data.user_id, ); } }