From 5d2dd91e299a0ab6db4e8d543a7c58fc50e77648 Mon Sep 17 00:00:00 2001 From: Serge <8313760+cbnsndwch@users.noreply.github.com> Date: Thu, 18 Jun 2026 20:03:48 +0000 Subject: [PATCH 1/6] feat(backend): add experimental emails.create for sending transactional email --- .changeset/clerk-email-send.md | 5 ++ .../src/api/__tests__/EmailApi.test.ts | 61 +++++++++++++++++++ .../backend/src/api/endpoints/EmailApi.ts | 41 +++++++++++++ packages/backend/src/api/endpoints/index.ts | 1 + packages/backend/src/api/factory.ts | 7 +++ 5 files changed, 115 insertions(+) create mode 100644 .changeset/clerk-email-send.md create mode 100644 packages/backend/src/api/__tests__/EmailApi.test.ts create mode 100644 packages/backend/src/api/endpoints/EmailApi.ts diff --git a/.changeset/clerk-email-send.md b/.changeset/clerk-email-send.md new file mode 100644 index 00000000000..28838558b01 --- /dev/null +++ b/.changeset/clerk-email-send.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': minor +--- + +Add an experimental `clerkClient.emails.create()` method for sending a transactional email through the Clerk Backend API. Accepts `to`, `from`, optional `replyTo`, `subject`, and `html`/`text` content and returns the created `Email` resource. The underlying endpoint is internal and not yet generally available, so the method is marked `@experimental` and is subject to change; pin your SDK version if you rely on it. 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..012c5ef5ec0 --- /dev/null +++ b/packages/backend/src/api/__tests__/EmailApi.test.ts @@ -0,0 +1,61 @@ +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); + }); +}); diff --git a/packages/backend/src/api/endpoints/EmailApi.ts b/packages/backend/src/api/endpoints/EmailApi.ts new file mode 100644 index 00000000000..3f182e01665 --- /dev/null +++ b/packages/backend/src/api/endpoints/EmailApi.ts @@ -0,0 +1,41 @@ +import type { Email } from '../resources/Email'; +import { AbstractAPI } from './AbstractApi'; + +const basePath = '/email'; + +type Mailbox = { + /** + * 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; + address: string; +}; + +export type CreateEmailParams = { + to: Mailbox; + from: Mailbox; + // Top-level camelCase keys are snake_cased automatically, so `replyTo` is + // sent as `reply_to`. + replyTo?: Mailbox; + subject: string; + html?: string; + text?: string; +}; + +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, + }); + } +} 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({ From 7519207e8dec51cb5ce6476c467d8c40e21d9b7d Mon Sep 17 00:00:00 2001 From: cbnsndwch <8313760+cbnsndwch@users.noreply.github.com> Date: Thu, 18 Jun 2026 16:41:45 -0400 Subject: [PATCH 2/6] feat(backend): add experimental emails.create for sending transactional email --- .../backend/src/api/endpoints/EmailApi.ts | 40 +++++++++++++++++-- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/api/endpoints/EmailApi.ts b/packages/backend/src/api/endpoints/EmailApi.ts index 3f182e01665..b53ec6f33fe 100644 --- a/packages/backend/src/api/endpoints/EmailApi.ts +++ b/packages/backend/src/api/endpoints/EmailApi.ts @@ -3,23 +3,55 @@ 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 = { /** - * 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. + * (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; }; export type CreateEmailParams = { + /** + * The recipient of the email. Currently only a single recipient is supported. + */ to: Mailbox; + + /** + * 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; - // Top-level camelCase keys are snake_cased automatically, so `replyTo` is - // sent as `reply_to`. + + /** + * (Optional) The mailbox to include in the `reply-to` header of the email. + */ replyTo?: Mailbox; + subject: string; + + /** + * The HTML body of the email. At least one of `html` and `text` must be + * provided. If both are provided, the `html` version will take precedence. + */ html?: string; + + /** + * The plain text body of the email. At least one of `html` and `text` must be + * provided. If both are provided, the `html` version will take precedence. + */ text?: string; }; From 76aaebf7e78235c959061d30ca7851310250c91d Mon Sep 17 00:00:00 2001 From: cbnsndwch <8313760+cbnsndwch@users.noreply.github.com> Date: Thu, 25 Jun 2026 18:32:52 +0000 Subject: [PATCH 3/6] feat(backend): support user_id recipient in emails.create --- .changeset/clerk-email-send.md | 2 +- .../src/api/__tests__/EmailApi.test.ts | 35 ++++++++++++++++ .../backend/src/api/endpoints/EmailApi.ts | 42 ++++++++++++++++++- packages/backend/src/api/resources/Email.ts | 2 + 4 files changed, 79 insertions(+), 2 deletions(-) diff --git a/.changeset/clerk-email-send.md b/.changeset/clerk-email-send.md index 28838558b01..2c500d23aba 100644 --- a/.changeset/clerk-email-send.md +++ b/.changeset/clerk-email-send.md @@ -2,4 +2,4 @@ '@clerk/backend': minor --- -Add an experimental `clerkClient.emails.create()` method for sending a transactional email through the Clerk Backend API. Accepts `to`, `from`, optional `replyTo`, `subject`, and `html`/`text` content and returns the created `Email` resource. The underlying endpoint is internal and not yet generally available, so the method is marked `@experimental` and is subject to change; pin your SDK version if you rely on it. +Add an experimental `clerkClient.emails.create()` method for sending a transactional email through the Clerk Backend API. Accepts `to`, `from`, optional `replyTo`, `subject`, and `html`/`text` content and returns the created `Email` resource. The `to` recipient is mutually exclusive: pass either `{ address }` (with an optional `name`) or `{ userId }`, in which case Clerk resolves that user's primary email address server-side. The underlying endpoint is internal and not yet generally available, so the method is marked `@experimental` and is subject to change; pin your SDK version if you rely on it. diff --git a/packages/backend/src/api/__tests__/EmailApi.test.ts b/packages/backend/src/api/__tests__/EmailApi.test.ts index 012c5ef5ec0..f78154917e0 100644 --- a/packages/backend/src/api/__tests__/EmailApi.test.ts +++ b/packages/backend/src/api/__tests__/EmailApi.test.ts @@ -58,4 +58,39 @@ describe('EmailApi', () => { expect(response.status).toBe('queued'); expect(response.deliveredByClerk).toBe(true); }); + + 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 index b53ec6f33fe..a4f8d35d18b 100644 --- a/packages/backend/src/api/endpoints/EmailApi.ts +++ b/packages/backend/src/api/endpoints/EmailApi.ts @@ -23,11 +23,45 @@ type Mailbox = { 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; + }; + 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: Mailbox; + to: EmailRecipient; /** * The sender of the email. See {@link Mailbox} for the accepted format. Note @@ -68,6 +102,12 @@ export class EmailApi extends AbstractAPI { 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/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, ); } } From 01ba8cb4cc84ef5517132387362bc452957389f2 Mon Sep 17 00:00:00 2001 From: Serge the Lion <8313760+cbnsndwch@users.noreply.github.com> Date: Thu, 25 Jun 2026 21:35:34 -0400 Subject: [PATCH 4/6] fix(backend): require html or text in emails.create at the type level (#9011) --- .changeset/email-cr-fixes.md | 2 + .../src/api/__tests__/EmailApi.test.ts | 34 +++++++++++++++ .../backend/src/api/endpoints/EmailApi.ts | 43 +++++++++++++------ 3 files changed, 66 insertions(+), 13 deletions(-) create mode 100644 .changeset/email-cr-fixes.md diff --git a/.changeset/email-cr-fixes.md b/.changeset/email-cr-fixes.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/email-cr-fixes.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/backend/src/api/__tests__/EmailApi.test.ts b/packages/backend/src/api/__tests__/EmailApi.test.ts index f78154917e0..c02a8db753b 100644 --- a/packages/backend/src/api/__tests__/EmailApi.test.ts +++ b/packages/backend/src/api/__tests__/EmailApi.test.ts @@ -59,6 +59,40 @@ describe('EmailApi', () => { 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( diff --git a/packages/backend/src/api/endpoints/EmailApi.ts b/packages/backend/src/api/endpoints/EmailApi.ts index a4f8d35d18b..16a7960effb 100644 --- a/packages/backend/src/api/endpoints/EmailApi.ts +++ b/packages/backend/src/api/endpoints/EmailApi.ts @@ -55,6 +55,35 @@ type EmailRecipient = 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. @@ -75,19 +104,7 @@ export type CreateEmailParams = { replyTo?: Mailbox; subject: string; - - /** - * The HTML body of the email. At least one of `html` and `text` must be - * provided. If both are provided, the `html` version will take precedence. - */ - html?: string; - - /** - * The plain text body of the email. At least one of `html` and `text` must be - * provided. If both are provided, the `html` version will take precedence. - */ - text?: string; -}; +} & EmailContent; export class EmailApi extends AbstractAPI { /** From 51ca7ab1d17c0522765941778bbdc42010592eef Mon Sep 17 00:00:00 2001 From: Serge the Lion <8313760+cbnsndwch@users.noreply.github.com> Date: Thu, 25 Jun 2026 23:38:53 -0400 Subject: [PATCH 5/6] Update changeset Co-authored-by: Robert Soriano --- .changeset/clerk-email-send.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/clerk-email-send.md b/.changeset/clerk-email-send.md index 2c500d23aba..7569e92c59c 100644 --- a/.changeset/clerk-email-send.md +++ b/.changeset/clerk-email-send.md @@ -2,4 +2,6 @@ '@clerk/backend': minor --- -Add an experimental `clerkClient.emails.create()` method for sending a transactional email through the Clerk Backend API. Accepts `to`, `from`, optional `replyTo`, `subject`, and `html`/`text` content and returns the created `Email` resource. The `to` recipient is mutually exclusive: pass either `{ address }` (with an optional `name`) or `{ userId }`, in which case Clerk resolves that user's primary email address server-side. The underlying endpoint is internal and not yet generally available, so the method is marked `@experimental` and is subject to change; pin your SDK version if you rely on it. +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. From c00e980147772796ce140ede1a6e16c9c229edb1 Mon Sep 17 00:00:00 2001 From: cbnsndwch <8313760+cbnsndwch@users.noreply.github.com> Date: Fri, 26 Jun 2026 03:43:41 +0000 Subject: [PATCH 6/6] chore: delete changeset file from merged stacked pr --- .changeset/email-cr-fixes.md | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 .changeset/email-cr-fixes.md diff --git a/.changeset/email-cr-fixes.md b/.changeset/email-cr-fixes.md deleted file mode 100644 index a845151cc84..00000000000 --- a/.changeset/email-cr-fixes.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ----