Skip to content
7 changes: 7 additions & 0 deletions .changeset/clerk-email-send.md
Original file line number Diff line number Diff line change
@@ -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.
130 changes: 130 additions & 0 deletions packages/backend/src/api/__tests__/EmailApi.test.ts
Original file line number Diff line number Diff line change
@@ -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: '<p>hi</p>',
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: '<p>hi</p>',
});
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: '<p>hi</p>',
});

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: '<p>hi</p>',
});
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: '<p>hi</p>',
});

expect(response.toEmailAddress).toBe('member@acme.com');
expect(response.emailAddressId).toBe('idn_123');
expect(response.userId).toBe('user_123');
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
130 changes: 130 additions & 0 deletions packages/backend/src/api/endpoints/EmailApi.ts
Original file line number Diff line number Diff line change
@@ -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<Email>({
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,
},
});
}
}
1 change: 1 addition & 0 deletions packages/backend/src/api/endpoints/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
7 changes: 7 additions & 0 deletions packages/backend/src/api/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
ClientAPI,
DomainAPI,
EmailAddressAPI,
EmailApi,
EnterpriseConnectionAPI,
IdPOAuthAccessTokenApi,
InstanceAPI,
Expand Down Expand Up @@ -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({
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/api/resources/Email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export class Email {
readonly slug?: string | null,
readonly data?: Record<string, any> | null,
readonly deliveredByClerk?: boolean,
readonly userId?: string | null,
) {}

static fromJSON(data: EmailJSON): Email {
Expand All @@ -28,6 +29,7 @@ export class Email {
data.slug,
data.data,
data.delivered_by_clerk,
data.user_id,
);
}
}
Loading