@@ -7,11 +7,94 @@ import { setProfile, getProfile } from '../config/config-manager';
77import { createProfileCommands } from '../utils/profile-commands' ;
88import { performOAuthFlow } from '../auth/oauth' ;
99import { GmailClient } from '../services/gmail/client' ;
10- import { printMessageList , printMessage , printSendResult , printArchived , printMarked , printAttachmentList , printAttachmentDownloaded , raw } from '../utils/output' ;
10+ import { printMessageList , printMessage , printSendResult , printDraftResult , printArchived , printMarked , printAttachmentList , printAttachmentDownloaded , raw } from '../utils/output' ;
1111import { CliError , handleError } from '../utils/errors' ;
1212import { readStdin } from '../utils/stdin' ;
1313import { enforceWriteAccess } from '../utils/read-only' ;
14- import type { GmailAttachment } from '../types/gmail' ;
14+ import type { GmailAttachment , GmailSendOptions } from '../types/gmail' ;
15+
16+ function addComposeOptions ( cmd : Command ) : Command {
17+ return cmd
18+ . option ( '--profile <name>' , 'Profile name (optional if only one profile exists)' )
19+ . option ( '--to <email>' , 'Recipient (repeatable, required unless --reply-to)' , ( val : string , acc : string [ ] ) => [ ...acc , val ] , [ ] )
20+ . option ( '--cc <email>' , 'CC recipient (repeatable)' , ( val : string , acc : string [ ] ) => [ ...acc , val ] , [ ] )
21+ . option ( '--bcc <email>' , 'BCC recipient (repeatable)' , ( val : string , acc : string [ ] ) => [ ...acc , val ] , [ ] )
22+ . option ( '--subject <subject>' , 'Email subject (required unless --reply-to)' )
23+ . option ( '--body <body>' , 'Email body (or pipe via stdin)' )
24+ . option ( '--html' , 'Treat body as HTML' )
25+ . option ( '--reply-to <thread-id>' , 'Thread ID to reply to (derives to/subject from thread)' )
26+ . option ( '--attachment <path>' , 'File to attach (repeatable)' , ( val : string , acc : string [ ] ) => [ ...acc , val ] , [ ] )
27+ . option ( '--inline <cid:path>' , 'Inline image (repeatable, format: contentId:filepath). Supports PNG, JPG, GIF only (not SVG)' , ( val : string , acc : string [ ] ) => [ ...acc , val ] , [ ] ) ;
28+ }
29+
30+ async function parseBody ( body : string | undefined ) : Promise < string > {
31+ if ( ! body ) {
32+ body = await readStdin ( ) ?? undefined ;
33+ }
34+ if ( ! body ) {
35+ throw new CliError ( 'INVALID_PARAMS' , 'Body is required. Use --body or pipe via stdin.' ) ;
36+ }
37+ return body ;
38+ }
39+
40+ function parseAttachments ( paths : string [ ] ) : GmailAttachment [ ] {
41+ return paths . map ( ( path : string ) => ( {
42+ path,
43+ filename : basename ( path ) ,
44+ } ) ) ;
45+ }
46+
47+ function parseInlineAttachments ( specs : string [ ] ) : GmailAttachment [ ] {
48+ return specs . map ( ( spec : string ) => {
49+ const colonIndex = spec . indexOf ( ':' ) ;
50+ if ( colonIndex === - 1 ) {
51+ throw new CliError ( 'INVALID_PARAMS' , `Invalid inline format: ${ spec } ` , 'Use format: contentId:filepath (e.g., logo:./logo.png)' ) ;
52+ }
53+ const contentId = spec . substring ( 0 , colonIndex ) ;
54+ const path = spec . substring ( colonIndex + 1 ) ;
55+ return {
56+ path,
57+ filename : basename ( path ) ,
58+ contentId,
59+ } ;
60+ } ) ;
61+ }
62+
63+ async function parseSendOptions ( options : Record < string , unknown > ) : Promise < GmailSendOptions > {
64+ const replyTo = options . replyTo as string | undefined ;
65+ const to = options . to as string [ ] ;
66+ const subject = options . subject as string | undefined ;
67+
68+ if ( ! replyTo ) {
69+ if ( ! to . length ) {
70+ throw new CliError ( 'INVALID_PARAMS' , '--to is required (unless using --reply-to)' ) ;
71+ }
72+ if ( ! subject ) {
73+ throw new CliError ( 'INVALID_PARAMS' , '--subject is required (unless using --reply-to)' ) ;
74+ }
75+ }
76+
77+ const body = await parseBody ( options . body as string | undefined ) ;
78+
79+ const regularAttachments = parseAttachments ( options . attachment as string [ ] ) ;
80+ const inlineAttachments = parseInlineAttachments ( options . inline as string [ ] ) ;
81+
82+ const attachments : GmailAttachment [ ] | undefined =
83+ regularAttachments . length || inlineAttachments . length
84+ ? [ ...regularAttachments , ...inlineAttachments ]
85+ : undefined ;
86+
87+ return {
88+ to,
89+ cc : ( options . cc as string [ ] ) . length ? options . cc as string [ ] : undefined ,
90+ bcc : ( options . bcc as string [ ] ) . length ? options . bcc as string [ ] : undefined ,
91+ subject : subject || '' ,
92+ body,
93+ isHtml : options . html as boolean | undefined ,
94+ attachments,
95+ replyTo,
96+ } ;
97+ }
1598
1699function escapeHtml ( text : string ) : string {
17100 return text
@@ -176,112 +259,27 @@ Query Syntax Examples:
176259 }
177260 } ) ;
178261
179- gmail
180- . command ( 'send' )
181- . description ( 'Send an email' )
182- . option ( '--profile <name>' , 'Profile name (optional if only one profile exists)' )
183- . requiredOption ( '--to <email>' , 'Recipient (repeatable)' , ( val , acc : string [ ] ) => [ ...acc , val ] , [ ] )
184- . option ( '--cc <email>' , 'CC recipient (repeatable)' , ( val , acc : string [ ] ) => [ ...acc , val ] , [ ] )
185- . option ( '--bcc <email>' , 'BCC recipient (repeatable)' , ( val , acc : string [ ] ) => [ ...acc , val ] , [ ] )
186- . requiredOption ( '--subject <subject>' , 'Email subject' )
187- . option ( '--body <body>' , 'Email body (or pipe via stdin)' )
188- . option ( '--html' , 'Treat body as HTML' )
189- . option ( '--attachment <path>' , 'File to attach (repeatable)' , ( val , acc : string [ ] ) => [ ...acc , val ] , [ ] )
190- . option ( '--inline <cid:path>' , 'Inline image (repeatable, format: contentId:filepath). Supports PNG, JPG, GIF only (not SVG)' , ( val , acc : string [ ] ) => [ ...acc , val ] , [ ] )
262+ addComposeOptions ( gmail . command ( 'send' ) . description ( 'Send an email' ) )
191263 . action ( async ( options ) => {
192264 try {
193- let body = options . body ;
194-
195- // Check for stdin if no body provided
196- if ( ! body ) {
197- body = await readStdin ( ) ;
198- }
199-
200- if ( ! body ) {
201- throw new CliError ( 'INVALID_PARAMS' , 'Body is required. Use --body or pipe via stdin.' ) ;
202- }
203-
204- // Process regular attachments
205- const regularAttachments : GmailAttachment [ ] = options . attachment . map ( ( path : string ) => ( {
206- path,
207- filename : basename ( path ) ,
208- } ) ) ;
209-
210- // Process inline images (format: contentId:filepath)
211- const inlineAttachments : GmailAttachment [ ] = options . inline . map ( ( spec : string ) => {
212- const colonIndex = spec . indexOf ( ':' ) ;
213- if ( colonIndex === - 1 ) {
214- throw new CliError ( 'INVALID_PARAMS' , `Invalid inline format: ${ spec } ` , 'Use format: contentId:filepath (e.g., logo:./logo.png)' ) ;
215- }
216- const contentId = spec . substring ( 0 , colonIndex ) ;
217- const path = spec . substring ( colonIndex + 1 ) ;
218- return {
219- path,
220- filename : basename ( path ) ,
221- contentId,
222- } ;
223- } ) ;
224-
225- // Combine attachments
226- const attachments : GmailAttachment [ ] | undefined =
227- regularAttachments . length || inlineAttachments . length
228- ? [ ...regularAttachments , ...inlineAttachments ]
229- : undefined ;
230-
265+ const sendOptions = await parseSendOptions ( options ) ;
231266 const { client, profile } = await getGmailClient ( options . profile ) ;
232267 await enforceWriteAccess ( 'gmail' , profile , 'send email' ) ;
233- const result = await client . send ( {
234- to : options . to ,
235- cc : options . cc . length ? options . cc : undefined ,
236- bcc : options . bcc . length ? options . bcc : undefined ,
237- subject : options . subject ,
238- body,
239- isHtml : options . html ,
240- attachments,
241- } ) ;
268+ const result = await client . send ( sendOptions ) ;
242269 printSendResult ( result ) ;
243270 } catch ( error ) {
244271 handleError ( error ) ;
245272 }
246273 } ) ;
247274
248- gmail
249- . command ( 'reply' )
250- . description ( 'Reply to a thread' )
251- . option ( '--profile <name>' , 'Profile name (optional if only one profile exists)' )
252- . requiredOption ( '--thread-id <id>' , 'Thread ID' )
253- . option ( '--body <body>' , 'Reply body (or pipe via stdin)' )
254- . option ( '--html' , 'Treat body as HTML' )
255- . option ( '--attachment <path>' , 'File to attach (repeatable)' , ( val , acc : string [ ] ) => [ ...acc , val ] , [ ] )
275+ addComposeOptions ( gmail . command ( 'draft' ) . description ( 'Create an email draft' ) )
256276 . action ( async ( options ) => {
257277 try {
258- let body = options . body ;
259-
260- if ( ! body ) {
261- body = await readStdin ( ) ;
262- }
263-
264- if ( ! body ) {
265- throw new CliError ( 'INVALID_PARAMS' , 'Body is required. Use --body or pipe via stdin.' ) ;
266- }
267-
268- const attachments : GmailAttachment [ ] | undefined =
269- options . attachment . length
270- ? options . attachment . map ( ( path : string ) => ( {
271- path,
272- filename : basename ( path ) ,
273- } ) )
274- : undefined ;
275-
278+ const sendOptions = await parseSendOptions ( options ) ;
276279 const { client, profile } = await getGmailClient ( options . profile ) ;
277- await enforceWriteAccess ( 'gmail' , profile , 'reply to email' ) ;
278- const result = await client . reply ( {
279- threadId : options . threadId ,
280- body,
281- isHtml : options . html ,
282- attachments,
283- } ) ;
284- printSendResult ( result ) ;
280+ await enforceWriteAccess ( 'gmail' , profile , 'create draft' ) ;
281+ const result = await client . draft ( sendOptions ) ;
282+ printDraftResult ( result ) ;
285283 } catch ( error ) {
286284 handleError ( error ) ;
287285 }
0 commit comments