icloud-mcp 2.5.1 → 2.7.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.
package/index.js CHANGED
@@ -1,28 +1,16 @@
1
1
  #!/usr/bin/env node
2
+ import { readFileSync } from 'fs';
2
3
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
4
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
5
  import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
5
- import {
6
- TIMEOUT, withTimeout, createRateLimitedClient,
7
- fetchEmails, getInboxSummary, getMailboxSummary, getTopSenders, getUnreadSenders,
8
- getEmailsBySender, getEmailsByDateRange, searchEmails,
9
- getEmailContent, getEmailRaw, listAttachments, getAttachment, getUnsubscribeInfo,
10
- getThread, getStorageReport,
11
- flagEmail, markAsRead, deleteEmail, moveEmail, listMailboxes,
12
- bulkMove, bulkMoveBySender, bulkMoveByDomain, archiveOlderThan,
13
- bulkDelete, bulkDeleteBySender, bulkDeleteBySubject, deleteOlderThan,
14
- bulkMarkRead, bulkMarkUnread, markOlderThanRead,
15
- bulkFlag, bulkFlagBySender, emptyTrash,
16
- createMailbox, renameMailbox, deleteMailbox,
17
- getMoveStatus, abandonMove, countEmails,
18
- createRule, listRules, runRule, deleteRule, runAllRules,
19
- } from './lib/imap.js';
20
- import { logRead, logWrite, logClear } from './lib/session.js';
21
- import { composeEmail, replyToEmail, forwardEmail, saveDraft } from './lib/smtp.js';
22
- import { listContacts, searchContacts, getContact, createContact, updateContact, deleteContact } from './lib/carddav.js';
23
- import { getDigestState, updateDigestState } from './lib/digest.js';
24
- import { formatEmailForExtraction } from './lib/event-extractor.js';
25
- import { listCalendars, listEvents, getEvent, createEvent, updateEvent, deleteEvent, searchEvents } from './lib/caldav.js';
6
+ import { createRateLimitedClient } from './lib/imap.js';
7
+ import { mailTools, handleMailTool } from './lib/tools/mail.js';
8
+ import { contactTools, handleContactTool } from './lib/tools/contacts.js';
9
+ import { calendarTools, suggestEventTools, handleCalendarTool } from './lib/tools/calendar.js';
10
+ import { reminderTools, handleReminderTool } from './lib/tools/reminders.js';
11
+
12
+
13
+ const { version: SERVER_VERSION } = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
26
14
 
27
15
  const IMAP_USER = process.env.IMAP_USER;
28
16
  const IMAP_PASSWORD = process.env.IMAP_PASSWORD;
