icloud-mcp 1.4.1 → 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 +37 -8
- package/index.js +554 -223
- package/package.json +1 -1
- package/test.js +68 -26
package/README.md
CHANGED
|
@@ -43,9 +43,40 @@ Then find the install location:
|
|
|
43
43
|
npm root -g
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
The path varies by setup:
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
| Setup | Typical path |
|
|
49
|
+
|-------|-------------|
|
|
50
|
+
| Mac with Homebrew Node | `/opt/homebrew/lib/node_modules` |
|
|
51
|
+
| Mac with system Node | `/usr/local/lib/node_modules` |
|
|
52
|
+
| nvm | `~/.nvm/versions/node/v20.x.x/lib/node_modules` |
|
|
53
|
+
|
|
54
|
+
### 3. Verify your setup
|
|
55
|
+
|
|
56
|
+
Before configuring Claude Desktop, run the doctor command to confirm everything is working:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
IMAP_USER="you@icloud.com" IMAP_PASSWORD="your-app-specific-password" node $(npm root -g)/icloud-mcp/index.js --doctor
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
You should see:
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
icloud-mcp doctor
|
|
66
|
+
─────────────────────────────
|
|
67
|
+
✅ IMAP_USER is set
|
|
68
|
+
✅ IMAP_PASSWORD is set
|
|
69
|
+
✅ IMAP_USER looks like an email address
|
|
70
|
+
✅ Connected to imap.mail.me.com:993
|
|
71
|
+
✅ Authenticated as you@icloud.com
|
|
72
|
+
✅ INBOX opened (12453 messages)
|
|
73
|
+
─────────────────────────────
|
|
74
|
+
All checks passed. Ready to use with Claude Desktop.
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
If any step fails, a plain-English explanation and suggested fix will be shown.
|
|
78
|
+
|
|
79
|
+
### 4. Configure Claude Desktop
|
|
49
80
|
|
|
50
81
|
Open your Claude Desktop config file:
|
|
51
82
|
|
|
@@ -53,7 +84,7 @@ Open your Claude Desktop config file:
|
|
|
53
84
|
open ~/Library/Application\ Support/Claude/claude_desktop_config.json
|
|
54
85
|
```
|
|
55
86
|
|
|
56
|
-
Add the following under `mcpServers`, replacing the path with your npm root
|
|
87
|
+
Add the following under `mcpServers`, replacing the path with your npm root from step 2:
|
|
57
88
|
|
|
58
89
|
```json
|
|
59
90
|
{
|
|
@@ -70,9 +101,7 @@ Add the following under `mcpServers`, replacing the path with your npm root path
|
|
|
70
101
|
}
|
|
71
102
|
```
|
|
72
103
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
### 4. Add Custom Instructions (Recommended)
|
|
104
|
+
### 5. Add Custom Instructions (Recommended)
|
|
76
105
|
|
|
77
106
|
For large inbox operations, add the following to Claude Desktop's custom instructions to ensure Claude stays on track and checks in with you regularly. Go to **Claude Desktop → Settings → Custom Instructions** and add:
|
|
78
107
|
|
|
@@ -85,7 +114,7 @@ When using icloud-mail tools:
|
|
|
85
114
|
5. If you are ever unsure what you have done so far, call log_read before proceeding
|
|
86
115
|
```
|
|
87
116
|
|
|
88
|
-
###
|
|
117
|
+
### 6. Restart Claude Desktop
|
|
89
118
|
|
|
90
119
|
Fully quit Claude Desktop (Cmd+Q) and reopen it. You should now be able to manage your iCloud inbox through Claude.
|
|
91
120
|
|
|
@@ -103,7 +132,7 @@ Fully quit Claude Desktop (Cmd+Q) and reopen it. You should now be able to manag
|
|
|
103
132
|
| `get_emails_by_date_range` | Emails between two dates |
|
|
104
133
|
| `search_emails` | Search by keyword with optional filters (date, unread, domain, etc.) |
|
|
105
134
|
| `count_emails` | Count emails matching any combination of filters without modifying them |
|
|
106
|
-
| `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`) |
|
|
107
136
|
| `bulk_delete` | Delete emails matching any combination of filters (supports `dryRun`) |
|
|
108
137
|
| `bulk_flag` | Flag or unflag emails matching any combination of filters |
|
|
109
138
|
| `bulk_mark_read` | Mark all emails (or all from a sender) as read |
|
package/index.js
CHANGED
|
@@ -15,23 +15,52 @@ const IMAP_USER = process.env.IMAP_USER;
|
|
|
15
15
|
const IMAP_PASSWORD = process.env.IMAP_PASSWORD;
|
|
16
16
|
|
|
17
17
|
if (!IMAP_USER || !IMAP_PASSWORD) {
|
|
18
|
-
process.
|
|
19
|
-
|
|
18
|
+
if (process.argv.includes('--doctor')) {
|
|
19
|
+
// Doctor will handle missing credentials with friendly output
|
|
20
|
+
} else {
|
|
21
|
+
process.stderr.write('Error: IMAP_USER and IMAP_PASSWORD environment variables are required\n');
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
20
24
|
}
|
|
21
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
|
+
|
|
22
30
|
function createClient() {
|
|
23
31
|
return new ImapFlow({
|
|
24
32
|
host: 'imap.mail.me.com',
|
|
25
33
|
port: 993,
|
|
26
34
|
secure: true,
|
|
27
35
|
auth: { user: IMAP_USER, pass: IMAP_PASSWORD },
|
|
28
|
-
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
|
|
29
40
|
});
|
|
30
41
|
}
|
|
31
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
|
+
|
|
32
61
|
// ─── Move Manifest ────────────────────────────────────────────────────────────
|
|
33
62
|
|
|
34
|
-
const CHUNK_SIZE =
|
|
63
|
+
const CHUNK_SIZE = 500;
|
|
35
64
|
const CHUNK_SIZE_RETRY = 100;
|
|
36
65
|
|
|
37
66
|
function readManifest() {
|
|
@@ -49,7 +78,9 @@ function writeManifest(data) {
|
|
|
49
78
|
|
|
50
79
|
function updateManifest(updater) {
|
|
51
80
|
const data = readManifest();
|
|
81
|
+
if (!data.current) return data; // guard: operation already archived/failed
|
|
52
82
|
updater(data);
|
|
83
|
+
if (!data.current) return data; // guard: updater may have archived it
|
|
53
84
|
data.current.updatedAt = new Date().toISOString();
|
|
54
85
|
writeManifest(data);
|
|
55
86
|
return data;
|
|
@@ -140,7 +171,9 @@ function startOperation(source, target, uids) {
|
|
|
140
171
|
|
|
141
172
|
function updateChunk(index, updates) {
|
|
142
173
|
updateManifest((data) => {
|
|
174
|
+
if (!data.current) return; // guard: operation already archived
|
|
143
175
|
const chunk = data.current.chunks[index];
|
|
176
|
+
if (!chunk) return; // guard: chunk index out of range
|
|
144
177
|
Object.assign(chunk, updates);
|
|
145
178
|
|
|
146
179
|
let moved = 0, failed = 0, pending = 0;
|
|
@@ -222,188 +255,329 @@ function fingerprintToKey(fp) {
|
|
|
222
255
|
return fp.messageId ?? fp.fallback;
|
|
223
256
|
}
|
|
224
257
|
|
|
225
|
-
// ───
|
|
258
|
+
// ─── Transient error detection ────────────────────────────────────────────────
|
|
226
259
|
|
|
227
|
-
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
+
}
|
|
234
284
|
}
|
|
235
|
-
|
|
236
|
-
return envelopes;
|
|
285
|
+
throw lastErr;
|
|
237
286
|
}
|
|
238
287
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
+
]);
|
|
245
310
|
}
|
|
246
311
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
await client.messageDelete(uids, { uid: true });
|
|
252
|
-
await client.logout();
|
|
312
|
+
// ─── Move logging ─────────────────────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
function elapsed(startMs) {
|
|
315
|
+
return ((Date.now() - startMs) / 1000).toFixed(1) + 's';
|
|
253
316
|
}
|
|
254
317
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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).
|
|
260
328
|
|
|
261
|
-
|
|
262
|
-
|
|
329
|
+
async function verifyByEnvelopeScan(client, fingerprints, chunkIndex, knownTotal = null) {
|
|
330
|
+
if (fingerprints.length === 0) return { missing: [], found: 0 };
|
|
331
|
+
|
|
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);
|
|
263
335
|
const start = Math.max(1, total - fetchCount + 1);
|
|
264
336
|
const range = `${start}:${total}`;
|
|
265
337
|
|
|
266
338
|
const targetKeys = new Set();
|
|
339
|
+
let scanned = 0;
|
|
267
340
|
for await (const msg of client.fetch(range, { envelope: true })) {
|
|
268
341
|
const fp = buildFingerprint(msg);
|
|
269
342
|
targetKeys.add(fingerprintToKey(fp));
|
|
343
|
+
scanned++;
|
|
270
344
|
}
|
|
271
|
-
await client.logout();
|
|
272
345
|
|
|
273
346
|
const missing = [];
|
|
274
|
-
for (const fp of
|
|
275
|
-
|
|
276
|
-
if (!targetKeys.has(key)) missing.push(fp);
|
|
347
|
+
for (const fp of fingerprints) {
|
|
348
|
+
if (!targetKeys.has(fingerprintToKey(fp))) missing.push(fp);
|
|
277
349
|
}
|
|
278
350
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
missing,
|
|
282
|
-
found: expectedFingerprints.length - missing.length,
|
|
283
|
-
expected: expectedFingerprints.length
|
|
284
|
-
};
|
|
351
|
+
moveLog(chunkIndex, `envelope scan: ${scanned} scanned, ${fingerprints.length - missing.length}/${fingerprints.length} matched (${elapsed(t0)})`);
|
|
352
|
+
return { missing, found: fingerprints.length - missing.length };
|
|
285
353
|
}
|
|
286
354
|
|
|
287
|
-
|
|
355
|
+
async function verifyByMessageId(client, fingerprints, chunkIndex) {
|
|
356
|
+
if (fingerprints.length === 0) return { missing: [], verified: 0 };
|
|
288
357
|
|
|
289
|
-
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
msg.includes('ECONNREFUSED') ||
|
|
293
|
-
msg.includes('ETIMEDOUT') ||
|
|
294
|
-
msg.includes('EPIPE') ||
|
|
295
|
-
msg.includes('socket hang up') ||
|
|
296
|
-
msg.includes('Connection not available') ||
|
|
297
|
-
msg.includes('BAD') || // IMAP BAD response — often transient on Apple
|
|
298
|
-
msg.includes('NO '); // IMAP NO response — often transient on Apple
|
|
299
|
-
}
|
|
358
|
+
const t0 = Date.now();
|
|
359
|
+
const missing = [];
|
|
360
|
+
let verified = 0;
|
|
300
361
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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)})`);
|
|
312
378
|
}
|
|
313
379
|
}
|
|
314
|
-
|
|
380
|
+
|
|
381
|
+
moveLog(chunkIndex, `Message-ID fallback: ${verified}/${fingerprints.length} verified, ${missing.length} still missing (${elapsed(t0)})`);
|
|
382
|
+
return { missing, verified };
|
|
315
383
|
}
|
|
316
384
|
|
|
317
|
-
|
|
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) ───────
|
|
318
419
|
|
|
319
420
|
async function safeMoveEmails(uids, sourceMailbox, targetMailbox) {
|
|
320
421
|
const operation = startOperation(sourceMailbox, targetMailbox, uids);
|
|
321
422
|
let totalMoved = 0;
|
|
322
423
|
let totalFailed = 0;
|
|
424
|
+
const opStart = Date.now();
|
|
323
425
|
|
|
324
|
-
|
|
325
|
-
const chunkUids = chunk.uids;
|
|
326
|
-
let succeeded = false;
|
|
426
|
+
process.stderr.write(`[move] starting: ${uids.length} emails, ${operation.chunks.length} chunks, ${sourceMailbox} → ${targetMailbox}\n`);
|
|
327
427
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
const subChunks = [];
|
|
331
|
-
for (let i = 0; i < chunkUids.length; i += attemptChunkSize) {
|
|
332
|
-
subChunks.push(chunkUids.slice(i, i + attemptChunkSize));
|
|
333
|
-
}
|
|
428
|
+
let srcClient = await openClient(sourceMailbox);
|
|
429
|
+
let tgtClient = await openClient(targetMailbox);
|
|
334
430
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
const envelopes = await withRetry(
|
|
341
|
-
`fetchEnvelopes chunk ${chunk.index}`,
|
|
342
|
-
() => fetchEnvelopes(sourceMailbox, subChunk)
|
|
343
|
-
);
|
|
344
|
-
const fingerprints = envelopes.map(buildFingerprint);
|
|
345
|
-
|
|
346
|
-
updateManifest((data) => {
|
|
347
|
-
const c = data.current.chunks[chunk.index];
|
|
348
|
-
c.fingerprints = [...c.fingerprints, ...fingerprints];
|
|
349
|
-
c.status = 'pending';
|
|
350
|
-
});
|
|
431
|
+
try {
|
|
432
|
+
for (const chunk of operation.chunks) {
|
|
433
|
+
const chunkUids = chunk.uids;
|
|
434
|
+
let succeeded = false;
|
|
435
|
+
const chunkStart = Date.now();
|
|
351
436
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
)
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
+
}
|
|
543
|
+
|
|
544
|
+
if (!verificationFailed) {
|
|
545
|
+
succeeded = true;
|
|
366
546
|
break;
|
|
367
547
|
}
|
|
368
|
-
updateChunk(chunk.index, { status: 'verified_not_deleted', verifiedAt: new Date().toISOString() });
|
|
369
|
-
|
|
370
|
-
// Step 4: safe to delete from source — retries on transient errors
|
|
371
|
-
await withRetry(
|
|
372
|
-
`deleteChunk chunk ${chunk.index}`,
|
|
373
|
-
() => deleteChunk(sourceMailbox, subChunk)
|
|
374
|
-
);
|
|
375
|
-
totalMoved += subChunk.length;
|
|
376
|
-
}
|
|
377
548
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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}`);
|
|
381
566
|
}
|
|
382
567
|
|
|
383
|
-
if (
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
status: 'failed',
|
|
387
|
-
failureReason: 'Verification failed at both chunk sizes'
|
|
388
|
-
});
|
|
389
|
-
totalFailed += chunkUids.length;
|
|
390
|
-
failOperation(`Verification failed after retry on chunk ${chunk.index}`);
|
|
391
|
-
return {
|
|
392
|
-
status: 'partial',
|
|
393
|
-
moved: totalMoved,
|
|
394
|
-
failed: totalFailed,
|
|
395
|
-
message: `Verification failed after retry. ${totalMoved} emails moved successfully. ${operation.totalUids - totalMoved} remain in source untouched. Call get_move_status for details.`
|
|
396
|
-
};
|
|
568
|
+
if (succeeded) {
|
|
569
|
+
updateChunk(chunk.index, { status: 'complete', deletedAt: new Date().toISOString() });
|
|
570
|
+
moveLog(chunk.index, `COMPLETE (${elapsed(chunkStart)})`);
|
|
397
571
|
}
|
|
398
572
|
}
|
|
399
573
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
}
|
|
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);
|
|
403
580
|
}
|
|
404
|
-
|
|
405
|
-
completeOperation();
|
|
406
|
-
return { status: 'complete', moved: totalMoved, total: operation.totalUids };
|
|
407
581
|
}
|
|
408
582
|
|
|
409
583
|
// ─── Email Functions ──────────────────────────────────────────────────────────
|
|
@@ -551,18 +725,14 @@ async function bulkDeleteBySender(sender, mailbox = 'INBOX') {
|
|
|
551
725
|
await client.connect();
|
|
552
726
|
await client.mailboxOpen(mailbox);
|
|
553
727
|
const uids = (await client.search({ from: sender }, { uid: true })) ?? [];
|
|
554
|
-
await client.logout();
|
|
555
|
-
if (uids.length === 0) return { deleted: 0 };
|
|
728
|
+
if (uids.length === 0) { await client.logout(); return { deleted: 0 }; }
|
|
556
729
|
let deleted = 0;
|
|
557
730
|
for (let i = 0; i < uids.length; i += CHUNK_SIZE) {
|
|
558
731
|
const chunk = uids.slice(i, i + CHUNK_SIZE);
|
|
559
|
-
|
|
560
|
-
await dc.connect();
|
|
561
|
-
await dc.mailboxOpen(mailbox);
|
|
562
|
-
await dc.messageDelete(chunk, { uid: true });
|
|
563
|
-
await dc.logout();
|
|
732
|
+
await client.messageDelete(chunk, { uid: true });
|
|
564
733
|
deleted += chunk.length;
|
|
565
734
|
}
|
|
735
|
+
await client.logout();
|
|
566
736
|
return { deleted, sender };
|
|
567
737
|
}
|
|
568
738
|
|
|
@@ -583,18 +753,14 @@ async function bulkDeleteBySubject(subject, mailbox = 'INBOX') {
|
|
|
583
753
|
await client.connect();
|
|
584
754
|
await client.mailboxOpen(mailbox);
|
|
585
755
|
const uids = (await client.search({ subject }, { uid: true })) ?? [];
|
|
586
|
-
await client.logout();
|
|
587
|
-
if (uids.length === 0) return { deleted: 0 };
|
|
756
|
+
if (uids.length === 0) { await client.logout(); return { deleted: 0 }; }
|
|
588
757
|
let deleted = 0;
|
|
589
758
|
for (let i = 0; i < uids.length; i += CHUNK_SIZE) {
|
|
590
759
|
const chunk = uids.slice(i, i + CHUNK_SIZE);
|
|
591
|
-
|
|
592
|
-
await dc.connect();
|
|
593
|
-
await dc.mailboxOpen(mailbox);
|
|
594
|
-
await dc.messageDelete(chunk, { uid: true });
|
|
595
|
-
await dc.logout();
|
|
760
|
+
await client.messageDelete(chunk, { uid: true });
|
|
596
761
|
deleted += chunk.length;
|
|
597
762
|
}
|
|
763
|
+
await client.logout();
|
|
598
764
|
return { deleted, subject };
|
|
599
765
|
}
|
|
600
766
|
|
|
@@ -605,18 +771,14 @@ async function deleteOlderThan(days, mailbox = 'INBOX') {
|
|
|
605
771
|
const date = new Date();
|
|
606
772
|
date.setDate(date.getDate() - days);
|
|
607
773
|
const uids = (await client.search({ before: date }, { uid: true })) ?? [];
|
|
608
|
-
await client.logout();
|
|
609
|
-
if (uids.length === 0) return { deleted: 0 };
|
|
774
|
+
if (uids.length === 0) { await client.logout(); return { deleted: 0 }; }
|
|
610
775
|
let deleted = 0;
|
|
611
776
|
for (let i = 0; i < uids.length; i += CHUNK_SIZE) {
|
|
612
777
|
const chunk = uids.slice(i, i + CHUNK_SIZE);
|
|
613
|
-
|
|
614
|
-
await dc.connect();
|
|
615
|
-
await dc.mailboxOpen(mailbox);
|
|
616
|
-
await dc.messageDelete(chunk, { uid: true });
|
|
617
|
-
await dc.logout();
|
|
778
|
+
await client.messageDelete(chunk, { uid: true });
|
|
618
779
|
deleted += chunk.length;
|
|
619
780
|
}
|
|
781
|
+
await client.logout();
|
|
620
782
|
return { deleted, olderThan: date.toISOString() };
|
|
621
783
|
}
|
|
622
784
|
|
|
@@ -895,14 +1057,16 @@ async function ensureMailbox(name) {
|
|
|
895
1057
|
await client.logout();
|
|
896
1058
|
}
|
|
897
1059
|
|
|
898
|
-
async function bulkMove(filters, targetMailbox, sourceMailbox = 'INBOX', dryRun = false) {
|
|
1060
|
+
async function bulkMove(filters, targetMailbox, sourceMailbox = 'INBOX', dryRun = false, limit = null) {
|
|
899
1061
|
const client = createClient();
|
|
900
1062
|
await client.connect();
|
|
901
1063
|
await client.mailboxOpen(sourceMailbox);
|
|
902
1064
|
const query = buildQuery(filters);
|
|
903
|
-
|
|
1065
|
+
let uids = (await client.search(query, { uid: true })) ?? [];
|
|
904
1066
|
await client.logout();
|
|
905
1067
|
|
|
1068
|
+
if (limit !== null) uids = uids.slice(0, limit);
|
|
1069
|
+
|
|
906
1070
|
if (dryRun) {
|
|
907
1071
|
return { dryRun: true, wouldMove: uids.length, sourceMailbox, targetMailbox, filters };
|
|
908
1072
|
}
|
|
@@ -913,29 +1077,45 @@ async function bulkMove(filters, targetMailbox, sourceMailbox = 'INBOX', dryRun
|
|
|
913
1077
|
return { ...result, sourceMailbox, targetMailbox, filters };
|
|
914
1078
|
}
|
|
915
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
|
+
|
|
916
1085
|
async function bulkDelete(filters, sourceMailbox = 'INBOX', dryRun = false) {
|
|
917
1086
|
const client = createClient();
|
|
918
1087
|
await client.connect();
|
|
919
1088
|
await client.mailboxOpen(sourceMailbox);
|
|
920
1089
|
const query = buildQuery(filters);
|
|
921
1090
|
const uids = (await client.search(query, { uid: true })) ?? [];
|
|
922
|
-
await client.logout();
|
|
923
1091
|
|
|
924
1092
|
if (dryRun) {
|
|
1093
|
+
await client.logout();
|
|
925
1094
|
return { dryRun: true, wouldDelete: uids.length, sourceMailbox, filters };
|
|
926
1095
|
}
|
|
927
|
-
if (uids.length === 0) return { deleted: 0, sourceMailbox };
|
|
1096
|
+
if (uids.length === 0) { await client.logout(); return { deleted: 0, sourceMailbox }; }
|
|
928
1097
|
|
|
929
1098
|
let deleted = 0;
|
|
930
1099
|
for (let i = 0; i < uids.length; i += CHUNK_SIZE) {
|
|
931
1100
|
const chunk = uids.slice(i, i + CHUNK_SIZE);
|
|
932
|
-
const
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
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
|
+
}
|
|
938
1117
|
}
|
|
1118
|
+
await client.logout();
|
|
939
1119
|
return { deleted, sourceMailbox, filters };
|
|
940
1120
|
}
|
|
941
1121
|
|
|
@@ -977,7 +1157,7 @@ function logClear() {
|
|
|
977
1157
|
|
|
978
1158
|
async function main() {
|
|
979
1159
|
const server = new Server(
|
|
980
|
-
{ name: 'icloud-mail', version: '1.
|
|
1160
|
+
{ name: 'icloud-mail', version: '1.5.0' },
|
|
981
1161
|
{ capabilities: { tools: {} } }
|
|
982
1162
|
);
|
|
983
1163
|
|
|
@@ -1106,6 +1286,7 @@ async function main() {
|
|
|
1106
1286
|
targetMailbox: { type: 'string', description: 'Destination mailbox path' },
|
|
1107
1287
|
sourceMailbox: { type: 'string', description: 'Source mailbox (default INBOX)' },
|
|
1108
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)' },
|
|
1109
1290
|
...filtersSchema
|
|
1110
1291
|
},
|
|
1111
1292
|
required: ['targetMailbox']
|
|
@@ -1113,7 +1294,7 @@ async function main() {
|
|
|
1113
1294
|
},
|
|
1114
1295
|
{
|
|
1115
1296
|
name: 'bulk_delete',
|
|
1116
|
-
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.',
|
|
1117
1298
|
inputSchema: {
|
|
1118
1299
|
type: 'object',
|
|
1119
1300
|
properties: {
|
|
@@ -1350,71 +1531,80 @@ async function main() {
|
|
|
1350
1531
|
const { name, arguments: args } = request.params;
|
|
1351
1532
|
try {
|
|
1352
1533
|
let result;
|
|
1534
|
+
// ── Metadata tier (15s) ──
|
|
1353
1535
|
if (name === 'get_inbox_summary') {
|
|
1354
|
-
result = await getInboxSummary(args.mailbox || 'INBOX');
|
|
1536
|
+
result = await withTimeout('get_inbox_summary', TIMEOUT.METADATA, () => getInboxSummary(args.mailbox || 'INBOX'));
|
|
1355
1537
|
} else if (name === 'get_mailbox_summary') {
|
|
1356
|
-
result = await getMailboxSummary(args.mailbox);
|
|
1357
|
-
} else if (name === '
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
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) ──
|
|
1363
1551
|
} else if (name === 'read_inbox') {
|
|
1364
|
-
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));
|
|
1365
1553
|
} else if (name === 'get_email') {
|
|
1366
|
-
result = await getEmailContent(args.uid, args.mailbox || 'INBOX');
|
|
1554
|
+
result = await withTimeout('get_email', TIMEOUT.FETCH, () => getEmailContent(args.uid, args.mailbox || 'INBOX'));
|
|
1367
1555
|
} else if (name === 'search_emails') {
|
|
1368
1556
|
const { query, mailbox, limit, ...filters } = args;
|
|
1369
|
-
result = await searchEmails(query, mailbox || 'INBOX', limit || 10, filters);
|
|
1370
|
-
} else if (name === '
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
result = await
|
|
1379
|
-
|
|
1380
|
-
const { flagged, mailbox, ...filters } = args;
|
|
1381
|
-
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) ──
|
|
1382
1568
|
} else if (name === 'bulk_delete_by_sender') {
|
|
1383
|
-
result = await bulkDeleteBySender(args.sender, args.mailbox || 'INBOX');
|
|
1384
|
-
} else if (name === 'bulk_move_by_sender') {
|
|
1385
|
-
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'));
|
|
1386
1570
|
} else if (name === 'bulk_delete_by_subject') {
|
|
1387
|
-
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'));
|
|
1388
1572
|
} else if (name === 'bulk_mark_read') {
|
|
1389
|
-
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));
|
|
1390
1574
|
} else if (name === 'bulk_mark_unread') {
|
|
1391
|
-
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'));
|
|
1392
1579
|
} else if (name === 'delete_older_than') {
|
|
1393
|
-
result = await deleteOlderThan(args.days, args.mailbox || 'INBOX');
|
|
1394
|
-
} else if (name === '
|
|
1395
|
-
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) ──
|
|
1396
1594
|
} else if (name === 'flag_email') {
|
|
1397
|
-
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'));
|
|
1398
1596
|
} else if (name === 'mark_as_read') {
|
|
1399
|
-
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'));
|
|
1400
1598
|
} else if (name === 'delete_email') {
|
|
1401
|
-
result = await deleteEmail(args.uid, args.mailbox || 'INBOX');
|
|
1599
|
+
result = await withTimeout('delete_email', TIMEOUT.SINGLE, () => deleteEmail(args.uid, args.mailbox || 'INBOX'));
|
|
1402
1600
|
} else if (name === 'move_email') {
|
|
1403
|
-
result = await moveEmail(args.uid, args.targetMailbox, args.sourceMailbox || 'INBOX');
|
|
1404
|
-
|
|
1405
|
-
result = await listMailboxes();
|
|
1406
|
-
} else if (name === 'create_mailbox') {
|
|
1407
|
-
result = await createMailbox(args.name);
|
|
1408
|
-
} else if (name === 'rename_mailbox') {
|
|
1409
|
-
result = await renameMailbox(args.oldName, args.newName);
|
|
1410
|
-
} else if (name === 'delete_mailbox') {
|
|
1411
|
-
result = await deleteMailbox(args.name);
|
|
1412
|
-
} else if (name === 'empty_trash') {
|
|
1413
|
-
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) ──
|
|
1414
1603
|
} else if (name === 'get_move_status') {
|
|
1415
1604
|
result = getMoveStatus();
|
|
1416
1605
|
} else if (name === 'abandon_move') {
|
|
1417
1606
|
result = abandonMove();
|
|
1607
|
+
// ── Session log (synchronous, no timeout needed) ──
|
|
1418
1608
|
} else if (name === 'log_write') {
|
|
1419
1609
|
result = logWrite(args.step);
|
|
1420
1610
|
} else if (name === 'log_read') {
|
|
@@ -1426,7 +1616,7 @@ async function main() {
|
|
|
1426
1616
|
}
|
|
1427
1617
|
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
1428
1618
|
} catch (error) {
|
|
1429
|
-
return { content: [{ type: 'text', text: `Error: ${error
|
|
1619
|
+
return { content: [{ type: 'text', text: `Error: ${friendlyError(error)}` }], isError: true };
|
|
1430
1620
|
}
|
|
1431
1621
|
});
|
|
1432
1622
|
|
|
@@ -1435,6 +1625,140 @@ async function main() {
|
|
|
1435
1625
|
process.stderr.write('iCloud Mail MCP Server running\n');
|
|
1436
1626
|
}
|
|
1437
1627
|
|
|
1628
|
+
// ─── Friendly error messages ──────────────────────────────────────────────────
|
|
1629
|
+
|
|
1630
|
+
function friendlyError(err) {
|
|
1631
|
+
const msg = err.message ?? '';
|
|
1632
|
+
|
|
1633
|
+
if (msg.includes('AUTHENTICATIONFAILED') || msg.includes('Invalid credentials') || msg.includes('Authentication failed')) {
|
|
1634
|
+
return [
|
|
1635
|
+
'Authentication failed.',
|
|
1636
|
+
'→ Make sure IMAP_PASSWORD is an app-specific password, not your regular iCloud password.',
|
|
1637
|
+
'→ Generate one at: appleid.apple.com → Sign-In and Security → App-Specific Passwords',
|
|
1638
|
+
'→ Also check that IMAP_USER is your full iCloud email address (e.g. you@icloud.com)'
|
|
1639
|
+
].join('\n');
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
if (msg.includes('ECONNREFUSED') || msg.includes('ENOTFOUND') || msg.includes('ENETUNREACH')) {
|
|
1643
|
+
return [
|
|
1644
|
+
'Could not reach imap.mail.me.com:993.',
|
|
1645
|
+
'→ Check your internet connection.',
|
|
1646
|
+
'→ If you are behind a firewall or VPN, port 993 may be blocked.'
|
|
1647
|
+
].join('\n');
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
if (msg.includes('ETIMEDOUT') || msg.includes('socket hang up')) {
|
|
1651
|
+
return [
|
|
1652
|
+
'Connection to iCloud timed out.',
|
|
1653
|
+
'→ Check your internet connection and try again.',
|
|
1654
|
+
'→ iCloud IMAP can be slow under load — this is usually transient.'
|
|
1655
|
+
].join('\n');
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
if (msg.includes('ECONNRESET')) {
|
|
1659
|
+
return [
|
|
1660
|
+
'iCloud closed the connection unexpectedly.',
|
|
1661
|
+
'→ This is usually transient. Try again in a few seconds.'
|
|
1662
|
+
].join('\n');
|
|
1663
|
+
}
|
|
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
|
+
|
|
1673
|
+
if (msg.includes('Mailbox does not exist') || msg.includes('does not exist') || msg.includes('NONEXISTENT')) {
|
|
1674
|
+
return [
|
|
1675
|
+
`Mailbox not found: ${msg}`,
|
|
1676
|
+
'→ Check the folder name is correct — iCloud folder names are case-sensitive.',
|
|
1677
|
+
'→ Use list_mailboxes to see all available folders.'
|
|
1678
|
+
].join('\n');
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
// Fall through — return original message
|
|
1682
|
+
return msg;
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
// ─── Doctor command ───────────────────────────────────────────────────────────
|
|
1686
|
+
|
|
1687
|
+
async function runDoctor() {
|
|
1688
|
+
const divider = '─'.repeat(45);
|
|
1689
|
+
process.stdout.write(`\nicloud-mcp doctor\n${divider}\n`);
|
|
1690
|
+
|
|
1691
|
+
const checks = [
|
|
1692
|
+
{
|
|
1693
|
+
label: 'IMAP_USER is set',
|
|
1694
|
+
run: () => {
|
|
1695
|
+
if (!IMAP_USER) throw new Error('IMAP_USER environment variable is not set.\n→ Add it to your Claude Desktop config env block.');
|
|
1696
|
+
}
|
|
1697
|
+
},
|
|
1698
|
+
{
|
|
1699
|
+
label: 'IMAP_PASSWORD is set',
|
|
1700
|
+
run: () => {
|
|
1701
|
+
if (!IMAP_PASSWORD) throw new Error('IMAP_PASSWORD environment variable is not set.\n→ Add it to your Claude Desktop config env block.');
|
|
1702
|
+
}
|
|
1703
|
+
},
|
|
1704
|
+
{
|
|
1705
|
+
label: 'IMAP_USER looks like an email address',
|
|
1706
|
+
run: () => {
|
|
1707
|
+
if (!IMAP_USER?.includes('@')) throw new Error(`IMAP_USER "${IMAP_USER}" doesn't look like an email address.\n→ Use your full iCloud address, e.g. you@icloud.com`);
|
|
1708
|
+
}
|
|
1709
|
+
},
|
|
1710
|
+
{
|
|
1711
|
+
label: `Connected to imap.mail.me.com:993`,
|
|
1712
|
+
run: async () => {
|
|
1713
|
+
const client = createClient();
|
|
1714
|
+
await client.connect();
|
|
1715
|
+
await client.logout();
|
|
1716
|
+
}
|
|
1717
|
+
},
|
|
1718
|
+
{
|
|
1719
|
+
label: `Authenticated as ${IMAP_USER}`,
|
|
1720
|
+
run: async () => {
|
|
1721
|
+
// Auth is validated as part of connect — if we reach here it passed.
|
|
1722
|
+
// This check exists to give a clearer label in the output.
|
|
1723
|
+
}
|
|
1724
|
+
},
|
|
1725
|
+
{
|
|
1726
|
+
label: 'INBOX opened',
|
|
1727
|
+
run: async () => {
|
|
1728
|
+
const client = createClient();
|
|
1729
|
+
await client.connect();
|
|
1730
|
+
const mb = await client.mailboxOpen('INBOX');
|
|
1731
|
+
await client.logout();
|
|
1732
|
+
return `${mb.exists.toLocaleString()} messages`;
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
];
|
|
1736
|
+
|
|
1737
|
+
let allPassed = true;
|
|
1738
|
+
|
|
1739
|
+
for (const check of checks) {
|
|
1740
|
+
try {
|
|
1741
|
+
const detail = await check.run();
|
|
1742
|
+
const suffix = detail ? ` (${detail})` : '';
|
|
1743
|
+
process.stdout.write(`✅ ${check.label}${suffix}\n`);
|
|
1744
|
+
} catch (err) {
|
|
1745
|
+
process.stdout.write(`❌ ${check.label}\n ${friendlyError(err).replace(/\n/g, '\n ')}\n`);
|
|
1746
|
+
allPassed = false;
|
|
1747
|
+
break; // No point continuing after a failure
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
process.stdout.write(`${divider}\n`);
|
|
1752
|
+
if (allPassed) {
|
|
1753
|
+
process.stdout.write('All checks passed. Ready to use with Claude Desktop.\n\n');
|
|
1754
|
+
process.exit(0);
|
|
1755
|
+
} else {
|
|
1756
|
+
process.stdout.write('Setup is not complete. Fix the issue above and run --doctor again.\n\n');
|
|
1757
|
+
process.exit(1);
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
|
|
1438
1762
|
process.on('uncaughtException', (err) => {
|
|
1439
1763
|
process.stderr.write(`Uncaught exception: ${err.message}\n${err.stack}\n`);
|
|
1440
1764
|
process.exit(1);
|
|
@@ -1445,7 +1769,14 @@ process.on('unhandledRejection', (reason) => {
|
|
|
1445
1769
|
process.exit(1);
|
|
1446
1770
|
});
|
|
1447
1771
|
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1772
|
+
if (process.argv.includes('--doctor')) {
|
|
1773
|
+
runDoctor().catch((err) => {
|
|
1774
|
+
process.stderr.write(`Doctor failed unexpectedly: ${err.message}\n`);
|
|
1775
|
+
process.exit(1);
|
|
1776
|
+
});
|
|
1777
|
+
} else {
|
|
1778
|
+
main().catch((err) => {
|
|
1779
|
+
process.stderr.write(`Fatal error: ${err.message}\n${err.stack}\n`);
|
|
1780
|
+
process.exit(1);
|
|
1781
|
+
});
|
|
1782
|
+
}
|
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);
|