Skip to content

Commit f42aad9

Browse files
feat(backend): transactional email send with user_id recipient (#9010)
Co-authored-by: Robert Soriano <[email protected]>
1 parent d4bafb4 commit f42aad9

6 files changed

Lines changed: 277 additions & 0 deletions

File tree

‎.changeset/clerk-email-send.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@clerk/backend': minor
3+
---
4+
5+
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.
6+
7+
This method is marked `@experimental` and may change in a future release.
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { http, HttpResponse } from 'msw';
2+
import { describe, expect, it } from 'vitest';
3+
4+
import { server, validateHeaders } from '../../mock-server';
5+
import { createBackendApiClient } from '../factory';
6+
7+
describe('EmailApi', () => {
8+
const apiClient = createBackendApiClient({
9+
apiUrl: 'https://api.clerk.test',
10+
secretKey: 'deadbeef',
11+
});
12+
13+
const mockEmail = {
14+
object: 'email',
15+
id: 'ema_123',
16+
slug: null,
17+
from_email_name: 'noreply',
18+
reply_to_email_name: null,
19+
to_email_address: '[email protected]',
20+
email_address_id: null,
21+
user_id: null,
22+
subject: 'Hello',
23+
body: '<p>hi</p>',
24+
body_plain: null,
25+
status: 'queued',
26+
data: null,
27+
delivered_by_clerk: true,
28+
};
29+
30+
it('sends a transactional email and snake_cases the body', async () => {
31+
server.use(
32+
http.post(
33+
'https://api.clerk.test/v1/email',
34+
validateHeaders(async ({ request }) => {
35+
const body = await request.json();
36+
expect(body).toEqual({
37+
to: { address: '[email protected]' },
38+
from: { address: '[email protected]' },
39+
reply_to: { address: '[email protected]' },
40+
subject: 'Hello',
41+
html: '<p>hi</p>',
42+
});
43+
return HttpResponse.json(mockEmail);
44+
}),
45+
),
46+
);
47+
48+
const response = await apiClient.emails.create({
49+
to: { address: '[email protected]' },
50+
from: { address: '[email protected]' },
51+
replyTo: { address: '[email protected]' },
52+
subject: 'Hello',
53+
html: '<p>hi</p>',
54+
});
55+
56+
expect(response.id).toBe('ema_123');
57+
expect(response.toEmailAddress).toBe('[email protected]');
58+
expect(response.status).toBe('queued');
59+
expect(response.deliveredByClerk).toBe(true);
60+
});
61+
62+
it('sends a transactional email with a text body', async () => {
63+
server.use(
64+
http.post(
65+
'https://api.clerk.test/v1/email',
66+
validateHeaders(async ({ request }) => {
67+
const body = await request.json();
68+
expect(body).toEqual({
69+
to: { address: '[email protected]' },
70+
from: { address: '[email protected]' },
71+
subject: 'Hello',
72+
text: 'hi',
73+
});
74+
return HttpResponse.json({
75+
...mockEmail,
76+
body: null,
77+
body_plain: 'hi',
78+
});
79+
}),
80+
),
81+
);
82+
83+
const response = await apiClient.emails.create({
84+
to: { address: '[email protected]' },
85+
from: { address: '[email protected]' },
86+
subject: 'Hello',
87+
text: 'hi',
88+
});
89+
90+
expect(response.id).toBe('ema_123');
91+
expect(response.body).toBeNull();
92+
expect(response.bodyPlain).toBe('hi');
93+
expect(response.status).toBe('queued');
94+
});
95+
96+
it('sends a transactional email addressed by userId', async () => {
97+
server.use(
98+
http.post(
99+
'https://api.clerk.test/v1/email',
100+
validateHeaders(async ({ request }) => {
101+
const body = await request.json();
102+
// The nested `userId` must be snake_cased to `user_id` on the wire.
103+
expect(body).toEqual({
104+
to: { user_id: 'user_123' },
105+
from: { address: '[email protected]' },
106+
subject: 'Hello',
107+
html: '<p>hi</p>',
108+
});
109+
return HttpResponse.json({
110+
...mockEmail,
111+
to_email_address: '[email protected]',
112+
email_address_id: 'idn_123',
113+
user_id: 'user_123',
114+
});
115+
}),
116+
),
117+
);
118+
119+
const response = await apiClient.emails.create({
120+
to: { userId: 'user_123' },
121+
from: { address: '[email protected]' },
122+
subject: 'Hello',
123+
html: '<p>hi</p>',
124+
});
125+
126+
expect(response.toEmailAddress).toBe('[email protected]');
127+
expect(response.emailAddressId).toBe('idn_123');
128+
expect(response.userId).toBe('user_123');
129+
});
130+
});
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import type { Email } from '../resources/Email';
2+
import { AbstractAPI } from './AbstractApi';
3+
4+
const basePath = '/email';
5+
6+
/**
7+
* A subset of mailbox object as specified in RFC 5322 §3.4. Specifically, a
8+
* `name-addr` with an optional `display-name` and a required `addr-spec`.
9+
*
10+
* @see {@link https://datatracker.ietf.org/doc/html/rfc5322#section-3.4}
11+
*/
12+
type Mailbox = {
13+
/**
14+
* (Optional) Display name for the mailbox. Currently accepted by the API but
15+
* not yet rendered server-side, so it has no effect on the delivered email
16+
* for now.
17+
*/
18+
name?: string;
19+
20+
/**
21+
* The `addr-spec` of the mailbox, i.e. the email address itself.
22+
*/
23+
address: string;
24+
};
25+
26+
/**
27+
* The recipient of the email. Provide exactly one of the two mutually exclusive
28+
* forms:
29+
*
30+
* - a literal mailbox: an `address` (plus an optional `name`), or
31+
* - a `userId`: the ID of a Clerk user whose primary email address Clerk
32+
* resolves server-side, from the instance the secret key belongs to.
33+
*/
34+
type EmailRecipient =
35+
| {
36+
/**
37+
* The `addr-spec` of the recipient mailbox, i.e. the email address itself.
38+
*/
39+
address: string;
40+
/**
41+
* (Optional) Display name for the recipient mailbox. Currently accepted
42+
* by the API but not yet rendered server-side.
43+
*/
44+
name?: string;
45+
userId?: never;
46+
}
47+
| {
48+
/**
49+
* The ID of the Clerk user to send to. Clerk resolves the user's primary
50+
* email address from the instance context. Mutually exclusive with
51+
* `address`.
52+
*/
53+
userId: string;
54+
address?: never;
55+
name?: never;
56+
};
57+
58+
/**
59+
* The body of the email. At least one of `html` and `text` must be provided; if
60+
* both are provided, the `html` version takes precedence. Encoded as a union so
61+
* that omitting both is a compile-time error rather than a server-side one.
62+
*/
63+
type EmailContent =
64+
| {
65+
/**
66+
* The HTML body of the email. Takes precedence over `text` when both are
67+
* provided.
68+
*/
69+
html: string;
70+
/**
71+
* (Optional) The plain text body of the email.
72+
*/
73+
text?: string;
74+
}
75+
| {
76+
/**
77+
* (Optional) The HTML body of the email. Takes precedence over `text`
78+
* when both are provided.
79+
*/
80+
html?: string;
81+
/**
82+
* The plain text body of the email.
83+
*/
84+
text: string;
85+
};
86+
87+
export type CreateEmailParams = {
88+
/**
89+
* The recipient of the email. Currently only a single recipient is supported.
90+
* Provide either an `address` (with an optional `name`) or the `userId` of a
91+
* Clerk user; the two forms are mutually exclusive.
92+
*/
93+
to: EmailRecipient;
94+
95+
/**
96+
* The sender of the email. See {@link Mailbox} for the accepted format. Note
97+
* that the API does not yet render the `name` field of the `from` mailbox.
98+
*/
99+
from: Mailbox;
100+
101+
/**
102+
* (Optional) The mailbox to include in the `reply-to` header of the email.
103+
*/
104+
replyTo?: Mailbox;
105+
106+
subject: string;
107+
} & EmailContent;
108+
109+
export class EmailApi extends AbstractAPI {
110+
/**
111+
* @experimental This method calls an internal, not-yet-public endpoint and is
112+
* subject to change. It is advised to [pin](https://clerk.com/docs/pinning)
113+
* the SDK version to avoid breaking changes.
114+
*
115+
* Sends a transactional email.
116+
*/
117+
public async create(params: CreateEmailParams) {
118+
return this.request<Email>({
119+
method: 'POST',
120+
path: basePath,
121+
bodyParams: params,
122+
options: {
123+
// Snakecase nested keys too, so a `to: { userId }` recipient is sent as
124+
// `to: { user_id }` on the wire (the default only snakecases top-level
125+
// keys, which would leave the nested `userId` untouched).
126+
deepSnakecaseBodyParamKeys: true,
127+
},
128+
});
129+
}
130+
}

‎packages/backend/src/api/endpoints/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export * from './BlocklistIdentifierApi';
99
export * from './ClientApi';
1010
export * from './DomainApi';
1111
export * from './EmailAddressApi';
12+
export * from './EmailApi';
1213
export * from './EnterpriseConnectionApi';
1314
export * from './IdPOAuthAccessTokenApi';
1415
export * from './InstanceApi';

‎packages/backend/src/api/factory.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
ClientAPI,
1010
DomainAPI,
1111
EmailAddressAPI,
12+
EmailApi,
1213
EnterpriseConnectionAPI,
1314
IdPOAuthAccessTokenApi,
1415
InstanceAPI,
@@ -71,6 +72,12 @@ export function createBackendApiClient(options: CreateBackendApiOptions) {
7172
clients: new ClientAPI(request),
7273
domains: new DomainAPI(request),
7374
emailAddresses: new EmailAddressAPI(request),
75+
/**
76+
* @experimental This calls an internal, not-yet-public endpoint for sending
77+
* transactional emails and is subject to change. It is advised to
78+
* [pin](https://clerk.com/docs/pinning) the SDK version to avoid breaking changes.
79+
*/
80+
emails: new EmailApi(request),
7481
enterpriseConnections: new EnterpriseConnectionAPI(request),
7582
idPOAuthAccessToken: new IdPOAuthAccessTokenApi(
7683
buildRequest({

‎packages/backend/src/api/resources/Email.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export class Email {
1313
readonly slug?: string | null,
1414
readonly data?: Record<string, any> | null,
1515
readonly deliveredByClerk?: boolean,
16+
readonly userId?: string | null,
1617
) {}
1718

1819
static fromJSON(data: EmailJSON): Email {
@@ -28,6 +29,7 @@ export class Email {
2829
data.slug,
2930
data.data,
3031
data.delivered_by_clerk,
32+
data.user_id,
3133
);
3234
}
3335
}

0 commit comments

Comments
 (0)