@honest-magic/mail-mcp 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/cli/install-claude.d.ts +9 -0
  2. package/dist/cli/install-claude.js +42 -0
  3. package/dist/cli/install-claude.js.map +1 -0
  4. package/dist/config.d.ts +3 -0
  5. package/dist/config.js +3 -0
  6. package/dist/config.js.map +1 -1
  7. package/dist/index.d.ts +728 -2
  8. package/dist/index.js +901 -31
  9. package/dist/index.js.map +1 -1
  10. package/dist/protocol/imap.d.ts +16 -1
  11. package/dist/protocol/imap.js +80 -3
  12. package/dist/protocol/imap.js.map +1 -1
  13. package/dist/protocol/sieve.d.ts +62 -0
  14. package/dist/protocol/sieve.js +264 -0
  15. package/dist/protocol/sieve.js.map +1 -0
  16. package/dist/protocol/smtp.d.ts +1 -1
  17. package/dist/protocol/smtp.js +4 -1
  18. package/dist/protocol/smtp.js.map +1 -1
  19. package/dist/services/mail.d.ts +22 -3
  20. package/dist/services/mail.js +185 -4
  21. package/dist/services/mail.js.map +1 -1
  22. package/dist/utils/audit-logger.d.ts +25 -0
  23. package/dist/utils/audit-logger.js +47 -0
  24. package/dist/utils/audit-logger.js.map +1 -0
  25. package/dist/utils/confirmation-store.d.ts +33 -0
  26. package/dist/utils/confirmation-store.js +52 -0
  27. package/dist/utils/confirmation-store.js.map +1 -0
  28. package/dist/utils/rate-limiter.d.ts +23 -0
  29. package/dist/utils/rate-limiter.js +32 -0
  30. package/dist/utils/rate-limiter.js.map +1 -1
  31. package/dist/utils/redact.d.ts +20 -0
  32. package/dist/utils/redact.js +46 -0
  33. package/dist/utils/redact.js.map +1 -0
  34. package/dist/utils/templates.d.ts +24 -0
  35. package/dist/utils/templates.js +95 -0
  36. package/dist/utils/templates.js.map +1 -0
  37. package/dist/utils/validation.d.ts +10 -0
  38. package/dist/utils/validation.js +41 -0
  39. package/dist/utils/validation.js.map +1 -1
  40. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -5,12 +5,18 @@ import { ListToolsRequestSchema, CallToolRequestSchema, ErrorCode, McpError, } f
5
5
  import { parseArgs } from 'node:util';
6
6
  import { getAccounts } from './config.js';
7
7
  import { handleAccountsCommand } from './cli/accounts.js';
8
+ import { installClaude } from './cli/install-claude.js';
8
9
  import { MailService } from './services/mail.js';
9
10
  import { MailMCPError, NetworkError } from './errors.js';
10
- import { AccountRateLimiter } from './utils/rate-limiter.js';
11
+ import { TieredRateLimiter } from './utils/rate-limiter.js';
11
12
  import { ImapClient } from './protocol/imap.js';
12
13
  import { SmtpClient } from './protocol/smtp.js';