@@ -88,1138 +76,38 @@ function resolveMailbox(name, creds) {
88
76
 
89
77
  async function main() {
90
78
  const server = new Server(
91
- { name: 'icloud-mail', version: '2.5.1' },
79
+ { name: 'icloud-mail', version: SERVER_VERSION },
92
80
  { capabilities: { tools: {} } }
93
81
  );
94
82
 
95
- const filtersSchema = {
96
- sender: { type: 'string', description: 'Match exact sender email address' },
97
- domain: { type: 'string', description: 'Match any sender from this domain (e.g. substack.com)' },
98
- subject: { type: 'string', description: 'Keyword to match in subject' },
99
- before: { type: 'string', description: 'Only emails before this date (YYYY-MM-DD)' },
100
- since: { type: 'string', description: 'Only emails since this date (YYYY-MM-DD)' },
101
- unread: { type: 'boolean', description: 'True for unread only, false for read only' },
102
- flagged: { type: 'boolean', description: 'True for flagged only, false for unflagged only' },
103
- larger: { type: 'number', description: 'Only emails larger than this size in KB' },
104
- smaller: { type: 'number', description: 'Only emails smaller than this size in KB' },
105
- hasAttachment: { type: 'boolean', description: 'Only emails with attachments (client-side BODYSTRUCTURE scan — must be combined with other filters that narrow results to under 500 emails first)' },
106
- account: { type: 'string', description: "Account name to use (e.g. 'icloud', 'gmail'). Defaults to first configured account. Use list_accounts to see available accounts." }
107
- };
108
- const accountSchema = filtersSchema.account;
83
+ const orderedTools = [
84
+ ...mailTools,
85
+ ...contactTools,
86
+ ...calendarTools,
87
+ ...reminderTools,
88
+ ...suggestEventTools,
89
+ ];
109
90
 
110
91
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
111
- tools: [
112
- {
113
- name: 'list_accounts',
114
- description: 'List all configured email accounts (names and IMAP hosts). Use the account name in any mail tool\'s account parameter.',
115
- inputSchema: { type: 'object', properties: {} }
116
- },
117
- {
118
- name: 'get_inbox_summary',
119
- description: 'Get a summary of a mailbox including total, unread, and recent email counts',
120
- inputSchema: { type: 'object', properties: { mailbox: { type: 'string', description: 'Mailbox name (default INBOX)' }, account: accountSchema } }
121
- },
122
- {
123
- name: 'get_mailbox_summary',
124
- description: 'Get total, unread, and recent email counts for any specific mailbox/folder',
125
- inputSchema: {
126
- type: 'object',
127
- properties: { mailbox: { type: 'string', description: 'Mailbox path to summarize (e.g. Newsletters, Archive)' } },
128
- required: ['mailbox']
129
- }
130
- },
131
- {
132
- name: 'get_top_senders',
133
- description: 'Get the top senders by email count from a sample of the inbox',
134
- inputSchema: {
135
- type: 'object',
136
- properties: {
137
- mailbox: { type: 'string', description: 'Mailbox to analyze (default INBOX)' },
138
- sampleSize: { type: 'number', description: 'Number of emails to sample (default 500)' },
139
- maxResults: { type: 'number', description: 'Max number of senders/domains to return (default 20)' }
140
- }
141
- }
142
- },
143
- {
144
- name: 'get_unread_senders',
145
- description: 'Get top senders of unread emails',
146
- inputSchema: {
147
- type: 'object',
148
- properties: {
149
- mailbox: { type: 'string', description: 'Mailbox to analyze (default INBOX)' },
150
- sampleSize: { type: 'number', description: 'Number of emails to sample (default 500)' },
151
- maxResults: { type: 'number', description: 'Max number of senders to return (default 20)' }
152
- }
153
- }
154
- },
155
- {
156
- name: 'get_emails_by_sender',
157
- description: 'Get all emails from a specific sender',
158
- inputSchema: {
159
- type: 'object',
160
- properties: {
161
- sender: { type: 'string', description: 'Sender email address or domain' },
162
- mailbox: { type: 'string', description: 'Mailbox to search (default INBOX)' },
163
- limit: { type: 'number', description: 'Max results to show (default 10)' }
164
- },
165
- required: ['sender']
166
- }
167
- },
168
- {
169
- name: 'read_inbox',
170
- description: 'Read emails from iCloud inbox with pagination',
171
- inputSchema: {
172
- type: 'object',
173
- properties: {
174
- limit: { type: 'number', description: 'Number of emails per page (default 10)' },
175
- page: { type: 'number', description: 'Page number (default 1)' },
176
- onlyUnread: { type: 'boolean', description: 'Only fetch unread emails' },
177
- mailbox: { type: 'string', description: 'Mailbox to read (default INBOX)' }
178
- }
179
- }
180
- },
181
- {
182
- name: 'get_email',
183
- description: 'Get full content of a specific email by UID',
184
- inputSchema: {
185
- type: 'object',
186
- properties: {
187
- uid: { type: 'number', description: 'Email UID' },
188
- mailbox: { type: 'string', description: 'Mailbox name (default INBOX)' },
189
- maxChars: { type: 'number', description: 'Max body characters to return (default 8000, max 50000)' },
190
- includeHeaders: { type: 'boolean', description: 'If true, include a headers object with to/cc/replyTo/messageId/inReplyTo/references/listUnsubscribe' }
191
- },
192
- required: ['uid']
193
- }
194
- },
195
- {
196
- name: 'search_emails',
197
- description: 'Search emails by keyword or targeted field queries, with optional filters for date, read status, domain, and more',
198
- inputSchema: {
199
- type: 'object',
200
- properties: {
201
- query: { type: 'string', description: 'Search keyword (matches subject, sender, body — use OR across all fields)' },
202
- subjectQuery: { type: 'string', description: 'Match only in subject field' },
203
- bodyQuery: { type: 'string', description: 'Match only in body field' },
204
- fromQuery: { type: 'string', description: 'Match only in from/sender field' },
205
- queryMode: { type: 'string', enum: ['or', 'and'], description: 'How to combine subjectQuery/bodyQuery/fromQuery: or (default) or and' },
206
- mailbox: { type: 'string', description: 'Mailbox to search (default INBOX)' },
207
- limit: { type: 'number', description: 'Max results (default 10)' },
208
- includeSnippet: { type: 'boolean', description: 'If true, include a 200-char body preview snippet for each result (max 10 emails)' },
209
- ...filtersSchema
210
- }
211
- }
212
- },
213
- {
214
- name: 'count_emails',
215
- description: 'Count how many emails match a set of filters without moving or deleting them. Use this before bulk_move or bulk_delete to preview how many emails will be affected.',
216
- inputSchema: {
217
- type: 'object',
218
- properties: {
219
- mailbox: { type: 'string', description: 'Mailbox to count in (default INBOX)' },
220
- ...filtersSchema
221
- }
222
- }
223
- },
224
- {
225
- name: 'bulk_move',
226
- description: 'Move emails matching any combination of filters from one mailbox to another. Uses safe copy-verify-delete with fingerprint verification and a persistent manifest. Use dryRun: true to preview without making changes.',
227
- inputSchema: {
228
- type: 'object',
229
- properties: {
230
- targetMailbox: { type: 'string', description: 'Destination mailbox path' },
231
- sourceMailbox: { type: 'string', description: 'Source mailbox (default INBOX)' },
232
- dryRun: { type: 'boolean', description: 'If true, preview what would be moved without actually moving' },
233
- limit: { type: 'number', description: 'Maximum number of emails to move (default: all matching)' },
234
- ...filtersSchema
235
- },
236
- required: ['targetMailbox']
237
- }
238
- },
239
- {
240
- name: 'bulk_delete',
241
- description: 'Delete emails matching any combination of filters. Processes in chunks of 250 with per-chunk timeouts for reliability. Use dryRun: true to preview without making changes.',
242
- inputSchema: {
243
- type: 'object',
244
- properties: {
245
- sourceMailbox: { type: 'string', description: 'Mailbox to delete from (default INBOX)' },
246
- dryRun: { type: 'boolean', description: 'If true, preview what would be deleted without actually deleting' },
247
- ...filtersSchema
248
- }
249
- }
250
- },
251
- {
252
- name: 'bulk_flag',
253
- description: 'Flag or unflag emails matching any combination of filters in bulk',
254
- inputSchema: {
255
- type: 'object',
256
- properties: {
257
- flagged: { type: 'boolean', description: 'True to flag, false to unflag' },
258
- mailbox: { type: 'string', description: 'Mailbox (default INBOX)' },
259
- ...filtersSchema
260
- },
261
- required: ['flagged']
262
- }
263
- },
264
- {
265
- name: 'bulk_delete_by_sender',
266
- description: 'Delete all emails from a specific sender',
267
- inputSchema: {
268
- type: 'object',
269
- properties: {
270
- sender: { type: 'string', description: 'Sender email address' },
271
- mailbox: { type: 'string', description: 'Mailbox (default INBOX)' }
272
- },
273
- required: ['sender']
274
- }
275
- },
276
- {
277
- name: 'bulk_move_by_sender',
278
- description: 'Move all emails from a specific sender to a folder',
279
- inputSchema: {
280
- type: 'object',
281
- properties: {
282
- sender: { type: 'string', description: 'Sender email address' },
283
- targetMailbox: { type: 'string', description: 'Destination folder' },
284
- sourceMailbox: { type: 'string', description: 'Source mailbox (default INBOX)' },
285
- dryRun: { type: 'boolean', description: 'Preview only — return count without moving' }
286
- },
287
- required: ['sender', 'targetMailbox']
288
- }
289
- },
290
- {
291
- name: 'bulk_delete_by_subject',
292
- description: 'Delete all emails matching a subject pattern',
293
- inputSchema: {
294
- type: 'object',
295
- properties: {
296
- subject: { type: 'string', description: 'Subject keyword to match' },
297
- mailbox: { type: 'string', description: 'Mailbox (default INBOX)' }
298
- },
299
- required: ['subject']
300
- }
301
- },
302
- {
303
- name: 'bulk_mark_read',
304
- description: 'Mark all emails as read, optionally filtered by sender',
305
- inputSchema: {
306
- type: 'object',
307
- properties: {
308
- mailbox: { type: 'string', description: 'Mailbox (default INBOX)' },
309
- sender: { type: 'string', description: 'Optional: only mark emails from this sender as read' }
310
- }
311
- }
312
- },
313
- {
314
- name: 'bulk_mark_unread',
315
- description: 'Mark all emails as unread, optionally filtered by sender',
316
- inputSchema: {
317
- type: 'object',
318
- properties: {
319
- mailbox: { type: 'string', description: 'Mailbox (default INBOX)' },
320
- sender: { type: 'string', description: 'Optional: only mark emails from this sender as unread' }
321
- }
322
- }
323
- },
324
- {
325
- name: 'delete_older_than',
326
- description: 'Delete all emails older than a certain number of days',
327
- inputSchema: {
328
- type: 'object',
329
- properties: {
330
- days: { type: 'number', description: 'Delete emails older than this many days' },
331
- mailbox: { type: 'string', description: 'Mailbox (default INBOX)' }
332
- },
333
- required: ['days']
334
- }
335
- },
336
- {
337
- name: 'get_emails_by_date_range',
338
- description: 'Get emails between two dates',
339
- inputSchema: {
340
- type: 'object',
341
- properties: {
342
- startDate: { type: 'string', description: 'Start date (YYYY-MM-DD)' },
343
- endDate: { type: 'string', description: 'End date (YYYY-MM-DD)' },
344
- mailbox: { type: 'string', description: 'Mailbox (default INBOX)' },
345
- limit: { type: 'number', description: 'Max results (default 10)' }
346
- },
347
- required: ['startDate', 'endDate']
348
- }
349
- },
350
- {
351
- name: 'flag_email',
352
- description: 'Flag or unflag a single email',
353
- inputSchema: {
354
- type: 'object',
355
- properties: {
356
- uid: { type: 'number', description: 'Email UID' },
357
- flagged: { type: 'boolean', description: 'True to flag, false to unflag' },
358
- mailbox: { type: 'string', description: 'Mailbox name (default INBOX)' }
359
- },
360
- required: ['uid', 'flagged']
361
- }
362
- },
363
- {
364
- name: 'mark_as_read',
365
- description: 'Mark a single email as read or unread',
366
- inputSchema: {
367
- type: 'object',
368
- properties: {
369
- uid: { type: 'number', description: 'Email UID' },
370
- seen: { type: 'boolean', description: 'True to mark as read, false for unread' },
371
- mailbox: { type: 'string', description: 'Mailbox name (default INBOX)' }
372
- },
373
- required: ['uid', 'seen']
374
- }
375
- },
376
- {
377
- name: 'delete_email',
378
- description: 'Delete a single email',
379
- inputSchema: {
380
- type: 'object',
381
- properties: {
382
- uid: { type: 'number', description: 'Email UID' },
383
- mailbox: { type: 'string', description: 'Mailbox name (default INBOX)' }
384
- },
385
- required: ['uid']
386
- }
387
- },
388
- {
389
- name: 'move_email',
390
- description: 'Move a single email to a different mailbox/folder',
391
- inputSchema: {
392
- type: 'object',
393
- properties: {
394
- uid: { type: 'number', description: 'Email UID' },
395
- targetMailbox: { type: 'string', description: 'Destination mailbox path' },
396
- sourceMailbox: { type: 'string', description: 'Source mailbox (default INBOX)' }
397
- },
398
- required: ['uid', 'targetMailbox']
399
- }
400
- },
401
- {
402
- name: 'list_mailboxes',
403
- description: 'List all mailboxes/folders in iCloud Mail',
404
- inputSchema: { type: 'object', properties: {} }
405
- },
406
- {
407
- name: 'create_mailbox',
408
- description: 'Create a new mailbox/folder',
409
- inputSchema: {
410
- type: 'object',
411
- properties: { name: { type: 'string', description: 'Name of the new mailbox' } },
412
- required: ['name']
413
- }
414
- },
415
- {
416
- name: 'rename_mailbox',
417
- description: 'Rename an existing mailbox/folder',
418
- inputSchema: {
419
- type: 'object',
420
- properties: {
421
- oldName: { type: 'string', description: 'Current mailbox path' },
422
- newName: { type: 'string', description: 'New mailbox path' }
423
- },
424
- required: ['oldName', 'newName']
425
- }
426
- },
427
- {
428
- name: 'delete_mailbox',
429
- description: 'Delete a mailbox/folder. The folder must be empty first.',
430
- inputSchema: {
431
- type: 'object',
432
- properties: { name: { type: 'string', description: 'Mailbox path to delete' } },
433
- required: ['name']
434
- }
435
- },
436
- {
437
- name: 'empty_trash',
438
- description: 'Permanently delete all emails in the trash (Deleted Messages or Trash folder). Use dryRun: true to preview first.',
439
- inputSchema: {
440
- type: 'object',
441
- properties: {
442
- dryRun: { type: 'boolean', description: 'If true, preview how many emails would be deleted without deleting' }
443
- }
444
- }
445
- },
446
- {
447
- name: 'get_move_status',
448
- description: 'Check the status of the current or most recent bulk move operation. Shows progress, chunk statuses, and any failures. Call this to monitor a long-running move or inspect a failed one.',
449
- inputSchema: { type: 'object', properties: {} }
450
- },
451
- {
452
- name: 'abandon_move',
453
- description: 'Abandon an in-progress move operation so a new one can start. Only use if you are certain the operation should not be resumed. Emails already moved will not be returned to source.',
454
- inputSchema: { type: 'object', properties: {} }
455
- },
456
- {
457
- name: 'log_write',
458
- description: 'Write a step to the session log. Use this to record your plan before starting, and after each completed step. Helps maintain progress across long operations.',
459
- inputSchema: {
460
- type: 'object',
461
- properties: {
462
- step: { type: 'string', description: 'Description of what you are doing or just completed' }
463
- },
464
- required: ['step']
465
- }
466
- },
467
- {
468
- name: 'log_read',
469
- description: 'Read the current session log to see what has been done so far.',
470
- inputSchema: { type: 'object', properties: {} }
471
- },
472
- {
473
- name: 'log_clear',
474
- description: 'Clear the session log and start fresh. Use this at the start of a new task.',
475
- inputSchema: { type: 'object', properties: {} }
476
- },
477
- {
478
- name: 'list_attachments',
479
- description: 'List all attachments in an email without downloading them. Returns filename, MIME type, size, and IMAP part ID for each attachment.',
480
- inputSchema: {
481
- type: 'object',
482
- properties: {
483
- uid: { type: 'number', description: 'Email UID' },
484
- mailbox: { type: 'string', description: 'Mailbox name (default INBOX)' }
485
- },
486
- required: ['uid']
487
- }
488
- },
489
- {
490
- name: 'get_attachment',
491
- description: 'Download a specific attachment from an email. Returns the file content as base64-encoded data. Use list_attachments first to get the partId. Maximum 20 MB per request; use offset+length for larger files.',
492
- inputSchema: {
493
- type: 'object',
494
- properties: {
495
- uid: { type: 'number', description: 'Email UID' },
496
- partId: { type: 'string', description: 'IMAP body part ID from list_attachments (e.g. "2", "1.2")' },
497
- mailbox: { type: 'string', description: 'Mailbox name (default INBOX)' },
498
- offset: { type: 'number', description: 'Byte offset for paginated download (returns raw encoded bytes, not decoded)' },
499
- length: { type: 'number', description: 'Max bytes to return for paginated download (default 20 MB)' }
500
- },
501
- required: ['uid', 'partId']
502
- }
503
- },
504
- {
505
- name: 'get_unsubscribe_info',
506
- description: 'Get the List-Unsubscribe header from an email, parsed into email and URL components. Useful for AI-assisted inbox cleanup.',
507
- inputSchema: {
508
- type: 'object',
509
- properties: {
510
- uid: { type: 'number', description: 'Email UID' },
511
- mailbox: { type: 'string', description: 'Mailbox name (default INBOX)' }
512
- },
513
- required: ['uid']
514
- }
515
- },
516
- {
517
- name: 'mark_older_than_read',
518
- description: 'Mark all unread emails older than N days as read. Useful for bulk triage of a cluttered inbox.',
519
- inputSchema: {
520
- type: 'object',
521
- properties: {
522
- days: { type: 'number', description: 'Mark emails older than this many days as read' },
523
- mailbox: { type: 'string', description: 'Mailbox (default INBOX)' }
524
- },
525
- required: ['days']
526
- }
527
- },
528
- {
529
- name: 'bulk_move_by_domain',
530
- description: 'Move all emails from a specific domain to a folder. Convenience wrapper around bulk_move with a domain filter.',
531
- inputSchema: {
532
- type: 'object',
533
- properties: {
534
- domain: { type: 'string', description: 'Sender domain to match (e.g. github.com, substack.com)' },
535
- targetMailbox: { type: 'string', description: 'Destination folder' },
536
- sourceMailbox: { type: 'string', description: 'Source mailbox (default INBOX)' },
537
- dryRun: { type: 'boolean', description: 'Preview only — return count without moving' }
538
- },
539
- required: ['domain', 'targetMailbox']
540
- }
541
- },
542
- {
543
- name: 'get_email_raw',
544
- description: 'Get the raw RFC 2822 source of an email (full headers + MIME body) as base64-encoded data. Useful for debugging or export. Capped at 1 MB.',
545
- inputSchema: {
546
- type: 'object',
547
- properties: {
548
- uid: { type: 'number', description: 'Email UID' },
549
- mailbox: { type: 'string', description: 'Mailbox name (default INBOX)' }
550
- },
551
- required: ['uid']
552
- }
553
- },
554
- {
555
- name: 'bulk_flag_by_sender',
556
- description: 'Flag or unflag all emails from a specific sender',
557
- inputSchema: {
558
- type: 'object',
559
- properties: {
560
- sender: { type: 'string', description: 'Sender email address' },
561
- flagged: { type: 'boolean', description: 'True to flag, false to unflag' },
562
- mailbox: { type: 'string', description: 'Mailbox (default INBOX)' }
563
- },
564
- required: ['sender', 'flagged']
565
- }
566
- },
567
- {
568
- name: 'archive_older_than',
569
- description: 'Safely move emails older than N days from a source mailbox to an archive folder. Uses the same safe copy-verify-delete pipeline as bulk_move. Use dryRun: true to preview.',
570
- inputSchema: {
571
- type: 'object',
572
- properties: {
573
- days: { type: 'number', description: 'Archive emails older than this many days' },
574
- targetMailbox: { type: 'string', description: 'Destination archive folder (e.g. Archive)' },
575
- sourceMailbox: { type: 'string', description: 'Source mailbox (default INBOX)' },
576
- dryRun: { type: 'boolean', description: 'If true, preview what would be moved without moving' }
577
- },
578
- required: ['days', 'targetMailbox']
579
- }
580
- },
581
- {
582
- name: 'get_storage_report',
583
- description: 'Estimate storage usage by size bucket and identify top senders by email size. Uses SEARCH LARGER queries for bucketing and samples large emails for sender analysis.',
584
- inputSchema: {
585
- type: 'object',
586
- properties: {
587
- mailbox: { type: 'string', description: 'Mailbox to analyze (default INBOX)' },
588
- sampleSize: { type: 'number', description: 'Max number of large emails to sample for sender analysis (default 100)' }
589
- }
590
- }
591
- },
592
- {
593
- name: 'get_thread',
594
- description: 'Find all emails in the same thread as a given email. Uses subject matching + References/In-Reply-To header filtering. Note: iCloud does not support server-side threading — results are approximate.',
595
- inputSchema: {
596
- type: 'object',
597
- properties: {
598
- uid: { type: 'number', description: 'Email UID to find the thread for' },
599
- mailbox: { type: 'string', description: 'Mailbox to search (default INBOX)' }
600
- },
601
- required: ['uid']
602
- }
603
- },
604
- // ── Saved Rules ──
605
- {
606
- name: 'create_rule',
607
- description: 'Create a saved rule that applies a specific action to emails matching a set of filters. Rules are stored persistently and can be run on demand or all at once with run_all_rules.',
608
- inputSchema: {
609
- type: 'object',
610
- properties: {
611
- name: { type: 'string', description: 'Unique rule name (used to run or delete the rule)' },
612
- description: { type: 'string', description: 'Optional human-readable description of what the rule does' },
613
- filters: {
614
- type: 'object',
615
- description: 'Email filters (same as bulk_move/bulk_delete filters: sender, domain, subject, before, since, unread, flagged, larger, smaller)',
616
- properties: filtersSchema
617
- },
618
- action: {
619
- type: 'object',
620
- description: 'Action to apply to matching emails',
621
- properties: {
622
- type: { type: 'string', enum: ['move', 'delete', 'mark_read', 'mark_unread', 'flag', 'unflag'], description: 'Action type' },
623
- targetMailbox: { type: 'string', description: 'Destination folder (required for move)' },
624
- sourceMailbox: { type: 'string', description: 'Source mailbox (default INBOX)' }
625
- },
626
- required: ['type']
627
- }
628
- },
629
- required: ['name', 'filters', 'action']
630
- }
631
- },
632
- {
633
- name: 'list_rules',
634
- description: 'List all saved rules with their filters, actions, and run history.',
635
- inputSchema: { type: 'object', properties: {} }
636
- },
637
- {
638
- name: 'run_rule',
639
- description: 'Run a specific saved rule by name. Use dryRun: true to preview what would be affected without making changes.',
640
- inputSchema: {
641
- type: 'object',
642
- properties: {
643
- name: { type: 'string', description: 'Rule name to run' },
644
- dryRun: { type: 'boolean', description: 'If true, preview what would be affected without making changes' }
645
- },
646
- required: ['name']
647
- }
648
- },
649
- {
650
- name: 'delete_rule',
651
- description: 'Delete a saved rule by name.',
652
- inputSchema: {
653
- type: 'object',
654
- properties: {
655
- name: { type: 'string', description: 'Rule name to delete' }
656
- },
657
- required: ['name']
658
- }
659
- },
660
- {
661
- name: 'run_all_rules',
662
- description: 'Run all saved rules in sequence. Use dryRun: true to preview all rules without making changes.',
663
- inputSchema: {
664
- type: 'object',
665
- properties: {
666
- dryRun: { type: 'boolean', description: 'If true, preview all rules without making changes' }
667
- }
668
- }
669
- },
670
- // ── SMTP / Email sending ──
671
- {
672
- name: 'compose_email',
673
- description: 'Compose and send a new email via iCloud SMTP. The From address is always your iCloud account. Supports plain text, HTML, or both (multipart/alternative).',
674
- inputSchema: {
675
- type: 'object',
676
- properties: {
677
- to: { type: 'string', description: 'Recipient email address(es), comma-separated or array' },
678
- subject: { type: 'string', description: 'Email subject' },
679
- body: { type: 'string', description: 'Plain text body (used as fallback when html is also provided)' },
680
- html: { type: 'string', description: 'HTML body. If provided without body, plain text is auto-generated. If provided with body, sends multipart/alternative.' },
681
- cc: { type: 'string', description: 'CC recipient(s), comma-separated or array' },
682
- bcc: { type: 'string', description: 'BCC recipient(s), comma-separated or array' },
683
- replyTo: { type: 'string', description: 'Reply-To address override' }
684
- },
685
- required: ['to', 'subject']
686
- }
687
- },
688
- {
689
- name: 'reply_to_email',
690
- description: 'Reply to an existing email. Automatically sets correct threading headers (In-Reply-To, References) and prefixes the subject with Re:. Supports plain text and/or HTML body.',
691
- inputSchema: {
692
- type: 'object',
693
- properties: {
694
- uid: { type: 'number', description: 'UID of the email to reply to' },
695
- body: { type: 'string', description: 'Plain text reply body' },
696
- html: { type: 'string', description: 'HTML reply body (auto-generates plain text fallback if body not provided)' },
697
- mailbox: { type: 'string', description: 'Mailbox containing the original email (default INBOX)' },
698
- replyAll: { type: 'boolean', description: 'If true, reply to all recipients (To + Cc). Default false.' },
699
- cc: { type: 'string', description: 'Additional CC recipients for this reply' }
700
- },
701
- required: ['uid']
702
- }
703
- },
704
- {
705
- name: 'forward_email',
706
- description: 'Forward an existing email to one or more recipients. Fetches the original email body and includes it as a forwarded message block. Supports plain text and/or HTML note.',
707
- inputSchema: {
708
- type: 'object',
709
- properties: {
710
- uid: { type: 'number', description: 'UID of the email to forward' },
711
- to: { type: 'string', description: 'Recipient(s) to forward to, comma-separated or array' },
712
- note: { type: 'string', description: 'Optional plain text note to prepend before the forwarded message' },
713
- html: { type: 'string', description: 'Optional HTML note to prepend (overrides plain text note for HTML rendering)' },
714
- mailbox: { type: 'string', description: 'Mailbox containing the original email (default INBOX)' },
715
- cc: { type: 'string', description: 'CC recipients' }
716
- },
717
- required: ['uid', 'to']
718
- }
719
- },
720
- {
721
- name: 'save_draft',
722
- description: 'Save a draft email to your iCloud Drafts folder without sending it. Supports plain text, HTML, or both. The draft can be edited and sent later from Mail.app or iCloud.com.',
723
- inputSchema: {
724
- type: 'object',
725
- properties: {
726
- to: { type: 'string', description: 'Intended recipient(s), comma-separated or array' },
727
- subject: { type: 'string', description: 'Email subject' },
728
- body: { type: 'string', description: 'Plain text body (used as fallback when html is also provided)' },
729
- html: { type: 'string', description: 'HTML body. If provided without body, plain text is auto-generated. If provided with body, saves multipart/alternative.' },
730
- cc: { type: 'string', description: 'CC recipient(s)' },
731
- bcc: { type: 'string', description: 'BCC recipient(s)' },
732
- inReplyTo: { type: 'string', description: 'Message-ID of the email being replied to — sets In-Reply-To header for threading' },
733
- references: { type: 'string', description: 'Space-separated Message-IDs for the References header — enables full thread linking' },
734
- account: { type: 'string', description: 'Account whose Drafts folder to save into (e.g. "umd", "personal", "alt"). Defaults to iCloud.' }
735
- },
736
- required: ['to', 'subject']
737
- }
738
- },
739
- // ── Digest State ──
740
- {
741
- name: 'get_digest_state',
742
- description: 'Get the current inbox digest state — last run timestamp, processed email UIDs (to skip on next run), pending actions, and per-sender skip counts for smart unsubscribe.',
743
- inputSchema: { type: 'object', properties: {} }
744
- },
745
- {
746
- name: 'update_digest_state',
747
- description: 'Update the digest state after a run. Merges new processed UIDs into the existing list, updates lastRun, replaces pendingActions, and accumulates per-sender skip counts.',
748
- inputSchema: {
749
- type: 'object',
750
- properties: {
751
- lastRun: { type: 'string', description: 'ISO timestamp of this run' },
752
- processedUids: { type: 'array', items: { type: 'number' }, description: 'Email UIDs processed in this run — merged with existing and capped at 5000' },
753
- pendingActions: { type: 'array', description: 'Full replacement list of pending action items to track across runs (deadlines, waiting-for-reply, etc.). Each item: { type, subject, to/from, dueDate?, notes? }' },
754
- skipCounts: { type: 'object', description: 'Map of sender address to skip count increment for this run, e.g. { "bestbuy@email.bestbuy.com": 3 }. Accumulated across runs for smart unsubscribe.' }
755
- }
756
- }
757
- },
758
- // ── CardDAV / Contacts ──
759
- {
760
- name: 'list_contacts',
761
- description: 'List contacts from iCloud Contacts. Returns names, phones, emails, and other fields.',
762
- inputSchema: {
763
- type: 'object',
764
- properties: {
765
- limit: { type: 'number', description: 'Max contacts to return (default 50)' },
766
- offset: { type: 'number', description: 'Skip this many contacts (default 0, for pagination)' }
767
- }
768
- }
769
- },
770
- {
771
- name: 'search_contacts',
772
- description: 'Search iCloud Contacts by name, email address, or phone number.',
773
- inputSchema: {
774
- type: 'object',
775
- properties: {
776
- query: { type: 'string', description: 'Text to search for (matched against name, email, and phone)' }
777
- },
778
- required: ['query']
779
- }
780
- },
781
- {
782
- name: 'get_contact',
783
- description: 'Get full details for a specific contact by ID. Use list_contacts or search_contacts to find a contactId.',
784
- inputSchema: {
785
- type: 'object',
786
- properties: {
787
- contactId: { type: 'string', description: 'Contact ID (UUID from list_contacts or search_contacts)' }
788
- },
789
- required: ['contactId']
790
- }
791
- },
792
- {
793
- name: 'create_contact',
794
- description: 'Create a new contact in iCloud Contacts.',
795
- inputSchema: {
796
- type: 'object',
797
- properties: {
798
- firstName: { type: 'string', description: 'First name' },
799
- lastName: { type: 'string', description: 'Last name' },
800
- fullName: { type: 'string', description: 'Full display name (overrides firstName + lastName for FN field)' },
801
- org: { type: 'string', description: 'Organization / company name' },
802
- phone: { type: 'string', description: 'Primary phone number (shorthand for phones array)' },
803
- email: { type: 'string', description: 'Primary email address (shorthand for emails array)' },
804
- phones: { type: 'array', description: 'Array of phone objects: [{ number, type }] where type is cell/home/work/etc.' },
805
- emails: { type: 'array', description: 'Array of email objects: [{ email, type }] where type is home/work/etc.' },
806
- addresses: { type: 'array', description: 'Array of address objects: [{ street, city, state, zip, country, type }]' },
807
- birthday: { type: 'string', description: 'Birthday in YYYY-MM-DD format' },
808
- note: { type: 'string', description: 'Notes / free text' },
809
- url: { type: 'string', description: 'Website URL' }
810
- }
811
- }
812
- },
813
- {
814
- name: 'update_contact',
815
- description: 'Update an existing contact in iCloud Contacts. Only provided fields are changed; others are preserved.',
816
- inputSchema: {
817
- type: 'object',
818
- properties: {
819
- contactId: { type: 'string', description: 'Contact ID to update' },
820
- firstName: { type: 'string' },
821
- lastName: { type: 'string' },
822
- fullName: { type: 'string' },
823
- org: { type: 'string' },
824
- phone: { type: 'string' },
825
- email: { type: 'string' },
826
- phones: { type: 'array' },
827
- emails: { type: 'array' },
828
- addresses: { type: 'array' },
829
- birthday: { type: 'string' },
830
- note: { type: 'string' },
831
- url: { type: 'string' }
832
- },
833
- required: ['contactId']
834
- }
835
- },
836
- {
837
- name: 'delete_contact',
838
- description: 'Delete a contact from iCloud Contacts permanently.',
839
- inputSchema: {
840
- type: 'object',
841
- properties: {
842
- contactId: { type: 'string', description: 'Contact ID to delete' }
843
- },
844
- required: ['contactId']
845
- }
846
- },
847
- // ── CalDAV / Calendar ──
848
- {
849
- name: 'list_calendars',
850
- description: 'List all calendars in iCloud Calendar (e.g. Personal, Work, LSAT PREP). Returns calendarId, name, and supported event types.',
851
- inputSchema: { type: 'object', properties: {} }
852
- },
853
- {
854
- name: 'list_events',
855
- description: 'List events in a specific iCloud calendar within a date range. Use list_calendars first to get a calendarId.',
856
- inputSchema: {
857
- type: 'object',
858
- properties: {
859
- calendarId: { type: 'string', description: 'Calendar ID from list_calendars' },
860
- since: { type: 'string', description: 'Start of range (YYYY-MM-DD, default: 30 days ago)' },
861
- before: { type: 'string', description: 'End of range (YYYY-MM-DD, default: 30 days ahead)' },
862
- limit: { type: 'number', description: 'Max events to return (default 50)' }
863
- },
864
- required: ['calendarId']
865
- }
866
- },
867
- {
868
- name: 'get_event',
869
- description: 'Get full details of a specific calendar event by its ID.',
870
- inputSchema: {
871
- type: 'object',
872
- properties: {
873
- calendarId: { type: 'string', description: 'Calendar ID containing the event' },
874
- eventId: { type: 'string', description: 'Event ID (UUID from list_events or search_events)' }
875
- },
876
- required: ['calendarId', 'eventId']
877
- }
878
- },
879
- {
880
- name: 'create_event',
881
- description: 'Create a new event in an iCloud calendar. For all-day events use allDay:true and YYYY-MM-DD for start/end.',
882
- inputSchema: {
883
- type: 'object',
884
- properties: {
885
- calendarId: { type: 'string', description: 'Calendar ID to add the event to' },
886
- summary: { type: 'string', description: 'Event title' },
887
- start: { type: 'string', description: 'Start date/time — ISO 8601 (e.g. 2026-03-15T10:00:00) or YYYY-MM-DD for all-day' },
888
- end: { type: 'string', description: 'End date/time — ISO 8601 or YYYY-MM-DD. Defaults to 1 hour after start.' },
889
- timezone: { type: 'string', description: 'IANA timezone (e.g. America/New_York). Use "UTC" or omit for UTC.' },
890
- allDay: { type: 'boolean', description: 'True for all-day event (uses DATE values, no time)' },
891
- description: { type: 'string', description: 'Event description / notes' },
892
- location: { type: 'string', description: 'Event location' },
893
- recurrence: { type: 'string', description: 'iCal RRULE string (e.g. FREQ=WEEKLY;BYDAY=MO,WE,FR)' },
894
- status: { type: 'string', description: 'Event status: CONFIRMED, TENTATIVE, or CANCELLED' },
895
- reminder: { type: 'number', description: 'Alert this many minutes before the event (default 30, set to 0 to disable)' }
896
- },
897
- required: ['calendarId', 'summary', 'start']
898
- }
899
- },
900
- {
901
- name: 'update_event',
902
- description: 'Update an existing calendar event. Only provided fields are changed; others are preserved.',
903
- inputSchema: {
904
- type: 'object',
905
- properties: {
906
- calendarId: { type: 'string', description: 'Calendar ID containing the event' },
907
- eventId: { type: 'string', description: 'Event ID to update' },
908
- summary: { type: 'string' },
909
- start: { type: 'string' },
910
- end: { type: 'string' },
911
- timezone: { type: 'string' },
912
- allDay: { type: 'boolean' },
913
- description: { type: 'string' },
914
- location: { type: 'string' },
915
- recurrence: { type: 'string' },
916
- status: { type: 'string' },
917
- reminder: { type: 'number', description: 'Alert minutes before event (0 to disable)' }
918
- },
919
- required: ['calendarId', 'eventId']
920
- }
921
- },
922
- {
923
- name: 'delete_event',
924
- description: 'Delete a calendar event permanently from iCloud Calendar.',
925
- inputSchema: {
926
- type: 'object',
927
- properties: {
928
- calendarId: { type: 'string', description: 'Calendar ID containing the event' },
929
- eventId: { type: 'string', description: 'Event ID to delete' }
930
- },
931
- required: ['calendarId', 'eventId']
932
- }
933
- },
934
- {
935
- name: 'search_events',
936
- description: 'Search for events by title/summary across all calendars within an optional date range.',
937
- inputSchema: {
938
- type: 'object',
939
- properties: {
940
- query: { type: 'string', description: 'Text to search for in event titles' },
941
- since: { type: 'string', description: 'Start of search range (YYYY-MM-DD, default: 1 year ago)' },
942
- before: { type: 'string', description: 'End of search range (YYYY-MM-DD, default: 1 year ahead)' }
943
- },
944
- required: ['query']
945
- }
946
- },
947
- // ── Smart extraction ──
948
- {
949
- name: 'suggest_event_from_email',
950
- description: 'Fetch an email and return its content formatted for calendar event extraction. After calling this tool, extract the event fields from the returned content (pay attention to _dateAnchor for resolving relative dates like "Tuesday"), present a summary to the user for confirmation, then call create_event. No API key required.',
951
- inputSchema: {
952
- type: 'object',
953
- properties: {
954
- uid: { type: 'number', description: 'Email UID to extract event from' },
955
- mailbox: { type: 'string', description: 'Mailbox containing the email (default INBOX)' }
956
- },
957
- required: ['uid']
958
- }
959
- }
960
- ]
92
+ tools: orderedTools,
961
93
  }));
