From 708bf309d62258be578ff3453d97b424c505db7b Mon Sep 17 00:00:00 2001 From: pcristin Date: Fri, 13 Mar 2026 21:10:03 +0300 Subject: [PATCH 1/2] feat(emails): add --react-file flag to emails send --- README.md | 23 +++++++--- src/commands/emails/send.ts | 48 +++++++++++++++++--- tests/commands/emails/send.test.ts | 70 ++++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 9cc9bce..afbe9ee 100644 --- a/README.md +++ b/README.md @@ -227,9 +227,10 @@ resend emails send \ | `--from
` | Yes | Sender email address (must be from a verified domain) | | `--to ` | Yes | One or more recipient email addresses (space-separated) | | `--subject ` | Yes | Email subject line | -| `--text ` | One of text/html/html-file | Plain text body | -| `--html ` | One of text/html/html-file | HTML body as a string | -| `--html-file ` | One of text/html/html-file | Path to an HTML file to use as body | +| `--text ` | One of text/html/html-file/react-file | Plain text body | +| `--html ` | One of text/html/html-file/react-file | HTML body as a string | +| `--html-file ` | One of text/html/html-file/react-file | Path to an HTML file to use as body | +| `--react-file ` | One of text/html/html-file/react-file | Path to a React module (default export) for the body | | `--cc ` | No | CC recipients (space-separated) | | `--bcc ` | No | BCC recipients (space-separated) | | `--reply-to
` | No | Reply-to email address | @@ -255,7 +256,7 @@ echo "" | resend emails send --from "you@yourdomain.com" # Error: Missing required flags: --to, --subject ``` -A body (`--text`, `--html`, or `--html-file`) is also required — omitting all three exits with code `missing_body`. +A body (`--text`, `--html`, `--html-file`, or `--react-file`) is also required — omitting all four exits with code `missing_body`. #### Examples @@ -279,6 +280,16 @@ resend emails send \ --html-file ./newsletter.html ``` +**React template from a file:** + +```bash +resend emails send \ + --from "you@yourdomain.com" \ + --to recipient@example.com \ + --subject "Newsletter" \ + --react-file ./email.react.mjs +``` + **With CC, BCC, and reply-to:** ```bash @@ -315,8 +326,8 @@ Returns the email ID on success: | Code | Cause | |------|-------| | `auth_error` | No API key found or client creation failed | -| `missing_body` | No `--text`, `--html`, or `--html-file` provided | -| `file_read_error` | Could not read the file passed to `--html-file` | +| `missing_body` | No `--text`, `--html`, `--html-file`, or `--react-file` provided | +| `file_read_error` | Could not read `--html-file` or load `--react-file` | | `send_error` | Resend API returned an error | --- diff --git a/src/commands/emails/send.ts b/src/commands/emails/send.ts index d232d61..fced7a6 100644 --- a/src/commands/emails/send.ts +++ b/src/commands/emails/send.ts @@ -1,5 +1,6 @@ import { readFileSync } from 'node:fs'; -import { basename } from 'node:path'; +import { basename, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; import * as p from '@clack/prompts'; import { Command } from '@commander-js/extra-typings'; import type { Resend } from 'resend'; @@ -80,6 +81,27 @@ async function promptForFromAddress(domains: string[]): Promise { return result; } +async function loadReactFile( + filePath: string, + globalOpts: GlobalOpts, +): Promise { + try { + const mod = await import(pathToFileURL(resolve(filePath)).href); + if (mod.default === undefined) { + throw new Error('Missing default export'); + } + return mod.default; + } catch { + outputError( + { + message: `Failed to load React file: ${filePath}`, + code: 'file_read_error', + }, + { json: globalOpts.json }, + ); + } +} + export const sendCommand = new Command('send') .description('Send an email') .option('--from
', 'Sender address (required)') @@ -87,6 +109,7 @@ export const sendCommand = new Command('send') .option('--subject ', 'Email subject (required)') .option('--html ', 'HTML body') .option('--html-file ', 'Path to an HTML file for the body') + .option('--react-file ', 'Path to a React module for the body') .option('--text ', 'Plain-text body') .option('--cc ', 'CC recipients') .option('--bcc ', 'BCC recipients') @@ -112,7 +135,7 @@ export const sendCommand = new Command('send') 'after', buildHelpText({ context: - 'Required: --from, --to, --subject, and one of --text | --html | --html-file', + 'Required: --from, --to, --subject, and one of --text | --html | --html-file | --react-file', output: ' {"id":""}', errorCodes: [ 'auth_error', @@ -126,6 +149,7 @@ export const sendCommand = new Command('send') 'resend emails send --from you@domain.com --to user@example.com --subject "Hello" --text "Hi"', 'resend emails send --from you@domain.com --to a@example.com --to b@example.com --subject "Hi" --html "Hi" --json', 'resend emails send --from you@domain.com --to user@example.com --subject "Hi" --html-file ./email.html --json', + 'resend emails send --from you@domain.com --to user@example.com --subject "Hi" --react-file ./email.react.mjs --json', 'resend emails send --from you@domain.com --to user@example.com --subject "Hi" --text "Hi" --scheduled-at 2024-08-05T11:52:01.858Z', 'resend emails send --from you@domain.com --to user@example.com --subject "Hi" --text "Hi" --attachment ./report.pdf', 'resend emails send --from you@domain.com --to user@example.com --subject "Hi" --text "Hi" --headers X-Entity-Ref-ID=123 --tags category=marketing', @@ -166,15 +190,20 @@ export const sendCommand = new Command('send') globalOpts, ); + let react: unknown; + if (opts.reactFile) { + react = await loadReactFile(opts.reactFile, globalOpts); + } + let html = opts.html; const text = opts.text; - if (opts.htmlFile) { + if (!react && opts.htmlFile) { html = readFile(opts.htmlFile, globalOpts); } let body: string | undefined = text; - if (!html && !text) { + if (!react && !html && !text) { body = await requireText( undefined, { @@ -182,7 +211,8 @@ export const sendCommand = new Command('send') placeholder: 'Type your message...', }, { - message: 'Missing email body. Provide --html, --html-file, or --text', + message: + 'Missing email body. Provide --html, --html-file, --react-file, or --text', code: 'missing_body', }, globalOpts, @@ -241,6 +271,12 @@ export const sendCommand = new Command('send') return { name: t.slice(0, eq), value: t.slice(eq + 1) }; }); + const bodyPayload = react + ? { react } + : html + ? { html } + : { text: body as string }; + const data = await withSpinner( { loading: 'Sending email...', @@ -253,7 +289,7 @@ export const sendCommand = new Command('send') from: filled.from, to: toAddresses, subject: filled.subject, - ...(html ? { html } : { text: body as string }), + ...bodyPayload, ...(opts.cc && { cc: opts.cc }), ...(opts.bcc && { bcc: opts.bcc }), ...(opts.replyTo && { replyTo: opts.replyTo }), diff --git a/tests/commands/emails/send.test.ts b/tests/commands/emails/send.test.ts index 84b74ee..ccae3bd 100644 --- a/tests/commands/emails/send.test.ts +++ b/tests/commands/emails/send.test.ts @@ -245,6 +245,76 @@ describe('send command', () => { } }); + test('reads React body from --react-file', async () => { + spies = setupOutputSpies(); + + const tmpFile = join( + dirname(fileURLToPath(import.meta.url)), + '__test_email.react.mjs', + ); + writeFileSync(tmpFile, 'export default { type: "email-template" };'); + + try { + const { sendCommand } = await import('../../../src/commands/emails/send'); + await sendCommand.parseAsync( + [ + '--from', + 'a@test.com', + '--to', + 'b@test.com', + '--subject', + 'Test', + '--react-file', + tmpFile, + ], + { from: 'user' }, + ); + + const callArgs = mockSend.mock.calls[0][0] as Record; + expect(callArgs.react).toEqual({ type: 'email-template' }); + expect(callArgs.html).toBeUndefined(); + expect(callArgs.text).toBeUndefined(); + } finally { + unlinkSync(tmpFile); + } + }); + + test('errors with file_read_error when --react-file has no default export', async () => { + setNonInteractive(); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + exitSpy = mockExitThrow(); + + const tmpFile = join( + dirname(fileURLToPath(import.meta.url)), + '__test_email.no_default.mjs', + ); + writeFileSync(tmpFile, 'export const template = "missing-default";'); + + try { + const { sendCommand } = await import('../../../src/commands/emails/send'); + await expectExit1(() => + sendCommand.parseAsync( + [ + '--from', + 'a@test.com', + '--to', + 'b@test.com', + '--subject', + 'Test', + '--react-file', + tmpFile, + ], + { from: 'user' }, + ), + ); + } finally { + unlinkSync(tmpFile); + } + + const output = errorSpy.mock.calls.map((c) => c[0]).join(' '); + expect(output).toContain('file_read_error'); + }); + test('passes cc, bcc, reply-to when provided', async () => { spies = setupOutputSpies(); From c66655a600e9aac24cb4c8234e79d263ccf19cb5 Mon Sep 17 00:00:00 2001 From: pcristin Date: Fri, 13 Mar 2026 21:28:42 +0300 Subject: [PATCH 2/2] fix(emails): clarify and enforce react-file export contract --- README.md | 4 +- src/commands/emails/send.ts | 14 +++++- tests/commands/emails/send.test.ts | 77 ++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index afbe9ee..399374e 100644 --- a/README.md +++ b/README.md @@ -230,7 +230,7 @@ resend emails send \ | `--text ` | One of text/html/html-file/react-file | Plain text body | | `--html ` | One of text/html/html-file/react-file | HTML body as a string | | `--html-file ` | One of text/html/html-file/react-file | Path to an HTML file to use as body | -| `--react-file ` | One of text/html/html-file/react-file | Path to a React module (default export) for the body | +| `--react-file ` | One of text/html/html-file/react-file | Path to a React module whose default export is a rendered React value | | `--cc ` | No | CC recipients (space-separated) | | `--bcc ` | No | BCC recipients (space-separated) | | `--reply-to
` | No | Reply-to email address | @@ -290,6 +290,8 @@ resend emails send \ --react-file ./email.react.mjs ``` +`--react-file` expects the module's default export to be a rendered React value (for example, `export default EmailTemplate({ userName: "Ada" })`), not a component function. + **With CC, BCC, and reply-to:** ```bash diff --git a/src/commands/emails/send.ts b/src/commands/emails/send.ts index fced7a6..311f37e 100644 --- a/src/commands/emails/send.ts +++ b/src/commands/emails/send.ts @@ -90,6 +90,15 @@ async function loadReactFile( if (mod.default === undefined) { throw new Error('Missing default export'); } + if (typeof mod.default === 'function') { + outputError( + { + message: `Invalid React file: ${filePath}. Default export must be a rendered React value, not a component function.`, + code: 'file_read_error', + }, + { json: globalOpts.json }, + ); + } return mod.default; } catch { outputError( @@ -109,7 +118,10 @@ export const sendCommand = new Command('send') .option('--subject ', 'Email subject (required)') .option('--html ', 'HTML body') .option('--html-file ', 'Path to an HTML file for the body') - .option('--react-file ', 'Path to a React module for the body') + .option( + '--react-file ', + 'Path to a React module whose default export is a rendered React value', + ) .option('--text ', 'Plain-text body') .option('--cc ', 'CC recipients') .option('--bcc ', 'BCC recipients') diff --git a/tests/commands/emails/send.test.ts b/tests/commands/emails/send.test.ts index ccae3bd..5f7b780 100644 --- a/tests/commands/emails/send.test.ts +++ b/tests/commands/emails/send.test.ts @@ -279,6 +279,44 @@ describe('send command', () => { } }); + test('prefers --react-file over --html-file, --html, and --text', async () => { + spies = setupOutputSpies(); + + const baseDir = dirname(fileURLToPath(import.meta.url)); + const reactFile = join(baseDir, '__test_email.precedence.react.mjs'); + writeFileSync(reactFile, 'export default { type: "react-wins" };'); + + try { + const { sendCommand } = await import('../../../src/commands/emails/send'); + await sendCommand.parseAsync( + [ + '--from', + 'a@test.com', + '--to', + 'b@test.com', + '--subject', + 'Test', + '--react-file', + reactFile, + '--html-file', + '/tmp/nonexistent-html-file.html', + '--html', + '