13
- import { validateEmailAddresses } from './utils/validation.js';
14
+ import { validateEmailAddresses, validateRecipients } from './utils/validation.js';
15
+ import { getTemplates, applyVariables } from './utils/templates.js';
16
+ import { SieveClient } from './protocol/sieve.js';
17
+ import { AuditLogger } from './utils/audit-logger.js';
18
+ import { ConfirmationStore } from './utils/confirmation-store.js';
19
+ import { AUDIT_LOG_PATH } from './config.js';
14
20
  const WRITE_TOOLS = new Set([
15
21
  'send_email',
16
22
  'create_draft',
@@ -18,16 +24,94 @@ const WRITE_TOOLS = new Set([
18
24
  'modify_labels',
19
25
  'register_oauth2_account',
20
26
  'batch_operations',
27
+ 'reply_email',
28
+ 'forward_email',
29
+ 'delete_email',
30
+ 'mark_read',
31
+ 'mark_unread',
32
+ 'star',
33
+ 'unstar',
34
+ 'set_filter',
35
+ 'delete_filter',
21
36
  ]);
37
+ /**
38
+ * Build a human-readable description of a write tool action for the confirmation prompt.
39
+ */
40
+ function buildConfirmationDescription(toolName, args) {
41
+ const uid = args.uid;
42
+ const folder = args.folder || 'INBOX';
43
+ switch (toolName) {
44
+ case 'send_email':
45
+ return `Send email to ${args.to} with subject '${args.subject}'`;
46
+ case 'create_draft':
47
+ return `Save draft to ${args.to} with subject '${args.subject}'`;
48
+ case 'reply_email':
49
+ return `Reply to email UID ${uid} in ${folder}`;
50
+ case 'forward_email':
51
+ return `Forward email UID ${uid} to ${args.to}`;
52
+ case 'delete_email':
53
+ return `Permanently delete email UID ${uid} from ${folder}`;
54
+ case 'move_email':
55
+ return `Move email UID ${uid} from ${args.sourceFolder} to ${args.targetFolder}`;
56
+ case 'batch_operations': {
57
+ const uids = args.uids;
58
+ return `Batch ${args.action} ${uids ? uids.length : 0} emails in ${folder}`;
59
+ }
60
+ case 'modify_labels':
61
+ return `Modify labels on email UID ${uid} in ${folder}`;
62
+ case 'mark_read':
63
+ return `Mark email UID ${uid} as read in ${folder}`;
64
+ case 'mark_unread':
65
+ return `Mark email UID ${uid} as unread in ${folder}`;
66
+ case 'star':
67
+ return `Star email UID ${uid} in ${folder}`;
68
+ case 'unstar':
69
+ return `Unstar email UID ${uid} in ${folder}`;
70
+ case 'register_oauth2_account':
71
+ return `Register OAuth2 credentials for account ${args.accountId}`;
72
+ case 'set_filter':
73
+ return `Create/update SIEVE filter '${args.name}' on account ${args.accountId}`;
74
+ case 'delete_filter':
75
+ return `Delete SIEVE filter '${args.name}' on account ${args.accountId}`;
76
+ default:
77
+ return `Execute ${toolName}`;
78
+ }
79
+ }
22
80
  export class MailMCPServer {
23
81
  readOnly;
24
82
  server;
25
83
  services = new Map();
26
84
  shuttingDown = false;
27
85
  inFlightCount = 0;
28
- rateLimiter = new AccountRateLimiter();
29
- constructor(readOnly = false) {
86
+ rateLimiter = new TieredRateLimiter();
87
+ allowedTools;
88
+ confirmMode;
89
+ confirmStore;
90
+ auditLogger;
91
+ redact;
92
+ constructor(readOnly = false, allowedTools, auditLogger, confirmMode = false, redact = false) {
30
93
  this.readOnly = readOnly;
94
+ if (readOnly && allowedTools !== undefined) {
95
+ throw new Error('--read-only and --allow-tools are mutually exclusive. Use --read-only to disable all write tools, or --allow-tools to enable specific ones.');
96
+ }
97
+ this.allowedTools = allowedTools;
98
+ this.auditLogger = auditLogger;
99
+ this.confirmMode = confirmMode;
100
+ this.confirmStore = new ConfirmationStore();
101
+ this.redact = redact;
102
+ const instructionsSuffix = (() => {
103
+ if (readOnly) {
104
+ return ' This server is running in read-only mode. Write operations (send_email, create_draft, move_email, modify_labels, batch_operations, register_oauth2_account, reply_email, forward_email, delete_email, mark_read, mark_unread, star, unstar) are disabled.';
105
+ }
106
+ if (allowedTools !== undefined) {
107
+ const list = [...allowedTools].join(', ') || 'none';
108
+ return ` This server is running with allow-listed tools. Only these write operations are enabled: ${list}. All other write tools are disabled.`;
109
+ }
110
+ if (confirmMode) {
111
+ return ' This server is running in confirmation mode. Write operations require a two-step confirmation: the first call returns a confirmationId; include it in the second call to execute the action.';
112
+ }
113
+ return '';
114
+ })();
31
115
  this.server = new Server({
32
116
  name: 'mail-mcp-server',
33
117
  version: '0.1.0',
@@ -35,7 +119,7 @@ export class MailMCPServer {
35
119
  capabilities: {
36
120
  tools: {},
37
121
  },
38
- instructions: `Use mail-mcp for IMAP-based email accounts — works with any provider including Gmail, Outlook, and custom domains. Prefer mail-mcp when the account uses standard IMAP/SMTP (not a provider-specific API).${this.readOnly ? ' This server is running in read-only mode. Write operations (send_email, create_draft, move_email, modify_labels, batch_operations, register_oauth2_account) are disabled.' : ''}`,
122
+ instructions: `Use mail-mcp for IMAP-based email accounts — works with any provider including Gmail, Outlook, and custom domains. Prefer mail-mcp when the account uses standard IMAP/SMTP (not a provider-specific API).${instructionsSuffix}`,
39
123
  });
40
124
  this.setupToolHandlers();
41
125
  this.server.onerror = (error) => console.error('[MCP Error]', error);
@@ -58,7 +142,7 @@ export class MailMCPServer {
58
142
  if (!account) {
59
143
  throw new Error(`Account ${accountId} not found in configuration.`);
60
144
  }
61
- const service = new MailService(account, this.readOnly);
145
+ const service = new MailService(account, this.readOnly, this.redact);
62
146
  await service.connect();
63
147
  this.services.set(accountId, service);
64
148
  // Wire close listener for auto-reconnect (CONN-02)
@@ -87,7 +171,7 @@ export class MailMCPServer {
87
171
  }
88
172
  }
89
173
  }
90
- getTools(readOnly) {
174
+ getTools(readOnly, allowedTools) {
91
175
  const allTools = [
92
176
  {
93
177
  name: 'list_accounts',
@@ -100,7 +184,7 @@ export class MailMCPServer {
100
184
  },
101
185
  {
102
186
  name: 'list_emails',
103
- description: 'List recent emails via IMAP from any folder — works with Gmail, Outlook, and custom domains.',
187
+ description: 'List recent emails via IMAP from any folder — works with Gmail, Outlook, and custom domains. Use headerOnly=true for fast inbox scanning that returns only subject, sender, date, and flags without downloading message bodies.',
104
188
  annotations: { readOnlyHint: true, destructiveHint: false },
105
189
  inputSchema: {
106
190
  type: 'object',
@@ -108,7 +192,8 @@ export class MailMCPServer {
108
192
  accountId: { type: 'string', description: 'The ID of the account to use' },
109
193
  folder: { type: 'string', description: 'The folder to list emails from (default: INBOX)' },
110
194
  count: { type: 'number', description: 'The number of emails to retrieve (default: 10)' },
111
- offset: { type: 'number', description: 'Number of messages to skip from the newest (for pagination, default: 0)' }
195
+ offset: { type: 'number', description: 'Number of messages to skip from the newest (for pagination, default: 0)' },
196
+ headerOnly: { type: 'boolean', description: 'When true, skip body download and return only headers (subject, from, date, flags). Much faster for large mailboxes. snippet will be empty. Default: false.' }
112
197
  },
113
198
  required: ['accountId']
114
199
  }
@@ -164,7 +249,8 @@ export class MailMCPServer {
164
249
  includeSignature: {
165
250
  type: 'boolean',
166
251
  description: 'Whether to append the account signature (default: true). Set to false to suppress the signature for this message.'
167
- }
252
+ },
253
+ confirmationId: { type: 'string', description: 'Confirmation token from a prior confirmation-required response. Required to execute write operations when server is in --confirm mode.' }
168
254
  },
169
255
  required: ['accountId', 'to', 'subject', 'body']
170
256
  }
@@ -186,7 +272,8 @@ export class MailMCPServer {
186
272
  includeSignature: {
187
273
  type: 'boolean',
188
274
  description: 'Whether to append the account signature (default: true). Set to false to suppress the signature for this draft.'
189
- }
275
+ },
276
+ confirmationId: { type: 'string', description: 'Confirmation token from a prior confirmation-required response. Required to execute write operations when server is in --confirm mode.' }
190
277
  },
191
278
  required: ['accountId', 'to', 'subject', 'body']
192
279
  }
@@ -213,7 +300,8 @@ export class MailMCPServer {
213
300
  accountId: { type: 'string', description: 'The ID of the account to use' },
214
301
  uid: { type: 'string', description: 'The UID of the email to move' },
215
302
  sourceFolder: { type: 'string', description: 'The current folder of the email' },
216
- targetFolder: { type: 'string', description: 'The destination folder' }
303
+ targetFolder: { type: 'string', description: 'The destination folder' },
304
+ confirmationId: { type: 'string', description: 'Confirmation token from a prior confirmation-required response. Required to execute write operations when server is in --confirm mode.' }
217
305
  },
218
306
  required: ['accountId', 'uid', 'sourceFolder', 'targetFolder']
219
307
  }
@@ -229,7 +317,8 @@ export class MailMCPServer {
229
317
  uid: { type: 'string', description: 'The UID of the email' },
230
318
  folder: { type: 'string', description: 'The folder containing the email' },
231
319
  addLabels: { type: 'array', items: { type: 'string' }, description: 'Labels to add (e.g. \\Seen, \\Flagged)' },
232
- removeLabels: { type: 'array', items: { type: 'string' }, description: 'Labels to remove' }
320
+ removeLabels: { type: 'array', items: { type: 'string' }, description: 'Labels to remove' },
321
+ confirmationId: { type: 'string', description: 'Confirmation token from a prior confirmation-required response. Required to execute write operations when server is in --confirm mode.' }
233
322
  },
234
323
  required: ['accountId', 'uid', 'folder']
235
324
  }
@@ -289,7 +378,8 @@ export class MailMCPServer {
289
378
  clientId: { type: 'string', description: 'OAuth2 Client ID' },
290
379
  clientSecret: { type: 'string', description: 'OAuth2 Client Secret' },
291
380
  refreshToken: { type: 'string', description: 'OAuth2 Refresh Token' },
292
- tokenEndpoint: { type: 'string', description: 'OAuth2 Token Endpoint URL' }
381
+ tokenEndpoint: { type: 'string', description: 'OAuth2 Token Endpoint URL' },
382
+ confirmationId: { type: 'string', description: 'Confirmation token from a prior confirmation-required response. Required to execute write operations when server is in --confirm mode.' }
293
383
  },
294
384
  required: ['accountId', 'clientId', 'clientSecret', 'refreshToken', 'tokenEndpoint']
295
385
  }
@@ -307,15 +397,264 @@ export class MailMCPServer {
307
397
  action: { type: 'string', enum: ['move', 'delete', 'label'], description: 'The batch action to perform' },
308
398
  targetFolder: { type: 'string', description: 'Target folder (required for move action)' },
309
399
  addLabels: { type: 'array', items: { type: 'string' }, description: 'Labels to add (for label action)' },
310
- removeLabels: { type: 'array', items: { type: 'string' }, description: 'Labels to remove (for label action)' }
400
+ removeLabels: { type: 'array', items: { type: 'string' }, description: 'Labels to remove (for label action)' },
401
+ confirmationId: { type: 'string', description: 'Confirmation token from a prior confirmation-required response. Required to execute write operations when server is in --confirm mode.' }
311
402
  },
312
403
  required: ['accountId', 'uids', 'folder', 'action']
313
404
  }
314
405
  },
406
+ {
407
+ name: 'delete_email',
408
+ description: 'Permanently delete a single email by UID via IMAP — works with any provider (Gmail, Outlook, custom domains). This is irreversible; prefer move_email to Trash if you want recovery.',
409
+ annotations: { readOnlyHint: false, destructiveHint: true },
410
+ inputSchema: {
411
+ type: 'object',
412
+ properties: {
413
+ accountId: { type: 'string', description: 'The ID of the account to use' },
414
+ uid: { type: 'string', description: 'The UID of the email to delete' },
415
+ folder: { type: 'string', description: 'The folder containing the email (default: INBOX)' },
416
+ confirmationId: { type: 'string', description: 'Confirmation token from a prior confirmation-required response. Required to execute write operations when server is in --confirm mode.' }
417
+ },
418
+ required: ['accountId', 'uid']
419
+ }
420
+ },
421
+ {
422
+ name: 'reply_email',
423
+ description: 'Reply to an email in-thread via SMTP — sets In-Reply-To and References headers so the reply appears in the original conversation. Works with any IMAP/SMTP provider.',
424
+ annotations: { readOnlyHint: false, destructiveHint: true },
425
+ inputSchema: {
426
+ type: 'object',
427
+ properties: {
428
+ accountId: { type: 'string', description: 'The ID of the account to use' },
429
+ uid: { type: 'string', description: 'The UID of the email to reply to' },
430
+ folder: { type: 'string', description: 'The folder containing the email (default: INBOX)' },
431
+ body: { type: 'string', description: 'Reply body content' },
432
+ isHtml: { type: 'boolean', description: 'Whether the body is HTML (default: false)' },
433
+ cc: { type: 'string', description: 'CC recipients' },
434
+ bcc: { type: 'string', description: 'BCC recipients' },
435
+ includeSignature: {
436
+ type: 'boolean',
437
+ description: 'Whether to append the account signature (default: true). Set to false to suppress the signature.'
438
+ },
439
+ confirmationId: { type: 'string', description: 'Confirmation token from a prior confirmation-required response. Required to execute write operations when server is in --confirm mode.' }
440
+ },
441
+ required: ['accountId', 'uid', 'body']
442
+ }
443
+ },
444
+ {
445
+ name: 'forward_email',
446
+ description: 'Forward an email to a new recipient via SMTP — prepends "Fwd: " to subject and includes the original message body. Works with any IMAP/SMTP provider.',
447
+ annotations: { readOnlyHint: false, destructiveHint: true },
448
+ inputSchema: {
449
+ type: 'object',
450
+ properties: {
451
+ accountId: { type: 'string', description: 'The ID of the account to use' },
452
+ uid: { type: 'string', description: 'The UID of the email to forward' },
453
+ folder: { type: 'string', description: 'The folder containing the email (default: INBOX)' },
454
+ to: { type: 'string', description: 'Recipient email address to forward to' },
455
+ body: { type: 'string', description: 'Optional preamble to include before the forwarded message' },
456
+ isHtml: { type: 'boolean', description: 'Whether the body is HTML (default: false)' },
457
+ cc: { type: 'string', description: 'CC recipients' },
458
+ bcc: { type: 'string', description: 'BCC recipients' },
459
+ includeSignature: {
460
+ type: 'boolean',
461
+ description: 'Whether to append the account signature (default: true). Set to false to suppress the signature.'
462
+ },
463
+ confirmationId: { type: 'string', description: 'Confirmation token from a prior confirmation-required response. Required to execute write operations when server is in --confirm mode.' }
464
+ },
465
+ required: ['accountId', 'uid', 'to']
466
+ }
467
+ },
468
+ {
469
+ name: 'mailbox_stats',
470
+ description: 'Return mailbox statistics (total messages, unread count, recent) for one or more IMAP folders — without listing individual emails. Works with any provider (Gmail, Outlook, custom domains).',
471
+ annotations: { readOnlyHint: true, destructiveHint: false },
472
+ inputSchema: {
473
+ type: 'object',
474
+ properties: {
475
+ accountId: { type: 'string', description: 'The ID of the account to use' },
476
+ folders: {
477
+ type: 'array',
478
+ items: { type: 'string' },
479
+ description: 'Folder names to report stats for (default: all folders)'
480
+ }
481
+ },
482
+ required: ['accountId']
483
+ }
484
+ },
485
+ {
486
+ name: 'extract_contacts',
487
+ description: 'Scan recent messages via IMAP and return structured contact data (name, email, frequency) sorted by how often they email you — enables "who emails me most?" queries. Works with any IMAP provider.',
488
+ annotations: { readOnlyHint: true, destructiveHint: false },
489
+ inputSchema: {
490
+ type: 'object',
491
+ properties: {
492
+ accountId: { type: 'string', description: 'The ID of the account to use' },
493
+ folder: { type: 'string', description: 'The folder to scan (default: INBOX)' },
494
+ count: { type: 'number', description: 'Number of recent messages to scan (default: 100, max: 500)' },
495
+ },
496
+ required: ['accountId'],
497
+ },
498
+ },
499
+ {
500
+ name: 'list_templates',
501
+ description: 'List configured email templates — reusable message skeletons with {{variable}} placeholders for standard replies, acknowledgements, out-of-office notices, etc. Optionally filter by account to show global and account-specific templates.',
502
+ annotations: { readOnlyHint: true, destructiveHint: false },
503
+ inputSchema: {
504
+ type: 'object',
505
+ properties: {
506
+ accountId: { type: 'string', description: 'Optional account ID — returns global templates plus templates scoped to this account. Omit to return all templates.' },
507
+ },
508
+ },
509
+ },
510
+ {
511
+ name: 'use_template',
512
+ description: 'Apply a configured email template — fills {{variable}} placeholders with provided values and returns ready-to-use arguments for send_email or create_draft. Does not send; call send_email or create_draft with the returned args.',
513
+ annotations: { readOnlyHint: true, destructiveHint: false },
514
+ inputSchema: {
515
+ type: 'object',
516
+ properties: {
517
+ templateId: { type: 'string', description: 'The ID of the template to use' },
518
+ variables: {
519
+ type: 'object',
520
+ description: 'Key-value pairs to substitute into {{variable}} placeholders in the template subject and body',
521
+ additionalProperties: { type: 'string' },
522
+ },
523
+ to: { type: 'string', description: 'Recipient email address to include in the returned args' },
524
+ cc: { type: 'string', description: 'CC recipients to include in the returned args' },
525
+ bcc: { type: 'string', description: 'BCC recipients to include in the returned args' },
526
+ accountId: { type: 'string', description: 'Account ID to include in the returned args' },
527
+ },
528
+ required: ['templateId'],
529
+ },
530
+ },
531
+ {
532
+ name: 'mark_read',
533
+ description: 'Mark an email as read (sets the \\\\Seen flag) via IMAP — works with Gmail, Outlook, and custom domains. Simpler alternative to modify_labels.',
534
+ annotations: { readOnlyHint: false, destructiveHint: true },
535
+ inputSchema: {
536
+ type: 'object',
537
+ properties: {
538
+ accountId: { type: 'string', description: 'The ID of the account to use' },
539
+ uid: { type: 'string', description: 'The UID of the email to mark as read' },
540
+ folder: { type: 'string', description: 'The folder containing the email (default: INBOX)' },
541
+ confirmationId: { type: 'string', description: 'Confirmation token from a prior confirmation-required response. Required to execute write operations when server is in --confirm mode.' }
542
+ },
543
+ required: ['accountId', 'uid'],
544
+ },
545
+ },
546
+ {
547
+ name: 'mark_unread',
548
+ description: 'Mark an email as unread (removes the \\\\Seen flag) via IMAP — works with Gmail, Outlook, and custom domains. Simpler alternative to modify_labels.',
549
+ annotations: { readOnlyHint: false, destructiveHint: true },
550
+ inputSchema: {
551
+ type: 'object',
552
+ properties: {
553
+ accountId: { type: 'string', description: 'The ID of the account to use' },
554
+ uid: { type: 'string', description: 'The UID of the email to mark as unread' },
555
+ folder: { type: 'string', description: 'The folder containing the email (default: INBOX)' },
556
+ confirmationId: { type: 'string', description: 'Confirmation token from a prior confirmation-required response. Required to execute write operations when server is in --confirm mode.' }
557
+ },
558
+ required: ['accountId', 'uid'],
559
+ },
560
+ },
561
+ {
562
+ name: 'star',
563
+ description: 'Star (flag) an email (sets the \\\\Flagged flag) via IMAP — works with Gmail, Outlook, and custom domains. Simpler alternative to modify_labels.',
564
+ annotations: { readOnlyHint: false, destructiveHint: true },
565
+ inputSchema: {
566
+ type: 'object',
567
+ properties: {
568
+ accountId: { type: 'string', description: 'The ID of the account to use' },
569
+ uid: { type: 'string', description: 'The UID of the email to star' },
570
+ folder: { type: 'string', description: 'The folder containing the email (default: INBOX)' },
571
+ confirmationId: { type: 'string', description: 'Confirmation token from a prior confirmation-required response. Required to execute write operations when server is in --confirm mode.' }
572
+ },
573
+ required: ['accountId', 'uid'],
574
+ },
575
+ },
576
+ {
577
+ name: 'unstar',
578
+ description: 'Unstar (unflag) an email (removes the \\\\Flagged flag) via IMAP — works with Gmail, Outlook, and custom domains. Simpler alternative to modify_labels.',
579
+ annotations: { readOnlyHint: false, destructiveHint: true },
580
+ inputSchema: {
581
+ type: 'object',
582
+ properties: {
583
+ accountId: { type: 'string', description: 'The ID of the account to use' },
584
+ uid: { type: 'string', description: 'The UID of the email to unstar' },
585
+ folder: { type: 'string', description: 'The folder containing the email (default: INBOX)' },
586
+ confirmationId: { type: 'string', description: 'Confirmation token from a prior confirmation-required response. Required to execute write operations when server is in --confirm mode.' }
587
+ },
588
+ required: ['accountId', 'uid'],
589
+ },
590
+ },
591
+ {
592
+ name: 'list_filters',
593
+ description: 'List SIEVE filter scripts on the server via ManageSieve (RFC 5804). Returns script names and which one is active. Only available on self-hosted mail servers — Gmail and Outlook do not support ManageSieve.',
594
+ annotations: { readOnlyHint: true, destructiveHint: false },
595
+ inputSchema: {
596
+ type: 'object',
597
+ properties: {
598
+ accountId: { type: 'string', description: 'The ID of the account to use' },
599
+ },
600
+ required: ['accountId'],
601
+ },
602
+ },
603
+ {
604
+ name: 'get_filter',
605
+ description: 'Retrieve the content of a SIEVE filter script by name via ManageSieve. Only available on self-hosted mail servers — Gmail and Outlook do not support ManageSieve.',
606
+ annotations: { readOnlyHint: true, destructiveHint: false },
607
+ inputSchema: {
608
+ type: 'object',
609
+ properties: {
610
+ accountId: { type: 'string', description: 'The ID of the account to use' },
611
+ name: { type: 'string', description: 'The name of the SIEVE script to retrieve' },
612
+ },
613
+ required: ['accountId', 'name'],
614
+ },
615
+ },
616
+ {
617
+ name: 'set_filter',
618
+ description: 'Create or replace a SIEVE filter script on the server via ManageSieve. The script content should be valid SIEVE syntax. Only available on self-hosted mail servers — Gmail and Outlook do not support ManageSieve.',
619
+ annotations: { readOnlyHint: false, destructiveHint: true },
620
+ inputSchema: {
621
+ type: 'object',
622
+ properties: {
623
+ accountId: { type: 'string', description: 'The ID of the account to use' },
624
+ name: { type: 'string', description: 'The name for the SIEVE script (e.g. "spam-filter")' },
625
+ content: { type: 'string', description: 'Valid SIEVE script content' },
626
+ confirmationId: { type: 'string', description: 'Confirmation token from a prior confirmation-required response. Required to execute write operations when server is in --confirm mode.' }
627
+ },
628
+ required: ['accountId', 'name', 'content'],
629
+ },
630
+ },
631
+ {
632
+ name: 'delete_filter',
633
+ description: 'Delete a SIEVE filter script by name via ManageSieve. Cannot delete the currently active script. Only available on self-hosted mail servers — Gmail and Outlook do not support ManageSieve.',
634
+ annotations: { readOnlyHint: false, destructiveHint: true },
635
+ inputSchema: {
636
+ type: 'object',
637
+ properties: {
638
+ accountId: { type: 'string', description: 'The ID of the account to use' },
639
+ name: { type: 'string', description: 'The name of the SIEVE script to delete' },
640
+ confirmationId: { type: 'string', description: 'Confirmation token from a prior confirmation-required response. Required to execute write operations when server is in --confirm mode.' }
641
+ },
642
+ required: ['accountId', 'name'],
643
+ },
644
+ },
315
645
  ];
