diff --git a/README.md b/README.md
index 3686f35..b503924 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 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 |
@@ -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,18 @@ 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
+```
+
+`--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
@@ -315,8 +328,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 bbfdb4d..1abbf5b 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,36 @@ 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');
+ }
+ 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(
+ {
+ 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 +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 whose default export is a rendered React value',
+ )
.option('--text ', 'Plain-text body')
.option('--cc ', 'CC recipients')
.option('--bcc ', 'BCC recipients')
@@ -112,7 +147,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 +161,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 +202,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 +223,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 +283,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 +301,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..5f7b780 100644
--- a/tests/commands/emails/send.test.ts
+++ b/tests/commands/emails/send.test.ts
@@ -245,6 +245,153 @@ 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('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(() => {});
+ 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('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();