icloud-mcp 2.0.0 → 2.3.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,1980 +1,45 @@
1
1
  #!/usr/bin/env node
2
- import { ImapFlow } from 'imapflow';
3
2
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
4
3
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
4
  import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
6
- import { readFileSync, writeFileSync, existsSync } from 'fs';
7
- import { homedir } from 'os';
8
- import { join } from 'path';
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 { formatEmailForExtraction } from './lib/event-extractor.js';
24
+ import { listCalendars, listEvents, getEvent, createEvent, updateEvent, deleteEvent, searchEvents } from './lib/caldav.js';
9
25
 
10
- const LOG_FILE = join(homedir(), '.icloud-mcp-session.json');
11
- const MANIFEST_FILE = join(homedir(), '.icloud-mcp-move-manifest.json');
12
- const MAX_HISTORY = 5;
13
-
14
- const IMAP_USER = process.env.IMAP_USER;
15
- const IMAP_PASSWORD = process.env.IMAP_PASSWORD;
16
-
17
- if (!IMAP_USER || !IMAP_PASSWORD) {
18
- if (process.argv.includes('--doctor')) {
19
- // Doctor will handle missing credentials with friendly output
20
- } else {
21
- process.stderr.write('Error: IMAP_USER and IMAP_PASSWORD environment variables are required\n');
22
- process.exit(1);
23
- }
24
- }
25
-
26
- // ─── IMPROVEMENT 1: Connection-level timeout on createClient ──────────────────
27
- // ImapFlow supports connectionTimeout and greetingTimeout options.
28
- // This ensures we don't hang forever waiting for iCloud to respond.
29
-
30
- function createClient() {
31
- return new ImapFlow({
32
- host: 'imap.mail.me.com',
33
- port: 993,
34
- secure: true,
35
- auth: { user: IMAP_USER, pass: IMAP_PASSWORD },
36
- logger: false,
37
- connectionTimeout: 15_000, // 15s to establish TCP+TLS connection
38
- greetingTimeout: 15_000, // 15s to receive IMAP greeting after connect
39
- socketTimeout: 60_000, // 60s of inactivity before socket is killed
40
- });
41
- }
42
-
43
- // ─── Managed client helpers ───────────────────────────────────────────────────
44
-
45
- // Rate limit: space out connection initiations within a single server process
46
- // to avoid triggering iCloud's connection throttle under concurrent tool calls.
47
- // Wraps connect() on every client returned by createClient() so the gate
48
- // applies regardless of whether tools use openClient() or createClient() directly.
49
- // Uses a serialized gate — concurrent callers queue up; each waits 200ms after
50
- // the previous before initiating its connection. Connections run concurrently
51
- // after passing the gate.
52
- let _lastConnectTime = 0;
53
- let _connectGate = Promise.resolve();
54
- const MIN_CONNECT_INTERVAL = 10; // ms between connection initiations
55
-
56
- function createRateLimitedClient() {
57
- const client = createClient();
58
- const originalConnect = client.connect.bind(client);
59
- client.connect = async () => {
60
- await new Promise(resolve => {
61
- _connectGate = _connectGate.then(async () => {
62
- const wait = MIN_CONNECT_INTERVAL - (Date.now() - _lastConnectTime);
63
- if (wait > 0) await new Promise(r => setTimeout(r, wait));
64
- _lastConnectTime = Date.now();
65
- }).then(resolve, resolve);
66
- });
67
- return originalConnect();
68
- };
69
- return client;
70
- }
71
-
72
- async function openClient(mailbox) {
73
- const client = createRateLimitedClient();
74
- await client.connect();
75
- if (mailbox) await client.mailboxOpen(mailbox);
76
- return client;
77
- }
78
-
79
- async function safeClose(client) {
80
- try { await client.logout(); } catch { try { client.close(); } catch { /* already gone */ } }
81
- }
82
-
83
- async function reconnect(client, mailbox) {
84
- safeClose(client);
85
- return openClient(mailbox);
86
- }
87
-
88
- // ─── Move Manifest ────────────────────────────────────────────────────────────
89
-
90
- const CHUNK_SIZE = 500;
91
- const CHUNK_SIZE_RETRY = 100;
92
- const ATTACHMENT_SCAN_LIMIT = 500; // max UIDs to scan client-side for hasAttachment filter
93
- const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024; // 20 MB cap for get_attachment downloads
94
-
95
- function readManifest() {
96
- if (!existsSync(MANIFEST_FILE)) return { current: null, history: [] };
97
- try {
98
- return JSON.parse(readFileSync(MANIFEST_FILE, 'utf8'));
99
- } catch {
100
- return { current: null, history: [] };
101
- }
102
- }
103
-
104
- function writeManifest(data) {
105
- writeFileSync(MANIFEST_FILE, JSON.stringify(data, null, 2));
106
- }
107
-
108
- function updateManifest(updater) {
109
- const data = readManifest();
110
- if (!data.current) return data; // guard: operation already archived/failed
111
- updater(data);
112
- if (!data.current) return data; // guard: updater may have archived it
113
- data.current.updatedAt = new Date().toISOString();
114
- writeManifest(data);
115
- return data;
116
- }
117
-
118
- function archiveCurrent(data) {
119
- if (data.current) {
120
- data.history.unshift(data.current);
121
- if (data.history.length > MAX_HISTORY) data.history = data.history.slice(0, MAX_HISTORY);
122
- data.current = null;
123
- }
124
- }
125
-
126
- function getMoveStatus() {
127
- const data = readManifest();
128
- if (!data.current) return { status: 'no_operation', history: data.history.map(summarizeOp) };
129
-
130
- const result = {
131
- current: formatOperation(data.current),
132
- history: data.history.map(summarizeOp)
133
- };
134
-
135
- // Stale warning: in_progress but updatedAt is more than 24h ago
136
- if (data.current.status === 'in_progress') {
137
- const ageMs = Date.now() - new Date(data.current.updatedAt).getTime();
138
- if (ageMs > 24 * 60 * 60 * 1000) {
139
- result.staleWarning = `Operation ${data.current.operationId} has not been updated in ${Math.round(ageMs / 3_600_000)}h — it may be stale. Call abandon_move to discard it if you want to start a new operation.`;
140
- }
141
- }
142
-
143
- return result;
144
- }
145
-
146
- function abandonMove() {
147
- const data = readManifest();
148
- if (!data.current) return { abandoned: false, message: 'No in-progress operation to abandon' };
149
- if (data.current.status !== 'in_progress') {
150
- return { abandoned: false, message: `Current operation is already '${data.current.status}', nothing to abandon` };
151
- }
152
- const operationId = data.current.operationId;
153
- data.current.status = 'abandoned';
154
- data.current.updatedAt = new Date().toISOString();
155
- archiveCurrent(data);
156
- writeManifest(data);
157
- return { abandoned: true, operationId };
158
- }
159
-
160
- function startOperation(source, target, uids) {
161
- const data = readManifest();
162
-
163
- if (data.current && data.current.status === 'in_progress') {
164
- const op = data.current;
165
- throw new Error(
166
- `Incomplete move operation detected (${op.operationId}): ` +
167
- `${op.summary.emailsMoved} of ${op.totalUids} emails moved from '${op.source}' to '${op.target}' ` +
168
- `started at ${op.startedAt}. ` +
169
- `Call abandon_move to discard it or get_move_status to inspect it before starting a new operation.`
170
- );
171
- }
172
-
173
- archiveCurrent(data);
174
-
175
- const operationId = `move_${Date.now()}`;
176
- const chunks = [];
177
-
178
- for (let i = 0; i < uids.length; i += CHUNK_SIZE) {
179
- chunks.push({
180
- index: chunks.length,
181
- uids: uids.slice(i, i + CHUNK_SIZE),
182
- fingerprints: [],
183
- status: 'pending',
184
- copiedAt: null,
185
- verifiedAt: null,
186
- deletedAt: null,
187
- failureReason: null
188
- });
189
- }
190
-
191
- data.current = {
192
- operationId,
193
- startedAt: new Date().toISOString(),
194
- updatedAt: new Date().toISOString(),
195
- source,
196
- target,
197
- totalUids: uids.length,
198
- status: 'in_progress',
199
- phase: 'copying',
200
- verifiedAt: null,
201
- deletedAt: null,
202
- allFingerprints: null,
203
- chunks,
204
- summary: {
205
- chunksComplete: 0,
206
- emailsMoved: 0,
207
- emailsPending: uids.length,
208
- emailsFailed: 0
209
- }
210
- };
211
-
212
- writeManifest(data);
213
- return data.current;
214
- }
215
-
216
- function updateChunk(index, updates) {
217
- updateManifest((data) => {
218
- if (!data.current) return; // guard: operation already archived
219
- const chunk = data.current.chunks[index];
220
- if (!chunk) return; // guard: chunk index out of range
221
- Object.assign(chunk, updates);
222
-
223
- let moved = 0, failed = 0, pending = 0;
224
- for (const c of data.current.chunks) {
225
- if (c.status === 'complete') moved += c.uids.length;
226
- else if (c.status === 'failed') failed += c.uids.length;
227
- else pending += c.uids.length;
228
- }
229
- data.current.summary = {
230
- chunksComplete: data.current.chunks.filter(c => c.status === 'complete').length,
231
- emailsMoved: moved,
232
- emailsPending: pending,
233
- emailsFailed: failed
234
- };
235
- });
236
- }
237
-
238
- function updateOperationPhase(phase, extraFields = {}) {
239
- updateManifest((data) => {
240
- if (!data.current) return;
241
- data.current.phase = phase;
242
- Object.assign(data.current, extraFields);
243
- });
244
- }
245
-
246
- function completeOperation() {
247
- const data = readManifest();
248
- if (!data.current) return;
249
- data.current.status = 'complete';
250
- data.current.updatedAt = new Date().toISOString();
251
- archiveCurrent(data);
252
- writeManifest(data);
253
- }
254
-
255
- function failOperation(reason) {
256
- const data = readManifest();
257
- if (!data.current) return;
258
- data.current.status = 'failed';
259
- data.current.failureReason = reason;
260
- data.current.updatedAt = new Date().toISOString();
261
- archiveCurrent(data);
262
- writeManifest(data);
263
- }
264
-
265
- function formatOperation(op) {
266
- return {
267
- operationId: op.operationId,
268
- status: op.status,
269
- phase: op.phase ?? null,
270
- source: op.source,
271
- target: op.target,
272
- startedAt: op.startedAt,
273
- updatedAt: op.updatedAt,
274
- verifiedAt: op.verifiedAt ?? null,
275
- deletedAt: op.deletedAt ?? null,
276
- summary: op.summary,
277
- failedChunks: op.chunks.filter(c => c.status === 'failed').map(c => ({
278
- index: c.index,
279
- uids: c.uids.length,
280
- reason: c.failureReason
281
- }))
282
- };
283
- }
284
-
285
- function summarizeOp(op) {
286
- return {
287
- operationId: op.operationId,
288
- status: op.status,
289
- source: op.source,
290
- target: op.target,
291
- startedAt: op.startedAt,
292
- moved: op.summary.emailsMoved,
293
- failed: op.summary.emailsFailed,
294
- total: op.totalUids
295
- };
296
- }
297
-
298
- // ─── Fingerprinting ───────────────────────────────────────────────────────────
299
-
300
- function buildFingerprint(msg) {
301
- const messageId = msg.envelope?.messageId ?? null;
302
- const sender = msg.envelope?.from?.[0]?.address ?? '';
303
- const date = msg.envelope?.date ? new Date(msg.envelope.date).toISOString() : '';
304
- const subject = msg.envelope?.subject ?? '';
305
- const fallback = [sender, date, subject].join('|');
306
- return { uid: msg.uid, messageId, fallback };
307
- }
308
-
309
- function fingerprintToKey(fp) {
310
- return fp.messageId ?? fp.fallback;
311
- }
312
-
313
- // ─── Transient error detection ────────────────────────────────────────────────
314
-
315
- function isTransient(err) {
316
- const msg = err.message ?? '';
317
- return msg.includes('ECONNRESET') ||
318
- msg.includes('ECONNREFUSED') ||
319
- msg.includes('ETIMEDOUT') ||
320
- msg.includes('EPIPE') ||
321
- msg.includes('socket hang up') ||
322
- msg.includes('Connection not available') ||
323
- msg.includes('BAD') ||
324
- msg.includes('NO ');
325
- }
326
-
327
- async function withRetry(label, fn, maxAttempts = 3) {
328
- let lastErr;
329
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
330
- try {
331
- return await fn();
332
- } catch (err) {
333
- lastErr = err;
334
- if (!isTransient(err) || attempt === maxAttempts) throw err;
335
- const delay = attempt * 2000;
336
- process.stderr.write(`[retry] ${label} failed (attempt ${attempt}/${maxAttempts}): ${err.message} — retrying in ${delay}ms\n`);
337
- await new Promise(r => setTimeout(r, delay));
338
- }
339
- }
340
- throw lastErr;
341
- }
342
-
343
- // ─── Per-operation timeouts ───────────────────────────────────────────────────
344
-
345
- const COPY_CHUNK_DELAY_MS = 500; // ms between COPY chunks — mitigates iCloud copy throttling
346
-
347
- const TIMEOUT = {
348
- METADATA: 15_000,
349
- FETCH: 30_000,
350
- SCAN: 60_000,
351
- BULK_OP: 60_000,
352
- CHUNK: 300_000,
353
- SINGLE: 15_000,
354
- VERIFY_ALL: 120_000, // full envelope scan for all N emails
355
- DELETE_ALL: 600_000, // flag all + single UID EXPUNGE (measured up to 521s at 5k)
356
- };
357
-
358
- function withTimeout(label, ms, fn) {
359
- let timer;
360
- return Promise.race([
361
- fn().finally(() => clearTimeout(timer)),
362
- new Promise((_, reject) => {
363
- timer = setTimeout(() => {
364
- process.stderr.write(`[timeout] ${label} timed out after ${ms / 1000}s\n`);
365
- reject(new Error(`${label} timed out after ${ms / 1000}s`));
366
- }, ms);
367
- })
368
- ]);
369
- }
370
-
371
- // ─── Move logging ─────────────────────────────────────────────────────────────
372
-
373
- function elapsed(startMs) {
374
- return ((Date.now() - startMs) / 1000).toFixed(1) + 's';
375
- }
376
-
377
- function moveLog(chunkIndex, msg) {
378
- process.stderr.write(`[move] chunk ${chunkIndex}: ${msg}\n`);
379
- }
380
-
381
- // ─── Verification (v3: envelope scan first, Message-ID fallback) ──────────────
382
- // Strategy: one bulk FETCH of recent envelopes in the target is far faster than
383
- // N individual SEARCH commands. We use envelope scan as the primary check, then
384
- // only fall back to per-email Message-ID SEARCH for the few that didn't match
385
- // (which can happen if the envelope fingerprint differs slightly between source
386
- // and target, e.g. date normalization).
387
-
388
- async function verifyByEnvelopeScan(client, fingerprints, chunkIndex, knownTotal = null) {
389
- if (fingerprints.length === 0) return { missing: [], found: 0 };
390
-
391
- const t0 = Date.now();
392
- const total = knownTotal ?? (await client.status(client.mailbox.path, { messages: true })).messages;
393
- const fetchCount = Math.min(total, fingerprints.length + 150);
394
- const start = Math.max(1, total - fetchCount + 1);
395
- const range = `${start}:${total}`;
396
-
397
- const targetKeys = new Set();
398
- let scanned = 0;
399
- for await (const msg of client.fetch(range, { envelope: true })) {
400
- const fp = buildFingerprint(msg);
401
- targetKeys.add(fingerprintToKey(fp));
402
- scanned++;
403
- }
404
-
405
- const missing = [];
406
- for (const fp of fingerprints) {
407
- if (!targetKeys.has(fingerprintToKey(fp))) missing.push(fp);
408
- }
409
-
410
- moveLog(chunkIndex, `envelope scan: ${scanned} scanned, ${fingerprints.length - missing.length}/${fingerprints.length} matched (${elapsed(t0)})`);
411
- return { missing, found: fingerprints.length - missing.length };
412
- }
413
-
414
- async function verifyByMessageId(client, fingerprints, chunkIndex) {
415
- if (fingerprints.length === 0) return { missing: [], verified: 0 };
416
-
417
- const t0 = Date.now();
418
- const missing = [];
419
- let verified = 0;
420
-
421
- for (const fp of fingerprints) {
422
- if (!fp.messageId) {
423
- // No Message-ID — can't verify this way, count as missing
424
- missing.push(fp);
425
- continue;
426
- }
427
- const uids = (await client.search({ header: ['Message-ID', fp.messageId] }, { uid: true })) ?? [];
428
- if (uids.length === 0) {
429
- missing.push(fp);
430
- } else {
431
- verified++;
432
- }
433
- // Progress logging every 25 emails
434
- const checked = verified + missing.length;
435
- if (checked % 25 === 0) {
436
- moveLog(chunkIndex, `Message-ID fallback: ${checked}/${fingerprints.length} checked (${verified} found, ${missing.length} missing, ${elapsed(t0)})`);
437
- }
438
- }
439
-
440
- moveLog(chunkIndex, `Message-ID fallback: ${verified}/${fingerprints.length} verified, ${missing.length} still missing (${elapsed(t0)})`);
441
- return { missing, verified };
442
- }
443
-
444
- async function verifyInTarget(targetClient, fingerprints, chunkIndex, knownTotal = null) {
445
- // Primary: fast envelope scan (one FETCH command)
446
- const { missing: afterScan } = await verifyByEnvelopeScan(targetClient, fingerprints, chunkIndex, knownTotal);
447
-
448
- if (afterScan.length === 0) {
449
- return { verified: true, missing: [], found: fingerprints.length, expected: fingerprints.length };
450
- }
451
-
452
- if (afterScan.length > 200) {
453
- moveLog(chunkIndex, `${afterScan.length} unmatched after envelope scan — too many for Message-ID fallback, treating as failed`);
454
- return { verified: false, missing: afterScan, found: fingerprints.length - afterScan.length, expected: fingerprints.length };
455
- }
456
-
457
- // Secondary: Message-ID search only for the ones envelope scan missed
458
- moveLog(chunkIndex, `${afterScan.length} unmatched after envelope scan — trying Message-ID search`);
459
- const withMessageId = afterScan.filter(fp => fp.messageId);
460
- const noMessageId = afterScan.filter(fp => !fp.messageId);
461
-
462
- if (withMessageId.length > 0) {
463
- const { missing: stillMissing } = await verifyByMessageId(targetClient, withMessageId, chunkIndex);
464
- const allMissing = [...stillMissing, ...noMessageId];
465
- return {
466
- verified: allMissing.length === 0,
467
- missing: allMissing,
468
- found: fingerprints.length - allMissing.length,
469
- expected: fingerprints.length
470
- };
471
- }
472
-
473
- // No Message-IDs to try — whatever envelope scan missed is truly missing
474
- return {
475
- verified: noMessageId.length === 0,
476
- missing: noMessageId,
477
- found: fingerprints.length - noMessageId.length,
478
- expected: fingerprints.length
479
- };
480
- }
481
-
482
- // ─── Option B phase helpers ────────────────────────────────────────────────────
483
-
484
- // Phase 1: Copy all chunks to target without deleting.
485
- // Returns { success, totalCopied, srcClient, errorResult }
486
- async function copyAllChunks(operation, srcClient, targetMailbox, sourceMailbox) {
487
- let totalCopied = 0;
488
-
489
- for (const chunk of operation.chunks) {
490
- const chunkUids = chunk.uids;
491
- const chunkStart = Date.now();
492
- moveLog(chunk.index, `starting copy (${chunkUids.length} emails)`);
493
-
494
- try {
495
- await withTimeout(`copy chunk ${chunk.index}`, TIMEOUT.CHUNK, async () => {
496
- // Step 1: fetch envelopes → fingerprints
497
- let t = Date.now();
498
- const envelopes = [];
499
- try {
500
- for await (const msg of srcClient.fetch(chunkUids, { envelope: true }, { uid: true })) {
501
- envelopes.push(msg);
502
- }
503
- } catch (err) {
504
- if (!isTransient(err)) throw err;
505
- moveLog(chunk.index, `fetch envelopes failed (${err.message}), reconnecting...`);
506
- srcClient = await reconnect(srcClient, sourceMailbox);
507
- for await (const msg of srcClient.fetch(chunkUids, { envelope: true }, { uid: true })) {
508
- envelopes.push(msg);
509
- }
510
- }
511
- const fingerprints = envelopes.map(buildFingerprint);
512
- const withMsgId = fingerprints.filter(fp => fp.messageId).length;
513
- moveLog(chunk.index, `fetched ${envelopes.length} envelopes (${withMsgId} with Message-ID) (${elapsed(t)})`);
514
-
515
- // Update in-memory chunk so verifyAllChunks can flatMap fingerprints later
516
- chunk.fingerprints = fingerprints;
517
- updateManifest((data) => {
518
- if (!data.current) return;
519
- const c = data.current.chunks[chunk.index];
520
- if (!c) return;
521
- c.fingerprints = fingerprints;
522
- });
523
-
524
- // Step 2: copy to target
525
- t = Date.now();
526
- try {
527
- await srcClient.messageCopy(chunkUids, targetMailbox, { uid: true });
528
- } catch (err) {
529
- if (!isTransient(err)) throw err;
530
- moveLog(chunk.index, `copy failed (${err.message}), reconnecting...`);
531
- srcClient = await reconnect(srcClient, sourceMailbox);
532
- await srcClient.messageCopy(chunkUids, targetMailbox, { uid: true });
533
- }
534
- moveLog(chunk.index, `copied ${chunkUids.length} emails to target (${elapsed(t)})`);
535
- updateChunk(chunk.index, { status: 'copied_not_verified', copiedAt: new Date().toISOString() });
536
- });
537
-
538
- totalCopied += chunkUids.length;
539
- moveLog(chunk.index, `copy complete (${elapsed(chunkStart)})`);
540
- } catch (err) {
541
- moveLog(chunk.index, `copy FAILED: ${err.message}`);
542
- updateChunk(chunk.index, { status: 'failed', failureReason: err.message });
543
- return {
544
- success: false,
545
- totalCopied,
546
- srcClient,
547
- errorResult: {
548
- status: 'partial',
549
- moved: 0,
550
- failed: operation.totalUids,
551
- message: `Copy failed on chunk ${chunk.index}: ${err.message}. No emails deleted from source. ${totalCopied} emails were copied to target but not verified — call get_move_status for details.`
552
- }
553
- };
554
- }
555
-
556
- // Delay between chunks to mitigate iCloud copy throttling
557
- if (chunk.index < operation.chunks.length - 1) {
558
- await new Promise(r => setTimeout(r, COPY_CHUNK_DELAY_MS));
559
- }
560
- }
561
-
562
- return { success: true, totalCopied, srcClient, errorResult: null };
563
- }
564
-
565
- // Phase 2: Verify all copied emails are present in target.
566
- // Returns { verification, tgtClient }
567
- async function verifyAllChunks(tgtClient, operation, targetMailbox) {
568
- const allFingerprints = operation.chunks.flatMap(c => c.fingerprints);
569
-
570
- updateManifest((data) => {
571
- if (!data.current) return;
572
- data.current.allFingerprints = allFingerprints;
573
- });
574
-
575
- let tgtMb;
576
- try {
577
- tgtMb = await tgtClient.mailboxOpen(targetMailbox);
578
- } catch (err) {
579
- if (!isTransient(err)) throw err;
580
- moveLog('global', `mailboxOpen failed (${err.message}), reconnecting...`);
581
- tgtClient = await reconnect(tgtClient, targetMailbox);
582
- tgtMb = await tgtClient.mailboxOpen(targetMailbox);
583
- }
584
-
585
- let verification;
586
- try {
587
- verification = await verifyInTarget(tgtClient, allFingerprints, 'global', tgtMb.exists);
588
- } catch (err) {
589
- if (!isTransient(err)) throw err;
590
- moveLog('global', `verify failed (${err.message}), reconnecting...`);
591
- tgtClient = await reconnect(tgtClient, targetMailbox);
592
- verification = await verifyInTarget(tgtClient, allFingerprints, 'global');
593
- }
594
-
595
- return { verification, tgtClient };
596
- }
597
-
598
- // Phase 3: Delete all source emails in a single EXPUNGE.
599
- // Returns { srcClient }
600
- async function deleteAllChunks(srcClient, operation, sourceMailbox) {
601
- const allUids = operation.chunks.flatMap(c => c.uids);
602
- const t = Date.now();
603
-
604
- try {
605
- await srcClient.messageDelete(allUids, { uid: true });
606
- } catch (err) {
607
- if (!isTransient(err)) throw err;
608
- moveLog('global', `delete failed (${err.message}), reconnecting...`);
609
- srcClient = await reconnect(srcClient, sourceMailbox);
610
- // Retry is idempotent — expunging already-gone UIDs is a no-op
611
- await srcClient.messageDelete(allUids, { uid: true });
612
- }
613
-
614
- moveLog('global', `deleted ${allUids.length} from source — single EXPUNGE (${elapsed(t)})`);
615
- return { srcClient };
616
- }
617
-
618
- // ─── Safe Move (Option B: COPY-all → VERIFY-all → single EXPUNGE) ─────────────
619
-
620
- async function safeMoveEmails(uids, sourceMailbox, targetMailbox) {
621
- const operation = startOperation(sourceMailbox, targetMailbox, uids);
622
- const opStart = Date.now();
623
-
624
- process.stderr.write(`[move] starting: ${uids.length} emails, ${operation.chunks.length} chunks, ${sourceMailbox} → ${targetMailbox}\n`);
625
-
626
- let srcClient = await openClient(sourceMailbox);
627
- let tgtClient = await openClient(targetMailbox);
628
-
629
- try {
630
- // Phase 1: COPY all chunks to target (no delete yet)
631
- process.stderr.write(`[move] phase 1/3: copying ${uids.length} emails in ${operation.chunks.length} chunks\n`);
632
- const copyResult = await copyAllChunks(operation, srcClient, targetMailbox, sourceMailbox);
633
- srcClient = copyResult.srcClient;
634
-
635
- if (!copyResult.success) {
636
- failOperation(`Copy phase failed: ${copyResult.errorResult.message}`);
637
- return copyResult.errorResult;
638
- }
639
-
640
- // Phase 2: VERIFY all emails are present in target
641
- process.stderr.write(`[move] phase 2/3: verifying all ${copyResult.totalCopied} emails in target\n`);
642
- updateOperationPhase('verifying');
643
-
644
- let verifyResult;
645
- try {
646
- verifyResult = await withTimeout('verify all', TIMEOUT.VERIFY_ALL, () =>
647
- verifyAllChunks(tgtClient, operation, targetMailbox)
648
- );
649
- tgtClient = verifyResult.tgtClient;
650
- } catch (err) {
651
- moveLog('global', `verify phase FAILED: ${err.message}`);
652
- failOperation(`Verify phase failed: ${err.message}`);
653
- return {
654
- status: 'failed',
655
- moved: 0,
656
- message: `Verification timed out or failed: ${err.message}. All ${copyResult.totalCopied} emails remain in source (not deleted). Call get_move_status for details.`
657
- };
658
- }
659
-
660
- const { verification } = verifyResult;
661
- moveLog('global', `verification: ${verification.found}/${verification.expected} confirmed`);
662
-
663
- if (!verification.verified) {
664
- moveLog('global', `FAILED: ${verification.missing.length} emails missing from target after copy`);
665
- failOperation(`Verification failed: ${verification.missing.length} emails missing from target`);
666
- return {
667
- status: 'failed',
668
- moved: 0,
669
- message: `Verification failed: ${verification.missing.length} of ${verification.expected} emails did not arrive in target. Source emails untouched. Call get_move_status for details.`
670
- };
671
- }
672
-
673
- updateOperationPhase('verifying', { verifiedAt: new Date().toISOString() });
674
-
675
- // Mark all chunks as verified
676
- for (const chunk of operation.chunks) {
677
- updateChunk(chunk.index, { status: 'verified_not_deleted', verifiedAt: new Date().toISOString() });
678
- }
679
-
680
- // Phase 3: DELETE all source emails — single EXPUNGE
681
- process.stderr.write(`[move] phase 3/3: deleting all ${uids.length} emails from source (1 EXPUNGE)\n`);
682
- updateOperationPhase('deleting');
683
-
684
- let deleteResult;
685
- try {
686
- deleteResult = await withTimeout('delete all', TIMEOUT.DELETE_ALL, () =>
687
- deleteAllChunks(srcClient, operation, sourceMailbox)
688
- );
689
- srcClient = deleteResult.srcClient;
690
- } catch (err) {
691
- moveLog('global', `delete phase FAILED: ${err.message}`);
692
- // Emails are safe in target (verified). Source may still have them.
693
- failOperation(`Delete phase failed: ${err.message}`);
694
- return {
695
- status: 'failed',
696
- moved: 0,
697
- message: `Delete phase failed: ${err.message}. All ${copyResult.totalCopied} emails exist in target (verified) but may still exist in source. Call get_move_status for details.`
698
- };
699
- }
700
-
701
- // Mark all chunks complete
702
- const now = new Date().toISOString();
703
- for (const chunk of operation.chunks) {
704
- updateChunk(chunk.index, { status: 'complete', deletedAt: now });
705
- }
706
- updateOperationPhase('deleting', { deletedAt: now });
707
- completeOperation();
708
-
709
- process.stderr.write(`[move] COMPLETE: ${copyResult.totalCopied}/${operation.totalUids} emails moved (${elapsed(opStart)})\n`);
710
- return { status: 'complete', moved: copyResult.totalCopied, total: operation.totalUids };
711
- } finally {
712
- await safeClose(srcClient);
713
- await safeClose(tgtClient);
714
- }
715
- }
716
-
717
- // ─── Email Functions ──────────────────────────────────────────────────────────
718
-
719
- async function fetchEmails(mailbox = 'INBOX', limit = 10, onlyUnread = false, page = 1) {
720
- const client = createRateLimitedClient();
721
- await client.connect();
722
- const mb = await client.mailboxOpen(mailbox);
723
- const total = mb.exists;
724
- const emails = [];
725
-
726
- if (total === 0) {
727
- await client.logout();
728
- return { emails, page, limit, total, totalPages: 0, hasMore: false };
729
- }
730
-
731
- if (onlyUnread) {
732
- const uids = (await client.search({ seen: false }, { uid: true })) ?? [];
733
- const totalUnread = uids.length;
734
- const skip = (page - 1) * limit;
735
- const pageUids = uids.reverse().slice(skip, skip + limit);
736
- for (const uid of pageUids) {
737
- const msg = await client.fetchOne(uid, { envelope: true, flags: true }, { uid: true });
738
- if (msg) {
739
- emails.push({
740
- uid,
741
- subject: msg.envelope.subject,
742
- from: msg.envelope.from?.[0]?.address,
743
- date: msg.envelope.date,
744
- flagged: msg.flags.has('\\Flagged'),
745
- seen: msg.flags.has('\\Seen')
746
- });
747
- }
748
- }
749
- await client.logout();
750
- return { emails, page, limit, total: totalUnread, totalPages: Math.ceil(totalUnread / limit), hasMore: (page * limit) < totalUnread };
751
- }
752
-
753
- const end = Math.max(1, total - ((page - 1) * limit));
754
- const start = Math.max(1, end - limit + 1);
755
- const range = `${start}:${end}`;
756
-
757
- for await (const msg of client.fetch(range, { envelope: true, flags: true })) {
758
- emails.push({
759
- uid: msg.uid,
760
- subject: msg.envelope.subject,
761
- from: msg.envelope.from?.[0]?.address,
762
- date: msg.envelope.date,
763
- flagged: msg.flags.has('\\Flagged'),
764
- seen: msg.flags.has('\\Seen')
765
- });
766
- }
767
-
768
- await client.logout();
769
- emails.reverse();
770
- return { emails, page, limit, total, totalPages: Math.ceil(total / limit), hasMore: (page * limit) < total };
771
- }
772
-
773
- async function getInboxSummary(mailbox = 'INBOX') {
774
- const client = createRateLimitedClient();
775
- await client.connect();
776
- const status = await client.status(mailbox, { messages: true, unseen: true, recent: true });
777
- await client.logout();
778
- return { mailbox, total: status.messages, unread: status.unseen, recent: status.recent };
779
- }
780
-
781
- async function getTopSenders(mailbox = 'INBOX', sampleSize = 500, maxResults = 20) {
782
- const client = createRateLimitedClient();
783
- await client.connect();
784
- const mb = await client.mailboxOpen(mailbox);
785
- const total = mb.exists;
786
- const senderCounts = {};
787
- const senderDomains = {};
788
-
789
- const end = total;
790
- const start = Math.max(1, total - sampleSize + 1);
791
- const range = `${start}:${end}`;
792
- let count = 0;
793
-
794
- for await (const msg of client.fetch(range, { envelope: true })) {
795
- const address = msg.envelope.from?.[0]?.address;
796
- if (address) {
797
- senderCounts[address] = (senderCounts[address] || 0) + 1;
798
- const domain = address.split('@')[1];
799
- if (domain) senderDomains[domain] = (senderDomains[domain] || 0) + 1;
800
- }
801
- count++;
802
- }
803
-
804
- await client.logout();
805
- const topAddresses = Object.entries(senderCounts).sort((a, b) => b[1] - a[1]).slice(0, maxResults).map(([address, count]) => ({ address, count }));
806
- const topDomains = Object.entries(senderDomains).sort((a, b) => b[1] - a[1]).slice(0, maxResults).map(([domain, count]) => ({ domain, count }));
807
- return { sampledEmails: count, topAddresses, topDomains };
808
- }
809
-
810
- async function getUnreadSenders(mailbox = 'INBOX', sampleSize = 500, maxResults = 20) {
811
- const client = createRateLimitedClient();
812
- await client.connect();
813
- await client.mailboxOpen(mailbox);
814
- const uids = (await client.search({ seen: false }, { uid: true })) ?? [];
815
- const recentUids = uids.reverse().slice(0, sampleSize);
816
- const senderCounts = {};
817
-
818
- if (recentUids.length === 0) {
819
- await client.logout();
820
- return [];
821
- }
822
-
823
- for await (const msg of client.fetch(recentUids, { envelope: true }, { uid: true })) {
824
- const address = msg.envelope.from?.[0]?.address;
825
- if (address) senderCounts[address] = (senderCounts[address] || 0) + 1;
826
- }
827
-
828
- await client.logout();
829
- return Object.entries(senderCounts).sort((a, b) => b[1] - a[1]).slice(0, maxResults).map(([address, count]) => ({ address, count }));
830
- }
831
-
832
- async function getEmailsBySender(sender, mailbox = 'INBOX', limit = 10) {
833
- const client = createRateLimitedClient();
834
- await client.connect();
835
- await client.mailboxOpen(mailbox);
836
- const uids = (await client.search({ from: sender }, { uid: true })) ?? [];
837
- const total = uids.length;
838
- const recentUids = uids.slice(-limit).reverse();
839
- const emails = [];
840
- for (const uid of recentUids) {
841
- const msg = await client.fetchOne(uid, { envelope: true, flags: true }, { uid: true });
842
- if (msg) {
843
- emails.push({
844
- uid,
845
- subject: msg.envelope.subject,
846
- from: msg.envelope.from?.[0]?.address,
847
- date: msg.envelope.date,
848
- flagged: msg.flags.has('\\Flagged'),
849
- seen: msg.flags.has('\\Seen')
850
- });
851
- }
852
- }
853
- await client.logout();
854
- return { total, showing: emails.length, emails };
855
- }
856
-
857
- async function bulkDeleteBySender(sender, mailbox = 'INBOX') {
858
- const client = createRateLimitedClient();
859
- await client.connect();
860
- await client.mailboxOpen(mailbox);
861
- const uids = (await client.search({ from: sender }, { uid: true })) ?? [];
862
- if (uids.length === 0) { await client.logout(); return { deleted: 0 }; }
863
- let deleted = 0;
864
- for (let i = 0; i < uids.length; i += CHUNK_SIZE) {
865
- const chunk = uids.slice(i, i + CHUNK_SIZE);
866
- await client.messageDelete(chunk, { uid: true });
867
- deleted += chunk.length;
868
- }
869
- await client.logout();
870
- return { deleted, sender };
871
- }
872
-
873
- async function markOlderThanRead(days, mailbox = 'INBOX') {
874
- const client = createRateLimitedClient();
875
- await client.connect();
876
- await client.mailboxOpen(mailbox);
877
- const date = new Date();
878
- date.setDate(date.getDate() - days);
879
- const raw = await client.search({ before: date, seen: false }, { uid: true });
880
- const uids = Array.isArray(raw) ? raw : [];
881
- if (uids.length === 0) { await client.logout(); return { marked: 0, olderThan: date.toISOString() }; }
882
- await client.messageFlagsAdd(uids, ['\\Seen'], { uid: true });
883
- await client.logout();
884
- return { marked: uids.length, olderThan: date.toISOString() };
885
- }
886
-
887
- async function bulkMoveByDomain(domain, targetMailbox, sourceMailbox = 'INBOX', dryRun = false) {
888
- const result = await bulkMove({ domain }, targetMailbox, sourceMailbox, dryRun);
889
- return { ...result, domain };
890
- }
891
-
892
- async function bulkMoveBySender(sender, targetMailbox, sourceMailbox = 'INBOX', dryRun = false) {
893
- const client = createRateLimitedClient();
894
- await client.connect();
895
- await client.mailboxOpen(sourceMailbox);
896
- const uids = (await client.search({ from: sender }, { uid: true })) ?? [];
897
- await client.logout();
898
- if (dryRun) return { dryRun: true, wouldMove: uids.length, sender, sourceMailbox, targetMailbox };
899
- if (uids.length === 0) return { moved: 0 };
900
- await ensureMailbox(targetMailbox);
901
- const result = await safeMoveEmails(uids, sourceMailbox, targetMailbox);
902
- return { ...result, sender, targetMailbox };
903
- }
904
-
905
- async function bulkDeleteBySubject(subject, mailbox = 'INBOX') {
906
- const client = createRateLimitedClient();
907
- await client.connect();
908
- await client.mailboxOpen(mailbox);
909
- const uids = (await client.search({ subject }, { uid: true })) ?? [];
910
- if (uids.length === 0) { await client.logout(); return { deleted: 0 }; }
911
- let deleted = 0;
912
- for (let i = 0; i < uids.length; i += CHUNK_SIZE) {
913
- const chunk = uids.slice(i, i + CHUNK_SIZE);
914
- await client.messageDelete(chunk, { uid: true });
915
- deleted += chunk.length;
916
- }
917
- await client.logout();
918
- return { deleted, subject };
919
- }
920
-
921
- async function deleteOlderThan(days, mailbox = 'INBOX') {
922
- const client = createRateLimitedClient();
923
- await client.connect();
924
- await client.mailboxOpen(mailbox);
925
- const date = new Date();
926
- date.setDate(date.getDate() - days);
927
- const uids = (await client.search({ before: date }, { uid: true })) ?? [];
928
- if (uids.length === 0) { await client.logout(); return { deleted: 0 }; }
929
- let deleted = 0;
930
- for (let i = 0; i < uids.length; i += CHUNK_SIZE) {
931
- const chunk = uids.slice(i, i + CHUNK_SIZE);
932
- await client.messageDelete(chunk, { uid: true });
933
- deleted += chunk.length;
934
- }
935
- await client.logout();
936
- return { deleted, olderThan: date.toISOString() };
937
- }
938
-
939
- async function getEmailsByDateRange(startDate, endDate, mailbox = 'INBOX', limit = 10) {
940
- const client = createRateLimitedClient();
941
- await client.connect();
942
- await client.mailboxOpen(mailbox);
943
- const uids = (await client.search({ since: new Date(startDate), before: new Date(endDate) }, { uid: true })) ?? [];
944
- const total = uids.length;
945
- const recentUids = uids.slice(-limit).reverse();
946
- const emails = [];
947
- for (const uid of recentUids) {
948
- const msg = await client.fetchOne(uid, { envelope: true, flags: true }, { uid: true });
949
- if (msg) {
950
- emails.push({
951
- uid,
952
- subject: msg.envelope.subject,
953
- from: msg.envelope.from?.[0]?.address,
954
- date: msg.envelope.date,
955
- flagged: msg.flags.has('\\Flagged'),
956
- seen: msg.flags.has('\\Seen')
957
- });
958
- }
959
- }
960
- await client.logout();
961
- return { total, showing: emails.length, emails };
962
- }
963
-
964
- async function bulkMarkRead(mailbox = 'INBOX', sender = null) {
965
- const client = createRateLimitedClient();
966
- await client.connect();
967
- await client.mailboxOpen(mailbox);
968
- const query = sender ? { from: sender, seen: false } : { seen: false };
969
- const uids = (await client.search(query, { uid: true })) ?? [];
970
- if (uids.length === 0) { await client.logout(); return { marked: 0 }; }
971
- await client.messageFlagsAdd(uids, ['\\Seen'], { uid: true });
972
- await client.logout();
973
- return { marked: uids.length, sender: sender || 'all' };
974
- }
975
-
976
- async function bulkMarkUnread(mailbox = 'INBOX', sender = null) {
977
- const client = createRateLimitedClient();
978
- await client.connect();
979
- await client.mailboxOpen(mailbox);
980
- const query = sender ? { from: sender, seen: true } : { seen: true };
981
- const uids = (await client.search(query, { uid: true })) ?? [];
982
- if (uids.length === 0) { await client.logout(); return { marked: 0 }; }
983
- await client.messageFlagsRemove(uids, ['\\Seen'], { uid: true });
984
- await client.logout();
985
- return { marked: uids.length, sender: sender || 'all' };
986
- }
987
-
988
- async function bulkFlag(filters, flagged, mailbox = 'INBOX') {
989
- const client = createRateLimitedClient();
990
- await client.connect();
991
- await client.mailboxOpen(mailbox);
992
- const query = buildQuery(filters);
993
- const uids = (await client.search(query, { uid: true })) ?? [];
994
- if (uids.length === 0) { await client.logout(); return { flagged: 0 }; }
995
- if (flagged) {
996
- await client.messageFlagsAdd(uids, ['\\Flagged'], { uid: true });
997
- } else {
998
- await client.messageFlagsRemove(uids, ['\\Flagged'], { uid: true });
999
- }
1000
- await client.logout();
1001
- return { [flagged ? 'flagged' : 'unflagged']: uids.length, filters };
1002
- }
1003
-
1004
- async function bulkFlagBySender(sender, flagged, mailbox = 'INBOX') {
1005
- const client = createRateLimitedClient();
1006
- await client.connect();
1007
- await client.mailboxOpen(mailbox);
1008
- const raw = await client.search({ from: sender }, { uid: true });
1009
- const uids = Array.isArray(raw) ? raw : [];
1010
- if (uids.length === 0) { await client.logout(); return { [flagged ? 'flagged' : 'unflagged']: 0, sender }; }
1011
- if (flagged) {
1012
- await client.messageFlagsAdd(uids, ['\\Flagged'], { uid: true });
1013
- } else {
1014
- await client.messageFlagsRemove(uids, ['\\Flagged'], { uid: true });
1015
- }
1016
- await client.logout();
1017
- return { [flagged ? 'flagged' : 'unflagged']: uids.length, sender };
1018
- }
1019
-
1020
- async function emptyTrash(dryRun = false) {
1021
- const t0 = Date.now();
1022
- const trashFolders = ['Deleted Messages', 'Trash'];
1023
- const client = createRateLimitedClient();
1024
- await client.connect();
1025
-
1026
- let mailbox = null;
1027
- for (const folder of trashFolders) {
1028
- try {
1029
- await client.mailboxOpen(folder);
1030
- mailbox = folder;
1031
- break;
1032
- } catch (err) {
1033
- if (!err.message.includes('Mailbox does not exist') && !err.message.includes('NONEXISTENT') && !err.message.includes('does not exist')) {
1034
- await safeClose(client);
1035
- throw err;
1036
- }
1037
- }
1038
- }
1039
-
1040
- if (!mailbox) {
1041
- await safeClose(client);
1042
- throw new Error('No trash folder found — tried: ' + trashFolders.join(', '));
1043
- }
1044
-
1045
- const raw = await client.search({ all: true }, { uid: true });
1046
- const uids = Array.isArray(raw) ? raw : [];
1047
-
1048
- if (dryRun) {
1049
- await safeClose(client);
1050
- return { dryRun: true, wouldDelete: uids.length, mailbox };
1051
- }
1052
-
1053
- if (uids.length === 0) {
1054
- await safeClose(client);
1055
- return { deleted: 0, mailbox, timeTaken: ((Date.now() - t0) / 1000).toFixed(1) + 's' };
1056
- }
1057
-
1058
- let deleted = 0;
1059
- for (let i = 0; i < uids.length; i += CHUNK_SIZE) {
1060
- const chunk = uids.slice(i, i + CHUNK_SIZE);
1061
- await client.messageDelete(chunk, { uid: true });
1062
- deleted += chunk.length;
1063
- }
1064
- await safeClose(client);
1065
- return { deleted, mailbox, timeTaken: ((Date.now() - t0) / 1000).toFixed(1) + 's' };
1066
- }
1067
-
1068
- async function archiveOlderThan(days, targetMailbox, sourceMailbox = 'INBOX', dryRun = false) {
1069
- const client = createRateLimitedClient();
1070
- await client.connect();
1071
- await client.mailboxOpen(sourceMailbox);
1072
- const date = new Date();
1073
- date.setDate(date.getDate() - days);
1074
- const raw = await client.search({ before: date }, { uid: true });
1075
- const uids = Array.isArray(raw) ? raw : [];
1076
- await client.logout();
1077
- if (dryRun) return { dryRun: true, wouldMove: uids.length, olderThan: date.toISOString(), sourceMailbox, targetMailbox };
1078
- if (uids.length === 0) return { moved: 0, olderThan: date.toISOString(), sourceMailbox, targetMailbox };
1079
- await ensureMailbox(targetMailbox);
1080
- const result = await safeMoveEmails(uids, sourceMailbox, targetMailbox);
1081
- return { ...result, olderThan: date.toISOString(), sourceMailbox, targetMailbox };
1082
- }
1083
-
1084
- async function getStorageReport(mailbox = 'INBOX', sampleSize = 100) {
1085
- const client = createRateLimitedClient();
1086
- await client.connect();
1087
- await client.mailboxOpen(mailbox);
1088
-
1089
- // Count emails by size bucket using 4x SEARCH LARGER
1090
- const thresholds = [10 * 1024, 100 * 1024, 1024 * 1024, 10 * 1024 * 1024];
1091
- const counts = [];
1092
- for (const thresh of thresholds) {
1093
- const r = await client.search({ larger: thresh }, { uid: true }).catch(() => []);
1094
- counts.push(Array.isArray(r) ? r.length : 0);
1095
- }
1096
-
1097
- const buckets = [
1098
- { range: '10KB–100KB', count: counts[0] - counts[1] },
1099
- { range: '100KB–1MB', count: counts[1] - counts[2] },
1100
- { range: '1MB–10MB', count: counts[2] - counts[3] },
1101
- { range: '10MB+', count: counts[3] }
1102
- ];
1103
-
1104
- // Sample top senders among large emails (> 100 KB)
1105
- const largeRaw = await client.search({ larger: 100 * 1024 }, { uid: true }).catch(() => []);
1106
- const largeUids = Array.isArray(largeRaw) ? largeRaw : [];
1107
- const sampleUids = largeUids.slice(-sampleSize);
1108
-
1109
- const senderSizes = {};
1110
- if (sampleUids.length > 0) {
1111
- for await (const msg of client.fetch(sampleUids, { envelope: true, bodyStructure: true }, { uid: true })) {
1112
- const address = msg.envelope?.from?.[0]?.address;
1113
- if (address && msg.bodyStructure) {
1114
- senderSizes[address] = (senderSizes[address] || 0) + estimateEmailSize(msg.bodyStructure);
1115
- }
1116
- }
1117
- }
1118
-
1119
- await client.logout();
1120
-
1121
- const topSendersBySize = Object.entries(senderSizes)
1122
- .sort((a, b) => b[1] - a[1])
1123
- .slice(0, 10)
1124
- .map(([address, estimateBytes]) => ({ address, estimateKB: Math.round(estimateBytes / 1024) }));
1125
-
1126
- const midpoints = [50, 512, 5120, 15360]; // rough KB midpoint for each bucket
1127
- const estimatedTotalKB = buckets.reduce((sum, b, i) => sum + b.count * midpoints[i], 0);
1128
-
1129
- return {
1130
- mailbox,
1131
- buckets,
1132
- estimatedTotalKB,
1133
- topSendersBySize,
1134
- ...(sampleUids.length < largeUids.length && {
1135
- note: `Sender analysis sampled ${sampleUids.length} of ${largeUids.length} large emails (>100 KB)`
1136
- })
1137
- };
1138
- }
1139
-
1140
- async function getThread(uid, mailbox = 'INBOX') {
1141
- const THREAD_CANDIDATE_CAP = 100;
1142
- const client = createRateLimitedClient();
1143
- await client.connect();
1144
- await client.mailboxOpen(mailbox);
1145
-
1146
- // Fetch target email's envelope + raw headers for threading
1147
- const meta = await client.fetchOne(uid, {
1148
- envelope: true,
1149
- flags: true,
1150
- headers: new Set(['references', 'in-reply-to'])
1151
- }, { uid: true });
1152
- if (!meta) throw new Error(`Email UID ${uid} not found`);
1153
-
1154
- const targetMessageId = meta.envelope?.messageId ?? null;
1155
- const rawRefs = extractRawHeader(meta.headers, 'references');
1156
- const rawInReplyTo = extractRawHeader(meta.headers, 'in-reply-to');
1157
-
1158
- // Build full reference set for this email
1159
- const threadRefs = new Set();
1160
- if (targetMessageId) threadRefs.add(targetMessageId.trim());
1161
- if (rawInReplyTo) threadRefs.add(rawInReplyTo.trim());
1162
- if (rawRefs) {
1163
- rawRefs.split(/\s+/).filter(s => s.startsWith('<') && s.endsWith('>')).forEach(r => threadRefs.add(r));
1164
- }
1165
-
1166
- const normalizedSubject = stripSubjectPrefixes(meta.envelope?.subject ?? '');
1167
-
1168
- // SEARCH SUBJECT for candidates (iCloud doesn't support SEARCH HEADER)
1169
- let candidateUids = [];
1170
- if (normalizedSubject) {
1171
- const raw = await client.search({ subject: normalizedSubject }, { uid: true });
1172
- candidateUids = Array.isArray(raw) ? raw : [];
1173
- }
1174
-
1175
- const candidatesCapped = candidateUids.length > THREAD_CANDIDATE_CAP;
1176
- if (candidatesCapped) candidateUids = candidateUids.slice(-THREAD_CANDIDATE_CAP);
1177
-
1178
- // Fetch envelopes + headers for candidates to filter by References overlap
1179
- const threadEmails = [];
1180
- if (candidateUids.length > 0) {
1181
- for await (const msg of client.fetch(candidateUids, {
1182
- envelope: true,
1183
- flags: true,
1184
- headers: new Set(['references', 'in-reply-to'])
1185
- }, { uid: true })) {
1186
- const msgId = msg.envelope?.messageId ?? null;
1187
- const msgRefs = extractRawHeader(msg.headers, 'references');
1188
- const msgInReplyTo = extractRawHeader(msg.headers, 'in-reply-to');
1189
-
1190
- // Build this message's reference set
1191
- const msgRefSet = new Set();
1192
- if (msgId) msgRefSet.add(msgId.trim());
1193
- if (msgInReplyTo) msgRefSet.add(msgInReplyTo.trim());
1194
- if (msgRefs) msgRefs.split(/\s+/).filter(s => s.startsWith('<')).forEach(r => msgRefSet.add(r));
1195
-
1196
- // Include if there's any Reference chain overlap
1197
- const hasOverlap = (msgId && threadRefs.has(msgId.trim())) ||
1198
- [...threadRefs].some(r => msgRefSet.has(r));
1199
-
1200
- if (hasOverlap) {
1201
- threadEmails.push({
1202
- uid: msg.uid,
1203
- subject: msg.envelope?.subject,
1204
- from: msg.envelope?.from?.[0]?.address,
1205
- date: msg.envelope?.date,
1206
- seen: msg.flags?.has('\\Seen') ?? false,
1207
- flagged: msg.flags?.has('\\Flagged') ?? false,
1208
- messageId: msgId
1209
- });
1210
- }
1211
- }
1212
- }
1213
-
1214
- await client.logout();
1215
-
1216
- // Sort by date ascending
1217
- threadEmails.sort((a, b) => {
1218
- const da = a.date ? new Date(a.date).getTime() : 0;
1219
- const db = b.date ? new Date(b.date).getTime() : 0;
1220
- return da - db;
1221
- });
1222
-
1223
- return {
1224
- uid,
1225
- subject: normalizedSubject || meta.envelope?.subject,
1226
- count: threadEmails.length,
1227
- emails: threadEmails,
1228
- ...(candidatesCapped && {
1229
- candidatesCapped: true,
1230
- note: `Subject search returned more than ${THREAD_CANDIDATE_CAP} candidates — thread results may be incomplete`
1231
- })
1232
- };
1233
- }
1234
-
1235
- async function createMailbox(name) {
1236
- const client = createRateLimitedClient();
1237
- await client.connect();
1238
- await client.mailboxCreate(name);
1239
- await client.logout();
1240
- return { created: name };
1241
- }
1242
-
1243
- async function renameMailbox(oldName, newName) {
1244
- const client = createRateLimitedClient();
1245
- await client.connect();
1246
- try {
1247
- await Promise.race([
1248
- client.mailboxRename(oldName, newName),
1249
- new Promise((_, reject) =>
1250
- setTimeout(() => reject(new Error('rename timed out after 15s — Apple IMAP may not support renaming this folder')), 15000)
1251
- )
1252
- ]);
1253
- } finally {
1254
- try { await client.logout(); } catch { client.close(); }
1255
- }
1256
- return { renamed: { from: oldName, to: newName } };
1257
- }
1258
-
1259
- async function deleteMailbox(name) {
1260
- const client = createRateLimitedClient();
1261
- await client.connect();
1262
- try {
1263
- await Promise.race([
1264
- client.mailboxDelete(name),
1265
- new Promise((_, reject) =>
1266
- setTimeout(() => reject(new Error('delete timed out after 15s — Apple IMAP may not support deleting this folder')), 15000)
1267
- )
1268
- ]);
1269
- } finally {
1270
- try { await client.logout(); } catch { client.close(); }
1271
- }
1272
- return { deleted: name };
1273
- }
1274
-
1275
- async function getMailboxSummary(mailbox) {
1276
- const client = createRateLimitedClient();
1277
- await client.connect();
1278
- const status = await client.status(mailbox, { messages: true, unseen: true, recent: true });
1279
- await client.logout();
1280
- return { mailbox, total: status.messages, unread: status.unseen, recent: status.recent };
1281
- }
1282
-
1283
- // ─── MIME body parsing helpers ────────────────────────────────────────────────
1284
-
1285
- function decodeTransferEncoding(buffer, encoding) {
1286
- const enc = (encoding || '7bit').toLowerCase().trim();
1287
- if (enc === 'base64') {
1288
- return Buffer.from(buffer.toString('ascii').replace(/\s/g, ''), 'base64');
1289
- }
1290
- if (enc === 'quoted-printable') {
1291
- const str = buffer.toString('binary')
1292
- .replace(/[\t ]+$/gm, '')
1293
- .replace(/=(?:\r?\n|$)/g, '');
1294
- const result = Buffer.alloc(str.length);
1295
- let pos = 0;
1296
- for (let i = 0; i < str.length; i++) {
1297
- if (str[i] === '=' && i + 2 < str.length) {
1298
- const hex = str.slice(i + 1, i + 3);
1299
- if (/^[\da-fA-F]{2}$/.test(hex)) {
1300
- result[pos++] = parseInt(hex, 16);
1301
- i += 2;
1302
- continue;
1303
- }
1304
- }
1305
- result[pos++] = str.charCodeAt(i) & 0xff;
1306
- }
1307
- return result.slice(0, pos);
1308
- }
1309
- return buffer;
1310
- }
1311
-
1312
- async function decodeCharset(buffer, charset) {
1313
- const cs = (charset || 'utf-8').toLowerCase().trim();
1314
- const nativeMap = { 'utf-8': 'utf8', 'utf8': 'utf8', 'us-ascii': 'ascii',
1315
- 'ascii': 'ascii', 'latin1': 'latin1', 'iso-8859-1': 'latin1', 'binary': 'binary' };
1316
- if (nativeMap[cs]) return buffer.toString(nativeMap[cs]);
1317
- try {
1318
- const { default: iconv } = await import('iconv-lite');
1319
- if (iconv.encodingExists(cs)) return iconv.decode(buffer, cs);
1320
- } catch { /* iconv unavailable */ }
1321
- return buffer.toString('utf8');
1322
- }
1323
-
1324
- function stripHtml(html) {
1325
- return html
1326
- .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, ' ')
1327
- .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, ' ')
1328
- .replace(/<br\s*\/?>/gi, '\n')
1329
- .replace(/<\/p>/gi, '\n\n')
1330
- .replace(/<\/div>/gi, '\n')
1331
- .replace(/<\/li>/gi, '\n')
1332
- .replace(/<[^>]+>/g, '')
1333
- .replace(/&nbsp;/gi, ' ')
1334
- .replace(/&amp;/gi, '&')
1335
- .replace(/&lt;/gi, '<')
1336
- .replace(/&gt;/gi, '>')
1337
- .replace(/&quot;/gi, '"')
1338
- .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n)))
1339
- .replace(/[ \t]+/g, ' ')
1340
- .replace(/\n{3,}/g, '\n\n')
1341
- .trim();
1342
- }
1343
-
1344
- // Extract a specific header from imapflow's headers property.
1345
- // imapflow returns headers as a raw Buffer (BODY[HEADER.FIELDS ...] response bytes),
1346
- // so we parse it as text with MIME unfolding. Falls back to .get() if it's a Map.
1347
- function extractRawHeader(headers, name) {
1348
- if (!headers) return '';
1349
- let str;
1350
- if (Buffer.isBuffer(headers)) {
1351
- str = headers.toString();
1352
- } else if (typeof headers.get === 'function') {
1353
- return (headers.get(name) ?? '').toString().trim();
1354
- } else {
1355
- str = headers.toString();
1356
- }
1357
- // Unfold MIME-folded header values (CRLF + whitespace = continuation)
1358
- const unfolded = str.replace(/\r?\n[ \t]+/g, ' ');
1359
- return unfolded.match(new RegExp(`^${name}:\\s*(.+)`, 'im'))?.[1]?.trim() ?? '';
1360
- }
1361
-
1362
- function findTextPart(node) {
1363
- if (!node.childNodes) {
1364
- if (node.type && node.type.startsWith('text/') && node.disposition !== 'attachment') {
1365
- return { partId: null, type: node.type, encoding: node.encoding, charset: node.parameters?.charset, size: node.size };
1366
- }
1367
- return null;
1368
- }
1369
- if (node.type === 'multipart/alternative') {
1370
- let plainPart = null, htmlPart = null;
1371
- for (const child of node.childNodes) {
1372
- if (child.childNodes || child.disposition === 'attachment') continue;
1373
- if (child.type === 'text/plain') plainPart = child;
1374
- else if (child.type === 'text/html') htmlPart = child;
1375
- }
1376
- const chosen = plainPart || htmlPart;
1377
- if (chosen) return { partId: chosen.part, type: chosen.type, encoding: chosen.encoding, charset: chosen.parameters?.charset, size: chosen.size };
1378
- }
1379
- for (const child of node.childNodes) {
1380
- if (child.disposition === 'attachment') continue;
1381
- const found = findTextPart(child);
1382
- if (found) return found;
1383
- }
1384
- return null;
1385
- }
1386
-
1387
- function findAttachments(node, parts = []) {
1388
- if (node.childNodes) {
1389
- for (const child of node.childNodes) findAttachments(child, parts);
1390
- } else {
1391
- const filename = node.dispositionParameters?.filename ?? node.parameters?.name ?? null;
1392
- const isTextBody = (node.type === 'text/plain' || node.type === 'text/html') && node.disposition !== 'attachment';
1393
- if (node.disposition === 'attachment' || node.disposition === 'inline' || (filename && !isTextBody)) {
1394
- parts.push({
1395
- partId: node.part ?? 'TEXT',
1396
- filename,
1397
- mimeType: node.type ?? 'application/octet-stream',
1398
- size: node.size ?? 0,
1399
- encoding: node.encoding ?? '7bit',
1400
- disposition: node.disposition ?? 'attachment'
1401
- });
1402
- }
1403
- }
1404
- return parts;
1405
- }
1406
-
1407
- function estimateEmailSize(node) {
1408
- if (node.childNodes) return node.childNodes.reduce((s, c) => s + estimateEmailSize(c), 0);
1409
- return node.size || 0;
1410
- }
1411
-
1412
- function stripSubjectPrefixes(subject) {
1413
- if (!subject) return '';
1414
- return subject.replace(/^(Re:|RE:|Fwd:|FWD:|Fw:|FW:|AW:|回复:|转发:)\s*/i, '').trim();
1415
- }
1416
-
1417
- // ─── Email content fetcher (MIME-aware) ───────────────────────────────────────
1418
-
1419
- async function getEmailContent(uid, mailbox = 'INBOX', maxChars = 8000, includeHeaders = false) {
1420
- const client = createRateLimitedClient();
1421
- await client.connect();
1422
- await client.mailboxOpen(mailbox);
1423
-
1424
- const fetchOpts = { envelope: true, flags: true, bodyStructure: true };
1425
- if (includeHeaders) fetchOpts.headers = new Set(['references', 'list-unsubscribe']);
1426
- const meta = await client.fetchOne(uid, fetchOpts, { uid: true });
1427
- if (!meta) {
1428
- await client.logout();
1429
- return { uid, subject: null, from: null, date: null, flags: [], body: '(email not found)' };
1430
- }
1431
-
1432
- let body = '(body unavailable)';
1433
-
1434
- try {
1435
- const struct = meta.bodyStructure;
1436
- if (!struct) throw new Error('no bodyStructure');
1437
-
1438
- const textPart = findTextPart(struct);
1439
-
1440
- if (!textPart) {
1441
- body = '(no readable text — email may be image-only or have no text parts)';
1442
- } else {
1443
- // Single-part messages use 'TEXT'; multipart use dot-notation part id (e.g. '1', '1.1')
1444
- const imapKey = textPart.partId ?? 'TEXT';
1445
-
1446
- // For large parts, cap the fetch at 12KB to avoid downloading multi-MB newsletters
1447
- const fetchSpec = (textPart.size && textPart.size > 150_000)
1448
- ? [{ key: imapKey, start: 0, maxLength: 12_000 }]
1449
- : [imapKey];
1450
-
1451
- const partMsg = await Promise.race([
1452
- client.fetchOne(uid, { bodyParts: fetchSpec }, { uid: true }),
1453
- new Promise((_, reject) => setTimeout(() => reject(new Error('body fetch timeout')), 10_000))
1454
- ]);
1455
-
1456
- // bodyParts is a Map — try the key as-is, then uppercase, then lowercase
1457
- const partBuffer = partMsg?.bodyParts?.get(imapKey)
1458
- ?? partMsg?.bodyParts?.get(imapKey.toUpperCase())
1459
- ?? partMsg?.bodyParts?.get(imapKey.toLowerCase());
1460
-
1461
- if (!partBuffer || partBuffer.length === 0) throw new Error('empty body part');
1462
-
1463
- const decoded = decodeTransferEncoding(partBuffer, textPart.encoding);
1464
- let text = await decodeCharset(decoded, textPart.charset);
1465
-
1466
- if (textPart.type === 'text/html') text = stripHtml(text);
1467
-
1468
- const clampedMaxChars = Math.min(maxChars, 50_000);
1469
- if (text.length > clampedMaxChars) {
1470
- text = text.slice(0, clampedMaxChars) + `\n\n[... truncated — ${text.length.toLocaleString()} chars total]`;
1471
- }
1472
-
1473
- body = text.trim() || '(empty body)';
1474
-
1475
- if (textPart.size && textPart.size > 150_000) {
1476
- body += `\n\n[Note: email body is large (${Math.round(textPart.size / 1024)}KB) — showing first 12KB]`;
1477
- }
1478
- }
1479
- } catch {
1480
- // Fallback: raw source slice (original behaviour)
1481
- try {
1482
- const sourceMsg = await Promise.race([
1483
- client.fetchOne(uid, { source: true }, { uid: true }),
1484
- new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 5_000))
1485
- ]);
1486
- if (sourceMsg?.source) {
1487
- const raw = sourceMsg.source.toString();
1488
- const bodyStart = raw.indexOf('\r\n\r\n');
1489
- body = '[raw fallback]\n' + (bodyStart > -1 ? raw.slice(bodyStart + 4, bodyStart + 2000) : raw.slice(0, 2000));
1490
- }
1491
- } catch { /* leave as unavailable */ }
1492
- }
1493
-
1494
- await client.logout();
1495
-
1496
- const attachments = meta.bodyStructure ? findAttachments(meta.bodyStructure) : [];
1497
- const result = {
1498
- uid: meta.uid,
1499
- subject: meta.envelope.subject,
1500
- from: meta.envelope.from?.[0]?.address,
1501
- date: meta.envelope.date,
1502
- flags: [...meta.flags],
1503
- attachments: {
1504
- count: attachments.length,
1505
- items: attachments.map(a => ({ partId: a.partId, filename: a.filename, mimeType: a.mimeType, size: a.size }))
1506
- },
1507
- body
1508
- };
1509
-
1510
- if (includeHeaders) {
1511
- // imapflow returns headers as a raw Buffer — parse it as text
1512
- const rawRefs = extractRawHeader(meta.headers, 'references');
1513
- const rawUnsub = extractRawHeader(meta.headers, 'list-unsubscribe');
1514
- result.headers = {
1515
- to: meta.envelope.to?.map(a => a.address) ?? [],
1516
- cc: meta.envelope.cc?.map(a => a.address) ?? [],
1517
- replyTo: meta.envelope.replyTo?.[0]?.address ?? null,
1518
- messageId: meta.envelope.messageId ?? null,
1519
- inReplyTo: meta.envelope.inReplyTo ?? null,
1520
- references: rawRefs ? rawRefs.split(/\s+/).filter(s => s.startsWith('<')) : [],
1521
- listUnsubscribe: rawUnsub || null
1522
- };
1523
- }
1524
-
1525
- return result;
1526
- }
1527
-
1528
- async function listAttachments(uid, mailbox = 'INBOX') {
1529
- const client = createRateLimitedClient();
1530
- await client.connect();
1531
- await client.mailboxOpen(mailbox);
1532
- const meta = await client.fetchOne(uid, { envelope: true, bodyStructure: true }, { uid: true });
1533
- await client.logout();
1534
- if (!meta) return { uid, subject: null, attachmentCount: 0, attachments: [] };
1535
- const attachments = meta.bodyStructure ? findAttachments(meta.bodyStructure) : [];
1536
- return {
1537
- uid: meta.uid,
1538
- subject: meta.envelope.subject,
1539
- attachmentCount: attachments.length,
1540
- attachments
1541
- };
1542
- }
1543
-
1544
- async function getUnsubscribeInfo(uid, mailbox = 'INBOX') {
1545
- const client = createRateLimitedClient();
1546
- await client.connect();
1547
- await client.mailboxOpen(mailbox);
1548
- const meta = await client.fetchOne(uid, { headers: new Set(['list-unsubscribe', 'list-unsubscribe-post']) }, { uid: true });
1549
- await client.logout();
1550
- if (!meta) return { uid, email: null, url: null, raw: null };
1551
- const raw = extractRawHeader(meta.headers, 'list-unsubscribe') || null;
1552
- if (!raw) return { uid, email: null, url: null, raw: null };
1553
- const email = raw.match(/<mailto:([^>]+)>/i)?.[1] ?? null;
1554
- const url = raw.match(/<(https?:[^>]+)>/i)?.[1] ?? null;
1555
- return { uid, email, url, raw };
1556
- }
1557
-
1558
- async function getEmailRaw(uid, mailbox = 'INBOX') {
1559
- const MAX_RAW_BYTES = 1 * 1024 * 1024; // 1 MB cap
1560
- const client = createRateLimitedClient();
1561
- await client.connect();
1562
- await client.mailboxOpen(mailbox);
1563
- const msg = await client.fetchOne(uid, { source: true }, { uid: true });
1564
- await client.logout();
1565
- if (!msg || !msg.source) throw new Error(`Email UID ${uid} not found`);
1566
- const source = msg.source;
1567
- const truncated = source.length > MAX_RAW_BYTES;
1568
- const slice = truncated ? source.slice(0, MAX_RAW_BYTES) : source;
1569
- return {
1570
- uid,
1571
- size: source.length,
1572
- truncated,
1573
- data: slice.toString('base64'),
1574
- dataEncoding: 'base64'
1575
- };
1576
- }
1577
-
1578
- async function getAttachment(uid, partId, mailbox = 'INBOX', offset = null, length = null) {
1579
- const client = createRateLimitedClient();
1580
- await client.connect();
1581
- await client.mailboxOpen(mailbox);
1582
-
1583
- // First fetch bodyStructure to find the attachment and validate size
1584
- const meta = await client.fetchOne(uid, { bodyStructure: true }, { uid: true });
1585
- if (!meta) throw new Error(`Email UID ${uid} not found`);
1586
-
1587
- const attachments = meta.bodyStructure ? findAttachments(meta.bodyStructure) : [];
1588
- const att = attachments.find(a => a.partId === partId);
1589
- if (!att) throw new Error(`Part ID "${partId}" not found in email UID ${uid}. Use list_attachments to see available parts.`);
1590
-
1591
- const isPaginated = offset !== null || length !== null;
1592
-
1593
- if (!isPaginated && att.size > MAX_ATTACHMENT_BYTES) {
1594
- await client.logout();
1595
- return {
1596
- error: `Attachment too large to download in one request (${Math.round(att.size / 1024 / 1024 * 10) / 10} MB). Use offset and length params to download in chunks (max ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB per request).`,
1597
- filename: att.filename,
1598
- mimeType: att.mimeType,
1599
- size: att.size,
1600
- totalSize: att.size
1601
- };
1602
- }
1603
-
1604
- // Build fetch spec
1605
- let fetchSpec;
1606
- if (isPaginated) {
1607
- const start = offset ?? 0;
1608
- const maxLength = length ?? MAX_ATTACHMENT_BYTES;
1609
- fetchSpec = [{ key: partId, start, maxLength }];
1610
- } else {
1611
- fetchSpec = [partId];
1612
- }
1613
-
1614
- // Fetch the raw body part bytes
1615
- const rawChunks = [];
1616
- for await (const msg of client.fetch({ uid }, { bodyParts: fetchSpec }, { uid: true })) {
1617
- const buf = msg.bodyParts?.get(partId)
1618
- ?? msg.bodyParts?.get(partId.toUpperCase())
1619
- ?? msg.bodyParts?.get(partId.toLowerCase());
1620
- if (buf) rawChunks.push(buf);
1621
- }
1622
- await client.logout();
1623
-
1624
- if (rawChunks.length === 0) throw new Error(`No data returned for part "${partId}" of UID ${uid}`);
1625
-
1626
- const raw = Buffer.concat(rawChunks);
1627
-
1628
- if (isPaginated) {
1629
- // Paginated: return raw encoded bytes without transfer-encoding decode
1630
- const fetchOffset = offset ?? 0;
1631
- const actualLength = raw.length;
1632
- const hasMore = att.size ? (fetchOffset + actualLength < att.size) : false;
1633
- return {
1634
- uid, partId,
1635
- filename: att.filename,
1636
- mimeType: att.mimeType,
1637
- encoding: att.encoding,
1638
- totalSize: att.size,
1639
- offset: fetchOffset,
1640
- length: actualLength,
1641
- hasMore,
1642
- data: raw.toString('base64'),
1643
- dataEncoding: 'base64'
1644
- };
1645
- }
1646
-
1647
- // Full download: decode transfer encoding
1648
- const encoding = att.encoding.toLowerCase();
1649
- let decoded;
1650
- if (encoding === 'base64') {
1651
- decoded = Buffer.from(raw.toString('ascii').replace(/\s/g, ''), 'base64');
1652
- } else if (encoding === 'quoted-printable') {
1653
- const qp = raw.toString('binary').replace(/=\r?\n/g, '').replace(/=([0-9A-Fa-f]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16)));
1654
- decoded = Buffer.from(qp, 'binary');
1655
- } else {
1656
- decoded = raw; // 7bit / 8bit / binary
1657
- }
1658
-
1659
- return {
1660
- uid,
1661
- partId,
1662
- filename: att.filename,
1663
- mimeType: att.mimeType,
1664
- size: decoded.length,
1665
- encoding: att.encoding,
1666
- data: decoded.toString('base64'),
1667
- dataEncoding: 'base64'
1668
- };
1669
- }
1670
-
1671
- async function flagEmail(uid, flagged, mailbox = 'INBOX') {
1672
- const client = createRateLimitedClient();
1673
- await client.connect();
1674
- await client.mailboxOpen(mailbox);
1675
- if (flagged) {
1676
- await client.messageFlagsAdd(uid, ['\\Flagged'], { uid: true });
1677
- } else {
1678
- await client.messageFlagsRemove(uid, ['\\Flagged'], { uid: true });
1679
- }
1680
- await client.logout();
1681
- return true;
1682
- }
1683
-
1684
- async function markAsRead(uid, seen, mailbox = 'INBOX') {
1685
- const client = createRateLimitedClient();
1686
- await client.connect();
1687
- await client.mailboxOpen(mailbox);
1688
- if (seen) {
1689
- await client.messageFlagsAdd(uid, ['\\Seen'], { uid: true });
1690
- } else {
1691
- await client.messageFlagsRemove(uid, ['\\Seen'], { uid: true });
1692
- }
1693
- await client.logout();
1694
- return true;
1695
- }
1696
-
1697
- async function deleteEmail(uid, mailbox = 'INBOX') {
1698
- const client = createRateLimitedClient();
1699
- await client.connect();
1700
- await client.mailboxOpen(mailbox);
1701
- await client.messageDelete(uid, { uid: true });
1702
- await client.logout();
1703
- return true;
1704
- }
1705
-
1706
- async function listMailboxes() {
1707
- const client = createRateLimitedClient();
1708
- await client.connect();
1709
- const tree = await client.listTree();
1710
- const mailboxes = [];
1711
- function walk(items) {
1712
- for (const item of items) {
1713
- mailboxes.push({ name: item.name, path: item.path });
1714
- if (item.folders && item.folders.length > 0) walk(item.folders);
1715
- }
1716
- }
1717
- walk(tree.folders);
1718
- await client.logout();
1719
- return mailboxes;
1720
- }
1721
-
1722
- async function searchEmails(query, mailbox = 'INBOX', limit = 10, filters = {}, options = {}) {
1723
- const { queryMode = 'or', subjectQuery, bodyQuery, fromQuery, includeSnippet = false } = options;
1724
- const client = createRateLimitedClient();
1725
- await client.connect();
1726
- await client.mailboxOpen(mailbox);
1727
-
1728
- // Build text query
1729
- let textQuery;
1730
- const targetedParts = [];
1731
- if (subjectQuery) targetedParts.push({ subject: subjectQuery });
1732
- if (bodyQuery) targetedParts.push({ body: bodyQuery });
1733
- if (fromQuery) targetedParts.push({ from: fromQuery });
26
+ const IMAP_USER = process.env.IMAP_USER;
27
+ const IMAP_PASSWORD = process.env.IMAP_PASSWORD;
1734
28
 
1735
- if (targetedParts.length > 0) {
1736
- // Targeted field queries
1737
- if (queryMode === 'and') {
1738
- textQuery = Object.assign({}, ...targetedParts); // IMAP AND is implicit
1739
- } else {
1740
- textQuery = targetedParts.length === 1 ? targetedParts[0] : { or: targetedParts };
1741
- }
1742
- } else if (query) {
1743
- // Original OR across subject/from/body
1744
- textQuery = { or: [{ subject: query }, { from: query }, { body: query }] };
29
+ if (!IMAP_USER || !IMAP_PASSWORD) {
30
+ if (process.argv.includes('--doctor')) {
31
+ // Doctor will handle missing credentials with friendly output
1745
32
  } else {
1746
- textQuery = null;
1747
- }
1748
-
1749
- const extraQuery = buildQuery(filters);
1750
- const hasExtra = Object.keys(extraQuery).length > 0 && !extraQuery.all;
1751
- const finalQuery = textQuery
1752
- ? (hasExtra ? { ...textQuery, ...extraQuery } : textQuery)
1753
- : (hasExtra ? extraQuery : { all: true });
1754
-
1755
- let uids = (await client.search(finalQuery, { uid: true })) ?? [];
1756
- if (!Array.isArray(uids)) uids = [];
1757
-
1758
- if (filters.hasAttachment) {
1759
- if (uids.length > ATTACHMENT_SCAN_LIMIT) {
1760
- await client.logout();
1761
- return { total: null, showing: 0, emails: [], error: `hasAttachment requires narrower filters first — ${uids.length} candidates exceeds scan limit of ${ATTACHMENT_SCAN_LIMIT}. Add from/since/before/subject filters to reduce the set.` };
1762
- }
1763
- uids = await filterUidsByAttachment(client, uids);
1764
- }
1765
-
1766
- const emails = [];
1767
- const recentUids = uids.slice(-limit).reverse();
1768
- for (const uid of recentUids) {
1769
- const msg = await client.fetchOne(uid, { envelope: true, flags: true }, { uid: true });
1770
- if (msg) {
1771
- emails.push({
1772
- uid,
1773
- subject: msg.envelope.subject,
1774
- from: msg.envelope.from?.[0]?.address,
1775
- date: msg.envelope.date,
1776
- flagged: msg.flags.has('\\Flagged'),
1777
- seen: msg.flags.has('\\Seen')
1778
- });
1779
- }
1780
- }
1781
-
1782
- // Fetch body snippets if requested (max 10 emails to avoid timeout)
1783
- if (includeSnippet && emails.length > 0) {
1784
- for (const email of emails.slice(0, 10)) {
1785
- try {
1786
- const meta = await client.fetchOne(email.uid, { bodyStructure: true }, { uid: true });
1787
- if (!meta?.bodyStructure) continue;
1788
- const textPart = findTextPart(meta.bodyStructure);
1789
- if (!textPart) continue;
1790
- const imapKey = textPart.partId ?? 'TEXT';
1791
- const partMsg = await client.fetchOne(email.uid, {
1792
- bodyParts: [{ key: imapKey, start: 0, maxLength: 400 }]
1793
- }, { uid: true });
1794
- const buf = partMsg?.bodyParts?.get(imapKey)
1795
- ?? partMsg?.bodyParts?.get(imapKey.toUpperCase())
1796
- ?? partMsg?.bodyParts?.get(imapKey.toLowerCase());
1797
- if (!buf) continue;
1798
- const decoded = decodeTransferEncoding(buf, textPart.encoding);
1799
- let text = await decodeCharset(decoded, textPart.charset);
1800
- if (textPart.type === 'text/html') text = stripHtml(text);
1801
- email.snippet = text.replace(/\s+/g, ' ').slice(0, 200).trim();
1802
- } catch { /* skip snippet on error */ }
1803
- }
1804
- }
1805
-
1806
- await client.logout();
1807
- return { total: uids.length, showing: emails.length, emails };
1808
- }
1809
-
1810
- async function moveEmail(uid, targetMailbox, sourceMailbox = 'INBOX') {
1811
- const client = createRateLimitedClient();
1812
- await client.connect();
1813
- await client.mailboxOpen(sourceMailbox);
1814
- await client.messageMove(uid, targetMailbox, { uid: true });
1815
- await client.logout();
1816
- return true;
1817
- }
1818
-
1819
- function buildQuery(filters) {
1820
- const query = {};
1821
- if (filters.sender) query.from = filters.sender;
1822
- if (filters.domain) query.from = filters.domain.replace(/^@/, '');
1823
- if (filters.subject) query.subject = filters.subject;
1824
- if (filters.before) query.before = new Date(filters.before);
1825
- if (filters.since) query.since = new Date(filters.since);
1826
- if (filters.unread === true) query.seen = false;
1827
- if (filters.unread === false) query.seen = true;
1828
- if (filters.flagged === true) query.flagged = true;
1829
- if (filters.flagged === false) query.unflagged = true;
1830
- if (filters.larger) query.larger = filters.larger * 1024;
1831
- if (filters.smaller) query.smaller = filters.smaller * 1024;
1832
- // hasAttachment is handled as a client-side post-filter (see filterUidsByAttachment)
1833
- // iCloud does not support SEARCH HEADER or reliable size-based attachment detection
1834
- if (Object.keys(query).length === 0) query.all = true;
1835
- return query;
1836
- }
1837
-
1838
- async function filterUidsByAttachment(client, uids) {
1839
- if (uids.length === 0) return [];
1840
- const result = [];
1841
- for await (const msg of client.fetch(uids, { bodyStructure: true }, { uid: true })) {
1842
- if (msg.bodyStructure && findAttachments(msg.bodyStructure).length > 0) {
1843
- result.push(msg.uid);
1844
- }
1845
- }
1846
- return result;
1847
- }
1848
-
1849
- async function ensureMailbox(name) {
1850
- const client = createRateLimitedClient();
1851
- await client.connect();
1852
- try { await client.mailboxCreate(name); } catch { /* already exists */ }
1853
- await client.logout();
1854
- }
1855
-
1856
- async function bulkMove(filters, targetMailbox, sourceMailbox = 'INBOX', dryRun = false, limit = null) {
1857
- const client = createRateLimitedClient();
1858
- await client.connect();
1859
- await client.mailboxOpen(sourceMailbox);
1860
- const query = buildQuery(filters);
1861
- let uids = (await client.search(query, { uid: true })) ?? [];
1862
- if (filters.hasAttachment) {
1863
- if (uids.length > ATTACHMENT_SCAN_LIMIT) {
1864
- await client.logout();
1865
- return { error: `hasAttachment requires narrower filters first — ${uids.length} candidates exceeds scan limit of ${ATTACHMENT_SCAN_LIMIT}.` };
1866
- }
1867
- uids = await filterUidsByAttachment(client, uids);
1868
- }
1869
- await client.logout();
1870
-
1871
- if (limit !== null) uids = uids.slice(0, limit);
1872
-
1873
- if (dryRun) {
1874
- return { dryRun: true, wouldMove: uids.length, sourceMailbox, targetMailbox, filters };
1875
- }
1876
- if (uids.length === 0) return { moved: 0, sourceMailbox, targetMailbox };
1877
-
1878
- await ensureMailbox(targetMailbox);
1879
- const result = await safeMoveEmails(uids, sourceMailbox, targetMailbox);
1880
- return { ...result, sourceMailbox, targetMailbox, filters };
1881
- }
1882
-
1883
- // ─── IMPROVEMENT 3: bulk_delete now has per-chunk timeout ─────────────────────
1884
- // Previously the chunk loop could run unbounded. Now each chunk gets a BULK_OP
1885
- // timeout. If a single chunk hangs, we bail with a partial result instead of
1886
- // hanging forever.
1887
-
1888
- async function bulkDelete(filters, sourceMailbox = 'INBOX', dryRun = false) {
1889
- const client = createRateLimitedClient();
1890
- await client.connect();
1891
- await client.mailboxOpen(sourceMailbox);
1892
- const query = buildQuery(filters);
1893
- let uids = (await client.search(query, { uid: true })) ?? [];
1894
- if (filters.hasAttachment) {
1895
- if (uids.length > ATTACHMENT_SCAN_LIMIT) {
1896
- await client.logout();
1897
- return { error: `hasAttachment requires narrower filters first — ${uids.length} candidates exceeds scan limit of ${ATTACHMENT_SCAN_LIMIT}.` };
1898
- }
1899
- uids = await filterUidsByAttachment(client, uids);
1900
- }
1901
-
1902
- if (dryRun) {
1903
- await client.logout();
1904
- return { dryRun: true, wouldDelete: uids.length, sourceMailbox, filters };
1905
- }
1906
- if (uids.length === 0) { await client.logout(); return { deleted: 0, sourceMailbox }; }
1907
-
1908
- let deleted = 0;
1909
- for (let i = 0; i < uids.length; i += CHUNK_SIZE) {
1910
- const chunk = uids.slice(i, i + CHUNK_SIZE);
1911
- const chunkIndex = Math.floor(i / CHUNK_SIZE);
1912
- try {
1913
- await withTimeout(`bulk_delete chunk ${chunkIndex}`, TIMEOUT.BULK_OP, async () => {
1914
- await client.messageDelete(chunk, { uid: true });
1915
- });
1916
- deleted += chunk.length;
1917
- } catch (err) {
1918
- await safeClose(client);
1919
- return {
1920
- deleted,
1921
- failed: uids.length - deleted,
1922
- sourceMailbox,
1923
- filters,
1924
- error: `Chunk ${chunkIndex} failed: ${err.message}. ${deleted} deleted so far, ${uids.length - deleted} remaining.`
1925
- };
1926
- }
1927
- }
1928
- await client.logout();
1929
- return { deleted, sourceMailbox, filters };
1930
- }
1931
-
1932
- async function countEmails(filters, mailbox = 'INBOX') {
1933
- const client = createRateLimitedClient();
1934
- await client.connect();
1935
- await client.mailboxOpen(mailbox);
1936
- const query = buildQuery(filters);
1937
- let uids = (await client.search(query, { uid: true })) ?? [];
1938
- if (filters.hasAttachment) {
1939
- if (uids.length > ATTACHMENT_SCAN_LIMIT) {
1940
- await client.logout();
1941
- return { count: null, candidateCount: uids.length, mailbox, filters, error: `hasAttachment requires narrower filters first — ${uids.length} candidates exceeds scan limit of ${ATTACHMENT_SCAN_LIMIT}. Add from/since/before/subject filters to reduce the set.` };
1942
- }
1943
- uids = await filterUidsByAttachment(client, uids);
1944
- }
1945
- await client.logout();
1946
- return { count: uids.length, mailbox, filters };
1947
- }
1948
-
1949
- // ─── Session Log ──────────────────────────────────────────────────────────────
1950
-
1951
- function logRead() {
1952
- if (!existsSync(LOG_FILE)) return { steps: [], startedAt: null };
1953
- try {
1954
- return JSON.parse(readFileSync(LOG_FILE, 'utf8'));
1955
- } catch {
1956
- return { steps: [], startedAt: null };
33
+ process.stderr.write('Error: IMAP_USER and IMAP_PASSWORD environment variables are required\n');
34
+ process.exit(1);
1957
35
  }
1958
36
  }
1959
37
 
1960
- function logWrite(step) {
1961
- const log = logRead();
1962
- if (!log.startedAt) log.startedAt = new Date().toISOString();
1963
- log.steps.push({ time: new Date().toISOString(), step });
1964
- writeFileSync(LOG_FILE, JSON.stringify(log, null, 2));
1965
- return log;
1966
- }
1967
-
1968
- function logClear() {
1969
- writeFileSync(LOG_FILE, JSON.stringify({ steps: [], startedAt: null }, null, 2));
1970
- return { cleared: true };
1971
- }
1972
-
1973
38
  // ─── MCP Server ───────────────────────────────────────────────────────────────
1974
39
 
1975
40
  async function main() {
1976
41
  const server = new Server(
1977
- { name: 'icloud-mail', version: '2.0.0' },
42
+ { name: 'icloud-mail', version: '2.3.0' },
1978
43
  { capabilities: { tools: {} } }
1979
44
  );
1980
45
 
@@ -2479,6 +544,340 @@ async function main() {
2479
544
  },
2480
545
  required: ['uid']
2481
546
  }
547
+ },
548
+ // ── Saved Rules ──
549
+ {
550
+ name: 'create_rule',
551
+ 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.',
552
+ inputSchema: {
553
+ type: 'object',
554
+ properties: {
555
+ name: { type: 'string', description: 'Unique rule name (used to run or delete the rule)' },
556
+ description: { type: 'string', description: 'Optional human-readable description of what the rule does' },
557
+ filters: {
558
+ type: 'object',
559
+ description: 'Email filters (same as bulk_move/bulk_delete filters: sender, domain, subject, before, since, unread, flagged, larger, smaller)',
560
+ properties: filtersSchema
561
+ },
562
+ action: {
563
+ type: 'object',
564
+ description: 'Action to apply to matching emails',
565
+ properties: {
566
+ type: { type: 'string', enum: ['move', 'delete', 'mark_read', 'mark_unread', 'flag', 'unflag'], description: 'Action type' },
567
+ targetMailbox: { type: 'string', description: 'Destination folder (required for move)' },
568
+ sourceMailbox: { type: 'string', description: 'Source mailbox (default INBOX)' }
569
+ },
570
+ required: ['type']
571
+ }
572
+ },
573
+ required: ['name', 'filters', 'action']
574
+ }
575
+ },
576
+ {
577
+ name: 'list_rules',
578
+ description: 'List all saved rules with their filters, actions, and run history.',
579
+ inputSchema: { type: 'object', properties: {} }
580
+ },
581
+ {
582
+ name: 'run_rule',
583
+ description: 'Run a specific saved rule by name. Use dryRun: true to preview what would be affected without making changes.',
584
+ inputSchema: {
585
+ type: 'object',
586
+ properties: {
587
+ name: { type: 'string', description: 'Rule name to run' },
588
+ dryRun: { type: 'boolean', description: 'If true, preview what would be affected without making changes' }
589
+ },
590
+ required: ['name']
591
+ }
592
+ },
593
+ {
594
+ name: 'delete_rule',
595
+ description: 'Delete a saved rule by name.',
596
+ inputSchema: {
597
+ type: 'object',
598
+ properties: {
599
+ name: { type: 'string', description: 'Rule name to delete' }
600
+ },
601
+ required: ['name']
602
+ }
603
+ },
604
+ {
605
+ name: 'run_all_rules',
606
+ description: 'Run all saved rules in sequence. Use dryRun: true to preview all rules without making changes.',
607
+ inputSchema: {
608
+ type: 'object',
609
+ properties: {
610
+ dryRun: { type: 'boolean', description: 'If true, preview all rules without making changes' }
611
+ }
612
+ }
613
+ },
614
+ // ── SMTP / Email sending ──
615
+ {
616
+ name: 'compose_email',
617
+ 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).',
618
+ inputSchema: {
619
+ type: 'object',
620
+ properties: {
621
+ to: { type: 'string', description: 'Recipient email address(es), comma-separated or array' },
622
+ subject: { type: 'string', description: 'Email subject' },
623
+ body: { type: 'string', description: 'Plain text body (used as fallback when html is also provided)' },
624
+ html: { type: 'string', description: 'HTML body. If provided without body, plain text is auto-generated. If provided with body, sends multipart/alternative.' },
625
+ cc: { type: 'string', description: 'CC recipient(s), comma-separated or array' },
626
+ bcc: { type: 'string', description: 'BCC recipient(s), comma-separated or array' },
627
+ replyTo: { type: 'string', description: 'Reply-To address override' }
628
+ },
629
+ required: ['to', 'subject']
630
+ }
631
+ },
632
+ {
633
+ name: 'reply_to_email',
634
+ 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.',
635
+ inputSchema: {
636
+ type: 'object',
637
+ properties: {
638
+ uid: { type: 'number', description: 'UID of the email to reply to' },
639
+ body: { type: 'string', description: 'Plain text reply body' },
640
+ html: { type: 'string', description: 'HTML reply body (auto-generates plain text fallback if body not provided)' },
641
+ mailbox: { type: 'string', description: 'Mailbox containing the original email (default INBOX)' },
642
+ replyAll: { type: 'boolean', description: 'If true, reply to all recipients (To + Cc). Default false.' },
643
+ cc: { type: 'string', description: 'Additional CC recipients for this reply' }
644
+ },
645
+ required: ['uid']
646
+ }
647
+ },
648
+ {
649
+ name: 'forward_email',
650
+ 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.',
651
+ inputSchema: {
652
+ type: 'object',
653
+ properties: {
654
+ uid: { type: 'number', description: 'UID of the email to forward' },
655
+ to: { type: 'string', description: 'Recipient(s) to forward to, comma-separated or array' },
656
+ note: { type: 'string', description: 'Optional plain text note to prepend before the forwarded message' },
657
+ html: { type: 'string', description: 'Optional HTML note to prepend (overrides plain text note for HTML rendering)' },
658
+ mailbox: { type: 'string', description: 'Mailbox containing the original email (default INBOX)' },
659
+ cc: { type: 'string', description: 'CC recipients' }
660
+ },
661
+ required: ['uid', 'to']
662
+ }
663
+ },
664
+ {
665
+ name: 'save_draft',
666
+ 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.',
667
+ inputSchema: {
668
+ type: 'object',
669
+ properties: {
670
+ to: { type: 'string', description: 'Intended recipient(s), comma-separated or array' },
671
+ subject: { type: 'string', description: 'Email subject' },
672
+ body: { type: 'string', description: 'Plain text body (used as fallback when html is also provided)' },
673
+ html: { type: 'string', description: 'HTML body. If provided without body, plain text is auto-generated. If provided with body, saves multipart/alternative.' },
674
+ cc: { type: 'string', description: 'CC recipient(s)' },
675
+ bcc: { type: 'string', description: 'BCC recipient(s)' }
676
+ },
677
+ required: ['to', 'subject']
678
+ }
679
+ },
680
+ // ── CardDAV / Contacts ──
681
+ {
682
+ name: 'list_contacts',
683
+ description: 'List contacts from iCloud Contacts. Returns names, phones, emails, and other fields.',
684
+ inputSchema: {
685
+ type: 'object',
686
+ properties: {
687
+ limit: { type: 'number', description: 'Max contacts to return (default 50)' },
688
+ offset: { type: 'number', description: 'Skip this many contacts (default 0, for pagination)' }
689
+ }
690
+ }
691
+ },
692
+ {
693
+ name: 'search_contacts',
694
+ description: 'Search iCloud Contacts by name, email address, or phone number.',
695
+ inputSchema: {
696
+ type: 'object',
697
+ properties: {
698
+ query: { type: 'string', description: 'Text to search for (matched against name, email, and phone)' }
699
+ },
700
+ required: ['query']
701
+ }
702
+ },
703
+ {
704
+ name: 'get_contact',
705
+ description: 'Get full details for a specific contact by ID. Use list_contacts or search_contacts to find a contactId.',
706
+ inputSchema: {
707
+ type: 'object',
708
+ properties: {
709
+ contactId: { type: 'string', description: 'Contact ID (UUID from list_contacts or search_contacts)' }
710
+ },
711
+ required: ['contactId']
712
+ }
713
+ },
714
+ {
715
+ name: 'create_contact',
716
+ description: 'Create a new contact in iCloud Contacts.',
717
+ inputSchema: {
718
+ type: 'object',
719
+ properties: {
720
+ firstName: { type: 'string', description: 'First name' },
721
+ lastName: { type: 'string', description: 'Last name' },
722
+ fullName: { type: 'string', description: 'Full display name (overrides firstName + lastName for FN field)' },
723
+ org: { type: 'string', description: 'Organization / company name' },
724
+ phone: { type: 'string', description: 'Primary phone number (shorthand for phones array)' },
725
+ email: { type: 'string', description: 'Primary email address (shorthand for emails array)' },
726
+ phones: { type: 'array', description: 'Array of phone objects: [{ number, type }] where type is cell/home/work/etc.' },
727
+ emails: { type: 'array', description: 'Array of email objects: [{ email, type }] where type is home/work/etc.' },
728
+ addresses: { type: 'array', description: 'Array of address objects: [{ street, city, state, zip, country, type }]' },
729
+ birthday: { type: 'string', description: 'Birthday in YYYY-MM-DD format' },
730
+ note: { type: 'string', description: 'Notes / free text' },
731
+ url: { type: 'string', description: 'Website URL' }
732
+ }
733
+ }
734
+ },
735
+ {
736
+ name: 'update_contact',
737
+ description: 'Update an existing contact in iCloud Contacts. Only provided fields are changed; others are preserved.',
738
+ inputSchema: {
739
+ type: 'object',
740
+ properties: {
741
+ contactId: { type: 'string', description: 'Contact ID to update' },
742
+ firstName: { type: 'string' },
743
+ lastName: { type: 'string' },
744
+ fullName: { type: 'string' },
745
+ org: { type: 'string' },
746
+ phone: { type: 'string' },
747
+ email: { type: 'string' },
748
+ phones: { type: 'array' },
749
+ emails: { type: 'array' },
750
+ addresses: { type: 'array' },
751
+ birthday: { type: 'string' },
752
+ note: { type: 'string' },
753
+ url: { type: 'string' }
754
+ },
755
+ required: ['contactId']
756
+ }
757
+ },
758
+ {
759
+ name: 'delete_contact',
760
+ description: 'Delete a contact from iCloud Contacts permanently.',
761
+ inputSchema: {
762
+ type: 'object',
763
+ properties: {
764
+ contactId: { type: 'string', description: 'Contact ID to delete' }
765
+ },
766
+ required: ['contactId']
767
+ }
768
+ },
769
+ // ── CalDAV / Calendar ──
770
+ {
771
+ name: 'list_calendars',
772
+ description: 'List all calendars in iCloud Calendar (e.g. Personal, Work, LSAT PREP). Returns calendarId, name, and supported event types.',
773
+ inputSchema: { type: 'object', properties: {} }
774
+ },
775
+ {
776
+ name: 'list_events',
777
+ description: 'List events in a specific iCloud calendar within a date range. Use list_calendars first to get a calendarId.',
778
+ inputSchema: {
779
+ type: 'object',
780
+ properties: {
781
+ calendarId: { type: 'string', description: 'Calendar ID from list_calendars' },
782
+ since: { type: 'string', description: 'Start of range (YYYY-MM-DD, default: 30 days ago)' },
783
+ before: { type: 'string', description: 'End of range (YYYY-MM-DD, default: 30 days ahead)' },
784
+ limit: { type: 'number', description: 'Max events to return (default 50)' }
785
+ },
786
+ required: ['calendarId']
787
+ }
788
+ },
789
+ {
790
+ name: 'get_event',
791
+ description: 'Get full details of a specific calendar event by its ID.',
792
+ inputSchema: {
793
+ type: 'object',
794
+ properties: {
795
+ calendarId: { type: 'string', description: 'Calendar ID containing the event' },
796
+ eventId: { type: 'string', description: 'Event ID (UUID from list_events or search_events)' }
797
+ },
798
+ required: ['calendarId', 'eventId']
799
+ }
800
+ },
801
+ {
802
+ name: 'create_event',
803
+ description: 'Create a new event in an iCloud calendar. For all-day events use allDay:true and YYYY-MM-DD for start/end.',
804
+ inputSchema: {
805
+ type: 'object',
806
+ properties: {
807
+ calendarId: { type: 'string', description: 'Calendar ID to add the event to' },
808
+ summary: { type: 'string', description: 'Event title' },
809
+ start: { type: 'string', description: 'Start date/time — ISO 8601 (e.g. 2026-03-15T10:00:00) or YYYY-MM-DD for all-day' },
810
+ end: { type: 'string', description: 'End date/time — ISO 8601 or YYYY-MM-DD. Defaults to 1 hour after start.' },
811
+ timezone: { type: 'string', description: 'IANA timezone (e.g. America/New_York). Use "UTC" or omit for UTC.' },
812
+ allDay: { type: 'boolean', description: 'True for all-day event (uses DATE values, no time)' },
813
+ description: { type: 'string', description: 'Event description / notes' },
814
+ location: { type: 'string', description: 'Event location' },
815
+ recurrence: { type: 'string', description: 'iCal RRULE string (e.g. FREQ=WEEKLY;BYDAY=MO,WE,FR)' },
816
+ status: { type: 'string', description: 'Event status: CONFIRMED, TENTATIVE, or CANCELLED' },
817
+ reminder: { type: 'number', description: 'Alert this many minutes before the event (default 30, set to 0 to disable)' }
818
+ },
819
+ required: ['calendarId', 'summary', 'start']
820
+ }
821
+ },
822
+ {
823
+ name: 'update_event',
824
+ description: 'Update an existing calendar event. Only provided fields are changed; others are preserved.',
825
+ inputSchema: {
826
+ type: 'object',
827
+ properties: {
828
+ calendarId: { type: 'string', description: 'Calendar ID containing the event' },
829
+ eventId: { type: 'string', description: 'Event ID to update' },
830
+ summary: { type: 'string' },
831
+ start: { type: 'string' },
832
+ end: { type: 'string' },
833
+ timezone: { type: 'string' },
834
+ allDay: { type: 'boolean' },
835
+ description: { type: 'string' },
836
+ location: { type: 'string' },
837
+ recurrence: { type: 'string' },
838
+ status: { type: 'string' },
839
+ reminder: { type: 'number', description: 'Alert minutes before event (0 to disable)' }
840
+ },
841
+ required: ['calendarId', 'eventId']
842
+ }
843
+ },
844
+ {
845
+ name: 'delete_event',
846
+ description: 'Delete a calendar event permanently from iCloud Calendar.',
847
+ inputSchema: {
848
+ type: 'object',
849
+ properties: {
850
+ calendarId: { type: 'string', description: 'Calendar ID containing the event' },
851
+ eventId: { type: 'string', description: 'Event ID to delete' }
852
+ },
853
+ required: ['calendarId', 'eventId']
854
+ }
855
+ },
856
+ {
857
+ name: 'search_events',
858
+ description: 'Search for events by title/summary across all calendars within an optional date range.',
859
+ inputSchema: {
860
+ type: 'object',
861
+ properties: {
862
+ query: { type: 'string', description: 'Text to search for in event titles' },
863
+ since: { type: 'string', description: 'Start of search range (YYYY-MM-DD, default: 1 year ago)' },
864
+ before: { type: 'string', description: 'End of search range (YYYY-MM-DD, default: 1 year ahead)' }
865
+ },
866
+ required: ['query']
867
+ }
868
+ },
869
+ // ── Smart extraction ──
870
+ {
871
+ name: 'suggest_event_from_email',
872
+ 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.',
873
+ inputSchema: {
874
+ type: 'object',
875
+ properties: {
876
+ uid: { type: 'number', description: 'Email UID to extract event from' },
877
+ mailbox: { type: 'string', description: 'Mailbox containing the email (default INBOX)' }
878
+ },
879
+ required: ['uid']
880
+ }
2482
881
  }
