icloud-mcp 1.4.2 → 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 +2 -1
  2. package/index.js +410 -216
  3. package/package.json +1 -1
  4. package/test.js +68 -26
package/README.md CHANGED
@@ -66,6 +66,7 @@ icloud-mcp doctor
66
66
  ─────────────────────────────
67
67
  ✅ IMAP_USER is set
68
68
  ✅ IMAP_PASSWORD is set
69
+ ✅ IMAP_USER looks like an email address
69
70
  ✅ Connected to imap.mail.me.com:993
70
71
  ✅ Authenticated as you@icloud.com
71
72
  ✅ INBOX opened (12453 messages)
@@ -131,7 +132,7 @@ Fully quit Claude Desktop (Cmd+Q) and reopen it. You should now be able to manag
131
132
  | `get_emails_by_date_range` | Emails between two dates |
132
133
  | `search_emails` | Search by keyword with optional filters (date, unread, domain, etc.) |
133
134
  | `count_emails` | Count emails matching any combination of filters without modifying them |
134
- | `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`) |
135
136
  | `bulk_delete` | Delete emails matching any combination of filters (supports `dryRun`) |
136
137
  | `bulk_flag` | Flag or unflag emails matching any combination of filters |
137
138
  | `bulk_mark_read` | Mark all emails (or all from a sender) as read |
package/index.js CHANGED
@@ -23,19 +23,44 @@ if (!IMAP_USER || !IMAP_PASSWORD) {
23
23
  }
24
24
  }
25
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
+
26
30
  function createClient() {
27
31
  return new ImapFlow({
28
32
  host: 'imap.mail.me.com',
29
33
  port: 993,
30
34
  secure: true,
31
35
  auth: { user: IMAP_USER, pass: IMAP_PASSWORD },
32
- 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
33
40
  });
34
41
  }
35
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
+
36
61
  // ─── Move Manifest ────────────────────────────────────────────────────────────
37
62
 
38
- const CHUNK_SIZE = 250;
63
+ const CHUNK_SIZE = 500;
39
64
  const CHUNK_SIZE_RETRY = 100;
40
65
 
