Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/quiet-mails-arrive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': minor
---

Add experimental methods for sending and retrieving internal transactional emails, including Clerk suppression state and reasons.
102 changes: 102 additions & 0 deletions packages/backend/src/api/__tests__/EmailApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ describe('EmailApi', () => {
status: 'queued',
data: null,
delivered_by_clerk: true,
suppression_reason: null,
};

it('sends a transactional email and snake_cases the body', async () => {
Expand Down Expand Up @@ -59,6 +60,107 @@ describe('EmailApi', () => {
expect(response.deliveredByClerk).toBe(true);
});

it('sends an idempotency key without adding it to the body', async () => {
server.use(
http.post(
'https://api.clerk.test/v1/email',
validateHeaders(async ({ request }) => {
expect(request.headers.get('Idempotency-Key')).toBe('campaign-123-contact-456');
const body = await request.json();
expect(body).not.toHaveProperty('idempotency_key');
return HttpResponse.json(mockEmail);
}),
),
);

await apiClient.emails.create(
{
to: { address: 'admin@acme.com' },
from: { address: 'noreply@acme.com' },
subject: 'Hello',
html: '<p>hi</p>',
},
{ idempotencyKey: 'campaign-123-contact-456' },
);
});

it.each([
['an empty value', ''],
['a null value', null],
['a numeric value', 123],
['unsupported characters', 'campaign:123'],
['more than 255 characters', 'a'.repeat(256)],
])('rejects idempotency keys with %s before sending a request', async (_, idempotencyKey) => {
let requestCount = 0;
server.use(
http.post('https://api.clerk.test/v1/email', () => {
requestCount += 1;
return HttpResponse.json(mockEmail);
}),
);

await expect(
apiClient.emails.create(
{
to: { address: 'admin@acme.com' },
from: { address: 'noreply@acme.com' },
subject: 'Hello',
html: '<p>hi</p>',
},
// Exercise the runtime boundary that exists for JavaScript consumers.
{ idempotencyKey: idempotencyKey as string },
),
).rejects.toThrow('Idempotency key must contain only ASCII letters, digits, underscores, and hyphens');
expect(requestCount).toBe(0);
});

it('gets the stored provider-acceptance status', async () => {
server.use(
http.get(
'https://api.clerk.test/v1/email/ema_123',
validateHeaders(() => HttpResponse.json({ ...mockEmail, status: 'accepted' })),
),
);

const response = await apiClient.emails.get('ema_123');
expect(response.id).toBe('ema_123');
expect(response.status).toBe('accepted');
});

it('surfaces transactional suppression state', async () => {
server.use(
http.get(
'https://api.clerk.test/v1/email/ema_123',
validateHeaders(() =>
HttpResponse.json({
...mockEmail,
status: 'suppressed',
delivered_by_clerk: false,
suppression_reason: 'application_communication_lock',
}),
),
),
);

const response = await apiClient.emails.get('ema_123');
expect(response.status).toBe('suppressed');
expect(response.deliveredByClerk).toBe(false);
expect(response.suppressionReason).toBe('application_communication_lock');
});

it('rejects an empty email ID before sending a request', async () => {
let requestCount = 0;
server.use(
http.get('https://api.clerk.test/v1/email/:emailId', () => {
requestCount += 1;
return HttpResponse.json(mockEmail);
}),
);

await expect(apiClient.emails.get('')).rejects.toThrow('A valid resource ID is required.');
expect(requestCount).toBe(0);
});

it('sends a transactional email with a text body', async () => {
server.use(
http.post(
Expand Down
97 changes: 74 additions & 23 deletions packages/backend/src/api/endpoints/EmailApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,14 @@ import type { Email } from '../resources/Email';
import { AbstractAPI } from './AbstractApi';

const basePath = '/email';
const idempotencyKeyPattern = /^[a-zA-Z0-9_-]{1,255}$/;

/**
* 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`.
* A mailbox address as specified by RFC 5322's `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.
*/
Expand All @@ -27,7 +20,7 @@ type Mailbox = {
* 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 literal mailbox: an `address`, 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.
*/
Expand All @@ -37,11 +30,6 @@ 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;
}
| {
Expand All @@ -52,13 +40,13 @@ type EmailRecipient =
*/
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.
* both are provided, the `html` version takes precedence. Their combined UTF-8
* encoding is limited to 50,000 bytes. Encoded as a union so that omitting both
* is a compile-time error rather than a server-side one.
*/
type EmailContent =
| {
Expand Down Expand Up @@ -87,38 +75,81 @@ type EmailContent =
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
* Provide either an `address` 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.
* The sender of the email. Its domain must exactly match the instance's
* verified production sending domain.
*/
from: Mailbox;

/**
* (Optional) The mailbox to include in the `reply-to` header of the email.
* (Optional) The mailbox to include in the `reply-to` header. Its domain must
* exactly match the same verified production domain as `from`.
*/
replyTo?: Mailbox;

/** Maximum 998 characters. */
subject: string;
} & EmailContent;

export type CreateEmailOptions = {
/**
* Deduplicates retries of the same logical send. Reuse a key only when the
* recipient and content are identical; use one stable key per recipient when
* fanning out a batch. Clerk durably returns the original email for the same
* key and request, and returns a conflict if the key is reused with different
* parameters. Without a key, each call is a distinct send and the SDK does
* not retry an ambiguous POST. Keys may contain only ASCII letters, digits,
* underscores, and hyphens, up to 255 characters.
*/
idempotencyKey?: string;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};

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.
*
* @param params - The recipient, sender, subject, and content of the email.
* @param options - Optional request settings, including an idempotency key.
* @returns The stored email and its current send status.
* @throws If the idempotency key does not match the supported format.
* @example
* ```ts
* const email = await clerkClient.emails.create(
* {
* to: { address: 'customer@example.com' },
* from: { address: 'support@example.com' },
* subject: 'Your receipt',
* html: '<p>Thanks for your order.</p>',
* },
* { idempotencyKey: 'order_123_receipt' },
* );
* ```
*/
public async create(params: CreateEmailParams) {
public async create(params: CreateEmailParams, options: CreateEmailOptions = {}): Promise<Email> {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const { idempotencyKey } = options;
if (
idempotencyKey !== undefined &&
(typeof idempotencyKey !== 'string' || !idempotencyKeyPattern.test(idempotencyKey))
) {
throw new Error(
'Idempotency key must contain only ASCII letters, digits, underscores, and hyphens and cannot exceed 255 characters.',
);
}

return this.request<Email>({
method: 'POST',
path: basePath,
bodyParams: params,
...(idempotencyKey !== undefined ? { headerParams: { 'Idempotency-Key': idempotencyKey } } : {}),
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
Expand All @@ -127,4 +158,24 @@ export class EmailApi extends AbstractAPI {
},
});
}

/**
* Returns Clerk's stored send state for a transactional email. `accepted`
* means the provider accepted the request; it does not prove delivery.
*
* @param emailId - The ID returned when the email was created.
* @returns The stored email and its current send status.
* @throws If `emailId` is empty.
* @example
* ```ts
* const email = await clerkClient.emails.get('ema_123');
* ```
*/
public async get(emailId: string): Promise<Email> {
this.requireId(emailId);
return this.request<Email>({
method: 'GET',
path: `${basePath}/${emailId}`,
});
}
}
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 @@ -14,6 +14,7 @@ export class Email {
readonly data?: Record<string, any> | null,
readonly deliveredByClerk?: boolean,
readonly userId?: string | null,
readonly suppressionReason?: string | null,
) {}

static fromJSON(data: EmailJSON): Email {
Expand All @@ -30,6 +31,7 @@ export class Email {
data.data,
data.delivered_by_clerk,
data.user_id,
data.suppression_reason,
);
}
}
1 change: 1 addition & 0 deletions packages/backend/src/api/resources/JSON.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ export interface EmailJSON extends ClerkResourceJSON {
status?: string;
data?: Record<string, any> | null;
delivered_by_clerk: boolean;
suppression_reason?: string | null;
}

export interface EmailAddressJSON extends ClerkResourceJSON {
Expand Down
Loading