11import { google , gmail_v1 } from 'googleapis' ;
22import type { OAuth2Client } from 'google-auth-library' ;
3- import type { GmailMessage , GmailListOptions , GmailSendOptions , GmailReplyOptions } from '../../types/gmail' ;
3+ import { basename } from 'path' ;
4+ import type { GmailMessage , GmailListOptions , GmailSendOptions , GmailReplyOptions , GmailAttachment } from '../../types/gmail' ;
45import { CliError } from '../../utils/errors' ;
56
7+ // Common MIME types by extension
8+ const MIME_TYPES : Record < string , string > = {
9+ '.txt' : 'text/plain' ,
10+ '.html' : 'text/html' ,
11+ '.css' : 'text/css' ,
12+ '.js' : 'application/javascript' ,
13+ '.json' : 'application/json' ,
14+ '.xml' : 'application/xml' ,
15+ '.pdf' : 'application/pdf' ,
16+ '.zip' : 'application/zip' ,
17+ '.gz' : 'application/gzip' ,
18+ '.tar' : 'application/x-tar' ,
19+ '.png' : 'image/png' ,
20+ '.jpg' : 'image/jpeg' ,
21+ '.jpeg' : 'image/jpeg' ,
22+ '.gif' : 'image/gif' ,
23+ '.svg' : 'image/svg+xml' ,
24+ '.webp' : 'image/webp' ,
25+ '.mp3' : 'audio/mpeg' ,
26+ '.mp4' : 'video/mp4' ,
27+ '.wav' : 'audio/wav' ,
28+ '.doc' : 'application/msword' ,
29+ '.docx' : 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ,
30+ '.xls' : 'application/vnd.ms-excel' ,
31+ '.xlsx' : 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ,
32+ '.ppt' : 'application/vnd.ms-powerpoint' ,
33+ '.pptx' : 'application/vnd.openxmlformats-officedocument.presentationml.presentation' ,
34+ } ;
35+
36+ function getMimeType ( filename : string ) : string {
37+ const ext = filename . toLowerCase ( ) . match ( / \. [ ^ . ] + $ / ) ?. [ 0 ] || '' ;
38+ return MIME_TYPES [ ext ] || 'application/octet-stream' ;
39+ }
40+
641export class GmailClient {
742 private gmail : gmail_v1 . Gmail ;
843 private userEmail : string | null = null ;
@@ -139,21 +174,38 @@ export class GmailClient {
139174 }
140175
141176 async send ( options : GmailSendOptions ) : Promise < { id : string ; threadId : string ; labelIds : string [ ] } > {
142- const { to, cc, bcc, subject, body, isHtml } = options ;
177+ const { to, cc, bcc, subject, body, isHtml, attachments } = options ;
143178 const userEmail = await this . getUserEmail ( ) ;
144179
145- const headers = [
146- `From: ${ userEmail } ` ,
147- `To: ${ to . join ( ', ' ) } ` ,
148- cc ?. length ? `Cc: ${ cc . join ( ', ' ) } ` : '' ,
149- bcc ?. length ? `Bcc: ${ bcc . join ( ', ' ) } ` : '' ,
150- `Subject: ${ subject } ` ,
151- `Content-Type: ${ isHtml ? 'text/html' : 'text/plain' } ; charset=utf-8` ,
152- '' ,
153- body ,
154- ] . filter ( Boolean ) . join ( '\r\n' ) ;
180+ let rawMessage : string ;
181+
182+ if ( attachments && attachments . length > 0 ) {
183+ // Build multipart MIME message with attachments
184+ rawMessage = await this . buildMultipartMessage ( {
185+ from : userEmail ,
186+ to,
187+ cc,
188+ bcc,
189+ subject,
190+ body,
191+ isHtml,
192+ attachments,
193+ } ) ;
194+ } else {
195+ // Simple message without attachments
196+ rawMessage = [
197+ `From: ${ userEmail } ` ,
198+ `To: ${ to . join ( ', ' ) } ` ,
199+ cc ?. length ? `Cc: ${ cc . join ( ', ' ) } ` : '' ,
200+ bcc ?. length ? `Bcc: ${ bcc . join ( ', ' ) } ` : '' ,
201+ `Subject: ${ subject } ` ,
202+ `Content-Type: ${ isHtml ? 'text/html' : 'text/plain' } ; charset=utf-8` ,
203+ '' ,
204+ body ,
205+ ] . filter ( Boolean ) . join ( '\r\n' ) ;
206+ }
155207
156- const encodedMessage = Buffer . from ( headers ) . toString ( 'base64url' ) ;
208+ const encodedMessage = Buffer . from ( rawMessage ) . toString ( 'base64url' ) ;
157209
158210 try {
159211 const response = await this . gmail . users . messages . send ( {
@@ -171,6 +223,67 @@ export class GmailClient {
171223 }
172224 }
173225
226+ private async buildMultipartMessage ( options : {
227+ from : string ;
228+ to : string [ ] ;
229+ cc ?: string [ ] ;
230+ bcc ?: string [ ] ;
231+ subject : string ;
232+ body : string ;
233+ isHtml ?: boolean ;
234+ attachments : GmailAttachment [ ] ;
235+ } ) : Promise < string > {
236+ const { from, to, cc, bcc, subject, body, isHtml, attachments } = options ;
237+ const boundary = `----=_Part_${ Date . now ( ) } _${ Math . random ( ) . toString ( 36 ) . substring ( 2 ) } ` ;
238+
239+ const headers = [
240+ `From: ${ from } ` ,
241+ `To: ${ to . join ( ', ' ) } ` ,
242+ cc ?. length ? `Cc: ${ cc . join ( ', ' ) } ` : '' ,
243+ bcc ?. length ? `Bcc: ${ bcc . join ( ', ' ) } ` : '' ,
244+ `Subject: ${ subject } ` ,
245+ 'MIME-Version: 1.0' ,
246+ `Content-Type: multipart/mixed; boundary="${ boundary } "` ,
247+ '' ,
248+ `--${ boundary } ` ,
249+ `Content-Type: ${ isHtml ? 'text/html' : 'text/plain' } ; charset=utf-8` ,
250+ '' ,
251+ body ,
252+ ] . filter ( Boolean ) ;
253+
254+ // Add attachments
255+ for ( const attachment of attachments ) {
256+ try {
257+ const file = Bun . file ( attachment . path ) ;
258+ const exists = await file . exists ( ) ;
259+ if ( ! exists ) {
260+ throw new CliError ( 'NOT_FOUND' , `Attachment not found: ${ attachment . path } ` ) ;
261+ }
262+
263+ const content = await file . arrayBuffer ( ) ;
264+ const base64Content = Buffer . from ( content ) . toString ( 'base64' ) ;
265+ const filename = attachment . filename || basename ( attachment . path ) ;
266+ const mimeType = attachment . mimeType || getMimeType ( filename ) ;
267+
268+ headers . push (
269+ `--${ boundary } ` ,
270+ `Content-Type: ${ mimeType } ; name="${ filename } "` ,
271+ 'Content-Transfer-Encoding: base64' ,
272+ `Content-Disposition: attachment; filename="${ filename } "` ,
273+ '' ,
274+ base64Content
275+ ) ;
276+ } catch ( error : any ) {
277+ if ( error instanceof CliError ) throw error ;
278+ throw new CliError ( 'API_ERROR' , `Failed to read attachment ${ attachment . path } : ${ error . message } ` ) ;
279+ }
280+ }
281+
282+ headers . push ( `--${ boundary } --` ) ;
283+
284+ return headers . join ( '\r\n' ) ;
285+ }
286+
174287 async reply ( options : GmailReplyOptions ) : Promise < { id : string ; threadId : string ; labelIds : string [ ] } > {
175288 const { threadId, body, isHtml } = options ;
176289
0 commit comments