Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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