icloud-mcp 1.2.2 ā 1.4.1
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 +30 -2
- package/index.js +536 -63
- package/package.json +1 -1
- package/test.js +188 -26
package/README.md
CHANGED
|
@@ -13,6 +13,8 @@ A Model Context Protocol (MCP) server that connects Claude Desktop to your iClou
|
|
|
13
13
|
- ā
Mark emails as read/unread, flag/unflag in bulk or individually
|
|
14
14
|
- šļø List, create, rename, and delete mailboxes
|
|
15
15
|
- š Dry run mode for bulk operations ā preview before committing
|
|
16
|
+
- š Safe move ā emails are fingerprinted and verified in the destination before being removed from the source
|
|
17
|
+
- š Session logging ā Claude tracks progress across long multi-step operations
|
|
16
18
|
|
|
17
19
|
## Prerequisites
|
|
18
20
|
|
|
@@ -70,7 +72,20 @@ Add the following under `mcpServers`, replacing the path with your npm root path
|
|
|
70
72
|
|
|
71
73
|
> **Note:** If your `npm root -g` returned a different path, replace `/opt/homebrew/lib/node_modules` with that path.
|
|
72
74
|
|
|
73
|
-
### 4.
|
|
75
|
+
### 4. Add Custom Instructions (Recommended)
|
|
76
|
+
|
|
77
|
+
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
|
+
|
|
79
|
+
```
|
|
80
|
+
When using icloud-mail tools:
|
|
81
|
+
1. Before starting any multi-step operation, call log_clear then log_write with your full plan
|
|
82
|
+
2. After every single tool call, call log_write with what you did and the result
|
|
83
|
+
3. After every 3 tool calls, stop and summarize progress to the user and wait for confirmation before continuing
|
|
84
|
+
4. Never assume a bulk operation succeeded ā always verify with count_emails after
|
|
85
|
+
5. If you are ever unsure what you have done so far, call log_read before proceeding
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### 5. Restart Claude Desktop
|
|
74
89
|
|
|
75
90
|
Fully quit Claude Desktop (Cmd+Q) and reopen it. You should now be able to manage your iCloud inbox through Claude.
|
|
76
91
|
|
|
@@ -106,6 +121,11 @@ Fully quit Claude Desktop (Cmd+Q) and reopen it. You should now be able to manag
|
|
|
106
121
|
| `rename_mailbox` | Rename an existing folder |
|
|
107
122
|
| `delete_mailbox` | Delete a folder (must be empty first) |
|
|
108
123
|
| `empty_trash` | Permanently delete all emails in Deleted Messages |
|
|
124
|
+
| `get_move_status` | Check the status of the current or most recent bulk move operation |
|
|
125
|
+
| `abandon_move` | Abandon an in-progress move operation so a new one can start |
|
|
126
|
+
| `log_write` | Write a step to the session log |
|
|
127
|
+
| `log_read` | Read the session log to see what has been done so far |
|
|
128
|
+
| `log_clear` | Clear the session log and start fresh |
|
|
109
129
|
|
|
110
130
|
## Bulk Move, Delete & Flag Filters
|
|
111
131
|
|
|
@@ -124,12 +144,20 @@ Fully quit Claude Desktop (Cmd+Q) and reopen it. You should now be able to manag
|
|
|
124
144
|
| `smaller` | number | Only emails smaller than this size in KB |
|
|
125
145
|
| `hasAttachment` | boolean | Only emails with attachments |
|
|
126
146
|
|
|
127
|
-
|
|
147
|
+
## Dry Run Mode
|
|
128
148
|
|
|
129
149
|
Pass `dryRun: true` to `bulk_move` or `bulk_delete` to preview how many emails would be affected without making any changes:
|
|
130
150
|
|
|
131
151
|
> *"How many emails would be deleted if I removed everything from linkedin.com before 2022?"*
|
|
132
152
|
|
|
153
|
+
## Safe Move
|
|
154
|
+
|
|
155
|
+
All bulk move operations use a copy-verify-delete approach. Emails are fingerprinted before copying, confirmed present in the destination, and only then removed from the source. A persistent manifest at `~/.icloud-mcp-move-manifest.json` tracks progress across chunks so that a crash or connection drop mid-operation never results in data loss. Use `get_move_status` to inspect any operation and `abandon_move` to clear a stuck one.
|
|
156
|
+
|
|
157
|
+
## Session Log
|
|
158
|
+
|
|
159
|
+
The session log persists to `~/.icloud-mcp-session.json` on your Mac ā outside Claude's context window ā so progress is never lost during long operations. Claude can write its plan at the start, log each completed step, and read the log back at any point to reorient itself.
|
|
160
|
+
|
|
133
161
|
## Example Usage
|
|
134
162
|
|
|
135
163
|
Once configured, you can ask Claude things like:
|
package/index.js
CHANGED
|
@@ -3,6 +3,13 @@ import { ImapFlow } from 'imapflow';
|
|
|
3
3
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
4
4
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
5
5
|
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
6
|
+
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
7
|
+
import { homedir } from 'os';
|
|
8
|
+
import { join } from 'path';
|
|
9
|
+
|
|
10
|
+
const LOG_FILE = join(homedir(), '.icloud-mcp-session.json');
|
|
11
|
+
const MANIFEST_FILE = join(homedir(), '.icloud-mcp-move-manifest.json');
|
|
12
|
+
const MAX_HISTORY = 5;
|
|
6
13
|
|
|
7
14
|
const IMAP_USER = process.env.IMAP_USER;
|
|
8
15
|
const IMAP_PASSWORD = process.env.IMAP_PASSWORD;
|
|
@@ -22,6 +29,385 @@ function createClient() {
|
|
|
22
29
|
});
|
|
23
30
|
}
|
|
24
31
|
|
|
32
|
+
// āāā Move Manifest āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
33
|
+
|
|
34
|
+
const CHUNK_SIZE = 250;
|
|
35
|
+
const CHUNK_SIZE_RETRY = 100;
|
|
36
|
+
|
|
37
|
+
function readManifest() {
|
|
38
|
+
if (!existsSync(MANIFEST_FILE)) return { current: null, history: [] };
|
|
39
|
+
try {
|
|
40
|
+
return JSON.parse(readFileSync(MANIFEST_FILE, 'utf8'));
|
|
41
|
+
} catch {
|
|
42
|
+
return { current: null, history: [] };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function writeManifest(data) {
|
|
47
|
+
writeFileSync(MANIFEST_FILE, JSON.stringify(data, null, 2));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function updateManifest(updater) {
|
|
51
|
+
const data = readManifest();
|
|
52
|
+
updater(data);
|
|
53
|
+
data.current.updatedAt = new Date().toISOString();
|
|
54
|
+
writeManifest(data);
|
|
55
|
+
return data;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function archiveCurrent(data) {
|
|
59
|
+
if (data.current) {
|
|
60
|
+
data.history.unshift(data.current);
|
|
61
|
+
if (data.history.length > MAX_HISTORY) data.history = data.history.slice(0, MAX_HISTORY);
|
|
62
|
+
data.current = null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function getMoveStatus() {
|
|
67
|
+
const data = readManifest();
|
|
68
|
+
if (!data.current) return { status: 'no_operation', history: data.history.map(summarizeOp) };
|
|
69
|
+
return {
|
|
70
|
+
current: formatOperation(data.current),
|
|
71
|
+
history: data.history.map(summarizeOp)
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function abandonMove() {
|
|
76
|
+
const data = readManifest();
|
|
77
|
+
if (!data.current) return { abandoned: false, message: 'No in-progress operation to abandon' };
|
|
78
|
+
if (data.current.status !== 'in_progress') {
|
|
79
|
+
return { abandoned: false, message: `Current operation is already '${data.current.status}', nothing to abandon` };
|
|
80
|
+
}
|
|
81
|
+
const operationId = data.current.operationId;
|
|
82
|
+
data.current.status = 'abandoned';
|
|
83
|
+
data.current.updatedAt = new Date().toISOString();
|
|
84
|
+
archiveCurrent(data);
|
|
85
|
+
writeManifest(data);
|
|
86
|
+
return { abandoned: true, operationId };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function startOperation(source, target, uids) {
|
|
90
|
+
const data = readManifest();
|
|
91
|
+
|
|
92
|
+
if (data.current && data.current.status === 'in_progress') {
|
|
93
|
+
const op = data.current;
|
|
94
|
+
throw new Error(
|
|
95
|
+
`Incomplete move operation detected (${op.operationId}): ` +
|
|
96
|
+
`${op.summary.emailsMoved} of ${op.totalUids} emails moved from '${op.source}' to '${op.target}' ` +
|
|
97
|
+
`started at ${op.startedAt}. ` +
|
|
98
|
+
`Call abandon_move to discard it or get_move_status to inspect it before starting a new operation.`
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
archiveCurrent(data);
|
|
103
|
+
|
|
104
|
+
const operationId = `move_${Date.now()}`;
|
|
105
|
+
const chunks = [];
|
|
106
|
+
|
|
107
|
+
for (let i = 0; i < uids.length; i += CHUNK_SIZE) {
|
|
108
|
+
chunks.push({
|
|
109
|
+
index: chunks.length,
|
|
110
|
+
uids: uids.slice(i, i + CHUNK_SIZE),
|
|
111
|
+
fingerprints: [],
|
|
112
|
+
status: 'pending',
|
|
113
|
+
copiedAt: null,
|
|
114
|
+
verifiedAt: null,
|
|
115
|
+
deletedAt: null,
|
|
116
|
+
failureReason: null
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
data.current = {
|
|
121
|
+
operationId,
|
|
122
|
+
startedAt: new Date().toISOString(),
|
|
123
|
+
updatedAt: new Date().toISOString(),
|
|
124
|
+
source,
|
|
125
|
+
target,
|
|
126
|
+
totalUids: uids.length,
|
|
127
|
+
status: 'in_progress',
|
|
128
|
+
chunks,
|
|
129
|
+
summary: {
|
|
130
|
+
chunksComplete: 0,
|
|
131
|
+
emailsMoved: 0,
|
|
132
|
+
emailsPending: uids.length,
|
|
133
|
+
emailsFailed: 0
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
writeManifest(data);
|
|
138
|
+
return data.current;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function updateChunk(index, updates) {
|
|
142
|
+
updateManifest((data) => {
|
|
143
|
+
const chunk = data.current.chunks[index];
|
|
144
|
+
Object.assign(chunk, updates);
|
|
145
|
+
|
|
146
|
+
let moved = 0, failed = 0, pending = 0;
|
|
147
|
+
for (const c of data.current.chunks) {
|
|
148
|
+
if (c.status === 'complete') moved += c.uids.length;
|
|
149
|
+
else if (c.status === 'failed') failed += c.uids.length;
|
|
150
|
+
else pending += c.uids.length;
|
|
151
|
+
}
|
|
152
|
+
data.current.summary = {
|
|
153
|
+
chunksComplete: data.current.chunks.filter(c => c.status === 'complete').length,
|
|
154
|
+
emailsMoved: moved,
|
|
155
|
+
emailsPending: pending,
|
|
156
|
+
emailsFailed: failed
|
|
157
|
+
};
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function completeOperation() {
|
|
162
|
+
const data = readManifest();
|
|
163
|
+
if (!data.current) return;
|
|
164
|
+
data.current.status = 'complete';
|
|
165
|
+
data.current.updatedAt = new Date().toISOString();
|
|
166
|
+
archiveCurrent(data);
|
|
167
|
+
writeManifest(data);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function failOperation(reason) {
|
|
171
|
+
const data = readManifest();
|
|
172
|
+
if (!data.current) return;
|
|
173
|
+
data.current.status = 'failed';
|
|
174
|
+
data.current.failureReason = reason;
|
|
175
|
+
data.current.updatedAt = new Date().toISOString();
|
|
176
|
+
archiveCurrent(data);
|
|
177
|
+
writeManifest(data);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function formatOperation(op) {
|
|
181
|
+
return {
|
|
182
|
+
operationId: op.operationId,
|
|
183
|
+
status: op.status,
|
|
184
|
+
source: op.source,
|
|
185
|
+
target: op.target,
|
|
186
|
+
startedAt: op.startedAt,
|
|
187
|
+
updatedAt: op.updatedAt,
|
|
188
|
+
summary: op.summary,
|
|
189
|
+
failedChunks: op.chunks.filter(c => c.status === 'failed').map(c => ({
|
|
190
|
+
index: c.index,
|
|
191
|
+
uids: c.uids.length,
|
|
192
|
+
reason: c.failureReason
|
|
193
|
+
}))
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function summarizeOp(op) {
|
|
198
|
+
return {
|
|
199
|
+
operationId: op.operationId,
|
|
200
|
+
status: op.status,
|
|
201
|
+
source: op.source,
|
|
202
|
+
target: op.target,
|
|
203
|
+
startedAt: op.startedAt,
|
|
204
|
+
moved: op.summary.emailsMoved,
|
|
205
|
+
failed: op.summary.emailsFailed,
|
|
206
|
+
total: op.totalUids
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// āāā Fingerprinting āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
211
|
+
|
|
212
|
+
function buildFingerprint(msg) {
|
|
213
|
+
const messageId = msg.envelope?.messageId ?? null;
|
|
214
|
+
const sender = msg.envelope?.from?.[0]?.address ?? '';
|
|
215
|
+
const date = msg.envelope?.date ? new Date(msg.envelope.date).toISOString() : '';
|
|
216
|
+
const subject = msg.envelope?.subject ?? '';
|
|
217
|
+
const fallback = [sender, date, subject].join('|');
|
|
218
|
+
return { uid: msg.uid, messageId, fallback };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function fingerprintToKey(fp) {
|
|
222
|
+
return fp.messageId ?? fp.fallback;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// āāā IMAP Move Helpers āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
226
|
+
|
|
227
|
+
async function fetchEnvelopes(mailbox, uids) {
|
|
228
|
+
const client = createClient();
|
|
229
|
+
await client.connect();
|
|
230
|
+
await client.mailboxOpen(mailbox);
|
|
231
|
+
const envelopes = [];
|
|
232
|
+
for await (const msg of client.fetch(uids, { envelope: true }, { uid: true })) {
|
|
233
|
+
envelopes.push(msg);
|
|
234
|
+
}
|
|
235
|
+
await client.logout();
|
|
236
|
+
return envelopes;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function copyChunk(sourceMailbox, targetMailbox, uids) {
|
|
240
|
+
const client = createClient();
|
|
241
|
+
await client.connect();
|
|
242
|
+
await client.mailboxOpen(sourceMailbox);
|
|
243
|
+
await client.messageCopy(uids, targetMailbox, { uid: true });
|
|
244
|
+
await client.logout();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function deleteChunk(sourceMailbox, uids) {
|
|
248
|
+
const client = createClient();
|
|
249
|
+
await client.connect();
|
|
250
|
+
await client.mailboxOpen(sourceMailbox);
|
|
251
|
+
await client.messageDelete(uids, { uid: true });
|
|
252
|
+
await client.logout();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function verifyFingerprintsInTarget(targetMailbox, expectedFingerprints) {
|
|
256
|
+
const client = createClient();
|
|
257
|
+
await client.connect();
|
|
258
|
+
const mb = await client.mailboxOpen(targetMailbox);
|
|
259
|
+
const total = mb.exists;
|
|
260
|
+
|
|
261
|
+
// Fetch recent envelopes from target ā copies land at the end
|
|
262
|
+
const fetchCount = Math.min(total, expectedFingerprints.length * 2 + 500);
|
|
263
|
+
const start = Math.max(1, total - fetchCount + 1);
|
|
264
|
+
const range = `${start}:${total}`;
|
|
265
|
+
|
|
266
|
+
const targetKeys = new Set();
|
|
267
|
+
for await (const msg of client.fetch(range, { envelope: true })) {
|
|
268
|
+
const fp = buildFingerprint(msg);
|
|
269
|
+
targetKeys.add(fingerprintToKey(fp));
|
|
270
|
+
}
|
|
271
|
+
await client.logout();
|
|
272
|
+
|
|
273
|
+
const missing = [];
|
|
274
|
+
for (const fp of expectedFingerprints) {
|
|
275
|
+
const key = fingerprintToKey(fp);
|
|
276
|
+
if (!targetKeys.has(key)) missing.push(fp);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
verified: missing.length === 0,
|
|
281
|
+
missing,
|
|
282
|
+
found: expectedFingerprints.length - missing.length,
|
|
283
|
+
expected: expectedFingerprints.length
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// āāā Transient error detection āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
288
|
+
|
|
289
|
+
function isTransient(err) {
|
|
290
|
+
const msg = err.message ?? '';
|
|
291
|
+
return msg.includes('ECONNRESET') ||
|
|
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
|
+
}
|
|
300
|
+
|
|
301
|
+
async function withRetry(label, fn, maxAttempts = 3) {
|
|
302
|
+
let lastErr;
|
|
303
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
304
|
+
try {
|
|
305
|
+
return await fn();
|
|
306
|
+
} catch (err) {
|
|
307
|
+
lastErr = err;
|
|
308
|
+
if (!isTransient(err) || attempt === maxAttempts) throw err;
|
|
309
|
+
const delay = attempt * 2000; // 2s, 4s backoff
|
|
310
|
+
process.stderr.write(`[retry] ${label} failed (attempt ${attempt}/${maxAttempts}): ${err.message} ā retrying in ${delay}ms\n`);
|
|
311
|
+
await new Promise(r => setTimeout(r, delay));
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
throw lastErr;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// āāā Safe Move āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
318
|
+
|
|
319
|
+
async function safeMoveEmails(uids, sourceMailbox, targetMailbox) {
|
|
320
|
+
const operation = startOperation(sourceMailbox, targetMailbox, uids);
|
|
321
|
+
let totalMoved = 0;
|
|
322
|
+
let totalFailed = 0;
|
|
323
|
+
|
|
324
|
+
for (const chunk of operation.chunks) {
|
|
325
|
+
const chunkUids = chunk.uids;
|
|
326
|
+
let succeeded = false;
|
|
327
|
+
|
|
328
|
+
// Try at full chunk size first, then smaller on verification failure
|
|
329
|
+
for (const attemptChunkSize of [CHUNK_SIZE, CHUNK_SIZE_RETRY]) {
|
|
330
|
+
const subChunks = [];
|
|
331
|
+
for (let i = 0; i < chunkUids.length; i += attemptChunkSize) {
|
|
332
|
+
subChunks.push(chunkUids.slice(i, i + attemptChunkSize));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
let verificationFailed = false;
|
|
336
|
+
|
|
337
|
+
for (const subChunk of subChunks) {
|
|
338
|
+
// Step 1: fetch fingerprints from source and write to manifest
|
|
339
|
+
// before doing anything destructive ā retries on transient errors
|
|
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
|
+
});
|
|
351
|
+
|
|
352
|
+
// Step 2: copy to target ā retries on transient errors
|
|
353
|
+
await withRetry(
|
|
354
|
+
`copyChunk chunk ${chunk.index}`,
|
|
355
|
+
() => copyChunk(sourceMailbox, targetMailbox, subChunk)
|
|
356
|
+
);
|
|
357
|
+
updateChunk(chunk.index, { status: 'copied_not_verified', copiedAt: new Date().toISOString() });
|
|
358
|
+
|
|
359
|
+
// Step 3: verify by fetching envelopes from target ā retries on transient errors
|
|
360
|
+
const verification = await withRetry(
|
|
361
|
+
`verifyFingerprints chunk ${chunk.index}`,
|
|
362
|
+
() => verifyFingerprintsInTarget(targetMailbox, fingerprints)
|
|
363
|
+
);
|
|
364
|
+
if (!verification.verified) {
|
|
365
|
+
verificationFailed = true;
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
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
|
+
|
|
378
|
+
if (!verificationFailed) {
|
|
379
|
+
succeeded = true;
|
|
380
|
+
break;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (attemptChunkSize === CHUNK_SIZE_RETRY) {
|
|
384
|
+
// Verification failed at both chunk sizes ā stop and report, source untouched from here
|
|
385
|
+
updateChunk(chunk.index, {
|
|
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
|
+
};
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (succeeded) {
|
|
401
|
+
updateChunk(chunk.index, { status: 'complete', deletedAt: new Date().toISOString() });
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
completeOperation();
|
|
406
|
+
return { status: 'complete', moved: totalMoved, total: operation.totalUids };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// āāā Email Functions āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
410
|
+
|
|
25
411
|
async function fetchEmails(mailbox = 'INBOX', limit = 10, onlyUnread = false, page = 1) {
|
|
26
412
|
const client = createClient();
|
|
27
413
|
await client.connect();
|
|
@@ -165,15 +551,18 @@ async function bulkDeleteBySender(sender, mailbox = 'INBOX') {
|
|
|
165
551
|
await client.connect();
|
|
166
552
|
await client.mailboxOpen(mailbox);
|
|
167
553
|
const uids = (await client.search({ from: sender }, { uid: true })) ?? [];
|
|
168
|
-
|
|
169
|
-
|
|
554
|
+
await client.logout();
|
|
555
|
+
if (uids.length === 0) return { deleted: 0 };
|
|
170
556
|
let deleted = 0;
|
|
171
|
-
for (let i = 0; i < uids.length; i +=
|
|
172
|
-
const chunk = uids.slice(i, i +
|
|
173
|
-
|
|
557
|
+
for (let i = 0; i < uids.length; i += CHUNK_SIZE) {
|
|
558
|
+
const chunk = uids.slice(i, i + CHUNK_SIZE);
|
|
559
|
+
const dc = createClient();
|
|
560
|
+
await dc.connect();
|
|
561
|
+
await dc.mailboxOpen(mailbox);
|
|
562
|
+
await dc.messageDelete(chunk, { uid: true });
|
|
563
|
+
await dc.logout();
|
|
174
564
|
deleted += chunk.length;
|
|
175
565
|
}
|
|
176
|
-
await client.logout();
|
|
177
566
|
return { deleted, sender };
|
|
178
567
|
}
|
|
179
568
|
|
|
@@ -182,16 +571,11 @@ async function bulkMoveBySender(sender, targetMailbox, sourceMailbox = 'INBOX')
|
|
|
182
571
|
await client.connect();
|
|
183
572
|
await client.mailboxOpen(sourceMailbox);
|
|
184
573
|
const uids = (await client.search({ from: sender }, { uid: true })) ?? [];
|
|
185
|
-
if (uids.length === 0) { await client.logout(); return { moved: 0 }; }
|
|
186
|
-
const chunkSize = 250;
|
|
187
|
-
let moved = 0;
|
|
188
|
-
for (let i = 0; i < uids.length; i += chunkSize) {
|
|
189
|
-
const chunk = uids.slice(i, i + chunkSize);
|
|
190
|
-
await client.messageMove(chunk, targetMailbox, { uid: true });
|
|
191
|
-
moved += chunk.length;
|
|
192
|
-
}
|
|
193
574
|
await client.logout();
|
|
194
|
-
return { moved
|
|
575
|
+
if (uids.length === 0) return { moved: 0 };
|
|
576
|
+
await ensureMailbox(targetMailbox);
|
|
577
|
+
const result = await safeMoveEmails(uids, sourceMailbox, targetMailbox);
|
|
578
|
+
return { ...result, sender, targetMailbox };
|
|
195
579
|
}
|
|
196
580
|
|
|
197
581
|
async function bulkDeleteBySubject(subject, mailbox = 'INBOX') {
|
|
@@ -199,15 +583,18 @@ async function bulkDeleteBySubject(subject, mailbox = 'INBOX') {
|
|
|
199
583
|
await client.connect();
|
|
200
584
|
await client.mailboxOpen(mailbox);
|
|
201
585
|
const uids = (await client.search({ subject }, { uid: true })) ?? [];
|
|
202
|
-
|
|
203
|
-
|
|
586
|
+
await client.logout();
|
|
587
|
+
if (uids.length === 0) return { deleted: 0 };
|
|
204
588
|
let deleted = 0;
|
|
205
|
-
for (let i = 0; i < uids.length; i +=
|
|
206
|
-
const chunk = uids.slice(i, i +
|
|
207
|
-
|
|
589
|
+
for (let i = 0; i < uids.length; i += CHUNK_SIZE) {
|
|
590
|
+
const chunk = uids.slice(i, i + CHUNK_SIZE);
|
|
591
|
+
const dc = createClient();
|
|
592
|
+
await dc.connect();
|
|
593
|
+
await dc.mailboxOpen(mailbox);
|
|
594
|
+
await dc.messageDelete(chunk, { uid: true });
|
|
595
|
+
await dc.logout();
|
|
208
596
|
deleted += chunk.length;
|
|
209
597
|
}
|
|
210
|
-
await client.logout();
|
|
211
598
|
return { deleted, subject };
|
|
212
599
|
}
|
|
213
600
|
|
|
@@ -218,15 +605,18 @@ async function deleteOlderThan(days, mailbox = 'INBOX') {
|
|
|
218
605
|
const date = new Date();
|
|
219
606
|
date.setDate(date.getDate() - days);
|
|
220
607
|
const uids = (await client.search({ before: date }, { uid: true })) ?? [];
|
|
221
|
-
|
|
222
|
-
|
|
608
|
+
await client.logout();
|
|
609
|
+
if (uids.length === 0) return { deleted: 0 };
|
|
223
610
|
let deleted = 0;
|
|
224
|
-
for (let i = 0; i < uids.length; i +=
|
|
225
|
-
const chunk = uids.slice(i, i +
|
|
226
|
-
|
|
611
|
+
for (let i = 0; i < uids.length; i += CHUNK_SIZE) {
|
|
612
|
+
const chunk = uids.slice(i, i + CHUNK_SIZE);
|
|
613
|
+
const dc = createClient();
|
|
614
|
+
await dc.connect();
|
|
615
|
+
await dc.mailboxOpen(mailbox);
|
|
616
|
+
await dc.messageDelete(chunk, { uid: true });
|
|
617
|
+
await dc.logout();
|
|
227
618
|
deleted += chunk.length;
|
|
228
619
|
}
|
|
229
|
-
await client.logout();
|
|
230
620
|
return { deleted, olderThan: date.toISOString() };
|
|
231
621
|
}
|
|
232
622
|
|
|
@@ -301,10 +691,9 @@ async function emptyTrash() {
|
|
|
301
691
|
await client.mailboxOpen('Deleted Messages');
|
|
302
692
|
const uids = (await client.search({ all: true }, { uid: true })) ?? [];
|
|
303
693
|
if (uids.length === 0) { await client.logout(); return { deleted: 0 }; }
|
|
304
|
-
const chunkSize = 250;
|
|
305
694
|
let deleted = 0;
|
|
306
|
-
for (let i = 0; i < uids.length; i +=
|
|
307
|
-
const chunk = uids.slice(i, i +
|
|
695
|
+
for (let i = 0; i < uids.length; i += CHUNK_SIZE) {
|
|
696
|
+
const chunk = uids.slice(i, i + CHUNK_SIZE);
|
|
308
697
|
await client.messageDelete(chunk, { uid: true });
|
|
309
698
|
deleted += chunk.length;
|
|
310
699
|
}
|
|
@@ -323,16 +712,32 @@ async function createMailbox(name) {
|
|
|
323
712
|
async function renameMailbox(oldName, newName) {
|
|
324
713
|
const client = createClient();
|
|
325
714
|
await client.connect();
|
|
326
|
-
|
|
327
|
-
|
|
715
|
+
try {
|
|
716
|
+
await Promise.race([
|
|
717
|
+
client.mailboxRename(oldName, newName),
|
|
718
|
+
new Promise((_, reject) =>
|
|
719
|
+
setTimeout(() => reject(new Error('rename timed out after 15s ā Apple IMAP may not support renaming this folder')), 15000)
|
|
720
|
+
)
|
|
721
|
+
]);
|
|
722
|
+
} finally {
|
|
723
|
+
try { await client.logout(); } catch { client.close(); }
|
|
724
|
+
}
|
|
328
725
|
return { renamed: { from: oldName, to: newName } };
|
|
329
726
|
}
|
|
330
727
|
|
|
331
728
|
async function deleteMailbox(name) {
|
|
332
729
|
const client = createClient();
|
|
333
730
|
await client.connect();
|
|
334
|
-
|
|
335
|
-
|
|
731
|
+
try {
|
|
732
|
+
await Promise.race([
|
|
733
|
+
client.mailboxDelete(name),
|
|
734
|
+
new Promise((_, reject) =>
|
|
735
|
+
setTimeout(() => reject(new Error('delete timed out after 15s ā Apple IMAP may not support deleting this folder')), 15000)
|
|
736
|
+
)
|
|
737
|
+
]);
|
|
738
|
+
} finally {
|
|
739
|
+
try { await client.logout(); } catch { client.close(); }
|
|
740
|
+
}
|
|
336
741
|
return { deleted: name };
|
|
337
742
|
}
|
|
338
743
|
|
|
@@ -483,26 +888,29 @@ function buildQuery(filters) {
|
|
|
483
888
|
return query;
|
|
484
889
|
}
|
|
485
890
|
|
|
891
|
+
async function ensureMailbox(name) {
|
|
892
|
+
const client = createClient();
|
|
893
|
+
await client.connect();
|
|
894
|
+
try { await client.mailboxCreate(name); } catch { /* already exists */ }
|
|
895
|
+
await client.logout();
|
|
896
|
+
}
|
|
897
|
+
|
|
486
898
|
async function bulkMove(filters, targetMailbox, sourceMailbox = 'INBOX', dryRun = false) {
|
|
487
899
|
const client = createClient();
|
|
488
900
|
await client.connect();
|
|
489
901
|
await client.mailboxOpen(sourceMailbox);
|
|
490
902
|
const query = buildQuery(filters);
|
|
491
903
|
const uids = (await client.search(query, { uid: true })) ?? [];
|
|
904
|
+
await client.logout();
|
|
905
|
+
|
|
492
906
|
if (dryRun) {
|
|
493
|
-
await client.logout();
|
|
494
907
|
return { dryRun: true, wouldMove: uids.length, sourceMailbox, targetMailbox, filters };
|
|
495
908
|
}
|
|
496
|
-
if (uids.length === 0)
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
await client.messageMove(chunk, targetMailbox, { uid: true });
|
|
502
|
-
moved += chunk.length;
|
|
503
|
-
}
|
|
504
|
-
await client.logout();
|
|
505
|
-
return { moved, sourceMailbox, targetMailbox, filters };
|
|
909
|
+
if (uids.length === 0) return { moved: 0, sourceMailbox, targetMailbox };
|
|
910
|
+
|
|
911
|
+
await ensureMailbox(targetMailbox);
|
|
912
|
+
const result = await safeMoveEmails(uids, sourceMailbox, targetMailbox);
|
|
913
|
+
return { ...result, sourceMailbox, targetMailbox, filters };
|
|
506
914
|
}
|
|
507
915
|
|
|
508
916
|
async function bulkDelete(filters, sourceMailbox = 'INBOX', dryRun = false) {
|
|
@@ -511,19 +919,23 @@ async function bulkDelete(filters, sourceMailbox = 'INBOX', dryRun = false) {
|
|
|
511
919
|
await client.mailboxOpen(sourceMailbox);
|
|
512
920
|
const query = buildQuery(filters);
|
|
513
921
|
const uids = (await client.search(query, { uid: true })) ?? [];
|
|
922
|
+
await client.logout();
|
|
923
|
+
|
|
514
924
|
if (dryRun) {
|
|
515
|
-
await client.logout();
|
|
516
925
|
return { dryRun: true, wouldDelete: uids.length, sourceMailbox, filters };
|
|
517
926
|
}
|
|
518
|
-
if (uids.length === 0)
|
|
519
|
-
|
|
927
|
+
if (uids.length === 0) return { deleted: 0, sourceMailbox };
|
|
928
|
+
|
|
520
929
|
let deleted = 0;
|
|
521
|
-
for (let i = 0; i < uids.length; i +=
|
|
522
|
-
const chunk = uids.slice(i, i +
|
|
523
|
-
|
|
930
|
+
for (let i = 0; i < uids.length; i += CHUNK_SIZE) {
|
|
931
|
+
const chunk = uids.slice(i, i + CHUNK_SIZE);
|
|
932
|
+
const deleteClient = createClient();
|
|
933
|
+
await deleteClient.connect();
|
|
934
|
+
await deleteClient.mailboxOpen(sourceMailbox);
|
|
935
|
+
await deleteClient.messageDelete(chunk, { uid: true });
|
|
936
|
+
await deleteClient.logout();
|
|
524
937
|
deleted += chunk.length;
|
|
525
938
|
}
|
|
526
|
-
await client.logout();
|
|
527
939
|
return { deleted, sourceMailbox, filters };
|
|
528
940
|
}
|
|
529
941
|
|
|
@@ -537,9 +949,35 @@ async function countEmails(filters, mailbox = 'INBOX') {
|
|
|
537
949
|
return { count: uids.length, mailbox, filters };
|
|
538
950
|
}
|
|
539
951
|
|
|
952
|
+
// āāā Session Log āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
953
|
+
|
|
954
|
+
function logRead() {
|
|
955
|
+
if (!existsSync(LOG_FILE)) return { steps: [], startedAt: null };
|
|
956
|
+
try {
|
|
957
|
+
return JSON.parse(readFileSync(LOG_FILE, 'utf8'));
|
|
958
|
+
} catch {
|
|
959
|
+
return { steps: [], startedAt: null };
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
function logWrite(step) {
|
|
964
|
+
const log = logRead();
|
|
965
|
+
if (!log.startedAt) log.startedAt = new Date().toISOString();
|
|
966
|
+
log.steps.push({ time: new Date().toISOString(), step });
|
|
967
|
+
writeFileSync(LOG_FILE, JSON.stringify(log, null, 2));
|
|
968
|
+
return log;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
function logClear() {
|
|
972
|
+
writeFileSync(LOG_FILE, JSON.stringify({ steps: [], startedAt: null }, null, 2));
|
|
973
|
+
return { cleared: true };
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// āāā MCP Server āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
977
|
+
|
|
540
978
|
async function main() {
|
|
541
979
|
const server = new Server(
|
|
542
|
-
{ name: 'icloud-mail', version: '1.
|
|
980
|
+
{ name: 'icloud-mail', version: '1.4.1' },
|
|
543
981
|
{ capabilities: { tools: {} } }
|
|
544
982
|
);
|
|
545
983
|
|
|
@@ -568,9 +1006,7 @@ async function main() {
|
|
|
568
1006
|
description: 'Get total, unread, and recent email counts for any specific mailbox/folder',
|
|
569
1007
|
inputSchema: {
|
|
570
1008
|
type: 'object',
|
|
571
|
-
properties: {
|
|
572
|
-
mailbox: { type: 'string', description: 'Mailbox path to summarize (e.g. Newsletters, Archive)' }
|
|
573
|
-
},
|
|
1009
|
+
properties: { mailbox: { type: 'string', description: 'Mailbox path to summarize (e.g. Newsletters, Archive)' } },
|
|
574
1010
|
required: ['mailbox']
|
|
575
1011
|
}
|
|
576
1012
|
},
|
|
@@ -663,7 +1099,7 @@ async function main() {
|
|
|
663
1099
|
},
|
|
664
1100
|
{
|
|
665
1101
|
name: 'bulk_move',
|
|
666
|
-
description: 'Move emails matching any combination of filters from one mailbox to another.
|
|
1102
|
+
description: 'Move emails matching any combination of filters from one mailbox to another. Uses safe copy-verify-delete with fingerprint verification and a persistent manifest. Use dryRun: true to preview without making changes.',
|
|
667
1103
|
inputSchema: {
|
|
668
1104
|
type: 'object',
|
|
669
1105
|
properties: {
|
|
@@ -846,9 +1282,7 @@ async function main() {
|
|
|
846
1282
|
description: 'Create a new mailbox/folder',
|
|
847
1283
|
inputSchema: {
|
|
848
1284
|
type: 'object',
|
|
849
|
-
properties: {
|
|
850
|
-
name: { type: 'string', description: 'Name of the new mailbox' }
|
|
851
|
-
},
|
|
1285
|
+
properties: { name: { type: 'string', description: 'Name of the new mailbox' } },
|
|
852
1286
|
required: ['name']
|
|
853
1287
|
}
|
|
854
1288
|
},
|
|
@@ -869,9 +1303,7 @@ async function main() {
|
|
|
869
1303
|
description: 'Delete a mailbox/folder. The folder must be empty first.',
|
|
870
1304
|
inputSchema: {
|
|
871
1305
|
type: 'object',
|
|
872
|
-
properties: {
|
|
873
|
-
name: { type: 'string', description: 'Mailbox path to delete' }
|
|
874
|
-
},
|
|
1306
|
+
properties: { name: { type: 'string', description: 'Mailbox path to delete' } },
|
|
875
1307
|
required: ['name']
|
|
876
1308
|
}
|
|
877
1309
|
},
|
|
@@ -879,6 +1311,37 @@ async function main() {
|
|
|
879
1311
|
name: 'empty_trash',
|
|
880
1312
|
description: 'Permanently delete all emails in Deleted Messages',
|
|
881
1313
|
inputSchema: { type: 'object', properties: {} }
|
|
1314
|
+
},
|
|
1315
|
+
{
|
|
1316
|
+
name: 'get_move_status',
|
|
1317
|
+
description: 'Check the status of the current or most recent bulk move operation. Shows progress, chunk statuses, and any failures. Call this to monitor a long-running move or inspect a failed one.',
|
|
1318
|
+
inputSchema: { type: 'object', properties: {} }
|
|
1319
|
+
},
|
|
1320
|
+
{
|
|
1321
|
+
name: 'abandon_move',
|
|
1322
|
+
description: 'Abandon an in-progress move operation so a new one can start. Only use if you are certain the operation should not be resumed. Emails already moved will not be returned to source.',
|
|
1323
|
+
inputSchema: { type: 'object', properties: {} }
|
|
1324
|
+
},
|
|
1325
|
+
{
|
|
1326
|
+
name: 'log_write',
|
|
1327
|
+
description: 'Write a step to the session log. Use this to record your plan before starting, and after each completed step. Helps maintain progress across long operations.',
|
|
1328
|
+
inputSchema: {
|
|
1329
|
+
type: 'object',
|
|
1330
|
+
properties: {
|
|
1331
|
+
step: { type: 'string', description: 'Description of what you are doing or just completed' }
|
|
1332
|
+
},
|
|
1333
|
+
required: ['step']
|
|
1334
|
+
}
|
|
1335
|
+
},
|
|
1336
|
+
{
|
|
1337
|
+
name: 'log_read',
|
|
1338
|
+
description: 'Read the current session log to see what has been done so far.',
|
|
1339
|
+
inputSchema: { type: 'object', properties: {} }
|
|
1340
|
+
},
|
|
1341
|
+
{
|
|
1342
|
+
name: 'log_clear',
|
|
1343
|
+
description: 'Clear the session log and start fresh. Use this at the start of a new task.',
|
|
1344
|
+
inputSchema: { type: 'object', properties: {} }
|
|
882
1345
|
}
|
|
883
1346
|
]
|
|
884
1347
|
}));
|
|
@@ -948,6 +1411,16 @@ async function main() {
|
|
|
948
1411
|
result = await deleteMailbox(args.name);
|
|
949
1412
|
} else if (name === 'empty_trash') {
|
|
950
1413
|
result = await emptyTrash();
|
|
1414
|
+
} else if (name === 'get_move_status') {
|
|
1415
|
+
result = getMoveStatus();
|
|
1416
|
+
} else if (name === 'abandon_move') {
|
|
1417
|
+
result = abandonMove();
|
|
1418
|
+
} else if (name === 'log_write') {
|
|
1419
|
+
result = logWrite(args.step);
|
|
1420
|
+
} else if (name === 'log_read') {
|
|
1421
|
+
result = logRead();
|
|
1422
|
+
} else if (name === 'log_clear') {
|
|
1423
|
+
result = logClear();
|
|
951
1424
|
} else {
|
|
952
1425
|
throw new Error(`Unknown tool: ${name}`);
|
|
953
1426
|
}
|
package/package.json
CHANGED
package/test.js
CHANGED
|
@@ -14,7 +14,15 @@ if (!IMAP_USER || !IMAP_PASSWORD) {
|
|
|
14
14
|
|
|
15
15
|
const projectDir = fileURLToPath(new URL('.', import.meta.url));
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
// Timeout in ms per tool category
|
|
18
|
+
const TIMEOUTS = {
|
|
19
|
+
mailbox_mgmt: 60000, // create/rename/delete mailbox ā can be slow on iCloud
|
|
20
|
+
default: 300000 // everything else ā allow up to 5 min for large operations
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const MAILBOX_MGMT_TOOLS = new Set(['create_mailbox', 'rename_mailbox', 'delete_mailbox']);
|
|
24
|
+
|
|
25
|
+
function callToolRaw(name, args = {}, timeout = TIMEOUTS.default) {
|
|
18
26
|
const messages = [
|
|
19
27
|
{ jsonrpc: '2.0', id: 0, method: 'initialize', params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'test', version: '1.0.0' } } },
|
|
20
28
|
{ jsonrpc: '2.0', method: 'notifications/initialized' },
|
|
@@ -32,7 +40,7 @@ function callTool(name, args = {}) {
|
|
|
32
40
|
{
|
|
33
41
|
cwd: projectDir,
|
|
34
42
|
encoding: 'utf8',
|
|
35
|
-
timeout
|
|
43
|
+
timeout,
|
|
36
44
|
env: { ...process.env, IMAP_USER, IMAP_PASSWORD }
|
|
37
45
|
}
|
|
38
46
|
);
|
|
@@ -53,6 +61,26 @@ function callTool(name, args = {}) {
|
|
|
53
61
|
}
|
|
54
62
|
}
|
|
55
63
|
|
|
64
|
+
function callTool(name, args = {}) {
|
|
65
|
+
const timeout = MAILBOX_MGMT_TOOLS.has(name) ? TIMEOUTS.mailbox_mgmt : TIMEOUTS.default;
|
|
66
|
+
try {
|
|
67
|
+
return callToolRaw(name, args, timeout);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
// Only retry on spawn-level transient errors (ECONNRESET, ETIMEDOUT on the
|
|
70
|
+
// child process itself) ā NOT on Tool errors, which are application-level
|
|
71
|
+
// failures that should propagate immediately.
|
|
72
|
+
const isSpawnTransient = (
|
|
73
|
+
err.message.includes('ECONNRESET') ||
|
|
74
|
+
err.message.includes('ETIMEDOUT')
|
|
75
|
+
) && !err.message.startsWith('Tool error:');
|
|
76
|
+
if (isSpawnTransient) {
|
|
77
|
+
console.log(`\n ā ļø transient spawn error (${err.message.split(':')[0]}), retrying...`);
|
|
78
|
+
return callToolRaw(name, args, timeout);
|
|
79
|
+
}
|
|
80
|
+
throw err;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
56
84
|
let passed = 0;
|
|
57
85
|
let failed = 0;
|
|
58
86
|
|
|
@@ -72,7 +100,40 @@ function assert(condition, message) {
|
|
|
72
100
|
if (!condition) throw new Error(message);
|
|
73
101
|
}
|
|
74
102
|
|
|
75
|
-
console.log('\nš§Ŗ iCloud MCP Server Tests\n');
|
|
103
|
+
console.log('\nš§Ŗ iCloud MCP Server Tests v1.6.0\n');
|
|
104
|
+
|
|
105
|
+
// āāā Pre-flight cleanup āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
106
|
+
// Abandon any leftover in-progress manifest from a previous crashed run,
|
|
107
|
+
// then restore any emails stranded in the test folder.
|
|
108
|
+
console.log('š§¹ Pre-flight cleanup');
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
const status = callTool('get_move_status');
|
|
112
|
+
if (status.current && status.current.status === 'in_progress') {
|
|
113
|
+
console.log(` ā ļø found in-progress manifest (${status.current.operationId}) ā abandoning before cleanup`);
|
|
114
|
+
callTool('abandon_move');
|
|
115
|
+
}
|
|
116
|
+
} catch (err) {
|
|
117
|
+
console.log(` ā ļø could not check manifest: ${err.message}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
try { callTool('delete_mailbox', { name: 'mcp-test-folder-renamed' }); } catch {}
|
|
121
|
+
try { callTool('delete_mailbox', { name: 'mcp-test-folder' }); } catch {}
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
const strandedInTest = callTool('count_emails', { mailbox: 'test' });
|
|
125
|
+
if (strandedInTest.count > 0) {
|
|
126
|
+
console.log(` ā ļø found ${strandedInTest.count} stranded emails in test folder ā restoring to newsletters first`);
|
|
127
|
+
const restoreResult = callTool('bulk_move', { sourceMailbox: 'test', targetMailbox: 'newsletters' });
|
|
128
|
+
console.log(` ā ļø restore status: ${restoreResult.status}, moved: ${restoreResult.moved}`);
|
|
129
|
+
} else {
|
|
130
|
+
console.log(' ā test folder is clean');
|
|
131
|
+
}
|
|
132
|
+
} catch (err) {
|
|
133
|
+
console.log(` ā ļø stranded email cleanup failed: ${err.message} ā proceeding anyway`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
console.log('');
|
|
76
137
|
|
|
77
138
|
// āāā Mailbox & Summary āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
78
139
|
console.log('š¬ Mailbox & Summary');
|
|
@@ -270,6 +331,7 @@ test('bulk_move (dryRun)', () => {
|
|
|
270
331
|
const result = callTool('bulk_move', { domain: topDomain, targetMailbox: 'Archive', dryRun: true });
|
|
271
332
|
assert(result.dryRun === true, 'dryRun should be true');
|
|
272
333
|
assert(typeof result.wouldMove === 'number', 'wouldMove should be a number');
|
|
334
|
+
assert(result.targetMailbox === 'Archive', 'targetMailbox should be Archive');
|
|
273
335
|
console.log(`\n ā would move ${result.wouldMove} emails from @${topDomain}`);
|
|
274
336
|
});
|
|
275
337
|
|
|
@@ -277,6 +339,7 @@ test('bulk_delete (dryRun)', () => {
|
|
|
277
339
|
const result = callTool('bulk_delete', { before: '2015-01-01', dryRun: true });
|
|
278
340
|
assert(result.dryRun === true, 'dryRun should be true');
|
|
279
341
|
assert(typeof result.wouldDelete === 'number', 'wouldDelete should be a number');
|
|
342
|
+
assert(typeof result.sourceMailbox === 'string', 'sourceMailbox should be a string');
|
|
280
343
|
console.log(`\n ā would delete ${result.wouldDelete} emails before 2015`);
|
|
281
344
|
});
|
|
282
345
|
|
|
@@ -314,48 +377,147 @@ test('create_mailbox', () => {
|
|
|
314
377
|
console.log(`\n ā created: ${result.created}`);
|
|
315
378
|
});
|
|
316
379
|
|
|
317
|
-
test('rename_mailbox', () => {
|
|
318
|
-
const
|
|
319
|
-
assert(
|
|
320
|
-
assert(
|
|
321
|
-
console.log(`\n ā renamed: ${
|
|
380
|
+
test('rename_mailbox + delete_mailbox', () => {
|
|
381
|
+
const renamed = callTool('rename_mailbox', { oldName: 'mcp-test-folder', newName: 'mcp-test-folder-renamed' });
|
|
382
|
+
assert(renamed.renamed.from === 'mcp-test-folder', 'from should match old name');
|
|
383
|
+
assert(renamed.renamed.to === 'mcp-test-folder-renamed', 'to should match new name');
|
|
384
|
+
console.log(`\n ā renamed: ${renamed.renamed.from} ā ${renamed.renamed.to}`);
|
|
385
|
+
|
|
386
|
+
const deleted = callTool('delete_mailbox', { name: 'mcp-test-folder-renamed' });
|
|
387
|
+
assert(deleted.deleted === 'mcp-test-folder-renamed', 'should confirm deletion');
|
|
388
|
+
console.log(`\n ā deleted: ${deleted.deleted}`);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// āāā Move Manifest āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
392
|
+
console.log('\nšŗļø Move Manifest');
|
|
393
|
+
|
|
394
|
+
test('get_move_status (no operation)', () => {
|
|
395
|
+
// Abandon any leftover operation so we start clean
|
|
396
|
+
try { callTool('abandon_move'); } catch {}
|
|
397
|
+
const result = callTool('get_move_status');
|
|
398
|
+
assert(result.status === 'no_operation', `expected no_operation, got ${result.status}`);
|
|
399
|
+
assert(Array.isArray(result.history), 'history should be an array');
|
|
400
|
+
console.log(`\n ā status: ${result.status}, history: ${result.history.length} entries`);
|
|
322
401
|
});
|
|
323
402
|
|
|
324
|
-
test('
|
|
325
|
-
const result = callTool('
|
|
326
|
-
assert(result.
|
|
327
|
-
|
|
403
|
+
test('abandon_move (nothing to abandon)', () => {
|
|
404
|
+
const result = callTool('abandon_move');
|
|
405
|
+
assert(result.abandoned === false, 'should return abandoned: false when nothing in progress');
|
|
406
|
+
assert(typeof result.message === 'string', 'should include a message');
|
|
407
|
+
console.log(`\n ā ${result.message}`);
|
|
328
408
|
});
|
|
329
409
|
|
|
330
|
-
// āāā
|
|
331
|
-
console.log('\n
|
|
410
|
+
// āāā Safe Move (live) āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
411
|
+
console.log('\nš Safe Move Test (live ā newsletters ā test)');
|
|
332
412
|
|
|
333
|
-
test('bulk_move newsletters ā test (
|
|
413
|
+
test('bulk_move newsletters ā test (fingerprint verified)', () => {
|
|
334
414
|
const beforeSource = callTool('count_emails', { mailbox: 'newsletters' });
|
|
335
415
|
assert(beforeSource.count > 0, 'newsletters should have emails');
|
|
336
416
|
console.log(`\n ā newsletters before: ${beforeSource.count}`);
|
|
337
417
|
|
|
338
418
|
const moveResult = callTool('bulk_move', { sourceMailbox: 'newsletters', targetMailbox: 'test' });
|
|
339
|
-
console.log(`\n ā moved: ${moveResult.moved}`);
|
|
340
|
-
assert(moveResult.
|
|
419
|
+
console.log(`\n ā status: ${moveResult.status}, moved: ${moveResult.moved} of ${moveResult.total}`);
|
|
420
|
+
assert(moveResult.status === 'complete', `expected complete, got ${moveResult.status}: ${moveResult.message || ''}`);
|
|
421
|
+
assert(moveResult.moved === beforeSource.count, `moved ${moveResult.moved} but expected ${beforeSource.count}`);
|
|
422
|
+
assert(moveResult.total === beforeSource.count, `total ${moveResult.total} should match source count ${beforeSource.count}`);
|
|
423
|
+
|
|
424
|
+
const afterSource = callTool('count_emails', { mailbox: 'newsletters' });
|
|
425
|
+
console.log(`\n ā newsletters after (should be 0): ${afterSource.count}`);
|
|
426
|
+
assert(afterSource.count === 0, `newsletters should be empty, has ${afterSource.count}`);
|
|
341
427
|
|
|
342
428
|
const afterTarget = callTool('count_emails', { mailbox: 'test' });
|
|
343
429
|
console.log(`\n ā test folder after: ${afterTarget.count}`);
|
|
344
|
-
assert(afterTarget.count === beforeSource.count, `test
|
|
430
|
+
assert(afterTarget.count === beforeSource.count, `test should have ${beforeSource.count}, has ${afterTarget.count}`);
|
|
345
431
|
});
|
|
346
432
|
|
|
347
|
-
test('
|
|
433
|
+
test('get_move_status (after completed move)', () => {
|
|
434
|
+
const result = callTool('get_move_status');
|
|
435
|
+
// Current should be null (archived after completion), history should have the move
|
|
436
|
+
assert(result.status === 'no_operation', `expected no_operation after completed move, got ${result.status}`);
|
|
437
|
+
assert(result.history.length > 0, 'history should have at least one entry');
|
|
438
|
+
const last = result.history[0];
|
|
439
|
+
assert(last.status === 'complete', `last operation should be complete, got ${last.status}`);
|
|
440
|
+
assert(last.source === 'newsletters', `source should be newsletters, got ${last.source}`);
|
|
441
|
+
assert(last.target === 'test', `target should be test, got ${last.target}`);
|
|
442
|
+
console.log(`\n ā last op: ${last.status}, ${last.moved}/${last.total} moved from ${last.source} ā ${last.target}`);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
test('bulk_move test ā newsletters (restore, fingerprint verified)', () => {
|
|
348
446
|
const beforeSource = callTool('count_emails', { mailbox: 'test' });
|
|
349
447
|
assert(beforeSource.count > 0, 'test folder should have emails to restore');
|
|
350
|
-
console.log(`\n ā test
|
|
448
|
+
console.log(`\n ā test before restore: ${beforeSource.count}`);
|
|
351
449
|
|
|
352
450
|
const moveBack = callTool('bulk_move', { sourceMailbox: 'test', targetMailbox: 'newsletters' });
|
|
353
|
-
console.log(`\n ā moved
|
|
354
|
-
assert(moveBack.
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
451
|
+
console.log(`\n ā status: ${moveBack.status}, moved: ${moveBack.moved} of ${moveBack.total}`);
|
|
452
|
+
assert(moveBack.status === 'complete', `expected complete, got ${moveBack.status}: ${moveBack.message || ''}`);
|
|
453
|
+
assert(moveBack.moved === beforeSource.count, `moved ${moveBack.moved} but expected ${beforeSource.count}`);
|
|
454
|
+
|
|
455
|
+
const afterSource = callTool('count_emails', { mailbox: 'test' });
|
|
456
|
+
console.log(`\n ā test after (should be 0): ${afterSource.count}`);
|
|
457
|
+
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
|
+
});
|
|
463
|
+
|
|
464
|
+
test('get_move_status (history has both moves)', () => {
|
|
465
|
+
const result = callTool('get_move_status');
|
|
466
|
+
assert(result.status === 'no_operation', 'no operation should be in progress');
|
|
467
|
+
assert(result.history.length >= 2, `history should have at least 2 entries, has ${result.history.length}`);
|
|
468
|
+
const [restore, forward] = result.history;
|
|
469
|
+
assert(restore.status === 'complete', `restore op should be complete, got ${restore.status}`);
|
|
470
|
+
assert(restore.source === 'test', `restore source should be test, got ${restore.source}`);
|
|
471
|
+
assert(forward.status === 'complete', `forward op should be complete, got ${forward.status}`);
|
|
472
|
+
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})`);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
// āāā Session Log āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
478
|
+
console.log('\nš Session Log');
|
|
479
|
+
|
|
480
|
+
test('log_clear', () => {
|
|
481
|
+
const result = callTool('log_clear');
|
|
482
|
+
assert(result.cleared === true, 'should confirm cleared');
|
|
483
|
+
console.log(`\n ā log cleared`);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test('log_write (plan)', () => {
|
|
487
|
+
const result = callTool('log_write', { step: 'plan: test log functionality' });
|
|
488
|
+
assert(Array.isArray(result.steps), 'steps should be an array');
|
|
489
|
+
assert(result.steps.length === 1, 'should have 1 step');
|
|
490
|
+
assert(typeof result.startedAt === 'string', 'startedAt should be a string');
|
|
491
|
+
assert(result.steps[0].step === 'plan: test log functionality', 'step content should match');
|
|
492
|
+
assert(typeof result.steps[0].time === 'string', 'step should have a timestamp');
|
|
493
|
+
console.log(`\n ā wrote step, log has ${result.steps.length} entry`);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test('log_write (second step)', () => {
|
|
497
|
+
const result = callTool('log_write', { step: 'done: log test complete' });
|
|
498
|
+
assert(Array.isArray(result.steps), 'steps should be an array');
|
|
499
|
+
assert(result.steps.length === 2, 'should have 2 steps');
|
|
500
|
+
assert(result.steps[1].step === 'done: log test complete', 'second step content should match');
|
|
501
|
+
console.log(`\n ā log now has ${result.steps.length} entries`);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test('log_read', () => {
|
|
505
|
+
const result = callTool('log_read');
|
|
506
|
+
assert(Array.isArray(result.steps), 'steps should be an array');
|
|
507
|
+
assert(result.steps.length === 2, 'should have 2 steps');
|
|
508
|
+
assert(result.steps[0].step === 'plan: test log functionality', 'first step should match');
|
|
509
|
+
assert(result.steps[1].step === 'done: log test complete', 'second step should match');
|
|
510
|
+
assert(typeof result.startedAt === 'string', 'startedAt should persist');
|
|
511
|
+
console.log(`\n ā read log: ${result.steps.length} steps, started ${result.startedAt}`);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test('log_clear (cleanup)', () => {
|
|
515
|
+
const result = callTool('log_clear');
|
|
516
|
+
assert(result.cleared === true, 'should confirm cleared');
|
|
517
|
+
const log = callTool('log_read');
|
|
518
|
+
assert(log.steps.length === 0, 'log should be empty after clear');
|
|
519
|
+
assert(log.startedAt === null, 'startedAt should be null after clear');
|
|
520
|
+
console.log(`\n ā log cleared and verified empty`);
|
|
359
521
|
});
|
|
360
522
|
|
|
361
523
|
// āāā Destructive (skipped) āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|