2483
882
  ]
2484
883
  }));
@@ -2587,6 +986,104 @@ async function main() {
2587
986
  result = logRead();
2588
987
  } else if (name === 'log_clear') {
2589
988
  result = logClear();
989
+ // ── Saved rules (synchronous CRUD; run_rule/run_all_rules use internal chunk timeouts) ──
990
+ } else if (name === 'create_rule') {
991
+ result = createRule(args.name, args.filters || {}, args.action, args.description || '');
992
+ } else if (name === 'list_rules') {
993
+ result = listRules();
994
+ } else if (name === 'delete_rule') {
995
+ result = deleteRule(args.name);
996
+ } else if (name === 'run_rule') {
997
+ result = await runRule(args.name, args.dryRun || false);
998
+ } else if (name === 'run_all_rules') {
999
+ result = await runAllRules(args.dryRun || false);
1000
+ // ── SMTP (email sending — uses SCAN tier 60s for two-phase fetch+send) ──
1001
+ } else if (name === 'compose_email') {
1002
+ result = await withTimeout('compose_email', TIMEOUT.SCAN, () =>
1003
+ composeEmail(args.to, args.subject, args.body, { html: args.html, cc: args.cc, bcc: args.bcc, replyTo: args.replyTo })
1004
+ );
1005
+ } else if (name === 'reply_to_email') {
1006
+ const origEmail = await withTimeout('get_email_for_reply', TIMEOUT.FETCH, () =>
1007
+ getEmailContent(args.uid, args.mailbox || 'INBOX', 5000, true)
1008
+ );
1009
+ result = await withTimeout('reply_to_email', TIMEOUT.FETCH, () =>
1010
+ replyToEmail(origEmail, args.body, { html: args.html, replyAll: args.replyAll || false, cc: args.cc })
1011
+ );
1012
+ } else if (name === 'forward_email') {
1013
+ const origEmail = await withTimeout('get_email_for_forward', TIMEOUT.FETCH, () =>
1014
+ getEmailContent(args.uid, args.mailbox || 'INBOX', 5000, false)
1015
+ );
1016
+ result = await withTimeout('forward_email', TIMEOUT.FETCH, () =>
1017
+ forwardEmail(origEmail, args.to, args.note || '', { html: args.html, cc: args.cc })
1018
+ );
1019
+ } else if (name === 'save_draft') {
1020
+ result = await withTimeout('save_draft', TIMEOUT.FETCH, () =>
1021
+ saveDraft(args.to, args.subject, args.body, { html: args.html, cc: args.cc, bcc: args.bcc })
1022
+ );
1023
+ // ── CardDAV / Contacts (FETCH tier 30s) ──
1024
+ } else if (name === 'list_contacts') {
1025
+ result = await withTimeout('list_contacts', TIMEOUT.FETCH, () =>
1026
+ listContacts(args.limit || 50, args.offset || 0)
1027
+ );
1028
+ } else if (name === 'search_contacts') {
1029
+ result = await withTimeout('search_contacts', TIMEOUT.FETCH, () =>
1030
+ searchContacts(args.query)
1031
+ );
1032
+ } else if (name === 'get_contact') {
1033
+ result = await withTimeout('get_contact', TIMEOUT.FETCH, () =>
1034
+ getContact(args.contactId)
1035
+ );
1036
+ } else if (name === 'create_contact') {
1037
+ const { contactId: _ignore, ...fields } = args;
1038
+ result = await withTimeout('create_contact', TIMEOUT.FETCH, () =>
1039
+ createContact(fields)
1040
+ );
1041
+ } else if (name === 'update_contact') {
1042
+ const { contactId, ...fields } = args;
1043
+ result = await withTimeout('update_contact', TIMEOUT.FETCH, () =>
1044
+ updateContact(contactId, fields)
1045
+ );
1046
+ } else if (name === 'delete_contact') {
1047
+ result = await withTimeout('delete_contact', TIMEOUT.SINGLE, () =>
1048
+ deleteContact(args.contactId)
1049
+ );
1050
+ // ── CalDAV / Calendar (FETCH tier 30s) ──
1051
+ } else if (name === 'list_calendars') {
1052
+ result = await withTimeout('list_calendars', TIMEOUT.FETCH, () =>
1053
+ listCalendars()
1054
+ );
1055
+ } else if (name === 'list_events') {
1056
+ result = await withTimeout('list_events', TIMEOUT.FETCH, () =>
1057
+ listEvents(args.calendarId, args.since || null, args.before || null, args.limit || 50)
1058
+ );
1059
+ } else if (name === 'get_event') {
1060
+ result = await withTimeout('get_event', TIMEOUT.FETCH, () =>
1061
+ getEvent(args.calendarId, args.eventId)
1062
+ );
1063
+ } else if (name === 'create_event') {
1064
+ const { calendarId, ...fields } = args;
1065
+ result = await withTimeout('create_event', TIMEOUT.FETCH, () =>
1066
+ createEvent(calendarId, fields)
1067
+ );
1068
+ } else if (name === 'update_event') {
1069
+ const { calendarId, eventId, ...fields } = args;
1070
+ result = await withTimeout('update_event', TIMEOUT.FETCH, () =>
1071
+ updateEvent(calendarId, eventId, fields)
1072
+ );
1073
+ } else if (name === 'delete_event') {
1074
+ result = await withTimeout('delete_event', TIMEOUT.SINGLE, () =>
1075
+ deleteEvent(args.calendarId, args.eventId)
1076
+ );
1077
+ } else if (name === 'search_events') {
1078
+ result = await withTimeout('search_events', TIMEOUT.FETCH, () =>
1079
+ searchEvents(args.query, args.since || null, args.before || null)
1080
+ );
1081
+ // ── Smart extraction (SCAN tier 60s — LLM round-trip) ──
1082
+ } else if (name === 'suggest_event_from_email') {
1083
+ const email = await withTimeout('get_email_for_extraction', TIMEOUT.FETCH, () =>
1084
+ getEmailContent(args.uid, args.mailbox || 'INBOX', 10000, false)
1085
+ );
1086
+ result = formatEmailForExtraction(email);
2590
1087
  } else {
2591
1088
  throw new Error(`Unknown tool: ${name}`);
2592
1089
  }