google-workspace-mcp 2.0.1 → 2.3.4

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.
Files changed (43) hide show
  1. package/README.md +39 -6
  2. package/dist/accounts.d.ts.map +1 -1
  3. package/dist/accounts.js +1 -0
  4. package/dist/accounts.js.map +1 -1
  5. package/dist/excelHelpers.d.ts +108 -0
  6. package/dist/excelHelpers.d.ts.map +1 -0
  7. package/dist/excelHelpers.js +343 -0
  8. package/dist/excelHelpers.js.map +1 -0
  9. package/dist/securityHelpers.d.ts +118 -0
  10. package/dist/securityHelpers.d.ts.map +1 -0
  11. package/dist/securityHelpers.js +437 -0
  12. package/dist/securityHelpers.js.map +1 -0
  13. package/dist/server.js +22 -6
  14. package/dist/server.js.map +1 -1
  15. package/dist/serverWrapper.d.ts +9 -1
  16. package/dist/serverWrapper.d.ts.map +1 -1
  17. package/dist/serverWrapper.js +76 -7
  18. package/dist/serverWrapper.js.map +1 -1
  19. package/dist/tools/docs.tools.d.ts.map +1 -1
  20. package/dist/tools/docs.tools.js +30 -11
  21. package/dist/tools/docs.tools.js.map +1 -1
  22. package/dist/tools/drive.tools.d.ts.map +1 -1
  23. package/dist/tools/drive.tools.js +680 -6
  24. package/dist/tools/drive.tools.js.map +1 -1
  25. package/dist/tools/excel.tools.d.ts +3 -0
  26. package/dist/tools/excel.tools.d.ts.map +1 -0
  27. package/dist/tools/excel.tools.js +651 -0
  28. package/dist/tools/excel.tools.js.map +1 -0
  29. package/dist/tools/forms.tools.d.ts.map +1 -1
  30. package/dist/tools/forms.tools.js +15 -9
  31. package/dist/tools/forms.tools.js.map +1 -1
  32. package/dist/tools/gmail.tools.d.ts.map +1 -1
  33. package/dist/tools/gmail.tools.js +1751 -126
  34. package/dist/tools/gmail.tools.js.map +1 -1
  35. package/dist/tools/sheets.tools.d.ts.map +1 -1
  36. package/dist/tools/sheets.tools.js +138 -4
  37. package/dist/tools/sheets.tools.js.map +1 -1
  38. package/dist/tools/slides.tools.d.ts.map +1 -1
  39. package/dist/tools/slides.tools.js +3 -1
  40. package/dist/tools/slides.tools.js.map +1 -1
  41. package/dist/types.d.ts +5 -0
  42. package/dist/types.d.ts.map +1 -1
  43. package/package.json +12 -6
@@ -1,9 +1,15 @@
1
1
  // gmail.tools.ts - Gmail tool module
2
+ import * as fs from 'fs/promises';
3
+ import * as os from 'os';
4
+ import * as path from 'path';
2
5
  import { z } from 'zod';
3
6
  import { formatToolError } from '../errorHelpers.js';
4
- import { getGmailMessageUrl, getGmailDraftsUrl } from '../urlHelpers.js';
7
+ import { getGmailMessageUrl, getGmailDraftsUrl, getDriveFileUrl } from '../urlHelpers.js';
8
+ import { Readable } from 'stream';
9
+ import { validateWritePath, wrapEmailContent } from '../securityHelpers.js';
10
+ import { getServerConfig } from '../serverWrapper.js';
5
11
  export function registerGmailTools(options) {
6
- const { server, getGmailClient, getAccountEmail } = options;
12
+ const { server, getGmailClient, getDriveClient, getAccountEmail } = options;
7
13
  server.addTool({
8
14
  name: 'listGmailMessages',
9
15
  description: 'List email messages from Gmail inbox or other labels. Returns message IDs and snippets.',
@@ -79,6 +85,10 @@ export function registerGmailTools(options) {
79
85
  .optional()
80
86
  .default('full')
81
87
  .describe('Response format'),
88
+ maxLength: z
89
+ .number()
90
+ .optional()
91
+ .describe('Maximum character length of the response. If the result exceeds this, it will be truncated with a notice.'),
82
92
  }),
83
93
  async execute(args, { log: _log }) {
84
94
  try {
@@ -154,101 +164,34 @@ export function registerGmailTools(options) {
154
164
  if (message.snippet) {
155
165
  result += `**Snippet:** ${message.snippet}\n\n`;
156
166
  }
157
- result += `**Body**\n${body || '(empty)'}\n\n`;
167
+ // Wrap email body with security warnings to defend against prompt injection
168
+ const from = getHeader('From');
169
+ const subject = getHeader('Subject');
170
+ const wrappedBody = body
171
+ ? wrapEmailContent(body, from || undefined, subject || undefined)
172
+ : '(empty)';
173
+ result += `**Body**\n${wrappedBody}\n\n`;
158
174
  if (attachments.length > 0) {
159
175
  result += `**Attachments (${attachments.length})**\n`;
160
176
  attachments.forEach((att, i) => {
161
177
  result += `${i + 1}. ${att.filename} (${att.mimeType}, ${att.size} bytes)\n`;
178
+ result += ` Attachment ID: ${att.attachmentId}\n`;
162
179
  });
163
- result += '\n';
180
+ result +=
181
+ '\nUse getGmailAttachment with the message ID and attachment ID to download attachments.\n';
164
182
  }
165
183
  if (link) {
166
184
  result += `View in Gmail: ${link}`;
167
185
  }
168
- return result;
169
- }
170
- catch (error) {
171
- throw new Error(formatToolError('readGmailMessage', error));
172
- }
173
- },
174
- });
175
- // --- Send Gmail Message ---
176
- server.addTool({
177
- name: 'sendGmailMessage',
178
- description: 'Send an email via Gmail. Supports plain text and HTML content.',
179
- annotations: {
180
- title: 'Send Gmail Message',
181
- readOnlyHint: false,
182
- destructiveHint: false,
183
- idempotentHint: false,
184
- openWorldHint: true,
185
- },
186
- parameters: z.object({
187
- account: z.string().describe('Account name to use'),
188
- to: z.string().describe('Recipient email address(es), comma-separated for multiple'),
189
- subject: z.string().describe('Email subject'),
190
- body: z.string().describe('Email body content'),
191
- cc: z.string().optional().describe('CC recipients, comma-separated'),
192
- bcc: z.string().optional().describe('BCC recipients, comma-separated'),
193
- isHtml: z
194
- .boolean()
195
- .optional()
196
- .default(false)
197
- .describe('Whether the body is HTML (default: false for plain text)'),
198
- replyToMessageId: z.string().optional().describe('Message ID to reply to (for threading)'),
199
- }),
200
- async execute(args, { log: _log }) {
201
- try {
202
- const gmail = await getGmailClient(args.account);
203
- // Build the email
204
- let emailContent = '';
205
- emailContent += `To: ${args.to}\r\n`;
206
- if (args.cc)
207
- emailContent += `Cc: ${args.cc}\r\n`;
208
- if (args.bcc)
209
- emailContent += `Bcc: ${args.bcc}\r\n`;
210
- emailContent += `Subject: ${args.subject}\r\n`;
211
- emailContent += 'MIME-Version: 1.0\r\n';
212
- if (args.isHtml) {
213
- emailContent += 'Content-Type: text/html; charset=utf-8\r\n';
214
- }
215
- else {
216
- emailContent += 'Content-Type: text/plain; charset=utf-8\r\n';
217
- }
218
- emailContent += `\r\n${args.body}`;
219
- // Base64 encode the email
220
- const encodedEmail = Buffer.from(emailContent)
221
- .toString('base64')
222
- .replace(/\+/g, '-')
223
- .replace(/\//g, '_')
224
- .replace(/=+$/, '');
225
- const response = await gmail.users.messages.send({
226
- userId: 'me',
227
- requestBody: {
228
- raw: encodedEmail,
229
- threadId: args.replyToMessageId
230
- ? (await gmail.users.messages.get({ userId: 'me', id: args.replyToMessageId })).data
231
- .threadId
232
- : undefined,
233
- },
234
- });
235
- const accountEmail = await getAccountEmail(args.account);
236
- const link = response.data.id
237
- ? getGmailMessageUrl(response.data.id, accountEmail)
238
- : undefined;
239
- let result = 'Successfully sent email.\n\n';
240
- result += `To: ${args.to}\n`;
241
- result += `Subject: ${args.subject}\n`;
242
- result += `Message ID: ${response.data.id}\n`;
243
- result += `Thread ID: ${response.data.threadId}\n`;
244
- result += `Labels: ${(response.data.labelIds ?? []).join(', ')}\n`;
245
- if (link) {
246
- result += `\nView sent message: ${link}`;
186
+ // Apply maxLength truncation if specified
187
+ if (args.maxLength && result.length > args.maxLength) {
188
+ result = result.substring(0, args.maxLength);
189
+ result += `\n\n[...TRUNCATED at ${args.maxLength} chars. Full message has more content. Use a larger maxLength or omit it to see everything.]`;
247
190
  }
248
191
  return result;
249
192
  }
250
193
  catch (error) {
251
- throw new Error(formatToolError('sendGmailMessage', error));
194
+ throw new Error(formatToolError('readGmailMessage', error));
252
195
  }
253
196
  },
254
197
  });
@@ -376,28 +319,95 @@ export function registerGmailTools(options) {
376
319
  }
377
320
  },
378
321
  });