41
66
  function readManifest() {
@@ -53,7 +78,9 @@ function writeManifest(data) {
53
78
 
54
79
  function updateManifest(updater) {
55
80
  const data = readManifest();
81
+ if (!data.current) return data; // guard: operation already archived/failed
56
82
  updater(data);
83
+ if (!data.current) return data; // guard: updater may have archived it
57
84
  data.current.updatedAt = new Date().toISOString();
58
85
  writeManifest(data);
59
86
  return data;
@@ -144,7 +171,9 @@ function startOperation(source, target, uids) {
144
171
 
145
172
  function updateChunk(index, updates) {
146
173
  updateManifest((data) => {
174
+ if (!data.current) return; // guard: operation already archived
147
175
  const chunk = data.current.chunks[index];
176
+ if (!chunk) return; // guard: chunk index out of range
148
177
  Object.assign(chunk, updates);
149
178
 
150
179
  let moved = 0, failed = 0, pending = 0;
@@ -226,188 +255,329 @@ function fingerprintToKey(fp) {
226
255
  return fp.messageId ?? fp.fallback;
227
256
  }
228
257
 
229
- // ─── IMAP Move Helpers ────────────────────────────────────────────────────────
258
+ // ─── Transient error detection ────────────────────────────────────────────────
230
259
 
231
- async function fetchEnvelopes(mailbox, uids) {
232
- const client = createClient();
233
- await client.connect();
234
- await client.mailboxOpen(mailbox);
235
- const envelopes = [];
236
- for await (const msg of client.fetch(uids, { envelope: true }, { uid: true })) {
237
- 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
+ }
238
284
  }
239
- await client.logout();
240
- return envelopes;
285
+ throw lastErr;
241
286
  }
242
287
 
243
- async function copyChunk(sourceMailbox, targetMailbox, uids) {
244
- const client = createClient();
245
- await client.connect();
246
- await client.mailboxOpen(sourceMailbox);
247
- await client.messageCopy(uids, targetMailbox, { uid: true });
248
- 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
+ ]);
249
310
  }
250
311
 
251
- async function deleteChunk(sourceMailbox, uids) {
252
- const client = createClient();
253
- await client.connect();
254
- await client.mailboxOpen(sourceMailbox);
255
- await client.messageDelete(uids, { uid: true });
256
- await client.logout();
312
+ // ─── Move logging ─────────────────────────────────────────────────────────────
313
+
314
+ function elapsed(startMs) {
315
+ return ((Date.now() - startMs) / 1000).toFixed(1) + 's';
257
316
  }
258
317
 
259
- async function verifyFingerprintsInTarget(targetMailbox, expectedFingerprints) {
260
- const client = createClient();
261
- await client.connect();
262
- const mb = await client.mailboxOpen(targetMailbox);
263
- 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).
328
+
329
+ async function verifyByEnvelopeScan(client, fingerprints, chunkIndex, knownTotal = null) {
330
+ if (fingerprints.length === 0) return { missing: [], found: 0 };
264
331
 
265
- // Fetch recent envelopes from target — copies land at the end
266
- const fetchCount = Math.min(total, expectedFingerprints.length * 2 + 500);
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);
267
335
  const start = Math.max(1, total - fetchCount + 1);
268
336
  const range = `${start}:${total}`;
269
337
 
270
338
  const targetKeys = new Set();
339
+ let scanned = 0;
271
340
  for await (const msg of client.fetch(range, { envelope: true })) {
272
341
  const fp = buildFingerprint(msg);
273
342
  targetKeys.add(fingerprintToKey(fp));
343
+ scanned++;
274
344
  }
275
- await client.logout();
276
345
 
277
346
  const missing = [];
278
- for (const fp of expectedFingerprints) {
279
- const key = fingerprintToKey(fp);
280
- if (!targetKeys.has(key)) missing.push(fp);
347
+ for (const fp of fingerprints) {
348
+ if (!targetKeys.has(fingerprintToKey(fp))) missing.push(fp);
281
349
  }
282
350
 
283
- return {
284
- verified: missing.length === 0,
285
- missing,
286
- found: expectedFingerprints.length - missing.length,
287
- expected: expectedFingerprints.length
288
- };
351
+ moveLog(chunkIndex, `envelope scan: ${scanned} scanned, ${fingerprints.length - missing.length}/${fingerprints.length} matched (${elapsed(t0)})`);
352
+ return { missing, found: fingerprints.length - missing.length };
289
353
  }
290
354
 
291
- // ─── Transient error detection ────────────────────────────────────────────────
355
+ async function verifyByMessageId(client, fingerprints, chunkIndex) {
356
+ if (fingerprints.length === 0) return { missing: [], verified: 0 };
292
357
 
293
- function isTransient(err) {
294
- const msg = err.message ?? '';
295
- return msg.includes('ECONNRESET') ||
296
- msg.includes('ECONNREFUSED') ||
297
- msg.includes('ETIMEDOUT') ||
298
- msg.includes('EPIPE') ||
299
- msg.includes('socket hang up') ||
300
- msg.includes('Connection not available') ||
301
- msg.includes('BAD') || // IMAP BAD response — often transient on Apple
302
- msg.includes('NO '); // IMAP NO response — often transient on Apple
303
- }
358
+ const t0 = Date.now();
359
+ const missing = [];
360
+ let verified = 0;
304
361
 
305
- async function withRetry(label, fn, maxAttempts = 3) {
306
- let lastErr;
307
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
308
- try {
309
- return await fn();
310
- } catch (err) {
311
- lastErr = err;
312
- if (!isTransient(err) || attempt === maxAttempts) throw err;
313
- const delay = attempt * 2000; // 2s, 4s backoff
314
- process.stderr.write(`[retry] ${label} failed (attempt ${attempt}/${maxAttempts}): ${err.message} — retrying in ${delay}ms\n`);
315
- 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)})`);
316
378
  }
317
379
  }
318
- throw lastErr;
380
+
381
+ moveLog(chunkIndex, `Message-ID fallback: ${verified}/${fingerprints.length} verified, ${missing.length} still missing (${elapsed(t0)})`);
382
+ return { missing, verified };
319
383
  }
320
384
 
321
- // ─── 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) ───────
322
419
 
323
420
  async function safeMoveEmails(uids, sourceMailbox, targetMailbox) {
324
421
  const operation = startOperation(sourceMailbox, targetMailbox, uids);
325
422
  let totalMoved = 0;
326
423
  let totalFailed = 0;
424
+ const opStart = Date.now();
327
425
 
328
- for (const chunk of operation.chunks) {
329
- const chunkUids = chunk.uids;
330
- let succeeded = false;
426
+ process.stderr.write(`[move] starting: ${uids.length} emails, ${operation.chunks.length} chunks, ${sourceMailbox} → ${targetMailbox}\n`);
331
427
 
332
- // Try at full chunk size first, then smaller on verification failure
333
- for (const attemptChunkSize of [CHUNK_SIZE, CHUNK_SIZE_RETRY]) {
334
- const subChunks = [];
335
- for (let i = 0; i < chunkUids.length; i += attemptChunkSize) {
336
- subChunks.push(chunkUids.slice(i, i + attemptChunkSize));
337
- }
428
+ let srcClient = await openClient(sourceMailbox);
429
+ let tgtClient = await openClient(targetMailbox);
338
430
 
339
- let verificationFailed = false;
340
-
341
- for (const subChunk of subChunks) {
342
- // Step 1: fetch fingerprints from source and write to manifest
343
- // before doing anything destructive — retries on transient errors
344
- const envelopes = await withRetry(
345
- `fetchEnvelopes chunk ${chunk.index}`,
346
- () => fetchEnvelopes(sourceMailbox, subChunk)
347
- );
348
- const fingerprints = envelopes.map(buildFingerprint);
349
-
350
- updateManifest((data) => {
351
- const c = data.current.chunks[chunk.index];
352
- c.fingerprints = [...c.fingerprints, ...fingerprints];
353
- c.status = 'pending';
354
- });
431
+ try {
432
+ for (const chunk of operation.chunks) {
433
+ const chunkUids = chunk.uids;
434
+ let succeeded = false;
435
+ const chunkStart = Date.now();
436
+
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
+ }
355
543
 
356
- // Step 2: copy to target — retries on transient errors
357
- await withRetry(
358
- `copyChunk chunk ${chunk.index}`,
359
- () => copyChunk(sourceMailbox, targetMailbox, subChunk)
360
- );
361
- updateChunk(chunk.index, { status: 'copied_not_verified', copiedAt: new Date().toISOString() });
362
-
363
- // Step 3: verify by fetching envelopes from target — retries on transient errors
364
- const verification = await withRetry(
365
- `verifyFingerprints chunk ${chunk.index}`,
366
- () => verifyFingerprintsInTarget(targetMailbox, fingerprints)
367
- );
368
- if (!verification.verified) {
369
- verificationFailed = true;
544
+ if (!verificationFailed) {
545
+ succeeded = true;
370
546
  break;
371
547
  }
372
- updateChunk(chunk.index, { status: 'verified_not_deleted', verifiedAt: new Date().toISOString() });
373
-
374
- // Step 4: safe to delete from source — retries on transient errors
375
- await withRetry(
376
- `deleteChunk chunk ${chunk.index}`,
377
- () => deleteChunk(sourceMailbox, subChunk)
378
- );
379
- totalMoved += subChunk.length;
380
- }
381
548
 
382
- if (!verificationFailed) {
383
- succeeded = true;
384
- 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}`);
385
566
  }
386
567
 
387
- if (attemptChunkSize === CHUNK_SIZE_RETRY) {
388
- // Verification failed at both chunk sizes stop and report, source untouched from here
389
- updateChunk(chunk.index, {
390
- status: 'failed',
391
- failureReason: 'Verification failed at both chunk sizes'
392
- });
393
- totalFailed += chunkUids.length;
394
- failOperation(`Verification failed after retry on chunk ${chunk.index}`);
395
- return {
396
- status: 'partial',
397
- moved: totalMoved,
398
- failed: totalFailed,
399
- message: `Verification failed after retry. ${totalMoved} emails moved successfully. ${operation.totalUids - totalMoved} remain in source untouched. Call get_move_status for details.`
400
- };
568
+ if (succeeded) {
569
+ updateChunk(chunk.index, { status: 'complete', deletedAt: new Date().toISOString() });
570
+ moveLog(chunk.index, `COMPLETE (${elapsed(chunkStart)})`);
401
571
  }
402
572
  }
403
573
 
404
- if (succeeded) {
405
- updateChunk(chunk.index, { status: 'complete', deletedAt: new Date().toISOString() });
406
- }
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);
407
580
  }
408
-
409
- completeOperation();
410
- return { status: 'complete', moved: totalMoved, total: operation.totalUids };
411
581
  }
