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.
- package/README.md +39 -6
- package/dist/accounts.d.ts.map +1 -1
- package/dist/accounts.js +1 -0
- package/dist/accounts.js.map +1 -1
- package/dist/excelHelpers.d.ts +108 -0
- package/dist/excelHelpers.d.ts.map +1 -0
- package/dist/excelHelpers.js +343 -0
- package/dist/excelHelpers.js.map +1 -0
- package/dist/securityHelpers.d.ts +118 -0
- package/dist/securityHelpers.d.ts.map +1 -0
- package/dist/securityHelpers.js +437 -0
- package/dist/securityHelpers.js.map +1 -0
- package/dist/server.js +22 -6
- package/dist/server.js.map +1 -1
- package/dist/serverWrapper.d.ts +9 -1
- package/dist/serverWrapper.d.ts.map +1 -1
- package/dist/serverWrapper.js +76 -7
- package/dist/serverWrapper.js.map +1 -1
- package/dist/tools/docs.tools.d.ts.map +1 -1
- package/dist/tools/docs.tools.js +30 -11
- package/dist/tools/docs.tools.js.map +1 -1
- package/dist/tools/drive.tools.d.ts.map +1 -1
- package/dist/tools/drive.tools.js +680 -6
- package/dist/tools/drive.tools.js.map +1 -1
- package/dist/tools/excel.tools.d.ts +3 -0
- package/dist/tools/excel.tools.d.ts.map +1 -0
- package/dist/tools/excel.tools.js +651 -0
- package/dist/tools/excel.tools.js.map +1 -0
- package/dist/tools/forms.tools.d.ts.map +1 -1
- package/dist/tools/forms.tools.js +15 -9
- package/dist/tools/forms.tools.js.map +1 -1
- package/dist/tools/gmail.tools.d.ts.map +1 -1
- package/dist/tools/gmail.tools.js +1751 -126
- package/dist/tools/gmail.tools.js.map +1 -1
- package/dist/tools/sheets.tools.d.ts.map +1 -1
- package/dist/tools/sheets.tools.js +138 -4
- package/dist/tools/sheets.tools.js.map +1 -1
- package/dist/tools/slides.tools.d.ts.map +1 -1
- package/dist/tools/slides.tools.js +3 -1
- package/dist/tools/slides.tools.js.map +1 -1
- package/dist/types.d.ts +5 -0
- package/dist/types.d.ts.map +1 -1
- 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
|
-
|
|
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 +=
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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('
|
|
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
|
-
// ---
|
|
322
|
+
// --- Create Gmail Label ---
|
|
380
323
|
server.addTool({
|
|
381
|
-
name: '
|
|
382
|
-
description: '
|
|
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: '
|
|
327
|
+
title: 'Create Gmail Label',
|
|
385
328
|
readOnlyHint: false,
|
|
386
329
|
destructiveHint: false,
|
|
387
|
-
idempotentHint:
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
.
|
|
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
|
-
.
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
.
|
|
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.
|
|
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
|
|
418
|
-
|
|
419
|
-
|
|
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
|
|
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('
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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: {
|
|
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
|
-
// ---
|
|
636
|
+
// --- List Gmail Drafts ---
|
|
491
637
|
server.addTool({
|
|
492
|
-
name: '
|
|
493
|
-
description: '
|
|
638
|
+
name: 'listGmailDrafts',
|
|
639
|
+
description: 'List all draft emails in Gmail.',
|
|
494
640
|
annotations: {
|
|
495
|
-
title: '
|
|
496
|
-
readOnlyHint:
|
|
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
|
-
|
|
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.
|
|
656
|
+
const response = await gmail.users.drafts.list({
|
|
509
657
|
userId: 'me',
|
|
510
|
-
|
|
658
|
+
maxResults: Math.min(args.maxResults || 20, 100),
|
|
511
659
|
});
|
|
512
|
-
|
|
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('
|
|
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
|
});
|