316
- return readOnly ? allTools.filter(t => !WRITE_TOOLS.has(t.name)) : allTools;
646
+ if (readOnly) {
647
+ return allTools.filter(t => !WRITE_TOOLS.has(t.name));
648
+ }
649
+ if (allowedTools !== undefined) {
650
+ return allTools.filter(t => !WRITE_TOOLS.has(t.name) || allowedTools.has(t.name));
651
+ }
652
+ return allTools;
317
653
  }
318
654
  async dispatchTool(name, readOnly, args) {
655
+ const _dispatchStart = Date.now();
656
+ let _dispatchIsError = false;
657
+ let _dispatchErrorMsg;
319
658
  try {
320
659
  if (readOnly && WRITE_TOOLS.has(name)) {
321
660
  return {
@@ -326,10 +665,64 @@ export class MailMCPServer {
326
665
  isError: true,
327
666
  };
328
667
  }
668
+ if (this.allowedTools !== undefined && WRITE_TOOLS.has(name) && !this.allowedTools.has(name)) {
669
+ const list = [...this.allowedTools].join(', ') || 'none';
670
+ return {
671
+ content: [{
672
+ type: 'text',
673
+ text: `Tool '${name}' is not available: not in the allowed tools list. Allowed write tools: ${list}. Use --allow-tools to change the list.`,
674
+ }],
675
+ isError: true,
676
+ };
677
+ }
678
+ // Confirmation mode gate — intercept write tools when --confirm is active
679
+ if (this.confirmMode && WRITE_TOOLS.has(name)) {
680
+ const confirmationId = args.confirmationId;
681
+ if (confirmationId) {
682
+ // Second call: validate and consume the token
683
+ const pending = this.confirmStore.consume(confirmationId);
684
+ if (!pending) {
685
+ return {
686
+ content: [{
687
+ type: 'text',
688
+ text: 'Confirmation token invalid or expired. Call the tool again without confirmationId to get a new token.',
689
+ }],
690
+ isError: true,
691
+ };
692
+ }
693
+ // Token valid — strip confirmationId from args and fall through to execute
694
+ const { confirmationId: _removed, ...cleanArgs } = args;
695
+ args = cleanArgs;
696
+ }
697
+ else {
698
+ // First call: create confirmation token and return prompt
699
+ const argsWithoutId = { ...args };
700
+ delete argsWithoutId.confirmationId;
701
+ const id = this.confirmStore.create(name, argsWithoutId);
702
+ const description = buildConfirmationDescription(name, args);
703
+ return {
704
+ content: [{
705
+ type: 'text',
706
+ text: JSON.stringify({
707
+ confirmationRequired: true,
708
+ action: name,
709
+ description,
710
+ confirmationId: id,
711
+ expiresIn: '5 minutes',
712
+ }, null, 2),
713
+ }],
714
+ };
715
+ }
716
+ }
329
717
  // Rate limit guard — before any I/O (list_accounts has no accountId, skip it)
330
718
  const accountId = args?.accountId;
331
719
  if (accountId) {
332
- await this.rateLimiter.consume(accountId);
720
+ if (WRITE_TOOLS.has(name)) {
721
+ await this.rateLimiter.consumeWrite(accountId);
722
+ }
723
+ else {
724
+ await this.rateLimiter.consumeRead(accountId);
725
+ }
333
726
  }
334
727
  if (name === 'list_accounts') {
335
728
  const accounts = await getAccounts();
@@ -340,13 +733,64 @@ export class MailMCPServer {
340
733
  }],
341
734
  };
342
735
  }
343
- // Email validation guard for send/draft tools — before SMTP/IMAP I/O
736
+ // Email validation guard for send/draft/reply/forward tools — before SMTP/IMAP I/O
344
737
  if (name === 'send_email' || name === 'create_draft') {
345
738
  validateEmailAddresses(args.to, args.cc, args.bcc);
346
739
  }
740
+ if (name === 'forward_email') {
741
+ validateEmailAddresses(args.to, args.cc, args.bcc);
742
+ }
743
+ if (name === 'reply_email') {
744
+ // to is determined from original message; validate optional cc/bcc only
745
+ if (args.cc || args.bcc) {
746
+ validateEmailAddresses('placeholder@example.com', // dummy valid to — real to is set from original message
747
+ args.cc, args.bcc);
748
+ }
749
+ }
750
+ // Allowlist guard — validate recipients against per-account allowedRecipients when set
751
+ if (name === 'send_email' ||
752
+ name === 'create_draft' ||
753
+ name === 'forward_email' ||
754
+ name === 'reply_email') {
755
+ const sendAccountId = args.accountId;
756
+ if (sendAccountId) {
757
+ const allAccounts = await getAccounts();
758
+ const sendAccount = allAccounts.find((a) => a.id === sendAccountId);
759
+ if (sendAccount?.allowedRecipients && sendAccount.allowedRecipients.length > 0) {
760
+ const allowlist = sendAccount.allowedRecipients;
761
+ if (name === 'reply_email') {
762
+ // to is auto-determined from original sender — only validate cc/bcc
763
+ validateRecipients([args.cc, args.bcc], allowlist, sendAccountId);
764
+ }
765
+ else {
766
+ validateRecipients([
767
+ args.to,
768
+ args.cc,
769
+ args.bcc,
770
+ ], allowlist, sendAccountId);
771
+ }
772
+ }
773
+ }
774
+ }
775
+ if (name === 'reply_email') {
776
+ const service = await this.getService(args.accountId);
777
+ const includeSignature = args.includeSignature !== false;
778
+ await service.replyEmail(args.uid, args.folder || 'INBOX', args.body, args.isHtml, args.cc, args.bcc, includeSignature);
779
+ return {
780
+ content: [{ type: 'text', text: `Reply sent and saved to Sent folder.` }],
781
+ };
782
+ }
783
+ if (name === 'forward_email') {
784
+ const service = await this.getService(args.accountId);
785
+ const includeSignature = args.includeSignature !== false;
786
+ await service.forwardEmail(args.uid, args.folder || 'INBOX', args.to, args.body || '', args.isHtml, args.cc, args.bcc, includeSignature);
787
+ return {
788
+ content: [{ type: 'text', text: `Email forwarded to ${args.to} and saved to Sent folder.` }],
789
+ };
790
+ }
347
791
  if (name === 'list_emails') {
348
792
  const service = await this.getService(args.accountId);
349
- const messages = await service.listEmails(args.folder, args.count, args.offset);
793
+ const messages = await service.listEmails(args.folder, args.count, args.offset, args.headerOnly ?? false);
350
794
  return {
351
795
  content: [{ type: 'text', text: JSON.stringify(messages, null, 2) }],
352
796
  };
@@ -378,6 +822,148 @@ export class MailMCPServer {
378
822
  content: [{ type: 'text', text: `Draft successfully created in Drafts folder.` }],
379
823
  };
380
824
  }
825
+ if (name === 'mailbox_stats') {
826
+ const service = await this.getService(args.accountId);
827
+ const stats = await service.getMailboxStats(args.folders);
828
+ const header = `Folder | Total | Unread | Recent\n` +
829
+ `------------------|-------|--------|-------\n`;
830
+ const rows = stats.map((s) => {
831
+ const total = s.total !== null ? String(s.total) : (s.error ? 'ERR' : '-');
832
+ const unread = s.unread !== null ? String(s.unread) : (s.error ? 'ERR' : '-');
833
+ const recent = s.recent !== null ? String(s.recent) : (s.error ? 'ERR' : '-');
834
+ const folderName = s.name.padEnd(17).slice(0, 17);
835
+ return `${folderName} | ${total.padStart(5)} | ${unread.padStart(6)} | ${recent.padStart(6)}${s.error ? ` [${s.error}]` : ''}`;
836
+ }).join('\n');
837
+ return {
838
+ content: [{ type: 'text', text: header + rows }],
839
+ };
840
+ }
841
+ if (name === 'list_templates') {
842
+ const accountId = args.accountId;
843
+ const templates = await getTemplates();
844
+ const filtered = accountId
845
+ ? templates.filter(t => !t.accountId || t.accountId === accountId)
846
+ : templates;
847
+ return {
848
+ content: [{ type: 'text', text: JSON.stringify(filtered, null, 2) }],
849
+ };
850
+ }
851
+ if (name === 'use_template') {
852
+ const templateId = args.templateId;
853
+ const variables = args.variables ?? {};
854
+ const templates = await getTemplates();
855
+ const template = templates.find(t => t.id === templateId);
856
+ if (!template) {
857
+ return {
858
+ content: [{ type: 'text', text: `Template not found: "${templateId}". Use list_templates to see available templates.` }],
859
+ isError: true,
860
+ };
861
+ }
862
+ const result = {
863
+ body: applyVariables(template.body, variables),
864
+ };
865
+ if (template.subject !== undefined) {
866
+ result.subject = applyVariables(template.subject, variables);
867
+ }
868
+ if (template.isHtml !== undefined)
869
+ result.isHtml = template.isHtml;
870
+ if (args.to !== undefined)
871
+ result.to = args.to;
872
+ if (args.cc !== undefined)
873
+ result.cc = args.cc;
874
+ if (args.bcc !== undefined)
875
+ result.bcc = args.bcc;
876
+ if (args.accountId !== undefined)
877
+ result.accountId = args.accountId;
878
+ return {
879
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
880
+ };
881
+ }
882
+ if (name === 'delete_email') {
883
+ const service = await this.getService(args.accountId);
884
+ const folder = args.folder ?? 'INBOX';
885
+ await service.deleteEmail(args.uid, folder);
886
+ return {
887
+ content: [{ type: 'text', text: `Email ${args.uid} deleted from ${folder}.` }],
888
+ };
889
+ }
890
+ if (name === 'mark_read') {
891
+ const service = await this.getService(args.accountId);
892
+ const folder = args.folder ?? 'INBOX';
893
+ await service.modifyLabels(args.uid, folder, ['\\Seen'], []);
894
+ return {
895
+ content: [{ type: 'text', text: `Email ${args.uid} marked as read in ${folder}.` }],
896
+ };
897
+ }
898
+ if (name === 'mark_unread') {
899
+ const service = await this.getService(args.accountId);
900
+ const folder = args.folder ?? 'INBOX';
901
+ await service.modifyLabels(args.uid, folder, [], ['\\Seen']);
902
+ return {
903
+ content: [{ type: 'text', text: `Email ${args.uid} marked as unread in ${folder}.` }],
904
+ };
905
+ }
906
+ if (name === 'star') {
907
+ const service = await this.getService(args.accountId);
908
+ const folder = args.folder ?? 'INBOX';
909
+ await service.modifyLabels(args.uid, folder, ['\\Flagged'], []);
910
+ return {
911
+ content: [{ type: 'text', text: `Email ${args.uid} starred in ${folder}.` }],
912
+ };
913
+ }
914
+ if (name === 'unstar') {
915
+ const service = await this.getService(args.accountId);
916
+ const folder = args.folder ?? 'INBOX';
917
+ await service.modifyLabels(args.uid, folder, [], ['\\Flagged']);
918
+ return {
919
+ content: [{ type: 'text', text: `Email ${args.uid} unstarred in ${folder}.` }],
920
+ };
921
+ }
922
+ if (name === 'list_filters' || name === 'get_filter' || name === 'set_filter' || name === 'delete_filter') {
923
+ const accountId = args.accountId;
924
+ const accounts = await getAccounts();
925
+ const account = accounts.find(a => a.id === accountId);
926
+ if (!account) {
927
+ throw new Error(`Account ${accountId} not found in configuration.`);
928
+ }
929
+ const { loadCredentials } = await import('./security/keychain.js');
930
+ const password = await loadCredentials(accountId);
931
+ if (!password) {
932
+ throw new Error(`Credentials not found for account: ${accountId}`);
933
+ }
934
+ const sievePort = account.manageSievePort ?? 4190;
935
+ const sieve = new SieveClient(account.host, sievePort, account.user, password);
936
+ await sieve.connect();
937
+ try {
938
+ if (name === 'list_filters') {
939
+ const scripts = await sieve.listScripts();
940
+ return {
941
+ content: [{ type: 'text', text: JSON.stringify(scripts, null, 2) }],
942
+ };
943
+ }
944
+ if (name === 'get_filter') {
945
+ const content = await sieve.getScript(args.name);
946
+ return {
947
+ content: [{ type: 'text', text: content }],
948
+ };
949
+ }
950
+ if (name === 'set_filter') {
951
+ await sieve.putScript(args.name, args.content);
952
+ return {
953
+ content: [{ type: 'text', text: `Filter "${args.name}" saved successfully.` }],
954
+ };
955
+ }
956
+ if (name === 'delete_filter') {
957
+ await sieve.deleteScript(args.name);
958
+ return {
959
+ content: [{ type: 'text', text: `Filter "${args.name}" deleted.` }],
960
+ };
961
+ }
962
+ }
963
+ finally {
964
+ await sieve.disconnect();
965
+ }
966
+ }
381
967
  // Tools beyond list_accounts require an account connection.
382
968
  // Attempt to fetch the service so auth errors surface via the catch block.
383
969
  await this.getService(args.accountId);
@@ -389,15 +975,31 @@ export class MailMCPServer {
389
975
  : error instanceof Error
390
976
  ? error.message
391
977
  : String(error);
978
+ _dispatchIsError = true;
979
+ _dispatchErrorMsg = message;
392
980
  return {
393
981
  content: [{ type: 'text', text: message }],
394
982
  isError: true,
395
983
  };
396
984
  }
985
+ finally {
986
+ if (this.auditLogger) {
987
+ const _accountId = args?.accountId;
988
+ await this.auditLogger.log({
989
+ timestamp: new Date().toISOString(),
990
+ tool: name,
991
+ ...(_accountId !== undefined ? { accountId: _accountId } : {}),
992
+ args,
993
+ success: !_dispatchIsError,
994
+ durationMs: Date.now() - _dispatchStart,
995
+ ...(_dispatchIsError ? { error: _dispatchErrorMsg } : {}),
996
+ }).catch(() => { });
997
+ }
998
+ }
397
999
  }