ignored

', + '--text', + 'ignored', + ], + { from: 'user' }, + ); + + const callArgs = mockSend.mock.calls[0][0] as Record; + expect(callArgs.react).toEqual({ type: 'react-wins' }); + expect(callArgs.html).toBeUndefined(); + expect(callArgs.text).toBeUndefined(); + } finally { + unlinkSync(reactFile); + } + }); + test('errors with file_read_error when --react-file has no default export', async () => { setNonInteractive(); errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); @@ -315,6 +353,45 @@ describe('send command', () => { expect(output).toContain('file_read_error'); }); + test('errors when --react-file default export is a component function', async () => { + setNonInteractive(); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + exitSpy = mockExitThrow(); + + const tmpFile = join( + dirname(fileURLToPath(import.meta.url)), + '__test_email.component_default.mjs', + ); + writeFileSync( + tmpFile, + 'export default function EmailTemplate() { return "hello"; }', + ); + + try { + const { sendCommand } = await import('../../../src/commands/emails/send'); + await expectExit1(() => + sendCommand.parseAsync( + [ + '--from', + 'a@test.com', + '--to', + 'b@test.com', + '--subject', + 'Test', + '--react-file', + tmpFile, + ], + { from: 'user' }, + ), + ); + } finally { + unlinkSync(tmpFile); + } + + const output = errorSpy.mock.calls.map((c) => c[0]).join(' '); + expect(output).toContain('rendered React value'); + }); + test('passes cc, bcc, reply-to when provided', async () => { spies = setupOutputSpies();