diff --git a/skills/resend-cli/SKILL.md b/skills/resend-cli/SKILL.md index 1b94bf15..91e14cb5 100644 --- a/skills/resend-cli/SKILL.md +++ b/skills/resend-cli/SKILL.md @@ -1,10 +1,11 @@ --- name: resend-cli description: > - Operate the Resend platform from the terminal — send emails, manage domains, - contacts, broadcasts, templates, webhooks, and API keys via the `resend` CLI. - Use when the user wants to run Resend commands in the shell, scripts, or CI/CD - pipelines. Always load this skill before running `resend` commands — it contains + Operate the Resend platform from the terminal — send emails (including React Email + .tsx templates via --react-email), manage domains, contacts, broadcasts, templates, + webhooks, and API keys via the `resend` CLI. Use when the user wants to run Resend + commands in the shell, scripts, or CI/CD pipelines, or send/preview React Email + templates. Always load this skill before running `resend` commands — it contains the non-interactive flag contract and gotchas that prevent silent failures. license: MIT metadata: @@ -101,6 +102,11 @@ Read the matching reference file for detailed flags and output shapes. resend emails send --from "you@domain.com" --to user@example.com --subject "Hello" --text "Body" ``` +**Send a React Email template (.tsx):** +```bash +resend emails send --from "you@domain.com" --to user@example.com --subject "Welcome" --react-email ./emails/welcome.tsx +``` + **Domain setup flow:** ```bash resend domains create --name example.com --region us-east-1 diff --git a/skills/resend-cli/references/broadcasts.md b/skills/resend-cli/references/broadcasts.md index e49d20f9..60d8c589 100644 --- a/skills/resend-cli/references/broadcasts.md +++ b/skills/resend-cli/references/broadcasts.md @@ -24,6 +24,7 @@ Detailed flag specifications for `resend broadcasts` commands. | `--html ` | string | At least one body flag | HTML body (supports `{{{PROPERTY\|fallback}}}`) | | `--html-file ` | string | At least one body flag | Path to HTML file | | `--text ` | string | At least one body flag | Plain-text body | +| `--react-email ` | string | At least one body flag | Path to React Email template (.tsx) — bundles and renders to HTML. Compatible with `--text` for plain-text fallback | | `--name ` | string | No | Internal label | | `--reply-to
` | string | No | Reply-to address | | `--preview-text ` | string | No | Preview text | @@ -66,6 +67,7 @@ Send a draft broadcast. | `--html ` | string | Update HTML body | | `--html-file ` | string | Path to HTML file | | `--text ` | string | Update plain-text body | +| `--react-email ` | string | Path to React Email template (.tsx) — bundles and renders to HTML | | `--name ` | string | Update internal label | --- diff --git a/skills/resend-cli/references/emails.md b/skills/resend-cli/references/emails.md index a5b18f9a..9174a681 100644 --- a/skills/resend-cli/references/emails.md +++ b/skills/resend-cli/references/emails.md @@ -13,9 +13,10 @@ Send an email via the Resend API. | `--from
` | string | Yes | Sender address (must be on a verified domain) | | `--to ` | string[] | Yes | Recipient(s), space-separated | | `--subject ` | string | Yes | Email subject line | -| `--text ` | string | One of text/html/html-file | Plain-text body | -| `--html ` | string | One of text/html/html-file | HTML body | -| `--html-file ` | string | One of text/html/html-file | Path to HTML file | +| `--text ` | string | One of text/html/html-file/react-email | Plain-text body | +| `--html ` | string | One of text/html/html-file/react-email | HTML body | +| `--html-file ` | string | One of text/html/html-file/react-email | Path to HTML file | +| `--react-email ` | string | One of text/html/html-file/react-email | Path to React Email template (.tsx) — bundles, renders to HTML, and sends | | `--cc ` | string[] | No | CC recipients | | `--bcc ` | string[] | No | BCC recipients | | `--reply-to
` | string | No | Reply-to address | @@ -72,6 +73,7 @@ Send up to 100 emails in a single request. | Flag | Type | Required | Description | |------|------|----------|-------------| | `--file ` | string | Yes (non-interactive) | Path to JSON file with email array | +| `--react-email ` | string | No | Path to React Email template (.tsx) — rendered HTML is set on every email in the batch | | `--idempotency-key ` | string | No | Deduplicate batch | | `--batch-validation ` | string | No | `strict` (fail all) or `permissive` (partial success) | diff --git a/skills/resend-cli/references/error-codes.md b/skills/resend-cli/references/error-codes.md index 1d53b90e..afc723f9 100644 --- a/skills/resend-cli/references/error-codes.md +++ b/skills/resend-cli/references/error-codes.md @@ -19,7 +19,9 @@ All errors exit with code `1` and output JSON to **stderr**: | Code | Cause | Resolution | |------|-------|------------| -| `missing_body` | None of `--text`, `--html`, or `--html-file` provided | Provide at least one body flag | +| `missing_body` | None of `--text`, `--html`, `--html-file`, or `--react-email` provided | Provide at least one body flag | +| `react_email_build_error` | Failed to bundle a React Email `.tsx` template with esbuild | Check the template compiles; ensure `react` and `@react-email/render` (or `@react-email/components`) are installed in the project | +| `react_email_render_error` | Bundled template failed during `render()` | Check the component exports a default function and renders valid React Email markup | | `file_read_error` | Could not read file from `--html-file` path | Check file path exists and is readable | | `send_error` | Resend API rejected the send request | Check from address is on a verified domain; check recipient is valid | diff --git a/skills/resend-cli/references/templates.md b/skills/resend-cli/references/templates.md index ed8f5846..e33866e9 100644 --- a/skills/resend-cli/references/templates.md +++ b/skills/resend-cli/references/templates.md @@ -19,8 +19,9 @@ Detailed flag specifications for `resend templates` commands. | Flag | Type | Required | Description | |------|------|----------|-------------| | `--name ` | string | Yes | Template name | -| `--html ` | string | One of html/html-file | HTML body with `{{{VAR_NAME}}}` placeholders | -| `--html-file ` | string | One of html/html-file | Path to HTML file | +| `--html ` | string | One of html/html-file/react-email | HTML body with `{{{VAR_NAME}}}` placeholders | +| `--html-file ` | string | One of html/html-file/react-email | Path to HTML file | +| `--react-email ` | string | One of html/html-file/react-email | Path to React Email template (.tsx) — bundles and renders to HTML | | `--subject ` | string | No | Email subject | | `--text ` | string | No | Plain-text body | | `--from
` | string | No | Sender address | @@ -42,7 +43,7 @@ Variable types: `string`, `number` **Argument:** `` — Template ID or alias -Same optional flags as `create`. At least one required. +Same optional flags as `create` (including `--react-email`). At least one required. --- diff --git a/skills/resend-cli/references/workflows.md b/skills/resend-cli/references/workflows.md index f2ed4fbf..efe23b80 100644 --- a/skills/resend-cli/references/workflows.md +++ b/skills/resend-cli/references/workflows.md @@ -42,6 +42,21 @@ resend emails send \ --cc manager@example.com \ --reply-to support@yourdomain.com +# React Email template (.tsx) — bundles, renders to HTML, and sends +resend emails send \ + --from "you@yourdomain.com" \ + --to recipient@example.com \ + --subject "Welcome" \ + --react-email ./emails/welcome.tsx + +# React Email with plain-text fallback +resend emails send \ + --from "you@yourdomain.com" \ + --to recipient@example.com \ + --subject "Welcome" \ + --react-email ./emails/welcome.tsx \ + --text "Welcome to our platform!" + # Scheduled email resend emails send \ --from "you@yourdomain.com" \ @@ -118,6 +133,14 @@ resend broadcasts create \ --html "

