Skip to content

Commit f9405af

Browse files
cursoragentcpenned
andcommitted
fix: only allow tags in emails batch, not attachments or schedule
Restore CLI rejection of attachments and scheduled_at. Tags were already pass-through; document them in help text and add a test. Co-authored-by: cpenned <cpenned@users.noreply.github.com>
1 parent 2ff0edb commit f9405af

2 files changed

Lines changed: 40 additions & 59 deletions

File tree

src/commands/emails/batch.ts

Lines changed: 21 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -10,42 +10,6 @@ import { buildReactEmailHtml } from '../../lib/react-email';
1010
import { withSpinner } from '../../lib/spinner';
1111
import { isInteractive } from '../../lib/tty';
1212

13-
/** Map API snake_case fields in batch JSON to SDK camelCase before send. */
14-
function normalizeBatchEmail(
15-
email: Record<string, unknown>,
16-
): Record<string, unknown> {
17-
const out = { ...email };
18-
19-
if ('scheduled_at' in out && !('scheduledAt' in out)) {
20-
out.scheduledAt = out.scheduled_at;
21-
}
22-
if ('reply_to' in out && !('replyTo' in out)) {
23-
out.replyTo = out.reply_to;
24-
}
25-
if ('topic_id' in out && !('topicId' in out)) {
26-
out.topicId = out.topic_id;
27-
}
28-
29-
const attachments = out.attachments;
30-
if (Array.isArray(attachments)) {
31-
out.attachments = attachments.map((item) => {
32-
if (item === null || typeof item !== 'object' || Array.isArray(item)) {
33-
return item;
34-
}
35-
const attachment = { ...(item as Record<string, unknown>) };
36-
if ('content_type' in attachment && !('contentType' in attachment)) {
37-
attachment.contentType = attachment.content_type;
38-
}
39-
if ('content_id' in attachment && !('contentId' in attachment)) {
40-
attachment.contentId = attachment.content_id;
41-
}
42-
return attachment;
43-
});
44-
}
45-
46-
return out;
47-
}
48-
4913
export const batchCommand = new Command('batch')
5014
.description('Send up to 100 emails in a single API request from a JSON file')
5115
.option(
@@ -70,7 +34,7 @@ export const batchCommand = new Command('batch')
7034
'after',
7135
buildHelpText({
7236
context:
73-
'Non-interactive: --file\nLimit: 100 emails per request (API hard limit — warned if exceeded)\nPer-email fields: attachments, scheduled_at, tags (and all single-send fields)\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":"2026-01-01T00:00:00Z","tags":[{"name":"category","value":"welcome"}],"attachments":[{"filename":"doc.pdf","content":"<base64>"}]}\n ]',
37+
'Non-interactive: --file\nLimit: 100 emails per request (API hard limit — warned if exceeded)\nUnsupported per-email fields: attachments, scheduled_at\nPer-email tags supported: [{"name":"category","value":"welcome"}]\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>","tags":[{"name":"category","value":"welcome"}]}\n ]',
7438
output: ' [{"id":"<email-id>"},{"id":"<email-id>"}]',
7539
errorCodes: [
7640
'auth_error',
@@ -156,6 +120,25 @@ export const batchCommand = new Command('batch')
156120
{ json: globalOpts.json },
157121
);
158122
}
123+
124+
if ('attachments' in email) {
125+
outputError(
126+
{
127+
message: `Email at index ${i} contains "attachments", which is not supported in batch sends.`,
128+
code: 'batch_error',
129+
},
130+
{ json: globalOpts.json },
131+
);
132+
}
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+
}
159142
}
160143

161144
const batchData = await withSpinner(
@@ -168,9 +151,7 @@ export const batchCommand = new Command('batch')
168151
}),
169152
};
170153
return resend.batch.send(
171-
emails.map((email) =>
172-
normalizeBatchEmail(email as Record<string, unknown>),
173-
) as CreateBatchOptions,
154+
emails as CreateBatchOptions,
174155
Object.keys(options).length > 0 ? options : undefined,
175156
);
176157
},

tests/commands/emails/batch.test.ts

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -212,43 +212,43 @@ describe('batch command', () => {
212212
expect(output).toContain('Email at index 0 must be a JSON object.');
213213
});
214214

215-
it('passes entries with attachments through to batch.send', async () => {
216-
spies = setupOutputSpies();
215+
it('rejects entries with attachments', async () => {
216+
setNonInteractive();
217+
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
218+
exitSpy = mockExitThrow();
217219

218220
const emails = [
219221
{
220222
...VALID_EMAILS[0],
221-
attachments: [{ filename: 'test.txt', content: 'aGVsbG8=' }],
223+
attachments: [{ filename: 'test.txt', content: 'hello' }],
222224
},
223225
];
224226
const file = await writeTmpJson(emails);
225227
const { batchCommand } = await import('../../../src/commands/emails/batch');
226-
await batchCommand.parseAsync(['--file', file], { from: 'user' });
228+
await expectExit1(() =>
229+
batchCommand.parseAsync(['--file', file], { from: 'user' }),
230+
);
227231

228-
expect(mockBatchSend).toHaveBeenCalledTimes(1);
229-
const sent = mockBatchSend.mock.calls[0][0] as Array<
230-
Record<string, unknown>
231-
>;
232-
expect(sent[0].attachments).toEqual([
233-
{ filename: 'test.txt', content: 'aGVsbG8=' },
234-
]);
232+
const output = errorSpy.mock.calls.map((c) => c[0]).join(' ');
233+
expect(output).toContain('attachments');
235234
});
236235

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

240241
const emails = [
241242
{ ...VALID_EMAILS[0], scheduled_at: '2026-01-01T00:00:00Z' },
242243
];
243244
const file = await writeTmpJson(emails);
244245
const { batchCommand } = await import('../../../src/commands/emails/batch');
245-
await batchCommand.parseAsync(['--file', file], { from: 'user' });
246+
await expectExit1(() =>
247+
batchCommand.parseAsync(['--file', file], { from: 'user' }),
248+
);
246249

247-
expect(mockBatchSend).toHaveBeenCalledTimes(1);
248-
const sent = mockBatchSend.mock.calls[0][0] as Array<
249-
Record<string, unknown>
250-
>;
251-
expect(sent[0].scheduledAt).toBe('2026-01-01T00:00:00Z');
250+
const output = errorSpy.mock.calls.map((c) => c[0]).join(' ');
251+
expect(output).toContain('scheduled_at');
252252
});
253253

254254
it('passes entries with tags through to batch.send', async () => {

0 commit comments

Comments
 (0)