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.
- package/index.js +303 -180
- package/package.json +2 -2
- 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
|
-
|
|
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:
|
|
292
|
-
FETCH:
|
|
293
|
-
SCAN:
|
|
294
|
-
BULK_OP:
|
|
295
|
-
CHUNK:
|
|
296
|
-
SINGLE:
|
|
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
|
-
// ───
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
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
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
569
|
-
|
|
570
|
-
|
|
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
|
-
|
|
576
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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.
|
|
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 =
|
|
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 =
|
|
1851
|
+
const client = createRateLimitedClient();
|
|
1729
1852
|
await client.connect();
|
|
1730
1853
|
const mb = await client.mailboxOpen('INBOX');
|
|
1731
1854
|
await client.logout();
|