Hello {{{FIRST_NAME|there}}}

News content...

" \ --send +# Create broadcast from a React Email template +resend broadcasts create \ + --from "news@yourdomain.com" \ + --subject "Monthly Update" \ + --segment-id \ + --react-email ./emails/newsletter.tsx \ + --text "Plain-text fallback for email clients that don't support HTML" + # Or create as draft first, then send later resend broadcasts create \ --from "news@yourdomain.com" \ @@ -223,6 +246,14 @@ resend templates duplicate welcome-email # Update the copy resend templates update --name "Welcome Email v2" --subject "Hey {{{NAME}}}!" + +# Create a template from a React Email component +resend templates create \ + --name "Onboarding" \ + --react-email ./emails/onboarding.tsx + +# Update a template with a new React Email version +resend templates update --react-email ./emails/onboarding-v2.tsx ``` --- diff --git a/src/commands/broadcasts/create.ts b/src/commands/broadcasts/create.ts index 36ac2aa1..11ca6c77 100644 --- a/src/commands/broadcasts/create.ts +++ b/src/commands/broadcasts/create.ts @@ -9,6 +9,7 @@ import { readFile } from '../../lib/files'; import { buildHelpText } from '../../lib/help-text'; import { outputError } from '../../lib/output'; import { cancelAndExit } from '../../lib/prompts'; +import { buildReactEmailHtml } from '../../lib/react-email'; import { isInteractive } from '../../lib/tty'; export const createBroadcastCommand = new Command('create') @@ -29,6 +30,10 @@ export const createBroadcastCommand = new Command('create') '--text-file ', 'Path to a plain-text file for the body (use "-" for stdin)', ) + .option( + '--react-email ', + 'Path to a React Email template (.tsx) to bundle, render, and use as HTML body', + ) .option('--name ', 'Internal label for the broadcast (optional)') .option('--reply-to
', 'Reply-to address (optional)') .option( @@ -48,7 +53,7 @@ export const createBroadcastCommand = new Command('create') 'after', buildHelpText({ context: `Non-interactive: --from, --subject, and --segment-id are required. -Body: provide at least one of --html, --html-file, --text, or --text-file. +Body: provide at least one of --html, --html-file, --text, --text-file, or --react-email. Variable interpolation: HTML bodies support triple-brace syntax for contact properties. @@ -68,6 +73,8 @@ Scheduling: 'file_read_error', 'invalid_options', 'stdin_read_error', + 'react_email_build_error', + 'react_email_render_error', 'create_error', ], examples: [ @@ -93,6 +100,16 @@ Scheduling: ); } + if (opts.reactEmail && (opts.html || opts.htmlFile)) { + outputError( + { + message: 'Cannot use --react-email with --html or --html-file', + code: 'invalid_options', + }, + { json: globalOpts.json }, + ); + } + const resend = await requireClient(globalOpts); let from = opts.from; @@ -181,12 +198,16 @@ Scheduling: text = readFile(opts.textFile, globalOpts); } + if (opts.reactEmail) { + html = await buildReactEmailHtml(opts.reactEmail, globalOpts); + } + if (!html && !text) { if (!isInteractive() || globalOpts.json) { outputError( { message: - 'Missing body. Provide --html, --html-file, --text, or --text-file.', + 'Missing body. Provide --html, --html-file, --text, --text-file, or --react-email.', code: 'missing_body', }, { json: globalOpts.json }, diff --git a/src/commands/broadcasts/update.ts b/src/commands/broadcasts/update.ts index ba2583bb..0545166e 100644 --- a/src/commands/broadcasts/update.ts +++ b/src/commands/broadcasts/update.ts @@ -5,6 +5,7 @@ import { readFile } from '../../lib/files'; import { buildHelpText } from '../../lib/help-text'; import { outputError } from '../../lib/output'; import { pickId } from '../../lib/prompts'; +import { buildReactEmailHtml } from '../../lib/react-email'; import { broadcastPickerConfig } from './utils'; export const updateBroadcastCommand = new Command('update') @@ -27,6 +28,10 @@ export const updateBroadcastCommand = new Command('update') '--text-file ', 'Path to a plain-text file to replace the body (use "-" for stdin)', ) + .option( + '--react-email ', + 'Path to a React Email template (.tsx) to bundle, render, and use as HTML body', + ) .option('--name ', 'Update internal label') .addHelpText( 'after', @@ -44,6 +49,8 @@ Variable interpolation: 'file_read_error', 'invalid_options', 'stdin_read_error', + 'react_email_build_error', + 'react_email_render_error', 'update_error', ], examples: [ @@ -64,12 +71,13 @@ Variable interpolation: !opts.htmlFile && !opts.text && !opts.textFile && + !opts.reactEmail && !opts.name ) { outputError( { message: - 'Provide at least one option to update: --from, --subject, --html, --html-file, --text, --text-file, or --name.', + 'Provide at least one option to update: --from, --subject, --html, --html-file, --text, --text-file, --react-email, or --name.', code: 'no_changes', }, { json: globalOpts.json }, @@ -87,6 +95,16 @@ Variable interpolation: ); } + if (opts.reactEmail && (opts.html || opts.htmlFile)) { + outputError( + { + message: 'Cannot use --react-email with --html or --html-file', + code: 'invalid_options', + }, + { json: globalOpts.json }, + ); + } + const id = await pickId(idArg, broadcastPickerConfig, globalOpts); let html = opts.html; @@ -110,6 +128,10 @@ Variable interpolation: text = readFile(opts.textFile, globalOpts); } + if (opts.reactEmail) { + html = await buildReactEmailHtml(opts.reactEmail, globalOpts); + } + await runWrite( { spinner: { diff --git a/src/commands/emails/batch.ts b/src/commands/emails/batch.ts index 2b1bf3c5..cf319956 100644 --- a/src/commands/emails/batch.ts +++ b/src/commands/emails/batch.ts @@ -6,6 +6,7 @@ import { readFile } from '../../lib/files'; import { buildHelpText } from '../../lib/help-text'; import { outputError, outputResult } from '../../lib/output'; import { requireText } from '../../lib/prompts'; +import { buildReactEmailHtml } from '../../lib/react-email'; import { withSpinner } from '../../lib/spinner'; import { isInteractive } from '../../lib/tty'; @@ -15,6 +16,10 @@ export const batchCommand = new Command('batch') '--file ', 'Path to a JSON file containing an array of email objects (use "-" for stdin; required in non-interactive mode)', ) + .option( + '--react-email ', + 'Path to a React Email template (.tsx) — rendered HTML is set on every email in the batch', + ) .option( '--idempotency-key ', 'Deduplicate this batch request using this key', @@ -38,6 +43,8 @@ export const batchCommand = new Command('batch') 'stdin_read_error', 'invalid_json', 'invalid_format', + 'react_email_build_error', + 'react_email_render_error', 'batch_error', ], examples: [ @@ -98,6 +105,13 @@ export const batchCommand = new Command('batch') ); } + if (opts.reactEmail) { + const reactHtml = await buildReactEmailHtml(opts.reactEmail, globalOpts); + for (const email of emails) { + (email as Record).html = reactHtml; + } + } + for (let i = 0; i < emails.length; i++) { const email = emails[i] as Record; if ('attachments' in email) { diff --git a/src/commands/emails/send.ts b/src/commands/emails/send.ts index 9a7d8e36..3a85bd32 100644 --- a/src/commands/emails/send.ts +++ b/src/commands/emails/send.ts @@ -9,6 +9,7 @@ import { readFile } from '../../lib/files'; import { buildHelpText } from '../../lib/help-text'; import { outputError, outputResult } from '../../lib/output'; import { promptForMissing, requireText } from '../../lib/prompts'; +import { buildReactEmailHtml } from '../../lib/react-email'; import { withSpinner } from '../../lib/spinner'; import { isInteractive } from '../../lib/tty'; @@ -27,6 +28,10 @@ export const sendCommand = new Command('send') '--text-file ', 'Path to a plain-text file for the body (use "-" for stdin)', ) + .option( + '--react-email ', + 'Path to a React Email template (.tsx) to bundle, render, and send', + ) .option('--cc ', 'CC recipients') .option('--bcc ', 'BCC recipients') .option('--reply-to
', 'Reply-to address') @@ -56,7 +61,7 @@ export const sendCommand = new Command('send') 'after', buildHelpText({ context: - 'Required: --to and either --template or (--from, --subject, and one of --text | --text-file | --html | --html-file)', + 'Required: --to and either --template, --react-email, or (--from, --subject, and one of --text | --text-file | --html | --html-file)', output: ' {"id":""}', errorCodes: [ 'auth_error', @@ -69,6 +74,8 @@ export const sendCommand = new Command('send') 'invalid_var', 'template_body_conflict', 'template_attachment_conflict', + 'react_email_build_error', + 'react_email_render_error', 'send_error', ], examples: [ @@ -82,6 +89,7 @@ export const sendCommand = new Command('send') 'echo "Hello" | resend emails send --from you@domain.com --to user@example.com --subject "Hi" --text-file -', 'resend emails send --template tmpl_123 --to user@example.com', 'resend emails send --template tmpl_123 --to user@example.com --var name=John --var count=42', + 'resend emails send --from you@domain.com --to user@example.com --subject "Welcome" --react-email ./emails/welcome.tsx', 'RESEND_API_KEY=re_123 resend emails send --from you@domain.com --to user@example.com --subject "Hi" --text "Hi"', ], }), @@ -124,6 +132,18 @@ export const sendCommand = new Command('send') ); } + // Validate: --react-email is mutually exclusive with body and template flags + if (opts.reactEmail && (opts.html || opts.htmlFile || hasTemplate)) { + outputError( + { + message: + 'Cannot use --react-email with --html, --html-file, or --template', + code: 'invalid_options', + }, + { json: globalOpts.json }, + ); + } + // Validate: template and body flags are mutually exclusive if ( hasTemplate && @@ -229,8 +249,12 @@ export const sendCommand = new Command('send') text = readFile(opts.textFile, globalOpts); } + if (opts.reactEmail) { + html = await buildReactEmailHtml(opts.reactEmail, globalOpts); + } + let body: string | undefined = text; - if (!hasTemplate && !html && !text) { + if (!hasTemplate && !opts.reactEmail && !html && !text) { body = await requireText( undefined, { @@ -240,7 +264,7 @@ export const sendCommand = new Command('send') }, { message: - 'Missing email body. Provide --html, --html-file, --text, or --text-file', + 'Missing email body. Provide --html, --html-file, --text, --text-file, or --react-email', code: 'missing_body', }, globalOpts, diff --git a/src/commands/templates/create.ts b/src/commands/templates/create.ts index 8f5324c0..d43f7390 100644 --- a/src/commands/templates/create.ts +++ b/src/commands/templates/create.ts @@ -6,6 +6,7 @@ import { readFile } from '../../lib/files'; import { buildHelpText } from '../../lib/help-text'; import { outputError } from '../../lib/output'; import { cancelAndExit } from '../../lib/prompts'; +import { buildReactEmailHtml } from '../../lib/react-email'; import { isInteractive } from '../../lib/tty'; import { parseVariables } from './utils'; @@ -23,6 +24,10 @@ export const createTemplateCommand = new Command('create') '--text-file ', 'Path to a plain-text file for the body (use "-" for stdin)', ) + .option( + '--react-email ', + 'Path to a React Email template (.tsx) to bundle, render, and use as HTML body', + ) .option('--from
', 'Sender address') .option('--reply-to
', 'Reply-to address') .option('--alias ', 'Template alias for lookup by name') @@ -35,7 +40,7 @@ export const createTemplateCommand = new Command('create') buildHelpText({ context: `Creates a new draft template. Use "resend templates publish" to make it available for sending. ---name is required. Body: provide --html or --html-file. Optionally add --text or --text-file for plain-text. +--name is required. Body: provide --html, --html-file, or --react-email. Optionally add --text or --text-file for plain-text. --var declares a template variable using the format KEY:type or KEY:type:fallback. Valid types: string, number. @@ -43,7 +48,7 @@ export const createTemplateCommand = new Command('create') --html "