412
582
 
413
583
  // ─── Email Functions ──────────────────────────────────────────────────────────
@@ -555,18 +725,14 @@ async function bulkDeleteBySender(sender, mailbox = 'INBOX') {
555
725
  await client.connect();
556
726
  await client.mailboxOpen(mailbox);
557
727
  const uids = (await client.search({ from: sender }, { uid: true })) ?? [];
558
- await client.logout();
559
- if (uids.length === 0) return { deleted: 0 };
728
+ if (uids.length === 0) { await client.logout(); return { deleted: 0 }; }
560
729
  let deleted = 0;
561
730
  for (let i = 0; i < uids.length; i += CHUNK_SIZE) {
562
731
  const chunk = uids.slice(i, i + CHUNK_SIZE);
563
- const dc = createClient();
564
- await dc.connect();
565
- await dc.mailboxOpen(mailbox);
566
- await dc.messageDelete(chunk, { uid: true });
567
- await dc.logout();
732
+ await client.messageDelete(chunk, { uid: true });
568
733
  deleted += chunk.length;
569
734
  }
735
+ await client.logout();
570
736
  return { deleted, sender };
571
737
  }
572
738
 
@@ -587,18 +753,14 @@ async function bulkDeleteBySubject(subject, mailbox = 'INBOX') {
587
753
  await client.connect();
588
754
  await client.mailboxOpen(mailbox);
589
755
  const uids = (await client.search({ subject }, { uid: true })) ?? [];
590
- await client.logout();
591
- if (uids.length === 0) return { deleted: 0 };
756
+ if (uids.length === 0) { await client.logout(); return { deleted: 0 }; }
592
757
  let deleted = 0;
593
758
  for (let i = 0; i < uids.length; i += CHUNK_SIZE) {
594
759
  const chunk = uids.slice(i, i + CHUNK_SIZE);
595
- const dc = createClient();
596
- await dc.connect();
597
- await dc.mailboxOpen(mailbox);
598
- await dc.messageDelete(chunk, { uid: true });
599
- await dc.logout();
760
+ await client.messageDelete(chunk, { uid: true });
600
761
  deleted += chunk.length;
601
762
  }
763
+ await client.logout();
602
764
  return { deleted, subject };
603
765
  }