962
94
 
963
95
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
964
96
  const { name, arguments: args } = request.params;
965
97
  try {
98
+ const ctx = { resolveCreds, resolveMailbox, accounts: ACCOUNTS };
99
+ const handlers = [handleMailTool, handleContactTool, handleCalendarTool, handleReminderTool];
966
100
  let result;
967
- // ── Account listing (no creds needed) ──
968
- if (name === 'list_accounts') {
969
- result = Object.entries(ACCOUNTS).map(([n, c]) => ({ name: n, host: c.host, smtpHost: c.smtpHost }));
970
- // ── Metadata tier (15s) ──
971
- } else if (name === 'get_inbox_summary') {
972
- const creds = resolveCreds(args.account);
973
- result = await withTimeout('get_inbox_summary', TIMEOUT.METADATA, () => getInboxSummary(args.mailbox || 'INBOX', creds));
974
- } else if (name === 'get_mailbox_summary') {
975
- const creds = resolveCreds(args.account);
976
- result = await withTimeout('get_mailbox_summary', TIMEOUT.METADATA, () => getMailboxSummary(resolveMailbox(args.mailbox, creds), creds));
977
- } else if (name === 'count_emails') {
978
- const { mailbox, account, ...filters } = args;
979
- const creds = resolveCreds(account);
980
- result = await withTimeout('count_emails', TIMEOUT.METADATA, () => countEmails(filters, mailbox || 'INBOX', creds));
981
- } else if (name === 'list_mailboxes') {
982
- const creds = resolveCreds(args.account);
983
- result = await withTimeout('list_mailboxes', TIMEOUT.METADATA, () => listMailboxes(creds));
984
- } else if (name === 'create_mailbox') {
985
- const creds = resolveCreds(args.account);
986
- result = await withTimeout('create_mailbox', TIMEOUT.METADATA, () => createMailbox(args.name, creds));
987
- } else if (name === 'rename_mailbox') {
988
- const creds = resolveCreds(args.account);
989
- result = await renameMailbox(args.oldName, args.newName, creds); // already has its own 15s timeout
990
- } else if (name === 'delete_mailbox') {
991
- const creds = resolveCreds(args.account);
992
- result = await deleteMailbox(args.name, creds); // already has its own 15s timeout
993
- // ── Fetch tier (30s) ──
994
- } else if (name === 'read_inbox') {
995
- const creds = resolveCreds(args.account);
996
- result = await withTimeout('read_inbox', TIMEOUT.FETCH, () => fetchEmails(args.mailbox || 'INBOX', args.limit || 10, args.onlyUnread || false, args.page || 1, creds));
997
- } else if (name === 'get_email') {
998
- const creds = resolveCreds(args.account);
999
- result = await withTimeout('get_email', TIMEOUT.FETCH, () => getEmailContent(args.uid, args.mailbox || 'INBOX', args.maxChars || 8000, args.includeHeaders || false, creds));
1000
- } else if (name === 'list_attachments') {
1001
- const creds = resolveCreds(args.account);
1002
- result = await withTimeout('list_attachments', TIMEOUT.FETCH, () => listAttachments(args.uid, args.mailbox || 'INBOX', creds));
1003
- } else if (name === 'get_attachment') {
1004
- const creds = resolveCreds(args.account);
1005
- result = await withTimeout('get_attachment', TIMEOUT.FETCH, () => getAttachment(args.uid, args.partId, args.mailbox || 'INBOX', args.offset ?? null, args.length ?? null, creds));
1006
- } else if (name === 'get_unsubscribe_info') {
1007
- const creds = resolveCreds(args.account);
1008
- result = await withTimeout('get_unsubscribe_info', TIMEOUT.FETCH, () => getUnsubscribeInfo(args.uid, args.mailbox || 'INBOX', creds));
1009
- } else if (name === 'get_email_raw') {
1010
- const creds = resolveCreds(args.account);
1011
- result = await withTimeout('get_email_raw', TIMEOUT.FETCH, () => getEmailRaw(args.uid, args.mailbox || 'INBOX', creds));
1012
- } else if (name === 'get_thread') {
1013
- const creds = resolveCreds(args.account);
1014
- result = await withTimeout('get_thread', TIMEOUT.FETCH, () => getThread(args.uid, args.mailbox || 'INBOX', creds));
1015
- } else if (name === 'search_emails') {
1016
- const { query, mailbox, limit, queryMode, subjectQuery, bodyQuery, fromQuery, includeSnippet, account, ...filters } = args;
1017
- const creds = resolveCreds(account);
1018
- result = await withTimeout('search_emails', TIMEOUT.FETCH, () => searchEmails(query, mailbox || 'INBOX', limit || 10, filters, { queryMode, subjectQuery, bodyQuery, fromQuery, includeSnippet }, creds));
1019
- } else if (name === 'get_emails_by_sender') {
1020
- const creds = resolveCreds(args.account);
1021
- result = await withTimeout('get_emails_by_sender', TIMEOUT.FETCH, () => getEmailsBySender(args.sender, args.mailbox || 'INBOX', args.limit || 10, creds));
1022
- } else if (name === 'get_emails_by_date_range') {
1023
- const creds = resolveCreds(args.account);
1024
- result = await withTimeout('get_emails_by_date_range', TIMEOUT.FETCH, () => getEmailsByDateRange(args.startDate, args.endDate, args.mailbox || 'INBOX', args.limit || 10, creds));
1025
- // ── Scan tier (60s) ──
1026
- } else if (name === 'get_top_senders') {
1027
- const creds = resolveCreds(args.account);
1028
- result = await withTimeout('get_top_senders', TIMEOUT.SCAN, () => getTopSenders(args.mailbox || 'INBOX', args.sampleSize || 500, args.maxResults || 20, creds));
1029
- } else if (name === 'get_unread_senders') {
1030
- const creds = resolveCreds(args.account);
1031
- result = await withTimeout('get_unread_senders', TIMEOUT.SCAN, () => getUnreadSenders(args.mailbox || 'INBOX', args.sampleSize || 500, args.maxResults || 20, creds));
1032
- } else if (name === 'get_storage_report') {
1033
- const creds = resolveCreds(args.account);
1034
- result = await withTimeout('get_storage_report', TIMEOUT.SCAN, () => getStorageReport(args.mailbox || 'INBOX', args.sampleSize || 100, creds));
1035
- // ── Bulk operation tier (60s) ──
1036
- } else if (name === 'bulk_delete_by_sender') {
1037
- const creds = resolveCreds(args.account);
1038
- result = await withTimeout('bulk_delete_by_sender', TIMEOUT.BULK_OP, () => bulkDeleteBySender(args.sender, args.mailbox || 'INBOX', creds));
1039
- } else if (name === 'bulk_delete_by_subject') {
1040
- const creds = resolveCreds(args.account);
1041
- result = await withTimeout('bulk_delete_by_subject', TIMEOUT.BULK_OP, () => bulkDeleteBySubject(args.subject, args.mailbox || 'INBOX', creds));
1042
- } else if (name === 'bulk_mark_read') {
1043
- const creds = resolveCreds(args.account);
1044
- result = await withTimeout('bulk_mark_read', TIMEOUT.BULK_OP, () => bulkMarkRead(args.mailbox || 'INBOX', args.sender || null, creds));
1045
- } else if (name === 'bulk_mark_unread') {
1046
- const creds = resolveCreds(args.account);
1047
- result = await withTimeout('bulk_mark_unread', TIMEOUT.BULK_OP, () => bulkMarkUnread(args.mailbox || 'INBOX', args.sender || null, creds));
1048
- } else if (name === 'bulk_flag') {
1049
- const { flagged, mailbox, account, ...filters } = args;
1050
- const creds = resolveCreds(account);
1051
- result = await withTimeout('bulk_flag', TIMEOUT.BULK_OP, () => bulkFlag(filters, flagged, mailbox || 'INBOX', creds));
1052
- } else if (name === 'mark_older_than_read') {
1053
- const creds = resolveCreds(args.account);
1054
- result = await withTimeout('mark_older_than_read', TIMEOUT.BULK_OP, () => markOlderThanRead(args.days, args.mailbox || 'INBOX', creds));
1055
- } else if (name === 'bulk_flag_by_sender') {
1056
- const creds = resolveCreds(args.account);
1057
- result = await withTimeout('bulk_flag_by_sender', TIMEOUT.BULK_OP, () => bulkFlagBySender(args.sender, args.flagged, args.mailbox || 'INBOX', creds));
1058
- } else if (name === 'delete_older_than') {
1059
- const creds = resolveCreds(args.account);
1060
- result = await withTimeout('delete_older_than', TIMEOUT.BULK_OP, () => deleteOlderThan(args.days, args.mailbox || 'INBOX', creds));
1061
- } else if (name === 'empty_trash') {
1062
- const creds = resolveCreds(args.account);
1063
- result = await withTimeout('empty_trash', TIMEOUT.BULK_OP, () => emptyTrash(args.dryRun || false, creds));
1064
- // ── No top-level timeout — chunked with internal timeouts ──
1065
- } else if (name === 'bulk_move') {
1066
- const { targetMailbox, sourceMailbox, dryRun, limit, account, ...filters } = args;
1067
- const creds = resolveCreds(account);
1068
- result = await bulkMove(filters, resolveMailbox(targetMailbox, creds), sourceMailbox || 'INBOX', dryRun || false, limit ?? null, creds);
1069
- } else if (name === 'bulk_move_by_sender') {
1070
- const creds = resolveCreds(args.account);
1071
- result = await bulkMoveBySender(args.sender, resolveMailbox(args.targetMailbox, creds), args.sourceMailbox || 'INBOX', args.dryRun || false, creds);
1072
- } else if (name === 'bulk_move_by_domain') {
1073
- const creds = resolveCreds(args.account);
1074
- result = await bulkMoveByDomain(args.domain, resolveMailbox(args.targetMailbox, creds), args.sourceMailbox || 'INBOX', args.dryRun || false, creds);
1075
- } else if (name === 'archive_older_than') {
1076
- const creds = resolveCreds(args.account);
1077
- result = await archiveOlderThan(args.days, resolveMailbox(args.targetMailbox, creds), args.sourceMailbox || 'INBOX', args.dryRun || false, creds);
1078
- } else if (name === 'bulk_delete') {
1079
- const { sourceMailbox, dryRun, account, ...filters } = args;
1080
- const creds = resolveCreds(account);
1081
- result = await bulkDelete(filters, sourceMailbox || 'INBOX', dryRun || false, creds);
1082
- // ── Single-email tier (15s) ──
1083
- } else if (name === 'flag_email') {
1084
- const creds = resolveCreds(args.account);
1085
- result = await withTimeout('flag_email', TIMEOUT.SINGLE, () => flagEmail(args.uid, args.flagged, args.mailbox || 'INBOX', creds));
1086
- } else if (name === 'mark_as_read') {
1087
- const creds = resolveCreds(args.account);
1088
- result = await withTimeout('mark_as_read', TIMEOUT.SINGLE, () => markAsRead(args.uid, args.seen, args.mailbox || 'INBOX', creds));
1089
- } else if (name === 'delete_email') {
1090
- const creds = resolveCreds(args.account);
1091
- result = await withTimeout('delete_email', TIMEOUT.SINGLE, () => deleteEmail(args.uid, args.mailbox || 'INBOX', creds));
1092
- } else if (name === 'move_email') {
1093
- const creds = resolveCreds(args.account);
1094
- result = await withTimeout('move_email', TIMEOUT.SINGLE, () => moveEmail(args.uid, resolveMailbox(args.targetMailbox, creds), args.sourceMailbox || 'INBOX', creds));
1095
- // ── Move status (synchronous, no timeout needed) ──
1096
- } else if (name === 'get_move_status') {
1097
- result = getMoveStatus();
1098
- } else if (name === 'abandon_move') {
1099
- result = abandonMove();
1100
- // ── Session log (synchronous, no timeout needed) ──
1101
- } else if (name === 'log_write') {
1102
- result = logWrite(args.step);
1103
- } else if (name === 'log_read') {
1104
- result = logRead();
1105
- } else if (name === 'log_clear') {
1106
- result = logClear();
1107
- // ── Digest state (synchronous, no timeout needed) ──
1108
- } else if (name === 'get_digest_state') {
1109
- result = getDigestState();
1110
- } else if (name === 'update_digest_state') {
1111
- result = updateDigestState({
1112
- lastRun: args.lastRun,
1113
- processedUids: args.processedUids,
1114
- pendingActions: args.pendingActions,
1115
- skipCounts: args.skipCounts
1116
- });
1117
- // ── Saved rules (synchronous CRUD; run_rule/run_all_rules use internal chunk timeouts) ──
1118
- } else if (name === 'create_rule') {
1119
- result = createRule(args.name, args.filters || {}, args.action, args.description || '');
1120
- } else if (name === 'list_rules') {
1121
- result = listRules();
1122
- } else if (name === 'delete_rule') {
1123
- result = deleteRule(args.name);
1124
- } else if (name === 'run_rule') {
1125
- result = await runRule(args.name, args.dryRun || false);
1126
- } else if (name === 'run_all_rules') {
1127
- result = await runAllRules(args.dryRun || false);
1128
- // ── SMTP (email sending — uses SCAN tier 60s for two-phase fetch+send) ──
1129
- } else if (name === 'compose_email') {
1130
- const creds = resolveCreds(args.account);
1131
- result = await withTimeout('compose_email', TIMEOUT.SCAN, () =>
1132
- composeEmail(args.to, args.subject, args.body, { html: args.html, cc: args.cc, bcc: args.bcc, replyTo: args.replyTo }, creds)
1133
- );
1134
- } else if (name === 'reply_to_email') {
1135
- const creds = resolveCreds(args.account);
1136
- const origEmail = await withTimeout('get_email_for_reply', TIMEOUT.FETCH, () =>
1137
- getEmailContent(args.uid, args.mailbox || 'INBOX', 5000, true, creds)
1138
- );
1139
- result = await withTimeout('reply_to_email', TIMEOUT.FETCH, () =>
1140
- replyToEmail(origEmail, args.body, { html: args.html, replyAll: args.replyAll || false, cc: args.cc }, creds)
1141
- );
1142
- } else if (name === 'forward_email') {
1143
- const creds = resolveCreds(args.account);
1144
- const origEmail = await withTimeout('get_email_for_forward', TIMEOUT.FETCH, () =>
1145
- getEmailContent(args.uid, args.mailbox || 'INBOX', 5000, false, creds)
1146
- );
1147
- result = await withTimeout('forward_email', TIMEOUT.FETCH, () =>
1148
- forwardEmail(origEmail, args.to, args.note || '', { html: args.html, cc: args.cc }, creds)
1149
- );
1150
- } else if (name === 'save_draft') {
1151
- const creds = resolveCreds(args.account);
1152
- result = await withTimeout('save_draft', TIMEOUT.FETCH, () =>
1153
- saveDraft(args.to, args.subject, args.body, { html: args.html, cc: args.cc, bcc: args.bcc, inReplyTo: args.inReplyTo, references: args.references }, creds)
1154
- );
1155
- // ── CardDAV / Contacts (FETCH tier 30s) ──
1156
- } else if (name === 'list_contacts') {
1157
- result = await withTimeout('list_contacts', TIMEOUT.FETCH, () =>
1158
- listContacts(args.limit || 50, args.offset || 0)
1159
- );
1160
- } else if (name === 'search_contacts') {
1161
- result = await withTimeout('search_contacts', TIMEOUT.FETCH, () =>
1162
- searchContacts(args.query)
1163
- );
1164
- } else if (name === 'get_contact') {
1165
- result = await withTimeout('get_contact', TIMEOUT.FETCH, () =>
1166
- getContact(args.contactId)
1167
- );
1168
- } else if (name === 'create_contact') {
1169
- const { contactId: _ignore, ...fields } = args;
1170
- result = await withTimeout('create_contact', TIMEOUT.FETCH, () =>
1171
- createContact(fields)
1172
- );
1173
- } else if (name === 'update_contact') {
1174
- const { contactId, ...fields } = args;
1175
- result = await withTimeout('update_contact', TIMEOUT.FETCH, () =>
1176
- updateContact(contactId, fields)
1177
- );
1178
- } else if (name === 'delete_contact') {
1179
- result = await withTimeout('delete_contact', TIMEOUT.SINGLE, () =>
1180
- deleteContact(args.contactId)
1181
- );
1182
- // ── CalDAV / Calendar (FETCH tier 30s) ──
1183
- } else if (name === 'list_calendars') {
1184
- result = await withTimeout('list_calendars', TIMEOUT.FETCH, () =>
1185
- listCalendars()
1186
- );
1187
- } else if (name === 'list_events') {
1188
- result = await withTimeout('list_events', TIMEOUT.FETCH, () =>
1189
- listEvents(args.calendarId, args.since || null, args.before || null, args.limit || 50)
1190
- );
1191
- } else if (name === 'get_event') {
1192
- result = await withTimeout('get_event', TIMEOUT.FETCH, () =>
1193
- getEvent(args.calendarId, args.eventId)
1194
- );
1195
- } else if (name === 'create_event') {
1196
- const { calendarId, ...fields } = args;
1197
- result = await withTimeout('create_event', TIMEOUT.FETCH, () =>
1198
- createEvent(calendarId, fields)
1199
- );
1200
- } else if (name === 'update_event') {
1201
- const { calendarId, eventId, ...fields } = args;
1202
- result = await withTimeout('update_event', TIMEOUT.FETCH, () =>
1203
- updateEvent(calendarId, eventId, fields)
1204
- );
1205
- } else if (name === 'delete_event') {
1206
- result = await withTimeout('delete_event', TIMEOUT.SINGLE, () =>
1207
- deleteEvent(args.calendarId, args.eventId)
1208
- );
1209
- } else if (name === 'search_events') {
1210
- result = await withTimeout('search_events', TIMEOUT.FETCH, () =>
1211
- searchEvents(args.query, args.since || null, args.before || null)
1212
- );
1213
- // ── Smart extraction (SCAN tier 60s — LLM round-trip) ──
1214
- } else if (name === 'suggest_event_from_email') {
1215
- const creds = resolveCreds(args.account);
1216
- const email = await withTimeout('get_email_for_extraction', TIMEOUT.FETCH, () =>
1217
- getEmailContent(args.uid, resolveMailbox(args.mailbox || 'INBOX', creds), 10000, false, creds)
1218
- );
1219
- result = formatEmailForExtraction(email);
1220
- } else {
1221
- throw new Error(`Unknown tool: ${name}`);
101
+ let handled = false;
102
+ for (const handle of handlers) {
103
+ const out = await handle(name, args, ctx);
104
+ if (out !== undefined) {
105
+ result = out;
106
+ handled = true;
107
+ break;
108
+ }
1222
109
  }
110
+ if (!handled) throw new Error(`Unknown tool: ${name}`);
1223
111
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
1224
112
  } catch (error) {
1225
113
  process.stderr.write(`[tool-error] ${name}: ${error.responseText ?? error.message}\n`);