Skip to content
Merged
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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,17 @@ Prints the CLI command tree as JSON for scripting and AI agents. In an interacti

---

## Dry-run (no API call)

`--dry-run` is only implemented where agents most often need to **validate a complex payload** before a high-impact send:

- **`resend emails send ... --dry-run`** — validates inputs and prints `{ "dryRun": true, "request": { ... } }` without sending. Attachments appear as `filename` and `byteLength` only.
- **`resend broadcasts create ... --dry-run`** — same for the broadcast create payload.

Other write commands (batch, `broadcasts send`, webhooks, contacts, etc.) do not support `--dry-run` yet. If that would help your workflow, open an issue — likely next candidates are **`emails batch`** (large JSON files) and **`broadcasts send`** (confirm id + schedule before delivery).

---

## Configuration

| Item | Path | Notes |
Expand Down
2 changes: 2 additions & 0 deletions skills/resend-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ Auth resolves: `--api-key` flag > `RESEND_API_KEY` env > config file (`resend lo

Read the matching reference file for detailed flags and output shapes.

**Dry-run:** Only `emails send` and `broadcasts create` support `--dry-run` (payload validation before send/create). They print `{ "dryRun": true, "request": { ... } }` on stdout without calling the API. There is no `--dry-run` on `emails batch`, `broadcasts send`, or other commands yet.

## Common Mistakes

| # | Mistake | Fix |
Expand Down
82 changes: 59 additions & 23 deletions src/commands/broadcasts/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { requireClient } from '../../lib/client';
import { fetchVerifiedDomains, promptForFromAddress } from '../../lib/domains';
import { readFile } from '../../lib/files';
import { buildHelpText } from '../../lib/help-text';
import { outputError } from '../../lib/output';
import { outputError, outputResult } from '../../lib/output';
import { cancelAndExit, pickId } from '../../lib/prompts';
import { buildReactEmailHtml } from '../../lib/react-email';
import { isInteractive } from '../../lib/tty';
Expand Down Expand Up @@ -51,12 +51,18 @@ export const createBroadcastCommand = new Command('create')
'--scheduled-at <datetime>',
'Schedule delivery — ISO 8601 or natural language e.g. "in 1 hour", "tomorrow at 9am ET" (only valid with --send)',
)
.option(
'--dry-run',
'Validate input and print the create request JSON without calling the API (interactive: type segment/topic IDs; lists are not fetched)',
)
.addHelpText(
'after',
buildHelpText({
context: `Non-interactive: --from, --subject, and --segment-id are required.
Body: provide at least one of --html, --html-file, --text, --text-file, or --react-email.

Use --dry-run to print the request JSON without creating a broadcast.

Variable interpolation:
HTML bodies support triple-brace syntax for contact properties.
Example: {{{FIRST_NAME|Friend}}} — uses FIRST_NAME or falls back to "Friend".
Expand Down Expand Up @@ -110,13 +116,12 @@ Scheduling:
);
}

const resend = await requireClient(globalOpts);

let from = opts.from;
let subject = opts.subject;
let segmentId = opts.segmentId;

if (!from && isInteractive() && !globalOpts.json) {
if (!from && isInteractive() && !globalOpts.json && !opts.dryRun) {
const resend = await requireClient(globalOpts);
const domains = await fetchVerifiedDomains(resend);
if (domains.length > 0) {
from = await promptForFromAddress(domains);
Expand Down Expand Up @@ -166,7 +171,19 @@ Scheduling:
{ json: globalOpts.json },
);
}
segmentId = await pickId(undefined, segmentPickerConfig, globalOpts);
if (opts.dryRun) {
const result = await p.text({
message: 'Segment ID',
placeholder: 'e.g. 7b1e0a3d-4c5f-4e8a-9b2d-1a3c5e7f9b2d',
validate: (v) => (!v ? 'Required' : undefined),
});
if (p.isCancel(result)) {
cancelAndExit('Cancelled.');
}
segmentId = result;
} else {
segmentId = await pickId(undefined, segmentPickerConfig, globalOpts);
}
}

