From 558a83d8a9e19fd11e1cf5b6e2336dfc2f6d252b Mon Sep 17 00:00:00 2001 From: Felipe Freitag Date: Mon, 23 Mar 2026 17:55:53 -0300 Subject: [PATCH 1/7] feat: add --react-email flag to emails send command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundles a React Email .tsx template with esbuild, renders it to HTML, and sends it in one command — matching the react-email export pipeline. --- src/commands/emails/send.ts | 78 +++++- src/lib/esbuild/escape-string-for-regex.ts | 3 + .../esbuild/rendering-utilities-exporter.ts | 51 ++++ src/lib/react-email-bundler.ts | 34 +++ src/lib/react-email-renderer.ts | 23 ++ tests/commands/emails/send.test.ts | 242 ++++++++++++++++++ 6 files changed, 426 insertions(+), 5 deletions(-) create mode 100644 src/lib/esbuild/escape-string-for-regex.ts create mode 100644 src/lib/esbuild/rendering-utilities-exporter.ts create mode 100644 src/lib/react-email-bundler.ts create mode 100644 src/lib/react-email-renderer.ts diff --git a/src/commands/emails/send.ts b/src/commands/emails/send.ts index 9a7d8e3..fc8254e 100644 --- a/src/commands/emails/send.ts +++ b/src/commands/emails/send.ts @@ -1,4 +1,4 @@ -import { readFileSync } from 'node:fs'; +import { readFileSync, rmSync } from 'node:fs'; import { basename } from 'node:path'; import { Command } from '@commander-js/extra-typings'; import type { CreateEmailOptions } from 'resend'; @@ -7,9 +7,11 @@ import { requireClient } from '../../lib/client'; import { fetchVerifiedDomains, promptForFromAddress } from '../../lib/domains'; import { readFile } from '../../lib/files'; import { buildHelpText } from '../../lib/help-text'; -import { outputError, outputResult } from '../../lib/output'; +import { errorMessage, outputError, outputResult } from '../../lib/output'; import { promptForMissing, requireText } from '../../lib/prompts'; -import { withSpinner } from '../../lib/spinner'; +import { bundleReactEmail } from '../../lib/react-email-bundler'; +import { renderReactEmail } from '../../lib/react-email-renderer'; +import { createSpinner, withSpinner } from '../../lib/spinner'; import { isInteractive } from '../../lib/tty'; export const sendCommand = new Command('send') @@ -27,6 +29,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 +62,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 +75,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 +90,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 +133,21 @@ export const sendCommand = new Command('send') ); } + // Validate: --react-email is mutually exclusive with body and template flags + if ( + opts.reactEmail && + (opts.html || opts.htmlFile || opts.text || opts.textFile || hasTemplate) + ) { + outputError( + { + message: + 'Cannot use --react-email with --html, --html-file, --text, --text-file, or --template', + code: 'invalid_options', + }, + { json: globalOpts.json }, + ); + } + // Validate: template and body flags are mutually exclusive if ( hasTemplate && @@ -229,8 +253,52 @@ export const sendCommand = new Command('send') text = readFile(opts.textFile, globalOpts); } + if (opts.reactEmail) { + const spinner = createSpinner( + 'Bundling React Email template...', + globalOpts.quiet, + ); + let cjsPath: string; + let tmpDir: string; + try { + const result = await bundleReactEmail(opts.reactEmail); + cjsPath = result.cjsPath; + tmpDir = result.tmpDir; + } catch (err) { + spinner.fail('Failed to bundle React Email template'); + return outputError( + { + message: errorMessage(err, 'Failed to bundle React Email template'), + code: 'react_email_build_error', + }, + { json: globalOpts.json }, + ); + } + spinner.stop('Bundled React Email template'); + + const renderSpinner = createSpinner( + 'Rendering React Email template...', + globalOpts.quiet, + ); + try { + html = await renderReactEmail(cjsPath); + } catch (err) { + renderSpinner.fail('Failed to render React Email template'); + return outputError( + { + message: errorMessage(err, 'Failed to render React Email template'), + code: 'react_email_render_error', + }, + { json: globalOpts.json }, + ); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + renderSpinner.stop('Rendered React Email template'); + } + let body: string | undefined = text; - if (!hasTemplate && !html && !text) { + if (!hasTemplate && !opts.reactEmail && !html && !text) { body = await requireText( undefined, { 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 0000000..71d343c --- /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 0000000..26b381d --- /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 0000000..fd0b4b6 --- /dev/null +++ b/src/lib/react-email-bundler.ts @@ -0,0 +1,34 @@ +import { mkdtempSync } 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-')); + + await build({ + bundle: true, + entryPoints: [resolved], + format: 'cjs', + jsx: 'automatic', + logLevel: 'silent', + outExtension: { '.js': '.cjs' }, + outdir: tmpDir, + platform: 'node', + plugins: [renderingUtilitiesExporter([resolved])], + write: true, + }); + + 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 0000000..a17b99c --- /dev/null +++ b/src/lib/react-email-renderer.ts @@ -0,0 +1,23 @@ +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); + +export async function renderReactEmail(cjsPath: string): Promise { + 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/tests/commands/emails/send.test.ts b/tests/commands/emails/send.test.ts index 47d6564..1d94f96 100644 --- a/tests/commands/emails/send.test.ts +++ b/tests/commands/emails/send.test.ts @@ -37,6 +37,23 @@ vi.mock('resend', () => ({ }, })); +const mockBundleReactEmail = vi.fn(async () => ({ + cjsPath: '/tmp/resend-react-email-test/welcome.cjs', + tmpDir: '/tmp/resend-react-email-test', +})); + +const mockRenderReactEmail = vi.fn( + async () => 'Rendered', +); + +vi.mock('../../../src/lib/react-email-bundler', () => ({ + bundleReactEmail: (...args: unknown[]) => mockBundleReactEmail(...args), +})); + +vi.mock('../../../src/lib/react-email-renderer', () => ({ + renderReactEmail: (...args: unknown[]) => mockRenderReactEmail(...args), +})); + describe('send command', () => { const restoreEnv = captureTestEnv(); let spies: ReturnType | undefined; @@ -49,6 +66,8 @@ describe('send command', () => { process.env.RESEND_API_KEY = 're_test_key'; mockSend.mockClear(); mockDomainsList.mockClear(); + mockBundleReactEmail.mockClear(); + mockRenderReactEmail.mockClear(); }); afterEach(() => { @@ -1073,6 +1092,229 @@ 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(mockBundleReactEmail).toHaveBeenCalledWith('./emails/welcome.tsx'); + expect(mockRenderReactEmail).toHaveBeenCalledWith( + '/tmp/resend-react-email-test/welcome.cjs', + ); + 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('errors with invalid_options when --react-email and --text 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', + '--text', + '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 --text-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', + '--text-file', + './body.txt', + ], + { 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 --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('errors with react_email_build_error when bundling fails', async () => { + setNonInteractive(); + errorSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + stderrSpy = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + exitSpy = mockExitThrow(); + mockBundleReactEmail.mockRejectedValueOnce(new Error('esbuild failed')); + + 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' }, + ), + ); + + const output = errorSpy.mock.calls.map((c) => c[0]).join(' '); + expect(output).toContain('react_email_build_error'); + }); + + test('errors with react_email_render_error when rendering fails', async () => { + setNonInteractive(); + errorSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + stderrSpy = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + exitSpy = mockExitThrow(); + mockRenderReactEmail.mockRejectedValueOnce(new Error('render() threw')); + + 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', + ], + { from: 'user' }, + ), + ); + + const output = errorSpy.mock.calls.map((c) => c[0]).join(' '); + expect(output).toContain('react_email_render_error'); + }); + test('degrades gracefully when domain fetch fails', async () => { const { fetchVerifiedDomains } = await import('../../../src/lib/domains'); const failingResend = { From 367aaeee8c4c7e7382ddb587aedcaecec5daf096 Mon Sep 17 00:00:00 2001 From: Felipe Freitag Date: Mon, 23 Mar 2026 18:13:43 -0300 Subject: [PATCH 2/7] feat: add --react-email flag to batch, broadcasts, and templates commands Extracts bundle+render pipeline into shared buildReactEmailHtml() helper and adds --react-email support to emails batch, broadcasts create, broadcasts update, and templates create. --- src/commands/broadcasts/create.ts | 25 +++++++++ src/commands/broadcasts/update.ts | 28 +++++++++- src/commands/emails/batch.ts | 14 +++++ src/commands/emails/send.ts | 51 ++---------------- src/commands/templates/create.ts | 25 +++++++++ src/lib/react-email.ts | 58 ++++++++++++++++++++ tests/commands/broadcasts/create.test.ts | 67 ++++++++++++++++++++++++ tests/commands/broadcasts/update.test.ts | 57 ++++++++++++++++++++ tests/commands/emails/batch.test.ts | 44 ++++++++++++++++ tests/commands/emails/send.test.ts | 63 ++++------------------ tests/commands/templates/create.test.ts | 54 +++++++++++++++++++ 11 files changed, 386 insertions(+), 100 deletions(-) create mode 100644 src/lib/react-email.ts diff --git a/src/commands/broadcasts/create.ts b/src/commands/broadcasts/create.ts index 36ac2aa..7be25f3 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( @@ -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,20 @@ Scheduling: ); } + if ( + opts.reactEmail && + (opts.html || opts.htmlFile || opts.text || opts.textFile) + ) { + outputError( + { + message: + 'Cannot use --react-email with --html, --html-file, --text, or --text-file', + code: 'invalid_options', + }, + { json: globalOpts.json }, + ); + } + const resend = await requireClient(globalOpts); let from = opts.from; @@ -181,6 +202,10 @@ Scheduling: text = readFile(opts.textFile, globalOpts); } + if (opts.reactEmail) { + html = await buildReactEmailHtml(opts.reactEmail, globalOpts); + } + if (!html && !text) { if (!isInteractive() || globalOpts.json) { outputError( diff --git a/src/commands/broadcasts/update.ts b/src/commands/broadcasts/update.ts index ba2583b..6426d9d 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,20 @@ Variable interpolation: ); } + if ( + opts.reactEmail && + (opts.html || opts.htmlFile || opts.text || opts.textFile) + ) { + outputError( + { + message: + 'Cannot use --react-email with --html, --html-file, --text, or --text-file', + code: 'invalid_options', + }, + { json: globalOpts.json }, + ); + } + const id = await pickId(idArg, broadcastPickerConfig, globalOpts); let html = opts.html; @@ -110,6 +132,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 2b1bf3c..cf31995 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 fc8254e..8a5f362 100644 --- a/src/commands/emails/send.ts +++ b/src/commands/emails/send.ts @@ -1,4 +1,4 @@ -import { readFileSync, rmSync } from 'node:fs'; +import { readFileSync } from 'node:fs'; import { basename } from 'node:path'; import { Command } from '@commander-js/extra-typings'; import type { CreateEmailOptions } from 'resend'; @@ -7,11 +7,10 @@ import { requireClient } from '../../lib/client'; import { fetchVerifiedDomains, promptForFromAddress } from '../../lib/domains'; import { readFile } from '../../lib/files'; import { buildHelpText } from '../../lib/help-text'; -import { errorMessage, outputError, outputResult } from '../../lib/output'; +import { outputError, outputResult } from '../../lib/output'; import { promptForMissing, requireText } from '../../lib/prompts'; -import { bundleReactEmail } from '../../lib/react-email-bundler'; -import { renderReactEmail } from '../../lib/react-email-renderer'; -import { createSpinner, withSpinner } from '../../lib/spinner'; +import { buildReactEmailHtml } from '../../lib/react-email'; +import { withSpinner } from '../../lib/spinner'; import { isInteractive } from '../../lib/tty'; export const sendCommand = new Command('send') @@ -254,47 +253,7 @@ export const sendCommand = new Command('send') } if (opts.reactEmail) { - const spinner = createSpinner( - 'Bundling React Email template...', - globalOpts.quiet, - ); - let cjsPath: string; - let tmpDir: string; - try { - const result = await bundleReactEmail(opts.reactEmail); - cjsPath = result.cjsPath; - tmpDir = result.tmpDir; - } catch (err) { - spinner.fail('Failed to bundle React Email template'); - return outputError( - { - message: errorMessage(err, 'Failed to bundle React Email template'), - code: 'react_email_build_error', - }, - { json: globalOpts.json }, - ); - } - spinner.stop('Bundled React Email template'); - - const renderSpinner = createSpinner( - 'Rendering React Email template...', - globalOpts.quiet, - ); - try { - html = await renderReactEmail(cjsPath); - } catch (err) { - renderSpinner.fail('Failed to render React Email template'); - return outputError( - { - message: errorMessage(err, 'Failed to render React Email template'), - code: 'react_email_render_error', - }, - { json: globalOpts.json }, - ); - } finally { - rmSync(tmpDir, { recursive: true, force: true }); - } - renderSpinner.stop('Rendered React Email template'); + html = await buildReactEmailHtml(opts.reactEmail, globalOpts); } let body: string | undefined = text; diff --git a/src/commands/templates/create.ts b/src/commands/templates/create.ts index 8f5324c..85c4b5c 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') @@ -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,20 @@ Non-interactive: --name and a body (--html or --html-file) are required. --text- ); } + if ( + opts.reactEmail && + (opts.html || opts.htmlFile || opts.text || opts.textFile) + ) { + outputError( + { + message: + 'Cannot use --react-email with --html, --html-file, --text, or --text-file', + code: 'invalid_options', + }, + { json: globalOpts.json }, + ); + } + let html = opts.html; let text = opts.text; @@ -117,6 +138,10 @@ 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( diff --git a/src/lib/react-email.ts b/src/lib/react-email.ts new file mode 100644 index 0000000..e08b062 --- /dev/null +++ b/src/lib/react-email.ts @@ -0,0 +1,58 @@ +import { rmSync } from 'node:fs'; +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'; + +/** + * 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 spinner = createSpinner( + 'Bundling React Email template...', + globalOpts.quiet, + ); + let cjsPath: string; + let tmpDir: string; + try { + const result = await bundleReactEmail(templatePath); + cjsPath = result.cjsPath; + tmpDir = result.tmpDir; + } catch (err) { + spinner.fail('Failed to bundle React Email template'); + return outputError( + { + message: errorMessage(err, 'Failed to bundle React Email template'), + code: 'react_email_build_error', + }, + { json: globalOpts.json }, + ); + } + spinner.stop('Bundled React Email template'); + + const renderSpinner = createSpinner( + 'Rendering React Email template...', + globalOpts.quiet, + ); + try { + const html = await renderReactEmail(cjsPath); + renderSpinner.stop('Rendered React Email template'); + return html; + } catch (err) { + renderSpinner.fail('Failed to render React Email template'); + return outputError( + { + message: errorMessage(err, 'Failed to render React Email template'), + code: 'react_email_render_error', + }, + { json: globalOpts.json }, + ); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } +} diff --git a/tests/commands/broadcasts/create.test.ts b/tests/commands/broadcasts/create.test.ts index a6ee71f..4964e50 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 3405b8d..4364d09 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 563dc06..048deba 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 1d94f96..c9b577f 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,21 +38,12 @@ vi.mock('resend', () => ({ }, })); -const mockBundleReactEmail = vi.fn(async () => ({ - cjsPath: '/tmp/resend-react-email-test/welcome.cjs', - tmpDir: '/tmp/resend-react-email-test', -})); - -const mockRenderReactEmail = vi.fn( +const mockBuildReactEmailHtml = vi.fn( async () => 'Rendered', ); -vi.mock('../../../src/lib/react-email-bundler', () => ({ - bundleReactEmail: (...args: unknown[]) => mockBundleReactEmail(...args), -})); - -vi.mock('../../../src/lib/react-email-renderer', () => ({ - renderReactEmail: (...args: unknown[]) => mockRenderReactEmail(...args), +vi.mock('../../../src/lib/react-email', () => ({ + buildReactEmailHtml: (...args: unknown[]) => mockBuildReactEmailHtml(...args), })); describe('send command', () => { @@ -66,8 +58,7 @@ describe('send command', () => { process.env.RESEND_API_KEY = 're_test_key'; mockSend.mockClear(); mockDomainsList.mockClear(); - mockBundleReactEmail.mockClear(); - mockRenderReactEmail.mockClear(); + mockBuildReactEmailHtml.mockClear(); }); afterEach(() => { @@ -1110,9 +1101,9 @@ describe('send command', () => { { from: 'user' }, ); - expect(mockBundleReactEmail).toHaveBeenCalledWith('./emails/welcome.tsx'); - expect(mockRenderReactEmail).toHaveBeenCalledWith( - '/tmp/resend-react-email-test/welcome.cjs', + expect(mockBuildReactEmailHtml).toHaveBeenCalledWith( + './emails/welcome.tsx', + expect.anything(), ); expect(mockSend).toHaveBeenCalledTimes(1); const callArgs = mockSend.mock.calls[0][0] as Record; @@ -1255,14 +1246,13 @@ describe('send command', () => { expect(output).toContain('invalid_options'); }); - test('errors with react_email_build_error when bundling fails', async () => { + test('exits when buildReactEmailHtml fails', async () => { setNonInteractive(); errorSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); stderrSpy = vi .spyOn(process.stderr, 'write') .mockImplementation(() => true); - exitSpy = mockExitThrow(); - mockBundleReactEmail.mockRejectedValueOnce(new Error('esbuild failed')); + mockBuildReactEmailHtml.mockRejectedValueOnce(new ExitError(1)); const { sendCommand } = await import('../../../src/commands/emails/send'); await expectExit1(() => @@ -1280,39 +1270,6 @@ describe('send command', () => { { from: 'user' }, ), ); - - const output = errorSpy.mock.calls.map((c) => c[0]).join(' '); - expect(output).toContain('react_email_build_error'); - }); - - test('errors with react_email_render_error when rendering fails', async () => { - setNonInteractive(); - errorSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - stderrSpy = vi - .spyOn(process.stderr, 'write') - .mockImplementation(() => true); - exitSpy = mockExitThrow(); - mockRenderReactEmail.mockRejectedValueOnce(new Error('render() threw')); - - 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', - ], - { from: 'user' }, - ), - ); - - const output = errorSpy.mock.calls.map((c) => c[0]).join(' '); - expect(output).toContain('react_email_render_error'); }); test('degrades gracefully when domain fetch fails', async () => { diff --git a/tests/commands/templates/create.test.ts b/tests/commands/templates/create.test.ts index eab69e4..0c6257d 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( From dc580901ff2d8990c15063cc4979d0ad4e0efb9f Mon Sep 17 00:00:00 2001 From: Felipe Freitag Date: Tue, 24 Mar 2026 07:59:22 -0300 Subject: [PATCH 3/7] fix: address review feedback for --react-email flag - Clean up temp dir on build failure (wrap both phases in single finally) - Add file-existence check before bundling for clearer error - Move createRequire inside function to avoid module-scope side effect - Update all help text and error messages to mention --react-email --- src/commands/broadcasts/create.ts | 4 +-- src/commands/emails/send.ts | 2 +- src/commands/templates/create.ts | 7 ++-- src/lib/react-email-renderer.ts | 3 +- src/lib/react-email.ts | 60 ++++++++++++++++++------------- 5 files changed, 44 insertions(+), 32 deletions(-) diff --git a/src/commands/broadcasts/create.ts b/src/commands/broadcasts/create.ts index 7be25f3..bc4ddc0 100644 --- a/src/commands/broadcasts/create.ts +++ b/src/commands/broadcasts/create.ts @@ -53,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. @@ -211,7 +211,7 @@ Scheduling: 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/emails/send.ts b/src/commands/emails/send.ts index 8a5f362..72530cc 100644 --- a/src/commands/emails/send.ts +++ b/src/commands/emails/send.ts @@ -267,7 +267,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 85c4b5c..435c6d3 100644 --- a/src/commands/templates/create.ts +++ b/src/commands/templates/create.ts @@ -40,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. @@ -48,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', @@ -146,7 +146,8 @@ Non-interactive: --name and a body (--html or --html-file) are required. --text- 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/lib/react-email-renderer.ts b/src/lib/react-email-renderer.ts index a17b99c..062bcc0 100644 --- a/src/lib/react-email-renderer.ts +++ b/src/lib/react-email-renderer.ts @@ -1,8 +1,7 @@ import { createRequire } from 'node:module'; -const require = createRequire(import.meta.url); - 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; diff --git a/src/lib/react-email.ts b/src/lib/react-email.ts index e08b062..3d22fc1 100644 --- a/src/lib/react-email.ts +++ b/src/lib/react-email.ts @@ -1,4 +1,5 @@ -import { rmSync } from 'node:fs'; +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'; @@ -13,46 +14,57 @@ export async function buildReactEmailHtml( templatePath: string, globalOpts: GlobalOpts, ): Promise { - const spinner = createSpinner( - 'Bundling React Email template...', - globalOpts.quiet, - ); - let cjsPath: string; - let tmpDir: string; - try { - const result = await bundleReactEmail(templatePath); - cjsPath = result.cjsPath; - tmpDir = result.tmpDir; - } catch (err) { - spinner.fail('Failed to bundle React Email template'); + const resolved = resolve(templatePath); + if (!existsSync(resolved)) { return outputError( { - message: errorMessage(err, 'Failed to bundle React Email template'), + message: `File not found: ${templatePath}`, code: 'react_email_build_error', }, { json: globalOpts.json }, ); } - spinner.stop('Bundled React Email template'); - const renderSpinner = createSpinner( - 'Rendering React Email template...', + const spinner = createSpinner( + 'Bundling React Email template...', globalOpts.quiet, ); + let tmpDir: string | undefined; try { - const html = await renderReactEmail(cjsPath); - renderSpinner.stop('Rendered React Email template'); - return html; + 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'); + return html; + } catch (err) { + renderSpinner.fail('Failed to render React Email template'); + return outputError( + { + message: errorMessage(err, 'Failed to render React Email template'), + code: 'react_email_render_error', + }, + { json: globalOpts.json }, + ); + } } catch (err) { - renderSpinner.fail('Failed to render React Email template'); + spinner.fail('Failed to bundle React Email template'); return outputError( { - message: errorMessage(err, 'Failed to render React Email template'), - code: 'react_email_render_error', + message: errorMessage(err, 'Failed to bundle React Email template'), + code: 'react_email_build_error', }, { json: globalOpts.json }, ); } finally { - rmSync(tmpDir, { recursive: true, force: true }); + if (tmpDir) { + rmSync(tmpDir, { recursive: true, force: true }); + } } } From 92ba04e3c8b9fe0b881b23c5eaaf15b06438efab Mon Sep 17 00:00:00 2001 From: Felipe Freitag Date: Tue, 24 Mar 2026 09:26:34 -0300 Subject: [PATCH 4/7] fix: allow --react-email with --text/--text-file and clean up temp dir on build failure - Narrow conflict check to only --html/--html-file (and --template for send), allowing --text/--text-file as a plain-text fallback alongside --react-email - Clean up temp directory in bundleReactEmail when esbuild throws --- src/commands/broadcasts/create.ts | 8 +--- src/commands/broadcasts/update.ts | 8 +--- src/commands/emails/send.ts | 7 +-- src/commands/templates/create.ts | 8 +--- src/lib/react-email-bundler.ts | 31 +++++++------ tests/commands/emails/send.test.ts | 70 +++++++++--------------------- 6 files changed, 46 insertions(+), 86 deletions(-) diff --git a/src/commands/broadcasts/create.ts b/src/commands/broadcasts/create.ts index bc4ddc0..11ca6c7 100644 --- a/src/commands/broadcasts/create.ts +++ b/src/commands/broadcasts/create.ts @@ -100,14 +100,10 @@ Scheduling: ); } - if ( - opts.reactEmail && - (opts.html || opts.htmlFile || opts.text || opts.textFile) - ) { + if (opts.reactEmail && (opts.html || opts.htmlFile)) { outputError( { - message: - 'Cannot use --react-email with --html, --html-file, --text, or --text-file', + message: 'Cannot use --react-email with --html or --html-file', code: 'invalid_options', }, { json: globalOpts.json }, diff --git a/src/commands/broadcasts/update.ts b/src/commands/broadcasts/update.ts index 6426d9d..0545166 100644 --- a/src/commands/broadcasts/update.ts +++ b/src/commands/broadcasts/update.ts @@ -95,14 +95,10 @@ Variable interpolation: ); } - if ( - opts.reactEmail && - (opts.html || opts.htmlFile || opts.text || opts.textFile) - ) { + if (opts.reactEmail && (opts.html || opts.htmlFile)) { outputError( { - message: - 'Cannot use --react-email with --html, --html-file, --text, or --text-file', + message: 'Cannot use --react-email with --html or --html-file', code: 'invalid_options', }, { json: globalOpts.json }, diff --git a/src/commands/emails/send.ts b/src/commands/emails/send.ts index 72530cc..3a85bd3 100644 --- a/src/commands/emails/send.ts +++ b/src/commands/emails/send.ts @@ -133,14 +133,11 @@ export const sendCommand = new Command('send') } // Validate: --react-email is mutually exclusive with body and template flags - if ( - opts.reactEmail && - (opts.html || opts.htmlFile || opts.text || opts.textFile || hasTemplate) - ) { + if (opts.reactEmail && (opts.html || opts.htmlFile || hasTemplate)) { outputError( { message: - 'Cannot use --react-email with --html, --html-file, --text, --text-file, or --template', + 'Cannot use --react-email with --html, --html-file, or --template', code: 'invalid_options', }, { json: globalOpts.json }, diff --git a/src/commands/templates/create.ts b/src/commands/templates/create.ts index 435c6d3..d43f739 100644 --- a/src/commands/templates/create.ts +++ b/src/commands/templates/create.ts @@ -103,14 +103,10 @@ Non-interactive: --name and a body (--html, --html-file, or --react-email) are r ); } - if ( - opts.reactEmail && - (opts.html || opts.htmlFile || opts.text || opts.textFile) - ) { + if (opts.reactEmail && (opts.html || opts.htmlFile)) { outputError( { - message: - 'Cannot use --react-email with --html, --html-file, --text, or --text-file', + message: 'Cannot use --react-email with --html or --html-file', code: 'invalid_options', }, { json: globalOpts.json }, diff --git a/src/lib/react-email-bundler.ts b/src/lib/react-email-bundler.ts index fd0b4b6..b92a65e 100644 --- a/src/lib/react-email-bundler.ts +++ b/src/lib/react-email-bundler.ts @@ -1,4 +1,4 @@ -import { mkdtempSync } from 'node:fs'; +import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { build } from 'esbuild'; @@ -15,18 +15,23 @@ export async function bundleReactEmail( const resolved = path.resolve(templatePath); const tmpDir = mkdtempSync(path.join(tmpdir(), 'resend-react-email-')); - await build({ - bundle: true, - entryPoints: [resolved], - format: 'cjs', - jsx: 'automatic', - logLevel: 'silent', - outExtension: { '.js': '.cjs' }, - outdir: tmpDir, - platform: 'node', - plugins: [renderingUtilitiesExporter([resolved])], - write: true, - }); + 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`); diff --git a/tests/commands/emails/send.test.ts b/tests/commands/emails/send.test.ts index c9b577f..419e829 100644 --- a/tests/commands/emails/send.test.ts +++ b/tests/commands/emails/send.test.ts @@ -1166,60 +1166,30 @@ describe('send command', () => { expect(output).toContain('invalid_options'); }); - test('errors with invalid_options when --react-email and --text 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', - '--text', - '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 --text-file used together', async () => { - setNonInteractive(); - errorSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - exitSpy = mockExitThrow(); + test('allows --react-email with --text for plain-text fallback', async () => { + spies = setupOutputSpies(); 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', - '--text-file', - './body.txt', - ], - { from: 'user' }, - ), + await sendCommand.parseAsync( + [ + '--from', + 'a@test.com', + '--to', + 'b@test.com', + '--subject', + 'Test', + '--react-email', + './emails/welcome.tsx', + '--text', + 'Plain text fallback', + ], + { from: 'user' }, ); - const output = errorSpy.mock.calls.map((c) => c[0]).join(' '); - expect(output).toContain('invalid_options'); + 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 () => { From ca2d3d8cb4e043816090adabd21c7431cd0ffda6 Mon Sep 17 00:00:00 2001 From: Felipe Freitag Date: Tue, 24 Mar 2026 09:33:57 -0300 Subject: [PATCH 5/7] fix: clean up temp dir before process.exit in error paths process.exit() does not run finally blocks, so temp directories were leaked on render failure. Now cleanup runs explicitly before outputError. --- src/lib/react-email.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/lib/react-email.ts b/src/lib/react-email.ts index 3d22fc1..b5180c8 100644 --- a/src/lib/react-email.ts +++ b/src/lib/react-email.ts @@ -6,6 +6,12 @@ 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. @@ -42,9 +48,11 @@ export async function buildReactEmailHtml( 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'), @@ -55,6 +63,7 @@ export async function buildReactEmailHtml( } } catch (err) { spinner.fail('Failed to bundle React Email template'); + cleanupTmpDir(tmpDir); return outputError( { message: errorMessage(err, 'Failed to bundle React Email template'), @@ -62,9 +71,5 @@ export async function buildReactEmailHtml( }, { json: globalOpts.json }, ); - } finally { - if (tmpDir) { - rmSync(tmpDir, { recursive: true, force: true }); - } } } From 6471e0493f2656f2966132de958490b3f3d075b3 Mon Sep 17 00:00:00 2001 From: Felipe Freitag Date: Tue, 24 Mar 2026 09:55:00 -0300 Subject: [PATCH 6/7] feat: add --react-email flag to templates update command --- src/commands/templates/update.ts | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/commands/templates/update.ts b/src/commands/templates/update.ts index 4f47746..17fd32c 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: { From e3fb61e84e8b14e516bda07748e1fe5d60133165 Mon Sep 17 00:00:00 2001 From: Felipe Freitag Date: Tue, 24 Mar 2026 11:07:39 -0300 Subject: [PATCH 7/7] docs: update resend-cli skill for --react-email flag - Add React Email to skill description for better triggering - Document --react-email in emails, broadcasts, templates references - Add react_email_build_error and react_email_render_error to error codes - Add React Email examples to workflow recipes (send, broadcast, template) --- skills/resend-cli/SKILL.md | 14 +++++++--- skills/resend-cli/references/broadcasts.md | 2 ++ skills/resend-cli/references/emails.md | 8 ++++-- skills/resend-cli/references/error-codes.md | 4 ++- skills/resend-cli/references/templates.md | 7 +++-- skills/resend-cli/references/workflows.md | 31 +++++++++++++++++++++ 6 files changed, 55 insertions(+), 11 deletions(-) diff --git a/skills/resend-cli/SKILL.md b/skills/resend-cli/SKILL.md index 1b94bf1..91e14cb 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 e49d20f..60d8c58 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 a5b18f9..9174a68 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 1d53b90..afc723f 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 ed8f584..e33866e 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 f2ed4fb..efe23b8 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 ``` ---