icloud-mcp 1.4.2 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/index.js +410 -216
- package/package.json +1 -1
- package/test.js +68 -26
package/README.md
CHANGED
|
@@ -66,6 +66,7 @@ icloud-mcp doctor
|
|
|
66
66
|
─────────────────────────────
|
|
67
67
|
✅ IMAP_USER is set
|
|
68
68
|
✅ IMAP_PASSWORD is set
|
|
69
|
+
✅ IMAP_USER looks like an email address
|
|
69
70
|
✅ Connected to imap.mail.me.com:993
|
|
70
71
|
✅ Authenticated as you@icloud.com
|
|
71
72
|
✅ INBOX opened (12453 messages)
|
|
@@ -131,7 +132,7 @@ Fully quit Claude Desktop (Cmd+Q) and reopen it. You should now be able to manag
|
|
|
131
132
|
| `get_emails_by_date_range` | Emails between two dates |
|
|
132
133
|
| `search_emails` | Search by keyword with optional filters (date, unread, domain, etc.) |
|
|
133
134
|
| `count_emails` | Count emails matching any combination of filters without modifying them |
|
|
134
|
-
| `bulk_move` | Move emails matching any combination of filters between folders (supports `dryRun`) |
|
|
135
|
+
| `bulk_move` | Move emails matching any combination of filters between folders (supports `dryRun` and `limit`) |
|
|
135
136
|
| `bulk_delete` | Delete emails matching any combination of filters (supports `dryRun`) |
|
|
136
137
|
| `bulk_flag` | Flag or unflag emails matching any combination of filters |
|
|
137
138
|
| `bulk_mark_read` | Mark all emails (or all from a sender) as read |
|
package/index.js
CHANGED
|
@@ -23,19 +23,44 @@ if (!IMAP_USER || !IMAP_PASSWORD) {
|
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// ─── IMPROVEMENT 1: Connection-level timeout on createClient ──────────────────
|
|
27
|
+
// ImapFlow supports connectionTimeout and greetingTimeout options.
|
|
28
|
+
// This ensures we don't hang forever waiting for iCloud to respond.
|
|
29
|
+
|
|
26
30
|
function createClient() {
|
|
27
31
|
return new ImapFlow({
|
|
28
32
|
host: 'imap.mail.me.com',
|
|
29
33
|
port: 993,
|
|
30
34
|
secure: true,
|
|
31
35
|
auth: { user: IMAP_USER, pass: IMAP_PASSWORD },
|
|
32
|
-
logger: false
|
|
36
|
+
logger: false,
|
|
37
|
+
connectionTimeout: 15_000, // 15s to establish TCP+TLS connection
|
|
38
|
+
greetingTimeout: 15_000, // 15s to receive IMAP greeting after connect
|
|
39
|
+
socketTimeout: 60_000, // 60s of inactivity before socket is killed
|
|
33
40
|
});
|
|
34
41
|
}
|
|
35
42
|
|
|
43
|
+
// ─── Managed client helpers ───────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
async function openClient(mailbox) {
|
|
46
|
+
const client = createClient();
|
|
47
|
+
await client.connect();
|
|
48
|
+
if (mailbox) await client.mailboxOpen(mailbox);
|
|
49
|
+
return client;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function safeClose(client) {
|
|
53
|
+
try { await client.logout(); } catch { try { client.close(); } catch { /* already gone */ } }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function reconnect(client, mailbox) {
|
|
57
|
+
safeClose(client);
|
|
58
|
+
return openClient(mailbox);
|
|
59
|
+
}
|
|
60
|
+
|
|
36
61
|
// ─── Move Manifest ────────────────────────────────────────────────────────────
|
|
37
62
|
|
|
38
|
-
const CHUNK_SIZE =
|
|
63
|
+
const CHUNK_SIZE = 500;
|
|
39
64
|
const CHUNK_SIZE_RETRY = 100;
|
|
40
65
|
|
|
41
66
|
function readManifest() {
|
|
@@ -53,7 +78,9 @@ function writeManifest(data) {
|
|
|
53
78
|
|
|
54
79
|
function updateManifest(updater) {
|
|
55
80
|
const data = readManifest();
|
|
81
|
+
if (!data.current) return data; // guard: operation already archived/failed
|
|
56
82
|
updater(data);
|
|
83
|
+
if (!data.current) return data; // guard: updater may have archived it
|
|
57
84
|
data.current.updatedAt = new Date().toISOString();
|
|
58
85
|
writeManifest(data);
|
|
59
86
|
return data;
|
|
@@ -144,7 +171,9 @@ function startOperation(source, target, uids) {
|
|
|
144
171
|
|
|
145
172
|
function updateChunk(index, updates) {
|
|
146
173
|
updateManifest((data) => {
|
|
174
|
+
if (!data.current) return; // guard: operation already archived
|
|
147
175
|
const chunk = data.current.chunks[index];
|
|
176
|
+
if (!chunk) return; // guard: chunk index out of range
|
|
148
177
|
Object.assign(chunk, updates);
|
|
149
178
|
|
|
150
179
|
let moved = 0, failed = 0, pending = 0;
|
|
@@ -226,188 +255,329 @@ function fingerprintToKey(fp) {
|
|
|
226
255
|
return fp.messageId ?? fp.fallback;
|
|
227
256
|
}
|
|
228
257
|
|
|
229
|
-
// ───
|
|
258
|
+
// ─── Transient error detection ────────────────────────────────────────────────
|
|
230
259
|
|
|
231
|
-
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
260
|
+
function isTransient(err) {
|
|
261
|
+
const msg = err.message ?? '';
|
|
262
|
+
return msg.includes('ECONNRESET') ||
|
|
263
|
+
msg.includes('ECONNREFUSED') ||
|
|
264
|
+
msg.includes('ETIMEDOUT') ||
|
|
265
|
+
msg.includes('EPIPE') ||
|
|
266
|
+
msg.includes('socket hang up') ||
|
|
267
|
+
msg.includes('Connection not available') ||
|
|
268
|
+
msg.includes('BAD') ||
|
|
269
|
+
msg.includes('NO ');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function withRetry(label, fn, maxAttempts = 3) {
|
|
273
|
+
let lastErr;
|
|
274
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
275
|
+
try {
|
|
276
|
+
return await fn();
|
|
277
|
+
} catch (err) {
|
|
278
|
+
lastErr = err;
|
|
279
|
+
if (!isTransient(err) || attempt === maxAttempts) throw err;
|
|
280
|
+
const delay = attempt * 2000;
|
|
281
|
+
process.stderr.write(`[retry] ${label} failed (attempt ${attempt}/${maxAttempts}): ${err.message} — retrying in ${delay}ms\n`);
|
|
282
|
+
await new Promise(r => setTimeout(r, delay));
|
|
283
|
+
}
|
|
238
284
|
}
|
|
239
|
-
|
|
240
|
-
return envelopes;
|
|
285
|
+
throw lastErr;
|
|
241
286
|
}
|
|
242
287
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
288
|
+
// ─── Per-operation timeouts ───────────────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
const TIMEOUT = {
|
|
291
|
+
METADATA: 15_000,
|
|
292
|
+
FETCH: 30_000,
|
|
293
|
+
SCAN: 60_000,
|
|
294
|
+
BULK_OP: 60_000,
|
|
295
|
+
CHUNK: 300_000,
|
|
296
|
+
SINGLE: 15_000,
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
function withTimeout(label, ms, fn) {
|
|
300
|
+
let timer;
|
|
301
|
+
return Promise.race([
|
|
302
|
+
fn().finally(() => clearTimeout(timer)),
|
|
303
|
+
new Promise((_, reject) => {
|
|
304
|
+
timer = setTimeout(() => {
|
|
305
|
+
process.stderr.write(`[timeout] ${label} timed out after ${ms / 1000}s\n`);
|
|
306
|
+
reject(new Error(`${label} timed out after ${ms / 1000}s`));
|
|
307
|
+
}, ms);
|
|
308
|
+
})
|
|
309
|
+
]);
|
|
249
310
|
}
|
|
250
311
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
await client.messageDelete(uids, { uid: true });
|
|
256
|
-
await client.logout();
|
|
312
|
+
// ─── Move logging ─────────────────────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
function elapsed(startMs) {
|
|
315
|
+
return ((Date.now() - startMs) / 1000).toFixed(1) + 's';
|
|
257
316
|
}
|
|
258
317
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
318
|
+
function moveLog(chunkIndex, msg) {
|
|
319
|
+
process.stderr.write(`[move] chunk ${chunkIndex}: ${msg}\n`);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ─── Verification (v3: envelope scan first, Message-ID fallback) ──────────────
|
|
323
|
+
// Strategy: one bulk FETCH of recent envelopes in the target is far faster than
|
|
324
|
+
// N individual SEARCH commands. We use envelope scan as the primary check, then
|
|
325
|
+
// only fall back to per-email Message-ID SEARCH for the few that didn't match
|
|
326
|
+
// (which can happen if the envelope fingerprint differs slightly between source
|
|
327
|
+
// and target, e.g. date normalization).
|
|
328
|
+
|
|
329
|
+
async function verifyByEnvelopeScan(client, fingerprints, chunkIndex, knownTotal = null) {
|
|
330
|
+
if (fingerprints.length === 0) return { missing: [], found: 0 };
|
|
264
331
|
|
|
265
|
-
|
|
266
|
-
const
|
|
332
|
+
const t0 = Date.now();
|
|
333
|
+
const total = knownTotal ?? (await client.status(client.mailbox.path, { messages: true })).messages;
|
|
334
|
+
const fetchCount = Math.min(total, fingerprints.length + 150);
|
|
267
335
|
const start = Math.max(1, total - fetchCount + 1);
|
|
268
336
|
const range = `${start}:${total}`;
|
|
269
337
|
|
|
270
338
|
const targetKeys = new Set();
|
|
339
|
+
let scanned = 0;
|
|
271
340
|
for await (const msg of client.fetch(range, { envelope: true })) {
|
|
272
341
|
const fp = buildFingerprint(msg);
|
|
273
342
|
targetKeys.add(fingerprintToKey(fp));
|
|
343
|
+
scanned++;
|
|
274
344
|
}
|
|
275
|
-
await client.logout();
|
|
276
345
|
|
|
277
346
|
const missing = [];
|
|
278
|
-
for (const fp of
|
|
279
|
-
|
|
280
|
-
if (!targetKeys.has(key)) missing.push(fp);
|
|
347
|
+
for (const fp of fingerprints) {
|
|
348
|
+
if (!targetKeys.has(fingerprintToKey(fp))) missing.push(fp);
|
|
281
349
|
}
|
|
282
350
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
missing,
|
|
286
|
-
found: expectedFingerprints.length - missing.length,
|
|
287
|
-
expected: expectedFingerprints.length
|
|
288
|
-
};
|
|
351
|
+
moveLog(chunkIndex, `envelope scan: ${scanned} scanned, ${fingerprints.length - missing.length}/${fingerprints.length} matched (${elapsed(t0)})`);
|
|
352
|
+
return { missing, found: fingerprints.length - missing.length };
|
|
289
353
|
}
|
|
290
354
|
|
|
291
|
-
|
|
355
|
+
async function verifyByMessageId(client, fingerprints, chunkIndex) {
|
|
356
|
+
if (fingerprints.length === 0) return { missing: [], verified: 0 };
|
|
292
357
|
|
|
293
|
-
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
msg.includes('ECONNREFUSED') ||
|
|
297
|
-
msg.includes('ETIMEDOUT') ||
|
|
298
|
-
msg.includes('EPIPE') ||
|
|
299
|
-
msg.includes('socket hang up') ||
|
|
300
|
-
msg.includes('Connection not available') ||
|
|
301
|
-
msg.includes('BAD') || // IMAP BAD response — often transient on Apple
|
|
302
|
-
msg.includes('NO '); // IMAP NO response — often transient on Apple
|
|
303
|
-
}
|
|
358
|
+
const t0 = Date.now();
|
|
359
|
+
const missing = [];
|
|
360
|
+
let verified = 0;
|
|
304
361
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
362
|
+
for (const fp of fingerprints) {
|
|
363
|
+
if (!fp.messageId) {
|
|
364
|
+
// No Message-ID — can't verify this way, count as missing
|
|
365
|
+
missing.push(fp);
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
const uids = (await client.search({ header: ['Message-ID', fp.messageId] }, { uid: true })) ?? [];
|
|
369
|
+
if (uids.length === 0) {
|
|
370
|
+
missing.push(fp);
|
|
371
|
+
} else {
|
|
372
|
+
verified++;
|
|
373
|
+
}
|
|
374
|
+
// Progress logging every 25 emails
|
|
375
|
+
const checked = verified + missing.length;
|
|
376
|
+
if (checked % 25 === 0) {
|
|
377
|
+
moveLog(chunkIndex, `Message-ID fallback: ${checked}/${fingerprints.length} checked (${verified} found, ${missing.length} missing, ${elapsed(t0)})`);
|
|
316
378
|
}
|
|
317
379
|
}
|
|
318
|
-
|
|
380
|
+
|
|
381
|
+
moveLog(chunkIndex, `Message-ID fallback: ${verified}/${fingerprints.length} verified, ${missing.length} still missing (${elapsed(t0)})`);
|
|
382
|
+
return { missing, verified };
|
|
319
383
|
}
|
|
320
384
|
|
|
321
|
-
|
|
385
|
+
async function verifyInTarget(targetClient, fingerprints, chunkIndex, knownTotal = null) {
|
|
386
|
+
// Primary: fast envelope scan (one FETCH command)
|
|
387
|
+
const { missing: afterScan } = await verifyByEnvelopeScan(targetClient, fingerprints, chunkIndex, knownTotal);
|
|
388
|
+
|
|
389
|
+
if (afterScan.length === 0) {
|
|
390
|
+
return { verified: true, missing: [], found: fingerprints.length, expected: fingerprints.length };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Secondary: Message-ID search only for the ones envelope scan missed
|
|
394
|
+
moveLog(chunkIndex, `${afterScan.length} unmatched after envelope scan — trying Message-ID search`);
|
|
395
|
+
const withMessageId = afterScan.filter(fp => fp.messageId);
|
|
396
|
+
const noMessageId = afterScan.filter(fp => !fp.messageId);
|
|
397
|
+
|
|
398
|
+
if (withMessageId.length > 0) {
|
|
399
|
+
const { missing: stillMissing } = await verifyByMessageId(targetClient, withMessageId, chunkIndex);
|
|
400
|
+
const allMissing = [...stillMissing, ...noMessageId];
|
|
401
|
+
return {
|
|
402
|
+
verified: allMissing.length === 0,
|
|
403
|
+
missing: allMissing,
|
|
404
|
+
found: fingerprints.length - allMissing.length,
|
|
405
|
+
expected: fingerprints.length
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// No Message-IDs to try — whatever envelope scan missed is truly missing
|
|
410
|
+
return {
|
|
411
|
+
verified: noMessageId.length === 0,
|
|
412
|
+
missing: noMessageId,
|
|
413
|
+
found: fingerprints.length - noMessageId.length,
|
|
414
|
+
expected: fingerprints.length
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ─── Safe Move (v3: connection reuse + envelope-first verify + logging) ───────
|
|
322
419
|
|
|
323
420
|
async function safeMoveEmails(uids, sourceMailbox, targetMailbox) {
|
|
324
421
|
const operation = startOperation(sourceMailbox, targetMailbox, uids);
|
|
325
422
|
let totalMoved = 0;
|
|
326
423
|
let totalFailed = 0;
|
|
424
|
+
const opStart = Date.now();
|
|
327
425
|
|
|
328
|
-
|
|
329
|
-
const chunkUids = chunk.uids;
|
|
330
|
-
let succeeded = false;
|
|
426
|
+
process.stderr.write(`[move] starting: ${uids.length} emails, ${operation.chunks.length} chunks, ${sourceMailbox} → ${targetMailbox}\n`);
|
|
331
427
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
const subChunks = [];
|
|
335
|
-
for (let i = 0; i < chunkUids.length; i += attemptChunkSize) {
|
|
336
|
-
subChunks.push(chunkUids.slice(i, i + attemptChunkSize));
|
|
337
|
-
}
|
|
428
|
+
let srcClient = await openClient(sourceMailbox);
|
|
429
|
+
let tgtClient = await openClient(targetMailbox);
|
|
338
430
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
431
|
+
try {
|
|
432
|
+
for (const chunk of operation.chunks) {
|
|
433
|
+
const chunkUids = chunk.uids;
|
|
434
|
+
let succeeded = false;
|
|
435
|
+
const chunkStart = Date.now();
|
|
436
|
+
|
|
437
|
+
moveLog(chunk.index, `starting (${chunkUids.length} emails)`);
|
|
438
|
+
|
|
439
|
+
for (const attemptChunkSize of [CHUNK_SIZE, CHUNK_SIZE_RETRY]) {
|
|
440
|
+
const subChunks = [];
|
|
441
|
+
for (let i = 0; i < chunkUids.length; i += attemptChunkSize) {
|
|
442
|
+
subChunks.push(chunkUids.slice(i, i + attemptChunkSize));
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
let verificationFailed = false;
|
|
446
|
+
|
|
447
|
+
for (const subChunk of subChunks) {
|
|
448
|
+
try {
|
|
449
|
+
await withTimeout(`safeMoveEmails chunk ${chunk.index}`, TIMEOUT.CHUNK, async () => {
|
|
450
|
+
// Step 1: fetch fingerprints from source
|
|
451
|
+
let t = Date.now();
|
|
452
|
+
const envelopes = [];
|
|
453
|
+
try {
|
|
454
|
+
for await (const msg of srcClient.fetch(subChunk, { envelope: true }, { uid: true })) {
|
|
455
|
+
envelopes.push(msg);
|
|
456
|
+
}
|
|
457
|
+
} catch (err) {
|
|
458
|
+
if (!isTransient(err)) throw err;
|
|
459
|
+
moveLog(chunk.index, `fetch envelopes failed (${err.message}), reconnecting...`);
|
|
460
|
+
srcClient = await reconnect(srcClient, sourceMailbox);
|
|
461
|
+
for await (const msg of srcClient.fetch(subChunk, { envelope: true }, { uid: true })) {
|
|
462
|
+
envelopes.push(msg);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
const fingerprints = envelopes.map(buildFingerprint);
|
|
466
|
+
const withMsgId = fingerprints.filter(fp => fp.messageId).length;
|
|
467
|
+
moveLog(chunk.index, `fetched ${envelopes.length} envelopes (${withMsgId} with Message-ID) (${elapsed(t)})`);
|
|
468
|
+
|
|
469
|
+
updateManifest((data) => {
|
|
470
|
+
if (!data.current) return;
|
|
471
|
+
const c = data.current.chunks[chunk.index];
|
|
472
|
+
if (!c) return;
|
|
473
|
+
c.fingerprints = [...c.fingerprints, ...fingerprints];
|
|
474
|
+
c.status = 'pending';
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
// Step 2: copy to target
|
|
478
|
+
t = Date.now();
|
|
479
|
+
try {
|
|
480
|
+
await srcClient.messageCopy(subChunk, targetMailbox, { uid: true });
|
|
481
|
+
} catch (err) {
|
|
482
|
+
if (!isTransient(err)) throw err;
|
|
483
|
+
moveLog(chunk.index, `copy failed (${err.message}), reconnecting...`);
|
|
484
|
+
srcClient = await reconnect(srcClient, sourceMailbox);
|
|
485
|
+
await srcClient.messageCopy(subChunk, targetMailbox, { uid: true });
|
|
486
|
+
}
|
|
487
|
+
moveLog(chunk.index, `copied ${subChunk.length} emails to target (${elapsed(t)})`);
|
|
488
|
+
updateChunk(chunk.index, { status: 'copied_not_verified', copiedAt: new Date().toISOString() });
|
|
489
|
+
|
|
490
|
+
// Step 3: verify in target
|
|
491
|
+
t = Date.now();
|
|
492
|
+
let verification;
|
|
493
|
+
try {
|
|
494
|
+
const tgtMb = await tgtClient.mailboxOpen(targetMailbox);
|
|
495
|
+
verification = await verifyInTarget(tgtClient, fingerprints, chunk.index, tgtMb.exists);
|
|
496
|
+
} catch (err) {
|
|
497
|
+
if (!isTransient(err)) throw err;
|
|
498
|
+
moveLog(chunk.index, `verify failed (${err.message}), reconnecting...`);
|
|
499
|
+
tgtClient = await reconnect(tgtClient, targetMailbox);
|
|
500
|
+
verification = await verifyInTarget(tgtClient, fingerprints, chunk.index);
|
|
501
|
+
}
|
|
502
|
+
moveLog(chunk.index, `verification: ${verification.found}/${verification.expected} confirmed (${elapsed(t)})`);
|
|
503
|
+
|
|
504
|
+
if (!verification.verified) {
|
|
505
|
+
throw Object.assign(new Error('verification_failed'), { _verificationFailed: true });
|
|
506
|
+
}
|
|
507
|
+
updateChunk(chunk.index, { status: 'verified_not_deleted', verifiedAt: new Date().toISOString() });
|
|
508
|
+
|
|
509
|
+
// Step 4: delete from source
|
|
510
|
+
t = Date.now();
|
|
511
|
+
try {
|
|
512
|
+
await srcClient.messageDelete(subChunk, { uid: true });
|
|
513
|
+
} catch (err) {
|
|
514
|
+
if (!isTransient(err)) throw err;
|
|
515
|
+
moveLog(chunk.index, `delete failed (${err.message}), reconnecting...`);
|
|
516
|
+
srcClient = await reconnect(srcClient, sourceMailbox);
|
|
517
|
+
await srcClient.messageDelete(subChunk, { uid: true });
|
|
518
|
+
}
|
|
519
|
+
moveLog(chunk.index, `deleted ${subChunk.length} from source (${elapsed(t)})`);
|
|
520
|
+
});
|
|
521
|
+
totalMoved += subChunk.length;
|
|
522
|
+
moveLog(chunk.index, `sub-chunk complete: ${subChunk.length} moved (chunk total: ${totalMoved}/${operation.totalUids})`);
|
|
523
|
+
} catch (err) {
|
|
524
|
+
if (err._verificationFailed) {
|
|
525
|
+
verificationFailed = true;
|
|
526
|
+
break;
|
|
527
|
+
}
|
|
528
|
+
moveLog(chunk.index, `FAILED: ${err.message}`);
|
|
529
|
+
updateChunk(chunk.index, {
|
|
530
|
+
status: 'failed',
|
|
531
|
+
failureReason: err.message
|
|
532
|
+
});
|
|
533
|
+
totalFailed += chunkUids.length;
|
|
534
|
+
failOperation(`Chunk ${chunk.index} failed: ${err.message}`);
|
|
535
|
+
return {
|
|
536
|
+
status: 'partial',
|
|
537
|
+
moved: totalMoved,
|
|
538
|
+
failed: totalFailed,
|
|
539
|
+
message: `${err.message}. ${totalMoved} emails moved successfully. ${operation.totalUids - totalMoved} remain in source untouched. Call get_move_status for details.`
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
}
|
|
355
543
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
`copyChunk chunk ${chunk.index}`,
|
|
359
|
-
() => copyChunk(sourceMailbox, targetMailbox, subChunk)
|
|
360
|
-
);
|
|
361
|
-
updateChunk(chunk.index, { status: 'copied_not_verified', copiedAt: new Date().toISOString() });
|
|
362
|
-
|
|
363
|
-
// Step 3: verify by fetching envelopes from target — retries on transient errors
|
|
364
|
-
const verification = await withRetry(
|
|
365
|
-
`verifyFingerprints chunk ${chunk.index}`,
|
|
366
|
-
() => verifyFingerprintsInTarget(targetMailbox, fingerprints)
|
|
367
|
-
);
|
|
368
|
-
if (!verification.verified) {
|
|
369
|
-
verificationFailed = true;
|
|
544
|
+
if (!verificationFailed) {
|
|
545
|
+
succeeded = true;
|
|
370
546
|
break;
|
|
371
547
|
}
|
|
372
|
-
updateChunk(chunk.index, { status: 'verified_not_deleted', verifiedAt: new Date().toISOString() });
|
|
373
|
-
|
|
374
|
-
// Step 4: safe to delete from source — retries on transient errors
|
|
375
|
-
await withRetry(
|
|
376
|
-
`deleteChunk chunk ${chunk.index}`,
|
|
377
|
-
() => deleteChunk(sourceMailbox, subChunk)
|
|
378
|
-
);
|
|
379
|
-
totalMoved += subChunk.length;
|
|
380
|
-
}
|
|
381
548
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
549
|
+
if (attemptChunkSize === CHUNK_SIZE_RETRY) {
|
|
550
|
+
moveLog(chunk.index, `FAILED: verification failed at both chunk sizes`);
|
|
551
|
+
updateChunk(chunk.index, {
|
|
552
|
+
status: 'failed',
|
|
553
|
+
failureReason: 'Verification failed at both chunk sizes'
|
|
554
|
+
});
|
|
555
|
+
totalFailed += chunkUids.length;
|
|
556
|
+
failOperation(`Verification failed after retry on chunk ${chunk.index}`);
|
|
557
|
+
return {
|
|
558
|
+
status: 'partial',
|
|
559
|
+
moved: totalMoved,
|
|
560
|
+
failed: totalFailed,
|
|
561
|
+
message: `Verification failed after retry. ${totalMoved} emails moved successfully. ${operation.totalUids - totalMoved} remain in source untouched. Call get_move_status for details.`
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
moveLog(chunk.index, `verification failed at chunk size ${attemptChunkSize}, retrying at ${CHUNK_SIZE_RETRY}`);
|
|
385
566
|
}
|
|
386
567
|
|
|
387
|
-
if (
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
status: 'failed',
|
|
391
|
-
failureReason: 'Verification failed at both chunk sizes'
|
|
392
|
-
});
|
|
393
|
-
totalFailed += chunkUids.length;
|
|
394
|
-
failOperation(`Verification failed after retry on chunk ${chunk.index}`);
|
|
395
|
-
return {
|
|
396
|
-
status: 'partial',
|
|
397
|
-
moved: totalMoved,
|
|
398
|
-
failed: totalFailed,
|
|
399
|
-
message: `Verification failed after retry. ${totalMoved} emails moved successfully. ${operation.totalUids - totalMoved} remain in source untouched. Call get_move_status for details.`
|
|
400
|
-
};
|
|
568
|
+
if (succeeded) {
|
|
569
|
+
updateChunk(chunk.index, { status: 'complete', deletedAt: new Date().toISOString() });
|
|
570
|
+
moveLog(chunk.index, `COMPLETE (${elapsed(chunkStart)})`);
|
|
401
571
|
}
|
|
402
572
|
}
|
|
403
573
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
}
|
|
574
|
+
completeOperation();
|
|
575
|
+
process.stderr.write(`[move] COMPLETE: ${totalMoved}/${operation.totalUids} emails moved (${elapsed(opStart)})\n`);
|
|
576
|
+
return { status: 'complete', moved: totalMoved, total: operation.totalUids };
|
|
577
|
+
} finally {
|
|
578
|
+
await safeClose(srcClient);
|
|
579
|
+
await safeClose(tgtClient);
|
|
407
580
|
}
|
|
408
|
-
|
|
409
|
-
completeOperation();
|
|
410
|
-
return { status: 'complete', moved: totalMoved, total: operation.totalUids };
|
|
411
581
|
}
|
|
412
582
|
|
|
413
583
|
// ─── Email Functions ──────────────────────────────────────────────────────────
|
|
@@ -555,18 +725,14 @@ async function bulkDeleteBySender(sender, mailbox = 'INBOX') {
|
|
|
555
725
|
await client.connect();
|
|
556
726
|
await client.mailboxOpen(mailbox);
|
|
557
727
|
const uids = (await client.search({ from: sender }, { uid: true })) ?? [];
|
|
558
|
-
await client.logout();
|
|
559
|
-
if (uids.length === 0) return { deleted: 0 };
|
|
728
|
+
if (uids.length === 0) { await client.logout(); return { deleted: 0 }; }
|
|
560
729
|
let deleted = 0;
|
|
561
730
|
for (let i = 0; i < uids.length; i += CHUNK_SIZE) {
|
|
562
731
|
const chunk = uids.slice(i, i + CHUNK_SIZE);
|
|
563
|
-
|
|
564
|
-
await dc.connect();
|
|
565
|
-
await dc.mailboxOpen(mailbox);
|
|
566
|
-
await dc.messageDelete(chunk, { uid: true });
|
|
567
|
-
await dc.logout();
|
|
732
|
+
await client.messageDelete(chunk, { uid: true });
|
|
568
733
|
deleted += chunk.length;
|
|
569
734
|
}
|
|
735
|
+
await client.logout();
|
|
570
736
|
return { deleted, sender };
|
|
571
737
|
}
|
|
572
738
|
|
|
@@ -587,18 +753,14 @@ async function bulkDeleteBySubject(subject, mailbox = 'INBOX') {
|
|
|
587
753
|
await client.connect();
|
|
588
754
|
await client.mailboxOpen(mailbox);
|
|
589
755
|
const uids = (await client.search({ subject }, { uid: true })) ?? [];
|
|
590
|
-
await client.logout();
|
|
591
|
-
if (uids.length === 0) return { deleted: 0 };
|
|
756
|
+
if (uids.length === 0) { await client.logout(); return { deleted: 0 }; }
|
|
592
757
|
let deleted = 0;
|
|
593
758
|
for (let i = 0; i < uids.length; i += CHUNK_SIZE) {
|
|
594
759
|
const chunk = uids.slice(i, i + CHUNK_SIZE);
|
|
595
|
-
|
|
596
|
-
await dc.connect();
|
|
597
|
-
await dc.mailboxOpen(mailbox);
|
|
598
|
-
await dc.messageDelete(chunk, { uid: true });
|
|
599
|
-
await dc.logout();
|
|
760
|
+
await client.messageDelete(chunk, { uid: true });
|
|
600
761
|
deleted += chunk.length;
|
|
601
762
|
}
|
|
763
|
+
await client.logout();
|
|
602
764
|
return { deleted, subject };
|
|
603
765
|
}
|
|
604
766
|
|
|
@@ -609,18 +771,14 @@ async function deleteOlderThan(days, mailbox = 'INBOX') {
|
|
|
609
771
|
const date = new Date();
|
|
610
772
|
date.setDate(date.getDate() - days);
|
|
611
773
|
const uids = (await client.search({ before: date }, { uid: true })) ?? [];
|
|
612
|
-
await client.logout();
|
|
613
|
-
if (uids.length === 0) return { deleted: 0 };
|
|
774
|
+
if (uids.length === 0) { await client.logout(); return { deleted: 0 }; }
|
|
614
775
|
let deleted = 0;
|
|
615
776
|
for (let i = 0; i < uids.length; i += CHUNK_SIZE) {
|
|
616
777
|
const chunk = uids.slice(i, i + CHUNK_SIZE);
|
|
617
|
-
|
|
618
|
-
await dc.connect();
|
|
619
|
-
await dc.mailboxOpen(mailbox);
|
|
620
|
-
await dc.messageDelete(chunk, { uid: true });
|
|
621
|
-
await dc.logout();
|
|
778
|
+
await client.messageDelete(chunk, { uid: true });
|
|
622
779
|
deleted += chunk.length;
|
|
623
780
|
}
|
|
781
|
+
await client.logout();
|
|
624
782
|
return { deleted, olderThan: date.toISOString() };
|
|
625
783
|
}
|
|
626
784
|
|
|
@@ -899,14 +1057,16 @@ async function ensureMailbox(name) {
|
|
|
899
1057
|
await client.logout();
|
|
900
1058
|
}
|
|
901
1059
|
|
|
902
|
-
async function bulkMove(filters, targetMailbox, sourceMailbox = 'INBOX', dryRun = false) {
|
|
1060
|
+
async function bulkMove(filters, targetMailbox, sourceMailbox = 'INBOX', dryRun = false, limit = null) {
|
|
903
1061
|
const client = createClient();
|
|
904
1062
|
await client.connect();
|
|
905
1063
|
await client.mailboxOpen(sourceMailbox);
|
|
906
1064
|
const query = buildQuery(filters);
|
|
907
|
-
|
|
1065
|
+
let uids = (await client.search(query, { uid: true })) ?? [];
|
|
908
1066
|
await client.logout();
|
|
909
1067
|
|
|
1068
|
+
if (limit !== null) uids = uids.slice(0, limit);
|
|
1069
|
+
|
|
910
1070
|
if (dryRun) {
|
|
911
1071
|
return { dryRun: true, wouldMove: uids.length, sourceMailbox, targetMailbox, filters };
|
|
912
1072
|
}
|
|
@@ -917,29 +1077,45 @@ async function bulkMove(filters, targetMailbox, sourceMailbox = 'INBOX', dryRun
|
|
|
917
1077
|
return { ...result, sourceMailbox, targetMailbox, filters };
|
|
918
1078
|
}
|
|
919
1079
|
|
|
1080
|
+
// ─── IMPROVEMENT 3: bulk_delete now has per-chunk timeout ─────────────────────
|
|
1081
|
+
// Previously the chunk loop could run unbounded. Now each chunk gets a BULK_OP
|
|
1082
|
+
// timeout. If a single chunk hangs, we bail with a partial result instead of
|
|
1083
|
+
// hanging forever.
|
|
1084
|
+
|
|
920
1085
|
async function bulkDelete(filters, sourceMailbox = 'INBOX', dryRun = false) {
|
|
921
1086
|
const client = createClient();
|
|
922
1087
|
await client.connect();
|
|
923
1088
|
await client.mailboxOpen(sourceMailbox);
|
|
924
1089
|
const query = buildQuery(filters);
|
|
925
1090
|
const uids = (await client.search(query, { uid: true })) ?? [];
|
|
926
|
-
await client.logout();
|
|
927
1091
|
|
|
928
1092
|
if (dryRun) {
|
|
1093
|
+
await client.logout();
|
|
929
1094
|
return { dryRun: true, wouldDelete: uids.length, sourceMailbox, filters };
|
|
930
1095
|
}
|
|
931
|
-
if (uids.length === 0) return { deleted: 0, sourceMailbox };
|
|
1096
|
+
if (uids.length === 0) { await client.logout(); return { deleted: 0, sourceMailbox }; }
|
|
932
1097
|
|
|
933
1098
|
let deleted = 0;
|
|
934
1099
|
for (let i = 0; i < uids.length; i += CHUNK_SIZE) {
|
|
935
1100
|
const chunk = uids.slice(i, i + CHUNK_SIZE);
|
|
936
|
-
const
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
1101
|
+
const chunkIndex = Math.floor(i / CHUNK_SIZE);
|
|
1102
|
+
try {
|
|
1103
|
+
await withTimeout(`bulk_delete chunk ${chunkIndex}`, TIMEOUT.BULK_OP, async () => {
|
|
1104
|
+
await client.messageDelete(chunk, { uid: true });
|
|
1105
|
+
});
|
|
1106
|
+
deleted += chunk.length;
|
|
1107
|
+
} catch (err) {
|
|
1108
|
+
await safeClose(client);
|
|
1109
|
+
return {
|
|
1110
|
+
deleted,
|
|
1111
|
+
failed: uids.length - deleted,
|
|
1112
|
+
sourceMailbox,
|
|
1113
|
+
filters,
|
|
1114
|
+
error: `Chunk ${chunkIndex} failed: ${err.message}. ${deleted} deleted so far, ${uids.length - deleted} remaining.`
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
942
1117
|
}
|
|
1118
|
+
await client.logout();
|
|
943
1119
|
return { deleted, sourceMailbox, filters };
|
|
944
1120
|
}
|
|
945
1121
|
|
|
@@ -981,7 +1157,7 @@ function logClear() {
|
|
|
981
1157
|
|
|
982
1158
|
async function main() {
|
|
983
1159
|
const server = new Server(
|
|
984
|
-
{ name: 'icloud-mail', version: '1.
|
|
1160
|
+
{ name: 'icloud-mail', version: '1.5.0' },
|
|
985
1161
|
{ capabilities: { tools: {} } }
|
|
986
1162
|
);
|
|
987
1163
|
|
|
@@ -1110,6 +1286,7 @@ async function main() {
|
|
|
1110
1286
|
targetMailbox: { type: 'string', description: 'Destination mailbox path' },
|
|
1111
1287
|
sourceMailbox: { type: 'string', description: 'Source mailbox (default INBOX)' },
|
|
1112
1288
|
dryRun: { type: 'boolean', description: 'If true, preview what would be moved without actually moving' },
|
|
1289
|
+
limit: { type: 'number', description: 'Maximum number of emails to move (default: all matching)' },
|
|
1113
1290
|
...filtersSchema
|
|
1114
1291
|
},
|
|
1115
1292
|
required: ['targetMailbox']
|
|
@@ -1117,7 +1294,7 @@ async function main() {
|
|
|
1117
1294
|
},
|
|
1118
1295
|
{
|
|
1119
1296
|
name: 'bulk_delete',
|
|
1120
|
-
description: 'Delete emails matching any combination of filters. Processes in chunks of 250 for reliability. Use dryRun: true to preview without making changes.',
|
|
1297
|
+
description: 'Delete emails matching any combination of filters. Processes in chunks of 250 with per-chunk timeouts for reliability. Use dryRun: true to preview without making changes.',
|
|
1121
1298
|
inputSchema: {
|
|
1122
1299
|
type: 'object',
|
|
1123
1300
|
properties: {
|
|
@@ -1354,71 +1531,80 @@ async function main() {
|
|
|
1354
1531
|
const { name, arguments: args } = request.params;
|
|
1355
1532
|
try {
|
|
1356
1533
|
let result;
|
|
1534
|
+
// ── Metadata tier (15s) ──
|
|
1357
1535
|
if (name === 'get_inbox_summary') {
|
|
1358
|
-
result = await getInboxSummary(args.mailbox || 'INBOX');
|
|
1536
|
+
result = await withTimeout('get_inbox_summary', TIMEOUT.METADATA, () => getInboxSummary(args.mailbox || 'INBOX'));
|
|
1359
1537
|
} else if (name === 'get_mailbox_summary') {
|
|
1360
|
-
result = await getMailboxSummary(args.mailbox);
|
|
1361
|
-
} else if (name === '
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1538
|
+
result = await withTimeout('get_mailbox_summary', TIMEOUT.METADATA, () => getMailboxSummary(args.mailbox));
|
|
1539
|
+
} else if (name === 'count_emails') {
|
|
1540
|
+
const { mailbox, ...filters } = args;
|
|
1541
|
+
result = await withTimeout('count_emails', TIMEOUT.METADATA, () => countEmails(filters, mailbox || 'INBOX'));
|
|
1542
|
+
} else if (name === 'list_mailboxes') {
|
|
1543
|
+
result = await withTimeout('list_mailboxes', TIMEOUT.METADATA, () => listMailboxes());
|
|
1544
|
+
} else if (name === 'create_mailbox') {
|
|
1545
|
+
result = await withTimeout('create_mailbox', TIMEOUT.METADATA, () => createMailbox(args.name));
|
|
1546
|
+
} else if (name === 'rename_mailbox') {
|
|
1547
|
+
result = await renameMailbox(args.oldName, args.newName); // already has its own 15s timeout
|
|
1548
|
+
} else if (name === 'delete_mailbox') {
|
|
1549
|
+
result = await deleteMailbox(args.name); // already has its own 15s timeout
|
|
1550
|
+
// ── Fetch tier (30s) ──
|
|
1367
1551
|
} else if (name === 'read_inbox') {
|
|
1368
|
-
result = await fetchEmails(args.mailbox || 'INBOX', args.limit || 10, args.onlyUnread || false, args.page || 1);
|
|
1552
|
+
result = await withTimeout('read_inbox', TIMEOUT.FETCH, () => fetchEmails(args.mailbox || 'INBOX', args.limit || 10, args.onlyUnread || false, args.page || 1));
|
|
1369
1553
|
} else if (name === 'get_email') {
|
|
1370
|
-
result = await getEmailContent(args.uid, args.mailbox || 'INBOX');
|
|
1554
|
+
result = await withTimeout('get_email', TIMEOUT.FETCH, () => getEmailContent(args.uid, args.mailbox || 'INBOX'));
|
|
1371
1555
|
} else if (name === 'search_emails') {
|
|
1372
1556
|
const { query, mailbox, limit, ...filters } = args;
|
|
1373
|
-
result = await searchEmails(query, mailbox || 'INBOX', limit || 10, filters);
|
|
1374
|
-
} else if (name === '
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
result = await
|
|
1383
|
-
|
|
1384
|
-
const { flagged, mailbox, ...filters } = args;
|
|
1385
|
-
result = await bulkFlag(filters, flagged, mailbox || 'INBOX');
|
|
1557
|
+
result = await withTimeout('search_emails', TIMEOUT.FETCH, () => searchEmails(query, mailbox || 'INBOX', limit || 10, filters));
|
|
1558
|
+
} else if (name === 'get_emails_by_sender') {
|
|
1559
|
+
result = await withTimeout('get_emails_by_sender', TIMEOUT.FETCH, () => getEmailsBySender(args.sender, args.mailbox || 'INBOX', args.limit || 10));
|
|
1560
|
+
} else if (name === 'get_emails_by_date_range') {
|
|
1561
|
+
result = await withTimeout('get_emails_by_date_range', TIMEOUT.FETCH, () => getEmailsByDateRange(args.startDate, args.endDate, args.mailbox || 'INBOX', args.limit || 10));
|
|
1562
|
+
// ── Scan tier (60s) ──
|
|
1563
|
+
} else if (name === 'get_top_senders') {
|
|
1564
|
+
result = await withTimeout('get_top_senders', TIMEOUT.SCAN, () => getTopSenders(args.mailbox || 'INBOX', args.sampleSize || 500, args.maxResults || 20));
|
|
1565
|
+
} else if (name === 'get_unread_senders') {
|
|
1566
|
+
result = await withTimeout('get_unread_senders', TIMEOUT.SCAN, () => getUnreadSenders(args.mailbox || 'INBOX', args.sampleSize || 500, args.maxResults || 20));
|
|
1567
|
+
// ── Bulk operation tier (60s) ──
|
|
1386
1568
|
} else if (name === 'bulk_delete_by_sender') {
|
|
1387
|
-
result = await bulkDeleteBySender(args.sender, args.mailbox || 'INBOX');
|
|
1388
|
-
} else if (name === 'bulk_move_by_sender') {
|
|
1389
|
-
result = await bulkMoveBySender(args.sender, args.targetMailbox, args.sourceMailbox || 'INBOX');
|
|
1569
|
+
result = await withTimeout('bulk_delete_by_sender', TIMEOUT.BULK_OP, () => bulkDeleteBySender(args.sender, args.mailbox || 'INBOX'));
|
|
1390
1570
|
} else if (name === 'bulk_delete_by_subject') {
|
|
1391
|
-
result = await bulkDeleteBySubject(args.subject, args.mailbox || 'INBOX');
|
|
1571
|
+
result = await withTimeout('bulk_delete_by_subject', TIMEOUT.BULK_OP, () => bulkDeleteBySubject(args.subject, args.mailbox || 'INBOX'));
|
|
1392
1572
|
} else if (name === 'bulk_mark_read') {
|
|
1393
|
-
result = await bulkMarkRead(args.mailbox || 'INBOX', args.sender || null);
|
|
1573
|
+
result = await withTimeout('bulk_mark_read', TIMEOUT.BULK_OP, () => bulkMarkRead(args.mailbox || 'INBOX', args.sender || null));
|
|
1394
1574
|
} else if (name === 'bulk_mark_unread') {
|
|
1395
|
-
result = await bulkMarkUnread(args.mailbox || 'INBOX', args.sender || null);
|
|
1575
|
+
result = await withTimeout('bulk_mark_unread', TIMEOUT.BULK_OP, () => bulkMarkUnread(args.mailbox || 'INBOX', args.sender || null));
|
|
1576
|
+
} else if (name === 'bulk_flag') {
|
|
1577
|
+
const { flagged, mailbox, ...filters } = args;
|
|
1578
|
+
result = await withTimeout('bulk_flag', TIMEOUT.BULK_OP, () => bulkFlag(filters, flagged, mailbox || 'INBOX'));
|
|
1396
1579
|
} else if (name === 'delete_older_than') {
|
|
1397
|
-
result = await deleteOlderThan(args.days, args.mailbox || 'INBOX');
|
|
1398
|
-
} else if (name === '
|
|
1399
|
-
result = await
|
|
1580
|
+
result = await withTimeout('delete_older_than', TIMEOUT.BULK_OP, () => deleteOlderThan(args.days, args.mailbox || 'INBOX'));
|
|
1581
|
+
} else if (name === 'empty_trash') {
|
|
1582
|
+
result = await withTimeout('empty_trash', TIMEOUT.BULK_OP, () => emptyTrash());
|
|
1583
|
+
// ── No top-level timeout — chunked with internal timeouts ──
|
|
1584
|
+
} else if (name === 'bulk_move') {
|
|
1585
|
+
const { targetMailbox, sourceMailbox, dryRun, limit, ...filters } = args;
|
|
1586
|
+
result = await bulkMove(filters, targetMailbox, sourceMailbox || 'INBOX', dryRun || false, limit ?? null);
|
|
1587
|
+
} else if (name === 'bulk_move_by_sender') {
|
|
1588
|
+
result = await bulkMoveBySender(args.sender, args.targetMailbox, args.sourceMailbox || 'INBOX');
|
|
1589
|
+
} else if (name === 'bulk_delete') {
|
|
1590
|
+
// IMPROVEMENT 3: bulk_delete now has per-chunk timeouts internally
|
|
1591
|
+
const { sourceMailbox, dryRun, ...filters } = args;
|
|
1592
|
+
result = await bulkDelete(filters, sourceMailbox || 'INBOX', dryRun || false);
|
|
1593
|
+
// ── Single-email tier (15s) ──
|
|
1400
1594
|
} else if (name === 'flag_email') {
|
|
1401
|
-
result = await flagEmail(args.uid, args.flagged, args.mailbox || 'INBOX');
|
|
1595
|
+
result = await withTimeout('flag_email', TIMEOUT.SINGLE, () => flagEmail(args.uid, args.flagged, args.mailbox || 'INBOX'));
|
|
1402
1596
|
} else if (name === 'mark_as_read') {
|
|
1403
|
-
result = await markAsRead(args.uid, args.seen, args.mailbox || 'INBOX');
|
|
1597
|
+
result = await withTimeout('mark_as_read', TIMEOUT.SINGLE, () => markAsRead(args.uid, args.seen, args.mailbox || 'INBOX'));
|
|
1404
1598
|
} else if (name === 'delete_email') {
|
|
1405
|
-
result = await deleteEmail(args.uid, args.mailbox || 'INBOX');
|
|
1599
|
+
result = await withTimeout('delete_email', TIMEOUT.SINGLE, () => deleteEmail(args.uid, args.mailbox || 'INBOX'));
|
|
1406
1600
|
} else if (name === 'move_email') {
|
|
1407
|
-
result = await moveEmail(args.uid, args.targetMailbox, args.sourceMailbox || 'INBOX');
|
|
1408
|
-
|
|
1409
|
-
result = await listMailboxes();
|
|
1410
|
-
} else if (name === 'create_mailbox') {
|
|
1411
|
-
result = await createMailbox(args.name);
|
|
1412
|
-
} else if (name === 'rename_mailbox') {
|
|
1413
|
-
result = await renameMailbox(args.oldName, args.newName);
|
|
1414
|
-
} else if (name === 'delete_mailbox') {
|
|
1415
|
-
result = await deleteMailbox(args.name);
|
|
1416
|
-
} else if (name === 'empty_trash') {
|
|
1417
|
-
result = await emptyTrash();
|
|
1601
|
+
result = await withTimeout('move_email', TIMEOUT.SINGLE, () => moveEmail(args.uid, args.targetMailbox, args.sourceMailbox || 'INBOX'));
|
|
1602
|
+
// ── Move status (synchronous, no timeout needed) ──
|
|
1418
1603
|
} else if (name === 'get_move_status') {
|
|
1419
1604
|
result = getMoveStatus();
|
|
1420
1605
|
} else if (name === 'abandon_move') {
|
|
1421
1606
|
result = abandonMove();
|
|
1607
|
+
// ── Session log (synchronous, no timeout needed) ──
|
|
1422
1608
|
} else if (name === 'log_write') {
|
|
1423
1609
|
result = logWrite(args.step);
|
|
1424
1610
|
} else if (name === 'log_read') {
|
|
@@ -1476,6 +1662,14 @@ function friendlyError(err) {
|
|
|
1476
1662
|
].join('\n');
|
|
1477
1663
|
}
|
|
1478
1664
|
|
|
1665
|
+
if (msg.includes('timed out after')) {
|
|
1666
|
+
return [
|
|
1667
|
+
`Operation ${msg}`,
|
|
1668
|
+
'→ This usually means iCloud is slow or the operation is larger than expected.',
|
|
1669
|
+
'→ Try again — if it persists, the operation may need to be broken into smaller steps.'
|
|
1670
|
+
].join('\n');
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1479
1673
|
if (msg.includes('Mailbox does not exist') || msg.includes('does not exist') || msg.includes('NONEXISTENT')) {
|
|
1480
1674
|
return [
|
|
1481
1675
|
`Mailbox not found: ${msg}`,
|
package/package.json
CHANGED
package/test.js
CHANGED
|
@@ -13,15 +13,20 @@ if (!IMAP_USER || !IMAP_PASSWORD) {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
const projectDir = fileURLToPath(new URL('.', import.meta.url));
|
|
16
|
+
const VERBOSE = process.argv.includes('--verbose') || process.argv.includes('-v');
|
|
16
17
|
|
|
17
18
|
// Timeout in ms per tool category
|
|
18
19
|
const TIMEOUTS = {
|
|
19
20
|
mailbox_mgmt: 60000, // create/rename/delete mailbox — can be slow on iCloud
|
|
21
|
+
bulk_move: 900000, // pre-flight restore may have many stranded emails; test moves use limit:50
|
|
20
22
|
default: 300000 // everything else — allow up to 5 min for large operations
|
|
21
23
|
};
|
|
22
24
|
|
|
23
25
|
const MAILBOX_MGMT_TOOLS = new Set(['create_mailbox', 'rename_mailbox', 'delete_mailbox']);
|
|
24
26
|
|
|
27
|
+
// Tools whose stderr output is always interesting (move pipeline logging)
|
|
28
|
+
const ALWAYS_LOG_STDERR = new Set(['bulk_move', 'bulk_move_by_sender']);
|
|
29
|
+
|
|
25
30
|
function callToolRaw(name, args = {}, timeout = TIMEOUTS.default) {
|
|
26
31
|
const messages = [
|
|
27
32
|
{ jsonrpc: '2.0', id: 0, method: 'initialize', params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'test', version: '1.0.0' } } },
|
|
@@ -45,8 +50,22 @@ function callToolRaw(name, args = {}, timeout = TIMEOUTS.default) {
|
|
|
45
50
|
}
|
|
46
51
|
);
|
|
47
52
|
|
|
53
|
+
// Capture stderr for logging
|
|
54
|
+
const stderr = (result.stderr || '').trim();
|
|
55
|
+
|
|
56
|
+
// Print stderr for move operations (always) or all tools (verbose mode)
|
|
57
|
+
if (stderr && (VERBOSE || ALWAYS_LOG_STDERR.has(name))) {
|
|
58
|
+
const lines = stderr.split('\n');
|
|
59
|
+
for (const line of lines) {
|
|
60
|
+
// Only print [move], [timeout], [retry] lines — skip noise
|
|
61
|
+
if (VERBOSE || /^\[(move|timeout|retry)\]/.test(line)) {
|
|
62
|
+
console.log(` ${line}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
48
67
|
if (result.error) throw new Error(`Spawn error: ${result.error.message}`);
|
|
49
|
-
if (result.status !== 0) throw new Error(`Process exited with code ${result.status}: ${
|
|
68
|
+
if (result.status !== 0) throw new Error(`Process exited with code ${result.status}: ${stderr}`);
|
|
50
69
|
|
|
51
70
|
const lines = (result.stdout || '').trim().split('\n').filter(l => l.trim().startsWith('{'));
|
|
52
71
|
const responses = lines.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
@@ -54,7 +73,19 @@ function callToolRaw(name, args = {}, timeout = TIMEOUTS.default) {
|
|
|
54
73
|
if (!toolResponse) throw new Error(`No response for tool: ${name}`);
|
|
55
74
|
const content = toolResponse.result?.content?.[0]?.text;
|
|
56
75
|
if (!content) throw new Error(`No content in response for: ${name}`);
|
|
57
|
-
if (toolResponse.result?.isError)
|
|
76
|
+
if (toolResponse.result?.isError) {
|
|
77
|
+
// On tool errors, always show stderr for diagnostics
|
|
78
|
+
if (stderr && !VERBOSE && !ALWAYS_LOG_STDERR.has(name)) {
|
|
79
|
+
const stderrLines = stderr.split('\n').filter(l => /^\[(move|timeout|retry)\]/.test(l));
|
|
80
|
+
if (stderrLines.length > 0) {
|
|
81
|
+
console.log(`\n 📋 stderr diagnostics:`);
|
|
82
|
+
for (const line of stderrLines) {
|
|
83
|
+
console.log(` ${line}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
throw new Error(`Tool error: ${content}`);
|
|
88
|
+
}
|
|
58
89
|
return JSON.parse(content);
|
|
59
90
|
} finally {
|
|
60
91
|
try { unlinkSync(tmpFile); } catch {}
|
|
@@ -62,18 +93,24 @@ function callToolRaw(name, args = {}, timeout = TIMEOUTS.default) {
|
|
|
62
93
|
}
|
|
63
94
|
|
|
64
95
|
function callTool(name, args = {}) {
|
|
65
|
-
|
|
96
|
+
let timeout = TIMEOUTS.default;
|
|
97
|
+
if (MAILBOX_MGMT_TOOLS.has(name)) timeout = TIMEOUTS.mailbox_mgmt;
|
|
98
|
+
else if (name === 'bulk_move' || name === 'bulk_move_by_sender') timeout = TIMEOUTS.bulk_move;
|
|
99
|
+
|
|
66
100
|
try {
|
|
67
101
|
return callToolRaw(name, args, timeout);
|
|
68
102
|
} catch (err) {
|
|
69
103
|
// Only retry on spawn-level transient errors (ECONNRESET, ETIMEDOUT on the
|
|
70
104
|
// child process itself) — NOT on Tool errors, which are application-level
|
|
71
105
|
// failures that should propagate immediately.
|
|
106
|
+
// Never retry bulk_move: a timed-out move leaves the manifest in_progress,
|
|
107
|
+
// so a retry would immediately fail with a manifest conflict.
|
|
108
|
+
const isBulkMove = name === 'bulk_move' || name === 'bulk_move_by_sender';
|
|
72
109
|
const isSpawnTransient = (
|
|
73
110
|
err.message.includes('ECONNRESET') ||
|
|
74
111
|
err.message.includes('ETIMEDOUT')
|
|
75
112
|
) && !err.message.startsWith('Tool error:');
|
|
76
|
-
if (isSpawnTransient) {
|
|
113
|
+
if (isSpawnTransient && !isBulkMove) {
|
|
77
114
|
console.log(`\n ⚠️ transient spawn error (${err.message.split(':')[0]}), retrying...`);
|
|
78
115
|
return callToolRaw(name, args, timeout);
|
|
79
116
|
}
|
|
@@ -100,7 +137,8 @@ function assert(condition, message) {
|
|
|
100
137
|
if (!condition) throw new Error(message);
|
|
101
138
|
}
|
|
102
139
|
|
|
103
|
-
console.log('\n🧪 iCloud MCP Server Tests v1.
|
|
140
|
+
console.log('\n🧪 iCloud MCP Server Tests v1.5.0\n');
|
|
141
|
+
if (VERBOSE) console.log('🔊 Verbose mode: showing all stderr output\n');
|
|
104
142
|
|
|
105
143
|
// ─── Pre-flight cleanup ───────────────────────────────────────────────────────
|
|
106
144
|
// Abandon any leftover in-progress manifest from a previous crashed run,
|
|
@@ -408,57 +446,61 @@ test('abandon_move (nothing to abandon)', () => {
|
|
|
408
446
|
});
|
|
409
447
|
|
|
410
448
|
// ─── Safe Move (live) ─────────────────────────────────────────────────────────
|
|
411
|
-
|
|
449
|
+
// Uses limit:50 to keep the test fast and deterministic. Moving the full
|
|
450
|
+
// newsletters folder (5000+ emails) is too slow for a test suite — IMAP EXPUNGE
|
|
451
|
+
// scales with mailbox size, taking 60-160s per chunk on large folders.
|
|
452
|
+
// 50 emails completes in ~15-30s total and tests all the same code paths:
|
|
453
|
+
// fingerprinting, envelope scan, Message-ID fallback, manifest, delete.
|
|
454
|
+
console.log('\n🔐 Safe Move Test (live — newsletters ↔ test, 50-email sample)');
|
|
412
455
|
|
|
413
|
-
|
|
456
|
+
const MOVE_SAMPLE = 50;
|
|
457
|
+
|
|
458
|
+
test(`bulk_move newsletters → test (${MOVE_SAMPLE} emails, fingerprint verified)`, () => {
|
|
414
459
|
const beforeSource = callTool('count_emails', { mailbox: 'newsletters' });
|
|
415
|
-
assert(beforeSource.count
|
|
416
|
-
console.log(`\n → newsletters before: ${beforeSource.count}`);
|
|
460
|
+
assert(beforeSource.count >= MOVE_SAMPLE, `newsletters needs at least ${MOVE_SAMPLE} emails, has ${beforeSource.count}`);
|
|
461
|
+
console.log(`\n → newsletters before: ${beforeSource.count} (moving ${MOVE_SAMPLE})`);
|
|
417
462
|
|
|
418
|
-
const moveResult = callTool('bulk_move', { sourceMailbox: 'newsletters', targetMailbox: 'test' });
|
|
463
|
+
const moveResult = callTool('bulk_move', { sourceMailbox: 'newsletters', targetMailbox: 'test', limit: MOVE_SAMPLE });
|
|
419
464
|
console.log(`\n → status: ${moveResult.status}, moved: ${moveResult.moved} of ${moveResult.total}`);
|
|
420
465
|
assert(moveResult.status === 'complete', `expected complete, got ${moveResult.status}: ${moveResult.message || ''}`);
|
|
421
|
-
assert(moveResult.moved ===
|
|
422
|
-
assert(moveResult.total ===
|
|
466
|
+
assert(moveResult.moved === MOVE_SAMPLE, `moved ${moveResult.moved} but expected ${MOVE_SAMPLE}`);
|
|
467
|
+
assert(moveResult.total === MOVE_SAMPLE, `total ${moveResult.total} should match limit ${MOVE_SAMPLE}`);
|
|
423
468
|
|
|
424
469
|
const afterSource = callTool('count_emails', { mailbox: 'newsletters' });
|
|
425
|
-
console.log(`\n → newsletters after
|
|
426
|
-
assert(afterSource.count ===
|
|
470
|
+
console.log(`\n → newsletters after: ${afterSource.count} (was ${beforeSource.count})`);
|
|
471
|
+
assert(afterSource.count === beforeSource.count - MOVE_SAMPLE, `newsletters should have ${beforeSource.count - MOVE_SAMPLE}, has ${afterSource.count}`);
|
|
427
472
|
|
|
428
473
|
const afterTarget = callTool('count_emails', { mailbox: 'test' });
|
|
429
474
|
console.log(`\n → test folder after: ${afterTarget.count}`);
|
|
430
|
-
assert(afterTarget.count ===
|
|
475
|
+
assert(afterTarget.count === MOVE_SAMPLE, `test should have ${MOVE_SAMPLE}, has ${afterTarget.count}`);
|
|
431
476
|
});
|
|
432
477
|
|
|
433
478
|
test('get_move_status (after completed move)', () => {
|
|
434
479
|
const result = callTool('get_move_status');
|
|
435
|
-
// Current should be null (archived after completion), history should have the move
|
|
436
480
|
assert(result.status === 'no_operation', `expected no_operation after completed move, got ${result.status}`);
|
|
437
481
|
assert(result.history.length > 0, 'history should have at least one entry');
|
|
438
482
|
const last = result.history[0];
|
|
439
483
|
assert(last.status === 'complete', `last operation should be complete, got ${last.status}`);
|
|
440
484
|
assert(last.source === 'newsletters', `source should be newsletters, got ${last.source}`);
|
|
441
485
|
assert(last.target === 'test', `target should be test, got ${last.target}`);
|
|
486
|
+
assert(last.moved === MOVE_SAMPLE, `last op should have moved ${MOVE_SAMPLE}, got ${last.moved}`);
|
|
442
487
|
console.log(`\n → last op: ${last.status}, ${last.moved}/${last.total} moved from ${last.source} → ${last.target}`);
|
|
443
488
|
});
|
|
444
489
|
|
|
445
|
-
test(
|
|
490
|
+
test(`bulk_move test → newsletters (restore ${MOVE_SAMPLE} emails, fingerprint verified)`, () => {
|
|
446
491
|
const beforeSource = callTool('count_emails', { mailbox: 'test' });
|
|
447
|
-
assert(beforeSource.count
|
|
492
|
+
assert(beforeSource.count === MOVE_SAMPLE, `test should have ${MOVE_SAMPLE} emails, has ${beforeSource.count}`);
|
|
448
493
|
console.log(`\n → test before restore: ${beforeSource.count}`);
|
|
449
494
|
|
|
495
|
+
// No limit needed — test folder only has MOVE_SAMPLE emails; small folder = fast delete
|
|
450
496
|
const moveBack = callTool('bulk_move', { sourceMailbox: 'test', targetMailbox: 'newsletters' });
|
|
451
497
|
console.log(`\n → status: ${moveBack.status}, moved: ${moveBack.moved} of ${moveBack.total}`);
|
|
452
498
|
assert(moveBack.status === 'complete', `expected complete, got ${moveBack.status}: ${moveBack.message || ''}`);
|
|
453
|
-
assert(moveBack.moved ===
|
|
499
|
+
assert(moveBack.moved === MOVE_SAMPLE, `moved ${moveBack.moved} but expected ${MOVE_SAMPLE}`);
|
|
454
500
|
|
|
455
501
|
const afterSource = callTool('count_emails', { mailbox: 'test' });
|
|
456
502
|
console.log(`\n → test after (should be 0): ${afterSource.count}`);
|
|
457
503
|
assert(afterSource.count === 0, `test should be empty, has ${afterSource.count}`);
|
|
458
|
-
|
|
459
|
-
const afterTarget = callTool('count_emails', { mailbox: 'newsletters' });
|
|
460
|
-
console.log(`\n → newsletters restored: ${afterTarget.count}`);
|
|
461
|
-
assert(afterTarget.count === beforeSource.count, `newsletters should have ${beforeSource.count}, has ${afterTarget.count}`);
|
|
462
504
|
});
|
|
463
505
|
|
|
464
506
|
test('get_move_status (history has both moves)', () => {
|
|
@@ -470,8 +512,8 @@ test('get_move_status (history has both moves)', () => {
|
|
|
470
512
|
assert(restore.source === 'test', `restore source should be test, got ${restore.source}`);
|
|
471
513
|
assert(forward.status === 'complete', `forward op should be complete, got ${forward.status}`);
|
|
472
514
|
assert(forward.source === 'newsletters', `forward source should be newsletters, got ${forward.source}`);
|
|
473
|
-
console.log(`\n → history[0]: ${restore.source} → ${restore.target} (${restore.status})`);
|
|
474
|
-
console.log(`\n → history[1]: ${forward.source} → ${forward.target} (${forward.status})`);
|
|
515
|
+
console.log(`\n → history[0]: ${restore.source} → ${restore.target} (${restore.status}, ${restore.moved} emails)`);
|
|
516
|
+
console.log(`\n → history[1]: ${forward.source} → ${forward.target} (${forward.status}, ${forward.moved} emails)`);
|
|
475
517
|
});
|
|
476
518
|
|
|
477
519
|
// ─── Session Log ─────────────────────────────────────────────────────────────
|
|
@@ -538,4 +580,4 @@ console.log(`\n✅ Passed: ${passed}`);
|
|
|
538
580
|
console.log(`❌ Failed: ${failed}`);
|
|
539
581
|
console.log(`📊 Total: ${passed + failed}\n`);
|
|
540
582
|
|
|
541
|
-
if (failed > 0) process.exit(1);
|
|
583
|
+
if (failed > 0) process.exit(1);
|