@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs';
22import { basename } from 'node:path' ;
33import * as p from '@clack/prompts' ;
44import { Command } from '@commander-js/extra-typings' ;
5- import type { Resend } from 'resend' ;
5+ import type { CreateEmailOptions , Resend } from 'resend' ;
66import type { GlobalOpts } from '../../lib/client' ;
77import { requireClient } from '../../lib/client' ;
88import { 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