-
Notifications
You must be signed in to change notification settings - Fork 459
feat(backend): transactional email send with user_id recipient #9010
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
cbnsndwch
wants to merge
8
commits into
main
Choose a base branch
from
serge/clerk-email-sdk
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
5d2dd91
feat(backend): add experimental emails.create for sending transaction…
cbnsndwch 7519207
feat(backend): add experimental emails.create for sending transaction…
cbnsndwch 76aaebf
feat(backend): support user_id recipient in emails.create
cbnsndwch 01ba8cb
fix(backend): require html or text in emails.create at the type level…
cbnsndwch f2a8892
Merge branch 'main' into serge/clerk-email-sdk
cbnsndwch 51ca7ab
Update changeset
cbnsndwch c00e980
chore: delete changeset file from merged stacked pr
cbnsndwch ca23a31
Merge branch 'main' into serge/clerk-email-sdk
cbnsndwch File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| --- | ||
| '@clerk/backend': minor | ||
| --- | ||
|
|
||
| Add an experimental `clerkClient.emails.create()` method for sending transactional emails. It accepts address- or user-based recipients, supports optional `replyTo`, `subject`, and HTML and/or text content, and returns the created `Email` resource. | ||
|
|
||
| This method is marked `@experimental` and may change in a future release. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,130 @@ | ||
| import { http, HttpResponse } from 'msw'; | ||
| import { describe, expect, it } from 'vitest'; | ||
|
|
||
| import { server, validateHeaders } from '../../mock-server'; | ||
| import { createBackendApiClient } from '../factory'; | ||
|
|
||
| describe('EmailApi', () => { | ||
| const apiClient = createBackendApiClient({ | ||
| apiUrl: 'https://api.clerk.test', | ||
| secretKey: 'deadbeef', | ||
| }); | ||
|
|
||
| const mockEmail = { | ||
| object: 'email', | ||
| id: 'ema_123', | ||
| slug: null, | ||
| from_email_name: 'noreply', | ||
| reply_to_email_name: null, | ||
| to_email_address: 'admin@acme.com', | ||
| email_address_id: null, | ||
| user_id: null, | ||
| subject: 'Hello', | ||
| body: '<p>hi</p>', | ||
| body_plain: null, | ||
| status: 'queued', | ||
| data: null, | ||
| delivered_by_clerk: true, | ||
| }; | ||
|
|
||
| it('sends a transactional email and snake_cases the body', async () => { | ||
| server.use( | ||
| http.post( | ||
| 'https://api.clerk.test/v1/email', | ||
| validateHeaders(async ({ request }) => { | ||
| const body = await request.json(); | ||
| expect(body).toEqual({ | ||
| to: { address: 'admin@acme.com' }, | ||
| from: { address: 'noreply@acme.com' }, | ||
| reply_to: { address: 'support@acme.com' }, | ||
| subject: 'Hello', | ||
| html: '<p>hi</p>', | ||
| }); | ||
| return HttpResponse.json(mockEmail); | ||
| }), | ||
| ), | ||
| ); | ||
|
|
||
| const response = await apiClient.emails.create({ | ||
| to: { address: 'admin@acme.com' }, | ||
| from: { address: 'noreply@acme.com' }, | ||
| replyTo: { address: 'support@acme.com' }, | ||
| subject: 'Hello', | ||
| html: '<p>hi</p>', | ||
| }); | ||
|
|
||
| expect(response.id).toBe('ema_123'); | ||
| expect(response.toEmailAddress).toBe('admin@acme.com'); | ||
| expect(response.status).toBe('queued'); | ||
| expect(response.deliveredByClerk).toBe(true); | ||
| }); | ||
|
|
||
| it('sends a transactional email with a text body', async () => { | ||
| server.use( | ||
| http.post( | ||
| 'https://api.clerk.test/v1/email', | ||
| validateHeaders(async ({ request }) => { | ||
| const body = await request.json(); | ||
| expect(body).toEqual({ | ||
| to: { address: 'admin@acme.com' }, | ||
| from: { address: 'noreply@acme.com' }, | ||
| subject: 'Hello', | ||
| text: 'hi', | ||
| }); | ||
| return HttpResponse.json({ | ||
| ...mockEmail, | ||
| body: null, | ||
| body_plain: 'hi', | ||
| }); | ||
| }), | ||
| ), | ||
| ); | ||
|
|
||
| const response = await apiClient.emails.create({ | ||
| to: { address: 'admin@acme.com' }, | ||
| from: { address: 'noreply@acme.com' }, | ||
| subject: 'Hello', | ||
| text: 'hi', | ||
| }); | ||
|
|
||
| expect(response.id).toBe('ema_123'); | ||
| expect(response.body).toBeNull(); | ||
| expect(response.bodyPlain).toBe('hi'); | ||
| expect(response.status).toBe('queued'); | ||
| }); | ||
|
|
||
| it('sends a transactional email addressed by userId', async () => { | ||
| server.use( | ||
| http.post( | ||
| 'https://api.clerk.test/v1/email', | ||
| validateHeaders(async ({ request }) => { | ||
| const body = await request.json(); | ||
| // The nested `userId` must be snake_cased to `user_id` on the wire. | ||
| expect(body).toEqual({ | ||
| to: { user_id: 'user_123' }, | ||
| from: { address: 'noreply@acme.com' }, | ||
| subject: 'Hello', | ||
| html: '<p>hi</p>', | ||
| }); | ||
| return HttpResponse.json({ | ||
| ...mockEmail, | ||
| to_email_address: 'member@acme.com', | ||
| email_address_id: 'idn_123', | ||
| user_id: 'user_123', | ||
| }); | ||
| }), | ||
| ), | ||
| ); | ||
|
|
||
| const response = await apiClient.emails.create({ | ||
| to: { userId: 'user_123' }, | ||
| from: { address: 'noreply@acme.com' }, | ||
| subject: 'Hello', | ||
| html: '<p>hi</p>', | ||
| }); | ||
|
|
||
| expect(response.toEmailAddress).toBe('member@acme.com'); | ||
| expect(response.emailAddressId).toBe('idn_123'); | ||
| expect(response.userId).toBe('user_123'); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,130 @@ | ||
| import type { Email } from '../resources/Email'; | ||
| import { AbstractAPI } from './AbstractApi'; | ||
|
|
||
| const basePath = '/email'; | ||
|
|
||
| /** | ||
| * A subset of mailbox object as specified in RFC 5322 §3.4. Specifically, a | ||
| * `name-addr` with an optional `display-name` and a required `addr-spec`. | ||
| * | ||
| * @see {@link https://datatracker.ietf.org/doc/html/rfc5322#section-3.4} | ||
| */ | ||
| type Mailbox = { | ||
| /** | ||
| * (Optional) Display name for the mailbox. Currently accepted by the API but | ||
| * not yet rendered server-side, so it has no effect on the delivered email | ||
| * for now. | ||
| */ | ||
| name?: string; | ||
|
|
||
| /** | ||
| * The `addr-spec` of the mailbox, i.e. the email address itself. | ||
| */ | ||
| address: string; | ||
| }; | ||
|
|
||
| /** | ||
| * The recipient of the email. Provide exactly one of the two mutually exclusive | ||
| * forms: | ||
| * | ||
| * - a literal mailbox: an `address` (plus an optional `name`), or | ||
| * - a `userId`: the ID of a Clerk user whose primary email address Clerk | ||
| * resolves server-side, from the instance the secret key belongs to. | ||
| */ | ||
| type EmailRecipient = | ||
| | { | ||
| /** | ||
| * The `addr-spec` of the recipient mailbox, i.e. the email address itself. | ||
| */ | ||
| address: string; | ||
| /** | ||
| * (Optional) Display name for the recipient mailbox. Currently accepted | ||
| * by the API but not yet rendered server-side. | ||
| */ | ||
| name?: string; | ||
| userId?: never; | ||
| } | ||
| | { | ||
| /** | ||
| * The ID of the Clerk user to send to. Clerk resolves the user's primary | ||
| * email address from the instance context. Mutually exclusive with | ||
| * `address`. | ||
| */ | ||
| userId: string; | ||
| address?: never; | ||
| name?: never; | ||
| }; | ||
|
|
||
| /** | ||
| * The body of the email. At least one of `html` and `text` must be provided; if | ||
| * both are provided, the `html` version takes precedence. Encoded as a union so | ||
| * that omitting both is a compile-time error rather than a server-side one. | ||
| */ | ||
| type EmailContent = | ||
| | { | ||
| /** | ||
| * The HTML body of the email. Takes precedence over `text` when both are | ||
| * provided. | ||
| */ | ||
| html: string; | ||
| /** | ||
| * (Optional) The plain text body of the email. | ||
| */ | ||
| text?: string; | ||
| } | ||
| | { | ||
| /** | ||
| * (Optional) The HTML body of the email. Takes precedence over `text` | ||
| * when both are provided. | ||
| */ | ||
| html?: string; | ||
| /** | ||
| * The plain text body of the email. | ||
| */ | ||
| text: string; | ||
| }; | ||
|
|
||
| export type CreateEmailParams = { | ||
| /** | ||
| * The recipient of the email. Currently only a single recipient is supported. | ||
| * Provide either an `address` (with an optional `name`) or the `userId` of a | ||
| * Clerk user; the two forms are mutually exclusive. | ||
| */ | ||
| to: EmailRecipient; | ||
|
|
||
| /** | ||
| * The sender of the email. See {@link Mailbox} for the accepted format. Note | ||
| * that the API does not yet render the `name` field of the `from` mailbox. | ||
| */ | ||
| from: Mailbox; | ||
|
|
||
| /** | ||
| * (Optional) The mailbox to include in the `reply-to` header of the email. | ||
| */ | ||
| replyTo?: Mailbox; | ||
|
|
||
| subject: string; | ||
| } & EmailContent; | ||
|
|
||
| export class EmailApi extends AbstractAPI { | ||
| /** | ||
| * @experimental This method calls an internal, not-yet-public endpoint and is | ||
| * subject to change. It is advised to [pin](https://clerk.com/docs/pinning) | ||
| * the SDK version to avoid breaking changes. | ||
| * | ||
| * Sends a transactional email. | ||
| */ | ||
| public async create(params: CreateEmailParams) { | ||
| return this.request<Email>({ | ||
| method: 'POST', | ||
| path: basePath, | ||
| bodyParams: params, | ||
| options: { | ||
| // Snakecase nested keys too, so a `to: { userId }` recipient is sent as | ||
| // `to: { user_id }` on the wire (the default only snakecases top-level | ||
| // keys, which would leave the nested `userId` untouched). | ||
| deepSnakecaseBodyParamKeys: true, | ||
| }, | ||
| }); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.