379
- // --- Modify Gmail Labels ---
322
+ // --- Create Gmail Label ---
380
323
  server.addTool({
381
- name: 'modifyGmailLabels',
382
- description: 'Add or remove labels from a Gmail message. Use to archive, mark as read/unread, star, etc.',
324
+ name: 'createGmailLabel',
325
+ description: 'Create a new Gmail label (folder). Labels can be used to organize emails. After creating a label, use addGmailLabel to apply it to messages.',
383
326
  annotations: {
384
- title: 'Modify Gmail Labels',
327
+ title: 'Create Gmail Label',
385
328
  readOnlyHint: false,
386
329
  destructiveHint: false,
387
- idempotentHint: true,
330
+ idempotentHint: false,
388
331
  openWorldHint: true,
389
332
  },
390
333
  parameters: z.object({
391
334
  account: z.string().describe('Account name to use'),
392
- messageId: z.string().describe('The message ID to modify'),
393
- addLabelIds: z
394
- .array(z.string())
335
+ name: z
336
+ .string()
337
+ .describe('Name of the label to create. Use "/" for nested labels (e.g., "Work/Projects")'),
338
+ labelListVisibility: z
339
+ .enum(['labelShow', 'labelShowIfUnread', 'labelHide'])
395
340
  .optional()
396
- .describe('Label IDs to add (e.g., ["STARRED", "IMPORTANT"])'),
397
- removeLabelIds: z
398
- .array(z.string())
341
+ .default('labelShow')
342
+ .describe('Whether to show the label in the label list: labelShow (always), labelShowIfUnread (only when unread), labelHide (hidden)'),
343
+ messageListVisibility: z
344
+ .enum(['show', 'hide'])
399
345
  .optional()
400
- .describe('Label IDs to remove (e.g., ["UNREAD", "INBOX"])'),
346
+ .default('show')
347
+ .describe('Whether to show the label in the message list'),
348
+ backgroundColor: z
349
+ .string()
350
+ .optional()
351
+ .describe('Background color in hex format (e.g., "#16a765")'),
352
+ textColor: z.string().optional().describe('Text color in hex format (e.g., "#ffffff")'),
353
+ }),
354
+ async execute(args, { log: _log }) {
355
+ try {
356
+ const gmail = await getGmailClient(args.account);
357
+ const labelColor = args.backgroundColor || args.textColor
358
+ ? {
359
+ backgroundColor: args.backgroundColor,
360
+ textColor: args.textColor,
361
+ }
362
+ : undefined;
363
+ const response = await gmail.users.labels.create({
364
+ userId: 'me',
365
+ requestBody: {
366
+ name: args.name,
367
+ labelListVisibility: args.labelListVisibility,
368
+ messageListVisibility: args.messageListVisibility,
369
+ color: labelColor,
370
+ },
371
+ });
372
+ const label = response.data;
373
+ let result = `Successfully created label "${args.name}".\n\n`;
374
+ result += `Label ID: ${label.id}\n`;
375
+ result += `Name: ${label.name}\n`;
376
+ result += `Type: ${label.type}\n`;
377
+ if (label.labelListVisibility) {
378
+ result += `Label List Visibility: ${label.labelListVisibility}\n`;
379
+ }
380
+ if (label.messageListVisibility) {
381
+ result += `Message List Visibility: ${label.messageListVisibility}\n`;
382
+ }
383
+ if (label.color) {
384
+ result += `Color: ${label.color.backgroundColor || 'default'} / ${label.color.textColor || 'default'}\n`;
385
+ }
386
+ result += '\nUse this Label ID with addGmailLabel to apply it to messages.';
387
+ return result;
388
+ }
389
+ catch (error) {
390
+ throw new Error(formatToolError('createGmailLabel', error));
391
+ }
392
+ },
393
+ });
394
+ // --- Add Gmail Label ---
395
+ server.addTool({
396
+ name: 'addGmailLabel',
397
+ description: 'Add a label to a Gmail message. Common labels: STARRED (star), IMPORTANT, INBOX. Use listGmailLabels to see all available labels. WARNING: Draft message IDs are ephemeral and may change after draft modifications (e.g., adding/removing attachments). If labeling a draft, perform label operations BEFORE modifying draft attachments, or re-fetch the draft to get the current message ID.',
398
+ annotations: {
399
+ title: 'Add Gmail Label',
400
+ readOnlyHint: false,
401
+ destructiveHint: false,
402
+ idempotentHint: true,
403
+ openWorldHint: true,
404
+ },
405
+ parameters: z.object({
406
+ account: z.string().describe('Account name to use'),
407
+ messageId: z.string().describe('The message ID to modify'),
408
+ labelId: z
409
+ .string()
410
+ .describe('Label ID to add (e.g., "STARRED", "IMPORTANT", or a custom label ID)'),
401
411
  }),
402
412
  async execute(args, { log: _log }) {
403
413
  try {
@@ -406,36 +416,73 @@ export function registerGmailTools(options) {
406
416
  userId: 'me',
407
417
  id: args.messageId,
408
418
  requestBody: {
409
- addLabelIds: args.addLabelIds,
410
- removeLabelIds: args.removeLabelIds,
419
+ addLabelIds: [args.labelId],
411
420
  },
412
421
  });
413
422
  const accountEmail = await getAccountEmail(args.account);
414
423
  const link = response.data.id
415
424
  ? getGmailMessageUrl(response.data.id, accountEmail)
416
425
  : undefined;
417
- let result = `Successfully modified labels for message ${args.messageId}.\n\n`;
418
- if (args.addLabelIds?.length) {
419
- result += `Added labels: ${args.addLabelIds.join(', ')}\n`;
420
- }
421
- if (args.removeLabelIds?.length) {
422
- result += `Removed labels: ${args.removeLabelIds.join(', ')}\n`;
426
+ let result = `Successfully added label "${args.labelId}" to message ${args.messageId}.\n`;
427
+ result += `Current labels: ${(response.data.labelIds ?? []).join(', ')}\n`;
428
+ if (link) {
429
+ result += `\nView message: ${link}`;
423
430
  }
424
- result += `\nCurrent labels: ${(response.data.labelIds ?? []).join(', ')}\n`;
431
+ return result;
432
+ }
433
+ catch (error) {
434
+ throw new Error(formatToolError('addGmailLabel', error));
435
+ }
436
+ },
437
+ });
438
+ // --- Remove Gmail Label ---
439
+ server.addTool({
440
+ name: 'removeGmailLabel',
441
+ description: 'Remove a label from a Gmail message. Common uses: remove UNREAD (mark as read), remove INBOX (archive), remove STARRED (unstar). WARNING: Draft message IDs are ephemeral and may change after draft modifications (e.g., adding/removing attachments). If removing labels from a draft, perform label operations BEFORE modifying draft attachments, or re-fetch the draft to get the current message ID.',
442
+ annotations: {
443
+ title: 'Remove Gmail Label',
444
+ readOnlyHint: false,
445
+ destructiveHint: false,
446
+ idempotentHint: true,
447
+ openWorldHint: true,
448
+ },
449
+ parameters: z.object({
450
+ account: z.string().describe('Account name to use'),
451
+ messageId: z.string().describe('The message ID to modify'),
452
+ labelId: z
453
+ .string()
454
+ .describe('Label ID to remove (e.g., "UNREAD" to mark as read, "INBOX" to archive, "STARRED" to unstar)'),
455
+ }),
456
+ async execute(args, { log: _log }) {
457
+ try {
458
+ const gmail = await getGmailClient(args.account);
459
+ const response = await gmail.users.messages.modify({
460
+ userId: 'me',
461
+ id: args.messageId,
462
+ requestBody: {
463
+ removeLabelIds: [args.labelId],
464
+ },
465
+ });
466
+ const accountEmail = await getAccountEmail(args.account);
467
+ const link = response.data.id
468
+ ? getGmailMessageUrl(response.data.id, accountEmail)
469
+ : undefined;
470
+ let result = `Successfully removed label "${args.labelId}" from message ${args.messageId}.\n`;
471
+ result += `Current labels: ${(response.data.labelIds ?? []).join(', ')}\n`;
425
472
  if (link) {
426
473
  result += `\nView message: ${link}`;
427
474
  }
428
475
  return result;
429
476
  }
430
477
  catch (error) {
431
- throw new Error(formatToolError('modifyGmailLabels', error));
478
+ throw new Error(formatToolError('removeGmailLabel', error));
432
479
  }
433
480
  },
434
481
  });
435
482
  // --- Create Gmail Draft ---
