icloud-mcp 1.5.0 → 1.6.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 (3) hide show
  1. package/index.js +303 -180
  2. package/package.json +2 -2
  3. package/test.js +0 -583
package/index.js CHANGED
@@ -42,8 +42,35 @@ function createClient() {
42
42
 
43
43
  // ─── Managed client helpers ───────────────────────────────────────────────────
44
44
 
45
- async function openClient(mailbox) {
45
+ // Rate limit: space out connection initiations within a single server process
46
+ // to avoid triggering iCloud's connection throttle under concurrent tool calls.
47
+ // Wraps connect() on every client returned by createClient() so the gate
48
+ // applies regardless of whether tools use openClient() or createClient() directly.
49
+ // Uses a serialized gate — concurrent callers queue up; each waits 200ms after
50
+ // the previous before initiating its connection. Connections run concurrently
51
+ // after passing the gate.
52
+ let _lastConnectTime = 0;
53
+ let _connectGate = Promise.resolve();
54
+ const MIN_CONNECT_INTERVAL = 10; // ms between connection initiations
55
+
56
+ function createRateLimitedClient() {
46
57
  const client = createClient();
58
+ const originalConnect = client.connect.bind(client);
59
+ client.connect = async () => {
60
+ await new Promise(resolve => {
61
+ _connectGate = _connectGate.then(async () => {
62
+ const wait = MIN_CONNECT_INTERVAL - (Date.now() - _lastConnectTime);
63
+ if (wait > 0) await new Promise(r => setTimeout(r, wait));
64
+ _lastConnectTime = Date.now();
65
+ }).then(resolve, resolve);
66
+ });
67
+ return originalConnect();
68
+ };
69
+ return client;
70
+ }
71
+
72
+ async function openClient(mailbox) {
73
+ const client = createRateLimitedClient();
47
74
  await client.connect();
48
75
  if (mailbox) await client.mailboxOpen(mailbox);
49
76
  return client;
@@ -156,6 +183,10 @@ function startOperation(source, target, uids) {
156
183
  target,
157
184
  totalUids: uids.length,
158
185
  status: 'in_progress',
186
+ phase: 'copying',
187
+ verifiedAt: null,
188
+ deletedAt: null,
189
+ allFingerprints: null,
159
190
  chunks,
160
191
  summary: {
161
192
  chunksComplete: 0,
@@ -191,6 +222,14 @@ function updateChunk(index, updates) {
191
222
  });
192
223
  }
193
224
 
225
+ function updateOperationPhase(phase, extraFields = {}) {
226
+ updateManifest((data) => {
227
+ if (!data.current) return;
228
+ data.current.phase = phase;
229
+ Object.assign(data.current, extraFields);
230
+ });
231
+ }
232
+
194
233
  function completeOperation() {
195
234
  const data = readManifest();
196
235
  if (!data.current) return;
@@ -214,10 +253,13 @@ function formatOperation(op) {
214
253
  return {
215
254
  operationId: op.operationId,
216
255
  status: op.status,
256
+ phase: op.phase ?? null,
217
257
  source: op.source,
218
258
  target: op.target,
219
259
  startedAt: op.startedAt,
220
260
  updatedAt: op.updatedAt,
261
+ verifiedAt: op.verifiedAt ?? null,
262
+ deletedAt: op.deletedAt ?? null,
221
263
  summary: op.summary,
222
264
  failedChunks: op.chunks.filter(c => c.status === 'failed').map(c => ({
223
265
  index: c.index,
@@ -287,13 +329,17 @@ async function withRetry(label, fn, maxAttempts = 3) {
287
329
 
288
330
  // ─── Per-operation timeouts ───────────────────────────────────────────────────
289
331
 
332
+ const COPY_CHUNK_DELAY_MS = 500; // ms between COPY chunks — mitigates iCloud copy throttling
333
+
290
334
  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,
335
+ METADATA: 15_000,
336
+ FETCH: 30_000,
337
+ SCAN: 60_000,
338
+ BULK_OP: 60_000,
339
+ CHUNK: 300_000,
340
+ SINGLE: 15_000,
341
+ VERIFY_ALL: 120_000, // full envelope scan for all N emails
342
+ DELETE_ALL: 600_000, // flag all + single UID EXPUNGE (measured up to 521s at 5k)
297
343
  };
298
344
 
299
345
  function withTimeout(label, ms, fn) {
@@ -390,6 +436,11 @@ async function verifyInTarget(targetClient, fingerprints, chunkIndex, knownTotal
390
436
  return { verified: true, missing: [], found: fingerprints.length, expected: fingerprints.length };
391
437
  }
392
438
 
439
+ if (afterScan.length > 200) {
440
+ moveLog(chunkIndex, `${afterScan.length} unmatched after envelope scan — too many for Message-ID fallback, treating as failed`);
441
+ return { verified: false, missing: afterScan, found: fingerprints.length - afterScan.length, expected: fingerprints.length };
442
+ }
443
+
393
444
  // Secondary: Message-ID search only for the ones envelope scan missed
394
445
  moveLog(chunkIndex, `${afterScan.length} unmatched after envelope scan — trying Message-ID search`);
395
446
  const withMessageId = afterScan.filter(fp => fp.messageId);
@@ -415,12 +466,146 @@ async function verifyInTarget(targetClient, fingerprints, chunkIndex, knownTotal
415
466
  };
416
467
  }
417
468
 
418
- // ─── Safe Move (v3: connection reuse + envelope-first verify + logging) ───────
469
+ // ─── Option B phase helpers ────────────────────────────────────────────────────
470
+
471
+ // Phase 1: Copy all chunks to target without deleting.
472
+ // Returns { success, totalCopied, srcClient, errorResult }
473
+ async function copyAllChunks(operation, srcClient, targetMailbox, sourceMailbox) {
474
+ let totalCopied = 0;
475
+
476
+ for (const chunk of operation.chunks) {
477
+ const chunkUids = chunk.uids;
478
+ const chunkStart = Date.now();
479
+ moveLog(chunk.index, `starting copy (${chunkUids.length} emails)`);
480
+
481
+ try {
482
+ await withTimeout(`copy chunk ${chunk.index}`, TIMEOUT.CHUNK, async () => {
483
+ // Step 1: fetch envelopes → fingerprints
484
+ let t = Date.now();
485
+ const envelopes = [];
486
+ try {
487
+ for await (const msg of srcClient.fetch(chunkUids, { envelope: true }, { uid: true })) {
488
+ envelopes.push(msg);
489
+ }
490
+ } catch (err) {
491
+ if (!isTransient(err)) throw err;
492
+ moveLog(chunk.index, `fetch envelopes failed (${err.message}), reconnecting...`);
493
+ srcClient = await reconnect(srcClient, sourceMailbox);
494
+ for await (const msg of srcClient.fetch(chunkUids, { envelope: true }, { uid: true })) {
495
+ envelopes.push(msg);
496
+ }
497
+ }
498
+ const fingerprints = envelopes.map(buildFingerprint);
499
+ const withMsgId = fingerprints.filter(fp => fp.messageId).length;
500
+ moveLog(chunk.index, `fetched ${envelopes.length} envelopes (${withMsgId} with Message-ID) (${elapsed(t)})`);
501
+
502
+ // Update in-memory chunk so verifyAllChunks can flatMap fingerprints later
503
+ chunk.fingerprints = fingerprints;
504
+ updateManifest((data) => {
505
+ if (!data.current) return;
506
+ const c = data.current.chunks[chunk.index];
507
+ if (!c) return;
508
+ c.fingerprints = fingerprints;
509
+ });
510
+
511
+ // Step 2: copy to target
512
+ t = Date.now();
513
+ try {
514
+ await srcClient.messageCopy(chunkUids, targetMailbox, { uid: true });
515
+ } catch (err) {
516
+ if (!isTransient(err)) throw err;
517
+ moveLog(chunk.index, `copy failed (${err.message}), reconnecting...`);
518
+ srcClient = await reconnect(srcClient, sourceMailbox);
519
+ await srcClient.messageCopy(chunkUids, targetMailbox, { uid: true });
520
+ }
521
+ moveLog(chunk.index, `copied ${chunkUids.length} emails to target (${elapsed(t)})`);
522
+ updateChunk(chunk.index, { status: 'copied_not_verified', copiedAt: new Date().toISOString() });
523
+ });
524
+
525
+ totalCopied += chunkUids.length;
526
+ moveLog(chunk.index, `copy complete (${elapsed(chunkStart)})`);
527
+ } catch (err) {
528
+ moveLog(chunk.index, `copy FAILED: ${err.message}`);
529
+ updateChunk(chunk.index, { status: 'failed', failureReason: err.message });
530
+ return {
531
+ success: false,
532
+ totalCopied,
533
+ srcClient,
534
+ errorResult: {
535
+ status: 'partial',
536
+ moved: 0,
537
+ failed: operation.totalUids,
538
+ message: `Copy failed on chunk ${chunk.index}: ${err.message}. No emails deleted from source. ${totalCopied} emails were copied to target but not verified — call get_move_status for details.`
539
+ }
540
+ };
541
+ }
542
+
543
+ // Delay between chunks to mitigate iCloud copy throttling
544
+ if (chunk.index < operation.chunks.length - 1) {
545
+ await new Promise(r => setTimeout(r, COPY_CHUNK_DELAY_MS));
546
+ }
547
+ }
548
+
549
+ return { success: true, totalCopied, srcClient, errorResult: null };
550
+ }
551
+
552
+ // Phase 2: Verify all copied emails are present in target.
553
+ // Returns { verification, tgtClient }
554
+ async function verifyAllChunks(tgtClient, operation, targetMailbox) {
555
+ const allFingerprints = operation.chunks.flatMap(c => c.fingerprints);
556
+
557
+ updateManifest((data) => {
558
+ if (!data.current) return;
559
+ data.current.allFingerprints = allFingerprints;
560
+ });
561
+
562
+ let tgtMb;
563
+ try {
564
+ tgtMb = await tgtClient.mailboxOpen(targetMailbox);
565
+ } catch (err) {
566
+ if (!isTransient(err)) throw err;
567
+ moveLog('global', `mailboxOpen failed (${err.message}), reconnecting...`);
568
+ tgtClient = await reconnect(tgtClient, targetMailbox);
569
+ tgtMb = await tgtClient.mailboxOpen(targetMailbox);
570
+ }
571
+
572
+ let verification;
573
+ try {
574
+ verification = await verifyInTarget(tgtClient, allFingerprints, 'global', tgtMb.exists);
575
+ } catch (err) {
576
+ if (!isTransient(err)) throw err;
577
+ moveLog('global', `verify failed (${err.message}), reconnecting...`);
578
+ tgtClient = await reconnect(tgtClient, targetMailbox);
579
+ verification = await verifyInTarget(tgtClient, allFingerprints, 'global');
580
+ }
581
+
582
+ return { verification, tgtClient };
583
+ }
584
+
585
+ // Phase 3: Delete all source emails in a single EXPUNGE.
586
+ // Returns { srcClient }
587
+ async function deleteAllChunks(srcClient, operation, sourceMailbox) {
588
+ const allUids = operation.chunks.flatMap(c => c.uids);
589
+ const t = Date.now();
590
+
591
+ try {
592
+ await srcClient.messageDelete(allUids, { uid: true });
593
+ } catch (err) {
594
+ if (!isTransient(err)) throw err;
595
+ moveLog('global', `delete failed (${err.message}), reconnecting...`);
596
+ srcClient = await reconnect(srcClient, sourceMailbox);
597
+ // Retry is idempotent — expunging already-gone UIDs is a no-op
598
+ await srcClient.messageDelete(allUids, { uid: true });
599
+ }
600
+
601
+ moveLog('global', `deleted ${allUids.length} from source — single EXPUNGE (${elapsed(t)})`);
602
+ return { srcClient };
603
+ }
604
+
605
+ // ─── Safe Move (Option B: COPY-all → VERIFY-all → single EXPUNGE) ─────────────
419
606
 
420
607
  async function safeMoveEmails(uids, sourceMailbox, targetMailbox) {
421
608
  const operation = startOperation(sourceMailbox, targetMailbox, uids);
422
- let totalMoved = 0;
423
- let totalFailed = 0;
424
609
  const opStart = Date.now();
425
610
 
426
611
  process.stderr.write(`[move] starting: ${uids.length} emails, ${operation.chunks.length} chunks, ${sourceMailbox} → ${targetMailbox}\n`);
@@ -429,151 +614,87 @@ async function safeMoveEmails(uids, sourceMailbox, targetMailbox) {
429
614
  let tgtClient = await openClient(targetMailbox);
430
615
 
431
616
  try {
432
- for (const chunk of operation.chunks) {
433
- const chunkUids = chunk.uids;
434
- let succeeded = false;
435
- const chunkStart = Date.now();
617
+ // Phase 1: COPY all chunks to target (no delete yet)
618
+ process.stderr.write(`[move] phase 1/3: copying ${uids.length} emails in ${operation.chunks.length} chunks\n`);
619
+ const copyResult = await copyAllChunks(operation, srcClient, targetMailbox, sourceMailbox);
620
+ srcClient = copyResult.srcClient;
621
+
622
+ if (!copyResult.success) {
623
+ failOperation(`Copy phase failed: ${copyResult.errorResult.message}`);
624
+ return copyResult.errorResult;
625
+ }
436
626
 
437
- moveLog(chunk.index, `starting (${chunkUids.length} emails)`);
627
+ // Phase 2: VERIFY all emails are present in target
628
+ process.stderr.write(`[move] phase 2/3: verifying all ${copyResult.totalCopied} emails in target\n`);
629
+ updateOperationPhase('verifying');
438
630
 
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
- }
631
+ let verifyResult;
632
+ try {
633
+ verifyResult = await withTimeout('verify all', TIMEOUT.VERIFY_ALL, () =>
634
+ verifyAllChunks(tgtClient, operation, targetMailbox)
635
+ );
636
+ tgtClient = verifyResult.tgtClient;
637
+ } catch (err) {
638
+ moveLog('global', `verify phase FAILED: ${err.message}`);
639
+ failOperation(`Verify phase failed: ${err.message}`);
640
+ return {
641
+ status: 'failed',
642
+ moved: 0,
643
+ message: `Verification timed out or failed: ${err.message}. All ${copyResult.totalCopied} emails remain in source (not deleted). Call get_move_status for details.`
644
+ };
645
+ }
444
646
 
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
- }
647
+ const { verification } = verifyResult;
648
+ moveLog('global', `verification: ${verification.found}/${verification.expected} confirmed`);
543
649
 
544
- if (!verificationFailed) {
545
- succeeded = true;
546
- break;
547
- }
650
+ if (!verification.verified) {
651
+ moveLog('global', `FAILED: ${verification.missing.length} emails missing from target after copy`);
652
+ failOperation(`Verification failed: ${verification.missing.length} emails missing from target`);
653
+ return {
654
+ status: 'failed',
655
+ moved: 0,
656
+ message: `Verification failed: ${verification.missing.length} of ${verification.expected} emails did not arrive in target. Source emails untouched. Call get_move_status for details.`
657
+ };
658
+ }
548
659
 
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
- }
660
+ updateOperationPhase('verifying', { verifiedAt: new Date().toISOString() });
564
661
 
565
- moveLog(chunk.index, `verification failed at chunk size ${attemptChunkSize}, retrying at ${CHUNK_SIZE_RETRY}`);
566
- }
662
+ // Mark all chunks as verified
663
+ for (const chunk of operation.chunks) {
664
+ updateChunk(chunk.index, { status: 'verified_not_deleted', verifiedAt: new Date().toISOString() });
665
+ }
567
666
 
568
- if (succeeded) {
569
- updateChunk(chunk.index, { status: 'complete', deletedAt: new Date().toISOString() });
570
- moveLog(chunk.index, `COMPLETE (${elapsed(chunkStart)})`);
571
- }
667
+ // Phase 3: DELETE all source emails — single EXPUNGE
668
+ process.stderr.write(`[move] phase 3/3: deleting all ${uids.length} emails from source (1 EXPUNGE)\n`);
669
+ updateOperationPhase('deleting');
670
+
671
+ let deleteResult;
672
+ try {
673
+ deleteResult = await withTimeout('delete all', TIMEOUT.DELETE_ALL, () =>
674
+ deleteAllChunks(srcClient, operation, sourceMailbox)
675
+ );
676
+ srcClient = deleteResult.srcClient;
677
+ } catch (err) {
678
+ moveLog('global', `delete phase FAILED: ${err.message}`);
679
+ // Emails are safe in target (verified). Source may still have them.
680
+ failOperation(`Delete phase failed: ${err.message}`);
681
+ return {
682
+ status: 'failed',
683
+ moved: 0,
684
+ message: `Delete phase failed: ${err.message}. All ${copyResult.totalCopied} emails exist in target (verified) but may still exist in source. Call get_move_status for details.`
685
+ };
572
686
  }
573
687
 
688
+ // Mark all chunks complete
689
+ const now = new Date().toISOString();
690
+ for (const chunk of operation.chunks) {
691
+ updateChunk(chunk.index, { status: 'complete', deletedAt: now });
692
+ }
693
+ updateOperationPhase('deleting', { deletedAt: now });
574
694
  completeOperation();
575
- process.stderr.write(`[move] COMPLETE: ${totalMoved}/${operation.totalUids} emails moved (${elapsed(opStart)})\n`);
576
- return { status: 'complete', moved: totalMoved, total: operation.totalUids };
695
+
696
+ process.stderr.write(`[move] COMPLETE: ${copyResult.totalCopied}/${operation.totalUids} emails moved (${elapsed(opStart)})\n`);
697
+ return { status: 'complete', moved: copyResult.totalCopied, total: operation.totalUids };
577
698
  } finally {
578
699
  await safeClose(srcClient);
579
700
  await safeClose(tgtClient);
@@ -583,7 +704,7 @@ async function safeMoveEmails(uids, sourceMailbox, targetMailbox) {
583
704
  // ─── Email Functions ──────────────────────────────────────────────────────────
584
705
 
585
706
  async function fetchEmails(mailbox = 'INBOX', limit = 10, onlyUnread = false, page = 1) {
586
- const client = createClient();
707
+ const client = createRateLimitedClient();
587
708
  await client.connect();
588
709
  const mb = await client.mailboxOpen(mailbox);
589
710
  const total = mb.exists;
@@ -637,7 +758,7 @@ async function fetchEmails(mailbox = 'INBOX', limit = 10, onlyUnread = false, pa
637
758
  }
638
759
 
639
760
  async function getInboxSummary(mailbox = 'INBOX') {
640
- const client = createClient();
761
+ const client = createRateLimitedClient();
641
762
  await client.connect();
642
763
  const status = await client.status(mailbox, { messages: true, unseen: true, recent: true });
643
764
  await client.logout();
@@ -645,7 +766,7 @@ async function getInboxSummary(mailbox = 'INBOX') {
645
766
  }
646
767
 
647
768
  async function getTopSenders(mailbox = 'INBOX', sampleSize = 500, maxResults = 20) {
648
- const client = createClient();
769
+ const client = createRateLimitedClient();
649
770
  await client.connect();
650
771
  const mb = await client.mailboxOpen(mailbox);
651
772
  const total = mb.exists;
@@ -674,7 +795,7 @@ async function getTopSenders(mailbox = 'INBOX', sampleSize = 500, maxResults = 2
674
795
  }
675
796
 
676
797
  async function getUnreadSenders(mailbox = 'INBOX', sampleSize = 500, maxResults = 20) {
677
- const client = createClient();
798
+ const client = createRateLimitedClient();
678
799
  await client.connect();
679
800
  await client.mailboxOpen(mailbox);
680
801
  const uids = (await client.search({ seen: false }, { uid: true })) ?? [];
@@ -696,7 +817,7 @@ async function getUnreadSenders(mailbox = 'INBOX', sampleSize = 500, maxResults
696
817
  }
697
818
 
698
819
  async function getEmailsBySender(sender, mailbox = 'INBOX', limit = 10) {
699
- const client = createClient();
820
+ const client = createRateLimitedClient();
700
821
  await client.connect();
701
822
  await client.mailboxOpen(mailbox);
702
823
  const uids = (await client.search({ from: sender }, { uid: true })) ?? [];
@@ -721,7 +842,7 @@ async function getEmailsBySender(sender, mailbox = 'INBOX', limit = 10) {
721
842
  }
722
843
 
723
844
  async function bulkDeleteBySender(sender, mailbox = 'INBOX') {
724
- const client = createClient();
845
+ const client = createRateLimitedClient();
725
846
  await client.connect();
726
847
  await client.mailboxOpen(mailbox);
727
848
  const uids = (await client.search({ from: sender }, { uid: true })) ?? [];
@@ -736,12 +857,13 @@ async function bulkDeleteBySender(sender, mailbox = 'INBOX') {
736
857
  return { deleted, sender };
737
858
  }
738
859
 
739
- async function bulkMoveBySender(sender, targetMailbox, sourceMailbox = 'INBOX') {
740
- const client = createClient();
860
+ async function bulkMoveBySender(sender, targetMailbox, sourceMailbox = 'INBOX', dryRun = false) {
861
+ const client = createRateLimitedClient();
741
862
  await client.connect();
742
863
  await client.mailboxOpen(sourceMailbox);
743
864
  const uids = (await client.search({ from: sender }, { uid: true })) ?? [];
744
865
  await client.logout();
866
+ if (dryRun) return { dryRun: true, wouldMove: uids.length, sender, sourceMailbox, targetMailbox };
745
867
  if (uids.length === 0) return { moved: 0 };
746
868
  await ensureMailbox(targetMailbox);
747
869
  const result = await safeMoveEmails(uids, sourceMailbox, targetMailbox);
@@ -749,7 +871,7 @@ async function bulkMoveBySender(sender, targetMailbox, sourceMailbox = 'INBOX')
749
871
  }
750
872
 
751
873
  async function bulkDeleteBySubject(subject, mailbox = 'INBOX') {
752
- const client = createClient();
874
+ const client = createRateLimitedClient();
753
875
  await client.connect();
754
876
  await client.mailboxOpen(mailbox);
755
877
  const uids = (await client.search({ subject }, { uid: true })) ?? [];
@@ -765,7 +887,7 @@ async function bulkDeleteBySubject(subject, mailbox = 'INBOX') {
765
887
  }
766
888
 
767
889
  async function deleteOlderThan(days, mailbox = 'INBOX') {
768
- const client = createClient();
890
+ const client = createRateLimitedClient();
769
891
  await client.connect();
770
892
  await client.mailboxOpen(mailbox);
771
893
  const date = new Date();
@@ -783,7 +905,7 @@ async function deleteOlderThan(days, mailbox = 'INBOX') {
783
905
  }
784
906
 
785
907
  async function getEmailsByDateRange(startDate, endDate, mailbox = 'INBOX', limit = 10) {
786
- const client = createClient();
908
+ const client = createRateLimitedClient();
787
909
  await client.connect();
788
910
  await client.mailboxOpen(mailbox);
789
911
  const uids = (await client.search({ since: new Date(startDate), before: new Date(endDate) }, { uid: true })) ?? [];
@@ -808,7 +930,7 @@ async function getEmailsByDateRange(startDate, endDate, mailbox = 'INBOX', limit
808
930
  }
809
931
 
810
932
  async function bulkMarkRead(mailbox = 'INBOX', sender = null) {
811
- const client = createClient();
933
+ const client = createRateLimitedClient();
812
934
  await client.connect();
813
935
  await client.mailboxOpen(mailbox);
814
936
  const query = sender ? { from: sender, seen: false } : { seen: false };
@@ -820,7 +942,7 @@ async function bulkMarkRead(mailbox = 'INBOX', sender = null) {
820
942
  }
821
943
 
822
944
  async function bulkMarkUnread(mailbox = 'INBOX', sender = null) {
823
- const client = createClient();
945
+ const client = createRateLimitedClient();
824
946
  await client.connect();
825
947
  await client.mailboxOpen(mailbox);
826
948
  const query = sender ? { from: sender, seen: true } : { seen: true };
@@ -832,7 +954,7 @@ async function bulkMarkUnread(mailbox = 'INBOX', sender = null) {
832
954
  }
833
955
 
834
956
  async function bulkFlag(filters, flagged, mailbox = 'INBOX') {
835
- const client = createClient();
957
+ const client = createRateLimitedClient();
836
958
  await client.connect();
837
959
  await client.mailboxOpen(mailbox);
838
960
  const query = buildQuery(filters);
@@ -848,7 +970,7 @@ async function bulkFlag(filters, flagged, mailbox = 'INBOX') {
848
970
  }
849
971
 
850
972
  async function emptyTrash() {
851
- const client = createClient();
973
+ const client = createRateLimitedClient();
852
974
  await client.connect();
853
975
  await client.mailboxOpen('Deleted Messages');
854
976
  const uids = (await client.search({ all: true }, { uid: true })) ?? [];
@@ -864,7 +986,7 @@ async function emptyTrash() {
864
986
  }
865
987
 
866
988
  async function createMailbox(name) {
867
- const client = createClient();
989
+ const client = createRateLimitedClient();
868
990
  await client.connect();
869
991
  await client.mailboxCreate(name);
870
992
  await client.logout();
@@ -872,7 +994,7 @@ async function createMailbox(name) {
872
994
  }
873
995
 
874
996
  async function renameMailbox(oldName, newName) {
875
- const client = createClient();
997
+ const client = createRateLimitedClient();
876
998
  await client.connect();
877
999
  try {
878
1000
  await Promise.race([
@@ -888,7 +1010,7 @@ async function renameMailbox(oldName, newName) {
888
1010
  }
889
1011
 
890
1012
  async function deleteMailbox(name) {
891
- const client = createClient();
1013
+ const client = createRateLimitedClient();
892
1014
  await client.connect();
893
1015
  try {
894
1016
  await Promise.race([
@@ -904,7 +1026,7 @@ async function deleteMailbox(name) {
904
1026
  }
905
1027
 
906
1028
  async function getMailboxSummary(mailbox) {
907
- const client = createClient();
1029
+ const client = createRateLimitedClient();
908
1030
  await client.connect();
909
1031
  const status = await client.status(mailbox, { messages: true, unseen: true, recent: true });
910
1032
  await client.logout();
@@ -912,7 +1034,7 @@ async function getMailboxSummary(mailbox) {
912
1034
  }
913
1035
 
914
1036
  async function getEmailContent(uid, mailbox = 'INBOX') {
915
- const client = createClient();
1037
+ const client = createRateLimitedClient();
916
1038
  await client.connect();
917
1039
  await client.mailboxOpen(mailbox);
918
1040
  const meta = await client.fetchOne(uid, { envelope: true, flags: true }, { uid: true });
@@ -942,7 +1064,7 @@ async function getEmailContent(uid, mailbox = 'INBOX') {
942
1064
  }
943
1065
 
944
1066
  async function flagEmail(uid, flagged, mailbox = 'INBOX') {
945
- const client = createClient();
1067
+ const client = createRateLimitedClient();
946
1068
  await client.connect();
947
1069
  await client.mailboxOpen(mailbox);
948
1070
  if (flagged) {
@@ -955,7 +1077,7 @@ async function flagEmail(uid, flagged, mailbox = 'INBOX') {
955
1077
  }
956
1078
 
957
1079
  async function markAsRead(uid, seen, mailbox = 'INBOX') {
958
- const client = createClient();
1080
+ const client = createRateLimitedClient();
959
1081
  await client.connect();
960
1082
  await client.mailboxOpen(mailbox);
961
1083
  if (seen) {
@@ -968,7 +1090,7 @@ async function markAsRead(uid, seen, mailbox = 'INBOX') {
968
1090
  }
969
1091
 
970
1092
  async function deleteEmail(uid, mailbox = 'INBOX') {
971
- const client = createClient();
1093
+ const client = createRateLimitedClient();
972
1094
  await client.connect();
973
1095
  await client.mailboxOpen(mailbox);
974
1096
  await client.messageDelete(uid, { uid: true });
@@ -977,7 +1099,7 @@ async function deleteEmail(uid, mailbox = 'INBOX') {
977
1099
  }
978
1100
 
979
1101
  async function listMailboxes() {
980
- const client = createClient();
1102
+ const client = createRateLimitedClient();
981
1103
  await client.connect();
982
1104
  const tree = await client.listTree();
983
1105
  const mailboxes = [];
@@ -993,7 +1115,7 @@ async function listMailboxes() {
993
1115
  }
994
1116
 
995
1117
  async function searchEmails(query, mailbox = 'INBOX', limit = 10, filters = {}) {
996
- const client = createClient();
1118
+ const client = createRateLimitedClient();
997
1119
  await client.connect();
998
1120
  await client.mailboxOpen(mailbox);
999
1121
 
@@ -1024,7 +1146,7 @@ async function searchEmails(query, mailbox = 'INBOX', limit = 10, filters = {})
1024
1146
  }
1025
1147
 
1026
1148
  async function moveEmail(uid, targetMailbox, sourceMailbox = 'INBOX') {
1027
- const client = createClient();
1149
+ const client = createRateLimitedClient();
1028
1150
  await client.connect();
1029
1151
  await client.mailboxOpen(sourceMailbox);
1030
1152
  await client.messageMove(uid, targetMailbox, { uid: true });
@@ -1051,14 +1173,14 @@ function buildQuery(filters) {
1051
1173
  }
1052
1174
 
1053
1175
  async function ensureMailbox(name) {
1054
- const client = createClient();
1176
+ const client = createRateLimitedClient();
1055
1177
  await client.connect();
1056
1178
  try { await client.mailboxCreate(name); } catch { /* already exists */ }
1057
1179
  await client.logout();
1058
1180
  }
1059
1181
 
1060
1182
  async function bulkMove(filters, targetMailbox, sourceMailbox = 'INBOX', dryRun = false, limit = null) {
1061
- const client = createClient();
1183
+ const client = createRateLimitedClient();
1062
1184
  await client.connect();
1063
1185
  await client.mailboxOpen(sourceMailbox);
1064
1186
  const query = buildQuery(filters);
@@ -1083,7 +1205,7 @@ async function bulkMove(filters, targetMailbox, sourceMailbox = 'INBOX', dryRun
1083
1205
  // hanging forever.
1084
1206
 
1085
1207
  async function bulkDelete(filters, sourceMailbox = 'INBOX', dryRun = false) {
1086
- const client = createClient();
1208
+ const client = createRateLimitedClient();
1087
1209
  await client.connect();
1088
1210
  await client.mailboxOpen(sourceMailbox);
1089
1211
  const query = buildQuery(filters);
@@ -1120,7 +1242,7 @@ async function bulkDelete(filters, sourceMailbox = 'INBOX', dryRun = false) {
1120
1242
  }
1121
1243
 
1122
1244
  async function countEmails(filters, mailbox = 'INBOX') {
1123
- const client = createClient();
1245
+ const client = createRateLimitedClient();
1124
1246
  await client.connect();
1125
1247
  await client.mailboxOpen(mailbox);
1126
1248
  const query = buildQuery(filters);
@@ -1157,7 +1279,7 @@ function logClear() {
1157
1279
 
1158
1280
  async function main() {
1159
1281
  const server = new Server(
1160
- { name: 'icloud-mail', version: '1.5.0' },
1282
+ { name: 'icloud-mail', version: '1.6.0' },
1161
1283
  { capabilities: { tools: {} } }
1162
1284
  );
1163
1285
 
@@ -1337,7 +1459,8 @@ async function main() {
1337
1459
  properties: {
1338
1460
  sender: { type: 'string', description: 'Sender email address' },
1339
1461
  targetMailbox: { type: 'string', description: 'Destination folder' },
1340
- sourceMailbox: { type: 'string', description: 'Source mailbox (default INBOX)' }
1462
+ sourceMailbox: { type: 'string', description: 'Source mailbox (default INBOX)' },
1463
+ dryRun: { type: 'boolean', description: 'Preview only — return count without moving' }
1341
1464
  },
1342
1465
  required: ['sender', 'targetMailbox']
1343
1466
  }
@@ -1585,7 +1708,7 @@ async function main() {
1585
1708
  const { targetMailbox, sourceMailbox, dryRun, limit, ...filters } = args;
1586
1709
  result = await bulkMove(filters, targetMailbox, sourceMailbox || 'INBOX', dryRun || false, limit ?? null);
1587
1710
  } else if (name === 'bulk_move_by_sender') {
1588
- result = await bulkMoveBySender(args.sender, args.targetMailbox, args.sourceMailbox || 'INBOX');
1711
+ result = await bulkMoveBySender(args.sender, args.targetMailbox, args.sourceMailbox || 'INBOX', args.dryRun || false);
1589
1712
  } else if (name === 'bulk_delete') {
1590
1713
  // IMPROVEMENT 3: bulk_delete now has per-chunk timeouts internally
1591
1714
  const { sourceMailbox, dryRun, ...filters } = args;
@@ -1710,7 +1833,7 @@ async function runDoctor() {
1710
1833
  {
1711
1834
  label: `Connected to imap.mail.me.com:993`,
1712
1835
  run: async () => {
1713
- const client = createClient();
1836
+ const client = createRateLimitedClient();
1714
1837
  await client.connect();
1715
1838
  await client.logout();
1716
1839
  }
@@ -1725,7 +1848,7 @@ async function runDoctor() {
1725
1848
  {
1726
1849
  label: 'INBOX opened',
1727
1850
  run: async () => {
1728
- const client = createClient();
1851
+ const client = createRateLimitedClient();
1729
1852
  await client.connect();
1730
1853
  const mb = await client.mailboxOpen('INBOX');
1731
1854
  await client.logout();