icloud-mcp 1.4.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +37 -8
  2. package/index.js +554 -223
  3. package/package.json +1 -1
  4. package/test.js +68 -26
package/README.md CHANGED
@@ -43,9 +43,40 @@ Then find the install location:
43
43
  npm root -g
44
44
  ```
45
45
 
46
- This will return a path like `/opt/homebrew/lib/node_modules` or `/usr/local/lib/node_modules`.
46
+ The path varies by setup:
47
47
 
48
- ### 3. Configure Claude Desktop
48
+ | Setup | Typical path |
49
+ |-------|-------------|
50
+ | Mac with Homebrew Node | `/opt/homebrew/lib/node_modules` |
51
+ | Mac with system Node | `/usr/local/lib/node_modules` |
52
+ | nvm | `~/.nvm/versions/node/v20.x.x/lib/node_modules` |
53
+
54
+ ### 3. Verify your setup
55
+
56
+ Before configuring Claude Desktop, run the doctor command to confirm everything is working:
57
+
58
+ ```bash
59
+ IMAP_USER="you@icloud.com" IMAP_PASSWORD="your-app-specific-password" node $(npm root -g)/icloud-mcp/index.js --doctor
60
+ ```
61
+
62
+ You should see:
63
+
64
+ ```
65
+ icloud-mcp doctor
66
+ ─────────────────────────────
67
+ ✅ IMAP_USER is set
68
+ ✅ IMAP_PASSWORD is set
69
+ ✅ IMAP_USER looks like an email address
70
+ ✅ Connected to imap.mail.me.com:993
71
+ ✅ Authenticated as you@icloud.com
72
+ ✅ INBOX opened (12453 messages)
73
+ ─────────────────────────────
74
+ All checks passed. Ready to use with Claude Desktop.
75
+ ```
76
+
77
+ If any step fails, a plain-English explanation and suggested fix will be shown.
78
+
79
+ ### 4. Configure Claude Desktop
49
80
 
50
81
  Open your Claude Desktop config file:
51
82
 
@@ -53,7 +84,7 @@ Open your Claude Desktop config file:
53
84
  open ~/Library/Application\ Support/Claude/claude_desktop_config.json
54
85
  ```
55
86
 
56
- Add the following under `mcpServers`, replacing the path with your npm root path from the previous step:
87
+ Add the following under `mcpServers`, replacing the path with your npm root from step 2:
57
88
 
58
89
  ```json
59
90
  {
@@ -70,9 +101,7 @@ Add the following under `mcpServers`, replacing the path with your npm root path
70
101
  }
71
102
  ```
72
103
 
73
- > **Note:** If your `npm root -g` returned a different path, replace `/opt/homebrew/lib/node_modules` with that path.
74
-
75
- ### 4. Add Custom Instructions (Recommended)
104
+ ### 5. Add Custom Instructions (Recommended)
76
105
 
77
106
  For large inbox operations, add the following to Claude Desktop's custom instructions to ensure Claude stays on track and checks in with you regularly. Go to **Claude Desktop → Settings → Custom Instructions** and add:
78
107
 
@@ -85,7 +114,7 @@ When using icloud-mail tools:
85
114
  5. If you are ever unsure what you have done so far, call log_read before proceeding
