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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/clerk-email-send.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
---
'@clerk/backend': minor
---

Add an experimental `clerkClient.emails.create()` method for sending transactional emails. It accepts address- or user-based recipients, supports optional `replyTo`, `subject`, and HTML and/or text content, and returns the created `Email` resource.

This method is marked `@experimental` and may change in a future release.
130 changes: 130 additions & 0 deletions packages/backend/src/api/__tests__/EmailApi.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
import { http, HttpResponse } from 'msw';
import { describe, expect, it } from 'vitest';

import { server, validateHeaders } from '../../mock-server';
import { createBackendApiClient } from '../factory';

describe('EmailApi', () => {
const apiClient = createBackendApiClient({
apiUrl: 'https://api.clerk.test',
secretKey: 'deadbeef',
});

const mockEmail = {
object: 'email',
id: 'ema_123',
slug: null,
from_email_name: 'noreply',
reply_to_email_name: null,
to_email_address: 'admin@acme.com',
email_address_id: null,
user_id: null,
subject: 'Hello',
body: '<p>hi</p>',
body_plain: null,
status: 'queued',
data: null,
delivered_by_clerk: true,
};

it('sends a transactional email and snake_cases the body', async () => {
server.use(
http.post(
'https://api.clerk.test/v1/email',
validateHeaders(async ({ request }) => {
const body = await request.json();
expect(body).toEqual({
to: { address: 'admin@acme.com' },
from: { address: 'noreply@acme.com' },
reply_to: { address: 'support@acme.com' },
subject: 'Hello',
html: '<p>hi</p>',
});
return HttpResponse.json(mockEmail);
}),
),
);

const response = await apiClient.emails.create({
to: { address: 'admin@acme.com' },
from: { address: 'noreply@acme.com' },
replyTo: { address: 'support@acme.com' },
subject: 'Hello',
html: '<p>hi</p>',
});

expect(response.id).toBe('ema_123');
expect(response.toEmailAddress).toBe('admin@acme.com');
expect(response.status).toBe('queued');
expect(response.deliveredByClerk).toBe(true);
});

it('sends a transactional email with a text body', async () => {
server.use(
http.post(
'https://api.clerk.test/v1/email',
validateHeaders(async ({ request }) => {
const body = await request.json();
expect(body).toEqual({
to: { address: 'admin@acme.com' },
from: { address: 'noreply@acme.com' },
subject: 'Hello',
text: 'hi',
});
return HttpResponse.json({
...mockEmail,
body: null,
body_plain: 'hi',
});
}),
),
);

const response = await apiClient.emails.create({
to: { address: 'admin@acme.com' },
from: { address: 'noreply@acme.com' },
subject: 'Hello',
text: 'hi',
});

expect(response.id).toBe('ema_123');
expect(response.body).toBeNull();
expect(response.bodyPlain).toBe('hi');
expect(response.status).toBe('queued');
});

it('sends a transactional email addressed by userId', async () => {
server.use(
http.post(
'https://api.clerk.test/v1/email',
validateHeaders(async ({ request }) => {
const body = await request.json();
// The nested `userId` must be snake_cased to `user_id` on the wire.
expect(body).toEqual({
to: { user_id: 'user_123' },
from: { address: 'noreply@acme.com' },
subject: 'Hello',
html: '<p>hi</p>',
});
return HttpResponse.json({
...mockEmail,
to_email_address: 'member@acme.com',
email_address_id: 'idn_123',
user_id: 'user_123',
});
}),
),
);

const response = await apiClient.emails.create({
to: { userId: 'user_123' },
from: { address: 'noreply@acme.com' },
subject: 'Hello',
html: '<p>hi</p>',
});

expect(response.toEmailAddress).toBe('member@acme.com');
expect(response.emailAddressId).toBe('idn_123');
expect(response.userId).toBe('user_123');
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
130 changes: 130 additions & 0 deletions packages/backend/src/api/endpoints/EmailApi.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
import type { Email } from '../resources/Email';
import { AbstractAPI } from './AbstractApi';

const basePath = '/email';

/**
* A subset of mailbox object as specified in RFC 5322 §3.4. Specifically, a
* `name-addr` with an optional `display-name` and a required `addr-spec`.
*
* @see {@link https://datatracker.ietf.org/doc/html/rfc5322#section-3.4}
*/
type Mailbox = {
/**
* (Optional) Display name for the mailbox. Currently accepted by the API but
* not yet rendered server-side, so it has no effect on the delivered email
* for now.
*/
name?: string;

/**
* The `addr-spec` of the mailbox, i.e. the email address itself.
*/
address: string;
};

/**
* The recipient of the email. Provide exactly one of the two mutually exclusive
* forms:
*
* - a literal mailbox: an `address` (plus an optional `name`), or
* - a `userId`: the ID of a Clerk user whose primary email address Clerk
* resolves server-side, from the instance the secret key belongs to.
*/
type EmailRecipient =
| {
/**
* The `addr-spec` of the recipient mailbox, i.e. the email address itself.
*/
address: string;
/**
* (Optional) Display name for the recipient mailbox. Currently accepted
* by the API but not yet rendered server-side.
*/
name?: string;
userId?: never;
}
| {
/**
* The ID of the Clerk user to send to. Clerk resolves the user's primary
* email address from the instance context. Mutually exclusive with
* `address`.
*/
userId: string;
address?: never;
name?: never;
};

/**
* The body of the email. At least one of `html` and `text` must be provided; if
* both are provided, the `html` version takes precedence. Encoded as a union so
* that omitting both is a compile-time error rather than a server-side one.
*/
type EmailContent =
| {
/**
* The HTML body of the email. Takes precedence over `text` when both are
* provided.
*/
html: string;
/**
* (Optional) The plain text body of the email.
*/
text?: string;
}
| {
/**
* (Optional) The HTML body of the email. Takes precedence over `text`
* when both are provided.
*/
html?: string;
/**
* The plain text body of the email.
*/
text: string;
};

export type CreateEmailParams = {
/**
* The recipient of the email. Currently only a single recipient is supported.
* Provide either an `address` (with an optional `name`) or the `userId` of a
* Clerk user; the two forms are mutually exclusive.
*/
to: EmailRecipient;

/**
* The sender of the email. See {@link Mailbox} for the accepted format. Note
* that the API does not yet render the `name` field of the `from` mailbox.
*/
from: Mailbox;

/**
* (Optional) The mailbox to include in the `reply-to` header of the email.
*/
replyTo?: Mailbox;

subject: string;
} & EmailContent;

export class EmailApi extends AbstractAPI {
/**
* @experimental This method calls an internal, not-yet-public endpoint and is
* subject to change. It is advised to [pin](https://clerk.com/docs/pinning)
* the SDK version to avoid breaking changes.
*
* Sends a transactional email.
*/
public async create(params: CreateEmailParams) {
return this.request<Email>({
method: 'POST',
path: basePath,
bodyParams: params,
options: {
// Snakecase nested keys too, so a `to: { userId }` recipient is sent as
// `to: { user_id }` on the wire (the default only snakecases top-level
// keys, which would leave the nested `userId` untouched).
deepSnakecaseBodyParamKeys: true,
},
});
}
}
1 change: 1 addition & 0 deletions packages/backend/src/api/endpoints/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ export * from './BlocklistIdentifierApi';
export * from './ClientApi';
export * from './DomainApi';
export * from './EmailAddressApi';
export * from './EmailApi';
export * from './EnterpriseConnectionApi';
export * from './IdPOAuthAccessTokenApi';
export * from './InstanceApi';
Expand Down
7 changes: 7 additions & 0 deletions packages/backend/src/api/factory.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ import {
ClientAPI,
DomainAPI,
EmailAddressAPI,
EmailApi,
EnterpriseConnectionAPI,
IdPOAuthAccessTokenApi,
InstanceAPI,
Expand DownExpand Up@@ -71,6 +72,12 @@ export function createBackendApiClient(options: CreateBackendApiOptions) {
clients: new ClientAPI(request),
domains: new DomainAPI(request),
emailAddresses: new EmailAddressAPI(request),
/**
* @experimental This calls an internal, not-yet-public endpoint for sending
* transactional emails and is subject to change. It is advised to
* [pin](https://clerk.com/docs/pinning) the SDK version to avoid breaking changes.
*/
emails: new EmailApi(request),
enterpriseConnections: new EnterpriseConnectionAPI(request),
idPOAuthAccessToken: new IdPOAuthAccessTokenApi(
buildRequest({
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/api/resources/Email.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ export class Email {
readonly slug?: string | null,
readonly data?: Record<string, any> | null,
readonly deliveredByClerk?: boolean,
readonly userId?: string | null,
) {}

static fromJSON(data: EmailJSON): Email {
Expand All@@ -28,6 +29,7 @@ export class Email {
data.slug,
data.data,
data.delivered_by_clerk,
data.user_id,
);
}
}
Loading
, '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
7 changes: 7 additions & 0 deletions .changeset/clerk-email-send.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
---
'@clerk/backend': minor
---

Add an experimental `clerkClient.emails.create()` method for sending transactional emails. It accepts address- or user-based recipients, supports optional `replyTo`, `subject`, and HTML and/or text content, and returns the created `Email` resource.

This method is marked `@experimental` and may change in a future release.
130 changes: 130 additions & 0 deletions packages/backend/src/api/__tests__/EmailApi.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
import { http, HttpResponse } from 'msw';
import { describe, expect, it } from 'vitest';

import { server, validateHeaders } from '../../mock-server';
import { createBackendApiClient } from '../factory';

describe('EmailApi', () => {
const apiClient = createBackendApiClient({
apiUrl: 'https://api.clerk.test',
secretKey: 'deadbeef',
});

const mockEmail = {
object: 'email',
id: 'ema_123',
slug: null,
from_email_name: 'noreply',
reply_to_email_name: null,
to_email_address: 'admin@acme.com',
email_address_id: null,
user_id: null,
subject: 'Hello',
body: '<p>hi</p>',
body_plain: null,
status: 'queued',
data: null,
delivered_by_clerk: true,
};

it('sends a transactional email and snake_cases the body', async () => {
server.use(
http.post(
'https://api.clerk.test/v1/email',
validateHeaders(async ({ request }) => {
const body = await request.json();
expect(body).toEqual({
to: { address: 'admin@acme.com' },
from: { address: 'noreply@acme.com' },
reply_to: { address: 'support@acme.com' },
subject: 'Hello',
html: '<p>hi</p>',
});
return HttpResponse.json(mockEmail);
}),
),
);

const response = await apiClient.emails.create({
to: { address: 'admin@acme.com' },
from: { address: 'noreply@acme.com' },
replyTo: { address: 'support@acme.com' },
subject: 'Hello',
html: '<p>hi</p>',
});

expect(response.id).toBe('ema_123');
expect(response.toEmailAddress).toBe('admin@acme.com');
expect(response.status).toBe('queued');
expect(response.deliveredByClerk).toBe(true);
});

it('sends a transactional email with a text body', async () => {
server.use(
http.post(
'https://api.clerk.test/v1/email',
validateHeaders(async ({ request }) => {
const body = await request.json();
expect(body).toEqual({
to: { address: 'admin@acme.com' },
from: { address: 'noreply@acme.com' },
subject: 'Hello',
text: 'hi',
});
return HttpResponse.json({
...mockEmail,
body: null,
body_plain: 'hi',
});
}),
),
);

const response = await apiClient.emails.create({
to: { address: 'admin@acme.com' },
from: { address: 'noreply@acme.com' },
subject: 'Hello',
text: 'hi',
});

expect(response.id).toBe('ema_123');
expect(response.body).toBeNull();
expect(response.bodyPlain).toBe('hi');
expect(response.status).toBe('queued');
});

it('sends a transactional email addressed by userId', async () => {
server.use(
http.post(
'https://api.clerk.test/v1/email',
validateHeaders(async ({ request }) => {
const body = await request.json();
// The nested `userId` must be snake_cased to `user_id` on the wire.
expect(body).toEqual({
to: { user_id: 'user_123' },
from: { address: 'noreply@acme.com' },
subject: 'Hello',
html: '<p>hi</p>',
});
return HttpResponse.json({
...mockEmail,
to_email_address: 'member@acme.com',
email_address_id: 'idn_123',
user_id: 'user_123',
});
}),
),
);

const response = await apiClient.emails.create({
to: { userId: 'user_123' },
from: { address: 'noreply@acme.com' },
subject: 'Hello',
html: '<p>hi</p>',
});

expect(response.toEmailAddress).toBe('member@acme.com');
expect(response.emailAddressId).toBe('idn_123');
expect(response.userId).toBe('user_123');
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
130 changes: 130 additions & 0 deletions packages/backend/src/api/endpoints/EmailApi.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
import type { Email } from '../resources/Email';
import { AbstractAPI } from './AbstractApi';

const basePath = '/email';

/**
* A subset of mailbox object as specified in RFC 5322 §3.4. Specifically, a
* `name-addr` with an optional `display-name` and a required `addr-spec`.
*
* @see {@link https://datatracker.ietf.org/doc/html/rfc5322#section-3.4}
*/
type Mailbox = {
/**
* (Optional) Display name for the mailbox. Currently accepted by the API but
* not yet rendered server-side, so it has no effect on the delivered email
* for now.
*/
name?: string;

/**
* The `addr-spec` of the mailbox, i.e. the email address itself.
*/
address: string;
};

/**
* The recipient of the email. Provide exactly one of the two mutually exclusive
* forms:
*
* - a literal mailbox: an `address` (plus an optional `name`), or
* - a `userId`: the ID of a Clerk user whose primary email address Clerk
* resolves server-side, from the instance the secret key belongs to.
*/
type EmailRecipient =
| {
/**
* The `addr-spec` of the recipient mailbox, i.e. the email address itself.
*/
address: string;
/**
* (Optional) Display name for the recipient mailbox. Currently accepted
* by the API but not yet rendered server-side.
*/
name?: string;
userId?: never;
}
| {
/**
* The ID of the Clerk user to send to. Clerk resolves the user's primary
* email address from the instance context. Mutually exclusive with
* `address`.
*/
userId: string;
address?: never;
name?: never;
};

/**
* The body of the email. At least one of `html` and `text` must be provided; if
* both are provided, the `html` version takes precedence. Encoded as a union so
* that omitting both is a compile-time error rather than a server-side one.
*/
type EmailContent =
| {
/**
* The HTML body of the email. Takes precedence over `text` when both are
* provided.
*/
html: string;
/**
* (Optional) The plain text body of the email.
*/
text?: string;
}
| {
/**
* (Optional) The HTML body of the email. Takes precedence over `text`
* when both are provided.
*/
html?: string;
/**
* The plain text body of the email.
*/
text: string;
};

export type CreateEmailParams = {
/**
* The recipient of the email. Currently only a single recipient is supported.
* Provide either an `address` (with an optional `name`) or the `userId` of a
* Clerk user; the two forms are mutually exclusive.
*/
to: EmailRecipient;

/**
* The sender of the email. See {@link Mailbox} for the accepted format. Note
* that the API does not yet render the `name` field of the `from` mailbox.
*/
from: Mailbox;

/**
* (Optional) The mailbox to include in the `reply-to` header of the email.
*/
replyTo?: Mailbox;

subject: string;
} & EmailContent;

export class EmailApi extends AbstractAPI {
/**
* @experimental This method calls an internal, not-yet-public endpoint and is
* subject to change. It is advised to [pin](https://clerk.com/docs/pinning)
* the SDK version to avoid breaking changes.
*
* Sends a transactional email.
*/
public async create(params: CreateEmailParams) {
return this.request<Email>({
method: 'POST',
path: basePath,
bodyParams: params,
options: {
// Snakecase nested keys too, so a `to: { userId }` recipient is sent as
// `to: { user_id }` on the wire (the default only snakecases top-level
// keys, which would leave the nested `userId` untouched).
deepSnakecaseBodyParamKeys: true,
},
});
}
}
1 change: 1 addition & 0 deletions packages/backend/src/api/endpoints/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ export * from './BlocklistIdentifierApi';
export * from './ClientApi';
export * from './DomainApi';
export * from './EmailAddressApi';
export * from './EmailApi';
export * from './EnterpriseConnectionApi';
export * from './IdPOAuthAccessTokenApi';
export * from './InstanceApi';
Expand Down
7 changes: 7 additions & 0 deletions packages/backend/src/api/factory.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ import {
ClientAPI,
DomainAPI,
EmailAddressAPI,
EmailApi,
EnterpriseConnectionAPI,
IdPOAuthAccessTokenApi,
InstanceAPI,
Expand DownExpand Up@@ -71,6 +72,12 @@ export function createBackendApiClient(options: CreateBackendApiOptions) {
clients: new ClientAPI(request),
domains: new DomainAPI(request),
emailAddresses: new EmailAddressAPI(request),
/**
* @experimental This calls an internal, not-yet-public endpoint for sending
* transactional emails and is subject to change. It is advised to
* [pin](https://clerk.com/docs/pinning) the SDK version to avoid breaking changes.
*/
emails: new EmailApi(request),
enterpriseConnections: new EnterpriseConnectionAPI(request),
idPOAuthAccessToken: new IdPOAuthAccessTokenApi(
buildRequest({
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/api/resources/Email.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ export class Email {
readonly slug?: string | null,
readonly data?: Record<string, any> | null,
readonly deliveredByClerk?: boolean,
readonly userId?: string | null,
) {}

static fromJSON(data: EmailJSON): Email {
Expand All@@ -28,6 +29,7 @@ export class Email {
data.slug,
data.data,
data.delivered_by_clerk,
data.user_id,
);
}
}
Loading
, '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
7 changes: 7 additions & 0 deletions .changeset/clerk-email-send.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
---
'@clerk/backend': minor
---

Add an experimental `clerkClient.emails.create()` method for sending transactional emails. It accepts address- or user-based recipients, supports optional `replyTo`, `subject`, and HTML and/or text content, and returns the created `Email` resource.

This method is marked `@experimental` and may change in a future release.
130 changes: 130 additions & 0 deletions packages/backend/src/api/__tests__/EmailApi.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
import { http, HttpResponse } from 'msw';
import { describe, expect, it } from 'vitest';

import { server, validateHeaders } from '../../mock-server';
import { createBackendApiClient } from '../factory';

describe('EmailApi', () => {
const apiClient = createBackendApiClient({
apiUrl: 'https://api.clerk.test',
secretKey: 'deadbeef',
});

const mockEmail = {
object: 'email',
id: 'ema_123',
slug: null,
from_email_name: 'noreply',
reply_to_email_name: null,
to_email_address: 'admin@acme.com',
email_address_id: null,
user_id: null,
subject: 'Hello',
body: '<p>hi</p>',
body_plain: null,
status: 'queued',
data: null,
delivered_by_clerk: true,
};

it('sends a transactional email and snake_cases the body', async () => {
server.use(
http.post(
'https://api.clerk.test/v1/email',
validateHeaders(async ({ request }) => {
const body = await request.json();
expect(body).toEqual({
to: { address: 'admin@acme.com' },
from: { address: 'noreply@acme.com' },
reply_to: { address: 'support@acme.com' },
subject: 'Hello',
html: '<p>hi</p>',
});
return HttpResponse.json(mockEmail);
}),
),
);

const response = await apiClient.emails.create({
to: { address: 'admin@acme.com' },
from: { address: 'noreply@acme.com' },
replyTo: { address: 'support@acme.com' },
subject: 'Hello',
html: '<p>hi</p>',
});

expect(response.id).toBe('ema_123');
expect(response.toEmailAddress).toBe('admin@acme.com');
expect(response.status).toBe('queued');
expect(response.deliveredByClerk).toBe(true);
});

it('sends a transactional email with a text body', async () => {
server.use(
http.post(
'https://api.clerk.test/v1/email',
validateHeaders(async ({ request }) => {
const body = await request.json();
expect(body).toEqual({
to: { address: 'admin@acme.com' },
from: { address: 'noreply@acme.com' },
subject: 'Hello',
text: 'hi',
});
return HttpResponse.json({
...mockEmail,
body: null,
body_plain: 'hi',
});
}),
),
);

const response = await apiClient.emails.create({
to: { address: 'admin@acme.com' },
from: { address: 'noreply@acme.com' },
subject: 'Hello',
text: 'hi',
});

expect(response.id).toBe('ema_123');
expect(response.body).toBeNull();
expect(response.bodyPlain).toBe('hi');
expect(response.status).toBe('queued');
});

it('sends a transactional email addressed by userId', async () => {
server.use(
http.post(
'https://api.clerk.test/v1/email',
validateHeaders(async ({ request }) => {
const body = await request.json();
// The nested `userId` must be snake_cased to `user_id` on the wire.
expect(body).toEqual({
to: { user_id: 'user_123' },
from: { address: 'noreply@acme.com' },
subject: 'Hello',
html: '<p>hi</p>',
});
return HttpResponse.json({
...mockEmail,
to_email_address: 'member@acme.com',
email_address_id: 'idn_123',
user_id: 'user_123',
});
}),
),
);

const response = await apiClient.emails.create({
to: { userId: 'user_123' },
from: { address: 'noreply@acme.com' },
subject: 'Hello',
html: '<p>hi</p>',
});

expect(response.toEmailAddress).toBe('member@acme.com');
expect(response.emailAddressId).toBe('idn_123');
expect(response.userId).toBe('user_123');
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
130 changes: 130 additions & 0 deletions packages/backend/src/api/endpoints/EmailApi.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
import type { Email } from '../resources/Email';
import { AbstractAPI } from './AbstractApi';

const basePath = '/email';

/**
* A subset of mailbox object as specified in RFC 5322 §3.4. Specifically, a
* `name-addr` with an optional `display-name` and a required `addr-spec`.
*
* @see {@link https://datatracker.ietf.org/doc/html/rfc5322#section-3.4}
*/
type Mailbox = {
/**
* (Optional) Display name for the mailbox. Currently accepted by the API but
* not yet rendered server-side, so it has no effect on the delivered email
* for now.
*/
name?: string;

/**
* The `addr-spec` of the mailbox, i.e. the email address itself.
*/
address: string;
};

/**
* The recipient of the email. Provide exactly one of the two mutually exclusive
* forms:
*
* - a literal mailbox: an `address` (plus an optional `name`), or
* - a `userId`: the ID of a Clerk user whose primary email address Clerk
* resolves server-side, from the instance the secret key belongs to.
*/
type EmailRecipient =
| {
/**
* The `addr-spec` of the recipient mailbox, i.e. the email address itself.
*/
address: string;
/**
* (Optional) Display name for the recipient mailbox. Currently accepted
* by the API but not yet rendered server-side.
*/
name?: string;
userId?: never;
}
| {
/**
* The ID of the Clerk user to send to. Clerk resolves the user's primary
* email address from the instance context. Mutually exclusive with
* `address`.
*/
userId: string;
address?: never;
name?: never;
};

/**
* The body of the email. At least one of `html` and `text` must be provided; if
* both are provided, the `html` version takes precedence. Encoded as a union so
* that omitting both is a compile-time error rather than a server-side one.
*/
type EmailContent =
| {
/**
* The HTML body of the email. Takes precedence over `text` when both are
* provided.
*/
html: string;
/**
* (Optional) The plain text body of the email.
*/
text?: string;
}
| {
/**
* (Optional) The HTML body of the email. Takes precedence over `text`
* when both are provided.
*/
html?: string;
/**
* The plain text body of the email.
*/
text: string;
};

export type CreateEmailParams = {
/**
* The recipient of the email. Currently only a single recipient is supported.
* Provide either an `address` (with an optional `name`) or the `userId` of a
* Clerk user; the two forms are mutually exclusive.
*/
to: EmailRecipient;

/**
* The sender of the email. See {@link Mailbox} for the accepted format. Note
* that the API does not yet render the `name` field of the `from` mailbox.
*/
from: Mailbox;

/**
* (Optional) The mailbox to include in the `reply-to` header of the email.
*/
replyTo?: Mailbox;

subject: string;
} & EmailContent;

export class EmailApi extends AbstractAPI {
/**
* @experimental This method calls an internal, not-yet-public endpoint and is
* subject to change. It is advised to [pin](https://clerk.com/docs/pinning)
* the SDK version to avoid breaking changes.
*
* Sends a transactional email.
*/
public async create(params: CreateEmailParams) {
return this.request<Email>({
method: 'POST',
path: basePath,
bodyParams: params,
options: {
// Snakecase nested keys too, so a `to: { userId }` recipient is sent as
// `to: { user_id }` on the wire (the default only snakecases top-level
// keys, which would leave the nested `userId` untouched).
deepSnakecaseBodyParamKeys: true,
},
});
}
}
1 change: 1 addition & 0 deletions packages/backend/src/api/endpoints/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ export * from './BlocklistIdentifierApi';
export * from './ClientApi';
export * from './DomainApi';
export * from './EmailAddressApi';
export * from './EmailApi';
export * from './EnterpriseConnectionApi';
export * from './IdPOAuthAccessTokenApi';
export * from './InstanceApi';
Expand Down
7 changes: 7 additions & 0 deletions packages/backend/src/api/factory.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ import {
ClientAPI,
DomainAPI,
EmailAddressAPI,
EmailApi,
EnterpriseConnectionAPI,
IdPOAuthAccessTokenApi,
InstanceAPI,
Expand DownExpand Up@@ -71,6 +72,12 @@ export function createBackendApiClient(options: CreateBackendApiOptions) {
clients: new ClientAPI(request),
domains: new DomainAPI(request),
emailAddresses: new EmailAddressAPI(request),
/**
* @experimental This calls an internal, not-yet-public endpoint for sending
* transactional emails and is subject to change. It is advised to
* [pin](https://clerk.com/docs/pinning) the SDK version to avoid breaking changes.
*/
emails: new EmailApi(request),
enterpriseConnections: new EnterpriseConnectionAPI(request),
idPOAuthAccessToken: new IdPOAuthAccessTokenApi(
buildRequest({
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/api/resources/Email.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ export class Email {
readonly slug?: string | null,
readonly data?: Record<string, any> | null,
readonly deliveredByClerk?: boolean,
readonly userId?: string | null,
) {}

static fromJSON(data: EmailJSON): Email {
Expand All@@ -28,6 +29,7 @@ export class Email {
data.slug,
data.data,
data.delivered_by_clerk,
data.user_id,
);
}
}
Loading
, '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
7 changes: 7 additions & 0 deletions .changeset/clerk-email-send.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
---
'@clerk/backend': minor
---

Add an experimental `clerkClient.emails.create()` method for sending transactional emails. It accepts address- or user-based recipients, supports optional `replyTo`, `subject`, and HTML and/or text content, and returns the created `Email` resource.

This method is marked `@experimental` and may change in a future release.
130 changes: 130 additions & 0 deletions packages/backend/src/api/__tests__/EmailApi.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
import { http, HttpResponse } from 'msw';
import { describe, expect, it } from 'vitest';

import { server, validateHeaders } from '../../mock-server';
import { createBackendApiClient } from '../factory';

describe('EmailApi', () => {
const apiClient = createBackendApiClient({
apiUrl: 'https://api.clerk.test',
secretKey: 'deadbeef',
});

const mockEmail = {
object: 'email',
id: 'ema_123',
slug: null,
from_email_name: 'noreply',
reply_to_email_name: null,
to_email_address: 'admin@acme.com',
email_address_id: null,
user_id: null,
subject: 'Hello',
body: '<p>hi</p>',
body_plain: null,
status: 'queued',
data: null,
delivered_by_clerk: true,
};

it('sends a transactional email and snake_cases the body', async () => {
server.use(
http.post(
'https://api.clerk.test/v1/email',
validateHeaders(async ({ request }) => {
const body = await request.json();
expect(body).toEqual({
to: { address: 'admin@acme.com' },
from: { address: 'noreply@acme.com' },
reply_to: { address: 'support@acme.com' },
subject: 'Hello',
html: '<p>hi</p>',
});
return HttpResponse.json(mockEmail);
}),
),
);

const response = await apiClient.emails.create({
to: { address: 'admin@acme.com' },
from: { address: 'noreply@acme.com' },
replyTo: { address: 'support@acme.com' },
subject: 'Hello',
html: '<p>hi</p>',
});

expect(response.id).toBe('ema_123');
expect(response.toEmailAddress).toBe('admin@acme.com');
expect(response.status).toBe('queued');
expect(response.deliveredByClerk).toBe(true);
});

it('sends a transactional email with a text body', async () => {
server.use(
http.post(
'https://api.clerk.test/v1/email',
validateHeaders(async ({ request }) => {
const body = await request.json();
expect(body).toEqual({
to: { address: 'admin@acme.com' },
from: { address: 'noreply@acme.com' },
subject: 'Hello',
text: 'hi',
});
return HttpResponse.json({
...mockEmail,
body: null,
body_plain: 'hi',
});
}),
),
);

const response = await apiClient.emails.create({
to: { address: 'admin@acme.com' },
from: { address: 'noreply@acme.com' },
subject: 'Hello',
text: 'hi',
});

expect(response.id).toBe('ema_123');
expect(response.body).toBeNull();
expect(response.bodyPlain).toBe('hi');
expect(response.status).toBe('queued');
});

it('sends a transactional email addressed by userId', async () => {
server.use(
http.post(
'https://api.clerk.test/v1/email',
validateHeaders(async ({ request }) => {
const body = await request.json();
// The nested `userId` must be snake_cased to `user_id` on the wire.
expect(body).toEqual({
to: { user_id: 'user_123' },
from: { address: 'noreply@acme.com' },
subject: 'Hello',
html: '<p>hi</p>',
});
return HttpResponse.json({
...mockEmail,
to_email_address: 'member@acme.com',
email_address_id: 'idn_123',
user_id: 'user_123',
});
}),
),
);

const response = await apiClient.emails.create({
to: { userId: 'user_123' },
from: { address: 'noreply@acme.com' },
subject: 'Hello',
html: '<p>hi</p>',
});

expect(response.toEmailAddress).toBe('member@acme.com');
expect(response.emailAddressId).toBe('idn_123');
expect(response.userId).toBe('user_123');
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
130 changes: 130 additions & 0 deletions packages/backend/src/api/endpoints/EmailApi.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
import type { Email } from '../resources/Email';
import { AbstractAPI } from './AbstractApi';

const basePath = '/email';

/**
* A subset of mailbox object as specified in RFC 5322 §3.4. Specifically, a
* `name-addr` with an optional `display-name` and a required `addr-spec`.
*
* @see {@link https://datatracker.ietf.org/doc/html/rfc5322#section-3.4}
*/
type Mailbox = {
/**
* (Optional) Display name for the mailbox. Currently accepted by the API but
* not yet rendered server-side, so it has no effect on the delivered email
* for now.
*/
name?: string;

/**
* The `addr-spec` of the mailbox, i.e. the email address itself.
*/
address: string;
};

/**
* The recipient of the email. Provide exactly one of the two mutually exclusive
* forms:
*
* - a literal mailbox: an `address` (plus an optional `name`), or
* - a `userId`: the ID of a Clerk user whose primary email address Clerk
* resolves server-side, from the instance the secret key belongs to.
*/
type EmailRecipient =
| {
/**
* The `addr-spec` of the recipient mailbox, i.e. the email address itself.
*/
address: string;
/**
* (Optional) Display name for the recipient mailbox. Currently accepted
* by the API but not yet rendered server-side.
*/
name?: string;
userId?: never;
}
| {
/**
* The ID of the Clerk user to send to. Clerk resolves the user's primary
* email address from the instance context. Mutually exclusive with
* `address`.
*/
userId: string;
address?: never;
name?: never;
};

/**
* The body of the email. At least one of `html` and `text` must be provided; if
* both are provided, the `html` version takes precedence. Encoded as a union so
* that omitting both is a compile-time error rather than a server-side one.
*/
type EmailContent =
| {
/**
* The HTML body of the email. Takes precedence over `text` when both are
* provided.
*/
html: string;
/**
* (Optional) The plain text body of the email.
*/
text?: string;
}
| {
/**
* (Optional) The HTML body of the email. Takes precedence over `text`
* when both are provided.
*/
html?: string;
/**
* The plain text body of the email.
*/
text: string;
};

export type CreateEmailParams = {
/**
* The recipient of the email. Currently only a single recipient is supported.
* Provide either an `address` (with an optional `name`) or the `userId` of a
* Clerk user; the two forms are mutually exclusive.
*/
to: EmailRecipient;

/**
* The sender of the email. See {@link Mailbox} for the accepted format. Note
* that the API does not yet render the `name` field of the `from` mailbox.
*/
from: Mailbox;

/**
* (Optional) The mailbox to include in the `reply-to` header of the email.
*/
replyTo?: Mailbox;

subject: string;
} & EmailContent;

export class EmailApi extends AbstractAPI {
/**
* @experimental This method calls an internal, not-yet-public endpoint and is
* subject to change. It is advised to [pin](https://clerk.com/docs/pinning)
* the SDK version to avoid breaking changes.
*
* Sends a transactional email.
*/
public async create(params: CreateEmailParams) {
return this.request<Email>({
method: 'POST',
path: basePath,
bodyParams: params,
options: {
// Snakecase nested keys too, so a `to: { userId }` recipient is sent as
// `to: { user_id }` on the wire (the default only snakecases top-level
// keys, which would leave the nested `userId` untouched).
deepSnakecaseBodyParamKeys: true,
},
});
}
}
1 change: 1 addition & 0 deletions packages/backend/src/api/endpoints/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ export * from './BlocklistIdentifierApi';
export * from './ClientApi';
export * from './DomainApi';
export * from './EmailAddressApi';
export * from './EmailApi';
export * from './EnterpriseConnectionApi';
export * from './IdPOAuthAccessTokenApi';
export * from './InstanceApi';
Expand Down
7 changes: 7 additions & 0 deletions packages/backend/src/api/factory.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ import {
ClientAPI,
DomainAPI,
EmailAddressAPI,
EmailApi,
EnterpriseConnectionAPI,
IdPOAuthAccessTokenApi,
InstanceAPI,
Expand DownExpand Up@@ -71,6 +72,12 @@ export function createBackendApiClient(options: CreateBackendApiOptions) {
clients: new ClientAPI(request),
domains: new DomainAPI(request),
emailAddresses: new EmailAddressAPI(request),
/**
* @experimental This calls an internal, not-yet-public endpoint for sending
* transactional emails and is subject to change. It is advised to
* [pin](https://clerk.com/docs/pinning) the SDK version to avoid breaking changes.
*/
emails: new EmailApi(request),
enterpriseConnections: new EnterpriseConnectionAPI(request),
idPOAuthAccessToken: new IdPOAuthAccessTokenApi(
buildRequest({
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/api/resources/Email.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ export class Email {
readonly slug?: string | null,
readonly data?: Record<string, any> | null,
readonly deliveredByClerk?: boolean,
readonly userId?: string | null,
) {}

static fromJSON(data: EmailJSON): Email {
Expand All@@ -28,6 +29,7 @@ export class Email {
data.slug,
data.data,
data.delivered_by_clerk,
data.user_id,
);
}
}
Loading
, '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
7 changes: 7 additions & 0 deletions .changeset/clerk-email-send.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
---
'@clerk/backend': minor
---

Add an experimental `clerkClient.emails.create()` method for sending transactional emails. It accepts address- or user-based recipients, supports optional `replyTo`, `subject`, and HTML and/or text content, and returns the created `Email` resource.

This method is marked `@experimental` and may change in a future release.
130 changes: 130 additions & 0 deletions packages/backend/src/api/__tests__/EmailApi.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
import { http, HttpResponse } from 'msw';
import { describe, expect, it } from 'vitest';

import { server, validateHeaders } from '../../mock-server';
import { createBackendApiClient } from '../factory';

describe('EmailApi', () => {
const apiClient = createBackendApiClient({
apiUrl: 'https://api.clerk.test',
secretKey: 'deadbeef',
});

const mockEmail = {
object: 'email',
id: 'ema_123',
slug: null,
from_email_name: 'noreply',
reply_to_email_name: null,
to_email_address: 'admin@acme.com',
email_address_id: null,
user_id: null,
subject: 'Hello',
body: '<p>hi</p>',
body_plain: null,
status: 'queued',
data: null,
delivered_by_clerk: true,
};

it('sends a transactional email and snake_cases the body', async () => {
server.use(
http.post(
'https://api.clerk.test/v1/email',
validateHeaders(async ({ request }) => {
const body = await request.json();
expect(body).toEqual({
to: { address: 'admin@acme.com' },
from: { address: 'noreply@acme.com' },
reply_to: { address: 'support@acme.com' },
subject: 'Hello',
html: '<p>hi</p>',
});
return HttpResponse.json(mockEmail);
}),
),
);

const response = await apiClient.emails.create({
to: { address: 'admin@acme.com' },
from: { address: 'noreply@acme.com' },
replyTo: { address: 'support@acme.com' },
subject: 'Hello',
html: '<p>hi</p>',
});

expect(response.id).toBe('ema_123');
expect(response.toEmailAddress).toBe('admin@acme.com');
expect(response.status).toBe('queued');
expect(response.deliveredByClerk).toBe(true);
});

it('sends a transactional email with a text body', async () => {
server.use(
http.post(
'https://api.clerk.test/v1/email',
validateHeaders(async ({ request }) => {
const body = await request.json();
expect(body).toEqual({
to: { address: 'admin@acme.com' },
from: { address: 'noreply@acme.com' },
subject: 'Hello',
text: 'hi',
});
return HttpResponse.json({
...mockEmail,
body: null,
body_plain: 'hi',
});
}),
),
);

const response = await apiClient.emails.create({
to: { address: 'admin@acme.com' },
from: { address: 'noreply@acme.com' },
subject: 'Hello',
text: 'hi',
});

expect(response.id).toBe('ema_123');
expect(response.body).toBeNull();
expect(response.bodyPlain).toBe('hi');
expect(response.status).toBe('queued');
});

it('sends a transactional email addressed by userId', async () => {
server.use(
http.post(
'https://api.clerk.test/v1/email',
validateHeaders(async ({ request }) => {
const body = await request.json();
// The nested `userId` must be snake_cased to `user_id` on the wire.
expect(body).toEqual({
to: { user_id: 'user_123' },
from: { address: 'noreply@acme.com' },
subject: 'Hello',
html: '<p>hi</p>',
});
return HttpResponse.json({
...mockEmail,
to_email_address: 'member@acme.com',
email_address_id: 'idn_123',
user_id: 'user_123',
});
}),
),
);

const response = await apiClient.emails.create({
to: { userId: 'user_123' },
from: { address: 'noreply@acme.com' },
subject: 'Hello',
html: '<p>hi</p>',
});

expect(response.toEmailAddress).toBe('member@acme.com');
expect(response.emailAddressId).toBe('idn_123');
expect(response.userId).toBe('user_123');
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
130 changes: 130 additions & 0 deletions packages/backend/src/api/endpoints/EmailApi.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
import type { Email } from '../resources/Email';
import { AbstractAPI } from './AbstractApi';

const basePath = '/email';

/**
* A subset of mailbox object as specified in RFC 5322 §3.4. Specifically, a
* `name-addr` with an optional `display-name` and a required `addr-spec`.
*
* @see {@link https://datatracker.ietf.org/doc/html/rfc5322#section-3.4}
*/
type Mailbox = {
/**
* (Optional) Display name for the mailbox. Currently accepted by the API but
* not yet rendered server-side, so it has no effect on the delivered email
* for now.
*/
name?: string;

/**
* The `addr-spec` of the mailbox, i.e. the email address itself.
*/
address: string;
};

/**
* The recipient of the email. Provide exactly one of the two mutually exclusive
* forms:
*
* - a literal mailbox: an `address` (plus an optional `name`), or
* - a `userId`: the ID of a Clerk user whose primary email address Clerk
* resolves server-side, from the instance the secret key belongs to.
*/
type EmailRecipient =
| {
/**
* The `addr-spec` of the recipient mailbox, i.e. the email address itself.
*/
address: string;
/**
* (Optional) Display name for the recipient mailbox. Currently accepted
* by the API but not yet rendered server-side.
*/
name?: string;
userId?: never;
}
| {
/**
* The ID of the Clerk user to send to. Clerk resolves the user's primary
* email address from the instance context. Mutually exclusive with
* `address`.
*/
userId: string;
address?: never;
name?: never;
};

/**
* The body of the email. At least one of `html` and `text` must be provided; if
* both are provided, the `html` version takes precedence. Encoded as a union so
* that omitting both is a compile-time error rather than a server-side one.
*/
type EmailContent =
| {
/**
* The HTML body of the email. Takes precedence over `text` when both are
* provided.
*/
html: string;
/**
* (Optional) The plain text body of the email.
*/
text?: string;
}
| {
/**
* (Optional) The HTML body of the email. Takes precedence over `text`
* when both are provided.
*/
html?: string;
/**
* The plain text body of the email.
*/
text: string;
};

export type CreateEmailParams = {
/**
* The recipient of the email. Currently only a single recipient is supported.
* Provide either an `address` (with an optional `name`) or the `userId` of a
* Clerk user; the two forms are mutually exclusive.
*/
to: EmailRecipient;

/**
* The sender of the email. See {@link Mailbox} for the accepted format. Note
* that the API does not yet render the `name` field of the `from` mailbox.
*/
from: Mailbox;

/**
* (Optional) The mailbox to include in the `reply-to` header of the email.
*/
replyTo?: Mailbox;

subject: string;
} & EmailContent;

export class EmailApi extends AbstractAPI {
/**
* @experimental This method calls an internal, not-yet-public endpoint and is
* subject to change. It is advised to [pin](https://clerk.com/docs/pinning)
* the SDK version to avoid breaking changes.
*
* Sends a transactional email.
*/
public async create(params: CreateEmailParams) {
return this.request<Email>({
method: 'POST',
path: basePath,
bodyParams: params,
options: {
// Snakecase nested keys too, so a `to: { userId }` recipient is sent as
// `to: { user_id }` on the wire (the default only snakecases top-level
// keys, which would leave the nested `userId` untouched).
deepSnakecaseBodyParamKeys: true,
},
});
}
}
1 change: 1 addition & 0 deletions packages/backend/src/api/endpoints/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ export * from './BlocklistIdentifierApi';
export * from './ClientApi';
export * from './DomainApi';
export * from './EmailAddressApi';
export * from './EmailApi';
export * from './EnterpriseConnectionApi';
export * from './IdPOAuthAccessTokenApi';
export * from './InstanceApi';
Expand Down
7 changes: 7 additions & 0 deletions packages/backend/src/api/factory.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ import {
ClientAPI,
DomainAPI,
EmailAddressAPI,
EmailApi,
EnterpriseConnectionAPI,
IdPOAuthAccessTokenApi,
InstanceAPI,
Expand DownExpand Up@@ -71,6 +72,12 @@ export function createBackendApiClient(options: CreateBackendApiOptions) {
clients: new ClientAPI(request),
domains: new DomainAPI(request),
emailAddresses: new EmailAddressAPI(request),
/**
* @experimental This calls an internal, not-yet-public endpoint for sending
* transactional emails and is subject to change. It is advised to
* [pin](https://clerk.com/docs/pinning) the SDK version to avoid breaking changes.
*/
emails: new EmailApi(request),
enterpriseConnections: new EnterpriseConnectionAPI(request),
idPOAuthAccessToken: new IdPOAuthAccessTokenApi(
buildRequest({
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/api/resources/Email.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ export class Email {
readonly slug?: string | null,
readonly data?: Record<string, any> | null,
readonly deliveredByClerk?: boolean,
readonly userId?: string | null,
) {}

static fromJSON(data: EmailJSON): Email {
Expand All@@ -28,6 +29,7 @@ export class Email {
data.slug,
data.data,
data.delivered_by_clerk,
data.user_id,
);
}
}
Loading
, '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
7 changes: 7 additions & 0 deletions .changeset/clerk-email-send.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
---
'@clerk/backend': minor
---

Add an experimental `clerkClient.emails.create()` method for sending transactional emails. It accepts address- or user-based recipients, supports optional `replyTo`, `subject`, and HTML and/or text content, and returns the created `Email` resource.

This method is marked `@experimental` and may change in a future release.
130 changes: 130 additions & 0 deletions packages/backend/src/api/__tests__/EmailApi.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
import { http, HttpResponse } from 'msw';
import { describe, expect, it } from 'vitest';

import { server, validateHeaders } from '../../mock-server';
import { createBackendApiClient } from '../factory';

describe('EmailApi', () => {
const apiClient = createBackendApiClient({
apiUrl: 'https://api.clerk.test',
secretKey: 'deadbeef',
});

const mockEmail = {
object: 'email',
id: 'ema_123',
slug: null,
from_email_name: 'noreply',
reply_to_email_name: null,
to_email_address: 'admin@acme.com',
email_address_id: null,
user_id: null,
subject: 'Hello',
body: '<p>hi</p>',
body_plain: null,
status: 'queued',
data: null,
delivered_by_clerk: true,
};

it('sends a transactional email and snake_cases the body', async () => {
server.use(
http.post(
'https://api.clerk.test/v1/email',
validateHeaders(async ({ request }) => {
const body = await request.json();
expect(body).toEqual({
to: { address: 'admin@acme.com' },
from: { address: 'noreply@acme.com' },
reply_to: { address: 'support@acme.com' },
subject: 'Hello',
html: '<p>hi</p>',
});
return HttpResponse.json(mockEmail);
}),
),
);

const response = await apiClient.emails.create({
to: { address: 'admin@acme.com' },
from: { address: 'noreply@acme.com' },
replyTo: { address: 'support@acme.com' },
subject: 'Hello',
html: '<p>hi</p>',
});

expect(response.id).toBe('ema_123');
expect(response.toEmailAddress).toBe('admin@acme.com');
expect(response.status).toBe('queued');
expect(response.deliveredByClerk).toBe(true);
});

it('sends a transactional email with a text body', async () => {
server.use(
http.post(
'https://api.clerk.test/v1/email',
validateHeaders(async ({ request }) => {
const body = await request.json();
expect(body).toEqual({
to: { address: 'admin@acme.com' },
from: { address: 'noreply@acme.com' },
subject: 'Hello',
text: 'hi',
});
return HttpResponse.json({
...mockEmail,
body: null,
body_plain: 'hi',
});
}),
),
);

const response = await apiClient.emails.create({
to: { address: 'admin@acme.com' },
from: { address: 'noreply@acme.com' },
subject: 'Hello',
text: 'hi',
});

expect(response.id).toBe('ema_123');
expect(response.body).toBeNull();
expect(response.bodyPlain).toBe('hi');
expect(response.status).toBe('queued');
});

it('sends a transactional email addressed by userId', async () => {
server.use(
http.post(
'https://api.clerk.test/v1/email',
validateHeaders(async ({ request }) => {
const body = await request.json();
// The nested `userId` must be snake_cased to `user_id` on the wire.
expect(body).toEqual({
to: { user_id: 'user_123' },
from: { address: 'noreply@acme.com' },
subject: 'Hello',
html: '<p>hi</p>',
});
return HttpResponse.json({
...mockEmail,
to_email_address: 'member@acme.com',
email_address_id: 'idn_123',
user_id: 'user_123',
});
}),
),
);

const response = await apiClient.emails.create({
to: { userId: 'user_123' },
from: { address: 'noreply@acme.com' },
subject: 'Hello',
html: '<p>hi</p>',
});

expect(response.toEmailAddress).toBe('member@acme.com');
expect(response.emailAddressId).toBe('idn_123');
expect(response.userId).toBe('user_123');
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
130 changes: 130 additions & 0 deletions packages/backend/src/api/endpoints/EmailApi.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
import type { Email } from '../resources/Email';
import { AbstractAPI } from './AbstractApi';

const basePath = '/email';

/**
* A subset of mailbox object as specified in RFC 5322 §3.4. Specifically, a
* `name-addr` with an optional `display-name` and a required `addr-spec`.
*
* @see {@link https://datatracker.ietf.org/doc/html/rfc5322#section-3.4}
*/
type Mailbox = {
/**
* (Optional) Display name for the mailbox. Currently accepted by the API but
* not yet rendered server-side, so it has no effect on the delivered email
* for now.
*/
name?: string;

/**
* The `addr-spec` of the mailbox, i.e. the email address itself.
*/
address: string;
};

/**
* The recipient of the email. Provide exactly one of the two mutually exclusive
* forms:
*
* - a literal mailbox: an `address` (plus an optional `name`), or
* - a `userId`: the ID of a Clerk user whose primary email address Clerk
* resolves server-side, from the instance the secret key belongs to.
*/
type EmailRecipient =
| {
/**
* The `addr-spec` of the recipient mailbox, i.e. the email address itself.
*/
address: string;
/**
* (Optional) Display name for the recipient mailbox. Currently accepted
* by the API but not yet rendered server-side.
*/
name?: string;
userId?: never;
}
| {
/**
* The ID of the Clerk user to send to. Clerk resolves the user's primary
* email address from the instance context. Mutually exclusive with
* `address`.
*/
userId: string;
address?: never;
name?: never;
};

/**
* The body of the email. At least one of `html` and `text` must be provided; if
* both are provided, the `html` version takes precedence. Encoded as a union so
* that omitting both is a compile-time error rather than a server-side one.
*/
type EmailContent =
| {
/**
* The HTML body of the email. Takes precedence over `text` when both are
* provided.
*/
html: string;
/**
* (Optional) The plain text body of the email.
*/
text?: string;
}
| {
/**
* (Optional) The HTML body of the email. Takes precedence over `text`
* when both are provided.
*/
html?: string;
/**
* The plain text body of the email.
*/
text: string;
};

export type CreateEmailParams = {
/**
* The recipient of the email. Currently only a single recipient is supported.
* Provide either an `address` (with an optional `name`) or the `userId` of a
* Clerk user; the two forms are mutually exclusive.
*/
to: EmailRecipient;

/**
* The sender of the email. See {@link Mailbox} for the accepted format. Note
* that the API does not yet render the `name` field of the `from` mailbox.
*/
from: Mailbox;

/**
* (Optional) The mailbox to include in the `reply-to` header of the email.
*/
replyTo?: Mailbox;

subject: string;
} & EmailContent;

export class EmailApi extends AbstractAPI {
/**
* @experimental This method calls an internal, not-yet-public endpoint and is
* subject to change. It is advised to [pin](https://clerk.com/docs/pinning)
* the SDK version to avoid breaking changes.
*
* Sends a transactional email.
*/
public async create(params: CreateEmailParams) {
return this.request<Email>({
method: 'POST',
path: basePath,
bodyParams: params,
options: {
// Snakecase nested keys too, so a `to: { userId }` recipient is sent as
// `to: { user_id }` on the wire (the default only snakecases top-level
// keys, which would leave the nested `userId` untouched).
deepSnakecaseBodyParamKeys: true,
},
});
}
}
1 change: 1 addition & 0 deletions packages/backend/src/api/endpoints/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ export * from './BlocklistIdentifierApi';
export * from './ClientApi';
export * from './DomainApi';
export * from './EmailAddressApi';
export * from './EmailApi';
export * from './EnterpriseConnectionApi';
export * from './IdPOAuthAccessTokenApi';
export * from './InstanceApi';
Expand Down
7 changes: 7 additions & 0 deletions packages/backend/src/api/factory.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ import {
ClientAPI,
DomainAPI,
EmailAddressAPI,
EmailApi,
EnterpriseConnectionAPI,
IdPOAuthAccessTokenApi,
InstanceAPI,
Expand DownExpand Up@@ -71,6 +72,12 @@ export function createBackendApiClient(options: CreateBackendApiOptions) {
clients: new ClientAPI(request),
domains: new DomainAPI(request),
emailAddresses: new EmailAddressAPI(request),
/**
* @experimental This calls an internal, not-yet-public endpoint for sending
* transactional emails and is subject to change. It is advised to
* [pin](https://clerk.com/docs/pinning) the SDK version to avoid breaking changes.
*/
emails: new EmailApi(request),
enterpriseConnections: new EnterpriseConnectionAPI(request),
idPOAuthAccessToken: new IdPOAuthAccessTokenApi(
buildRequest({
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/api/resources/Email.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ export class Email {
readonly slug?: string | null,
readonly data?: Record<string, any> | null,
readonly deliveredByClerk?: boolean,
readonly userId?: string | null,
) {}

static fromJSON(data: EmailJSON): Email {
Expand All@@ -28,6 +29,7 @@ export class Email {
data.slug,
data.data,
data.delivered_by_clerk,
data.user_id,
);
}
}
Loading
, '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
7 changes: 7 additions & 0 deletions .changeset/clerk-email-send.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
---
'@clerk/backend': minor
---

Add an experimental `clerkClient.emails.create()` method for sending transactional emails. It accepts address- or user-based recipients, supports optional `replyTo`, `subject`, and HTML and/or text content, and returns the created `Email` resource.

This method is marked `@experimental` and may change in a future release.
130 changes: 130 additions & 0 deletions packages/backend/src/api/__tests__/EmailApi.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
import { http, HttpResponse } from 'msw';
import { describe, expect, it } from 'vitest';

import { server, validateHeaders } from '../../mock-server';
import { createBackendApiClient } from '../factory';

describe('EmailApi', () => {
const apiClient = createBackendApiClient({
apiUrl: 'https://api.clerk.test',
secretKey: 'deadbeef',
});

const mockEmail = {
object: 'email',
id: 'ema_123',
slug: null,
from_email_name: 'noreply',
reply_to_email_name: null,
to_email_address: 'admin@acme.com',
email_address_id: null,
user_id: null,
subject: 'Hello',
body: '<p>hi</p>',
body_plain: null,
status: 'queued',
data: null,
delivered_by_clerk: true,
};

it('sends a transactional email and snake_cases the body', async () => {
server.use(
http.post(
'https://api.clerk.test/v1/email',
validateHeaders(async ({ request }) => {
const body = await request.json();
expect(body).toEqual({
to: { address: 'admin@acme.com' },
from: { address: 'noreply@acme.com' },
reply_to: { address: 'support@acme.com' },
subject: 'Hello',
html: '<p>hi</p>',
});
return HttpResponse.json(mockEmail);
}),
),
);

const response = await apiClient.emails.create({
to: { address: 'admin@acme.com' },
from: { address: 'noreply@acme.com' },
replyTo: { address: 'support@acme.com' },
subject: 'Hello',
html: '<p>hi</p>',
});

expect(response.id).toBe('ema_123');
expect(response.toEmailAddress).toBe('admin@acme.com');
expect(response.status).toBe('queued');
expect(response.deliveredByClerk).toBe(true);
});

it('sends a transactional email with a text body', async () => {
server.use(
http.post(
'https://api.clerk.test/v1/email',
validateHeaders(async ({ request }) => {
const body = await request.json();
expect(body).toEqual({
to: { address: 'admin@acme.com' },
from: { address: 'noreply@acme.com' },
subject: 'Hello',
text: 'hi',
});
return HttpResponse.json({
...mockEmail,
body: null,
body_plain: 'hi',
});
}),
),
);

const response = await apiClient.emails.create({
to: { address: 'admin@acme.com' },
from: { address: 'noreply@acme.com' },
subject: 'Hello',
text: 'hi',
});

expect(response.id).toBe('ema_123');
expect(response.body).toBeNull();
expect(response.bodyPlain).toBe('hi');
expect(response.status).toBe('queued');
});

it('sends a transactional email addressed by userId', async () => {
server.use(
http.post(
'https://api.clerk.test/v1/email',
validateHeaders(async ({ request }) => {
const body = await request.json();
// The nested `userId` must be snake_cased to `user_id` on the wire.
expect(body).toEqual({
to: { user_id: 'user_123' },
from: { address: 'noreply@acme.com' },
subject: 'Hello',
html: '<p>hi</p>',
});
return HttpResponse.json({
...mockEmail,
to_email_address: 'member@acme.com',
email_address_id: 'idn_123',
user_id: 'user_123',
});
}),
),
);

const response = await apiClient.emails.create({
to: { userId: 'user_123' },
from: { address: 'noreply@acme.com' },
subject: 'Hello',
html: '<p>hi</p>',
});

expect(response.toEmailAddress).toBe('member@acme.com');
expect(response.emailAddressId).toBe('idn_123');
expect(response.userId).toBe('user_123');
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
130 changes: 130 additions & 0 deletions packages/backend/src/api/endpoints/EmailApi.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
import type { Email } from '../resources/Email';
import { AbstractAPI } from './AbstractApi';

const basePath = '/email';

/**
* A subset of mailbox object as specified in RFC 5322 §3.4. Specifically, a
* `name-addr` with an optional `display-name` and a required `addr-spec`.
*
* @see {@link https://datatracker.ietf.org/doc/html/rfc5322#section-3.4}
*/
type Mailbox = {
/**
* (Optional) Display name for the mailbox. Currently accepted by the API but
* not yet rendered server-side, so it has no effect on the delivered email
* for now.
*/
name?: string;

/**
* The `addr-spec` of the mailbox, i.e. the email address itself.
*/
address: string;
};

/**
* The recipient of the email. Provide exactly one of the two mutually exclusive
* forms:
*
* - a literal mailbox: an `address` (plus an optional `name`), or
* - a `userId`: the ID of a Clerk user whose primary email address Clerk
* resolves server-side, from the instance the secret key belongs to.
*/
type EmailRecipient =
| {
/**
* The `addr-spec` of the recipient mailbox, i.e. the email address itself.
*/
address: string;
/**
* (Optional) Display name for the recipient mailbox. Currently accepted
* by the API but not yet rendered server-side.
*/
name?: string;
userId?: never;
}
| {
/**
* The ID of the Clerk user to send to. Clerk resolves the user's primary
* email address from the instance context. Mutually exclusive with
* `address`.
*/
userId: string;
address?: never;
name?: never;
};

/**
* The body of the email. At least one of `html` and `text` must be provided; if
* both are provided, the `html` version takes precedence. Encoded as a union so
* that omitting both is a compile-time error rather than a server-side one.
*/
type EmailContent =
| {
/**
* The HTML body of the email. Takes precedence over `text` when both are
* provided.
*/
html: string;
/**
* (Optional) The plain text body of the email.
*/
text?: string;
}
| {
/**
* (Optional) The HTML body of the email. Takes precedence over `text`
* when both are provided.
*/
html?: string;
/**
* The plain text body of the email.
*/
text: string;
};

export type CreateEmailParams = {
/**
* The recipient of the email. Currently only a single recipient is supported.
* Provide either an `address` (with an optional `name`) or the `userId` of a
* Clerk user; the two forms are mutually exclusive.
*/
to: EmailRecipient;

/**
* The sender of the email. See {@link Mailbox} for the accepted format. Note
* that the API does not yet render the `name` field of the `from` mailbox.
*/
from: Mailbox;

/**
* (Optional) The mailbox to include in the `reply-to` header of the email.
*/
replyTo?: Mailbox;

subject: string;
} & EmailContent;

export class EmailApi extends AbstractAPI {
/**
* @experimental This method calls an internal, not-yet-public endpoint and is
* subject to change. It is advised to [pin](https://clerk.com/docs/pinning)
* the SDK version to avoid breaking changes.
*
* Sends a transactional email.
*/
public async create(params: CreateEmailParams) {
return this.request<Email>({
method: 'POST',
path: basePath,
bodyParams: params,
options: {
// Snakecase nested keys too, so a `to: { userId }` recipient is sent as
// `to: { user_id }` on the wire (the default only snakecases top-level
// keys, which would leave the nested `userId` untouched).
deepSnakecaseBodyParamKeys: true,
},
});
}
}
1 change: 1 addition & 0 deletions packages/backend/src/api/endpoints/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ export * from './BlocklistIdentifierApi';
export * from './ClientApi';
export * from './DomainApi';
export * from './EmailAddressApi';
export * from './EmailApi';
export * from './EnterpriseConnectionApi';
export * from './IdPOAuthAccessTokenApi';
export * from './InstanceApi';
Expand Down
7 changes: 7 additions & 0 deletions packages/backend/src/api/factory.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ import {
ClientAPI,
DomainAPI,
EmailAddressAPI,
EmailApi,
EnterpriseConnectionAPI,
IdPOAuthAccessTokenApi,
InstanceAPI,
Expand DownExpand Up@@ -71,6 +72,12 @@ export function createBackendApiClient(options: CreateBackendApiOptions) {
clients: new ClientAPI(request),
domains: new DomainAPI(request),
emailAddresses: new EmailAddressAPI(request),
/**
* @experimental This calls an internal, not-yet-public endpoint for sending
* transactional emails and is subject to change. It is advised to
* [pin](https://clerk.com/docs/pinning) the SDK version to avoid breaking changes.
*/
emails: new EmailApi(request),
enterpriseConnections: new EnterpriseConnectionAPI(request),
idPOAuthAccessToken: new IdPOAuthAccessTokenApi(
buildRequest({
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/api/resources/Email.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ export class Email {
readonly slug?: string | null,
readonly data?: Record<string, any> | null,
readonly deliveredByClerk?: boolean,
readonly userId?: string | null,
) {}

static fromJSON(data: EmailJSON): Email {
Expand All@@ -28,6 +29,7 @@ export class Email {
data.slug,
data.data,
data.delivered_by_clerk,
data.user_id,
);
}
}
Loading
, '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
7 changes: 7 additions & 0 deletions .changeset/clerk-email-send.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
---
'@clerk/backend': minor
---

Add an experimental `clerkClient.emails.create()` method for sending transactional emails. It accepts address- or user-based recipients, supports optional `replyTo`, `subject`, and HTML and/or text content, and returns the created `Email` resource.

This method is marked `@experimental` and may change in a future release.
130 changes: 130 additions & 0 deletions packages/backend/src/api/__tests__/EmailApi.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
import { http, HttpResponse } from 'msw';
import { describe, expect, it } from 'vitest';

import { server, validateHeaders } from '../../mock-server';
import { createBackendApiClient } from '../factory';

describe('EmailApi', () => {
const apiClient = createBackendApiClient({
apiUrl: 'https://api.clerk.test',
secretKey: 'deadbeef',
});

const mockEmail = {
object: 'email',
id: 'ema_123',
slug: null,
from_email_name: 'noreply',
reply_to_email_name: null,
to_email_address: 'admin@acme.com',
email_address_id: null,
user_id: null,
subject: 'Hello',
body: '<p>hi</p>',
body_plain: null,
status: 'queued',
data: null,
delivered_by_clerk: true,
};

it('sends a transactional email and snake_cases the body', async () => {
server.use(
http.post(
'https://api.clerk.test/v1/email',
validateHeaders(async ({ request }) => {
const body = await request.json();
expect(body).toEqual({
to: { address: 'admin@acme.com' },
from: { address: 'noreply@acme.com' },
reply_to: { address: 'support@acme.com' },
subject: 'Hello',
html: '<p>hi</p>',
});
return HttpResponse.json(mockEmail);
}),
),
);

const response = await apiClient.emails.create({
to: { address: 'admin@acme.com' },
from: { address: 'noreply@acme.com' },
replyTo: { address: 'support@acme.com' },
subject: 'Hello',
html: '<p>hi</p>',
});

expect(response.id).toBe('ema_123');
expect(response.toEmailAddress).toBe('admin@acme.com');
expect(response.status).toBe('queued');
expect(response.deliveredByClerk).toBe(true);
});

it('sends a transactional email with a text body', async () => {
server.use(
http.post(
'https://api.clerk.test/v1/email',
validateHeaders(async ({ request }) => {
const body = await request.json();
expect(body).toEqual({
to: { address: 'admin@acme.com' },
from: { address: 'noreply@acme.com' },
subject: 'Hello',
text: 'hi',
});
return HttpResponse.json({
...mockEmail,
body: null,
body_plain: 'hi',
});
}),
),
);

const response = await apiClient.emails.create({
to: { address: 'admin@acme.com' },
from: { address: 'noreply@acme.com' },
subject: 'Hello',
text: 'hi',
});

expect(response.id).toBe('ema_123');
expect(response.body).toBeNull();
expect(response.bodyPlain).toBe('hi');
expect(response.status).toBe('queued');
});

it('sends a transactional email addressed by userId', async () => {
server.use(
http.post(
'https://api.clerk.test/v1/email',
validateHeaders(async ({ request }) => {
const body = await request.json();
// The nested `userId` must be snake_cased to `user_id` on the wire.
expect(body).toEqual({
to: { user_id: 'user_123' },
from: { address: 'noreply@acme.com' },
subject: 'Hello',
html: '<p>hi</p>',
});
return HttpResponse.json({
...mockEmail,
to_email_address: 'member@acme.com',
email_address_id: 'idn_123',
user_id: 'user_123',
});
}),
),
);

const response = await apiClient.emails.create({
to: { userId: 'user_123' },
from: { address: 'noreply@acme.com' },
subject: 'Hello',
html: '<p>hi</p>',
});

expect(response.toEmailAddress).toBe('member@acme.com');
expect(response.emailAddressId).toBe('idn_123');
expect(response.userId).toBe('user_123');
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
130 changes: 130 additions & 0 deletions packages/backend/src/api/endpoints/EmailApi.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
import type { Email } from '../resources/Email';
import { AbstractAPI } from './AbstractApi';

const basePath = '/email';

/**
* A subset of mailbox object as specified in RFC 5322 §3.4. Specifically, a
* `name-addr` with an optional `display-name` and a required `addr-spec`.
*
* @see {@link https://datatracker.ietf.org/doc/html/rfc5322#section-3.4}
*/
type Mailbox = {
/**
* (Optional) Display name for the mailbox. Currently accepted by the API but
* not yet rendered server-side, so it has no effect on the delivered email
* for now.
*/
name?: string;

/**
* The `addr-spec` of the mailbox, i.e. the email address itself.
*/
address: string;
};

/**
* The recipient of the email. Provide exactly one of the two mutually exclusive
* forms:
*
* - a literal mailbox: an `address` (plus an optional `name`), or
* - a `userId`: the ID of a Clerk user whose primary email address Clerk
* resolves server-side, from the instance the secret key belongs to.
*/
type EmailRecipient =
| {
/**
* The `addr-spec` of the recipient mailbox, i.e. the email address itself.
*/
address: string;
/**
* (Optional) Display name for the recipient mailbox. Currently accepted
* by the API but not yet rendered server-side.
*/
name?: string;
userId?: never;
}
| {
/**
* The ID of the Clerk user to send to. Clerk resolves the user's primary
* email address from the instance context. Mutually exclusive with
* `address`.
*/
userId: string;
address?: never;
name?: never;
};

/**
* The body of the email. At least one of `html` and `text` must be provided; if
* both are provided, the `html` version takes precedence. Encoded as a union so
* that omitting both is a compile-time error rather than a server-side one.
*/
type EmailContent =
| {
/**
* The HTML body of the email. Takes precedence over `text` when both are
* provided.
*/
html: string;
/**
* (Optional) The plain text body of the email.
*/
text?: string;
}
| {
/**
* (Optional) The HTML body of the email. Takes precedence over `text`
* when both are provided.
*/
html?: string;
/**
* The plain text body of the email.
*/
text: string;
};

export type CreateEmailParams = {
/**
* The recipient of the email. Currently only a single recipient is supported.
* Provide either an `address` (with an optional `name`) or the `userId` of a
* Clerk user; the two forms are mutually exclusive.
*/
to: EmailRecipient;

/**
* The sender of the email. See {@link Mailbox} for the accepted format. Note
* that the API does not yet render the `name` field of the `from` mailbox.
*/
from: Mailbox;

/**
* (Optional) The mailbox to include in the `reply-to` header of the email.
*/
replyTo?: Mailbox;

subject: string;
} & EmailContent;

export class EmailApi extends AbstractAPI {
/**
* @experimental This method calls an internal, not-yet-public endpoint and is
* subject to change. It is advised to [pin](https://clerk.com/docs/pinning)
* the SDK version to avoid breaking changes.
*
* Sends a transactional email.
*/
public async create(params: CreateEmailParams) {
return this.request<Email>({
method: 'POST',
path: basePath,
bodyParams: params,
options: {
// Snakecase nested keys too, so a `to: { userId }` recipient is sent as
// `to: { user_id }` on the wire (the default only snakecases top-level
// keys, which would leave the nested `userId` untouched).
deepSnakecaseBodyParamKeys: true,
},
});
}
}
1 change: 1 addition & 0 deletions packages/backend/src/api/endpoints/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ export * from './BlocklistIdentifierApi';
export * from './ClientApi';
export * from './DomainApi';
export * from './EmailAddressApi';
export * from './EmailApi';
export * from './EnterpriseConnectionApi';
export * from './IdPOAuthAccessTokenApi';
export * from './InstanceApi';
Expand Down
7 changes: 7 additions & 0 deletions packages/backend/src/api/factory.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ import {
ClientAPI,
DomainAPI,
EmailAddressAPI,
EmailApi,
EnterpriseConnectionAPI,
IdPOAuthAccessTokenApi,
InstanceAPI,
Expand DownExpand Up@@ -71,6 +72,12 @@ export function createBackendApiClient(options: CreateBackendApiOptions) {
clients: new ClientAPI(request),
domains: new DomainAPI(request),
emailAddresses: new EmailAddressAPI(request),
/**
* @experimental This calls an internal, not-yet-public endpoint for sending
* transactional emails and is subject to change. It is advised to
* [pin](https://clerk.com/docs/pinning) the SDK version to avoid breaking changes.
*/
emails: new EmailApi(request),
enterpriseConnections: new EnterpriseConnectionAPI(request),
idPOAuthAccessToken: new IdPOAuthAccessTokenApi(
buildRequest({
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/api/resources/Email.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ export class Email {
readonly slug?: string | null,
readonly data?: Record<string, any> | null,
readonly deliveredByClerk?: boolean,
readonly userId?: string | null,
) {}

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