let html = opts.html;
Expand Down Expand Up @@ -218,29 +235,48 @@ Scheduling:

let topicId = opts.topicId;
if (!topicId && isInteractive() && !globalOpts.json) {
topicId = await pickId(undefined, topicPickerConfig, globalOpts, {
optional: true,
});
if (opts.dryRun) {
const result = await p.text({
message: 'Topic ID (optional)',
placeholder: 'Press Enter to skip',
});
if (p.isCancel(result)) {
cancelAndExit('Cancelled.');
}
topicId = result.trim() || undefined;
} else {
topicId = await pickId(undefined, topicPickerConfig, globalOpts, {
optional: true,
});
}
}

const createPayload = {
from,
subject,
segmentId,
...(html && { html }),
...(text && { text }),
...(opts.name && { name: opts.name }),
...(opts.replyTo && { replyTo: opts.replyTo }),
...(opts.previewText && { previewText: opts.previewText }),
...(topicId && { topicId }),
...(opts.send && { send: true as const }),
...(opts.send && opts.scheduledAt && { scheduledAt: opts.scheduledAt }),
} as CreateBroadcastOptions;

if (opts.dryRun) {
outputResult(
{ dryRun: true, request: createPayload },
{ json: globalOpts.json },
);
return;
}

await runCreate(
{
loading: 'Creating broadcast...',
sdkCall: (resend) =>
resend.broadcasts.create({
from,
subject,
segmentId,
...(html && { html }),
...(text && { text }),
...(opts.name && { name: opts.name }),
...(opts.replyTo && { replyTo: opts.replyTo }),
...(opts.previewText && { previewText: opts.previewText }),
...(topicId && { topicId }),
...(opts.send && { send: true as const }),
...(opts.send &&
opts.scheduledAt && { scheduledAt: opts.scheduledAt }),
} as CreateBroadcastOptions),
sdkCall: (resend) => resend.broadcasts.create(createPayload),
onInteractive: (d) => {
if (opts.send) {
if (opts.scheduledAt) {
Expand Down
54 changes: 47 additions & 7 deletions src/commands/emails/send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,22 @@ import { buildReactEmailHtml } from '../../lib/react-email';
import { withSpinner } from '../../lib/spinner';
import { isInteractive } from '../../lib/tty';

function serializeEmailPayloadForDryRun(payload: CreateEmailOptions): unknown {
const { attachments, ...rest } = payload;
if (!attachments?.length) {
return rest;
}
return {
...rest,
attachments: attachments.map((a) => ({
filename: a.filename,
byteLength: Buffer.isBuffer(a.content)
? a.content.byteLength
: Buffer.byteLength(String(a.content), 'utf8'),
})),
};
}

export const sendCommand = new Command('send')
.description('Send an email')
.option(
Expand Down Expand Up @@ -58,6 +74,10 @@ export const sendCommand = new Command('send')
'--idempotency-key <key>',
'Deduplicate this send request using this key',
)
.option(
'--dry-run',
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
'Validate input and print the request JSON without calling the API (interactive: verified-domain list is not fetched)',
)
.option('--template <id>', 'Template ID to use')
.option(
'--var <key=value...>',
Expand All @@ -67,7 +87,7 @@ export const sendCommand = new Command('send')
'after',
buildHelpText({
context:
'Required: --to and either --template, --react-email, 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).\nUse --dry-run to print the request JSON without sending (attachments show filename and byteLength only).',
output: ' {"id":"<email-id>"}',
errorCodes: [
'auth_error',
Expand Down Expand Up @@ -113,10 +133,6 @@ export const sendCommand = new Command('send')
);
}

const resend = await requireClient(globalOpts, {
permission: 'sending_access',
});

const hasTemplate = !!opts.template;

// Validate: --var requires --template
Expand Down Expand Up @@ -190,8 +206,17 @@ export const sendCommand = new Command('send')
: undefined;

let fromAddress = opts.from;
if (!fromAddress && !hasTemplate && isInteractive() && !globalOpts.json) {
const domains = await fetchVerifiedDomains(resend);
if (
!opts.dryRun &&
!fromAddress &&
!hasTemplate &&
isInteractive() &&
!globalOpts.json
) {
const clientForDomains = await requireClient(globalOpts, {
permission: 'sending_access',
});
const domains = await fetchVerifiedDomains(clientForDomains);
if (domains.length > 0) {
fromAddress = await promptForFromAddress(domains);
}
Expand Down Expand Up @@ -356,6 +381,21 @@ export const sendCommand = new Command('send')
} as CreateEmailOptions;
}

if (opts.dryRun) {
outputResult(
{
dryRun: true,
request: serializeEmailPayloadForDryRun(payload),
},
{ json: globalOpts.json },
);
return;
}

const resend = await requireClient(globalOpts, {
permission: 'sending_access',
});

const data = await withSpinner(
opts.scheduledAt ? 'Scheduling email...' : 'Sending email...',
() =>
Expand Down
55 changes: 55 additions & 0 deletions tests/commands/broadcasts/create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,61 @@ describe('broadcasts create command', () => {
expect(args.html).toBe('<p>Hi</p>');
});

test('dry-run does not call API and prints create payload', 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',
'--html',
'<p>Hi</p>',
'--dry-run',
],
{ from: 'user' },
);

expect(mockCreate).not.toHaveBeenCalled();
const out = JSON.parse(spies.logSpy.mock.calls[0][0] as string);
expect(out.dryRun).toBe(true);
expect(out.request.from).toBe('hello@domain.com');
expect(out.request.segmentId).toBe('7b1e0a3d-4c5f-4e8a-9b2d-1a3c5e7f9b2d');
});

test('dry-run does not require API key', async () => {
spies = setupOutputSpies();
delete process.env.RESEND_API_KEY;

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',
'--html',
'<p>Hi</p>',
'--dry-run',
],
{ from: 'user' },
);

expect(mockCreate).not.toHaveBeenCalled();
const out = JSON.parse(spies.logSpy.mock.calls[0][0] as string);
expect(out.dryRun).toBe(true);
});

test('outputs JSON id when non-interactive', async () => {
spies = setupOutputSpies();

Expand Down
53 changes: 53 additions & 0 deletions tests/commands/emails/send.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,59 @@ describe('send command', () => {
expect(callArgs.text).toBe('Hello');
});

test('dry-run does not call API and prints request summary', async () => {
spies = setupOutputSpies();

const { sendCommand } = await import('../../../src/commands/emails/send');
await sendCommand.parseAsync(
[
'--from',
'a@test.com',
'--to',
'b@test.com',
'--subject',
'Test',
'--text',
'Hello',
'--dry-run',
],
{ from: 'user' },
);

expect(mockSend).not.toHaveBeenCalled();
expect(mockDomainsList).not.toHaveBeenCalled();
const out = JSON.parse(spies.logSpy.mock.calls[0][0] as string);
expect(out.dryRun).toBe(true);
expect(out.request.from).toBe('a@test.com');
expect(out.request.to).toEqual(['b@test.com']);
});

test('dry-run does not require API key or call domains API', async () => {
spies = setupOutputSpies();
delete process.env.RESEND_API_KEY;

const { sendCommand } = await import('../../../src/commands/emails/send');
await sendCommand.parseAsync(
[
'--from',
'a@test.com',
'--to',
'b@test.com',
'--subject',
'Test',
'--text',
'Hello',
'--dry-run',
],
{ from: 'user' },
);

expect(mockSend).not.toHaveBeenCalled();
expect(mockDomainsList).not.toHaveBeenCalled();
const out = JSON.parse(spies.logSpy.mock.calls[0][0] as string);
expect(out.dryRun).toBe(true);
});

test('outputs JSON with email ID on success', async () => {
spies = setupOutputSpies();

Expand Down