Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,9 +227,10 @@ resend emails send \
| `--from <address>` | Yes | Sender email address (must be from a verified domain) |
| `--to <addresses...>` | Yes | One or more recipient email addresses (space-separated) |
| `--subject <subject>` | Yes | Email subject line |
| `--text <text>` | One of text/html/html-file | Plain text body |
| `--html <html>` | One of text/html/html-file | HTML body as a string |
| `--html-file <path>` | One of text/html/html-file | Path to an HTML file to use as body |
| `--text <text>` | One of text/html/html-file/react-file | Plain text body |
| `--html <html>` | One of text/html/html-file/react-file | HTML body as a string |
| `--html-file <path>` | One of text/html/html-file/react-file | Path to an HTML file to use as body |
| `--react-file <path>` | One of text/html/html-file/react-file | Path to a React module whose default export is a rendered React value |
| `--cc <addresses...>` | No | CC recipients (space-separated) |
| `--bcc <addresses...>` | No | BCC recipients (space-separated) |
| `--reply-to <address>` | No | Reply-to email address |
Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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 |

---
Expand Down
60 changes: 54 additions & 6 deletions src/commands/emails/send.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -80,13 +81,47 @@ async function promptForFromAddress(domains: string[]): Promise<string> {
return result;
}

async function loadReactFile(
filePath: string,
globalOpts: GlobalOpts,
): Promise<unknown> {
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 <address>', 'Sender address (required)')
.option('--to <addresses...>', 'Recipient address(es) (required)')
.option('--subject <subject>', 'Email subject (required)')
.option('--html <html>', 'HTML body')
.option('--html-file <path>', 'Path to an HTML file for the body')
.option(
'--react-file <path>',
'Path to a React module whose default export is a rendered React value',
)
.option('--text <text>', 'Plain-text body')
.option('--cc <addresses...>', 'CC recipients')
.option('--bcc <addresses...>', 'BCC recipients')
Expand All @@ -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":"<email-id>"}',
errorCodes: [
'auth_error',
Expand All @@ -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 "<b>Hi</b>" --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',
Expand Down Expand Up @@ -166,23 +202,29 @@ 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,
{
message: 'Email body (plain text)',
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,
Expand Down Expand Up @@ -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...',
Expand All @@ -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 }),
Expand Down
147 changes: 147 additions & 0 deletions tests/commands/emails/send.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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',
'<h1>ignored</h1>',
'--text',
'ignored',
],
{ from: 'user' },
);

const callArgs = mockSend.mock.calls[0][0] as Record<string, unknown>;
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();

Expand Down