398
1000
  setupToolHandlers() {
399
1001
  this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
400
- tools: this.getTools(this.readOnly),
1002
+ tools: this.getTools(this.readOnly, this.allowedTools),
401
1003
  }));
402
1004
  this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
403
1005
  if (this.shuttingDown) {
@@ -407,6 +1009,9 @@ export class MailMCPServer {
407
1009
  };
408
1010
  }
409
1011
  this.inFlightCount++;
1012
+ const _auditStart = Date.now();
1013
+ let _auditIsError = false;
1014
+ let _auditErrorMsg;
410
1015
  try {
411
1016
  const toolName = request.params.name;
412
1017
  if (this.readOnly && WRITE_TOOLS.has(toolName)) {
@@ -418,10 +1023,62 @@ export class MailMCPServer {
418
1023
  isError: true,
419
1024
  };
420
1025
  }
1026
+ if (this.allowedTools !== undefined && WRITE_TOOLS.has(toolName) && !this.allowedTools.has(toolName)) {
1027
+ const list = [...this.allowedTools].join(', ') || 'none';
1028
+ return {
1029
+ content: [{
1030
+ type: 'text',
1031
+ text: `Tool '${toolName}' is not available: not in the allowed tools list. Allowed write tools: ${list}. Use --allow-tools to change the list.`,
1032
+ }],
1033
+ isError: true,
1034
+ };
1035
+ }
1036
+ // Confirmation mode gate — intercept write tools when --confirm is active
1037
+ if (this.confirmMode && WRITE_TOOLS.has(toolName)) {
1038
+ const reqArgs = (request.params.arguments ?? {});
1039
+ const confirmationId = reqArgs.confirmationId;
1040
+ if (confirmationId) {
1041
+ const pending = this.confirmStore.consume(confirmationId);
1042
+ if (!pending) {
1043
+ return {
1044
+ content: [{
1045
+ type: 'text',
1046
+ text: 'Confirmation token invalid or expired. Call the tool again without confirmationId to get a new token.',
1047
+ }],
1048
+ isError: true,
1049
+ };
1050
+ }
1051
+ // Token valid — replace request args with stored args (strip confirmationId)
1052
+ request.params.arguments = pending.args;
1053
+ }
1054
+ else {
1055
+ const argsForStore = { ...reqArgs };
1056
+ delete argsForStore.confirmationId;
1057
+ const id = this.confirmStore.create(toolName, argsForStore);
1058
+ const description = buildConfirmationDescription(toolName, reqArgs);
1059
+ return {
1060
+ content: [{
1061
+ type: 'text',
1062
+ text: JSON.stringify({
1063
+ confirmationRequired: true,
1064
+ action: toolName,
1065
+ description,
1066
+ confirmationId: id,
1067
+ expiresIn: '5 minutes',
1068
+ }, null, 2),
1069
+ }],
1070
+ };
1071
+ }
1072
+ }
421
1073
  // Rate limit guard — before any I/O
422
1074
  const reqAccountId = request.params.arguments?.accountId;
423
1075
  if (reqAccountId) {
424
- await this.rateLimiter.consume(reqAccountId);
1076
+ if (WRITE_TOOLS.has(toolName)) {
1077
+ await this.rateLimiter.consumeWrite(reqAccountId);
1078
+ }
1079
+ else {
1080
+ await this.rateLimiter.consumeRead(reqAccountId);
1081
+ }
425
1082
  }
426
1083
  if (request.params.name === 'list_accounts') {
427
1084
  const accounts = await getAccounts();
@@ -437,7 +1094,7 @@ export class MailMCPServer {
437
1094
  if (request.params.name === 'list_emails') {
438
1095
  const args = request.params.arguments;
439
1096
  const service = await this.getService(args.accountId);
440
- const messages = await service.listEmails(args.folder, args.count, args.offset);
1097
+ const messages = await service.listEmails(args.folder, args.count, args.offset, args.headerOnly ?? false);
441
1098
  return {
442
1099
  content: [
443
1100
  {
@@ -479,6 +1136,25 @@ export class MailMCPServer {
479
1136
  ]
480
1137
  };
481
1138
  }
1139
+ if (request.params.name === 'reply_email') {
1140
+ const args = request.params.arguments;
1141
+ const includeSignature = args.includeSignature !== false;
1142
+ const service = await this.getService(args.accountId);
1143
+ await service.replyEmail(args.uid, args.folder || 'INBOX', args.body, args.isHtml, args.cc, args.bcc, includeSignature);
1144
+ return {
1145
+ content: [{ type: 'text', text: `Reply sent and saved to Sent folder.` }],
1146
+ };
1147
+ }
1148
+ if (request.params.name === 'forward_email') {
1149
+ const args = request.params.arguments;
1150
+ validateEmailAddresses(args.to, args.cc, args.bcc);
1151
+ const includeSignature = args.includeSignature !== false;
1152
+ const service = await this.getService(args.accountId);
1153
+ await service.forwardEmail(args.uid, args.folder || 'INBOX', args.to, args.body || '', args.isHtml, args.cc, args.bcc, includeSignature);
1154
+ return {
1155
+ content: [{ type: 'text', text: `Email forwarded to ${args.to} and saved to Sent folder.` }],
1156
+ };
1157
+ }
482
1158
  if (request.params.name === 'send_email') {
483
1159
  const args = request.params.arguments;
484
1160
  validateEmailAddresses(args.to, args.cc, args.bcc);
@@ -550,6 +1226,42 @@ export class MailMCPServer {
550
1226
  ]
551
1227
  };
552
1228
  }
1229
+ if (request.params.name === 'mark_read') {
1230
+ const args = request.params.arguments;
1231
+ const folder = args.folder || 'INBOX';
1232
+ const service = await this.getService(args.accountId);
1233
+ await service.modifyLabels(args.uid, folder, ['\\Seen'], []);
1234
+ return {
1235
+ content: [{ type: 'text', text: `Email ${args.uid} marked as read in ${folder}.` }]
1236
+ };
1237
+ }
1238
+ if (request.params.name === 'mark_unread') {
1239
+ const args = request.params.arguments;
1240
+ const folder = args.folder || 'INBOX';
1241
+ const service = await this.getService(args.accountId);
1242
+ await service.modifyLabels(args.uid, folder, [], ['\\Seen']);
1243
+ return {
1244
+ content: [{ type: 'text', text: `Email ${args.uid} marked as unread in ${folder}.` }]
1245
+ };
1246
+ }
1247
+ if (request.params.name === 'star') {
1248
+ const args = request.params.arguments;
1249
+ const folder = args.folder || 'INBOX';
1250
+ const service = await this.getService(args.accountId);
1251
+ await service.modifyLabels(args.uid, folder, ['\\Flagged'], []);
1252
+ return {
1253
+ content: [{ type: 'text', text: `Email ${args.uid} starred in ${folder}.` }]
1254
+ };
1255
+ }
1256
+ if (request.params.name === 'unstar') {
1257
+ const args = request.params.arguments;
1258
+ const folder = args.folder || 'INBOX';
1259
+ const service = await this.getService(args.accountId);
1260
+ await service.modifyLabels(args.uid, folder, [], ['\\Flagged']);
1261
+ return {
1262
+ content: [{ type: 'text', text: `Email ${args.uid} unstarred in ${folder}.` }]
1263
+ };
1264
+ }
553
1265
  if (request.params.name === 'get_thread') {
554
1266
  const args = request.params.arguments;
555
1267
  const service = await this.getService(args.accountId);
@@ -637,6 +1349,95 @@ export class MailMCPServer {
637
1349
  ]
638
1350
  };
639
1351
  }
1352
+ if (request.params.name === 'mailbox_stats') {
1353
+ const args = request.params.arguments;
1354
+ const service = await this.getService(args.accountId);
1355
+ const stats = await service.getMailboxStats(args.folders);
1356
+ const header = `Folder | Total | Unread | Recent\n` +
1357
+ `------------------|-------|--------|-------\n`;
1358
+ const rows = stats.map(s => {
1359
+ const total = s.total !== null ? String(s.total) : (s.error ? 'ERR' : '-');
1360
+ const unread = s.unread !== null ? String(s.unread) : (s.error ? 'ERR' : '-');
1361
+ const recent = s.recent !== null ? String(s.recent) : (s.error ? 'ERR' : '-');
1362
+ const name = s.name.padEnd(17).slice(0, 17);
1363
+ return `${name} | ${total.padStart(5)} | ${unread.padStart(6)} | ${recent.padStart(6)}${s.error ? ` [${s.error}]` : ''}`;
1364
+ }).join('\n');
1365
+ return {
1366
+ content: [
1367
+ {
1368
+ type: 'text',
1369
+ text: header + rows
1370
+ }
1371
+ ]
1372
+ };
1373
+ }
1374
+ if (request.params.name === 'extract_contacts') {
1375
+ const args = request.params.arguments;
1376
+ const service = await this.getService(args.accountId);
1377
+ const contacts = await service.extractContacts(args.folder ?? 'INBOX', args.count ?? 100);
1378
+ return {
1379
+ content: [
1380
+ {
1381
+ type: 'text',
1382
+ text: JSON.stringify({ contacts }, null, 2),
1383
+ },
1384
+ ],
1385
+ };
1386
+ }
1387
+ if (request.params.name === 'delete_email') {
1388
+ const args = request.params.arguments;
1389
+ const service = await this.getService(args.accountId);
1390
+ const folder = args.folder ?? 'INBOX';
1391
+ await service.deleteEmail(args.uid, folder);
1392
+ return {
1393
+ content: [
1394
+ {
1395
+ type: 'text',
1396
+ text: `Email ${args.uid} deleted from ${folder}.`
1397
+ }
1398
+ ]
1399
+ };
1400
+ }
1401
+ if (request.params.name === 'list_filters' ||
1402
+ request.params.name === 'get_filter' ||
1403
+ request.params.name === 'set_filter' ||
1404
+ request.params.name === 'delete_filter') {
1405
+ const args = request.params.arguments;
1406
+ const accounts = await getAccounts();
1407
+ const account = accounts.find(a => a.id === args.accountId);
1408
+ if (!account) {
1409
+ throw new Error(`Account ${args.accountId} not found in configuration.`);
1410
+ }
1411
+ const { loadCredentials } = await import('./security/keychain.js');
1412
+ const password = await loadCredentials(args.accountId);
1413
+ if (!password) {
1414
+ throw new Error(`Credentials not found for account: ${args.accountId}`);
1415
+ }
1416
+ const sievePort = account.manageSievePort ?? 4190;
1417
+ const sieve = new SieveClient(account.host, sievePort, account.user, password);
1418
+ await sieve.connect();
1419
+ try {
1420
+ if (request.params.name === 'list_filters') {
1421
+ const scripts = await sieve.listScripts();
1422
+ return { content: [{ type: 'text', text: JSON.stringify(scripts, null, 2) }] };
1423
+ }
1424
+ if (request.params.name === 'get_filter') {
1425
+ const content = await sieve.getScript(args.name);
1426
+ return { content: [{ type: 'text', text: content }] };
1427
+ }
1428
+ if (request.params.name === 'set_filter') {
1429
+ await sieve.putScript(args.name, args.content);
1430
+ return { content: [{ type: 'text', text: `Filter "${args.name}" saved successfully.` }] };
1431
+ }
1432
+ if (request.params.name === 'delete_filter') {
1433
+ await sieve.deleteScript(args.name);
1434
+ return { content: [{ type: 'text', text: `Filter "${args.name}" deleted.` }] };
1435
+ }
1436
+ }
1437
+ finally {
1438
+ await sieve.disconnect();
1439
+ }
1440
+ }
640
1441
  throw new McpError(ErrorCode.MethodNotFound, `Tool not found: ${request.params.name}`);
641
1442
  }
642
1443
  catch (error) {
@@ -645,6 +1446,8 @@ export class MailMCPServer {
645
1446
  : error instanceof Error
646
1447
  ? error.message
647
1448
  : String(error);
1449
+ _auditIsError = true;
1450
+ _auditErrorMsg = message;
648
1451
  return {
649
1452
  content: [{ type: 'text', text: message }],
650
1453
  isError: true,
@@ -652,6 +1455,21 @@ export class MailMCPServer {
652
1455
  }
653
1456
  finally {
654
1457
  this.inFlightCount--;
1458
+ if (this.auditLogger) {
1459
+ const _toolName = request.params.name;
1460
+ const _rawArgs = (request.params.arguments ?? {});
1461
+ const _accountId = _rawArgs.accountId;
1462
+ const _errMsg = _auditIsError ? (_auditErrorMsg ?? '(error)') : undefined;
1463
+ this.auditLogger.log({
1464
+ timestamp: new Date().toISOString(),
1465
+ tool: _toolName,
1466
+ ...(_accountId !== undefined ? { accountId: _accountId } : {}),
1467
+ args: _rawArgs,
1468
+ success: !_auditIsError,
1469
+ durationMs: Date.now() - _auditStart,
1470
+ ...(_auditIsError ? { error: _errMsg } : {}),
1471
+ }).catch(() => { });
1472
+ }
655
1473
  }
656
1474
  });
657
1475
  }
@@ -706,7 +1524,12 @@ async function main() {
706
1524
  args,
707
1525
  options: {
708
1526
  'read-only': { type: 'boolean', default: false },
1527
+ 'allow-tools': { type: 'string' },
1528
+ 'confirm': { type: 'boolean', default: false },
1529
+ 'audit-log': { type: 'boolean', default: false },
1530
+ 'redact': { type: 'boolean', default: false },
709
1531
  'validate-accounts': { type: 'boolean', default: false },
1532
+ 'install-claude': { type: 'boolean', default: false },
710
1533
  'version': { type: 'boolean', default: false },
711
1534
  'help': { type: 'boolean', short: 'h', default: false },
712
1535
  },
@@ -733,17 +1556,60 @@ Commands:
733
1556
  accounts remove ID Remove an account
734
1557
 
735
1558
  Options:
736
- --read-only Start in read-only mode (no send/move/label tools)
737
- --validate-accounts Probe IMAP/SMTP connections and exit
738
- --version Show version number
739
- -h, --help Show this help message`);
1559
+ --read-only Start in read-only mode (no send/move/label tools)
1560
+ --allow-tools t1,t2,... Allow only specific write tools (comma-separated). Mutually exclusive with --read-only.
1561
+ --confirm Enable confirmation mode — write tools require a two-step call (first returns confirmationId, second executes)
1562
+ --audit-log Append a JSONL entry for every tool call to ~/.config/mail-mcp/audit.log
1563
+ --redact Mask credit card numbers, SSNs, passwords, and API keys in email content before returning to AI
1564
+ --validate-accounts Probe IMAP/SMTP connections and exit
1565
+ --install-claude Write mail-mcp to Claude Desktop config and exit (one-command setup)
1566
+ --version Show version number
1567
+ -h, --help Show this help message`);
740
1568
  process.exit(0);
741
1569
  }
742
1570
  if (values['validate-accounts']) {
743
1571
  await runValidateAccounts();
744
1572
  process.exit(0);
745
1573
  }
746
- const server = new MailMCPServer(values['read-only'] ?? false);
1574
+ if (values['install-claude']) {
1575
+ const { join } = await import('node:path');
1576
+ const { homedir } = await import('node:os');
1577
+ const { execSync } = await import('node:child_process');
1578
+ // Detect binary path: try `which mail-mcp`, fall back to current script
1579
+ let binaryPath;
1580
+ try {
1581
+ binaryPath = execSync('which mail-mcp', { encoding: 'utf8' }).trim();
1582
+ }
1583
+ catch {
1584
+ binaryPath = process.argv[1];
1585
+ }
1586
+ const configPath = join(homedir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
1587
+ try {
1588
+ const writtenPath = await installClaude(configPath, binaryPath);
1589
+ console.log(`mail-mcp configured for Claude Desktop at: ${writtenPath}`);
1590
+ console.log(`Server path: ${binaryPath}`);
1591
+ console.log('Restart Claude Desktop to activate.');
1592
+ }
1593
+ catch (err) {
1594
+ console.error(`Error: ${err.message}`);
1595
+ process.exit(1);
1596
+ }
1597
+ process.exit(0);
1598
+ }
1599
+ const readOnly = values['read-only'] ?? false;
1600
+ const allowToolsRaw = values['allow-tools'];
1601
+ if (readOnly && allowToolsRaw !== undefined) {
1602
+ console.error('Error: --read-only and --allow-tools are mutually exclusive.');
1603
+ process.exit(1);
1604
+ }
1605
+ const allowedTools = allowToolsRaw !== undefined
1606
+ ? new Set(allowToolsRaw.split(',').map(t => t.trim()).filter(t => t.length > 0))
1607
+ : undefined;
1608
+ const confirmMode = values['confirm'] ?? false;
1609
+ const auditLogEnabled = values['audit-log'] ?? false;
1610
+ const auditLogger = new AuditLogger(AUDIT_LOG_PATH, auditLogEnabled);
1611
+ const redact = values['redact'] ?? false;
1612
+ const server = new MailMCPServer(readOnly, allowedTools, auditLogger, confirmMode, redact);
747
1613
  const shutdown = async () => {
748
1614
  const timer = setTimeout(() => {
749
1615
  console.error('Forced exit after 10s shutdown timeout');
@@ -757,8 +1623,12 @@ Options:
757
1623
  process.on('SIGINT', shutdown);
758
1624
  server.run().catch(console.error);
759
1625
  }
760
- main().catch((err) => {
761
- console.error(err);
762
- process.exit(1);
763
- });
1626
+ // Only auto-run when executed directly (not when imported by tests)
1627
+ const isDirectRun = !process.env.VITEST && !process.env.NODE_TEST;
1628
+ if (isDirectRun) {
1629
+ main().catch((err) => {
1630
+ console.error(err);
1631
+ process.exit(1);
1632
+ });
1633
+ }
764
1634
  //# sourceMappingURL=index.js.map