436
483
  server.addTool({
437
484
  name: 'createGmailDraft',
438
- description: 'Create a draft email in Gmail.',
485
+ description: 'Create a draft email in Gmail. Supports threading by providing replyToMessageId to create a draft reply. Supports attachments by providing base64-encoded file data.',
439
486
  annotations: {
440
487
  title: 'Create Gmail Draft',
441
488
  readOnlyHint: false,
@@ -449,18 +496,104 @@ export function registerGmailTools(options) {
449
496
  subject: z.string().describe('Email subject'),
450
497
  body: z.string().describe('Email body content'),
451
498
  cc: z.string().optional().describe('CC recipients'),
499
+ bcc: z.string().optional().describe('BCC recipients'),
452
500
  isHtml: z.boolean().optional().default(false).describe('Whether body is HTML'),
501
+ replyToMessageId: z
502
+ .string()
503
+ .optional()
504
+ .describe('Message ID to reply to (for threading). The draft will appear in the same thread as the original message.'),
505
+ attachments: z
506
+ .array(z.object({
507
+ filename: z.string().describe('Name of the file (e.g., "report.pdf")'),
508
+ mimeType: z
509
+ .string()
510
+ .describe('MIME type of the file (e.g., "application/pdf", "image/png")'),
511
+ base64Data: z
512
+ .string()
513
+ .describe('Base64-encoded file content (standard base64, not base64url)'),
514
+ }))
515
+ .optional()
516
+ .describe('Array of attachments to include in the draft'),
453
517
  }),
454
518
  async execute(args, { log: _log }) {
455
519
  try {
456
520
  const gmail = await getGmailClient(args.account);
521
+ let threadId;
522
+ let inReplyTo;
523
+ let references;
524
+ // If replying to a message, get thread info and headers for proper threading
525
+ if (args.replyToMessageId) {
526
+ const originalMessage = await gmail.users.messages.get({
527
+ userId: 'me',
528
+ id: args.replyToMessageId,
529
+ format: 'metadata',
530
+ metadataHeaders: ['Message-ID', 'References'],
531
+ });
532
+ threadId = originalMessage.data.threadId ?? undefined;
533
+ // Get the Message-ID header for In-Reply-To and References
534
+ const headers = originalMessage.data.payload?.headers ?? [];
535
+ const messageIdHeader = headers.find((h) => h.name?.toLowerCase() === 'message-id')?.value;
536
+ const referencesHeader = headers.find((h) => h.name?.toLowerCase() === 'references')?.value;
537
+ if (messageIdHeader) {
538
+ inReplyTo = messageIdHeader;
539
+ // References should include the original References header (if any) plus the Message-ID
540
+ references = referencesHeader
541
+ ? `${referencesHeader} ${messageIdHeader}`
542
+ : messageIdHeader;
543
+ }
544
+ }
457
545
  let emailContent = '';
458
- emailContent += `To: ${args.to}\r\n`;
459
- if (args.cc)
460
- emailContent += `Cc: ${args.cc}\r\n`;
461
- emailContent += `Subject: ${args.subject}\r\n`;
462
- emailContent += `Content-Type: ${args.isHtml ? 'text/html' : 'text/plain'}; charset=utf-8\r\n`;
463
- emailContent += `\r\n${args.body}`;
546
+ if (args.attachments && args.attachments.length > 0) {
547
+ // Build multipart MIME message with attachments
548
+ const boundary = `boundary_${Date.now()}_${Math.random().toString(36).substring(2)}`;
549
+ emailContent += `To: ${args.to}\r\n`;
550
+ if (args.cc)
551
+ emailContent += `Cc: ${args.cc}\r\n`;
552
+ if (args.bcc)
553
+ emailContent += `Bcc: ${args.bcc}\r\n`;
554
+ emailContent += `Subject: ${args.subject}\r\n`;
555
+ if (inReplyTo)
556
+ emailContent += `In-Reply-To: ${inReplyTo}\r\n`;
557
+ if (references)
558
+ emailContent += `References: ${references}\r\n`;
559
+ emailContent += 'MIME-Version: 1.0\r\n';
560
+ emailContent += `Content-Type: multipart/mixed; boundary="${boundary}"\r\n`;
561
+ emailContent += '\r\n';
562
+ // Body part
563
+ emailContent += `--${boundary}\r\n`;
564
+ emailContent += `Content-Type: ${args.isHtml ? 'text/html' : 'text/plain'}; charset=utf-8\r\n`;
565
+ emailContent += 'Content-Transfer-Encoding: 7bit\r\n';
566
+ emailContent += '\r\n';
567
+ emailContent += `${args.body}\r\n`;
568
+ // Attachment parts
569
+ for (const attachment of args.attachments) {
570
+ emailContent += `--${boundary}\r\n`;
571
+ emailContent += `Content-Type: ${attachment.mimeType}; name="${attachment.filename}"\r\n`;
572
+ emailContent += `Content-Disposition: attachment; filename="${attachment.filename}"\r\n`;
573
+ emailContent += 'Content-Transfer-Encoding: base64\r\n';
574
+ emailContent += '\r\n';
575
+ // Split base64 data into 76-character lines per RFC 2045
576
+ const base64Lines = attachment.base64Data.match(/.{1,76}/g) || [];
577
+ emailContent += base64Lines.join('\r\n');
578
+ emailContent += '\r\n';
579
+ }
580
+ emailContent += `--${boundary}--\r\n`;
581
+ }
582
+ else {
583
+ // Simple message without attachments
584
+ emailContent += `To: ${args.to}\r\n`;
585
+ if (args.cc)
586
+ emailContent += `Cc: ${args.cc}\r\n`;
587
+ if (args.bcc)
588
+ emailContent += `Bcc: ${args.bcc}\r\n`;
589
+ emailContent += `Subject: ${args.subject}\r\n`;
590
+ if (inReplyTo)
591
+ emailContent += `In-Reply-To: ${inReplyTo}\r\n`;
592
+ if (references)
593
+ emailContent += `References: ${references}\r\n`;
594
+ emailContent += `Content-Type: ${args.isHtml ? 'text/html' : 'text/plain'}; charset=utf-8\r\n`;
595
+ emailContent += `\r\n${args.body}`;
596
+ }
464
597
  const encodedEmail = Buffer.from(emailContent)
465
598
  .toString('base64')
466
599
  .replace(/\+/g, '-')
@@ -469,16 +602,29 @@ export function registerGmailTools(options) {
469
602
  const response = await gmail.users.drafts.create({
470
603
  userId: 'me',
471
604
  requestBody: {
472
- message: { raw: encodedEmail },
605
+ message: {
606
+ raw: encodedEmail,
607
+ threadId: threadId,
608
+ },
473
609
  },
474
610
  });
475
611
  const accountEmail = await getAccountEmail(args.account);
476
612
  const draftsLink = getGmailDraftsUrl(accountEmail);
477
613
  let result = 'Successfully created draft email.\n\n';
478
614
  result += `To: ${args.to}\n`;
615
+ if (args.cc)
616
+ result += `Cc: ${args.cc}\n`;
617
+ if (args.bcc)
618
+ result += `Bcc: ${args.bcc}\n`;
479
619
  result += `Subject: ${args.subject}\n`;
480
620
  result += `Draft ID: ${response.data.id}\n`;
481
621
  result += `Message ID: ${response.data.message?.id}\n`;
622
+ if (threadId) {
623
+ result += `Thread ID: ${threadId} (draft will appear in thread)\n`;
624
+ }
625
+ if (args.attachments && args.attachments.length > 0) {
626
+ result += `Attachments: ${args.attachments.map((a) => a.filename).join(', ')}\n`;
627
+ }
482
628
  result += `\nView drafts: ${draftsLink}`;
483
629
  return result;
484
630
  }
@@ -487,32 +633,1511 @@ export function registerGmailTools(options) {
487
633
  }
488
634
  },
489
635
  });
490
- // --- Delete Gmail Message ---
636
+ // --- List Gmail Drafts ---
491
637
  server.addTool({
492
- name: 'deleteGmailMessage',
493
- description: 'Move a Gmail message to trash. Messages in trash are automatically deleted after 30 days.',
638
+ name: 'listGmailDrafts',
639
+ description: 'List all draft emails in Gmail.',
494
640
  annotations: {
495
- title: 'Delete Gmail Message',
496
- readOnlyHint: false,
497
- destructiveHint: true,
498
- idempotentHint: true,
641
+ title: 'List Gmail Drafts',
642
+ readOnlyHint: true,
499
643
  openWorldHint: true,
500
644
  },
501
645
  parameters: z.object({
502
646
  account: z.string().describe('Account name to use'),
503
- messageId: z.string().describe('The message ID to move to trash'),
647
+ maxResults: z
648
+ .number()
649
+ .optional()
650
+ .default(20)
651
+ .describe('Maximum number of drafts to return (default: 20)'),
504
652
  }),
505
653
  async execute(args, { log: _log }) {
506
654
  try {
507
655
  const gmail = await getGmailClient(args.account);
508
- await gmail.users.messages.trash({
656
+ const response = await gmail.users.drafts.list({
509
657
  userId: 'me',
510
- id: args.messageId,
658
+ maxResults: Math.min(args.maxResults || 20, 100),
511
659
  });
512
- return `Successfully moved message ${args.messageId} to trash.\n\nThe message will be automatically deleted after 30 days.`;
660
+ const drafts = response.data.drafts ?? [];
661
+ const accountEmail = await getAccountEmail(args.account);
662
+ let result = `**Gmail Drafts (${drafts.length} found)**\n\n`;
663
+ if (drafts.length === 0) {
664
+ result += 'No drafts found.';
665
+ return result;
666
+ }
667
+ for (let i = 0; i < drafts.length; i++) {
668
+ const draft = drafts[i];
669
+ if (!draft.id)
670
+ continue;
671
+ // Get draft details
672
+ const draftDetails = await gmail.users.drafts.get({
673
+ userId: 'me',
674
+ id: draft.id,
675
+ format: 'metadata',
676
+ });
677
+ const headers = draftDetails.data.message?.payload?.headers ?? [];
678
+ const getHeader = (name) => headers.find((h) => h.name?.toLowerCase() === name.toLowerCase())?.value;
679
+ result += `**${i + 1}. ${getHeader('Subject') || '(no subject)'}**\n`;
680
+ result += ` Draft ID: ${draft.id}\n`;
681
+ result += ` To: ${getHeader('To') || '(no recipient)'}\n`;
682
+ if (draftDetails.data.message?.snippet) {
683
+ result += ` Preview: ${draftDetails.data.message.snippet}\n`;
684
+ }
685
+ result += '\n';
686
+ }
687
+ const draftsLink = getGmailDraftsUrl(accountEmail);
688
+ result += `View all drafts: ${draftsLink}`;
689
+ return result;
513
690
  }
514
691
  catch (error) {
515
- throw new Error(formatToolError('deleteGmailMessage', error));
692
+ throw new Error(formatToolError('listGmailDrafts', error));
693
+ }
694
+ },
695
+ });
696
+ // --- Read Gmail Draft ---
697
+ server.addTool({
698
+ name: 'readGmailDraft',
699
+ description: 'Read the full content of a Gmail draft by its draft ID.',
700
+ annotations: {
701
+ title: 'Read Gmail Draft',
702
+ readOnlyHint: true,
703
+ openWorldHint: true,
704
+ },
705
+ parameters: z.object({
706
+ account: z.string().describe('Account name to use'),
707
+ draftId: z.string().describe('The draft ID to read'),
708
+ }),
709
+ async execute(args, { log: _log }) {
710
+ try {
711
+ const gmail = await getGmailClient(args.account);
712
+ const response = await gmail.users.drafts.get({
713
+ userId: 'me',
714
+ id: args.draftId,
715
+ format: 'full',
716
+ });
717
+ const message = response.data.message;
718
+ const headers = message?.payload?.headers ?? [];
719
+ const getHeader = (name) => headers.find((h) => h.name?.toLowerCase() === name.toLowerCase())?.value;
720
+ // Extract body
721
+ let body = '';
722
+ const extractBody = (part) => {
723
+ if (part.body?.data) {
724
+ return Buffer.from(part.body.data, 'base64').toString('utf8');
725
+ }
726
+ if (part.parts) {
727
+ for (const subpart of part.parts) {
728
+ if (subpart.mimeType === 'text/plain') {
729
+ return extractBody(subpart);
730
+ }
731
+ }
732
+ for (const subpart of part.parts) {
733
+ if (subpart.mimeType === 'text/html') {
734
+ return extractBody(subpart);
735
+ }
736
+ }
737
+ for (const subpart of part.parts) {
738
+ const result = extractBody(subpart);
739
+ if (result)
740
+ return result;
741
+ }
742
+ }
743
+ return '';
744
+ };
745
+ if (message?.payload) {
746
+ body = extractBody(message.payload);
747
+ }
748
+ const accountEmail = await getAccountEmail(args.account);
749
+ const draftsLink = getGmailDraftsUrl(accountEmail);
750
+ let result = '**Draft Email**\n\n';
751
+ result += `Draft ID: ${response.data.id}\n`;
752
+ result += `Message ID: ${message?.id}\n`;
753
+ if (message?.threadId) {
754
+ result += `Thread ID: ${message.threadId}\n`;
755
+ }
756
+ result += '\n**Headers**\n';
757
+ result += `To: ${getHeader('To') || '(empty)'}\n`;
758
+ result += `Cc: ${getHeader('Cc') || '(empty)'}\n`;
759
+ result += `Subject: ${getHeader('Subject') || '(empty)'}\n`;
760
+ result += '\n**Body**\n';
761
+ result += body || '(empty)';
762
+ result += `\n\nView drafts: ${draftsLink}`;
763
+ return result;
764
+ }
765
+ catch (error) {
766
+ throw new Error(formatToolError('readGmailDraft', error));
767
+ }
768
+ },
769
+ });
770
+ // --- Update Gmail Draft ---
771
+ server.addTool({
772
+ name: 'updateGmailDraft',
773
+ description: 'Update an existing Gmail draft. You can update any combination of to, cc, bcc, subject, body, or attachments. Fields not provided will keep their current values (except attachments which replace existing ones if provided).',
774
+ annotations: {
775
+ title: 'Update Gmail Draft',
776
+ readOnlyHint: false,
777
+ destructiveHint: false,
778
+ idempotentHint: true,
779
+ openWorldHint: true,
780
+ },
781
+ parameters: z.object({
782
+ account: z.string().describe('Account name to use'),
783
+ draftId: z.string().describe('The draft ID to update'),
784
+ to: z.string().optional().describe('New recipient(s) - if not provided, keeps current'),
785
+ cc: z.string().optional().describe('New CC recipient(s) - if not provided, keeps current'),
786
+ bcc: z.string().optional().describe('New BCC recipient(s) - if not provided, keeps current'),
787
+ subject: z.string().optional().describe('New subject - if not provided, keeps current'),
788
+ body: z.string().optional().describe('New body content - if not provided, keeps current'),
789
+ isHtml: z
790
+ .boolean()
791
+ .optional()
792
+ .describe('Whether the body is HTML (default: false for plain text)'),
793
+ attachments: z
794
+ .array(z.object({
795
+ filename: z.string().describe('Name of the file (e.g., "report.pdf")'),
796
+ mimeType: z
797
+ .string()
798
+ .describe('MIME type of the file (e.g., "application/pdf", "image/png")'),
799
+ base64Data: z
800
+ .string()
801
+ .describe('Base64-encoded file content (standard base64, not base64url)'),
802
+ }))
803
+ .optional()
804
+ .describe('Array of attachments (replaces existing attachments if provided)'),
805
+ }),
806
+ async execute(args, { log: _log }) {
807
+ try {
808
+ const gmail = await getGmailClient(args.account);
809
+ // First, get the current draft content
810
+ const currentDraft = await gmail.users.drafts.get({
811
+ userId: 'me',
812
+ id: args.draftId,
813
+ format: 'full',
814
+ });
815
+ const currentMessage = currentDraft.data.message;
816
+ const currentHeaders = currentMessage?.payload?.headers ?? [];
817
+ const getCurrentHeader = (name) => currentHeaders.find((h) => h.name?.toLowerCase() === name.toLowerCase())?.value || '';
818
+ // Extract current body
819
+ let currentBody = '';
820
+ const extractBody = (part) => {
821
+ if (part.body?.data) {
822
+ return Buffer.from(part.body.data, 'base64').toString('utf8');
823
+ }
824
+ if (part.parts) {
825
+ for (const subpart of part.parts) {
826
+ if (subpart.mimeType === 'text/plain') {
827
+ return extractBody(subpart);
828
+ }
829
+ }
830
+ for (const subpart of part.parts) {
831
+ if (subpart.mimeType === 'text/html') {
832
+ return extractBody(subpart);
833
+ }
834
+ }
835
+ for (const subpart of part.parts) {
836
+ const result = extractBody(subpart);
837
+ if (result)
838
+ return result;
839
+ }
840
+ }
841
+ return '';
842
+ };
843
+ if (currentMessage?.payload) {
844
+ currentBody = extractBody(currentMessage.payload);
845
+ }
846
+ // Merge with updates (use new values if provided, otherwise keep current)
847
+ const newTo = args.to ?? getCurrentHeader('To');
848
+ const newCc = args.cc ?? getCurrentHeader('Cc');
849
+ const newBcc = args.bcc ?? getCurrentHeader('Bcc');
850
+ const newSubject = args.subject ?? getCurrentHeader('Subject');
851
+ const newBody = args.body ?? currentBody;
852
+ const isHtml = args.isHtml ?? false;
853
+ // Build the updated email
854
+ let emailContent = '';
855
+ if (args.attachments && args.attachments.length > 0) {
856
+ // Build multipart MIME message with attachments
857
+ const boundary = `boundary_${Date.now()}_${Math.random().toString(36).substring(2)}`;
858
+ emailContent += `To: ${newTo}\r\n`;
859
+ if (newCc)
860
+ emailContent += `Cc: ${newCc}\r\n`;
861
+ if (newBcc)
862
+ emailContent += `Bcc: ${newBcc}\r\n`;
863
+ emailContent += `Subject: ${newSubject}\r\n`;
864
+ emailContent += 'MIME-Version: 1.0\r\n';
865
+ emailContent += `Content-Type: multipart/mixed; boundary="${boundary}"\r\n`;
866
+ emailContent += '\r\n';
867
+ // Body part
868
+ emailContent += `--${boundary}\r\n`;
869
+ emailContent += `Content-Type: ${isHtml ? 'text/html' : 'text/plain'}; charset=utf-8\r\n`;
870
+ emailContent += 'Content-Transfer-Encoding: 7bit\r\n';
871
+ emailContent += '\r\n';
872
+ emailContent += `${newBody}\r\n`;
873
+ // Attachment parts
874
+ for (const attachment of args.attachments) {
875
+ emailContent += `--${boundary}\r\n`;
876
+ emailContent += `Content-Type: ${attachment.mimeType}; name="${attachment.filename}"\r\n`;
877
+ emailContent += `Content-Disposition: attachment; filename="${attachment.filename}"\r\n`;
878
+ emailContent += 'Content-Transfer-Encoding: base64\r\n';
879
+ emailContent += '\r\n';
880
+ // Split base64 data into 76-character lines per RFC 2045
881
+ const base64Lines = attachment.base64Data.match(/.{1,76}/g) || [];
882
+ emailContent += base64Lines.join('\r\n');
883
+ emailContent += '\r\n';
884
+ }
885
+ emailContent += `--${boundary}--\r\n`;
886
+ }
887
+ else {
888
+ // Simple message without attachments
889
+ emailContent += `To: ${newTo}\r\n`;
890
+ if (newCc)
891
+ emailContent += `Cc: ${newCc}\r\n`;
892
+ if (newBcc)
893
+ emailContent += `Bcc: ${newBcc}\r\n`;
894
+ emailContent += `Subject: ${newSubject}\r\n`;
895
+ emailContent += `Content-Type: ${isHtml ? 'text/html' : 'text/plain'}; charset=utf-8\r\n`;
896
+ emailContent += `\r\n${newBody}`;
897
+ }
898
+ const encodedEmail = Buffer.from(emailContent)
899
+ .toString('base64')
900
+ .replace(/\+/g, '-')
901
+ .replace(/\//g, '_')
902
+ .replace(/=+$/, '');
903
+ // Update the draft
904
+ const response = await gmail.users.drafts.update({
905
+ userId: 'me',
906
+ id: args.draftId,
907
+ requestBody: {
908
+ message: {
909
+ raw: encodedEmail,
910
+ threadId: currentMessage?.threadId,
911
+ },
912
+ },
913
+ });
914
+ const accountEmail = await getAccountEmail(args.account);
915
+ const draftsLink = getGmailDraftsUrl(accountEmail);
916
+ let result = 'Successfully updated draft.\n\n';
917
+ result += `Draft ID: ${response.data.id}\n`;
918
+ result += `To: ${newTo}\n`;
919
+ if (newCc)
920
+ result += `Cc: ${newCc}\n`;
921
+ if (newBcc)
922
+ result += `Bcc: ${newBcc}\n`;
923
+ result += `Subject: ${newSubject}\n`;
924
+ result += '\n**Updated fields:**\n';
925
+ if (args.to !== undefined)
926
+ result += '- To\n';
927
+ if (args.cc !== undefined)
928
+ result += '- Cc\n';
929
+ if (args.bcc !== undefined)
930
+ result += '- Bcc\n';
931
+ if (args.subject !== undefined)
932
+ result += '- Subject\n';
933
+ if (args.body !== undefined)
934
+ result += '- Body\n';
935
+ if (args.attachments !== undefined)
936
+ result += `- Attachments (${args.attachments.length} files)\n`;
937
+ result += `\nView drafts: ${draftsLink}`;
938
+ return result;
939
+ }
940
+ catch (error) {
941
+ throw new Error(formatToolError('updateGmailDraft', error));
942
+ }
943
+ },
944
+ });
945
+ // --- Add Attachment to Draft ---
946
+ server.addTool({
947
+ name: 'addAttachmentToDraft',
948
+ description: 'Add an attachment to an existing Gmail draft. The attachment is added to the draft without modifying other content.',
949
+ annotations: {
950
+ title: 'Add Attachment to Draft',
951
+ readOnlyHint: false,
952
+ destructiveHint: false,
953
+ idempotentHint: false,
954
+ openWorldHint: true,
955
+ },
956
+ parameters: z.object({
957
+ account: z.string().describe('Account name to use'),
958
+ draftId: z.string().describe('The draft ID to add the attachment to'),
959
+ filename: z.string().describe('Name of the file (e.g., "report.pdf")'),
960
+ mimeType: z.string().describe('MIME type of the file (e.g., "application/pdf", "image/png")'),
961
+ base64Data: z
962
+ .string()
963
+ .describe('Base64-encoded file content (standard base64, not base64url)'),
964
+ }),
965
+ async execute(args, { log: _log }) {
966
+ try {
967
+ const gmail = await getGmailClient(args.account);
968
+ // Get current draft
969
+ const currentDraft = await gmail.users.drafts.get({
970
+ userId: 'me',
971
+ id: args.draftId,
972
+ format: 'full',
973
+ });
974
+ const currentMessage = currentDraft.data.message;
975
+ const currentHeaders = currentMessage?.payload?.headers ?? [];
976
+ const getCurrentHeader = (name) => currentHeaders.find((h) => h.name?.toLowerCase() === name.toLowerCase())?.value || '';
977
+ // Extract current body and attachments
978
+ let bodyContent = '';
979
+ let bodyMimeType = 'text/plain';
980
+ const existingAttachments = [];
981
+ const extractParts = async (part) => {
982
+ if (part.mimeType === 'text/plain' && !part.filename && part.body?.data) {
983
+ bodyContent = Buffer.from(part.body.data, 'base64').toString('utf8');
984
+ bodyMimeType = 'text/plain';
985
+ }
986
+ else if (part.mimeType === 'text/html' &&
987
+ !part.filename &&
988
+ part.body?.data &&
989
+ !bodyContent) {
990
+ bodyContent = Buffer.from(part.body.data, 'base64').toString('utf8');
991
+ bodyMimeType = 'text/html';
992
+ }
993
+ else if (part.filename && part.body?.attachmentId) {
994
+ // Fetch attachment data
995
+ const attachmentResponse = await gmail.users.messages.attachments.get({
996
+ userId: 'me',
997
+ messageId: currentMessage?.id || '',
998
+ id: part.body.attachmentId,
999
+ });
1000
+ if (attachmentResponse.data.data) {
1001
+ existingAttachments.push({
1002
+ filename: part.filename,
1003
+ mimeType: part.mimeType || 'application/octet-stream',
1004
+ data: attachmentResponse.data.data.replace(/-/g, '+').replace(/_/g, '/'),
1005
+ });
1006
+ }
1007
+ }
1008
+ if (part.parts) {
1009
+ for (const subpart of part.parts) {
1010
+ await extractParts(subpart);
1011
+ }
1012
+ }
1013
+ };
1014
+ if (currentMessage?.payload) {
1015
+ // Handle simple messages (no parts)
1016
+ if (currentMessage.payload.body?.data && !currentMessage.payload.parts) {
1017
+ bodyContent = Buffer.from(currentMessage.payload.body.data, 'base64').toString('utf8');
1018
+ bodyMimeType = currentMessage.payload.mimeType || 'text/plain';
1019
+ }
1020
+ else {
1021
+ await extractParts(currentMessage.payload);
1022
+ }
1023
+ }
1024
+ // Add the new attachment
1025
+ existingAttachments.push({
1026
+ filename: args.filename,
1027
+ mimeType: args.mimeType,
1028
+ data: args.base64Data,
1029
+ });
1030
+ // Rebuild the email with attachments
1031
+ const boundary = `boundary_${Date.now()}_${Math.random().toString(36).substring(2)}`;
1032
+ let emailContent = '';
1033
+ emailContent += `To: ${getCurrentHeader('To')}\r\n`;
1034
+ if (getCurrentHeader('Cc'))
1035
+ emailContent += `Cc: ${getCurrentHeader('Cc')}\r\n`;
1036
+ if (getCurrentHeader('Bcc'))
1037
+ emailContent += `Bcc: ${getCurrentHeader('Bcc')}\r\n`;
1038
+ emailContent += `Subject: ${getCurrentHeader('Subject')}\r\n`;
1039
+ emailContent += 'MIME-Version: 1.0\r\n';
1040
+ emailContent += `Content-Type: multipart/mixed; boundary="${boundary}"\r\n`;
1041
+ emailContent += '\r\n';
1042
+ // Body part
1043
+ emailContent += `--${boundary}\r\n`;
1044
+ emailContent += `Content-Type: ${bodyMimeType}; charset=utf-8\r\n`;
1045
+ emailContent += 'Content-Transfer-Encoding: 7bit\r\n';
1046
+ emailContent += '\r\n';
1047
+ emailContent += `${bodyContent}\r\n`;
1048
+ // Attachment parts
1049
+ for (const attachment of existingAttachments) {
1050
+ emailContent += `--${boundary}\r\n`;
1051
+ emailContent += `Content-Type: ${attachment.mimeType}; name="${attachment.filename}"\r\n`;
1052
+ emailContent += `Content-Disposition: attachment; filename="${attachment.filename}"\r\n`;
1053
+ emailContent += 'Content-Transfer-Encoding: base64\r\n';
1054
+ emailContent += '\r\n';
1055
+ const base64Lines = attachment.data.match(/.{1,76}/g) || [];
1056
+ emailContent += base64Lines.join('\r\n');
1057
+ emailContent += '\r\n';
1058
+ }
1059
+ emailContent += `--${boundary}--\r\n`;
1060
+ const encodedEmail = Buffer.from(emailContent)
1061
+ .toString('base64')
1062
+ .replace(/\+/g, '-')
1063
+ .replace(/\//g, '_')
1064
+ .replace(/=+$/, '');
1065
+ // Update the draft
1066
+ const response = await gmail.users.drafts.update({
1067
+ userId: 'me',
1068
+ id: args.draftId,
1069
+ requestBody: {
1070
+ message: {
1071
+ raw: encodedEmail,
1072
+ threadId: currentMessage?.threadId,
1073
+ },
1074
+ },
1075
+ });
1076
+ const accountEmail = await getAccountEmail(args.account);
1077
+ const draftsLink = getGmailDraftsUrl(accountEmail);
1078
+ let result = `Successfully added attachment "${args.filename}" to draft.\n\n`;
1079
+ result += `Draft ID: ${response.data.id}\n`;
1080
+ result += `Total attachments: ${existingAttachments.length}\n`;
1081
+ result += `Attachments: ${existingAttachments.map((a) => a.filename).join(', ')}\n`;
1082
+ result += `\nView drafts: ${draftsLink}`;
1083
+ return result;
1084
+ }
1085
+ catch (error) {
1086
+ throw new Error(formatToolError('addAttachmentToDraft', error));
1087
+ }
1088
+ },
1089
+ });
1090
+ // --- Remove Attachment from Draft ---
1091
+ server.addTool({
1092
+ name: 'removeAttachmentFromDraft',
1093
+ description: 'Remove an attachment from an existing Gmail draft by filename. The attachment is removed without modifying other content.',
1094
+ annotations: {
1095
+ title: 'Remove Attachment from Draft',
1096
+ readOnlyHint: false,
1097
+ destructiveHint: false,
1098
+ idempotentHint: true,
1099
+ openWorldHint: true,
1100
+ },
1101
+ parameters: z.object({
1102
+ account: z.string().describe('Account name to use'),
1103
+ draftId: z.string().describe('The draft ID to remove the attachment from'),
1104
+ filename: z.string().describe('Name of the file to remove (must match exactly)'),
1105
+ }),
1106
+ async execute(args, { log: _log }) {
1107
+ try {
1108
+ const gmail = await getGmailClient(args.account);
1109
+ // Get current draft
1110
+ const currentDraft = await gmail.users.drafts.get({
1111
+ userId: 'me',
1112
+ id: args.draftId,
1113
+ format: 'full',
1114
+ });
1115
+ const currentMessage = currentDraft.data.message;
1116
+ const currentHeaders = currentMessage?.payload?.headers ?? [];
1117
+ const getCurrentHeader = (name) => currentHeaders.find((h) => h.name?.toLowerCase() === name.toLowerCase())?.value || '';
1118
+ // Extract current body and attachments
1119
+ let bodyContent = '';
1120
+ let bodyMimeType = 'text/plain';
1121
+ const existingAttachments = [];
1122
+ const extractParts = async (part) => {
1123
+ if (part.mimeType === 'text/plain' && !part.filename && part.body?.data) {
1124
+ bodyContent = Buffer.from(part.body.data, 'base64').toString('utf8');
1125
+ bodyMimeType = 'text/plain';
1126
+ }
1127
+ else if (part.mimeType === 'text/html' &&
1128
+ !part.filename &&
1129
+ part.body?.data &&
1130
+ !bodyContent) {
1131
+ bodyContent = Buffer.from(part.body.data, 'base64').toString('utf8');
1132
+ bodyMimeType = 'text/html';
1133
+ }
1134
+ else if (part.filename && part.body?.attachmentId) {
1135
+ // Fetch attachment data
1136
+ const attachmentResponse = await gmail.users.messages.attachments.get({
1137
+ userId: 'me',
1138
+ messageId: currentMessage?.id || '',
1139
+ id: part.body.attachmentId,
1140
+ });
1141
+ if (attachmentResponse.data.data) {
1142
+ existingAttachments.push({
1143
+ filename: part.filename,
1144
+ mimeType: part.mimeType || 'application/octet-stream',
1145
+ data: attachmentResponse.data.data.replace(/-/g, '+').replace(/_/g, '/'),
1146
+ });
1147
+ }
1148
+ }
1149
+ if (part.parts) {
1150
+ for (const subpart of part.parts) {
1151
+ await extractParts(subpart);
1152
+ }
1153
+ }
1154
+ };
1155
+ if (currentMessage?.payload) {
1156
+ // Handle simple messages (no parts)
1157
+ if (currentMessage.payload.body?.data && !currentMessage.payload.parts) {
1158
+ bodyContent = Buffer.from(currentMessage.payload.body.data, 'base64').toString('utf8');
1159
+ bodyMimeType = currentMessage.payload.mimeType || 'text/plain';
1160
+ }
1161
+ else {
1162
+ await extractParts(currentMessage.payload);
1163
+ }
1164
+ }
1165
+ // Check if attachment exists
1166
+ const attachmentIndex = existingAttachments.findIndex((a) => a.filename === args.filename);
1167
+ if (attachmentIndex === -1) {
1168
+ const availableFiles = existingAttachments.map((a) => a.filename).join(', ') || 'none';
1169
+ throw new Error(`Attachment "${args.filename}" not found in draft. Available attachments: ${availableFiles}`);
1170
+ }
1171
+ // Remove the attachment
1172
+ existingAttachments.splice(attachmentIndex, 1);
1173
+ // Rebuild the email
1174
+ let emailContent = '';
1175
+ if (existingAttachments.length > 0) {
1176
+ // Still have attachments, use multipart
1177
+ const boundary = `boundary_${Date.now()}_${Math.random().toString(36).substring(2)}`;
1178
+ emailContent += `To: ${getCurrentHeader('To')}\r\n`;
1179
+ if (getCurrentHeader('Cc'))
1180
+ emailContent += `Cc: ${getCurrentHeader('Cc')}\r\n`;
1181
+ if (getCurrentHeader('Bcc'))
1182
+ emailContent += `Bcc: ${getCurrentHeader('Bcc')}\r\n`;
1183
+ emailContent += `Subject: ${getCurrentHeader('Subject')}\r\n`;
1184
+ emailContent += 'MIME-Version: 1.0\r\n';
1185
+ emailContent += `Content-Type: multipart/mixed; boundary="${boundary}"\r\n`;
1186
+ emailContent += '\r\n';
1187
+ // Body part
1188
+ emailContent += `--${boundary}\r\n`;
1189
+ emailContent += `Content-Type: ${bodyMimeType}; charset=utf-8\r\n`;
1190
+ emailContent += 'Content-Transfer-Encoding: 7bit\r\n';
1191
+ emailContent += '\r\n';
1192
+ emailContent += `${bodyContent}\r\n`;
1193
+ // Remaining attachment parts
1194
+ for (const attachment of existingAttachments) {
1195
+ emailContent += `--${boundary}\r\n`;
1196
+ emailContent += `Content-Type: ${attachment.mimeType}; name="${attachment.filename}"\r\n`;
1197
+ emailContent += `Content-Disposition: attachment; filename="${attachment.filename}"\r\n`;
1198
+ emailContent += 'Content-Transfer-Encoding: base64\r\n';
1199
+ emailContent += '\r\n';
1200
+ const base64Lines = attachment.data.match(/.{1,76}/g) || [];
1201
+ emailContent += base64Lines.join('\r\n');
1202
+ emailContent += '\r\n';
1203
+ }
1204
+ emailContent += `--${boundary}--\r\n`;
1205
+ }
1206
+ else {
1207
+ // No more attachments, use simple message
1208
+ emailContent += `To: ${getCurrentHeader('To')}\r\n`;
1209
+ if (getCurrentHeader('Cc'))
1210
+ emailContent += `Cc: ${getCurrentHeader('Cc')}\r\n`;
1211
+ if (getCurrentHeader('Bcc'))
1212
+ emailContent += `Bcc: ${getCurrentHeader('Bcc')}\r\n`;
1213
+ emailContent += `Subject: ${getCurrentHeader('Subject')}\r\n`;
1214
+ emailContent += `Content-Type: ${bodyMimeType}; charset=utf-8\r\n`;
1215
+ emailContent += `\r\n${bodyContent}`;
1216
+ }
1217
+ const encodedEmail = Buffer.from(emailContent)
1218
+ .toString('base64')
1219
+ .replace(/\+/g, '-')
1220
+ .replace(/\//g, '_')
1221
+ .replace(/=+$/, '');
1222
+ // Update the draft
1223
+ const response = await gmail.users.drafts.update({
1224
+ userId: 'me',
1225
+ id: args.draftId,
1226
+ requestBody: {
1227
+ message: {
1228
+ raw: encodedEmail,
1229
+ threadId: currentMessage?.threadId,
1230
+ },
1231
+ },
1232
+ });
1233
+ const accountEmail = await getAccountEmail(args.account);
1234
+ const draftsLink = getGmailDraftsUrl(accountEmail);
1235
+ let result = `Successfully removed attachment "${args.filename}" from draft.\n\n`;
1236
+ result += `Draft ID: ${response.data.id}\n`;
1237
+ result += `Remaining attachments: ${existingAttachments.length}\n`;
1238
+ if (existingAttachments.length > 0) {
1239
+ result += `Attachments: ${existingAttachments.map((a) => a.filename).join(', ')}\n`;
1240
+ }
1241
+ result += `\nView drafts: ${draftsLink}`;
1242
+ return result;
1243
+ }
1244
+ catch (error) {
1245
+ throw new Error(formatToolError('removeAttachmentFromDraft', error));
1246
+ }
1247
+ },
1248
+ });
1249
+ // --- Delete Gmail Message ---
1250
+ server.addTool({
1251
+ name: 'deleteGmailMessage',
1252
+ description: 'Move a Gmail message to trash. Messages in trash are automatically deleted after 30 days.',
1253
+ annotations: {
1254
+ title: 'Delete Gmail Message',
1255
+ readOnlyHint: false,
1256
+ destructiveHint: true,
1257
+ idempotentHint: true,
1258
+ openWorldHint: true,
1259
+ },
1260
+ parameters: z.object({
1261
+ account: z.string().describe('Account name to use'),
1262
+ messageId: z.string().describe('The message ID to move to trash'),
1263
+ }),
1264
+ async execute(args, { log: _log }) {
1265
+ try {
1266
+ const gmail = await getGmailClient(args.account);
1267
+ await gmail.users.messages.trash({
1268
+ userId: 'me',
1269
+ id: args.messageId,
1270
+ });
1271
+ return `Successfully moved message ${args.messageId} to trash.\n\nThe message will be automatically deleted after 30 days.`;
1272
+ }
1273
+ catch (error) {
1274
+ throw new Error(formatToolError('deleteGmailMessage', error));
1275
+ }
1276
+ },
1277
+ });
1278
+ // --- Send Gmail Draft ---
1279
+ server.addTool({
1280
+ name: 'sendGmailDraft',
1281
+ description: 'Send an existing Gmail draft. The draft will be sent and removed from drafts.',
1282
+ annotations: {
1283
+ title: 'Send Gmail Draft',
1284
+ readOnlyHint: false,
1285
+ destructiveHint: false,
1286
+ idempotentHint: false,
1287
+ openWorldHint: true,
1288
+ },
1289
+ parameters: z.object({
1290
+ account: z.string().describe('Account name to use'),
1291
+ draftId: z.string().describe('The draft ID to send (from createGmailDraft response)'),
1292
+ }),
1293
+ async execute(args, { log: _log }) {
1294
+ try {
1295
+ const gmail = await getGmailClient(args.account);
1296
+ const response = await gmail.users.drafts.send({
1297
+ userId: 'me',
1298
+ requestBody: {
1299
+ id: args.draftId,
1300
+ },
1301
+ });
1302
+ const accountEmail = await getAccountEmail(args.account);
1303
+ const link = response.data.id
1304
+ ? getGmailMessageUrl(response.data.id, accountEmail)
1305
+ : undefined;
1306
+ let result = 'Successfully sent draft.\n\n';
1307
+ result += `Message ID: ${response.data.id}\n`;
1308
+ result += `Thread ID: ${response.data.threadId}\n`;
1309
+ result += `Labels: ${(response.data.labelIds ?? []).join(', ')}\n`;
1310
+ if (link) {
1311
+ result += `\nView sent message: ${link}`;
1312
+ }
1313
+ return result;
1314
+ }
1315
+ catch (error) {
1316
+ throw new Error(formatToolError('sendGmailDraft', error));
1317
+ }
1318
+ },
1319
+ });
1320
+ // --- Delete Gmail Draft ---
1321
+ server.addTool({
1322
+ name: 'deleteGmailDraft',
1323
+ description: 'Permanently delete a Gmail draft. This action cannot be undone.',
1324
+ annotations: {
1325
+ title: 'Delete Gmail Draft',
1326
+ readOnlyHint: false,
1327
+ destructiveHint: true,
1328
+ idempotentHint: true,
1329
+ openWorldHint: true,
1330
+ },
1331
+ parameters: z.object({
1332
+ account: z.string().describe('Account name to use'),
1333
+ draftId: z.string().describe('The draft ID to delete (from createGmailDraft response)'),
1334
+ }),
1335
+ async execute(args, { log: _log }) {
1336
+ try {
1337
+ const gmail = await getGmailClient(args.account);
1338
+ await gmail.users.drafts.delete({
1339
+ userId: 'me',
1340
+ id: args.draftId,
1341
+ });
1342
+ return `Successfully deleted draft ${args.draftId}.\n\nNote: Draft deletion is permanent and cannot be undone.`;
1343
+ }
1344
+ catch (error) {
1345
+ throw new Error(formatToolError('deleteGmailDraft', error));
1346
+ }
1347
+ },
1348
+ });
1349
+ // --- Get Gmail Attachment ---
1350
+ server.addTool({
1351
+ name: 'getGmailAttachment',
1352
+ description: 'Get metadata and a preview of a Gmail attachment. Returns truncated base64 data (first 500 chars). For full attachment data or saving to file, use downloadGmailAttachment instead.',
1353
+ annotations: {
1354
+ title: 'Get Gmail Attachment',
1355
+ readOnlyHint: true,
1356
+ openWorldHint: true,
1357
+ },
1358
+ parameters: z.object({
1359
+ account: z.string().describe('Account name to use'),
1360
+ messageId: z.string().describe('The ID of the message containing the attachment'),
1361
+ attachmentId: z
1362
+ .string()
1363
+ .describe('The attachment ID (from readGmailMessage attachment info)'),
1364
+ }),
1365
+ async execute(args, { log: _log }) {
1366
+ try {
1367
+ const gmail = await getGmailClient(args.account);
1368
+ const response = await gmail.users.messages.attachments.get({
1369
+ userId: 'me',
1370
+ messageId: args.messageId,
1371
+ id: args.attachmentId,
1372
+ });
1373
+ const attachment = response.data;
1374
+ const size = attachment.size || 0;
1375
+ let result = '**Attachment Retrieved**\n\n';
1376
+ result += `Message ID: ${args.messageId}\n`;
1377
+ result += `Attachment ID: ${args.attachmentId}\n`;
1378
+ result += `Size: ${size} bytes (${(size / 1024).toFixed(2)} KB)\n\n`;
1379
+ if (attachment.data) {
1380
+ // The data is already base64url encoded from Gmail API
1381
+ result += `**Base64 Data (first 500 chars):**\n${attachment.data.substring(0, 500)}${attachment.data.length > 500 ? '...' : ''}\n\n`;
1382
+ result += `**Full data length:** ${attachment.data.length} characters\n`;
1383
+ result +=
1384
+ '\nNote: Data is base64url encoded. To decode, replace - with + and _ with /, then base64 decode.';
1385
+ result +=
1386
+ '\n\nTip: Use downloadGmailAttachment to get full data or save directly to a file.';
1387
+ }
1388
+ else {
1389
+ result += 'No attachment data available.';
1390
+ }
1391
+ return result;
1392
+ }
1393
+ catch (error) {
1394
+ throw new Error(formatToolError('getGmailAttachment', error));
1395
+ }
1396
+ },
1397
+ });
1398
+ // --- Download Gmail Attachment ---
1399
+ server.addTool({
1400
+ name: 'downloadGmailAttachment',
1401
+ description: 'Download a Gmail attachment with full data. Returns complete base64-encoded data, or saves directly to a file if savePath is provided. Use this instead of getGmailAttachment when you need the full attachment content.',
1402
+ annotations: {
1403
+ title: 'Download Gmail Attachment',
1404
+ readOnlyHint: true,
1405
+ openWorldHint: true,
1406
+ },
1407
+ parameters: z.object({
1408
+ account: z.string().describe('Account name to use'),
1409
+ messageId: z.string().describe('The ID of the message containing the attachment'),
1410
+ attachmentId: z
1411
+ .string()
1412
+ .describe('The attachment ID (from readGmailMessage attachment info)'),
1413
+ savePath: z
1414
+ .string()
1415
+ .optional()
1416
+ .describe('Optional file path to save the attachment to. If provided, the decoded attachment is written to this path. The path must be absolute.'),
1417
+ }),
1418
+ async execute(args, { log: _log }) {
1419
+ try {
1420
+ const gmail = await getGmailClient(args.account);
1421
+ // First, get attachment metadata from the message to find the filename
1422
+ const messageResponse = await gmail.users.messages.get({
1423
+ userId: 'me',
1424
+ id: args.messageId,
1425
+ format: 'full',
1426
+ });
1427
+ // Find attachment info in message parts
1428
+ let attachmentFilename = 'attachment';
1429
+ let attachmentMimeType = 'application/octet-stream';
1430
+ const findAttachmentInfo = (part) => {
1431
+ if (part.body?.attachmentId === args.attachmentId && part.filename) {
1432
+ attachmentFilename = part.filename;
1433
+ attachmentMimeType = part.mimeType || 'application/octet-stream';
1434
+ return true;
1435
+ }
1436
+ if (part.parts) {
1437
+ for (const subpart of part.parts) {
1438
+ if (findAttachmentInfo(subpart))
1439
+ return true;
1440
+ }
1441
+ }
1442
+ return false;
1443
+ };
1444
+ if (messageResponse.data.payload) {
1445
+ findAttachmentInfo(messageResponse.data.payload);
1446
+ }
1447
+ // Get the attachment data
1448
+ const response = await gmail.users.messages.attachments.get({
1449
+ userId: 'me',
1450
+ messageId: args.messageId,
1451
+ id: args.attachmentId,
1452
+ });
1453
+ const attachment = response.data;
1454
+ const size = attachment.size || 0;
1455
+ if (!attachment.data) {
1456
+ throw new Error('No attachment data available');
1457
+ }
1458
+ // Convert base64url to standard base64
1459
+ const base64Data = attachment.data.replace(/-/g, '+').replace(/_/g, '/');
1460
+ let result = '**Attachment Downloaded**\n\n';
1461
+ result += `Filename: ${attachmentFilename}\n`;
1462
+ result += `MIME Type: ${attachmentMimeType}\n`;
1463
+ result += `Size: ${size} bytes (${(size / 1024).toFixed(2)} KB)\n`;
1464
+ result += `Message ID: ${args.messageId}\n`;
1465
+ result += `Attachment ID: ${args.attachmentId}\n\n`;
1466
+ if (args.savePath) {
1467
+ // Validate that savePath is absolute
1468
+ if (!path.isAbsolute(args.savePath)) {
1469
+ throw new Error(`savePath must be an absolute path. Received: ${args.savePath}`);
1470
+ }
1471
+ // Validate path for security
1472
+ const pathValidation = validateWritePath(args.savePath, getServerConfig().pathSecurity);
1473
+ if (!pathValidation.valid) {
1474
+ throw new Error(`Cannot save to this path: ${pathValidation.error}`);
1475
+ }
1476
+ // Decode and save to file
1477
+ const buffer = Buffer.from(base64Data, 'base64');
1478
+ // Ensure parent directory exists
1479
+ const parentDir = path.dirname(pathValidation.resolvedPath);
1480
+ await fs.mkdir(parentDir, { recursive: true });
1481
+ await fs.writeFile(pathValidation.resolvedPath, buffer);
1482
+ result += `**Saved to:** ${pathValidation.resolvedPath}\n`;
1483
+ result += `File size on disk: ${buffer.length} bytes`;
1484
+ }
1485
+ else {
1486
+ // Auto-save large attachments to temp file to avoid blowing up LLM context
1487
+ const MAX_INLINE_BYTES = 1024; // 1KB threshold
1488
+ const buffer = Buffer.from(base64Data, 'base64');
1489
+ if (buffer.length > MAX_INLINE_BYTES) {
1490
+ const tempDir = os.tmpdir();
1491
+ const safeName = attachmentFilename.replace(/[^a-zA-Z0-9._-]/g, '_');
1492
+ const tempPath = path.join(tempDir, `gmail-attachment-${Date.now()}-${safeName}`);
1493
+ await fs.writeFile(tempPath, buffer);
1494
+ result += `**Saved to:** ${tempPath}\n`;
1495
+ result += `File size on disk: ${buffer.length} bytes\n\n`;
1496
+ result += `⚠️ File was auto-saved to a temp path because it exceeds the ${MAX_INLINE_BYTES}-byte inline limit.\n`;
1497
+ result += `Use the savePath parameter to save to a specific location.`;
1498
+ }
1499
+ else {
1500
+ // Small enough to return inline
1501
+ result += `**Base64 Data (standard encoding):**\n${base64Data}`;
1502
+ }
1503
+ }
1504
+ return result;
1505
+ }
1506
+ catch (error) {
1507
+ throw new Error(formatToolError('downloadGmailAttachment', error));
1508
+ }
1509
+ },
1510
+ });
1511
+ // --- Save Gmail Attachment to Drive ---
1512
+ server.addTool({
1513
+ name: 'saveAttachmentToDrive',
1514
+ description: 'Save a Gmail attachment directly to Google Drive. Uploads the attachment as a file to Drive without downloading locally first.',
1515
+ annotations: {
1516
+ title: 'Save Attachment to Drive',
1517
+ readOnlyHint: false,
1518
+ destructiveHint: false,
1519
+ idempotentHint: false,
1520
+ openWorldHint: true,
1521
+ },
1522
+ parameters: z.object({
1523
+ account: z.string().describe('Account name to use'),
1524
+ messageId: z.string().describe('The ID of the message containing the attachment'),
1525
+ attachmentId: z
1526
+ .string()
1527
+ .describe('The attachment ID (from readGmailMessage attachment info)'),
1528
+ fileName: z
1529
+ .string()
1530
+ .optional()
1531
+ .describe('Optional custom file name for the saved file. If not provided, uses the original attachment name.'),
1532
+ folderId: z
1533
+ .string()
1534
+ .optional()
1535
+ .describe('Optional Google Drive folder ID to save the file to. If not provided, saves to root of My Drive.'),
1536
+ }),
1537
+ async execute(args, { log: _log }) {
1538
+ try {
1539
+ const gmail = await getGmailClient(args.account);
1540
+ const drive = await getDriveClient(args.account);
1541
+ const accountEmail = await getAccountEmail(args.account);
1542
+ // First, get attachment metadata from the message to find the filename
1543
+ const messageResponse = await gmail.users.messages.get({
1544
+ userId: 'me',
1545
+ id: args.messageId,
1546
+ format: 'full',
1547
+ });
1548
+ // Find attachment info in message parts
1549
+ let attachmentFilename = 'attachment';
1550
+ let attachmentMimeType = 'application/octet-stream';
1551
+ const findAttachmentInfo = (part) => {
1552
+ if (part.body?.attachmentId === args.attachmentId && part.filename) {
1553
+ attachmentFilename = part.filename;
1554
+ attachmentMimeType = part.mimeType || 'application/octet-stream';
1555
+ return true;
1556
+ }
1557
+ if (part.parts) {
1558
+ for (const subpart of part.parts) {
1559
+ if (findAttachmentInfo(subpart))
1560
+ return true;
1561
+ }
1562
+ }
1563
+ return false;
1564
+ };
1565
+ if (messageResponse.data.payload) {
1566
+ findAttachmentInfo(messageResponse.data.payload);
1567
+ }
1568
+ // Use custom filename if provided
1569
+ const finalFileName = args.fileName || attachmentFilename;
1570
+ // Get the attachment data
1571
+ const attachmentResponse = await gmail.users.messages.attachments.get({
1572
+ userId: 'me',
1573
+ messageId: args.messageId,
1574
+ id: args.attachmentId,
1575
+ });
1576
+ const attachment = attachmentResponse.data;
1577
+ const size = attachment.size || 0;
1578
+ if (!attachment.data) {
1579
+ throw new Error('No attachment data available');
1580
+ }
1581
+ // Convert base64url to standard base64, then to buffer
1582
+ const base64Data = attachment.data.replace(/-/g, '+').replace(/_/g, '/');
1583
+ const buffer = Buffer.from(base64Data, 'base64');
1584
+ // Create a readable stream from the buffer for Drive upload
1585
+ const bufferStream = new Readable();
1586
+ bufferStream.push(buffer);
1587
+ bufferStream.push(null);
1588
+ // Upload to Google Drive
1589
+ const fileMetadata = {
1590
+ name: finalFileName,
1591
+ };
1592
+ if (args.folderId) {
1593
+ fileMetadata.parents = [args.folderId];
1594
+ }
1595
+ const driveResponse = await drive.files.create({
1596
+ requestBody: fileMetadata,
1597
+ media: {
1598
+ mimeType: attachmentMimeType,
1599
+ body: bufferStream,
1600
+ },
1601
+ fields: 'id,name,webViewLink,mimeType,size',
1602
+ });
1603
+ const driveFile = driveResponse.data;
1604
+ const fileId = driveFile.id;
1605
+ if (!fileId) {
1606
+ throw new Error('Failed to upload file to Drive - no file ID returned');
1607
+ }
1608
+ const driveLink = getDriveFileUrl(fileId, accountEmail);
1609
+ let result = '**Attachment Saved to Drive**\n\n';
1610
+ result += `File Name: ${driveFile.name}\n`;
1611
+ result += `File ID: ${fileId}\n`;
1612
+ result += `MIME Type: ${driveFile.mimeType || attachmentMimeType}\n`;
1613
+ result += `Size: ${size} bytes (${(size / 1024).toFixed(2)} KB)\n`;
1614
+ if (args.folderId) {
1615
+ result += `Folder ID: ${args.folderId}\n`;
1616
+ }
1617
+ result += `\nView in Drive: ${driveLink}\n`;
1618
+ result += `\nSource Message ID: ${args.messageId}`;
1619
+ return result;
1620
+ }
1621
+ catch (error) {
1622
+ throw new Error(formatToolError('saveAttachmentToDrive', error));
1623
+ }
1624
+ },
1625
+ });
1626
+ // --- Mark as Read ---
1627
+ server.addTool({
1628
+ name: 'markAsRead',
1629
+ description: 'Mark a Gmail message as read by removing the UNREAD label.',
1630
+ annotations: {
1631
+ title: 'Mark as Read',
1632
+ readOnlyHint: false,
1633
+ destructiveHint: false,
1634
+ idempotentHint: true,
1635
+ openWorldHint: true,
1636
+ },
1637
+ parameters: z.object({
1638
+ account: z.string().describe('Account name to use'),
1639
+ messageId: z.string().describe('The message ID to mark as read'),
1640
+ }),
1641
+ async execute(args, { log: _log }) {
1642
+ try {
1643
+ const gmail = await getGmailClient(args.account);
1644
+ const response = await gmail.users.messages.modify({
1645
+ userId: 'me',
1646
+ id: args.messageId,
1647
+ requestBody: {
1648
+ removeLabelIds: ['UNREAD'],
1649
+ },
1650
+ });
1651
+ const accountEmail = await getAccountEmail(args.account);
1652
+ const link = response.data.id
1653
+ ? getGmailMessageUrl(response.data.id, accountEmail)
1654
+ : undefined;
1655
+ let result = `Successfully marked message ${args.messageId} as read.\n`;
1656
+ result += `Current labels: ${(response.data.labelIds ?? []).join(', ')}\n`;
1657
+ if (link) {
1658
+ result += `\nView message: ${link}`;
1659
+ }
1660
+ return result;
1661
+ }
1662
+ catch (error) {
1663
+ throw new Error(formatToolError('markAsRead', error));
1664
+ }
1665
+ },
1666
+ });
1667
+ // --- Mark as Unread ---
1668
+ server.addTool({
1669
+ name: 'markAsUnread',
1670
+ description: 'Mark a Gmail message as unread by adding the UNREAD label.',
1671
+ annotations: {
1672
+ title: 'Mark as Unread',
1673
+ readOnlyHint: false,
1674
+ destructiveHint: false,
1675
+ idempotentHint: true,
1676
+ openWorldHint: true,
1677
+ },
1678
+ parameters: z.object({
1679
+ account: z.string().describe('Account name to use'),
1680
+ messageId: z.string().describe('The message ID to mark as unread'),
1681
+ }),
1682
+ async execute(args, { log: _log }) {
1683
+ try {
1684
+ const gmail = await getGmailClient(args.account);
1685
+ const response = await gmail.users.messages.modify({
1686
+ userId: 'me',
1687
+ id: args.messageId,
1688
+ requestBody: {
1689
+ addLabelIds: ['UNREAD'],
1690
+ },
1691
+ });
1692
+ const accountEmail = await getAccountEmail(args.account);
1693
+ const link = response.data.id
1694
+ ? getGmailMessageUrl(response.data.id, accountEmail)
1695
+ : undefined;
1696
+ let result = `Successfully marked message ${args.messageId} as unread.\n`;
1697
+ result += `Current labels: ${(response.data.labelIds ?? []).join(', ')}\n`;
1698
+ if (link) {
1699
+ result += `\nView message: ${link}`;
1700
+ }
1701
+ return result;
1702
+ }
1703
+ catch (error) {
1704
+ throw new Error(formatToolError('markAsUnread', error));
1705
+ }
1706
+ },
1707
+ });
1708
+ // --- List Gmail Threads ---
1709
+ server.addTool({
1710
+ name: 'listGmailThreads',
1711
+ description: 'List email threads (conversations) from Gmail. Each thread contains all messages in a conversation.',
1712
+ annotations: {
1713
+ title: 'List Gmail Threads',
1714
+ readOnlyHint: true,
1715
+ openWorldHint: true,
1716
+ },
1717
+ parameters: z.object({
1718
+ account: z.string().describe('Account name to use'),
1719
+ maxResults: z
1720
+ .number()
1721
+ .optional()
1722
+ .default(10)
1723
+ .describe('Maximum number of threads to return (default: 10, max: 500)'),
1724
+ labelIds: z
1725
+ .array(z.string())
1726
+ .optional()
1727
+ .describe('Filter by label IDs (e.g., ["INBOX", "UNREAD"])'),
1728
+ query: z
1729
+ .string()
1730
+ .optional()
1731
+ .describe('Search query (same syntax as Gmail search box, e.g., "from:user@example.com subject:test")'),
1732
+ }),
1733
+ async execute(args, { log: _log }) {
1734
+ try {
1735
+ const gmail = await getGmailClient(args.account);
1736
+ const response = await gmail.users.threads.list({
1737
+ userId: 'me',
1738
+ maxResults: Math.min(args.maxResults || 10, 500),
1739
+ labelIds: args.labelIds,
1740
+ q: args.query,
1741
+ });
1742
+ const threads = response.data.threads ?? [];
1743
+ const accountEmail = await getAccountEmail(args.account);
1744
+ let result = `Found approximately ${response.data.resultSizeEstimate} threads.\n\n`;
1745
+ if (threads.length === 0) {
1746
+ result += 'No threads found.';
1747
+ }
1748
+ else {
1749
+ result += `Showing ${threads.length} threads:\n\n`;
1750
+ for (let i = 0; i < threads.length; i++) {
1751
+ const t = threads[i];
1752
+ const link = t.id ? getGmailMessageUrl(t.id, accountEmail) : 'N/A';
1753
+ result += `${i + 1}. Thread ID: ${t.id}\n`;
1754
+ result += ` Snippet: ${t.snippet || '(no snippet)'}\n`;
1755
+ result += ` Link: ${link}\n\n`;
1756
+ }
1757
+ }
1758
+ if (response.data.nextPageToken) {
1759
+ result += `\nMore threads available (next page token: ${response.data.nextPageToken})`;
1760
+ }
1761
+ return result;
1762
+ }
1763
+ catch (error) {
1764
+ throw new Error(formatToolError('listGmailThreads', error));
1765
+ }
1766
+ },
1767
+ });
1768
+ // --- Read Gmail Thread ---
1769
+ server.addTool({
1770
+ name: 'readGmailThread',
1771
+ description: 'Read a complete Gmail thread (conversation) including all messages. Returns full content of all messages in the thread.',
1772
+ annotations: {
1773
+ title: 'Read Gmail Thread',
1774
+ readOnlyHint: true,
1775
+ openWorldHint: true,
1776
+ },
1777
+ parameters: z.object({
1778
+ account: z.string().describe('Account name to use'),
1779
+ threadId: z.string().describe('The ID of the thread to read'),
1780
+ format: z
1781
+ .enum(['full', 'metadata', 'minimal'])
1782
+ .optional()
1783
+ .default('full')
1784
+ .describe('Response format for messages in the thread'),
1785
+ maxLength: z
1786
+ .number()
1787
+ .optional()
1788
+ .describe('Maximum character length of the response. If the result exceeds this, it will be truncated with a notice.'),
1789
+ }),
1790
+ async execute(args, { log: _log }) {
1791
+ try {
1792
+ const gmail = await getGmailClient(args.account);
1793
+ const response = await gmail.users.threads.get({
1794
+ userId: 'me',
1795
+ id: args.threadId,
1796
+ format: args.format,
1797
+ });
1798
+ const thread = response.data;
1799
+ const messages = thread.messages ?? [];
1800
+ const accountEmail = await getAccountEmail(args.account);
1801
+ const getHeader = (headers, name) => headers.find((h) => h.name?.toLowerCase() === name.toLowerCase())?.value;
1802
+ const extractBody = (part) => {
1803
+ if (part.body?.data) {
1804
+ return Buffer.from(part.body.data, 'base64').toString('utf8');
1805
+ }
1806
+ if (part.parts) {
1807
+ for (const subpart of part.parts) {
1808
+ if (subpart.mimeType === 'text/plain') {
1809
+ return extractBody(subpart);
1810
+ }
1811
+ }
1812
+ for (const subpart of part.parts) {
1813
+ if (subpart.mimeType === 'text/html') {
1814
+ return extractBody(subpart);
1815
+ }
1816
+ }
1817
+ for (const subpart of part.parts) {
1818
+ const result = extractBody(subpart);
1819
+ if (result)
1820
+ return result;
1821
+ }
1822
+ }
1823
+ return '';
1824
+ };
1825
+ let result = '**Email Thread**\n\n';
1826
+ result += `Thread ID: ${thread.id}\n`;
1827
+ result += `Messages in thread: ${messages.length}\n`;
1828
+ result += `History ID: ${thread.historyId}\n\n`;
1829
+ result += '---\n\n';
1830
+ for (let i = 0; i < messages.length; i++) {
1831
+ const message = messages[i];
1832
+ const headers = message.payload?.headers ?? [];
1833
+ result += `**Message ${i + 1} of ${messages.length}**\n`;
1834
+ result += `ID: ${message.id}\n`;
1835
+ result += `From: ${getHeader(headers, 'From') || 'N/A'}\n`;
1836
+ result += `To: ${getHeader(headers, 'To') || 'N/A'}\n`;
1837
+ if (getHeader(headers, 'Cc'))
1838
+ result += `Cc: ${getHeader(headers, 'Cc')}\n`;
1839
+ result += `Subject: ${getHeader(headers, 'Subject') || 'N/A'}\n`;
1840
+ result += `Date: ${getHeader(headers, 'Date') || 'N/A'}\n`;
1841
+ result += `Labels: ${(message.labelIds ?? []).join(', ') || 'None'}\n\n`;
1842
+ if (args.format === 'full' && message.payload) {
1843
+ const body = extractBody(message.payload);
1844
+ // Wrap email body with security warnings to defend against prompt injection
1845
+ const from = getHeader(headers, 'From');
1846
+ const subject = getHeader(headers, 'Subject');
1847
+ const wrappedBody = body
1848
+ ? wrapEmailContent(body, from || undefined, subject || undefined)
1849
+ : '(empty)';
1850
+ result += `**Body:**\n${wrappedBody}\n`;
1851
+ }
1852
+ else if (message.snippet) {
1853
+ result += `**Snippet:** ${message.snippet}\n`;
1854
+ }
1855
+ if (i < messages.length - 1) {
1856
+ result += '\n---\n\n';
1857
+ }
1858
+ }
1859
+ const link = thread.id ? getGmailMessageUrl(thread.id, accountEmail) : undefined;
1860
+ if (link) {
1861
+ result += `\n\nView thread in Gmail: ${link}`;
1862
+ }
1863
+ // Apply maxLength truncation if specified
1864
+ if (args.maxLength && result.length > args.maxLength) {
1865
+ result = result.substring(0, args.maxLength);
1866
+ result += `\n\n[...TRUNCATED at ${args.maxLength} chars. Thread has ${messages.length} messages. Use a larger maxLength or omit it to see everything.]`;
1867
+ }
1868
+ return result;
1869
+ }
1870
+ catch (error) {
1871
+ throw new Error(formatToolError('readGmailThread', error));
1872
+ }
1873
+ },
1874
+ });
1875
+ // --- Batch Add Gmail Labels ---
1876
+ server.addTool({
1877
+ name: 'batchAddGmailLabels',
1878
+ description: 'Add labels to multiple Gmail messages at once. More efficient than adding labels one by one.',
1879
+ annotations: {
1880
+ title: 'Batch Add Gmail Labels',
1881
+ readOnlyHint: false,
1882
+ destructiveHint: false,
1883
+ idempotentHint: true,
1884
+ openWorldHint: true,
1885
+ },
1886
+ parameters: z.object({
1887
+ account: z.string().describe('Account name to use'),
1888
+ messageIds: z
1889
+ .array(z.string())
1890
+ .min(1)
1891
+ .max(1000)
1892
+ .describe('Array of message IDs to modify (max 1000)'),
1893
+ labelIds: z
1894
+ .array(z.string())
1895
+ .min(1)
1896
+ .describe('Array of label IDs to add (e.g., ["STARRED", "IMPORTANT"])'),
1897
+ }),
1898
+ async execute(args, { log: _log }) {
1899
+ try {
1900
+ const gmail = await getGmailClient(args.account);
1901
+ await gmail.users.messages.batchModify({
1902
+ userId: 'me',
1903
+ requestBody: {
1904
+ ids: args.messageIds,
1905
+ addLabelIds: args.labelIds,
1906
+ },
1907
+ });
1908
+ let result = `Successfully added labels to ${args.messageIds.length} messages.\n\n`;
1909
+ result += `Labels added: ${args.labelIds.join(', ')}\n`;
1910
+ result += `Message IDs: ${args.messageIds.slice(0, 10).join(', ')}`;
1911
+ if (args.messageIds.length > 10) {
1912
+ result += ` ... and ${args.messageIds.length - 10} more`;
1913
+ }
1914
+ return result;
1915
+ }
1916
+ catch (error) {
1917
+ throw new Error(formatToolError('batchAddGmailLabels', error));
1918
+ }
1919
+ },
1920
+ });
1921
+ // --- Batch Remove Gmail Labels ---
1922
+ server.addTool({
1923
+ name: 'batchRemoveGmailLabels',
1924
+ description: 'Remove labels from multiple Gmail messages at once. More efficient than removing labels one by one. Use to bulk archive (remove INBOX), bulk mark as read (remove UNREAD), etc.',
1925
+ annotations: {
1926
+ title: 'Batch Remove Gmail Labels',
1927
+ readOnlyHint: false,
1928
+ destructiveHint: false,
1929
+ idempotentHint: true,
1930
+ openWorldHint: true,
1931
+ },
1932
+ parameters: z.object({
1933
+ account: z.string().describe('Account name to use'),
1934
+ messageIds: z
1935
+ .array(z.string())
1936
+ .min(1)
1937
+ .max(1000)
1938
+ .describe('Array of message IDs to modify (max 1000)'),
1939
+ labelIds: z
1940
+ .array(z.string())
1941
+ .min(1)
1942
+ .describe('Array of label IDs to remove (e.g., ["UNREAD"] to mark all as read, ["INBOX"] to archive all)'),
1943
+ }),
1944
+ async execute(args, { log: _log }) {
1945
+ try {
1946
+ const gmail = await getGmailClient(args.account);
1947
+ await gmail.users.messages.batchModify({
1948
+ userId: 'me',
1949
+ requestBody: {
1950
+ ids: args.messageIds,
1951
+ removeLabelIds: args.labelIds,
1952
+ },
1953
+ });
1954
+ let result = `Successfully removed labels from ${args.messageIds.length} messages.\n\n`;
1955
+ result += `Labels removed: ${args.labelIds.join(', ')}\n`;
1956
+ result += `Message IDs: ${args.messageIds.slice(0, 10).join(', ')}`;
1957
+ if (args.messageIds.length > 10) {
1958
+ result += ` ... and ${args.messageIds.length - 10} more`;
1959
+ }
1960
+ return result;
1961
+ }
1962
+ catch (error) {
1963
+ throw new Error(formatToolError('batchRemoveGmailLabels', error));
1964
+ }
1965
+ },
1966
+ });
1967
+ // --- List Gmail Filters ---
1968
+ server.addTool({
1969
+ name: 'listGmailFilters',
1970
+ description: 'List all Gmail filters (rules that automatically process incoming messages based on criteria).',
1971
+ annotations: {
1972
+ title: 'List Gmail Filters',
1973
+ readOnlyHint: true,
1974
+ openWorldHint: true,
1975
+ },
1976
+ parameters: z.object({
1977
+ account: z.string().describe('Account name to use'),
1978
+ }),
1979
+ async execute(args, { log: _log }) {
1980
+ try {
1981
+ const gmail = await getGmailClient(args.account);
1982
+ const response = await gmail.users.settings.filters.list({
1983
+ userId: 'me',
1984
+ });
1985
+ const filters = response.data.filter ?? [];
1986
+ let result = `**Gmail Filters (${filters.length} total)**\n\n`;
1987
+ if (filters.length === 0) {
1988
+ result += 'No filters found.';
1989
+ return result;
1990
+ }
1991
+ for (let i = 0; i < filters.length; i++) {
1992
+ const filter = filters[i];
1993
+ const criteria = filter.criteria || {};
1994
+ const action = filter.action || {};
1995
+ result += `**${i + 1}. Filter ID: ${filter.id}**\n`;
1996
+ result += ' Criteria:\n';
1997
+ if (criteria.from)
1998
+ result += ` - From: ${criteria.from}\n`;
1999
+ if (criteria.to)
2000
+ result += ` - To: ${criteria.to}\n`;
2001
+ if (criteria.subject)
2002
+ result += ` - Subject: ${criteria.subject}\n`;
2003
+ if (criteria.query)
2004
+ result += ` - Query: ${criteria.query}\n`;
2005
+ if (criteria.hasAttachment)
2006
+ result += ' - Has attachment: yes\n';
2007
+ if (criteria.size)
2008
+ result += ` - Size: ${criteria.sizeComparison} ${criteria.size} bytes\n`;
2009
+ result += ' Actions:\n';
2010
+ if (action.addLabelIds?.length)
2011
+ result += ` - Add labels: ${action.addLabelIds.join(', ')}\n`;
2012
+ if (action.removeLabelIds?.length)
2013
+ result += ` - Remove labels: ${action.removeLabelIds.join(', ')}\n`;
2014
+ if (action.forward)
2015
+ result += ` - Forward to: ${action.forward}\n`;
2016
+ result += '\n';
2017
+ }
2018
+ return result;
2019
+ }
2020
+ catch (error) {
2021
+ throw new Error(formatToolError('listGmailFilters', error));
2022
+ }
2023
+ },
2024
+ });
2025
+ // --- Create Gmail Filter ---
2026
+ server.addTool({
2027
+ name: 'createGmailFilter',
2028
+ description: 'Create a Gmail filter to automatically process incoming messages. Filters can add/remove labels, forward messages, or archive them.',
2029
+ annotations: {
2030
+ title: 'Create Gmail Filter',
2031
+ readOnlyHint: false,
2032
+ destructiveHint: false,
2033
+ idempotentHint: false,
2034
+ openWorldHint: true,
2035
+ },
2036
+ parameters: z.object({
2037
+ account: z.string().describe('Account name to use'),
2038
+ // Criteria (at least one required)
2039
+ from: z.string().optional().describe('Filter emails from this sender'),
2040
+ to: z.string().optional().describe('Filter emails to this recipient'),
2041
+ subject: z.string().optional().describe('Filter emails with this subject'),
2042
+ query: z
2043
+ .string()
2044
+ .optional()
2045
+ .describe('Filter using Gmail search query syntax (most flexible option)'),
2046
+ hasAttachment: z.boolean().optional().describe('Filter emails that have attachments'),
2047
+ // Actions (at least one required)
2048
+ addLabelIds: z.array(z.string()).optional().describe('Label IDs to add to matching emails'),
2049
+ removeLabelIds: z
2050
+ .array(z.string())
2051
+ .optional()
2052
+ .describe('Label IDs to remove (e.g., ["INBOX"] to archive, ["UNREAD"] to mark as read)'),
2053
+ forward: z.string().optional().describe('Email address to forward matching emails to'),
2054
+ }),
2055
+ async execute(args, { log: _log }) {
2056
+ try {
2057
+ // Validate that at least one criteria is provided
2058
+ const hasCriteria = args.from || args.to || args.subject || args.query || args.hasAttachment;
2059
+ if (!hasCriteria) {
2060
+ throw new Error('At least one filter criteria must be provided (from, to, subject, query, or hasAttachment)');
2061
+ }
2062
+ // Validate that at least one action is provided
2063
+ const hasAction = (args.addLabelIds && args.addLabelIds.length > 0) ||
2064
+ (args.removeLabelIds && args.removeLabelIds.length > 0) ||
2065
+ args.forward;
2066
+ if (!hasAction) {
2067
+ throw new Error('At least one filter action must be provided (addLabelIds, removeLabelIds, or forward)');
2068
+ }
2069
+ const gmail = await getGmailClient(args.account);
2070
+ const response = await gmail.users.settings.filters.create({
2071
+ userId: 'me',
2072
+ requestBody: {
2073
+ criteria: {
2074
+ from: args.from,
2075
+ to: args.to,
2076
+ subject: args.subject,
2077
+ query: args.query,
2078
+ hasAttachment: args.hasAttachment,
2079
+ },
2080
+ action: {
2081
+ addLabelIds: args.addLabelIds,
2082
+ removeLabelIds: args.removeLabelIds,
2083
+ forward: args.forward,
2084
+ },
2085
+ },
2086
+ });
2087
+ const filter = response.data;
2088
+ let result = 'Successfully created Gmail filter.\n\n';
2089
+ result += `Filter ID: ${filter.id}\n\n`;
2090
+ result += '**Criteria:**\n';
2091
+ if (args.from)
2092
+ result += `- From: ${args.from}\n`;
2093
+ if (args.to)
2094
+ result += `- To: ${args.to}\n`;
2095
+ if (args.subject)
2096
+ result += `- Subject: ${args.subject}\n`;
2097
+ if (args.query)
2098
+ result += `- Query: ${args.query}\n`;
2099
+ if (args.hasAttachment)
2100
+ result += '- Has attachment: yes\n';
2101
+ result += '\n**Actions:**\n';
2102
+ if (args.addLabelIds?.length)
2103
+ result += `- Add labels: ${args.addLabelIds.join(', ')}\n`;
2104
+ if (args.removeLabelIds?.length)
2105
+ result += `- Remove labels: ${args.removeLabelIds.join(', ')}\n`;
2106
+ if (args.forward)
2107
+ result += `- Forward to: ${args.forward}\n`;
2108
+ return result;
2109
+ }
2110
+ catch (error) {
2111
+ throw new Error(formatToolError('createGmailFilter', error));
2112
+ }
2113
+ },
2114
+ });
2115
+ // --- Delete Gmail Filter ---
2116
+ server.addTool({
2117
+ name: 'deleteGmailFilter',
2118
+ description: 'Delete a Gmail filter by its ID.',
2119
+ annotations: {
2120
+ title: 'Delete Gmail Filter',
2121
+ readOnlyHint: false,
2122
+ destructiveHint: true,
2123
+ idempotentHint: true,
2124
+ openWorldHint: true,
2125
+ },
2126
+ parameters: z.object({
2127
+ account: z.string().describe('Account name to use'),
2128
+ filterId: z.string().describe('The ID of the filter to delete (from listGmailFilters)'),
2129
+ }),
2130
+ async execute(args, { log: _log }) {
2131
+ try {
2132
+ const gmail = await getGmailClient(args.account);
2133
+ await gmail.users.settings.filters.delete({
2134
+ userId: 'me',
2135
+ id: args.filterId,
2136
+ });
2137
+ return `Successfully deleted filter ${args.filterId}.`;
2138
+ }
2139
+ catch (error) {
2140
+ throw new Error(formatToolError('deleteGmailFilter', error));
516
2141
  }
517
2142
  },
518
2143
  });