Skip to content

Commit 9727eaf

Browse files
feat: add templates to send command (#131)
1 parent a941a1c commit 9727eaf

2 files changed

Lines changed: 331 additions & 30 deletions

File tree

src/commands/emails/send.ts

Lines changed: 126 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs';
22
import { basename } from 'node:path';
33
import * as p from '@clack/prompts';
44
import { Command } from '@commander-js/extra-typings';
5-
import type { Resend } from 'resend';
5+
import type { CreateEmailOptions, Resend } from 'resend';
66
import type { GlobalOpts } from '../../lib/client';
77
import { requireClient } from '../../lib/client';
88
import { readFile } from '../../lib/files';
@@ -108,18 +108,26 @@ export const sendCommand = new Command('send')
108108
'--idempotency-key <key>',
109109
'Deduplicate this send request using this key',
110110
)
111+
.option('--template <id>', 'Template ID to use')
112+
.option(
113+
'--var <key=value...>',
114+
'Template variables as key=value pairs (repeatable, e.g. --var name=John --var count=42)',
115+
)
111116
.addHelpText(
112117
'after',
113118
buildHelpText({
114119
context:
115-
'Required: --from, --to, --subject, and one of --text | --html | --html-file',
120+
'Required: --to and either --template or (--from, --subject, and one of --text | --html | --html-file)',
116121
output: ' {"id":"<email-id>"}',
117122
errorCodes: [
118123
'auth_error',
119124
'missing_body',
120125
'file_read_error',
121126
'invalid_header',
122127
'invalid_tag',
128+
'invalid_var',
129+
'template_body_conflict',
130+
'template_attachment_conflict',
123131
'send_error',
124132
],
125133
examples: [
@@ -129,6 +137,8 @@ export const sendCommand = new Command('send')
129137
'resend emails send --from you@domain.com --to user@example.com --subject "Hi" --text "Hi" --scheduled-at 2024-08-05T11:52:01.858Z',
130138
'resend emails send --from you@domain.com --to user@example.com --subject "Hi" --text "Hi" --attachment ./report.pdf',
131139
'resend emails send --from you@domain.com --to user@example.com --subject "Hi" --text "Hi" --headers X-Entity-Ref-ID=123 --tags category=marketing',
140+
'resend emails send --template tmpl_123 --to user@example.com',
141+
'resend emails send --template tmpl_123 --to user@example.com --var name=John --var count=42',
132142
'RESEND_API_KEY=re_123 resend emails send --from you@domain.com --to user@example.com --subject "Hi" --text "Hi"',
133143
],
134144
}),
@@ -138,31 +148,95 @@ export const sendCommand = new Command('send')
138148

139149
const resend = await requireClient(globalOpts);
140150

151+
const hasTemplate = !!opts.template;
152+
153+
// Validate: --var requires --template
154+
if (opts.var && !hasTemplate) {
155+
outputError(
156+
{
157+
message: '--var can only be used with --template',
158+
code: 'invalid_var',
159+
},
160+
{ json: globalOpts.json },
161+
);
162+
}
163+
164+
// Validate: template and body flags are mutually exclusive
165+
if (hasTemplate && (opts.html || opts.htmlFile || opts.text)) {
166+
outputError(
167+
{
168+
message: 'Cannot use --template with --html, --html-file, or --text',
169+
code: 'template_body_conflict',
170+
},
171+
{ json: globalOpts.json },
172+
);
173+
}
174+
175+
if (hasTemplate && opts.attachment) {
176+
outputError(
177+
{
178+
message: 'Cannot use --attachment with --template',
179+
code: 'template_attachment_conflict',
180+
},
181+
{ json: globalOpts.json },
182+
);
183+
}
184+
185+
// Parse key=value template variables
186+
const variables = opts.var
187+
? Object.fromEntries(
188+
opts.var.map((v) => {
189+
const eq = v.indexOf('=');
190+
if (eq < 1) {
191+
outputError(
192+
{
193+
message: `Invalid var format: "${v}". Expected key=value.`,
194+
code: 'invalid_var',
195+
},
196+
{ json: globalOpts.json },
197+
);
198+
}
199+
const key = v.slice(0, eq);
200+
const raw = v.slice(eq + 1);
201+
const num = Number(raw);
202+
return [key, raw !== '' && !Number.isNaN(num) ? num : raw];
203+
}),
204+
)
205+
: undefined;
206+
141207
// Only fetch verified domains in interactive mode — non-interactive
142208
// callers (CI, agents, scripts) must pass --from explicitly.
143209
let fromAddress = opts.from;
144-
if (!fromAddress && isInteractive() && !globalOpts.json) {
210+
if (!fromAddress && !hasTemplate && isInteractive() && !globalOpts.json) {
145211
const domains = await fetchVerifiedDomains(resend);
146212
if (domains.length > 0) {
147213
fromAddress = await promptForFromAddress(domains);
148214
}
149215
}
150216

217+
const promptFields = [
218+
{
219+
flag: 'from',
220+
message: 'From address',
221+
placeholder: 'you@example.com',
222+
required: !hasTemplate,
223+
},
224+
{
225+
flag: 'to',
226+
message: 'To address',
227+
placeholder: 'recipient@example.com',
228+
},
229+
{
230+
flag: 'subject',
231+
message: 'Subject',
232+
placeholder: 'Hello!',
233+
required: !hasTemplate,
234+
},
235+
];
236+
151237
const filled = await promptForMissing(
152238
{ from: fromAddress, to: opts.to?.[0], subject: opts.subject },
153-
[
154-
{
155-
flag: 'from',
156-
message: 'From address',
157-
placeholder: 'you@example.com',
158-
},
159-
{
160-
flag: 'to',
161-
message: 'To address',
162-
placeholder: 'recipient@example.com',
163-
},
164-
{ flag: 'subject', message: 'Subject', placeholder: 'Hello!' },
165-
],
239+
promptFields,
166240
globalOpts,
167241
);
168242

@@ -174,7 +248,7 @@ export const sendCommand = new Command('send')
174248
}
175249

176250
let body: string | undefined = text;
177-
if (!html && !text) {
251+
if (!hasTemplate && !html && !text) {
178252
body = await requireText(
179253
undefined,
180254
{
@@ -241,6 +315,40 @@ export const sendCommand = new Command('send')
241315
return { name: t.slice(0, eq), value: t.slice(eq + 1) };
242316
});
243317

318+
// Build payload based on template vs content mode
319+
let payload: CreateEmailOptions;
320+
if (hasTemplate) {
321+
payload = {
322+
template: {
323+
id: opts.template as string,
324+
...(variables && { variables }),
325+
},
326+
to: toAddresses,
327+
...(filled.from && { from: filled.from }),
328+
...(filled.subject && { subject: filled.subject }),
329+
...(opts.cc && { cc: opts.cc }),
330+
...(opts.bcc && { bcc: opts.bcc }),
331+
...(opts.replyTo && { replyTo: opts.replyTo }),
332+
...(opts.scheduledAt && { scheduledAt: opts.scheduledAt }),
333+
...(headers && { headers }),
334+
...(tags && { tags }),
335+
};
336+
} else {
337+
payload = {
338+
from: filled.from,
339+
to: toAddresses,
340+
subject: filled.subject,
341+
...(html ? { html } : { text: body as string }),
342+
...(opts.cc && { cc: opts.cc }),
343+
...(opts.bcc && { bcc: opts.bcc }),
344+
...(opts.replyTo && { replyTo: opts.replyTo }),
345+
...(opts.scheduledAt && { scheduledAt: opts.scheduledAt }),
346+
...(attachments && { attachments }),
347+
...(headers && { headers }),
348+
...(tags && { tags }),
349+
};
350+
}
351+
244352
const data = await withSpinner(
245353
{
246354
loading: opts.scheduledAt ? 'Scheduling email...' : 'Sending email...',
@@ -249,19 +357,7 @@ export const sendCommand = new Command('send')
249357
},
250358
() =>
251359
resend.emails.send(
252-
{
253-
from: filled.from,
254-
to: toAddresses,
255-
subject: filled.subject,
256-
...(html ? { html } : { text: body as string }),
257-
...(opts.cc && { cc: opts.cc }),
258-
...(opts.bcc && { bcc: opts.bcc }),
259-
...(opts.replyTo && { replyTo: opts.replyTo }),
260-
...(opts.scheduledAt && { scheduledAt: opts.scheduledAt }),
261-
...(attachments && { attachments }),
262-
...(headers && { headers }),
263-
...(tags && { tags }),
264-
},
360+
payload,
265361
opts.idempotencyKey
266362
? { idempotencyKey: opts.idempotencyKey }
267363
: undefined,

0 commit comments

Comments
 (0)