icloud-mcp 1.5.1 → 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 +240 -146
- package/package.json +2 -2
- package/test.js +0 -583
package/index.js
CHANGED
|
@@ -51,7 +51,7 @@ function createClient() {
|
|
|
51
51
|
// after passing the gate.
|
|
52
52
|
let _lastConnectTime = 0;
|
|
53
53
|
let _connectGate = Promise.resolve();
|
|
54
|
-
const MIN_CONNECT_INTERVAL =
|
|
54
|
+
const MIN_CONNECT_INTERVAL = 10; // ms between connection initiations
|
|
55
55
|
|
|
56
56
|
function createRateLimitedClient() {
|
|
57
57
|
const client = createClient();
|
|
@@ -183,6 +183,10 @@ function startOperation(source, target, uids) {
|
|
|
183
183
|
target,
|
|
184
184
|
totalUids: uids.length,
|
|
185
185
|
status: 'in_progress',
|
|
186
|
+
phase: 'copying',
|
|
187
|
+
verifiedAt: null,
|
|
188
|
+
deletedAt: null,
|
|
189
|
+
allFingerprints: null,
|
|
186
190
|
chunks,
|
|
187
191
|
summary: {
|
|
188
192
|
chunksComplete: 0,
|
|
@@ -218,6 +222,14 @@ function updateChunk(index, updates) {
|
|
|
218
222
|
});
|
|
219
223
|
}
|
|
220
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
|
+
|
|
221
233
|
function completeOperation() {
|
|
222
234
|
const data = readManifest();
|
|
223
235
|
if (!data.current) return;
|
|
@@ -241,10 +253,13 @@ function formatOperation(op) {
|
|
|
241
253
|
return {
|
|
242
254
|
operationId: op.operationId,
|
|
243
255
|
status: op.status,
|
|
256
|
+
phase: op.phase ?? null,
|
|
244
257
|
source: op.source,
|
|
245
258
|
target: op.target,
|
|
246
259
|
startedAt: op.startedAt,
|
|
247
260
|
updatedAt: op.updatedAt,
|
|
261
|
+
verifiedAt: op.verifiedAt ?? null,
|
|
262
|
+
deletedAt: op.deletedAt ?? null,
|
|
248
263
|
summary: op.summary,
|
|
249
264
|
failedChunks: op.chunks.filter(c => c.status === 'failed').map(c => ({
|
|
250
265
|
index: c.index,
|
|
@@ -314,13 +329,17 @@ async function withRetry(label, fn, maxAttempts = 3) {
|
|
|
314
329
|
|
|
315
330
|
// ─── Per-operation timeouts ───────────────────────────────────────────────────
|
|
316
331
|
|
|
332
|
+
const COPY_CHUNK_DELAY_MS = 500; // ms between COPY chunks — mitigates iCloud copy throttling
|
|
333
|
+
|
|
317
334
|
const TIMEOUT = {
|
|
318
|
-
METADATA:
|
|
319
|
-
FETCH:
|
|
320
|
-
SCAN:
|
|
321
|
-
BULK_OP:
|
|
322
|
-
CHUNK:
|
|
323
|
-
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)
|
|
324
343
|
};
|
|
325
344
|
|
|
326
345
|
function withTimeout(label, ms, fn) {
|
|
@@ -417,6 +436,11 @@ async function verifyInTarget(targetClient, fingerprints, chunkIndex, knownTotal
|
|
|
417
436
|
return { verified: true, missing: [], found: fingerprints.length, expected: fingerprints.length };
|
|
418
437
|
}
|
|
419
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
|
+
|
|
420
444
|
// Secondary: Message-ID search only for the ones envelope scan missed
|
|
421
445
|
moveLog(chunkIndex, `${afterScan.length} unmatched after envelope scan — trying Message-ID search`);
|
|
422
446
|
const withMessageId = afterScan.filter(fp => fp.messageId);
|
|
@@ -442,12 +466,146 @@ async function verifyInTarget(targetClient, fingerprints, chunkIndex, knownTotal
|
|
|
442
466
|
};
|
|
443
467
|
}
|
|
444
468
|
|
|
445
|
-
// ───
|
|
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) ─────────────
|
|
446
606
|
|
|
447
607
|
async function safeMoveEmails(uids, sourceMailbox, targetMailbox) {
|
|
448
608
|
const operation = startOperation(sourceMailbox, targetMailbox, uids);
|
|
449
|
-
let totalMoved = 0;
|
|
450
|
-
let totalFailed = 0;
|
|
451
609
|
const opStart = Date.now();
|
|
452
610
|
|
|
453
611
|
process.stderr.write(`[move] starting: ${uids.length} emails, ${operation.chunks.length} chunks, ${sourceMailbox} → ${targetMailbox}\n`);
|
|
@@ -456,151 +614,87 @@ async function safeMoveEmails(uids, sourceMailbox, targetMailbox) {
|
|
|
456
614
|
let tgtClient = await openClient(targetMailbox);
|
|
457
615
|
|
|
458
616
|
try {
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
+
}
|
|
463
626
|
|
|
464
|
-
|
|
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');
|
|
465
630
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
+
}
|
|
471
646
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
for (const subChunk of subChunks) {
|
|
475
|
-
try {
|
|
476
|
-
await withTimeout(`safeMoveEmails chunk ${chunk.index}`, TIMEOUT.CHUNK, async () => {
|
|
477
|
-
// Step 1: fetch fingerprints from source
|
|
478
|
-
let t = Date.now();
|
|
479
|
-
const envelopes = [];
|
|
480
|
-
try {
|
|
481
|
-
for await (const msg of srcClient.fetch(subChunk, { envelope: true }, { uid: true })) {
|
|
482
|
-
envelopes.push(msg);
|
|
483
|
-
}
|
|
484
|
-
} catch (err) {
|
|
485
|
-
if (!isTransient(err)) throw err;
|
|
486
|
-
moveLog(chunk.index, `fetch envelopes failed (${err.message}), reconnecting...`);
|
|
487
|
-
srcClient = await reconnect(srcClient, sourceMailbox);
|
|
488
|
-
for await (const msg of srcClient.fetch(subChunk, { envelope: true }, { uid: true })) {
|
|
489
|
-
envelopes.push(msg);
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
const fingerprints = envelopes.map(buildFingerprint);
|
|
493
|
-
const withMsgId = fingerprints.filter(fp => fp.messageId).length;
|
|
494
|
-
moveLog(chunk.index, `fetched ${envelopes.length} envelopes (${withMsgId} with Message-ID) (${elapsed(t)})`);
|
|
495
|
-
|
|
496
|
-
updateManifest((data) => {
|
|
497
|
-
if (!data.current) return;
|
|
498
|
-
const c = data.current.chunks[chunk.index];
|
|
499
|
-
if (!c) return;
|
|
500
|
-
c.fingerprints = [...c.fingerprints, ...fingerprints];
|
|
501
|
-
c.status = 'pending';
|
|
502
|
-
});
|
|
503
|
-
|
|
504
|
-
// Step 2: copy to target
|
|
505
|
-
t = Date.now();
|
|
506
|
-
try {
|
|
507
|
-
await srcClient.messageCopy(subChunk, targetMailbox, { uid: true });
|
|
508
|
-
} catch (err) {
|
|
509
|
-
if (!isTransient(err)) throw err;
|
|
510
|
-
moveLog(chunk.index, `copy failed (${err.message}), reconnecting...`);
|
|
511
|
-
srcClient = await reconnect(srcClient, sourceMailbox);
|
|
512
|
-
await srcClient.messageCopy(subChunk, targetMailbox, { uid: true });
|
|
513
|
-
}
|
|
514
|
-
moveLog(chunk.index, `copied ${subChunk.length} emails to target (${elapsed(t)})`);
|
|
515
|
-
updateChunk(chunk.index, { status: 'copied_not_verified', copiedAt: new Date().toISOString() });
|
|
516
|
-
|
|
517
|
-
// Step 3: verify in target
|
|
518
|
-
t = Date.now();
|
|
519
|
-
let verification;
|
|
520
|
-
try {
|
|
521
|
-
const tgtMb = await tgtClient.mailboxOpen(targetMailbox);
|
|
522
|
-
verification = await verifyInTarget(tgtClient, fingerprints, chunk.index, tgtMb.exists);
|
|
523
|
-
} catch (err) {
|
|
524
|
-
if (!isTransient(err)) throw err;
|
|
525
|
-
moveLog(chunk.index, `verify failed (${err.message}), reconnecting...`);
|
|
526
|
-
tgtClient = await reconnect(tgtClient, targetMailbox);
|
|
527
|
-
verification = await verifyInTarget(tgtClient, fingerprints, chunk.index);
|
|
528
|
-
}
|
|
529
|
-
moveLog(chunk.index, `verification: ${verification.found}/${verification.expected} confirmed (${elapsed(t)})`);
|
|
530
|
-
|
|
531
|
-
if (!verification.verified) {
|
|
532
|
-
throw Object.assign(new Error('verification_failed'), { _verificationFailed: true });
|
|
533
|
-
}
|
|
534
|
-
updateChunk(chunk.index, { status: 'verified_not_deleted', verifiedAt: new Date().toISOString() });
|
|
535
|
-
|
|
536
|
-
// Step 4: delete from source
|
|
537
|
-
t = Date.now();
|
|
538
|
-
try {
|
|
539
|
-
await srcClient.messageDelete(subChunk, { uid: true });
|
|
540
|
-
} catch (err) {
|
|
541
|
-
if (!isTransient(err)) throw err;
|
|
542
|
-
moveLog(chunk.index, `delete failed (${err.message}), reconnecting...`);
|
|
543
|
-
srcClient = await reconnect(srcClient, sourceMailbox);
|
|
544
|
-
await srcClient.messageDelete(subChunk, { uid: true });
|
|
545
|
-
}
|
|
546
|
-
moveLog(chunk.index, `deleted ${subChunk.length} from source (${elapsed(t)})`);
|
|
547
|
-
});
|
|
548
|
-
totalMoved += subChunk.length;
|
|
549
|
-
moveLog(chunk.index, `sub-chunk complete: ${subChunk.length} moved (chunk total: ${totalMoved}/${operation.totalUids})`);
|
|
550
|
-
} catch (err) {
|
|
551
|
-
if (err._verificationFailed) {
|
|
552
|
-
verificationFailed = true;
|
|
553
|
-
break;
|
|
554
|
-
}
|
|
555
|
-
moveLog(chunk.index, `FAILED: ${err.message}`);
|
|
556
|
-
updateChunk(chunk.index, {
|
|
557
|
-
status: 'failed',
|
|
558
|
-
failureReason: err.message
|
|
559
|
-
});
|
|
560
|
-
totalFailed += chunkUids.length;
|
|
561
|
-
failOperation(`Chunk ${chunk.index} failed: ${err.message}`);
|
|
562
|
-
return {
|
|
563
|
-
status: 'partial',
|
|
564
|
-
moved: totalMoved,
|
|
565
|
-
failed: totalFailed,
|
|
566
|
-
message: `${err.message}. ${totalMoved} emails moved successfully. ${operation.totalUids - totalMoved} remain in source untouched. Call get_move_status for details.`
|
|
567
|
-
};
|
|
568
|
-
}
|
|
569
|
-
}
|
|
647
|
+
const { verification } = verifyResult;
|
|
648
|
+
moveLog('global', `verification: ${verification.found}/${verification.expected} confirmed`);
|
|
570
649
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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
|
+
}
|
|
575
659
|
|
|
576
|
-
|
|
577
|
-
moveLog(chunk.index, `FAILED: verification failed at both chunk sizes`);
|
|
578
|
-
updateChunk(chunk.index, {
|
|
579
|
-
status: 'failed',
|
|
580
|
-
failureReason: 'Verification failed at both chunk sizes'
|
|
581
|
-
});
|
|
582
|
-
totalFailed += chunkUids.length;
|
|
583
|
-
failOperation(`Verification failed after retry on chunk ${chunk.index}`);
|
|
584
|
-
return {
|
|
585
|
-
status: 'partial',
|
|
586
|
-
moved: totalMoved,
|
|
587
|
-
failed: totalFailed,
|
|
588
|
-
message: `Verification failed after retry. ${totalMoved} emails moved successfully. ${operation.totalUids - totalMoved} remain in source untouched. Call get_move_status for details.`
|
|
589
|
-
};
|
|
590
|
-
}
|
|
660
|
+
updateOperationPhase('verifying', { verifiedAt: new Date().toISOString() });
|
|
591
661
|
|
|
592
|
-
|
|
593
|
-
|
|
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
|
+
}
|
|
594
666
|
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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
|
+
};
|
|
599
686
|
}
|
|
600
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 });
|
|
601
694
|
completeOperation();
|
|
602
|
-
|
|
603
|
-
|
|
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 };
|
|
604
698
|
} finally {
|
|
605
699
|
await safeClose(srcClient);
|
|
606
700
|
await safeClose(tgtClient);
|
|
@@ -1185,7 +1279,7 @@ function logClear() {
|
|
|
1185
1279
|
|
|
1186
1280
|
async function main() {
|
|
1187
1281
|
const server = new Server(
|
|
1188
|
-
{ name: 'icloud-mail', version: '1.
|
|
1282
|
+
{ name: 'icloud-mail', version: '1.6.0' },
|
|
1189
1283
|
{ capabilities: { tools: {} } }
|
|
1190
1284
|
);
|
|
1191
1285
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "icloud-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "A Model Context Protocol (MCP) server for iCloud Mail",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"type": "module",
|
|
10
10
|
"scripts": {
|
|
11
11
|
"start": "node index.js",
|
|
12
|
-
"test": "node test.js"
|
|
12
|
+
"test": "node tests/test.js"
|
|
13
13
|
},
|
|
14
14
|
"keywords": [
|
|
15
15
|
"mcp",
|
package/test.js
DELETED
|
@@ -1,583 +0,0 @@
|
|
|
1
|
-
import { spawnSync } from 'child_process';
|
|
2
|
-
import { writeFileSync, unlinkSync } from 'fs';
|
|
3
|
-
import { tmpdir } from 'os';
|
|
4
|
-
import { join } from 'path';
|
|
5
|
-
import { fileURLToPath } from 'url';
|
|
6
|
-
|
|
7
|
-
const IMAP_USER = process.env.IMAP_USER;
|
|
8
|
-
const IMAP_PASSWORD = process.env.IMAP_PASSWORD;
|
|
9
|
-
|
|
10
|
-
if (!IMAP_USER || !IMAP_PASSWORD) {
|
|
11
|
-
console.error('Error: IMAP_USER and IMAP_PASSWORD environment variables are required');
|
|
12
|
-
process.exit(1);
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const projectDir = fileURLToPath(new URL('.', import.meta.url));
|
|
16
|
-
const VERBOSE = process.argv.includes('--verbose') || process.argv.includes('-v');
|
|
17
|
-
|
|
18
|
-
// Timeout in ms per tool category
|
|
19
|
-
const TIMEOUTS = {
|
|
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
|
|
22
|
-
default: 300000 // everything else — allow up to 5 min for large operations
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
const MAILBOX_MGMT_TOOLS = new Set(['create_mailbox', 'rename_mailbox', 'delete_mailbox']);
|
|
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
|
-
|
|
30
|
-
function callToolRaw(name, args = {}, timeout = TIMEOUTS.default) {
|
|
31
|
-
const messages = [
|
|
32
|
-
{ jsonrpc: '2.0', id: 0, method: 'initialize', params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'test', version: '1.0.0' } } },
|
|
33
|
-
{ jsonrpc: '2.0', method: 'notifications/initialized' },
|
|
34
|
-
{ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args } }
|
|
35
|
-
];
|
|
36
|
-
|
|
37
|
-
const input = messages.map(m => JSON.stringify(m)).join('\n') + '\n';
|
|
38
|
-
const tmpFile = join(tmpdir(), `mcp-test-${Date.now()}.txt`);
|
|
39
|
-
writeFileSync(tmpFile, input);
|
|
40
|
-
|
|
41
|
-
try {
|
|
42
|
-
const result = spawnSync(
|
|
43
|
-
'/bin/sh',
|
|
44
|
-
['-c', `cat "${tmpFile}" | /opt/homebrew/bin/node index.js`],
|
|
45
|
-
{
|
|
46
|
-
cwd: projectDir,
|
|
47
|
-
encoding: 'utf8',
|
|
48
|
-
timeout,
|
|
49
|
-
env: { ...process.env, IMAP_USER, IMAP_PASSWORD }
|
|
50
|
-
}
|
|
51
|
-
);
|
|
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
|
-
|
|
67
|
-
if (result.error) throw new Error(`Spawn error: ${result.error.message}`);
|
|
68
|
-
if (result.status !== 0) throw new Error(`Process exited with code ${result.status}: ${stderr}`);
|
|
69
|
-
|
|
70
|
-
const lines = (result.stdout || '').trim().split('\n').filter(l => l.trim().startsWith('{'));
|
|
71
|
-
const responses = lines.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
72
|
-
const toolResponse = responses.find(r => r.id === 1);
|
|
73
|
-
if (!toolResponse) throw new Error(`No response for tool: ${name}`);
|
|
74
|
-
const content = toolResponse.result?.content?.[0]?.text;
|
|
75
|
-
if (!content) throw new Error(`No content in response for: ${name}`);
|
|
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
|
-
}
|
|
89
|
-
return JSON.parse(content);
|
|
90
|
-
} finally {
|
|
91
|
-
try { unlinkSync(tmpFile); } catch {}
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function callTool(name, args = {}) {
|
|
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
|
-
|
|
100
|
-
try {
|
|
101
|
-
return callToolRaw(name, args, timeout);
|
|
102
|
-
} catch (err) {
|
|
103
|
-
// Only retry on spawn-level transient errors (ECONNRESET, ETIMEDOUT on the
|
|
104
|
-
// child process itself) — NOT on Tool errors, which are application-level
|
|
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';
|
|
109
|
-
const isSpawnTransient = (
|
|
110
|
-
err.message.includes('ECONNRESET') ||
|
|
111
|
-
err.message.includes('ETIMEDOUT')
|
|
112
|
-
) && !err.message.startsWith('Tool error:');
|
|
113
|
-
if (isSpawnTransient && !isBulkMove) {
|
|
114
|
-
console.log(`\n ⚠️ transient spawn error (${err.message.split(':')[0]}), retrying...`);
|
|
115
|
-
return callToolRaw(name, args, timeout);
|
|
116
|
-
}
|
|
117
|
-
throw err;
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
let passed = 0;
|
|
122
|
-
let failed = 0;
|
|
123
|
-
|
|
124
|
-
function test(name, fn) {
|
|
125
|
-
process.stdout.write(` Testing ${name}... `);
|
|
126
|
-
try {
|
|
127
|
-
fn();
|
|
128
|
-
console.log('✅ passed');
|
|
129
|
-
passed++;
|
|
130
|
-
} catch (err) {
|
|
131
|
-
console.log(`❌ failed: ${err.message}`);
|
|
132
|
-
failed++;
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function assert(condition, message) {
|
|
137
|
-
if (!condition) throw new Error(message);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
console.log('\n🧪 iCloud MCP Server Tests v1.5.1\n');
|
|
141
|
-
if (VERBOSE) console.log('🔊 Verbose mode: showing all stderr output\n');
|
|
142
|
-
|
|
143
|
-
// ─── Pre-flight cleanup ───────────────────────────────────────────────────────
|
|
144
|
-
// Abandon any leftover in-progress manifest from a previous crashed run,
|
|
145
|
-
// then restore any emails stranded in the test folder.
|
|
146
|
-
console.log('🧹 Pre-flight cleanup');
|
|
147
|
-
|
|
148
|
-
try {
|
|
149
|
-
const status = callTool('get_move_status');
|
|
150
|
-
if (status.current && status.current.status === 'in_progress') {
|
|
151
|
-
console.log(` ⚠️ found in-progress manifest (${status.current.operationId}) — abandoning before cleanup`);
|
|
152
|
-
callTool('abandon_move');
|
|
153
|
-
}
|
|
154
|
-
} catch (err) {
|
|
155
|
-
console.log(` ⚠️ could not check manifest: ${err.message}`);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
try { callTool('delete_mailbox', { name: 'mcp-test-folder-renamed' }); } catch {}
|
|
159
|
-
try { callTool('delete_mailbox', { name: 'mcp-test-folder' }); } catch {}
|
|
160
|
-
|
|
161
|
-
try {
|
|
162
|
-
const strandedInTest = callTool('count_emails', { mailbox: 'test' });
|
|
163
|
-
if (strandedInTest.count > 0) {
|
|
164
|
-
console.log(` ⚠️ found ${strandedInTest.count} stranded emails in test folder — restoring to newsletters first`);
|
|
165
|
-
const restoreResult = callTool('bulk_move', { sourceMailbox: 'test', targetMailbox: 'newsletters' });
|
|
166
|
-
console.log(` ⚠️ restore status: ${restoreResult.status}, moved: ${restoreResult.moved}`);
|
|
167
|
-
} else {
|
|
168
|
-
console.log(' ✓ test folder is clean');
|
|
169
|
-
}
|
|
170
|
-
} catch (err) {
|
|
171
|
-
console.log(` ⚠️ stranded email cleanup failed: ${err.message} — proceeding anyway`);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
console.log('');
|
|
175
|
-
|
|
176
|
-
// ─── Mailbox & Summary ────────────────────────────────────────────────────────
|
|
177
|
-
console.log('📬 Mailbox & Summary');
|
|
178
|
-
|
|
179
|
-
test('get_inbox_summary', () => {
|
|
180
|
-
const result = callTool('get_inbox_summary');
|
|
181
|
-
assert(typeof result.total === 'number', 'total should be a number');
|
|
182
|
-
assert(typeof result.unread === 'number', 'unread should be a number');
|
|
183
|
-
assert(result.mailbox === 'INBOX', 'mailbox should be INBOX');
|
|
184
|
-
console.log(`\n → ${result.total} total, ${result.unread} unread`);
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
test('get_mailbox_summary', () => {
|
|
188
|
-
const result = callTool('get_mailbox_summary', { mailbox: 'INBOX' });
|
|
189
|
-
assert(typeof result.total === 'number', 'total should be a number');
|
|
190
|
-
assert(typeof result.unread === 'number', 'unread should be a number');
|
|
191
|
-
assert(result.mailbox === 'INBOX', 'mailbox should be INBOX');
|
|
192
|
-
console.log(`\n → ${result.total} total, ${result.unread} unread`);
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
test('list_mailboxes', () => {
|
|
196
|
-
const result = callTool('list_mailboxes');
|
|
197
|
-
assert(Array.isArray(result), 'result should be an array');
|
|
198
|
-
assert(result.length > 0, 'should have at least one mailbox');
|
|
199
|
-
assert(result.some(m => m.path === 'INBOX'), 'INBOX should exist');
|
|
200
|
-
console.log(`\n → ${result.length} mailboxes found`);
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
test('get_top_senders (sample 50, default maxResults)', () => {
|
|
204
|
-
const result = callTool('get_top_senders', { sampleSize: 50 });
|
|
205
|
-
assert(Array.isArray(result.topAddresses), 'topAddresses should be an array');
|
|
206
|
-
assert(Array.isArray(result.topDomains), 'topDomains should be an array');
|
|
207
|
-
assert(result.sampledEmails <= 50, 'should not exceed sample size');
|
|
208
|
-
assert(result.topAddresses.length <= 20, 'should not exceed default maxResults of 20');
|
|
209
|
-
console.log(`\n → top sender: ${result.topAddresses[0]?.address} (${result.topAddresses[0]?.count})`);
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
test('get_top_senders (sample 50, maxResults 5)', () => {
|
|
213
|
-
const result = callTool('get_top_senders', { sampleSize: 50, maxResults: 5 });
|
|
214
|
-
assert(Array.isArray(result.topAddresses), 'topAddresses should be an array');
|
|
215
|
-
assert(result.topAddresses.length <= 5, 'should not exceed maxResults of 5');
|
|
216
|
-
assert(result.topDomains.length <= 5, 'domains should not exceed maxResults of 5');
|
|
217
|
-
console.log(`\n → ${result.topAddresses.length} senders, ${result.topDomains.length} domains (capped at 5)`);
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
test('get_unread_senders (sample 50, default maxResults)', () => {
|
|
221
|
-
const result = callTool('get_unread_senders', { sampleSize: 50 });
|
|
222
|
-
assert(Array.isArray(result), 'result should be an array');
|
|
223
|
-
assert(result.length <= 20, 'should not exceed default maxResults of 20');
|
|
224
|
-
console.log(`\n → ${result.length} unread senders found`);
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
test('get_unread_senders (sample 50, maxResults 5)', () => {
|
|
228
|
-
const result = callTool('get_unread_senders', { sampleSize: 50, maxResults: 5 });
|
|
229
|
-
assert(Array.isArray(result), 'result should be an array');
|
|
230
|
-
assert(result.length <= 5, 'should not exceed maxResults of 5');
|
|
231
|
-
console.log(`\n → ${result.length} unread senders found (capped at 5)`);
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
test('get_unread_senders (sample 50, maxResults 50)', () => {
|
|
235
|
-
const result = callTool('get_unread_senders', { sampleSize: 50, maxResults: 50 });
|
|
236
|
-
assert(Array.isArray(result), 'result should be an array');
|
|
237
|
-
assert(result.length <= 50, 'should not exceed maxResults of 50');
|
|
238
|
-
console.log(`\n → ${result.length} unread senders found (capped at 50)`);
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
// ─── Reading Emails ───────────────────────────────────────────────────────────
|
|
242
|
-
console.log('\n📧 Reading Emails');
|
|
243
|
-
|
|
244
|
-
test('read_inbox (page 1, limit 5)', () => {
|
|
245
|
-
const result = callTool('read_inbox', { limit: 5, page: 1 });
|
|
246
|
-
assert(Array.isArray(result.emails), 'emails should be an array');
|
|
247
|
-
assert(result.emails.length <= 5, 'should not exceed limit');
|
|
248
|
-
assert(typeof result.total === 'number', 'total should be a number');
|
|
249
|
-
assert(typeof result.hasMore === 'boolean', 'hasMore should be a boolean');
|
|
250
|
-
console.log(`\n → ${result.emails.length} emails, ${result.total} total`);
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
test('read_inbox (page 2)', () => {
|
|
254
|
-
const p1 = callTool('read_inbox', { limit: 5, page: 1 });
|
|
255
|
-
const p2 = callTool('read_inbox', { limit: 5, page: 2 });
|
|
256
|
-
assert(Array.isArray(p2.emails), 'page 2 emails should be an array');
|
|
257
|
-
if (p1.emails.length > 0 && p2.emails.length > 0) {
|
|
258
|
-
assert(p1.emails[0].uid !== p2.emails[0].uid, 'pages should have different emails');
|
|
259
|
-
}
|
|
260
|
-
console.log(`\n → page 2 has ${p2.emails.length} emails`);
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
test('read_inbox (unread only)', () => {
|
|
264
|
-
const result = callTool('read_inbox', { limit: 5, onlyUnread: true });
|
|
265
|
-
assert(Array.isArray(result.emails), 'emails should be an array');
|
|
266
|
-
result.emails.forEach(e => assert(!e.seen, 'all emails should be unread'));
|
|
267
|
-
console.log(`\n → ${result.emails.length} unread emails`);
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
test('search_emails (keyword only)', () => {
|
|
271
|
-
const result = callTool('search_emails', { query: 'test', limit: 5 });
|
|
272
|
-
assert(typeof result.total === 'number', 'total should be a number');
|
|
273
|
-
assert(Array.isArray(result.emails), 'emails should be an array');
|
|
274
|
-
console.log(`\n → ${result.total} results`);
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
test('search_emails (keyword + unread filter)', () => {
|
|
278
|
-
const result = callTool('search_emails', { query: 'test', limit: 5, unread: true });
|
|
279
|
-
assert(typeof result.total === 'number', 'total should be a number');
|
|
280
|
-
assert(Array.isArray(result.emails), 'emails should be an array');
|
|
281
|
-
console.log(`\n → ${result.total} unread results`);
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
test('search_emails (keyword + date filter)', () => {
|
|
285
|
-
const result = callTool('search_emails', { query: 'test', limit: 5, since: '2024-01-01' });
|
|
286
|
-
assert(typeof result.total === 'number', 'total should be a number');
|
|
287
|
-
assert(Array.isArray(result.emails), 'emails should be an array');
|
|
288
|
-
console.log(`\n → ${result.total} results since 2024`);
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
test('get_emails_by_sender', () => {
|
|
292
|
-
const senders = callTool('get_top_senders', { sampleSize: 20 });
|
|
293
|
-
const topSender = senders.topAddresses[0]?.address;
|
|
294
|
-
assert(topSender, 'should have at least one sender');
|
|
295
|
-
const result = callTool('get_emails_by_sender', { sender: topSender, limit: 5 });
|
|
296
|
-
assert(typeof result.total === 'number', 'total should be a number');
|
|
297
|
-
assert(Array.isArray(result.emails), 'emails should be an array');
|
|
298
|
-
console.log(`\n → ${result.total} emails from ${topSender}`);
|
|
299
|
-
});
|
|
300
|
-
|
|
301
|
-
test('get_emails_by_date_range', () => {
|
|
302
|
-
const result = callTool('get_emails_by_date_range', {
|
|
303
|
-
startDate: '2025-01-01',
|
|
304
|
-
endDate: '2025-12-31',
|
|
305
|
-
limit: 5
|
|
306
|
-
});
|
|
307
|
-
assert(typeof result.total === 'number', 'total should be a number');
|
|
308
|
-
assert(Array.isArray(result.emails), 'emails should be an array');
|
|
309
|
-
console.log(`\n → ${result.total} emails in 2025`);
|
|
310
|
-
});
|
|
311
|
-
|
|
312
|
-
test('get_email (fetch first email content)', () => {
|
|
313
|
-
const inbox = callTool('read_inbox', { limit: 1 });
|
|
314
|
-
assert(inbox.emails.length > 0, 'inbox should have at least one email');
|
|
315
|
-
const uid = inbox.emails[0].uid;
|
|
316
|
-
const result = callTool('get_email', { uid });
|
|
317
|
-
assert(result.uid === uid, 'uid should match');
|
|
318
|
-
assert(typeof result.body === 'string', 'body should be a string');
|
|
319
|
-
console.log(`\n → fetched email: "${result.subject?.slice(0, 40)}..."`);
|
|
320
|
-
});
|
|
321
|
-
|
|
322
|
-
// ─── Count & Bulk Query ───────────────────────────────────────────────────────
|
|
323
|
-
console.log('\n🔍 Count & Bulk Query');
|
|
324
|
-
|
|
325
|
-
test('count_emails (all in INBOX)', () => {
|
|
326
|
-
const result = callTool('count_emails', { mailbox: 'INBOX' });
|
|
327
|
-
assert(typeof result.count === 'number', 'count should be a number');
|
|
328
|
-
assert(result.count > 0, 'should have emails in INBOX');
|
|
329
|
-
console.log(`\n → ${result.count} emails match`);
|
|
330
|
-
});
|
|
331
|
-
|
|
332
|
-
test('count_emails (unread only)', () => {
|
|
333
|
-
const result = callTool('count_emails', { unread: true });
|
|
334
|
-
assert(typeof result.count === 'number', 'count should be a number');
|
|
335
|
-
console.log(`\n → ${result.count} unread emails`);
|
|
336
|
-
});
|
|
337
|
-
|
|
338
|
-
test('count_emails (read only)', () => {
|
|
339
|
-
const result = callTool('count_emails', { unread: false });
|
|
340
|
-
assert(typeof result.count === 'number', 'count should be a number');
|
|
341
|
-
console.log(`\n → ${result.count} read emails`);
|
|
342
|
-
});
|
|
343
|
-
|
|
344
|
-
test('count_emails (by domain)', () => {
|
|
345
|
-
const senders = callTool('get_top_senders', { sampleSize: 20 });
|
|
346
|
-
const topDomain = senders.topDomains[0]?.domain;
|
|
347
|
-
assert(topDomain, 'should have at least one domain');
|
|
348
|
-
const result = callTool('count_emails', { domain: topDomain });
|
|
349
|
-
assert(typeof result.count === 'number', 'count should be a number');
|
|
350
|
-
console.log(`\n → ${result.count} emails from @${topDomain}`);
|
|
351
|
-
});
|
|
352
|
-
|
|
353
|
-
test('count_emails (before date)', () => {
|
|
354
|
-
const result = callTool('count_emails', { before: '2020-01-01' });
|
|
355
|
-
assert(typeof result.count === 'number', 'count should be a number');
|
|
356
|
-
console.log(`\n → ${result.count} emails before 2020`);
|
|
357
|
-
});
|
|
358
|
-
|
|
359
|
-
test('count_emails (flagged false)', () => {
|
|
360
|
-
const result = callTool('count_emails', { flagged: false });
|
|
361
|
-
assert(typeof result.count === 'number', 'count should be a number');
|
|
362
|
-
console.log(`\n → ${result.count} unflagged emails`);
|
|
363
|
-
});
|
|
364
|
-
|
|
365
|
-
test('bulk_move (dryRun)', () => {
|
|
366
|
-
const senders = callTool('get_top_senders', { sampleSize: 20 });
|
|
367
|
-
const topDomain = senders.topDomains[0]?.domain;
|
|
368
|
-
assert(topDomain, 'should have at least one domain');
|
|
369
|
-
const result = callTool('bulk_move', { domain: topDomain, targetMailbox: 'Archive', dryRun: true });
|
|
370
|
-
assert(result.dryRun === true, 'dryRun should be true');
|
|
371
|
-
assert(typeof result.wouldMove === 'number', 'wouldMove should be a number');
|
|
372
|
-
assert(result.targetMailbox === 'Archive', 'targetMailbox should be Archive');
|
|
373
|
-
console.log(`\n → would move ${result.wouldMove} emails from @${topDomain}`);
|
|
374
|
-
});
|
|
375
|
-
|
|
376
|
-
test('bulk_delete (dryRun)', () => {
|
|
377
|
-
const result = callTool('bulk_delete', { before: '2015-01-01', dryRun: true });
|
|
378
|
-
assert(result.dryRun === true, 'dryRun should be true');
|
|
379
|
-
assert(typeof result.wouldDelete === 'number', 'wouldDelete should be a number');
|
|
380
|
-
assert(typeof result.sourceMailbox === 'string', 'sourceMailbox should be a string');
|
|
381
|
-
console.log(`\n → would delete ${result.wouldDelete} emails before 2015`);
|
|
382
|
-
});
|
|
383
|
-
|
|
384
|
-
// ─── Write Operations ─────────────────────────────────────────────────────────
|
|
385
|
-
console.log('\n✏️ Write Operations (flag/mark only — no deletions)');
|
|
386
|
-
|
|
387
|
-
test('flag_email and unflag_email', () => {
|
|
388
|
-
const inbox = callTool('read_inbox', { limit: 1 });
|
|
389
|
-
assert(inbox.emails.length > 0, 'inbox should have at least one email');
|
|
390
|
-
const uid = inbox.emails[0].uid;
|
|
391
|
-
const flagResult = callTool('flag_email', { uid, flagged: true });
|
|
392
|
-
assert(flagResult === true, 'flag should return true');
|
|
393
|
-
const unflagResult = callTool('flag_email', { uid, flagged: false });
|
|
394
|
-
assert(unflagResult === true, 'unflag should return true');
|
|
395
|
-
console.log(`\n → flagged and unflagged uid ${uid}`);
|
|
396
|
-
});
|
|
397
|
-
|
|
398
|
-
test('mark_as_read and mark_as_unread', () => {
|
|
399
|
-
const inbox = callTool('read_inbox', { limit: 1 });
|
|
400
|
-
assert(inbox.emails.length > 0, 'inbox should have at least one email');
|
|
401
|
-
const uid = inbox.emails[0].uid;
|
|
402
|
-
const readResult = callTool('mark_as_read', { uid, seen: true });
|
|
403
|
-
assert(readResult === true, 'mark read should return true');
|
|
404
|
-
const unreadResult = callTool('mark_as_read', { uid, seen: false });
|
|
405
|
-
assert(unreadResult === true, 'mark unread should return true');
|
|
406
|
-
console.log(`\n → marked read/unread uid ${uid}`);
|
|
407
|
-
});
|
|
408
|
-
|
|
409
|
-
// ─── Mailbox Management ───────────────────────────────────────────────────────
|
|
410
|
-
console.log('\n🗂️ Mailbox Management');
|
|
411
|
-
|
|
412
|
-
test('create_mailbox', () => {
|
|
413
|
-
const result = callTool('create_mailbox', { name: 'mcp-test-folder' });
|
|
414
|
-
assert(result.created === 'mcp-test-folder', 'should confirm creation');
|
|
415
|
-
console.log(`\n → created: ${result.created}`);
|
|
416
|
-
});
|
|
417
|
-
|
|
418
|
-
test('rename_mailbox + delete_mailbox', () => {
|
|
419
|
-
const renamed = callTool('rename_mailbox', { oldName: 'mcp-test-folder', newName: 'mcp-test-folder-renamed' });
|
|
420
|
-
assert(renamed.renamed.from === 'mcp-test-folder', 'from should match old name');
|
|
421
|
-
assert(renamed.renamed.to === 'mcp-test-folder-renamed', 'to should match new name');
|
|
422
|
-
console.log(`\n → renamed: ${renamed.renamed.from} → ${renamed.renamed.to}`);
|
|
423
|
-
|
|
424
|
-
const deleted = callTool('delete_mailbox', { name: 'mcp-test-folder-renamed' });
|
|
425
|
-
assert(deleted.deleted === 'mcp-test-folder-renamed', 'should confirm deletion');
|
|
426
|
-
console.log(`\n → deleted: ${deleted.deleted}`);
|
|
427
|
-
});
|
|
428
|
-
|
|
429
|
-
// ─── Move Manifest ────────────────────────────────────────────────────────────
|
|
430
|
-
console.log('\n🗺️ Move Manifest');
|
|
431
|
-
|
|
432
|
-
test('get_move_status (no operation)', () => {
|
|
433
|
-
// Abandon any leftover operation so we start clean
|
|
434
|
-
try { callTool('abandon_move'); } catch {}
|
|
435
|
-
const result = callTool('get_move_status');
|
|
436
|
-
assert(result.status === 'no_operation', `expected no_operation, got ${result.status}`);
|
|
437
|
-
assert(Array.isArray(result.history), 'history should be an array');
|
|
438
|
-
console.log(`\n → status: ${result.status}, history: ${result.history.length} entries`);
|
|
439
|
-
});
|
|
440
|
-
|
|
441
|
-
test('abandon_move (nothing to abandon)', () => {
|
|
442
|
-
const result = callTool('abandon_move');
|
|
443
|
-
assert(result.abandoned === false, 'should return abandoned: false when nothing in progress');
|
|
444
|
-
assert(typeof result.message === 'string', 'should include a message');
|
|
445
|
-
console.log(`\n → ${result.message}`);
|
|
446
|
-
});
|
|
447
|
-
|
|
448
|
-
// ─── Safe Move (live) ─────────────────────────────────────────────────────────
|
|
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)');
|
|
455
|
-
|
|
456
|
-
const MOVE_SAMPLE = 50;
|
|
457
|
-
|
|
458
|
-
test(`bulk_move newsletters → test (${MOVE_SAMPLE} emails, fingerprint verified)`, () => {
|
|
459
|
-
const beforeSource = callTool('count_emails', { mailbox: 'newsletters' });
|
|
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})`);
|
|
462
|
-
|
|
463
|
-
const moveResult = callTool('bulk_move', { sourceMailbox: 'newsletters', targetMailbox: 'test', limit: MOVE_SAMPLE });
|
|
464
|
-
console.log(`\n → status: ${moveResult.status}, moved: ${moveResult.moved} of ${moveResult.total}`);
|
|
465
|
-
assert(moveResult.status === 'complete', `expected complete, got ${moveResult.status}: ${moveResult.message || ''}`);
|
|
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}`);
|
|
468
|
-
|
|
469
|
-
const afterSource = callTool('count_emails', { mailbox: 'newsletters' });
|
|
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}`);
|
|
472
|
-
|
|
473
|
-
const afterTarget = callTool('count_emails', { mailbox: 'test' });
|
|
474
|
-
console.log(`\n → test folder after: ${afterTarget.count}`);
|
|
475
|
-
assert(afterTarget.count === MOVE_SAMPLE, `test should have ${MOVE_SAMPLE}, has ${afterTarget.count}`);
|
|
476
|
-
});
|
|
477
|
-
|
|
478
|
-
test('get_move_status (after completed move)', () => {
|
|
479
|
-
const result = callTool('get_move_status');
|
|
480
|
-
assert(result.status === 'no_operation', `expected no_operation after completed move, got ${result.status}`);
|
|
481
|
-
assert(result.history.length > 0, 'history should have at least one entry');
|
|
482
|
-
const last = result.history[0];
|
|
483
|
-
assert(last.status === 'complete', `last operation should be complete, got ${last.status}`);
|
|
484
|
-
assert(last.source === 'newsletters', `source should be newsletters, got ${last.source}`);
|
|
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}`);
|
|
487
|
-
console.log(`\n → last op: ${last.status}, ${last.moved}/${last.total} moved from ${last.source} → ${last.target}`);
|
|
488
|
-
});
|
|
489
|
-
|
|
490
|
-
test(`bulk_move test → newsletters (restore ${MOVE_SAMPLE} emails, fingerprint verified)`, () => {
|
|
491
|
-
const beforeSource = callTool('count_emails', { mailbox: 'test' });
|
|
492
|
-
assert(beforeSource.count === MOVE_SAMPLE, `test should have ${MOVE_SAMPLE} emails, has ${beforeSource.count}`);
|
|
493
|
-
console.log(`\n → test before restore: ${beforeSource.count}`);
|
|
494
|
-
|
|
495
|
-
// No limit needed — test folder only has MOVE_SAMPLE emails; small folder = fast delete
|
|
496
|
-
const moveBack = callTool('bulk_move', { sourceMailbox: 'test', targetMailbox: 'newsletters' });
|
|
497
|
-
console.log(`\n → status: ${moveBack.status}, moved: ${moveBack.moved} of ${moveBack.total}`);
|
|
498
|
-
assert(moveBack.status === 'complete', `expected complete, got ${moveBack.status}: ${moveBack.message || ''}`);
|
|
499
|
-
assert(moveBack.moved === MOVE_SAMPLE, `moved ${moveBack.moved} but expected ${MOVE_SAMPLE}`);
|
|
500
|
-
|
|
501
|
-
const afterSource = callTool('count_emails', { mailbox: 'test' });
|
|
502
|
-
console.log(`\n → test after (should be 0): ${afterSource.count}`);
|
|
503
|
-
assert(afterSource.count === 0, `test should be empty, has ${afterSource.count}`);
|
|
504
|
-
});
|
|
505
|
-
|
|
506
|
-
test('get_move_status (history has both moves)', () => {
|
|
507
|
-
const result = callTool('get_move_status');
|
|
508
|
-
assert(result.status === 'no_operation', 'no operation should be in progress');
|
|
509
|
-
assert(result.history.length >= 2, `history should have at least 2 entries, has ${result.history.length}`);
|
|
510
|
-
const [restore, forward] = result.history;
|
|
511
|
-
assert(restore.status === 'complete', `restore op should be complete, got ${restore.status}`);
|
|
512
|
-
assert(restore.source === 'test', `restore source should be test, got ${restore.source}`);
|
|
513
|
-
assert(forward.status === 'complete', `forward op should be complete, got ${forward.status}`);
|
|
514
|
-
assert(forward.source === 'newsletters', `forward source should be newsletters, got ${forward.source}`);
|
|
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)`);
|
|
517
|
-
});
|
|
518
|
-
|
|
519
|
-
// ─── Session Log ─────────────────────────────────────────────────────────────
|
|
520
|
-
console.log('\n📝 Session Log');
|
|
521
|
-
|
|
522
|
-
test('log_clear', () => {
|
|
523
|
-
const result = callTool('log_clear');
|
|
524
|
-
assert(result.cleared === true, 'should confirm cleared');
|
|
525
|
-
console.log(`\n → log cleared`);
|
|
526
|
-
});
|
|
527
|
-
|
|
528
|
-
test('log_write (plan)', () => {
|
|
529
|
-
const result = callTool('log_write', { step: 'plan: test log functionality' });
|
|
530
|
-
assert(Array.isArray(result.steps), 'steps should be an array');
|
|
531
|
-
assert(result.steps.length === 1, 'should have 1 step');
|
|
532
|
-
assert(typeof result.startedAt === 'string', 'startedAt should be a string');
|
|
533
|
-
assert(result.steps[0].step === 'plan: test log functionality', 'step content should match');
|
|
534
|
-
assert(typeof result.steps[0].time === 'string', 'step should have a timestamp');
|
|
535
|
-
console.log(`\n → wrote step, log has ${result.steps.length} entry`);
|
|
536
|
-
});
|
|
537
|
-
|
|
538
|
-
test('log_write (second step)', () => {
|
|
539
|
-
const result = callTool('log_write', { step: 'done: log test complete' });
|
|
540
|
-
assert(Array.isArray(result.steps), 'steps should be an array');
|
|
541
|
-
assert(result.steps.length === 2, 'should have 2 steps');
|
|
542
|
-
assert(result.steps[1].step === 'done: log test complete', 'second step content should match');
|
|
543
|
-
console.log(`\n → log now has ${result.steps.length} entries`);
|
|
544
|
-
});
|
|
545
|
-
|
|
546
|
-
test('log_read', () => {
|
|
547
|
-
const result = callTool('log_read');
|
|
548
|
-
assert(Array.isArray(result.steps), 'steps should be an array');
|
|
549
|
-
assert(result.steps.length === 2, 'should have 2 steps');
|
|
550
|
-
assert(result.steps[0].step === 'plan: test log functionality', 'first step should match');
|
|
551
|
-
assert(result.steps[1].step === 'done: log test complete', 'second step should match');
|
|
552
|
-
assert(typeof result.startedAt === 'string', 'startedAt should persist');
|
|
553
|
-
console.log(`\n → read log: ${result.steps.length} steps, started ${result.startedAt}`);
|
|
554
|
-
});
|
|
555
|
-
|
|
556
|
-
test('log_clear (cleanup)', () => {
|
|
557
|
-
const result = callTool('log_clear');
|
|
558
|
-
assert(result.cleared === true, 'should confirm cleared');
|
|
559
|
-
const log = callTool('log_read');
|
|
560
|
-
assert(log.steps.length === 0, 'log should be empty after clear');
|
|
561
|
-
assert(log.startedAt === null, 'startedAt should be null after clear');
|
|
562
|
-
console.log(`\n → log cleared and verified empty`);
|
|
563
|
-
});
|
|
564
|
-
|
|
565
|
-
// ─── Destructive (skipped) ────────────────────────────────────────────────────
|
|
566
|
-
console.log('\n⚠️ Destructive Tests (skipped by default)');
|
|
567
|
-
console.log(' Skipping: bulk_delete (live)');
|
|
568
|
-
console.log(' Skipping: bulk_mark_read (live)');
|
|
569
|
-
console.log(' Skipping: bulk_mark_unread (live)');
|
|
570
|
-
console.log(' Skipping: bulk_flag (live)');
|
|
571
|
-
console.log(' Skipping: bulk_delete_by_sender');
|
|
572
|
-
console.log(' Skipping: bulk_delete_by_subject');
|
|
573
|
-
console.log(' Skipping: delete_older_than');
|
|
574
|
-
console.log(' Skipping: delete_email');
|
|
575
|
-
console.log(' Skipping: empty_trash');
|
|
576
|
-
console.log(' Run with --destructive flag to enable these\n');
|
|
577
|
-
|
|
578
|
-
console.log('─'.repeat(40));
|
|
579
|
-
console.log(`\n✅ Passed: ${passed}`);
|
|
580
|
-
console.log(`❌ Failed: ${failed}`);
|
|
581
|
-
console.log(`📊 Total: ${passed + failed}\n`);
|
|
582
|
-
|
|
583
|
-
if (failed > 0) process.exit(1);
|