604
766
 
@@ -609,18 +771,14 @@ async function deleteOlderThan(days, mailbox = 'INBOX') {
609
771
  const date = new Date();
610
772
  date.setDate(date.getDate() - days);
611
773
  const uids = (await client.search({ before: date }, { uid: true })) ?? [];
612
- await client.logout();
613
- if (uids.length === 0) return { deleted: 0 };
774
+ if (uids.length === 0) { await client.logout(); return { deleted: 0 }; }
614
775
  let deleted = 0;
615
776
  for (let i = 0; i < uids.length; i += CHUNK_SIZE) {
616
777
  const chunk = uids.slice(i, i + CHUNK_SIZE);
617
- const dc = createClient();
618
- await dc.connect();
619
- await dc.mailboxOpen(mailbox);
620
- await dc.messageDelete(chunk, { uid: true });
621
- await dc.logout();
778
+ await client.messageDelete(chunk, { uid: true });
622
779
  deleted += chunk.length;
623
780
  }
781
+ await client.logout();
624
782
  return { deleted, olderThan: date.toISOString() };
625
783
  }
626
784
 
@@ -899,14 +1057,16 @@ async function ensureMailbox(name) {
899
1057
  await client.logout();
900
1058
  }
901
1059
 
902
- async function bulkMove(filters, targetMailbox, sourceMailbox = 'INBOX', dryRun = false) {
1060
+ async function bulkMove(filters, targetMailbox, sourceMailbox = 'INBOX', dryRun = false, limit = null) {
903
1061
  const client = createClient();
904
1062
  await client.connect();
905
1063
  await client.mailboxOpen(sourceMailbox);
906
1064
  const query = buildQuery(filters);
907
- const uids = (await client.search(query, { uid: true })) ?? [];
1065
+ let uids = (await client.search(query, { uid: true })) ?? [];
908
1066
  await client.logout();
909
1067
 
1068
+ if (limit !== null) uids = uids.slice(0, limit);
1069
+
910
1070
  if (dryRun) {
911
1071
  return { dryRun: true, wouldMove: uids.length, sourceMailbox, targetMailbox, filters };
912
1072
  }
@@ -917,29 +1077,45 @@ async function bulkMove(filters, targetMailbox, sourceMailbox = 'INBOX', dryRun
917
1077
  return { ...result, sourceMailbox, targetMailbox, filters };
918
1078
  }
919
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
+
920
1085
  async function bulkDelete(filters, sourceMailbox = 'INBOX', dryRun = false) {
921
1086
  const client = createClient();
922
1087
  await client.connect();
923
1088
  await client.mailboxOpen(sourceMailbox);
924
1089
  const query = buildQuery(filters);
925
1090
  const uids = (await client.search(query, { uid: true })) ?? [];
926
- await client.logout();
927
1091
 
928
1092
  if (dryRun) {
1093
+ await client.logout();
929
1094
  return { dryRun: true, wouldDelete: uids.length, sourceMailbox, filters };
930
1095
  }
931
- if (uids.length === 0) return { deleted: 0, sourceMailbox };
1096
+ if (uids.length === 0) { await client.logout(); return { deleted: 0, sourceMailbox }; }
932
1097
 
933
1098
  let deleted = 0;
934
1099
  for (let i = 0; i < uids.length; i += CHUNK_SIZE) {
935
1100
  const chunk = uids.slice(i, i + CHUNK_SIZE);
936
- const deleteClient = createClient();
937
- await deleteClient.connect();
938
- await deleteClient.mailboxOpen(sourceMailbox);
939
- await deleteClient.messageDelete(chunk, { uid: true });
940
- await deleteClient.logout();
941
- 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
+ }
942
1117
  }
1118
+ await client.logout();
943
1119
  return { deleted, sourceMailbox, filters };
944
1120
  }
945
1121
 
@@ -981,7 +1157,7 @@ function logClear() {
981
1157
 
982
1158
  async function main() {
983
1159
  const server = new Server(
984
- { name: 'icloud-mail', version: '1.6.0' },
1160
+ { name: 'icloud-mail', version: '1.5.0' },
985
1161
  { capabilities: { tools: {} } }
986
1162
  );
987
1163
 
@@ -1110,6 +1286,7 @@ async function main() {
1110
1286
  targetMailbox: { type: 'string', description: 'Destination mailbox path' },
1111
1287
  sourceMailbox: { type: 'string', description: 'Source mailbox (default INBOX)' },
1112
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)' },
1113
1290
  ...filtersSchema
1114
1291
  },
1115
1292
  required: ['targetMailbox']
@@ -1117,7 +1294,7 @@ async function main() {
1117
1294
  },
1118
1295
  {
1119
1296
  name: 'bulk_delete',
1120
- 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.',
1121
1298
  inputSchema: {
1122
1299
  type: 'object',
1123
1300
  properties: {
@@ -1354,71 +1531,80 @@ async function main() {
1354
1531
  const { name, arguments: args } = request.params;
1355
1532
  try {
1356
1533
  let result;
1534
+ // ── Metadata tier (15s) ──
1357
1535
  if (name === 'get_inbox_summary') {
1358
- result = await getInboxSummary(args.mailbox || 'INBOX');
1536
+ result = await withTimeout('get_inbox_summary', TIMEOUT.METADATA, () => getInboxSummary(args.mailbox || 'INBOX'));
1359
1537
  } else if (name === 'get_mailbox_summary') {
1360
- result = await getMailboxSummary(args.mailbox);
1361
- } else if (name === 'get_top_senders') {
1362
- result = await getTopSenders(args.mailbox || 'INBOX', args.sampleSize || 500, args.maxResults || 20);
1363
- } else if (name === 'get_unread_senders') {
1364
- result = await getUnreadSenders(args.mailbox || 'INBOX', args.sampleSize || 500, args.maxResults || 20);
1365
- } else if (name === 'get_emails_by_sender') {
1366
- 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) ──
1367
1551
  } else if (name === 'read_inbox') {
1368
- 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));
1369
1553
  } else if (name === 'get_email') {
1370
- result = await getEmailContent(args.uid, args.mailbox || 'INBOX');
1554
+ result = await withTimeout('get_email', TIMEOUT.FETCH, () => getEmailContent(args.uid, args.mailbox || 'INBOX'));
1371
1555
  } else if (name === 'search_emails') {
1372
1556
  const { query, mailbox, limit, ...filters } = args;
1373
- result = await searchEmails(query, mailbox || 'INBOX', limit || 10, filters);
1374
- } else if (name === 'count_emails') {
1375
- const { mailbox, ...filters } = args;
1376
- result = await countEmails(filters, mailbox || 'INBOX');
1377
- } else if (name === 'bulk_move') {
1378
- const { targetMailbox, sourceMailbox, dryRun, ...filters } = args;
1379
- result = await bulkMove(filters, targetMailbox, sourceMailbox || 'INBOX', dryRun || false);
1380
- } else if (name === 'bulk_delete') {
1381
- const { sourceMailbox, dryRun, ...filters } = args;
1382
- result = await bulkDelete(filters, sourceMailbox || 'INBOX', dryRun || false);
1383
- } else if (name === 'bulk_flag') {
1384
- const { flagged, mailbox, ...filters } = args;
1385
- 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) ──
1386
1568
  } else if (name === 'bulk_delete_by_sender') {
1387
- result = await bulkDeleteBySender(args.sender, args.mailbox || 'INBOX');
1388
- } else if (name === 'bulk_move_by_sender') {
1389
- 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'));
1390
1570
  } else if (name === 'bulk_delete_by_subject') {
1391
- 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'));
1392
1572
  } else if (name === 'bulk_mark_read') {
1393
- 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));
1394
1574
  } else if (name === 'bulk_mark_unread') {
1395
- 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'));
1396
1579
  } else if (name === 'delete_older_than') {
1397
- result = await deleteOlderThan(args.days, args.mailbox || 'INBOX');
1398
- } else if (name === 'get_emails_by_date_range') {
1399
- 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) ──
1400
1594
  } else if (name === 'flag_email') {
1401
- 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'));
1402
1596
  } else if (name === 'mark_as_read') {
1403
- 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'));
1404
1598
  } else if (name === 'delete_email') {
1405
- result = await deleteEmail(args.uid, args.mailbox || 'INBOX');
1599
+ result = await withTimeout('delete_email', TIMEOUT.SINGLE, () => deleteEmail(args.uid, args.mailbox || 'INBOX'));
1406
1600
  } else if (name === 'move_email') {
1407
- result = await moveEmail(args.uid, args.targetMailbox, args.sourceMailbox || 'INBOX');
1408
- } else if (name === 'list_mailboxes') {
1409
- result = await listMailboxes();
1410
- } else if (name === 'create_mailbox') {
1411
- result = await createMailbox(args.name);
1412
- } else if (name === 'rename_mailbox') {
1413
- result = await renameMailbox(args.oldName, args.newName);
1414
- } else if (name === 'delete_mailbox') {
1415
- result = await deleteMailbox(args.name);
1416
- } else if (name === 'empty_trash') {
1417
- 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) ──
1418
1603
  } else if (name === 'get_move_status') {
1419
1604
  result = getMoveStatus();
1420
1605
  } else if (name === 'abandon_move') {
1421
1606
  result = abandonMove();
1607
+ // ── Session log (synchronous, no timeout needed) ──
1422
1608
  } else if (name === 'log_write') {
1423
1609
  result = logWrite(args.step);
1424
1610
  } else if (name === 'log_read') {
@@ -1476,6 +1662,14 @@ function friendlyError(err) {
1476
1662
  ].join('\n');
1477
1663
  }
1478
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
+
1479
1673
  if (msg.includes('Mailbox does not exist') || msg.includes('does not exist') || msg.includes('NONEXISTENT')) {
1480
1674
  return [
1481
1675
  `Mailbox not found: ${msg}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "icloud-mcp",
3
- "version": "1.4.2",
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);