Hi {{{NAME}}}, your total is {{{PRICE}}}

" --var NAME:string --var PRICE:number:0 -Non-interactive: --name and a body (--html or --html-file) are required. --text-file provides a plain-text fallback.`, +Non-interactive: --name and a body (--html, --html-file, or --react-email) are required. --text-file provides a plain-text fallback.`, output: ` {"object":"template","id":""}`, errorCodes: [ 'auth_error', @@ -52,6 +57,8 @@ Non-interactive: --name and a body (--html or --html-file) are required. --text- 'file_read_error', 'invalid_options', 'stdin_read_error', + 'react_email_build_error', + 'react_email_render_error', 'create_error', ], examples: [ @@ -96,6 +103,16 @@ Non-interactive: --name and a body (--html or --html-file) are required. --text- ); } + if (opts.reactEmail && (opts.html || opts.htmlFile)) { + outputError( + { + message: 'Cannot use --react-email with --html or --html-file', + code: 'invalid_options', + }, + { json: globalOpts.json }, + ); + } + let html = opts.html; let text = opts.text; @@ -117,11 +134,16 @@ Non-interactive: --name and a body (--html or --html-file) are required. --text- text = readFile(opts.textFile, globalOpts); } + if (opts.reactEmail) { + html = await buildReactEmailHtml(opts.reactEmail, globalOpts); + } + if (!html) { if (!isInteractive() || globalOpts.json) { outputError( { - message: 'Missing body. Provide --html or --html-file.', + message: + 'Missing body. Provide --html, --html-file, or --react-email.', code: 'missing_body', }, { json: globalOpts.json }, diff --git a/src/commands/templates/update.ts b/src/commands/templates/update.ts index 4f477467..17fd32cb 100644 --- a/src/commands/templates/update.ts +++ b/src/commands/templates/update.ts @@ -5,6 +5,7 @@ import { readFile } from '../../lib/files'; import { buildHelpText } from '../../lib/help-text'; import { outputError } from '../../lib/output'; import { pickId } from '../../lib/prompts'; +import { buildReactEmailHtml } from '../../lib/react-email'; import { parseVariables, templatePickerConfig } from './utils'; export const updateTemplateCommand = new Command('update') @@ -22,6 +23,10 @@ export const updateTemplateCommand = new Command('update') '--text-file ', 'Path to a plain-text file to replace the body (use "-" for stdin)', ) + .option( + '--react-email ', + 'Path to a React Email template (.tsx) to bundle, render, and use as HTML body', + ) .option('--from
', 'Update sender address') .option('--reply-to
', 'Update reply-to address') .option('--alias ', 'Update template alias') @@ -44,6 +49,8 @@ export const updateTemplateCommand = new Command('update') 'file_read_error', 'invalid_options', 'stdin_read_error', + 'react_email_build_error', + 'react_email_render_error', 'update_error', ], examples: [ @@ -62,6 +69,7 @@ export const updateTemplateCommand = new Command('update') opts.name == null && opts.html == null && opts.htmlFile == null && + opts.reactEmail == null && opts.subject == null && opts.text == null && opts.textFile == null && @@ -73,13 +81,23 @@ export const updateTemplateCommand = new Command('update') outputError( { message: - 'Provide at least one option to update: --name, --html, --html-file, --subject, --text, --text-file, --from, --reply-to, --alias, or --var.', + 'Provide at least one option to update: --name, --html, --html-file, --react-email, --subject, --text, --text-file, --from, --reply-to, --alias, or --var.', code: 'no_changes', }, { json: globalOpts.json }, ); } + if (opts.reactEmail && (opts.html || opts.htmlFile)) { + outputError( + { + message: 'Cannot use --react-email with --html or --html-file', + code: 'invalid_options', + }, + { json: globalOpts.json }, + ); + } + if (opts.html && opts.htmlFile) { outputError( { @@ -117,6 +135,10 @@ export const updateTemplateCommand = new Command('update') text = readFile(opts.textFile, globalOpts); } + if (opts.reactEmail) { + html = await buildReactEmailHtml(opts.reactEmail, globalOpts); + } + await runWrite( { spinner: { diff --git a/src/lib/esbuild/escape-string-for-regex.ts b/src/lib/esbuild/escape-string-for-regex.ts new file mode 100644 index 00000000..71d343cd --- /dev/null +++ b/src/lib/esbuild/escape-string-for-regex.ts @@ -0,0 +1,3 @@ +export function escapeStringForRegex(string: string) { + return string.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&').replace(/-/g, '\\x2d'); +} diff --git a/src/lib/esbuild/rendering-utilities-exporter.ts b/src/lib/esbuild/rendering-utilities-exporter.ts new file mode 100644 index 00000000..26b381dc --- /dev/null +++ b/src/lib/esbuild/rendering-utilities-exporter.ts @@ -0,0 +1,51 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { Loader, PluginBuild, ResolveOptions } from 'esbuild'; +import { escapeStringForRegex } from './escape-string-for-regex'; + +export const renderingUtilitiesExporter = (emailTemplates: string[]) => ({ + name: 'rendering-utilities-exporter', + setup: (b: PluginBuild) => { + b.onLoad( + { + filter: new RegExp( + emailTemplates + .map((emailPath) => escapeStringForRegex(emailPath)) + .join('|'), + ), + }, + async ({ path: pathToFile }) => { + return { + contents: `${await fs.readFile(pathToFile, 'utf8')}; + export { render } from 'react-email-module-that-will-export-render' + export { createElement as reactEmailCreateReactElement } from 'react'; + `, + loader: path.extname(pathToFile).slice(1) as Loader, + }; + }, + ); + + b.onResolve( + { filter: /^react-email-module-that-will-export-render$/ }, + async (args) => { + const options: ResolveOptions = { + kind: 'import-statement', + importer: args.importer, + resolveDir: args.resolveDir, + namespace: args.namespace, + }; + let result = await b.resolve('@react-email/render', options); + if (result.errors.length === 0) { + return result; + } + + result = await b.resolve('@react-email/components', options); + if (result.errors.length > 0 && result.errors[0]) { + result.errors[0].text = + "Failed trying to import `render` from either `@react-email/render` or `@react-email/components` to be able to render your email template.\n Maybe you don't have either of them installed?"; + } + return result; + }, + ); + }, +}); diff --git a/src/lib/react-email-bundler.ts b/src/lib/react-email-bundler.ts new file mode 100644 index 00000000..b92a65e1 --- /dev/null +++ b/src/lib/react-email-bundler.ts @@ -0,0 +1,39 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { build } from 'esbuild'; +import { renderingUtilitiesExporter } from './esbuild/rendering-utilities-exporter'; + +export interface BundleResult { + cjsPath: string; + tmpDir: string; +} + +export async function bundleReactEmail( + templatePath: string, +): Promise { + const resolved = path.resolve(templatePath); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'resend-react-email-')); + + try { + await build({ + bundle: true, + entryPoints: [resolved], + format: 'cjs', + jsx: 'automatic', + logLevel: 'silent', + outExtension: { '.js': '.cjs' }, + outdir: tmpDir, + platform: 'node', + plugins: [renderingUtilitiesExporter([resolved])], + write: true, + }); + } catch (err) { + rmSync(tmpDir, { recursive: true, force: true }); + throw err; + } + + const baseName = path.basename(resolved, path.extname(resolved)); + const cjsPath = path.join(tmpDir, `${baseName}.cjs`); + return { cjsPath, tmpDir }; +} diff --git a/src/lib/react-email-renderer.ts b/src/lib/react-email-renderer.ts new file mode 100644 index 00000000..062bcc0c --- /dev/null +++ b/src/lib/react-email-renderer.ts @@ -0,0 +1,22 @@ +import { createRequire } from 'node:module'; + +export async function renderReactEmail(cjsPath: string): Promise { + const require = createRequire(import.meta.url); + delete require.cache[cjsPath]; + const emailModule = require(cjsPath) as { + default: (...args: unknown[]) => unknown; + render: ( + element: unknown, + options?: Record, + ) => Promise; + reactEmailCreateReactElement: ( + type: unknown, + props: Record, + ) => unknown; + }; + + return emailModule.render( + emailModule.reactEmailCreateReactElement(emailModule.default, {}), + {}, + ); +} diff --git a/src/lib/react-email.ts b/src/lib/react-email.ts new file mode 100644 index 00000000..b5180c8b --- /dev/null +++ b/src/lib/react-email.ts @@ -0,0 +1,75 @@ +import { existsSync, rmSync } from 'node:fs'; +import { resolve } from 'node:path'; +import type { GlobalOpts } from './client'; +import { errorMessage, outputError } from './output'; +import { bundleReactEmail } from './react-email-bundler'; +import { renderReactEmail } from './react-email-renderer'; +import { createSpinner } from './spinner'; + +function cleanupTmpDir(tmpDir: string | undefined) { + if (tmpDir) { + rmSync(tmpDir, { recursive: true, force: true }); + } +} + +/** + * Bundles and renders a React Email template (.tsx) to an HTML string. + * Shows spinners for each phase and exits with the appropriate error code on failure. + */ +export async function buildReactEmailHtml( + templatePath: string, + globalOpts: GlobalOpts, +): Promise { + const resolved = resolve(templatePath); + if (!existsSync(resolved)) { + return outputError( + { + message: `File not found: ${templatePath}`, + code: 'react_email_build_error', + }, + { json: globalOpts.json }, + ); + } + + const spinner = createSpinner( + 'Bundling React Email template...', + globalOpts.quiet, + ); + let tmpDir: string | undefined; + try { + const result = await bundleReactEmail(templatePath); + tmpDir = result.tmpDir; + spinner.stop('Bundled React Email template'); + + const renderSpinner = createSpinner( + 'Rendering React Email template...', + globalOpts.quiet, + ); + try { + const html = await renderReactEmail(result.cjsPath); + renderSpinner.stop('Rendered React Email template'); + cleanupTmpDir(tmpDir); + return html; + } catch (err) { + renderSpinner.fail('Failed to render React Email template'); + cleanupTmpDir(tmpDir); + return outputError( + { + message: errorMessage(err, 'Failed to render React Email template'), + code: 'react_email_render_error', + }, + { json: globalOpts.json }, + ); + } + } catch (err) { + spinner.fail('Failed to bundle React Email template'); + cleanupTmpDir(tmpDir); + return outputError( + { + message: errorMessage(err, 'Failed to bundle React Email template'), + code: 'react_email_build_error', + }, + { json: globalOpts.json }, + ); + } +} diff --git a/tests/commands/broadcasts/create.test.ts b/tests/commands/broadcasts/create.test.ts index a6ee71f3..4964e507 100644 --- a/tests/commands/broadcasts/create.test.ts +++ b/tests/commands/broadcasts/create.test.ts @@ -29,6 +29,14 @@ vi.mock('resend', () => ({ }, })); +const mockBuildReactEmailHtml = vi.fn( + async () => 'Rendered', +); + +vi.mock('../../../src/lib/react-email', () => ({ + buildReactEmailHtml: (...args: unknown[]) => mockBuildReactEmailHtml(...args), +})); + describe('broadcasts create command', () => { const restoreEnv = captureTestEnv(); let spies: ReturnType | undefined; @@ -41,6 +49,7 @@ describe('broadcasts create command', () => { beforeEach(() => { process.env.RESEND_API_KEY = 're_test_key'; mockCreate.mockClear(); + mockBuildReactEmailHtml.mockClear(); }); afterEach(() => { @@ -602,6 +611,64 @@ describe('broadcasts create command', () => { expect(args.text).toBe('stdin text content'); }); + test('creates broadcast with --react-email flag', async () => { + spies = setupOutputSpies(); + + const { createBroadcastCommand } = await import( + '../../../src/commands/broadcasts/create' + ); + await createBroadcastCommand.parseAsync( + [ + '--from', + 'hello@domain.com', + '--subject', + 'Weekly Update', + '--segment-id', + '7b1e0a3d-4c5f-4e8a-9b2d-1a3c5e7f9b2d', + '--react-email', + './emails/newsletter.tsx', + ], + { from: 'user' }, + ); + + expect(mockBuildReactEmailHtml).toHaveBeenCalledWith( + './emails/newsletter.tsx', + expect.anything(), + ); + const args = mockCreate.mock.calls[0][0] as Record; + expect(args.html).toBe('Rendered'); + }); + + test('errors with invalid_options when --react-email and --html used together', async () => { + setNonInteractive(); + errorSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + exitSpy = mockExitThrow(); + + const { createBroadcastCommand } = await import( + '../../../src/commands/broadcasts/create' + ); + await expectExit1(() => + createBroadcastCommand.parseAsync( + [ + '--from', + 'hello@domain.com', + '--subject', + 'News', + '--segment-id', + '7b1e0a3d-4c5f-4e8a-9b2d-1a3c5e7f9b2d', + '--react-email', + './emails/newsletter.tsx', + '--html', + '

Hi

', + ], + { from: 'user' }, + ), + ); + + const output = errorSpy.mock.calls.map((c) => c[0]).join(' '); + expect(output).toContain('invalid_options'); + }); + test('errors with file_read_error when --html-file path is unreadable', async () => { setNonInteractive(); errorSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); diff --git a/tests/commands/broadcasts/update.test.ts b/tests/commands/broadcasts/update.test.ts index 3405b8d1..4364d095 100644 --- a/tests/commands/broadcasts/update.test.ts +++ b/tests/commands/broadcasts/update.test.ts @@ -29,6 +29,14 @@ vi.mock('resend', () => ({ }, })); +const mockBuildReactEmailHtml = vi.fn( + async () => 'Rendered', +); + +vi.mock('../../../src/lib/react-email', () => ({ + buildReactEmailHtml: (...args: unknown[]) => mockBuildReactEmailHtml(...args), +})); + describe('broadcasts update command', () => { const restoreEnv = captureTestEnv(); let spies: ReturnType | undefined; @@ -40,6 +48,7 @@ describe('broadcasts update command', () => { beforeEach(() => { process.env.RESEND_API_KEY = 're_test_key'; mockUpdate.mockClear(); + mockBuildReactEmailHtml.mockClear(); }); afterEach(() => { @@ -339,6 +348,54 @@ describe('broadcasts update command', () => { expect(output).toContain('invalid_options'); }); + test('updates broadcast html with --react-email flag', async () => { + spies = setupOutputSpies(); + + const { updateBroadcastCommand } = await import( + '../../../src/commands/broadcasts/update' + ); + await updateBroadcastCommand.parseAsync( + [ + 'd1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6', + '--react-email', + './emails/newsletter.tsx', + ], + { from: 'user' }, + ); + + expect(mockBuildReactEmailHtml).toHaveBeenCalledWith( + './emails/newsletter.tsx', + expect.anything(), + ); + const payload = mockUpdate.mock.calls[0][1] as Record; + expect(payload.html).toBe('Rendered'); + }); + + test('errors with invalid_options when --react-email and --html used together', async () => { + setNonInteractive(); + errorSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + exitSpy = mockExitThrow(); + + const { updateBroadcastCommand } = await import( + '../../../src/commands/broadcasts/update' + ); + await expectExit1(() => + updateBroadcastCommand.parseAsync( + [ + 'd1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6', + '--react-email', + './emails/newsletter.tsx', + '--html', + '

Hi

', + ], + { from: 'user' }, + ), + ); + + const output = errorSpy.mock.calls.map((c) => c[0]).join(' '); + expect(output).toContain('invalid_options'); + }); + test('errors with file_read_error when --html-file path is unreadable', async () => { setNonInteractive(); errorSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); diff --git a/tests/commands/emails/batch.test.ts b/tests/commands/emails/batch.test.ts index 563dc064..048debae 100644 --- a/tests/commands/emails/batch.test.ts +++ b/tests/commands/emails/batch.test.ts @@ -32,6 +32,14 @@ vi.mock('resend', () => ({ }, })); +const mockBuildReactEmailHtml = vi.fn( + async () => 'Rendered', +); + +vi.mock('../../../src/lib/react-email', () => ({ + buildReactEmailHtml: (...args: unknown[]) => mockBuildReactEmailHtml(...args), +})); + const VALID_EMAILS = [ { from: 'you@domain.com', @@ -59,6 +67,7 @@ describe('batch command', () => { beforeEach(() => { process.env.RESEND_API_KEY = 're_test_key'; mockBatchSend.mockClear(); + mockBuildReactEmailHtml.mockClear(); }); afterEach(async () => { @@ -375,6 +384,41 @@ describe('batch command', () => { expect(mockBatchSend).toHaveBeenCalledTimes(1); }); + test('injects rendered HTML from --react-email into all batch emails', async () => { + spies = setupOutputSpies(); + + const emailsWithoutHtml = [ + { + from: 'you@domain.com', + to: ['user1@example.com'], + subject: 'Hello 1', + }, + { + from: 'you@domain.com', + to: ['user2@example.com'], + subject: 'Hello 2', + }, + ]; + const file = await writeTmpJson(emailsWithoutHtml); + const { batchCommand } = await import('../../../src/commands/emails/batch'); + await batchCommand.parseAsync( + ['--file', file, '--react-email', './emails/welcome.tsx'], + { from: 'user' }, + ); + + expect(mockBuildReactEmailHtml).toHaveBeenCalledWith( + './emails/welcome.tsx', + expect.anything(), + ); + expect(mockBatchSend).toHaveBeenCalledTimes(1); + const emails = mockBatchSend.mock.calls[0][0] as Array< + Record + >; + for (const email of emails) { + expect(email.html).toBe('Rendered'); + } + }); + test('errors with batch_error when SDK returns an error', async () => { setNonInteractive(); errorSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); diff --git a/tests/commands/emails/send.test.ts b/tests/commands/emails/send.test.ts index 47d65644..419e8292 100644 --- a/tests/commands/emails/send.test.ts +++ b/tests/commands/emails/send.test.ts @@ -13,6 +13,7 @@ import { } from 'vitest'; import { captureTestEnv, + ExitError, expectExit1, mockExitThrow, setNonInteractive, @@ -37,6 +38,14 @@ vi.mock('resend', () => ({ }, })); +const mockBuildReactEmailHtml = vi.fn( + async () => 'Rendered', +); + +vi.mock('../../../src/lib/react-email', () => ({ + buildReactEmailHtml: (...args: unknown[]) => mockBuildReactEmailHtml(...args), +})); + describe('send command', () => { const restoreEnv = captureTestEnv(); let spies: ReturnType | undefined; @@ -49,6 +58,7 @@ describe('send command', () => { process.env.RESEND_API_KEY = 're_test_key'; mockSend.mockClear(); mockDomainsList.mockClear(); + mockBuildReactEmailHtml.mockClear(); }); afterEach(() => { @@ -1073,6 +1083,165 @@ describe('send command', () => { expect(output).toContain('template_attachment_conflict'); }); + test('sends email with --react-email flag', async () => { + spies = setupOutputSpies(); + + const { sendCommand } = await import('../../../src/commands/emails/send'); + await sendCommand.parseAsync( + [ + '--from', + 'a@test.com', + '--to', + 'b@test.com', + '--subject', + 'Welcome', + '--react-email', + './emails/welcome.tsx', + ], + { from: 'user' }, + ); + + expect(mockBuildReactEmailHtml).toHaveBeenCalledWith( + './emails/welcome.tsx', + expect.anything(), + ); + expect(mockSend).toHaveBeenCalledTimes(1); + const callArgs = mockSend.mock.calls[0][0] as Record; + expect(callArgs.html).toBe('Rendered'); + }); + + test('errors with invalid_options when --react-email and --html used together', async () => { + setNonInteractive(); + errorSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + exitSpy = mockExitThrow(); + + const { sendCommand } = await import('../../../src/commands/emails/send'); + await expectExit1(() => + sendCommand.parseAsync( + [ + '--from', + 'a@test.com', + '--to', + 'b@test.com', + '--subject', + 'Test', + '--react-email', + './emails/welcome.tsx', + '--html', + '

Hi

', + ], + { from: 'user' }, + ), + ); + + const output = errorSpy.mock.calls.map((c) => c[0]).join(' '); + expect(output).toContain('invalid_options'); + }); + + test('errors with invalid_options when --react-email and --html-file used together', async () => { + setNonInteractive(); + errorSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + exitSpy = mockExitThrow(); + + const { sendCommand } = await import('../../../src/commands/emails/send'); + await expectExit1(() => + sendCommand.parseAsync( + [ + '--from', + 'a@test.com', + '--to', + 'b@test.com', + '--subject', + 'Test', + '--react-email', + './emails/welcome.tsx', + '--html-file', + './email.html', + ], + { from: 'user' }, + ), + ); + + const output = errorSpy.mock.calls.map((c) => c[0]).join(' '); + expect(output).toContain('invalid_options'); + }); + + test('allows --react-email with --text for plain-text fallback', async () => { + spies = setupOutputSpies(); + + const { sendCommand } = await import('../../../src/commands/emails/send'); + await sendCommand.parseAsync( + [ + '--from', + 'a@test.com', + '--to', + 'b@test.com', + '--subject', + 'Test', + '--react-email', + './emails/welcome.tsx', + '--text', + 'Plain text fallback', + ], + { from: 'user' }, + ); + + expect(mockSend).toHaveBeenCalledTimes(1); + const callArgs = mockSend.mock.calls[0][0] as Record; + expect(callArgs.html).toBe('Rendered'); + expect(callArgs.text).toBe('Plain text fallback'); + }); + + test('errors with invalid_options when --react-email and --template used together', async () => { + setNonInteractive(); + errorSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + exitSpy = mockExitThrow(); + + const { sendCommand } = await import('../../../src/commands/emails/send'); + await expectExit1(() => + sendCommand.parseAsync( + [ + '--to', + 'b@test.com', + '--react-email', + './emails/welcome.tsx', + '--template', + 'tmpl_123', + ], + { from: 'user' }, + ), + ); + + const output = errorSpy.mock.calls.map((c) => c[0]).join(' '); + expect(output).toContain('invalid_options'); + }); + + test('exits when buildReactEmailHtml fails', async () => { + setNonInteractive(); + errorSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + stderrSpy = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + mockBuildReactEmailHtml.mockRejectedValueOnce(new ExitError(1)); + + const { sendCommand } = await import('../../../src/commands/emails/send'); + await expectExit1(() => + sendCommand.parseAsync( + [ + '--from', + 'a@test.com', + '--to', + 'b@test.com', + '--subject', + 'Test', + '--react-email', + './emails/broken.tsx', + ], + { from: 'user' }, + ), + ); + }); + test('degrades gracefully when domain fetch fails', async () => { const { fetchVerifiedDomains } = await import('../../../src/lib/domains'); const failingResend = { diff --git a/tests/commands/templates/create.test.ts b/tests/commands/templates/create.test.ts index eab69e4a..0c6257da 100644 --- a/tests/commands/templates/create.test.ts +++ b/tests/commands/templates/create.test.ts @@ -29,6 +29,14 @@ vi.mock('resend', () => ({ }, })); +const mockBuildReactEmailHtml = vi.fn( + async () => 'Rendered', +); + +vi.mock('../../../src/lib/react-email', () => ({ + buildReactEmailHtml: (...args: unknown[]) => mockBuildReactEmailHtml(...args), +})); + describe('templates create command', () => { const restoreEnv = captureTestEnv(); let spies: ReturnType | undefined; @@ -41,6 +49,7 @@ describe('templates create command', () => { beforeEach(() => { process.env.RESEND_API_KEY = 're_test_key'; mockCreate.mockClear(); + mockBuildReactEmailHtml.mockClear(); }); afterEach(() => { @@ -241,6 +250,51 @@ describe('templates create command', () => { expect(output).toContain('auth_error'); }); + test('creates template with --react-email flag', async () => { + spies = setupOutputSpies(); + + const { createTemplateCommand } = await import( + '../../../src/commands/templates/create' + ); + await createTemplateCommand.parseAsync( + ['--name', 'Welcome', '--react-email', './emails/welcome.tsx'], + { from: 'user' }, + ); + + expect(mockBuildReactEmailHtml).toHaveBeenCalledWith( + './emails/welcome.tsx', + expect.anything(), + ); + const args = mockCreate.mock.calls[0][0] as Record; + expect(args.html).toBe('Rendered'); + }); + + test('errors with invalid_options when --react-email and --html used together', async () => { + setNonInteractive(); + errorSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + exitSpy = mockExitThrow(); + + const { createTemplateCommand } = await import( + '../../../src/commands/templates/create' + ); + await expectExit1(() => + createTemplateCommand.parseAsync( + [ + '--name', + 'Welcome', + '--react-email', + './emails/welcome.tsx', + '--html', + '

Hello

', + ], + { from: 'user' }, + ), + ); + + const output = errorSpy.mock.calls.map((c) => c[0]).join(' '); + expect(output).toContain('invalid_options'); + }); + test('errors with create_error when SDK returns an error', async () => { setNonInteractive(); mockCreate.mockResolvedValueOnce(