Skip to content

Commit 4ad5fd6

Browse files
committed
feat: support scheduling and tags in batch email sends
The batch API now accepts per-email scheduled_at and tags. Remove the CLI-side scheduled_at rejection, normalize snake_case fields (reply_to, scheduled_at, topic_id) to the camelCase names the SDK serializes so they aren't silently dropped, bump resend to 6.18.0, and update help text and skill docs.
1 parent f739ee1 commit 4ad5fd6

7 files changed

Lines changed: 65 additions & 30 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
"esbuild": "0.28.1",
4747
"esbuild-wasm": "0.28.0",
4848
"picocolors": "1.1.1",
49-
"resend": "6.18.0-canary.0"
49+
"resend": "6.18.0"
5050
},
5151
"pkg": {
5252
"scripts": [

pnpm-lock.yaml

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-workspace.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@ allowBuilds:
22
esbuild: true
33
'@biomejs/biome': true
44
minimumReleaseAgeExclude:
5-
- resend@6.17.1
5+
- resend@6.18.0

skills/resend-cli/SKILL.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ metadata:
1313
author: resend
1414
# Skill version is independent from the CLI/package.json version —
1515
# bump it on skill content changes, not CLI releases.
16-
version: "2.4.0"
16+
version: "2.5.0"
1717
homepage: https://resend.com/docs/cli-agents
1818
source: https://github.com/resend/resend-cli
1919
openclaw:
@@ -169,7 +169,7 @@ Read the matching reference file for detailed flags and output shapes.
169169
| 1 | **Forgetting `--yes` on delete commands** | All `delete`/`rm` subcommands require `--yes` in non-interactive mode — otherwise the CLI exits with an error |
170170
| 2 | **Not saving webhook `signing_secret`** | `webhooks create` shows the secret once only — it cannot be retrieved later. Capture it from command output immediately |
171171
| 3 | **Omitting `--quiet` in CI** | Without `-q`, spinners and status text still go to stderr (not stdout). Use `-q` for JSON on stdout with no spinner noise on stderr |
172-
| 4 | **Using `--scheduled-at` with batch** | Batch sending does not support `scheduled_at`use single `emails send` instead |
172+
| 4 | **Passing `--scheduled-at` as a flag to batch** | There is no `--scheduled-at` flag on `emails batch`set `scheduled_at` per-email in the JSON file instead |
173173
| 5 | **Expecting `domains list` to include DNS records** | List returns summaries only — use `domains get <id>` for the full `records[]` array |
174174
| 6 | **Sending a dashboard-created broadcast via CLI** | Only API-created broadcasts can be sent with `broadcasts send` — dashboard broadcasts must be sent from the dashboard |
175175
| 7 | **Passing `--events` to `webhooks update` expecting additive behavior** | `--events` replaces the entire subscription list — always pass the complete set |

skills/resend-cli/references/emails.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,14 +85,16 @@ Send up to 100 emails in a single request.
8585
```json
8686
[
8787
{"from":"a@domain.com","to":["b@example.com"],"subject":"Hi","text":"Body"},
88-
{"from":"a@domain.com","to":["c@example.com"],"subject":"Hi","html":"<b>Body</b>"}
88+
{"from":"a@domain.com","to":["c@example.com"],"subject":"Hi","html":"<b>Body</b>","scheduled_at":"in 1 hour","tags":[{"name":"campaign","value":"welcome"}]}
8989
]
9090
```
9191

92+
Per-email `scheduled_at` (ISO 8601 or natural language) and `tags` are supported.
93+
9294
**Output (success):** `[{"id":"..."},{"id":"..."}]`
9395
**Output (permissive with errors):** `{"data":[{"id":"..."}],"errors":[{"index":1,"message":"..."}]}`
9496

95-
**Constraints:** Max 100 emails. Attachments and `scheduled_at` not supported per-email.
97+
**Constraints:** Max 100 emails. Attachments not supported per-email.
9698

9799
---
98100

src/commands/emails/batch.ts

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export const batchCommand = new Command('batch')
3434
'after',
3535
buildHelpText({
3636
context:
37-
'Non-interactive: --file\nLimit: 100 emails per request (API hard limit — warned if exceeded)\nUnsupported per-email fields: attachments, scheduled_at\n\nFile format (--file path):\n [\n {"from":"onboarding@resend.com","to":["delivered@resend.com"],"subject":"Hello","text":"Hi"},\n {"from":"onboarding@resend.com","to":["delivered@resend.com"],"subject":"Hello","html":"<b>Hi</b>"}\n ]',
37+
'Non-interactive: --file\nLimit: 100 emails per request (API hard limit — warned if exceeded)\nUnsupported per-email fields: attachments\n\nFile format (--file path):\n [\n {"from":"onboarding@resend.com","to":["delivered@resend.com"],"subject":"Hello","text":"Hi"},\n {"from":"onboarding@resend.com","to":["delivered@resend.com"],"subject":"Hello","html":"<b>Hi</b>","scheduled_at":"in 1 hour","tags":[{"name":"campaign","value":"welcome"}]}\n ]',
3838
output: ' [{"id":"<email-id>"},{"id":"<email-id>"}]',
3939
errorCodes: [
4040
'auth_error',
@@ -109,6 +109,14 @@ export const batchCommand = new Command('batch')
109109
}
110110
}
111111

112+
// The SDK re-serializes each email and only reads camelCase field names —
113+
// snake_case variants would be silently dropped.
114+
const snakeCaseAliases: Record<string, string> = {
115+
reply_to: 'replyTo',
116+
scheduled_at: 'scheduledAt',
117+
topic_id: 'topicId',
118+
};
119+
112120
for (let i = 0; i < emails.length; i++) {
113121
const email = emails[i];
114122
if (email === null || typeof email !== 'object' || Array.isArray(email)) {
@@ -130,14 +138,13 @@ export const batchCommand = new Command('batch')
130138
{ json: globalOpts.json },
131139
);
132140
}
133-
if ('scheduled_at' in email) {
134-
outputError(
135-
{
136-
message: `Email at index ${i} contains "scheduled_at", which is not supported in batch sends.`,
137-
code: 'batch_error',
138-
},
139-
{ json: globalOpts.json },
140-
);
141+
142+
const record = email as Record<string, unknown>;
143+
for (const [snake, camel] of Object.entries(snakeCaseAliases)) {
144+
if (snake in record) {
145+
record[camel] ??= record[snake];
146+
delete record[snake];
147+
}
141148
}
142149
}
143150

tests/commands/emails/batch.test.ts

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -233,22 +233,48 @@ describe('batch command', () => {
233233
expect(output).toContain('attachments');
234234
});
235235

236-
it('rejects entries with scheduled_at', async () => {
237-
setNonInteractive();
238-
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
239-
exitSpy = mockExitThrow();
236+
it('passes scheduling and tags through to the API', async () => {
237+
spies = setupOutputSpies();
240238

241239
const emails = [
242-
{ ...VALID_EMAILS[0], scheduled_at: '2026-01-01T00:00:00Z' },
240+
{
241+
...VALID_EMAILS[0],
242+
scheduledAt: '2026-01-01T00:00:00Z',
243+
tags: [{ name: 'campaign', value: 'welcome' }],
244+
},
243245
];
244246
const file = await writeTmpJson(emails);
245247
const { batchCommand } = await import('../../../src/commands/emails/batch');
246-
await expectExit1(() =>
247-
batchCommand.parseAsync(['--file', file], { from: 'user' }),
248-
);
248+
await batchCommand.parseAsync(['--file', file], { from: 'user' });
249249

250-
const output = errorSpy.mock.calls.map((c) => c[0]).join(' ');
251-
expect(output).toContain('scheduled_at');
250+
expect(mockBatchSend).toHaveBeenCalledTimes(1);
251+
const sent = mockBatchSend.mock.calls[0][0] as Record<string, unknown>[];
252+
expect(sent[0].scheduledAt).toBe('2026-01-01T00:00:00Z');
253+
expect(sent[0].tags).toEqual([{ name: 'campaign', value: 'welcome' }]);
254+
});
255+
256+
it('normalizes snake_case fields to the camelCase names the SDK serializes', async () => {
257+
spies = setupOutputSpies();
258+
259+
const emails = [
260+
{
261+
...VALID_EMAILS[0],
262+
scheduled_at: '2026-01-01T00:00:00Z',
263+
reply_to: 'reply@example.com',
264+
topic_id: 'topic-1',
265+
},
266+
];
267+
const file = await writeTmpJson(emails);
268+
const { batchCommand } = await import('../../../src/commands/emails/batch');
269+
await batchCommand.parseAsync(['--file', file], { from: 'user' });
270+
271+
const sent = mockBatchSend.mock.calls[0][0] as Record<string, unknown>[];
272+
expect(sent[0].scheduledAt).toBe('2026-01-01T00:00:00Z');
273+
expect(sent[0].replyTo).toBe('reply@example.com');
274+
expect(sent[0].topicId).toBe('topic-1');
275+
expect(sent[0]).not.toHaveProperty('scheduled_at');
276+
expect(sent[0]).not.toHaveProperty('reply_to');
277+
expect(sent[0]).not.toHaveProperty('topic_id');
252278
});
253279

254280
it('warns but continues when array length exceeds 100', async () => {

0 commit comments

Comments
 (0)