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
5 changes: 5 additions & 0 deletions .changeset/quiet-mails-arrive.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
5 changes: 5 additions & 0 deletions .changeset/quiet-mails-arrive.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
5 changes: 5 additions & 0 deletions .changeset/quiet-mails-arrive.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
5 changes: 5 additions & 0 deletions .changeset/quiet-mails-arrive.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
5 changes: 5 additions & 0 deletions .changeset/quiet-mails-arrive.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
5 changes: 5 additions & 0 deletions .changeset/quiet-mails-arrive.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
5 changes: 5 additions & 0 deletions .changeset/quiet-mails-arrive.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
5 changes: 5 additions & 0 deletions .changeset/quiet-mails-arrive.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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