86
115
  ```
87
116
 
88
- ### 5. Restart Claude Desktop
117
+ ### 6. Restart Claude Desktop
89
118
 
90
119
  Fully quit Claude Desktop (Cmd+Q) and reopen it. You should now be able to manage your iCloud inbox through Claude.
91
120
 
@@ -103,7 +132,7 @@ Fully quit Claude Desktop (Cmd+Q) and reopen it. You should now be able to manag
103
132
  | `get_emails_by_date_range` | Emails between two dates |
104
133
  | `search_emails` | Search by keyword with optional filters (date, unread, domain, etc.) |
105
134
  | `count_emails` | Count emails matching any combination of filters without modifying them |
106
- | `bulk_move` | Move emails matching any combination of filters between folders (supports `dryRun`) |
135
+ | `bulk_move` | Move emails matching any combination of filters between folders (supports `dryRun` and `limit`) |
107
136
  | `bulk_delete` | Delete emails matching any combination of filters (supports `dryRun`) |
108
137
  | `bulk_flag` | Flag or unflag emails matching any combination of filters |
109
138
  | `bulk_mark_read` | Mark all emails (or all from a sender) as read |
package/index.js CHANGED
@@ -15,23 +15,52 @@ const IMAP_USER = process.env.IMAP_USER;
15
15
  const IMAP_PASSWORD = process.env.IMAP_PASSWORD;
16
16
 
17
17
  if (!IMAP_USER || !IMAP_PASSWORD) {
18
- process.stderr.write('Error: IMAP_USER and IMAP_PASSWORD environment variables are required\n');
19
- process.exit(1);
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
+ }
20
24
  }
21
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
+
22
30
  function createClient() {
23
31
  return new ImapFlow({
24
32
  host: 'imap.mail.me.com',
25
33
  port: 993,
26
34
  secure: true,
27
35
  auth: { user: IMAP_USER, pass: IMAP_PASSWORD },
28
- logger: false
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
29
40
  });
30
41
  }
31
42
 
43
+ // ─── Managed client helpers ───────────────────────────────────────────────────
44
+
45
+ async function openClient(mailbox) {
46
+ const client = createClient();
47
+ await client.connect();
48
+ if (mailbox) await client.mailboxOpen(mailbox);
49
+ return client;
50
+ }
51
+
52
+ async function safeClose(client) {
53
+ try { await client.logout(); } catch { try { client.close(); } catch { /* already gone */ } }
54
+ }
55
+
56
+ async function reconnect(client, mailbox) {
57
+ safeClose(client);
58
+ return openClient(mailbox);
59
+ }
60
+
32
61
  // ─── Move Manifest ────────────────────────────────────────────────────────────
33
62
 
34
- const CHUNK_SIZE = 250;
63
+ const CHUNK_SIZE = 500;
35
64
  const CHUNK_SIZE_RETRY = 100;
36
65
 
37
66
  function readManifest() {
@@ -49,7 +78,9 @@ function writeManifest(data) {
49
78
 
50
79
  function updateManifest(updater) {
51
80
  const data = readManifest();
81
+ if (!data.current) return data; // guard: operation already archived/failed
52
82
  updater(data);
83
+ if (!data.current) return data; // guard: updater may have archived it
53
84
  data.current.updatedAt = new Date().toISOString();
54
85
  writeManifest(data);
55
86
  return data;
@@ -140,7 +171,9 @@ function startOperation(source, target, uids) {
140
171
 
141
172
  function updateChunk(index, updates) {
142
173
  updateManifest((data) => {
174
+ if (!data.current) return; // guard: operation already archived
143
175
  const chunk = data.current.chunks[index];
176
+ if (!chunk) return; // guard: chunk index out of range
144
177
  Object.assign(chunk, updates);
145
178
 
146
179
  let moved = 0, failed = 0, pending = 0;
@@ -222,188 +255,329 @@ function fingerprintToKey(fp) {
222
255
  return fp.messageId ?? fp.fallback;
223
256
  }
224
257
 
225
- // ─── IMAP Move Helpers ────────────────────────────────────────────────────────
258
+ // ─── Transient error detection ────────────────────────────────────────────────
226
259
 
227
- async function fetchEnvelopes(mailbox, uids) {
228
- const client = createClient();
229
- await client.connect();
230
- await client.mailboxOpen(mailbox);
231
- const envelopes = [];
232
- for await (const msg of client.fetch(uids, { envelope: true }, { uid: true })) {
233
- envelopes.push(msg);
260
+ function isTransient(err) {
261
+ const msg = err.message ?? '';
262
+ return msg.includes('ECONNRESET') ||
263
+ msg.includes('ECONNREFUSED') ||
264
+ msg.includes('ETIMEDOUT') ||
265
+ msg.includes('EPIPE') ||
266
+ msg.includes('socket hang up') ||
267
+ msg.includes('Connection not available') ||
268
+ msg.includes('BAD') ||
269
+ msg.includes('NO ');
270
+ }
271
+
272
+ async function withRetry(label, fn, maxAttempts = 3) {
273
+ let lastErr;
274
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
275
+ try {
276
+ return await fn();
277
+ } catch (err) {
278
+ lastErr = err;
279
+ if (!isTransient(err) || attempt === maxAttempts) throw err;
280
+ const delay = attempt * 2000;
281
+ process.stderr.write(`[retry] ${label} failed (attempt ${attempt}/${maxAttempts}): ${err.message} — retrying in ${delay}ms\n`);
282
+ await new Promise(r => setTimeout(r, delay));
283
+ }
234
284
  }
235
- await client.logout();
236
- return envelopes;
285
+ throw lastErr;
237
286
  }
238
287
 
239
- async function copyChunk(sourceMailbox, targetMailbox, uids) {
240
- const client = createClient();
241
- await client.connect();
242
- await client.mailboxOpen(sourceMailbox);
243
- await client.messageCopy(uids, targetMailbox, { uid: true });
244
- await client.logout();
288
+ // ─── Per-operation timeouts ───────────────────────────────────────────────────
289
+
290
+ const TIMEOUT = {
291
+ METADATA: 15_000,
292
+ FETCH: 30_000,
293
+ SCAN: 60_000,
294
+ BULK_OP: 60_000,
295
+ CHUNK: 300_000,
296
+ SINGLE: 15_000,
297
+ };
298
+
299
+ function withTimeout(label, ms, fn) {
300
+ let timer;
301
+ return Promise.race([
302
+ fn().finally(() => clearTimeout(timer)),
303
+ new Promise((_, reject) => {
304
+ timer = setTimeout(() => {
305
+ process.stderr.write(`[timeout] ${label} timed out after ${ms / 1000}s\n`);
306
+ reject(new Error(`${label} timed out after ${ms / 1000}s`));
307
+ }, ms);
308
+ })
309
+ ]);
245
310
  }
246
311
 
247
- async function deleteChunk(sourceMailbox, uids) {
248
- const client = createClient();
249
- await client.connect();
250
- await client.mailboxOpen(sourceMailbox);
251
- await client.messageDelete(uids, { uid: true });
252
- await client.logout();
312
+ // ─── Move logging ─────────────────────────────────────────────────────────────
313
+
314
+ function elapsed(startMs) {
315
+ return ((Date.now() - startMs) / 1000).toFixed(1) + 's';
253
316
  }
254
317
 
255
- async function verifyFingerprintsInTarget(targetMailbox, expectedFingerprints) {
256
- const client = createClient();
257
- await client.connect();
258
- const mb = await client.mailboxOpen(targetMailbox);
259
- const total = mb.exists;
318
+ function moveLog(chunkIndex, msg) {
319
+ process.stderr.write(`[move] chunk ${chunkIndex}: ${msg}\n`);
320
+ }
321
+
322
+ // ─── Verification (v3: envelope scan first, Message-ID fallback) ──────────────
323
+ // Strategy: one bulk FETCH of recent envelopes in the target is far faster than
324
+ // N individual SEARCH commands. We use envelope scan as the primary check, then
325
+ // only fall back to per-email Message-ID SEARCH for the few that didn't match
326
+ // (which can happen if the envelope fingerprint differs slightly between source
327
+ // and target, e.g. date normalization).
260
328
 
261
- // Fetch recent envelopes from target copies land at the end
262
- const fetchCount = Math.min(total, expectedFingerprints.length * 2 + 500);
329
+ async function verifyByEnvelopeScan(client, fingerprints, chunkIndex, knownTotal = null) {
330
+ if (fingerprints.length === 0) return { missing: [], found: 0 };
331
+
332
+ const t0 = Date.now();
333
+ const total = knownTotal ?? (await client.status(client.mailbox.path, { messages: true })).messages;
334
+ const fetchCount = Math.min(total, fingerprints.length + 150);
263
335
  const start = Math.max(1, total - fetchCount + 1);
264
336
  const range = `${start}:${total}`;
265
337
 
266
338
  const targetKeys = new Set();
339
+ let scanned = 0;
267
340
  for await (const msg of client.fetch(range, { envelope: true })) {
268
341
  const fp = buildFingerprint(msg);
269
342
  targetKeys.add(fingerprintToKey(fp));
343
+ scanned++;
270
344
  }
271
- await client.logout();
272
345
 
273
346
  const missing = [];
274
- for (const fp of expectedFingerprints) {
275
- const key = fingerprintToKey(fp);
276
- if (!targetKeys.has(key)) missing.push(fp);
347
+ for (const fp of fingerprints) {
348
+ if (!targetKeys.has(fingerprintToKey(fp))) missing.push(fp);
277
349
  }
278
350
 
279
- return {
280
- verified: missing.length === 0,
281
- missing,
282
- found: expectedFingerprints.length - missing.length,
283
- expected: expectedFingerprints.length
284
- };
351
+ moveLog(chunkIndex, `envelope scan: ${scanned} scanned, ${fingerprints.length - missing.length}/${fingerprints.length} matched (${elapsed(t0)})`);
352
+ return { missing, found: fingerprints.length - missing.length };
285
353
  }
286
354
 
287
- // ─── Transient error detection ────────────────────────────────────────────────
355
+ async function verifyByMessageId(client, fingerprints, chunkIndex) {
356
+ if (fingerprints.length === 0) return { missing: [], verified: 0 };
288
357
 
289
- function isTransient(err) {
290
- const msg = err.message ?? '';
291
- return msg.includes('ECONNRESET') ||
292
- msg.includes('ECONNREFUSED') ||
293
- msg.includes('ETIMEDOUT') ||
294
- msg.includes('EPIPE') ||
295
- msg.includes('socket hang up') ||
296
- msg.includes('Connection not available') ||
297
- msg.includes('BAD') || // IMAP BAD response — often transient on Apple
298
- msg.includes('NO '); // IMAP NO response — often transient on Apple
299
- }
358
+ const t0 = Date.now();
359
+ const missing = [];
360
+ let verified = 0;
300
361
 
301
- async function withRetry(label, fn, maxAttempts = 3) {
302
- let lastErr;
303
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
304
- try {
305
- return await fn();
306
- } catch (err) {
307
- lastErr = err;
308
- if (!isTransient(err) || attempt === maxAttempts) throw err;
309
- const delay = attempt * 2000; // 2s, 4s backoff
310
- process.stderr.write(`[retry] ${label} failed (attempt ${attempt}/${maxAttempts}): ${err.message} — retrying in ${delay}ms\n`);
311
- await new Promise(r => setTimeout(r, delay));
362
+ for (const fp of fingerprints) {
363
+ if (!fp.messageId) {
364
+ // No Message-ID can't verify this way, count as missing
365
+ missing.push(fp);
366
+ continue;
367
+ }
368
+ const uids = (await client.search({ header: ['Message-ID', fp.messageId] }, { uid: true })) ?? [];
369
+ if (uids.length === 0) {
370
+ missing.push(fp);
371
+ } else {
372
+ verified++;
373
+ }
374
+ // Progress logging every 25 emails
375
+ const checked = verified + missing.length;
376
+ if (checked % 25 === 0) {
377
+ moveLog(chunkIndex, `Message-ID fallback: ${checked}/${fingerprints.length} checked (${verified} found, ${missing.length} missing, ${elapsed(t0)})`);
312
378
  }
313
379
  }
314
- throw lastErr;
380
+
381
+ moveLog(chunkIndex, `Message-ID fallback: ${verified}/${fingerprints.length} verified, ${missing.length} still missing (${elapsed(t0)})`);
382
+ return { missing, verified };
315
383
  }
316
384
 
317
- // ─── Safe Move ────────────────────────────────────────────────────────────────
385
+ async function verifyInTarget(targetClient, fingerprints, chunkIndex, knownTotal = null) {
386
+ // Primary: fast envelope scan (one FETCH command)
387
+ const { missing: afterScan } = await verifyByEnvelopeScan(targetClient, fingerprints, chunkIndex, knownTotal);
388
+
389
+ if (afterScan.length === 0) {
390
+ return { verified: true, missing: [], found: fingerprints.length, expected: fingerprints.length };
391
+ }
392
+
393
+ // Secondary: Message-ID search only for the ones envelope scan missed
394
+ moveLog(chunkIndex, `${afterScan.length} unmatched after envelope scan — trying Message-ID search`);
395
+ const withMessageId = afterScan.filter(fp => fp.messageId);
396
+ const noMessageId = afterScan.filter(fp => !fp.messageId);
397
+
398
+ if (withMessageId.length > 0) {
399
+ const { missing: stillMissing } = await verifyByMessageId(targetClient, withMessageId, chunkIndex);
400
+ const allMissing = [...stillMissing, ...noMessageId];
401
+ return {
402
+ verified: allMissing.length === 0,
403
+ missing: allMissing,
404
+ found: fingerprints.length - allMissing.length,
405
+ expected: fingerprints.length
406
+ };
407
+ }
408
+
409
+ // No Message-IDs to try — whatever envelope scan missed is truly missing
410
+ return {
411
+ verified: noMessageId.length === 0,
412
+ missing: noMessageId,
413
+ found: fingerprints.length - noMessageId.length,
414
+ expected: fingerprints.length
415
+ };
416
+ }
417
+
418
+ // ─── Safe Move (v3: connection reuse + envelope-first verify + logging) ───────
318
419
 
319
420
  async function safeMoveEmails(uids, sourceMailbox, targetMailbox) {
320
421
  const operation = startOperation(sourceMailbox, targetMailbox, uids);
321
422
  let totalMoved = 0;
322
423
  let totalFailed = 0;
424
+ const opStart = Date.now();
323
425
 
324
- for (const chunk of operation.chunks) {
325
- const chunkUids = chunk.uids;
326
- let succeeded = false;
426
+ process.stderr.write(`[move] starting: ${uids.length} emails, ${operation.chunks.length} chunks, ${sourceMailbox} → ${targetMailbox}\n`);
327
427
 
328
- // Try at full chunk size first, then smaller on verification failure
329
- for (const attemptChunkSize of [CHUNK_SIZE, CHUNK_SIZE_RETRY]) {
330
- const subChunks = [];
331
- for (let i = 0; i < chunkUids.length; i += attemptChunkSize) {
332
- subChunks.push(chunkUids.slice(i, i + attemptChunkSize));
333
- }
428
+ let srcClient = await openClient(sourceMailbox);
429
+ let tgtClient = await openClient(targetMailbox);
334
430
 
335
- let verificationFailed = false;
336
-
337
- for (const subChunk of subChunks) {
338
- // Step 1: fetch fingerprints from source and write to manifest
339
- // before doing anything destructive — retries on transient errors
340
- const envelopes = await withRetry(
341
- `fetchEnvelopes chunk ${chunk.index}`,
342
- () => fetchEnvelopes(sourceMailbox, subChunk)
343
- );
344
- const fingerprints = envelopes.map(buildFingerprint);
345
-
346
- updateManifest((data) => {
347
- const c = data.current.chunks[chunk.index];
348
- c.fingerprints = [...c.fingerprints, ...fingerprints];
349
- c.status = 'pending';
350
- });
431
+ try {
432
+ for (const chunk of operation.chunks) {
433
+ const chunkUids = chunk.uids;
434
+ let succeeded = false;
435
+ const chunkStart = Date.now();
351
436
 
352
- // Step 2: copy to target — retries on transient errors
353
- await withRetry(
354
- `copyChunk chunk ${chunk.index}`,
355
- () => copyChunk(sourceMailbox, targetMailbox, subChunk)
356
- );
357
- updateChunk(chunk.index, { status: 'copied_not_verified', copiedAt: new Date().toISOString() });
358
-
359
- // Step 3: verify by fetching envelopes from target — retries on transient errors
360
- const verification = await withRetry(
361
- `verifyFingerprints chunk ${chunk.index}`,
362
- () => verifyFingerprintsInTarget(targetMailbox, fingerprints)
363
- );
364
- if (!verification.verified) {
365
- verificationFailed = true;
437
+ moveLog(chunk.index, `starting (${chunkUids.length} emails)`);
438
+
439
+ for (const attemptChunkSize of [CHUNK_SIZE, CHUNK_SIZE_RETRY]) {
440
+ const subChunks = [];
441
+ for (let i = 0; i < chunkUids.length; i += attemptChunkSize) {
442
+ subChunks.push(chunkUids.slice(i, i + attemptChunkSize));
443
+ }
444
+
445
+ let verificationFailed = false;
446
+
447
+ for (const subChunk of subChunks) {
448
+ try {
449
+ await withTimeout(`safeMoveEmails chunk ${chunk.index}`, TIMEOUT.CHUNK, async () => {
450
+ // Step 1: fetch fingerprints from source
451
+ let t = Date.now();
452
+ const envelopes = [];
453
+ try {
454
+ for await (const msg of srcClient.fetch(subChunk, { envelope: true }, { uid: true })) {
455
+ envelopes.push(msg);
456
+ }
457
+ } catch (err) {
458
+ if (!isTransient(err)) throw err;
459
+ moveLog(chunk.index, `fetch envelopes failed (${err.message}), reconnecting...`);
460
+ srcClient = await reconnect(srcClient, sourceMailbox);
461
+ for await (const msg of srcClient.fetch(subChunk, { envelope: true }, { uid: true })) {
462
+ envelopes.push(msg);
463
+ }
464
+ }
465
+ const fingerprints = envelopes.map(buildFingerprint);
466
+ const withMsgId = fingerprints.filter(fp => fp.messageId).length;
467
+ moveLog(chunk.index, `fetched ${envelopes.length} envelopes (${withMsgId} with Message-ID) (${elapsed(t)})`);
468
+
469
+ updateManifest((data) => {
470
+ if (!data.current) return;
471
+ const c = data.current.chunks[chunk.index];
472
+ if (!c) return;
473
+ c.fingerprints = [...c.fingerprints, ...fingerprints];
474
+ c.status = 'pending';
475
+ });
476
+
477
+ // Step 2: copy to target
478
+ t = Date.now();
479
+ try {
480
+ await srcClient.messageCopy(subChunk, targetMailbox, { uid: true });
481
+ } catch (err) {
482
+ if (!isTransient(err)) throw err;
483
+ moveLog(chunk.index, `copy failed (${err.message}), reconnecting...`);
484
+ srcClient = await reconnect(srcClient, sourceMailbox);
485
+ await srcClient.messageCopy(subChunk, targetMailbox, { uid: true });
486
+ }
487
+ moveLog(chunk.index, `copied ${subChunk.length} emails to target (${elapsed(t)})`);
488
+ updateChunk(chunk.index, { status: 'copied_not_verified', copiedAt: new Date().toISOString() });
489
+
490
+ // Step 3: verify in target
491
+ t = Date.now();
492
+ let verification;
493
+ try {
494
+ const tgtMb = await tgtClient.mailboxOpen(targetMailbox);
495
+ verification = await verifyInTarget(tgtClient, fingerprints, chunk.index, tgtMb.exists);
496
+ } catch (err) {
497
+ if (!isTransient(err)) throw err;
498
+ moveLog(chunk.index, `verify failed (${err.message}), reconnecting...`);
499
+ tgtClient = await reconnect(tgtClient, targetMailbox);
500
+ verification = await verifyInTarget(tgtClient, fingerprints, chunk.index);
501
+ }
502
+ moveLog(chunk.index, `verification: ${verification.found}/${verification.expected} confirmed (${elapsed(t)})`);
503
+
504
+ if (!verification.verified) {
505
+ throw Object.assign(new Error('verification_failed'), { _verificationFailed: true });
506
+ }
507
+ updateChunk(chunk.index, { status: 'verified_not_deleted', verifiedAt: new Date().toISOString() });
508
+
509
+ // Step 4: delete from source
510
+ t = Date.now();
511
+ try {
512
+ await srcClient.messageDelete(subChunk, { uid: true });
513
+ } catch (err) {
514
+ if (!isTransient(err)) throw err;
515
+ moveLog(chunk.index, `delete failed (${err.message}), reconnecting...`);
516
+ srcClient = await reconnect(srcClient, sourceMailbox);
517
+ await srcClient.messageDelete(subChunk, { uid: true });
518
+ }
519
+ moveLog(chunk.index, `deleted ${subChunk.length} from source (${elapsed(t)})`);
520
+ });
521
+ totalMoved += subChunk.length;
522
+ moveLog(chunk.index, `sub-chunk complete: ${subChunk.length} moved (chunk total: ${totalMoved}/${operation.totalUids})`);
523
+ } catch (err) {
524
+ if (err._verificationFailed) {
525
+ verificationFailed = true;
526
+ break;
527
+ }
528
+ moveLog(chunk.index, `FAILED: ${err.message}`);
529
+ updateChunk(chunk.index, {
530
+ status: 'failed',
531
+ failureReason: err.message
532
+ });
533
+ totalFailed += chunkUids.length;
534
+ failOperation(`Chunk ${chunk.index} failed: ${err.message}`);
535
+ return {
536
+ status: 'partial',
537
+ moved: totalMoved,
538
+ failed: totalFailed,
539
+ message: `${err.message}. ${totalMoved} emails moved successfully. ${operation.totalUids - totalMoved} remain in source untouched. Call get_move_status for details.`
540
+ };
541
+ }
542
+ }
543
+
544
+ if (!verificationFailed) {
545
+ succeeded = true;
366
546
  break;
367
547
  }
368
- updateChunk(chunk.index, { status: 'verified_not_deleted', verifiedAt: new Date().toISOString() });
369
-
370
- // Step 4: safe to delete from source — retries on transient errors
371
- await withRetry(
372
- `deleteChunk chunk ${chunk.index}`,
373
- () => deleteChunk(sourceMailbox, subChunk)
374
- );
375
- totalMoved += subChunk.length;
376
- }
377
548
 
378
- if (!verificationFailed) {
379
- succeeded = true;
380
- break;
549
+ if (attemptChunkSize === CHUNK_SIZE_RETRY) {
550
+ moveLog(chunk.index, `FAILED: verification failed at both chunk sizes`);
551
+ updateChunk(chunk.index, {
552
+ status: 'failed',
553
+ failureReason: 'Verification failed at both chunk sizes'
554
+ });
555
+ totalFailed += chunkUids.length;
556
+ failOperation(`Verification failed after retry on chunk ${chunk.index}`);
557
+ return {
558
+ status: 'partial',
559
+ moved: totalMoved,
560
+ failed: totalFailed,
561
+ message: `Verification failed after retry. ${totalMoved} emails moved successfully. ${operation.totalUids - totalMoved} remain in source untouched. Call get_move_status for details.`
562
+ };
563
+ }
564
+
565
+ moveLog(chunk.index, `verification failed at chunk size ${attemptChunkSize}, retrying at ${CHUNK_SIZE_RETRY}`);
381
566
  }
382
567
 
383
- if (attemptChunkSize === CHUNK_SIZE_RETRY) {
384
- // Verification failed at both chunk sizes stop and report, source untouched from here
385
- updateChunk(chunk.index, {
386
- status: 'failed',
387
- failureReason: 'Verification failed at both chunk sizes'
388
- });
389
- totalFailed += chunkUids.length;
390
- failOperation(`Verification failed after retry on chunk ${chunk.index}`);
391
- return {
392
- status: 'partial',
393
- moved: totalMoved,
394
- failed: totalFailed,
395
- message: `Verification failed after retry. ${totalMoved} emails moved successfully. ${operation.totalUids - totalMoved} remain in source untouched. Call get_move_status for details.`
396
- };
568
+ if (succeeded) {
569
+ updateChunk(chunk.index, { status: 'complete', deletedAt: new Date().toISOString() });
570
+ moveLog(chunk.index, `COMPLETE (${elapsed(chunkStart)})`);
397
571
  }
398
572
  }
399
573
 
400
- if (succeeded) {
401
- updateChunk(chunk.index, { status: 'complete', deletedAt: new Date().toISOString() });
402
- }
574
+ completeOperation();
575
+ process.stderr.write(`[move] COMPLETE: ${totalMoved}/${operation.totalUids} emails moved (${elapsed(opStart)})\n`);
576
+ return { status: 'complete', moved: totalMoved, total: operation.totalUids };
577
+ } finally {
578
+ await safeClose(srcClient);
579
+ await safeClose(tgtClient);
403
580
  }
404
-
405
- completeOperation();
406
- return { status: 'complete', moved: totalMoved, total: operation.totalUids };
407
581
  }
408
582
 
409
583
  // ─── Email Functions ──────────────────────────────────────────────────────────
@@ -551,18 +725,14 @@ async function bulkDeleteBySender(sender, mailbox = 'INBOX') {
551
725
  await client.connect();
552
726
  await client.mailboxOpen(mailbox);
553
727
  const uids = (await client.search({ from: sender }, { uid: true })) ?? [];
554
- await client.logout();
555
- if (uids.length === 0) return { deleted: 0 };
728
+ if (uids.length === 0) { await client.logout(); return { deleted: 0 }; }
556
729
  let deleted = 0;
557
730
  for (let i = 0; i < uids.length; i += CHUNK_SIZE) {
558
731
  const chunk = uids.slice(i, i + CHUNK_SIZE);
559
- const dc = createClient();
560
- await dc.connect();
561
- await dc.mailboxOpen(mailbox);
562
- await dc.messageDelete(chunk, { uid: true });
563
- await dc.logout();
732
+ await client.messageDelete(chunk, { uid: true });
564
733
  deleted += chunk.length;
565
734
  }
735
+ await client.logout();
566
736
  return { deleted, sender };
567
737
  }
568
738
 
@@ -583,18 +753,14 @@ async function bulkDeleteBySubject(subject, mailbox = 'INBOX') {
583
753
  await client.connect();
584
754
  await client.mailboxOpen(mailbox);
585
755
  const uids = (await client.search({ subject }, { uid: true })) ?? [];
586
- await client.logout();
587
- if (uids.length === 0) return { deleted: 0 };
756
+ if (uids.length === 0) { await client.logout(); return { deleted: 0 }; }
588
757
  let deleted = 0;
589
758
  for (let i = 0; i < uids.length; i += CHUNK_SIZE) {
590
759
  const chunk = uids.slice(i, i + CHUNK_SIZE);
591
- const dc = createClient();
592
- await dc.connect();
593
- await dc.mailboxOpen(mailbox);
594
- await dc.messageDelete(chunk, { uid: true });
595
- await dc.logout();
760
+ await client.messageDelete(chunk, { uid: true });
596
761
  deleted += chunk.length;
597
762
  }
763
+ await client.logout();
598
764
  return { deleted, subject };
599
765
  }
600
766
 
@@ -605,18 +771,14 @@ async function deleteOlderThan(days, mailbox = 'INBOX') {
605
771
  const date = new Date();
606
772
  date.setDate(date.getDate() - days);
607
773
  const uids = (await client.search({ before: date }, { uid: true })) ?? [];
608
- await client.logout();
609
- if (uids.length === 0) return { deleted: 0 };
774
+ if (uids.length === 0) { await client.logout(); return { deleted: 0 }; }
610
775
  let deleted = 0;
611
776
  for (let i = 0; i < uids.length; i += CHUNK_SIZE) {
612
777
  const chunk = uids.slice(i, i + CHUNK_SIZE);
613
- const dc = createClient();
614
- await dc.connect();
615
- await dc.mailboxOpen(mailbox);
616
- await dc.messageDelete(chunk, { uid: true });
617
- await dc.logout();
778
+ await client.messageDelete(chunk, { uid: true });
618
779
  deleted += chunk.length;
619
780
  }
781
+ await client.logout();
620
782
  return { deleted, olderThan: date.toISOString() };
621
783
  }
622
784
 
@@ -895,14 +1057,16 @@ async function ensureMailbox(name) {
895
1057
  await client.logout();
896
1058
  }
897
1059
 
898
- async function bulkMove(filters, targetMailbox, sourceMailbox = 'INBOX', dryRun = false) {
1060
+ async function bulkMove(filters, targetMailbox, sourceMailbox = 'INBOX', dryRun = false, limit = null) {
899
1061
  const client = createClient();
900
1062
  await client.connect();
901
1063
  await client.mailboxOpen(sourceMailbox);
902
1064
  const query = buildQuery(filters);
903
- const uids = (await client.search(query, { uid: true })) ?? [];
1065
+ let uids = (await client.search(query, { uid: true })) ?? [];
904
1066
  await client.logout();
905
1067
 
1068
+ if (limit !== null) uids = uids.slice(0, limit);
1069
+
906
1070
  if (dryRun) {
907
1071
  return { dryRun: true, wouldMove: uids.length, sourceMailbox, targetMailbox, filters };
908
1072
  }
@@ -913,29 +1077,45 @@ async function bulkMove(filters, targetMailbox, sourceMailbox = 'INBOX', dryRun
913
1077
  return { ...result, sourceMailbox, targetMailbox, filters };
914
1078
  }
915
1079
 
1080
+ // ─── IMPROVEMENT 3: bulk_delete now has per-chunk timeout ─────────────────────
1081
+ // Previously the chunk loop could run unbounded. Now each chunk gets a BULK_OP
1082
+ // timeout. If a single chunk hangs, we bail with a partial result instead of
1083
+ // hanging forever.
1084
+
916
1085
  async function bulkDelete(filters, sourceMailbox = 'INBOX', dryRun = false) {
917
1086
  const client = createClient();
918
1087
  await client.connect();
919
1088
  await client.mailboxOpen(sourceMailbox);
920
1089
  const query = buildQuery(filters);
921
1090
  const uids = (await client.search(query, { uid: true })) ?? [];
922
- await client.logout();
923
1091
 
924
1092
  if (dryRun) {
1093
+ await client.logout();
925
1094
  return { dryRun: true, wouldDelete: uids.length, sourceMailbox, filters };
926
1095
  }
927
- if (uids.length === 0) return { deleted: 0, sourceMailbox };
1096
+ if (uids.length === 0) { await client.logout(); return { deleted: 0, sourceMailbox }; }
928
1097
 
929
1098
  let deleted = 0;
930
1099
  for (let i = 0; i < uids.length; i += CHUNK_SIZE) {
931
1100
  const chunk = uids.slice(i, i + CHUNK_SIZE);
932
- const deleteClient = createClient();
933
- await deleteClient.connect();
934
- await deleteClient.mailboxOpen(sourceMailbox);
935
- await deleteClient.messageDelete(chunk, { uid: true });
936
- await deleteClient.logout();
937
- deleted += chunk.length;
1101
+ const chunkIndex = Math.floor(i / CHUNK_SIZE);
1102
+ try {
1103
+ await withTimeout(`bulk_delete chunk ${chunkIndex}`, TIMEOUT.BULK_OP, async () => {
1104
+ await client.messageDelete(chunk, { uid: true });
1105
+ });
1106
+ deleted += chunk.length;
1107
+ } catch (err) {
1108
+ await safeClose(client);
1109
+ return {
1110
+ deleted,
1111
+ failed: uids.length - deleted,
1112
+ sourceMailbox,
1113
+ filters,
1114
+ error: `Chunk ${chunkIndex} failed: ${err.message}. ${deleted} deleted so far, ${uids.length - deleted} remaining.`
1115
+ };
1116
+ }
938
1117
  }
1118
+ await client.logout();
939
1119
  return { deleted, sourceMailbox, filters };
940
1120
  }
941
1121
 
@@ -977,7 +1157,7 @@ function logClear() {
977
1157
 
978
1158
  async function main() {
979
1159
  const server = new Server(
980
- { name: 'icloud-mail', version: '1.4.1' },
1160
+ { name: 'icloud-mail', version: '1.5.0' },
981
1161
  { capabilities: { tools: {} } }
982
1162
  );
983
1163
 
@@ -1106,6 +1286,7 @@ async function main() {
1106
1286
  targetMailbox: { type: 'string', description: 'Destination mailbox path' },
1107
1287
  sourceMailbox: { type: 'string', description: 'Source mailbox (default INBOX)' },
1108
1288
  dryRun: { type: 'boolean', description: 'If true, preview what would be moved without actually moving' },
1289
+ limit: { type: 'number', description: 'Maximum number of emails to move (default: all matching)' },
1109
1290
  ...filtersSchema
1110
1291
  },
1111
1292
  required: ['targetMailbox']
@@ -1113,7 +1294,7 @@ async function main() {
1113
1294
  },
1114
1295
  {
1115
1296
  name: 'bulk_delete',
1116
- description: 'Delete emails matching any combination of filters. Processes in chunks of 250 for reliability. Use dryRun: true to preview without making changes.',
1297
+ description: 'Delete emails matching any combination of filters. Processes in chunks of 250 with per-chunk timeouts for reliability. Use dryRun: true to preview without making changes.',
1117
1298
  inputSchema: {
1118
1299
  type: 'object',
1119
1300
  properties: {
@@ -1350,71 +1531,80 @@ async function main() {
1350
1531
  const { name, arguments: args } = request.params;
1351
1532
  try {
1352
1533
  let result;
1534
+ // ── Metadata tier (15s) ──
1353
1535
  if (name === 'get_inbox_summary') {
1354
- result = await getInboxSummary(args.mailbox || 'INBOX');
1536
+ result = await withTimeout('get_inbox_summary', TIMEOUT.METADATA, () => getInboxSummary(args.mailbox || 'INBOX'));
1355
1537
  } else if (name === 'get_mailbox_summary') {
1356
- result = await getMailboxSummary(args.mailbox);
1357
- } else if (name === 'get_top_senders') {
1358
- result = await getTopSenders(args.mailbox || 'INBOX', args.sampleSize || 500, args.maxResults || 20);
1359
- } else if (name === 'get_unread_senders') {
1360
- result = await getUnreadSenders(args.mailbox || 'INBOX', args.sampleSize || 500, args.maxResults || 20);
1361
- } else if (name === 'get_emails_by_sender') {
1362
- result = await getEmailsBySender(args.sender, args.mailbox || 'INBOX', args.limit || 10);
1538
+ result = await withTimeout('get_mailbox_summary', TIMEOUT.METADATA, () => getMailboxSummary(args.mailbox));
1539
+ } else if (name === 'count_emails') {
1540
+ const { mailbox, ...filters } = args;
1541
+ result = await withTimeout('count_emails', TIMEOUT.METADATA, () => countEmails(filters, mailbox || 'INBOX'));
1542
+ } else if (name === 'list_mailboxes') {
1543
+ result = await withTimeout('list_mailboxes', TIMEOUT.METADATA, () => listMailboxes());
1544
+ } else if (name === 'create_mailbox') {
1545
+ result = await withTimeout('create_mailbox', TIMEOUT.METADATA, () => createMailbox(args.name));
1546
+ } else if (name === 'rename_mailbox') {
1547
+ result = await renameMailbox(args.oldName, args.newName); // already has its own 15s timeout
1548
+ } else if (name === 'delete_mailbox') {
1549
+ result = await deleteMailbox(args.name); // already has its own 15s timeout
1550
+ // ── Fetch tier (30s) ──
1363
1551
  } else if (name === 'read_inbox') {
1364
- result = await fetchEmails(args.mailbox || 'INBOX', args.limit || 10, args.onlyUnread || false, args.page || 1);
1552
+ result = await withTimeout('read_inbox', TIMEOUT.FETCH, () => fetchEmails(args.mailbox || 'INBOX', args.limit || 10, args.onlyUnread || false, args.page || 1));
1365
1553
  } else if (name === 'get_email') {
1366
- result = await getEmailContent(args.uid, args.mailbox || 'INBOX');
1554
+ result = await withTimeout('get_email', TIMEOUT.FETCH, () => getEmailContent(args.uid, args.mailbox || 'INBOX'));
1367
1555
  } else if (name === 'search_emails') {
1368
1556
  const { query, mailbox, limit, ...filters } = args;
1369
- result = await searchEmails(query, mailbox || 'INBOX', limit || 10, filters);
1370
- } else if (name === 'count_emails') {
1371
- const { mailbox, ...filters } = args;
1372
- result = await countEmails(filters, mailbox || 'INBOX');
1373
- } else if (name === 'bulk_move') {
1374
- const { targetMailbox, sourceMailbox, dryRun, ...filters } = args;
1375
- result = await bulkMove(filters, targetMailbox, sourceMailbox || 'INBOX', dryRun || false);
1376
- } else if (name === 'bulk_delete') {
1377
- const { sourceMailbox, dryRun, ...filters } = args;
1378
- result = await bulkDelete(filters, sourceMailbox || 'INBOX', dryRun || false);
1379
- } else if (name === 'bulk_flag') {
1380
- const { flagged, mailbox, ...filters } = args;
1381
- result = await bulkFlag(filters, flagged, mailbox || 'INBOX');
1557
+ result = await withTimeout('search_emails', TIMEOUT.FETCH, () => searchEmails(query, mailbox || 'INBOX', limit || 10, filters));
1558
+ } else if (name === 'get_emails_by_sender') {
1559
+ result = await withTimeout('get_emails_by_sender', TIMEOUT.FETCH, () => getEmailsBySender(args.sender, args.mailbox || 'INBOX', args.limit || 10));
1560
+ } else if (name === 'get_emails_by_date_range') {
1561
+ result = await withTimeout('get_emails_by_date_range', TIMEOUT.FETCH, () => getEmailsByDateRange(args.startDate, args.endDate, args.mailbox || 'INBOX', args.limit || 10));
1562
+ // ── Scan tier (60s) ──
1563
+ } else if (name === 'get_top_senders') {
1564
+ result = await withTimeout('get_top_senders', TIMEOUT.SCAN, () => getTopSenders(args.mailbox || 'INBOX', args.sampleSize || 500, args.maxResults || 20));
1565
+ } else if (name === 'get_unread_senders') {
1566
+ result = await withTimeout('get_unread_senders', TIMEOUT.SCAN, () => getUnreadSenders(args.mailbox || 'INBOX', args.sampleSize || 500, args.maxResults || 20));
1567
+ // ── Bulk operation tier (60s) ──
1382
1568
  } else if (name === 'bulk_delete_by_sender') {
1383
- result = await bulkDeleteBySender(args.sender, args.mailbox || 'INBOX');
1384
- } else if (name === 'bulk_move_by_sender') {
1385
- result = await bulkMoveBySender(args.sender, args.targetMailbox, args.sourceMailbox || 'INBOX');
1569
+ result = await withTimeout('bulk_delete_by_sender', TIMEOUT.BULK_OP, () => bulkDeleteBySender(args.sender, args.mailbox || 'INBOX'));
1386
1570
  } else if (name === 'bulk_delete_by_subject') {
1387
- result = await bulkDeleteBySubject(args.subject, args.mailbox || 'INBOX');
1571
+ result = await withTimeout('bulk_delete_by_subject', TIMEOUT.BULK_OP, () => bulkDeleteBySubject(args.subject, args.mailbox || 'INBOX'));
1388
1572
  } else if (name === 'bulk_mark_read') {
1389
- result = await bulkMarkRead(args.mailbox || 'INBOX', args.sender || null);
1573
+ result = await withTimeout('bulk_mark_read', TIMEOUT.BULK_OP, () => bulkMarkRead(args.mailbox || 'INBOX', args.sender || null));
1390
1574
  } else if (name === 'bulk_mark_unread') {
1391
- result = await bulkMarkUnread(args.mailbox || 'INBOX', args.sender || null);
1575
+ result = await withTimeout('bulk_mark_unread', TIMEOUT.BULK_OP, () => bulkMarkUnread(args.mailbox || 'INBOX', args.sender || null));
1576
+ } else if (name === 'bulk_flag') {
1577
+ const { flagged, mailbox, ...filters } = args;
1578
+ result = await withTimeout('bulk_flag', TIMEOUT.BULK_OP, () => bulkFlag(filters, flagged, mailbox || 'INBOX'));
1392
1579
  } else if (name === 'delete_older_than') {
1393
- result = await deleteOlderThan(args.days, args.mailbox || 'INBOX');
1394
- } else if (name === 'get_emails_by_date_range') {
1395
- result = await getEmailsByDateRange(args.startDate, args.endDate, args.mailbox || 'INBOX', args.limit || 10);
1580
+ result = await withTimeout('delete_older_than', TIMEOUT.BULK_OP, () => deleteOlderThan(args.days, args.mailbox || 'INBOX'));
1581
+ } else if (name === 'empty_trash') {
1582
+ result = await withTimeout('empty_trash', TIMEOUT.BULK_OP, () => emptyTrash());
1583
+ // ── No top-level timeout — chunked with internal timeouts ──
1584
+ } else if (name === 'bulk_move') {
1585
+ const { targetMailbox, sourceMailbox, dryRun, limit, ...filters } = args;
1586
+ result = await bulkMove(filters, targetMailbox, sourceMailbox || 'INBOX', dryRun || false, limit ?? null);
1587
+ } else if (name === 'bulk_move_by_sender') {
1588
+ result = await bulkMoveBySender(args.sender, args.targetMailbox, args.sourceMailbox || 'INBOX');
1589
+ } else if (name === 'bulk_delete') {
1590
+ // IMPROVEMENT 3: bulk_delete now has per-chunk timeouts internally
1591
+ const { sourceMailbox, dryRun, ...filters } = args;
1592
+ result = await bulkDelete(filters, sourceMailbox || 'INBOX', dryRun || false);
1593
+ // ── Single-email tier (15s) ──
1396
1594
  } else if (name === 'flag_email') {
1397
- result = await flagEmail(args.uid, args.flagged, args.mailbox || 'INBOX');
1595
+ result = await withTimeout('flag_email', TIMEOUT.SINGLE, () => flagEmail(args.uid, args.flagged, args.mailbox || 'INBOX'));
1398
1596
  } else if (name === 'mark_as_read') {
1399
- result = await markAsRead(args.uid, args.seen, args.mailbox || 'INBOX');
1597
+ result = await withTimeout('mark_as_read', TIMEOUT.SINGLE, () => markAsRead(args.uid, args.seen, args.mailbox || 'INBOX'));
1400
1598
  } else if (name === 'delete_email') {
1401
- result = await deleteEmail(args.uid, args.mailbox || 'INBOX');
1599
+ result = await withTimeout('delete_email', TIMEOUT.SINGLE, () => deleteEmail(args.uid, args.mailbox || 'INBOX'));
1402
1600
  } else if (name === 'move_email') {
1403
- result = await moveEmail(args.uid, args.targetMailbox, args.sourceMailbox || 'INBOX');
1404
- } else if (name === 'list_mailboxes') {
1405
- result = await listMailboxes();
1406
- } else if (name === 'create_mailbox') {
1407
- result = await createMailbox(args.name);
1408
- } else if (name === 'rename_mailbox') {
1409
- result = await renameMailbox(args.oldName, args.newName);
1410
- } else if (name === 'delete_mailbox') {
1411
- result = await deleteMailbox(args.name);
1412
- } else if (name === 'empty_trash') {
1413
- result = await emptyTrash();
1601
+ result = await withTimeout('move_email', TIMEOUT.SINGLE, () => moveEmail(args.uid, args.targetMailbox, args.sourceMailbox || 'INBOX'));
1602
+ // ── Move status (synchronous, no timeout needed) ──
1414
1603
  } else if (name === 'get_move_status') {
1415
1604
  result = getMoveStatus();
1416
1605
  } else if (name === 'abandon_move') {
1417
1606
  result = abandonMove();
1607
+ // ── Session log (synchronous, no timeout needed) ──
1418
1608
  } else if (name === 'log_write') {
1419
1609
  result = logWrite(args.step);
1420
1610
  } else if (name === 'log_read') {
@@ -1426,7 +1616,7 @@ async function main() {
1426
1616
  }
1427
1617
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
1428
1618
  } catch (error) {
1429
- return { content: [{ type: 'text', text: `Error: ${error.message}` }], isError: true };
1619
+ return { content: [{ type: 'text', text: `Error: ${friendlyError(error)}` }], isError: true };
1430
1620
  }
1431
1621
  });
1432
1622
 
@@ -1435,6 +1625,140 @@ async function main() {
1435
1625
  process.stderr.write('iCloud Mail MCP Server running\n');
1436
1626
  }
1437
1627
 
1628
+ // ─── Friendly error messages ──────────────────────────────────────────────────
1629
+
1630
+ function friendlyError(err) {
1631
+ const msg = err.message ?? '';
1632
+
1633
+ if (msg.includes('AUTHENTICATIONFAILED') || msg.includes('Invalid credentials') || msg.includes('Authentication failed')) {
1634
+ return [
1635
+ 'Authentication failed.',
1636
+ '→ Make sure IMAP_PASSWORD is an app-specific password, not your regular iCloud password.',
1637
+ '→ Generate one at: appleid.apple.com → Sign-In and Security → App-Specific Passwords',
1638
+ '→ Also check that IMAP_USER is your full iCloud email address (e.g. you@icloud.com)'
1639
+ ].join('\n');
1640
+ }
1641
+
1642
+ if (msg.includes('ECONNREFUSED') || msg.includes('ENOTFOUND') || msg.includes('ENETUNREACH')) {
1643
+ return [
1644
+ 'Could not reach imap.mail.me.com:993.',
1645
+ '→ Check your internet connection.',
1646
+ '→ If you are behind a firewall or VPN, port 993 may be blocked.'
1647
+ ].join('\n');
1648
+ }
1649
+
1650
+ if (msg.includes('ETIMEDOUT') || msg.includes('socket hang up')) {
1651
+ return [
1652
+ 'Connection to iCloud timed out.',
1653
+ '→ Check your internet connection and try again.',
1654
+ '→ iCloud IMAP can be slow under load — this is usually transient.'
1655
+ ].join('\n');
1656
+ }
1657
+
1658
+ if (msg.includes('ECONNRESET')) {
1659
+ return [
1660
+ 'iCloud closed the connection unexpectedly.',
1661
+ '→ This is usually transient. Try again in a few seconds.'
1662
+ ].join('\n');
1663
+ }
1664
+
1665
+ if (msg.includes('timed out after')) {
1666
+ return [
1667
+ `Operation ${msg}`,
1668
+ '→ This usually means iCloud is slow or the operation is larger than expected.',
1669
+ '→ Try again — if it persists, the operation may need to be broken into smaller steps.'
1670
+ ].join('\n');
1671
+ }
1672
+
1673
+ if (msg.includes('Mailbox does not exist') || msg.includes('does not exist') || msg.includes('NONEXISTENT')) {
1674
+ return [
1675
+ `Mailbox not found: ${msg}`,
1676
+ '→ Check the folder name is correct — iCloud folder names are case-sensitive.',
1677
+ '→ Use list_mailboxes to see all available folders.'
1678
+ ].join('\n');
1679
+ }
1680
+
1681
+ // Fall through — return original message
1682
+ return msg;
1683
+ }
1684
+
1685
+ // ─── Doctor command ───────────────────────────────────────────────────────────
1686
+
1687
+ async function runDoctor() {
1688
+ const divider = '─'.repeat(45);
1689
+ process.stdout.write(`\nicloud-mcp doctor\n${divider}\n`);
1690
+
1691
+ const checks = [
1692
+ {
1693
+ label: 'IMAP_USER is set',
1694
+ run: () => {
1695
+ if (!IMAP_USER) throw new Error('IMAP_USER environment variable is not set.\n→ Add it to your Claude Desktop config env block.');
1696
+ }
1697
+ },
1698
+ {
1699
+ label: 'IMAP_PASSWORD is set',
1700
+ run: () => {
1701
+ if (!IMAP_PASSWORD) throw new Error('IMAP_PASSWORD environment variable is not set.\n→ Add it to your Claude Desktop config env block.');
1702
+ }
1703
+ },
1704
+ {
1705
+ label: 'IMAP_USER looks like an email address',
1706
+ run: () => {
1707
+ if (!IMAP_USER?.includes('@')) throw new Error(`IMAP_USER "${IMAP_USER}" doesn't look like an email address.\n→ Use your full iCloud address, e.g. you@icloud.com`);
1708
+ }
1709
+ },
1710
+ {
1711
+ label: `Connected to imap.mail.me.com:993`,
1712
+ run: async () => {
1713
+ const client = createClient();
1714
+ await client.connect();
1715
+ await client.logout();
1716
+ }
1717
+ },
1718
+ {
1719
+ label: `Authenticated as ${IMAP_USER}`,
1720
+ run: async () => {
1721
+ // Auth is validated as part of connect — if we reach here it passed.
1722
+ // This check exists to give a clearer label in the output.
1723
+ }
1724
+ },
1725
+ {
1726
+ label: 'INBOX opened',
1727
+ run: async () => {
1728
+ const client = createClient();
1729
+ await client.connect();
1730
+ const mb = await client.mailboxOpen('INBOX');
1731
+ await client.logout();
1732
+ return `${mb.exists.toLocaleString()} messages`;
1733
+ }
1734
+ }
1735
+ ];
1736
+
1737
+ let allPassed = true;
1738
+
1739
+ for (const check of checks) {
1740
+ try {
1741
+ const detail = await check.run();
1742
+ const suffix = detail ? ` (${detail})` : '';
1743
+ process.stdout.write(`✅ ${check.label}${suffix}\n`);
1744
+ } catch (err) {
1745
+ process.stdout.write(`❌ ${check.label}\n ${friendlyError(err).replace(/\n/g, '\n ')}\n`);
1746
+ allPassed = false;
1747
+ break; // No point continuing after a failure
1748
+ }
1749
+ }
1750
+
1751
+ process.stdout.write(`${divider}\n`);
1752
+ if (allPassed) {
1753
+ process.stdout.write('All checks passed. Ready to use with Claude Desktop.\n\n');
1754
+ process.exit(0);
1755
+ } else {
1756
+ process.stdout.write('Setup is not complete. Fix the issue above and run --doctor again.\n\n');
1757
+ process.exit(1);
1758
+ }
1759
+ }
1760
+
1761
+
1438
1762
  process.on('uncaughtException', (err) => {
1439
1763
  process.stderr.write(`Uncaught exception: ${err.message}\n${err.stack}\n`);
1440
1764
  process.exit(1);
@@ -1445,7 +1769,14 @@ process.on('unhandledRejection', (reason) => {
1445
1769
  process.exit(1);
1446
1770
  });
1447
1771
 
1448
- main().catch((err) => {
1449
- process.stderr.write(`Fatal error: ${err.message}\n${err.stack}\n`);
1450
- process.exit(1);
1451
- });
1772
+ if (process.argv.includes('--doctor')) {
1773
+ runDoctor().catch((err) => {
1774
+ process.stderr.write(`Doctor failed unexpectedly: ${err.message}\n`);
1775
+ process.exit(1);
1776
+ });
1777
+ } else {
1778
+ main().catch((err) => {
1779
+ process.stderr.write(`Fatal error: ${err.message}\n${err.stack}\n`);
1780
+ process.exit(1);
1781
+ });
1782
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "icloud-mcp",
3
- "version": "1.4.1",
3
+ "version": "1.5.0",
4
4
  "description": "A Model Context Protocol (MCP) server for iCloud Mail",
5
5
  "main": "index.js",
6
6
  "bin": {
package/test.js CHANGED
@@ -13,15 +13,20 @@ if (!IMAP_USER || !IMAP_PASSWORD) {
13
13
  }
14
14
 
15
15
  const projectDir = fileURLToPath(new URL('.', import.meta.url));
16
+ const VERBOSE = process.argv.includes('--verbose') || process.argv.includes('-v');
16
17
 
17
18
  // Timeout in ms per tool category
18
19
  const TIMEOUTS = {
19
20
  mailbox_mgmt: 60000, // create/rename/delete mailbox — can be slow on iCloud
21
+ bulk_move: 900000, // pre-flight restore may have many stranded emails; test moves use limit:50
20
22
  default: 300000 // everything else — allow up to 5 min for large operations
21
23
  };
22
24
 
23
25
  const MAILBOX_MGMT_TOOLS = new Set(['create_mailbox', 'rename_mailbox', 'delete_mailbox']);
24
26
 
27
+ // Tools whose stderr output is always interesting (move pipeline logging)
28
+ const ALWAYS_LOG_STDERR = new Set(['bulk_move', 'bulk_move_by_sender']);
29
+
25
30
  function callToolRaw(name, args = {}, timeout = TIMEOUTS.default) {
26
31
  const messages = [
27
32
  { jsonrpc: '2.0', id: 0, method: 'initialize', params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'test', version: '1.0.0' } } },
@@ -45,8 +50,22 @@ function callToolRaw(name, args = {}, timeout = TIMEOUTS.default) {
45
50
  }
46
51
  );
47
52
 
53
+ // Capture stderr for logging
54
+ const stderr = (result.stderr || '').trim();
55
+
56
+ // Print stderr for move operations (always) or all tools (verbose mode)
57
+ if (stderr && (VERBOSE || ALWAYS_LOG_STDERR.has(name))) {
58
+ const lines = stderr.split('\n');
59
+ for (const line of lines) {
60
+ // Only print [move], [timeout], [retry] lines — skip noise
61
+ if (VERBOSE || /^\[(move|timeout|retry)\]/.test(line)) {
62
+ console.log(` ${line}`);
63
+ }
64
+ }
65
+ }
66
+
48
67
  if (result.error) throw new Error(`Spawn error: ${result.error.message}`);
49
- if (result.status !== 0) throw new Error(`Process exited with code ${result.status}: ${result.stderr}`);
68
+ if (result.status !== 0) throw new Error(`Process exited with code ${result.status}: ${stderr}`);
50
69
 
51
70
  const lines = (result.stdout || '').trim().split('\n').filter(l => l.trim().startsWith('{'));
52
71
  const responses = lines.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
@@ -54,7 +73,19 @@ function callToolRaw(name, args = {}, timeout = TIMEOUTS.default) {
54
73
  if (!toolResponse) throw new Error(`No response for tool: ${name}`);
55
74
  const content = toolResponse.result?.content?.[0]?.text;
56
75
  if (!content) throw new Error(`No content in response for: ${name}`);
57
- if (toolResponse.result?.isError) throw new Error(`Tool error: ${content}`);
76
+ if (toolResponse.result?.isError) {
77
+ // On tool errors, always show stderr for diagnostics
78
+ if (stderr && !VERBOSE && !ALWAYS_LOG_STDERR.has(name)) {
79
+ const stderrLines = stderr.split('\n').filter(l => /^\[(move|timeout|retry)\]/.test(l));
80
+ if (stderrLines.length > 0) {
81
+ console.log(`\n 📋 stderr diagnostics:`);
82
+ for (const line of stderrLines) {
83
+ console.log(` ${line}`);
84
+ }
85
+ }
86
+ }
87
+ throw new Error(`Tool error: ${content}`);
88
+ }
58
89
  return JSON.parse(content);
59
90
  } finally {
60
91
  try { unlinkSync(tmpFile); } catch {}
@@ -62,18 +93,24 @@ function callToolRaw(name, args = {}, timeout = TIMEOUTS.default) {
62
93
  }
63
94
 
64
95
  function callTool(name, args = {}) {
65
- const timeout = MAILBOX_MGMT_TOOLS.has(name) ? TIMEOUTS.mailbox_mgmt : TIMEOUTS.default;
96
+ let timeout = TIMEOUTS.default;
97
+ if (MAILBOX_MGMT_TOOLS.has(name)) timeout = TIMEOUTS.mailbox_mgmt;
98
+ else if (name === 'bulk_move' || name === 'bulk_move_by_sender') timeout = TIMEOUTS.bulk_move;
99
+
66
100
  try {
67
101
  return callToolRaw(name, args, timeout);
68
102
  } catch (err) {
69
103
  // Only retry on spawn-level transient errors (ECONNRESET, ETIMEDOUT on the
70
104
  // child process itself) — NOT on Tool errors, which are application-level
71
105
  // failures that should propagate immediately.
106
+ // Never retry bulk_move: a timed-out move leaves the manifest in_progress,
107
+ // so a retry would immediately fail with a manifest conflict.
108
+ const isBulkMove = name === 'bulk_move' || name === 'bulk_move_by_sender';
72
109
  const isSpawnTransient = (
73
110
  err.message.includes('ECONNRESET') ||
74
111
  err.message.includes('ETIMEDOUT')
75
112
  ) && !err.message.startsWith('Tool error:');
76
- if (isSpawnTransient) {
113
+ if (isSpawnTransient && !isBulkMove) {
77
114
  console.log(`\n ⚠️ transient spawn error (${err.message.split(':')[0]}), retrying...`);
78
115
  return callToolRaw(name, args, timeout);
79
116
  }
@@ -100,7 +137,8 @@ function assert(condition, message) {
100
137
  if (!condition) throw new Error(message);
101
138
  }
102
139
 
103
- console.log('\n🧪 iCloud MCP Server Tests v1.6.0\n');
140
+ console.log('\n🧪 iCloud MCP Server Tests v1.5.0\n');
141
+ if (VERBOSE) console.log('🔊 Verbose mode: showing all stderr output\n');
104
142
 
105
143
  // ─── Pre-flight cleanup ───────────────────────────────────────────────────────
106
144
  // Abandon any leftover in-progress manifest from a previous crashed run,
@@ -408,57 +446,61 @@ test('abandon_move (nothing to abandon)', () => {
408
446
  });
409
447
 
410
448
  // ─── Safe Move (live) ─────────────────────────────────────────────────────────
411
- console.log('\n🔐 Safe Move Test (live newsletters test)');
449
+ // Uses limit:50 to keep the test fast and deterministic. Moving the full
450
+ // newsletters folder (5000+ emails) is too slow for a test suite — IMAP EXPUNGE
451
+ // scales with mailbox size, taking 60-160s per chunk on large folders.
452
+ // 50 emails completes in ~15-30s total and tests all the same code paths:
453
+ // fingerprinting, envelope scan, Message-ID fallback, manifest, delete.
454
+ console.log('\n🔐 Safe Move Test (live — newsletters ↔ test, 50-email sample)');
412
455
 
413
- test('bulk_move newsletters test (fingerprint verified)', () => {
456
+ const MOVE_SAMPLE = 50;
457
+
458
+ test(`bulk_move newsletters → test (${MOVE_SAMPLE} emails, fingerprint verified)`, () => {
414
459
  const beforeSource = callTool('count_emails', { mailbox: 'newsletters' });
415
- assert(beforeSource.count > 0, 'newsletters should have emails');
416
- console.log(`\n → newsletters before: ${beforeSource.count}`);
460
+ assert(beforeSource.count >= MOVE_SAMPLE, `newsletters needs at least ${MOVE_SAMPLE} emails, has ${beforeSource.count}`);
461
+ console.log(`\n → newsletters before: ${beforeSource.count} (moving ${MOVE_SAMPLE})`);
417
462
 
418
- const moveResult = callTool('bulk_move', { sourceMailbox: 'newsletters', targetMailbox: 'test' });
463
+ const moveResult = callTool('bulk_move', { sourceMailbox: 'newsletters', targetMailbox: 'test', limit: MOVE_SAMPLE });
419
464
  console.log(`\n → status: ${moveResult.status}, moved: ${moveResult.moved} of ${moveResult.total}`);
420
465
  assert(moveResult.status === 'complete', `expected complete, got ${moveResult.status}: ${moveResult.message || ''}`);
421
- assert(moveResult.moved === beforeSource.count, `moved ${moveResult.moved} but expected ${beforeSource.count}`);
422
- assert(moveResult.total === beforeSource.count, `total ${moveResult.total} should match source count ${beforeSource.count}`);
466
+ assert(moveResult.moved === MOVE_SAMPLE, `moved ${moveResult.moved} but expected ${MOVE_SAMPLE}`);
467
+ assert(moveResult.total === MOVE_SAMPLE, `total ${moveResult.total} should match limit ${MOVE_SAMPLE}`);
423
468
 
424
469
  const afterSource = callTool('count_emails', { mailbox: 'newsletters' });
425
- console.log(`\n → newsletters after (should be 0): ${afterSource.count}`);
426
- assert(afterSource.count === 0, `newsletters should be empty, has ${afterSource.count}`);
470
+ console.log(`\n → newsletters after: ${afterSource.count} (was ${beforeSource.count})`);
471
+ assert(afterSource.count === beforeSource.count - MOVE_SAMPLE, `newsletters should have ${beforeSource.count - MOVE_SAMPLE}, has ${afterSource.count}`);
427
472
 
428
473
  const afterTarget = callTool('count_emails', { mailbox: 'test' });
429
474
  console.log(`\n → test folder after: ${afterTarget.count}`);
430
- assert(afterTarget.count === beforeSource.count, `test should have ${beforeSource.count}, has ${afterTarget.count}`);
475
+ assert(afterTarget.count === MOVE_SAMPLE, `test should have ${MOVE_SAMPLE}, has ${afterTarget.count}`);
431
476
  });
432
477
 
433
478
  test('get_move_status (after completed move)', () => {
434
479
  const result = callTool('get_move_status');
435
- // Current should be null (archived after completion), history should have the move
436
480
  assert(result.status === 'no_operation', `expected no_operation after completed move, got ${result.status}`);
437
481
  assert(result.history.length > 0, 'history should have at least one entry');
438
482
  const last = result.history[0];
439
483
  assert(last.status === 'complete', `last operation should be complete, got ${last.status}`);
440
484
  assert(last.source === 'newsletters', `source should be newsletters, got ${last.source}`);
441
485
  assert(last.target === 'test', `target should be test, got ${last.target}`);
486
+ assert(last.moved === MOVE_SAMPLE, `last op should have moved ${MOVE_SAMPLE}, got ${last.moved}`);
442
487
  console.log(`\n → last op: ${last.status}, ${last.moved}/${last.total} moved from ${last.source} → ${last.target}`);
443
488
  });
444
489
 
445
- test('bulk_move test → newsletters (restore, fingerprint verified)', () => {
490
+ test(`bulk_move test → newsletters (restore ${MOVE_SAMPLE} emails, fingerprint verified)`, () => {
446
491
  const beforeSource = callTool('count_emails', { mailbox: 'test' });
447
- assert(beforeSource.count > 0, 'test folder should have emails to restore');
492
+ assert(beforeSource.count === MOVE_SAMPLE, `test should have ${MOVE_SAMPLE} emails, has ${beforeSource.count}`);
448
493
  console.log(`\n → test before restore: ${beforeSource.count}`);
449
494
 
495
+ // No limit needed — test folder only has MOVE_SAMPLE emails; small folder = fast delete
450
496
  const moveBack = callTool('bulk_move', { sourceMailbox: 'test', targetMailbox: 'newsletters' });
451
497
  console.log(`\n → status: ${moveBack.status}, moved: ${moveBack.moved} of ${moveBack.total}`);
452
498
  assert(moveBack.status === 'complete', `expected complete, got ${moveBack.status}: ${moveBack.message || ''}`);
453
- assert(moveBack.moved === beforeSource.count, `moved ${moveBack.moved} but expected ${beforeSource.count}`);
499
+ assert(moveBack.moved === MOVE_SAMPLE, `moved ${moveBack.moved} but expected ${MOVE_SAMPLE}`);
454
500
 
455
501
  const afterSource = callTool('count_emails', { mailbox: 'test' });
456
502
  console.log(`\n → test after (should be 0): ${afterSource.count}`);
457
503
  assert(afterSource.count === 0, `test should be empty, has ${afterSource.count}`);
458
-
459
- const afterTarget = callTool('count_emails', { mailbox: 'newsletters' });
460
- console.log(`\n → newsletters restored: ${afterTarget.count}`);
461
- assert(afterTarget.count === beforeSource.count, `newsletters should have ${beforeSource.count}, has ${afterTarget.count}`);
462
504
  });
463
505
 
464
506
  test('get_move_status (history has both moves)', () => {
@@ -470,8 +512,8 @@ test('get_move_status (history has both moves)', () => {
470
512
  assert(restore.source === 'test', `restore source should be test, got ${restore.source}`);
471
513
  assert(forward.status === 'complete', `forward op should be complete, got ${forward.status}`);
472
514
  assert(forward.source === 'newsletters', `forward source should be newsletters, got ${forward.source}`);
473
- console.log(`\n → history[0]: ${restore.source} → ${restore.target} (${restore.status})`);
474
- console.log(`\n → history[1]: ${forward.source} → ${forward.target} (${forward.status})`);
515
+ console.log(`\n → history[0]: ${restore.source} → ${restore.target} (${restore.status}, ${restore.moved} emails)`);
516
+ console.log(`\n → history[1]: ${forward.source} → ${forward.target} (${forward.status}, ${forward.moved} emails)`);
475
517
  });
476
518
 
477
519
  // ─── Session Log ─────────────────────────────────────────────────────────────
@@ -538,4 +580,4 @@ console.log(`\n✅ Passed: ${passed}`);
538
580
  console.log(`❌ Failed: ${failed}`);
539
581
  console.log(`📊 Total: ${passed + failed}\n`);
540
582
 
541
- if (failed > 0) process.exit(1);
583
+ if (failed > 0) process.exit(1);