@cli4ai/gmail 1.0.5 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,199 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { getGmail, output, decodeBase64Url, type OutputOptions, type MessagePayload } from './api';
4
+
5
+ /** Attachment info */
6
+ export interface AttachmentInfo {
7
+ filename: string;
8
+ mimeType?: string;
9
+ size: number;
10
+ attachmentId?: string | null;
11
+ partId?: string;
12
+ }
13
+
14
+ /** Download result */
15
+ export interface DownloadResult {
16
+ ok: boolean;
17
+ filename: string;
18
+ savedTo?: string;
19
+ size?: number;
20
+ error?: string;
21
+ }
22
+
23
+ /** Download all result */
24
+ export interface DownloadAllResult {
25
+ ok: boolean;
26
+ message?: string;
27
+ downloaded: DownloadResult[];
28
+ }
29
+
30
+ /**
31
+ * Extract attachment info from message payload
32
+ */
33
+ function extractAttachmentInfo(payload: MessagePayload | undefined, attachments: AttachmentInfo[] = []): AttachmentInfo[] {
34
+ if (!payload) return attachments;
35
+
36
+ // Check if this part is an attachment
37
+ if (payload.filename && payload.filename.length > 0) {
38
+ attachments.push({
39
+ filename: payload.filename,
40
+ mimeType: payload.mimeType,
41
+ size: payload.body?.size || 0,
42
+ attachmentId: payload.body?.attachmentId || null,
43
+ partId: payload.partId
44
+ });
45
+ }
46
+
47
+ // Recursively check parts
48
+ if (payload.parts) {
49
+ for (const part of payload.parts) {
50
+ extractAttachmentInfo(part, attachments);
51
+ }
52
+ }
53
+
54
+ return attachments;
55
+ }
56
+
57
+ /**
58
+ * List attachments in a message
59
+ */
60
+ export async function list(messageId: string, options: OutputOptions = {}): Promise<AttachmentInfo[]> {
61
+ const gmail = await getGmail();
62
+
63
+ const res = await gmail.users.messages.get({
64
+ userId: 'me',
65
+ id: messageId,
66
+ format: 'full'
67
+ });
68
+
69
+ const attachments = extractAttachmentInfo(res.data.payload as MessagePayload);
70
+
71
+ output(attachments, options);
72
+ return attachments;
73
+ }
74
+
75
+ /**
76
+ * Download an attachment
77
+ */
78
+ export async function download(messageId: string, filename?: string, outputPath?: string, options: OutputOptions = {}): Promise<DownloadResult> {
79
+ const gmail = await getGmail();
80
+
81
+ // Get message to find attachment
82
+ const res = await gmail.users.messages.get({
83
+ userId: 'me',
84
+ id: messageId,
85
+ format: 'full'
86
+ });
87
+
88
+ const attachments = extractAttachmentInfo(res.data.payload as MessagePayload);
89
+
90
+ // Find the attachment by filename (or get first if not specified)
91
+ let attachment: AttachmentInfo | undefined;
92
+ if (filename) {
93
+ attachment = attachments.find(a =>
94
+ a.filename.toLowerCase() === filename.toLowerCase() ||
95
+ a.filename.toLowerCase().includes(filename.toLowerCase())
96
+ );
97
+ } else if (attachments.length === 1) {
98
+ attachment = attachments[0];
99
+ }
100
+
101
+ if (!attachment) {
102
+ throw new Error(`Attachment not found: ${filename || '(no attachments)'}`);
103
+ }
104
+
105
+ if (!attachment.attachmentId) {
106
+ throw new Error(`Attachment ${attachment.filename} has no downloadable content`);
107
+ }
108
+
109
+ // Get the attachment data
110
+ const attachmentRes = await gmail.users.messages.attachments.get({
111
+ userId: 'me',
112
+ messageId: messageId,
113
+ id: attachment.attachmentId
114
+ });
115
+
116
+ // Decode and save
117
+ const data = decodeBase64Url(attachmentRes.data.data);
118
+ const savePath = outputPath || attachment.filename;
119
+
120
+ fs.writeFileSync(savePath, data, 'binary');
121
+
122
+ const result: DownloadResult = {
123
+ ok: true,
124
+ filename: attachment.filename,
125
+ savedTo: path.resolve(savePath),
126
+ size: attachment.size
127
+ };
128
+
129
+ output(result, options);
130
+ return result;
131
+ }
132
+
133
+ /**
134
+ * Download all attachments from a message
135
+ */
136
+ export async function downloadAll(messageId: string, outputDir?: string, options: OutputOptions = {}): Promise<DownloadAllResult> {
137
+ const gmail = await getGmail();
138
+
139
+ // Ensure output directory exists
140
+ const dir = outputDir || '.';
141
+ if (!fs.existsSync(dir)) {
142
+ fs.mkdirSync(dir, { recursive: true });
143
+ }
144
+
145
+ // Get message
146
+ const res = await gmail.users.messages.get({
147
+ userId: 'me',
148
+ id: messageId,
149
+ format: 'full'
150
+ });
151
+
152
+ const attachments = extractAttachmentInfo(res.data.payload as MessagePayload);
153
+
154
+ if (attachments.length === 0) {
155
+ const result: DownloadAllResult = { ok: true, message: 'No attachments found', downloaded: [] };
156
+ output(result, options);
157
+ return result;
158
+ }
159
+
160
+ const downloaded: DownloadResult[] = [];
161
+
162
+ for (const attachment of attachments) {
163
+ if (!attachment.attachmentId) continue;
164
+
165
+ try {
166
+ const attachmentRes = await gmail.users.messages.attachments.get({
167
+ userId: 'me',
168
+ messageId: messageId,
169
+ id: attachment.attachmentId
170
+ });
171
+
172
+ const data = decodeBase64Url(attachmentRes.data.data);
173
+ const savePath = path.join(dir, attachment.filename);
174
+
175
+ fs.writeFileSync(savePath, data, 'binary');
176
+
177
+ downloaded.push({
178
+ ok: true,
179
+ filename: attachment.filename,
180
+ savedTo: path.resolve(savePath),
181
+ size: attachment.size
182
+ });
183
+ } catch (err) {
184
+ downloaded.push({
185
+ ok: false,
186
+ filename: attachment.filename,
187
+ error: (err as Error).message
188
+ });
189
+ }
190
+ }
191
+
192
+ const result: DownloadAllResult = {
193
+ ok: true,
194
+ downloaded
195
+ };
196
+
197
+ output(result, options);
198
+ return result;
199
+ }
package/lib/drafts.ts ADDED
@@ -0,0 +1,434 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { getGmail, output, formatDate, encodeBase64Url, extractHeaders, extractBody, type OutputOptions, type MessagePayload } from './api';
4
+
5
+ /** Attachment path */
6
+ export interface AttachmentPath {
7
+ path: string;
8
+ mimeType?: string;
9
+ }
10
+
11
+ /** Draft data */
12
+ export interface DraftData {
13
+ id: string;
14
+ messageId?: string;
15
+ threadId?: string;
16
+ to: string;
17
+ cc?: string | null;
18
+ subject: string;
19
+ snippet?: string;
20
+ body?: string;
21
+ }
22
+
23
+ /** Draft result */
24
+ export interface DraftResult {
25
+ ok: boolean;
26
+ id?: string;
27
+ messageId?: string;
28
+ threadId?: string;
29
+ to?: string;
30
+ subject?: string;
31
+ inReplyTo?: string;
32
+ deleted?: string;
33
+ sentDraft?: string;
34
+ }
35
+
36
+ /** Draft options */
37
+ export interface DraftOptions extends OutputOptions {
38
+ cc?: string;
39
+ bcc?: string;
40
+ attach?: string | string[];
41
+ limit?: number;
42
+ }
43
+
44
+ /**
45
+ * Create multipart message with attachment
46
+ */
47
+ export function createMultipartMessage({ to, cc, bcc, subject, body, inReplyTo, references, attachments = [] }: {
48
+ to: string;
49
+ cc?: string;
50
+ bcc?: string;
51
+ subject: string;
52
+ body: string;
53
+ inReplyTo?: string;
54
+ references?: string;
55
+ attachments?: AttachmentPath[];
56
+ }): string {
57
+ const boundary = `boundary_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
58
+
59
+ const headers: string[] = [];
60
+ headers.push(`To: ${to}`);
61
+ if (cc) headers.push(`Cc: ${cc}`);
62
+ if (bcc) headers.push(`Bcc: ${bcc}`);
63
+ headers.push(`Subject: ${subject}`);
64
+ if (inReplyTo) {
65
+ headers.push(`In-Reply-To: ${inReplyTo}`);
66
+ headers.push(`References: ${references || inReplyTo}`);
67
+ }
68
+ headers.push('MIME-Version: 1.0');
69
+ headers.push(`Content-Type: multipart/mixed; boundary="${boundary}"`);
70
+
71
+ const parts: string[] = [];
72
+
73
+ // Text body part
74
+ parts.push(`--${boundary}`);
75
+ parts.push('Content-Type: text/plain; charset=utf-8');
76
+ parts.push('Content-Transfer-Encoding: 7bit');
77
+ parts.push('');
78
+ parts.push(body);
79
+
80
+ // Attachment parts
81
+ for (const att of attachments) {
82
+ const filename = path.basename(att.path);
83
+ const content = fs.readFileSync(att.path);
84
+ const base64Content = content.toString('base64');
85
+ const mimeType = att.mimeType || getMimeType(filename);
86
+
87
+ parts.push(`--${boundary}`);
88
+ parts.push(`Content-Type: ${mimeType}; name="${filename}"`);
89
+ parts.push('Content-Transfer-Encoding: base64');
90
+ parts.push(`Content-Disposition: attachment; filename="${filename}"`);
91
+ parts.push('');
92
+ // Split base64 into lines of 76 chars
93
+ for (let i = 0; i < base64Content.length; i += 76) {
94
+ parts.push(base64Content.slice(i, i + 76));
95
+ }
96
+ }
97
+
98
+ parts.push(`--${boundary}--`);
99
+
100
+ const message = headers.join('\r\n') + '\r\n\r\n' + parts.join('\r\n');
101
+ return encodeBase64Url(message);
102
+ }
103
+
104
+ /**
105
+ * Simple message without attachments
106
+ */
107
+ export function createSimpleMessage({ to, cc, bcc, subject, body, inReplyTo, references }: {
108
+ to: string;
109
+ cc?: string;
110
+ bcc?: string;
111
+ subject: string;
112
+ body: string;
113
+ inReplyTo?: string;
114
+ references?: string;
115
+ }): string {
116
+ const lines: string[] = [];
117
+ lines.push(`To: ${to}`);
118
+ if (cc) lines.push(`Cc: ${cc}`);
119
+ if (bcc) lines.push(`Bcc: ${bcc}`);
120
+ lines.push(`Subject: ${subject}`);
121
+ lines.push('Content-Type: text/plain; charset=utf-8');
122
+ if (inReplyTo) {
123
+ lines.push(`In-Reply-To: ${inReplyTo}`);
124
+ lines.push(`References: ${references || inReplyTo}`);
125
+ }
126
+ lines.push('');
127
+ lines.push(body);
128
+ return encodeBase64Url(lines.join('\r\n'));
129
+ }
130
+
131
+ /**
132
+ * Get MIME type from filename
133
+ */
134
+ function getMimeType(filename: string): string {
135
+ const ext = path.extname(filename).toLowerCase();
136
+ const mimeTypes: Record<string, string> = {
137
+ '.pdf': 'application/pdf',
138
+ '.doc': 'application/msword',
139
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
140
+ '.xls': 'application/vnd.ms-excel',
141
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
142
+ '.png': 'image/png',
143
+ '.jpg': 'image/jpeg',
144
+ '.jpeg': 'image/jpeg',
145
+ '.gif': 'image/gif',
146
+ '.txt': 'text/plain',
147
+ '.csv': 'text/csv',
148
+ '.zip': 'application/zip',
149
+ '.json': 'application/json',
150
+ '.xml': 'application/xml',
151
+ '.html': 'text/html'
152
+ };
153
+ return mimeTypes[ext] || 'application/octet-stream';
154
+ }
155
+
156
+ /**
157
+ * List all drafts
158
+ */
159
+ export async function list(options: DraftOptions = {}): Promise<DraftData[]> {
160
+ const gmail = await getGmail();
161
+ const limit = options.limit || 20;
162
+
163
+ const res = await gmail.users.drafts.list({
164
+ userId: 'me',
165
+ maxResults: limit
166
+ });
167
+
168
+ if (!res.data.drafts || res.data.drafts.length === 0) {
169
+ output([], options);
170
+ return [];
171
+ }
172
+
173
+ // Get details for each draft
174
+ const drafts = await Promise.all(
175
+ res.data.drafts.map(async (draft) => {
176
+ const draftRes = await gmail.users.drafts.get({
177
+ userId: 'me',
178
+ id: draft.id!,
179
+ format: 'metadata',
180
+ metadataHeaders: ['To', 'Subject', 'Date']
181
+ });
182
+
183
+ const headers = extractHeaders(draftRes.data.message?.payload?.headers as Array<{ name: string; value: string }>);
184
+
185
+ return {
186
+ id: draft.id!,
187
+ messageId: draftRes.data.message?.id,
188
+ to: headers.to || '(no recipient)',
189
+ subject: headers.subject || '(no subject)',
190
+ snippet: draftRes.data.message?.snippet || ''
191
+ };
192
+ })
193
+ );
194
+
195
+ output(drafts, options);
196
+ return drafts;
197
+ }
198
+
199
+ /**
200
+ * Get a draft
201
+ */
202
+ export async function get(draftId: string, options: OutputOptions = {}): Promise<DraftData> {
203
+ const gmail = await getGmail();
204
+
205
+ const res = await gmail.users.drafts.get({
206
+ userId: 'me',
207
+ id: draftId,
208
+ format: 'full'
209
+ });
210
+
211
+ const headers = extractHeaders(res.data.message?.payload?.headers as Array<{ name: string; value: string }>);
212
+ const body = extractBody(res.data.message?.payload as MessagePayload);
213
+
214
+ const draft: DraftData = {
215
+ id: res.data.id!,
216
+ messageId: res.data.message?.id,
217
+ threadId: res.data.message?.threadId || undefined,
218
+ to: headers.to || '',
219
+ cc: headers.cc || null,
220
+ subject: headers.subject || '(no subject)',
221
+ body: body
222
+ };
223
+
224
+ output(draft, options);
225
+ return draft;
226
+ }
227
+
228
+ /**
229
+ * Create a new draft
230
+ */
231
+ export async function create(to: string, subject: string, body: string, options: DraftOptions = {}): Promise<DraftResult> {
232
+ const gmail = await getGmail();
233
+
234
+ body = body.replace(/\\n/g, '\n');
235
+
236
+ let raw: string;
237
+ if (options.attach) {
238
+ const attachments = Array.isArray(options.attach) ? options.attach : [options.attach];
239
+ const attachmentPaths = attachments.map(a => ({ path: a }));
240
+ raw = createMultipartMessage({
241
+ to,
242
+ cc: options.cc,
243
+ bcc: options.bcc,
244
+ subject,
245
+ body,
246
+ attachments: attachmentPaths
247
+ });
248
+ } else {
249
+ raw = createSimpleMessage({
250
+ to,
251
+ cc: options.cc,
252
+ bcc: options.bcc,
253
+ subject,
254
+ body
255
+ });
256
+ }
257
+
258
+ const res = await gmail.users.drafts.create({
259
+ userId: 'me',
260
+ requestBody: {
261
+ message: { raw }
262
+ }
263
+ });
264
+
265
+ const result: DraftResult = {
266
+ ok: true,
267
+ id: res.data.id || undefined,
268
+ messageId: res.data.message?.id,
269
+ to,
270
+ subject
271
+ };
272
+
273
+ output(result, options);
274
+ return result;
275
+ }
276
+
277
+ /**
278
+ * Create a draft reply to a message
279
+ */
280
+ export async function createReply(messageId: string, body: string, options: DraftOptions = {}): Promise<DraftResult> {
281
+ const gmail = await getGmail();
282
+
283
+ // Get original message
284
+ const original = await gmail.users.messages.get({
285
+ userId: 'me',
286
+ id: messageId,
287
+ format: 'metadata',
288
+ metadataHeaders: ['From', 'To', 'Subject', 'Message-ID', 'References']
289
+ });
290
+
291
+ const headers = extractHeaders(original.data.payload?.headers as Array<{ name: string; value: string }>);
292
+
293
+ // Determine reply-to address
294
+ const replyTo = headers.from!;
295
+ let subject = headers.subject || '';
296
+ if (!subject.toLowerCase().startsWith('re:')) {
297
+ subject = `Re: ${subject}`;
298
+ }
299
+
300
+ body = body.replace(/\\n/g, '\n');
301
+
302
+ let raw: string;
303
+ if (options.attach) {
304
+ const attachments = Array.isArray(options.attach) ? options.attach : [options.attach];
305
+ const attachmentPaths = attachments.map(a => ({ path: a }));
306
+ raw = createMultipartMessage({
307
+ to: replyTo,
308
+ subject,
309
+ body,
310
+ inReplyTo: headers.message_id,
311
+ references: headers.references ? `${headers.references} ${headers.message_id}` : headers.message_id,
312
+ attachments: attachmentPaths
313
+ });
314
+ } else {
315
+ raw = createSimpleMessage({
316
+ to: replyTo,
317
+ subject,
318
+ body,
319
+ inReplyTo: headers.message_id,
320
+ references: headers.references ? `${headers.references} ${headers.message_id}` : headers.message_id
321
+ });
322
+ }
323
+
324
+ const res = await gmail.users.drafts.create({
325
+ userId: 'me',
326
+ requestBody: {
327
+ message: {
328
+ raw,
329
+ threadId: original.data.threadId || undefined
330
+ }
331
+ }
332
+ });
333
+
334
+ const result: DraftResult = {
335
+ ok: true,
336
+ id: res.data.id || undefined,
337
+ messageId: res.data.message?.id,
338
+ threadId: original.data.threadId || undefined,
339
+ to: replyTo,
340
+ subject,
341
+ inReplyTo: messageId
342
+ };
343
+
344
+ output(result, options);
345
+ return result;
346
+ }
347
+
348
+ /**
349
+ * Update a draft
350
+ */
351
+ export async function update(draftId: string, to: string, subject: string, body: string, options: DraftOptions = {}): Promise<DraftResult> {
352
+ const gmail = await getGmail();
353
+
354
+ body = body.replace(/\\n/g, '\n');
355
+
356
+ let raw: string;
357
+ if (options.attach) {
358
+ const attachments = Array.isArray(options.attach) ? options.attach : [options.attach];
359
+ const attachmentPaths = attachments.map(a => ({ path: a }));
360
+ raw = createMultipartMessage({
361
+ to,
362
+ cc: options.cc,
363
+ subject,
364
+ body,
365
+ attachments: attachmentPaths
366
+ });
367
+ } else {
368
+ raw = createSimpleMessage({
369
+ to,
370
+ cc: options.cc,
371
+ subject,
372
+ body
373
+ });
374
+ }
375
+
376
+ const res = await gmail.users.drafts.update({
377
+ userId: 'me',
378
+ id: draftId,
379
+ requestBody: {
380
+ message: { raw }
381
+ }
382
+ });
383
+
384
+ const result: DraftResult = {
385
+ ok: true,
386
+ id: res.data.id || undefined,
387
+ messageId: res.data.message?.id,
388
+ to,
389
+ subject
390
+ };
391
+
392
+ output(result, options);
393
+ return result;
394
+ }
395
+
396
+ /**
397
+ * Delete a draft
398
+ */
399
+ export async function remove(draftId: string, options: OutputOptions = {}): Promise<DraftResult> {
400
+ const gmail = await getGmail();
401
+
402
+ await gmail.users.drafts.delete({
403
+ userId: 'me',
404
+ id: draftId
405
+ });
406
+
407
+ const result: DraftResult = { ok: true, deleted: draftId };
408
+ output(result, options);
409
+ return result;
410
+ }
411
+
412
+ /**
413
+ * Send a draft
414
+ */
415
+ export async function send(draftId: string, options: OutputOptions = {}): Promise<DraftResult> {
416
+ const gmail = await getGmail();
417
+
418
+ const res = await gmail.users.drafts.send({
419
+ userId: 'me',
420
+ requestBody: {
421
+ id: draftId
422
+ }
423
+ });
424
+
425
+ const result: DraftResult = {
426
+ ok: true,
427
+ id: res.data.id || undefined,
428
+ threadId: res.data.threadId || undefined,
429
+ sentDraft: draftId
430
+ };
431
+
432
+ output(result, options);
433
+ return result;
434
+ }