icloud-mcp 1.2.1 ā 1.4.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 +23 -2
- package/index.js +556 -45
- package/package.json +1 -1
- package/test.js +206 -14
package/README.md
CHANGED
|
@@ -13,6 +13,7 @@ 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
|
+
- š Session logging ā Claude tracks progress across long multi-step operations
|
|
16
17
|
|
|
17
18
|
## Prerequisites
|
|
18
19
|
|
|
@@ -70,7 +71,20 @@ Add the following under `mcpServers`, replacing the path with your npm root path
|
|
|
70
71
|
|
|
71
72
|
> **Note:** If your `npm root -g` returned a different path, replace `/opt/homebrew/lib/node_modules` with that path.
|
|
72
73
|
|
|
73
|
-
### 4.
|
|
74
|
+
### 4. Add Custom Instructions (Recommended)
|
|
75
|
+
|
|
76
|
+
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:
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
When using icloud-mail tools:
|
|
80
|
+
1. Before starting any multi-step operation, call log_clear then log_write with your full plan
|
|
81
|
+
2. After every single tool call, call log_write with what you did and the result
|
|
82
|
+
3. After every 3 tool calls, stop and summarize progress to the user and wait for confirmation before continuing
|
|
83
|
+
4. Never assume a bulk operation succeeded ā always verify with count_emails after
|
|
84
|
+
5. If you are ever unsure what you have done so far, call log_read before proceeding
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### 5. Restart Claude Desktop
|
|
74
88
|
|
|
75
89
|
Fully quit Claude Desktop (Cmd+Q) and reopen it. You should now be able to manage your iCloud inbox through Claude.
|
|
76
90
|
|
|
@@ -106,6 +120,9 @@ Fully quit Claude Desktop (Cmd+Q) and reopen it. You should now be able to manag
|
|
|
106
120
|
| `rename_mailbox` | Rename an existing folder |
|
|
107
121
|
| `delete_mailbox` | Delete a folder (must be empty first) |
|
|
108
122
|
| `empty_trash` | Permanently delete all emails in Deleted Messages |
|
|
123
|
+
| `log_write` | Write a step to the session log |
|
|
124
|
+
| `log_read` | Read the session log to see what has been done so far |
|
|
125
|
+
| `log_clear` | Clear the session log and start fresh |
|
|
109
126
|
|
|
110
127
|
## Bulk Move, Delete & Flag Filters
|
|
111
128
|
|
|
@@ -130,6 +147,10 @@ Pass `dryRun: true` to `bulk_move` or `bulk_delete` to preview how many emails w
|
|
|
130
147
|
|
|
131
148
|
> *"How many emails would be deleted if I removed everything from linkedin.com before 2022?"*
|
|
132
149
|
|
|
150
|
+
### Session Log
|
|
151
|
+
|
|
152
|
+
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.
|
|
153
|
+
|
|
133
154
|
## Example Usage
|
|
134
155
|
|
|
135
156
|
Once configured, you can ask Claude things like:
|
|
@@ -153,4 +174,4 @@ Once configured, you can ask Claude things like:
|
|
|
153
174
|
|
|
154
175
|
## License
|
|
155
176
|
|
|
156
|
-
MIT
|
|
177
|
+
MIT
|
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,10 +551,19 @@ 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
|
-
if (uids.length === 0) { await client.logout(); return { deleted: 0 }; }
|
|
169
|
-
await client.messageDelete(uids, { uid: true });
|
|
170
554
|
await client.logout();
|
|
171
|
-
return { deleted:
|
|
555
|
+
if (uids.length === 0) return { deleted: 0 };
|
|
556
|
+
let deleted = 0;
|
|
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();
|
|
564
|
+
deleted += chunk.length;
|
|
565
|
+
}
|
|
566
|
+
return { deleted, sender };
|
|
172
567
|
}
|
|
173
568
|
|
|
174
569
|
async function bulkMoveBySender(sender, targetMailbox, sourceMailbox = 'INBOX') {
|
|
@@ -176,10 +571,11 @@ async function bulkMoveBySender(sender, targetMailbox, sourceMailbox = 'INBOX')
|
|
|
176
571
|
await client.connect();
|
|
177
572
|
await client.mailboxOpen(sourceMailbox);
|
|
178
573
|
const uids = (await client.search({ from: sender }, { uid: true })) ?? [];
|
|
179
|
-
if (uids.length === 0) { await client.logout(); return { moved: 0 }; }
|
|
180
|
-
await client.messageMove(uids, targetMailbox, { uid: true });
|
|
181
574
|
await client.logout();
|
|
182
|
-
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 };
|
|
183
579
|
}
|
|
184
580
|
|
|
185
581
|
async function bulkDeleteBySubject(subject, mailbox = 'INBOX') {
|
|
@@ -187,10 +583,19 @@ async function bulkDeleteBySubject(subject, mailbox = 'INBOX') {
|
|
|
187
583
|
await client.connect();
|
|
188
584
|
await client.mailboxOpen(mailbox);
|
|
189
585
|
const uids = (await client.search({ subject }, { uid: true })) ?? [];
|
|
190
|
-
if (uids.length === 0) { await client.logout(); return { deleted: 0 }; }
|
|
191
|
-
await client.messageDelete(uids, { uid: true });
|
|
192
586
|
await client.logout();
|
|
193
|
-
return { deleted:
|
|
587
|
+
if (uids.length === 0) return { deleted: 0 };
|
|
588
|
+
let deleted = 0;
|
|
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();
|
|
596
|
+
deleted += chunk.length;
|
|
597
|
+
}
|
|
598
|
+
return { deleted, subject };
|
|
194
599
|
}
|
|
195
600
|
|
|
196
601
|
async function deleteOlderThan(days, mailbox = 'INBOX') {
|
|
@@ -200,10 +605,19 @@ async function deleteOlderThan(days, mailbox = 'INBOX') {
|
|
|
200
605
|
const date = new Date();
|
|
201
606
|
date.setDate(date.getDate() - days);
|
|
202
607
|
const uids = (await client.search({ before: date }, { uid: true })) ?? [];
|
|
203
|
-
if (uids.length === 0) { await client.logout(); return { deleted: 0 }; }
|
|
204
|
-
await client.messageDelete(uids, { uid: true });
|
|
205
608
|
await client.logout();
|
|
206
|
-
return { deleted:
|
|
609
|
+
if (uids.length === 0) return { deleted: 0 };
|
|
610
|
+
let deleted = 0;
|
|
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();
|
|
618
|
+
deleted += chunk.length;
|
|
619
|
+
}
|
|
620
|
+
return { deleted, olderThan: date.toISOString() };
|
|
207
621
|
}
|
|
208
622
|
|
|
209
623
|
async function getEmailsByDateRange(startDate, endDate, mailbox = 'INBOX', limit = 10) {
|
|
@@ -277,9 +691,14 @@ async function emptyTrash() {
|
|
|
277
691
|
await client.mailboxOpen('Deleted Messages');
|
|
278
692
|
const uids = (await client.search({ all: true }, { uid: true })) ?? [];
|
|
279
693
|
if (uids.length === 0) { await client.logout(); return { deleted: 0 }; }
|
|
280
|
-
|
|
694
|
+
let deleted = 0;
|
|
695
|
+
for (let i = 0; i < uids.length; i += CHUNK_SIZE) {
|
|
696
|
+
const chunk = uids.slice(i, i + CHUNK_SIZE);
|
|
697
|
+
await client.messageDelete(chunk, { uid: true });
|
|
698
|
+
deleted += chunk.length;
|
|
699
|
+
}
|
|
281
700
|
await client.logout();
|
|
282
|
-
return { deleted
|
|
701
|
+
return { deleted };
|
|
283
702
|
}
|
|
284
703
|
|
|
285
704
|
async function createMailbox(name) {
|
|
@@ -293,16 +712,32 @@ async function createMailbox(name) {
|
|
|
293
712
|
async function renameMailbox(oldName, newName) {
|
|
294
713
|
const client = createClient();
|
|
295
714
|
await client.connect();
|
|
296
|
-
|
|
297
|
-
|
|
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
|
+
}
|
|
298
725
|
return { renamed: { from: oldName, to: newName } };
|
|
299
726
|
}
|
|
300
727
|
|
|
301
728
|
async function deleteMailbox(name) {
|
|
302
729
|
const client = createClient();
|
|
303
730
|
await client.connect();
|
|
304
|
-
|
|
305
|
-
|
|
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
|
+
}
|
|
306
741
|
return { deleted: name };
|
|
307
742
|
}
|
|
308
743
|
|
|
@@ -400,10 +835,7 @@ async function searchEmails(query, mailbox = 'INBOX', limit = 10, filters = {})
|
|
|
400
835
|
await client.connect();
|
|
401
836
|
await client.mailboxOpen(mailbox);
|
|
402
837
|
|
|
403
|
-
// Build base text search
|
|
404
838
|
const textQuery = { or: [{ subject: query }, { from: query }, { body: query }] };
|
|
405
|
-
|
|
406
|
-
// Merge with additional filters if provided
|
|
407
839
|
const extraQuery = buildQuery(filters);
|
|
408
840
|
const finalQuery = Object.keys(extraQuery).length > 0 && !extraQuery.all
|
|
409
841
|
? { ...textQuery, ...extraQuery }
|
|
@@ -438,7 +870,6 @@ async function moveEmail(uid, targetMailbox, sourceMailbox = 'INBOX') {
|
|
|
438
870
|
return true;
|
|
439
871
|
}
|
|
440
872
|
|
|
441
|
-
// Build an IMAP search query from a filters object
|
|
442
873
|
function buildQuery(filters) {
|
|
443
874
|
const query = {};
|
|
444
875
|
if (filters.sender) query.from = filters.sender;
|
|
@@ -457,20 +888,29 @@ function buildQuery(filters) {
|
|
|
457
888
|
return query;
|
|
458
889
|
}
|
|
459
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
|
+
|
|
460
898
|
async function bulkMove(filters, targetMailbox, sourceMailbox = 'INBOX', dryRun = false) {
|
|
461
899
|
const client = createClient();
|
|
462
900
|
await client.connect();
|
|
463
901
|
await client.mailboxOpen(sourceMailbox);
|
|
464
902
|
const query = buildQuery(filters);
|
|
465
903
|
const uids = (await client.search(query, { uid: true })) ?? [];
|
|
904
|
+
await client.logout();
|
|
905
|
+
|
|
466
906
|
if (dryRun) {
|
|
467
|
-
await client.logout();
|
|
468
907
|
return { dryRun: true, wouldMove: uids.length, sourceMailbox, targetMailbox, filters };
|
|
469
908
|
}
|
|
470
|
-
if (uids.length === 0)
|
|
471
|
-
|
|
472
|
-
await
|
|
473
|
-
|
|
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 };
|
|
474
914
|
}
|
|
475
915
|
|
|
476
916
|
async function bulkDelete(filters, sourceMailbox = 'INBOX', dryRun = false) {
|
|
@@ -479,14 +919,24 @@ async function bulkDelete(filters, sourceMailbox = 'INBOX', dryRun = false) {
|
|
|
479
919
|
await client.mailboxOpen(sourceMailbox);
|
|
480
920
|
const query = buildQuery(filters);
|
|
481
921
|
const uids = (await client.search(query, { uid: true })) ?? [];
|
|
922
|
+
await client.logout();
|
|
923
|
+
|
|
482
924
|
if (dryRun) {
|
|
483
|
-
await client.logout();
|
|
484
925
|
return { dryRun: true, wouldDelete: uids.length, sourceMailbox, filters };
|
|
485
926
|
}
|
|
486
|
-
if (uids.length === 0)
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
927
|
+
if (uids.length === 0) return { deleted: 0, sourceMailbox };
|
|
928
|
+
|
|
929
|
+
let deleted = 0;
|
|
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();
|
|
937
|
+
deleted += chunk.length;
|
|
938
|
+
}
|
|
939
|
+
return { deleted, sourceMailbox, filters };
|
|
490
940
|
}
|
|
491
941
|
|
|
492
942
|
async function countEmails(filters, mailbox = 'INBOX') {
|
|
@@ -499,9 +949,35 @@ async function countEmails(filters, mailbox = 'INBOX') {
|
|
|
499
949
|
return { count: uids.length, mailbox, filters };
|
|
500
950
|
}
|
|
501
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
|
+
|
|
502
978
|
async function main() {
|
|
503
979
|
const server = new Server(
|
|
504
|
-
{ name: 'icloud-mail', version: '1.
|
|
980
|
+
{ name: 'icloud-mail', version: '1.6.0' },
|
|
505
981
|
{ capabilities: { tools: {} } }
|
|
506
982
|
);
|
|
507
983
|
|
|
@@ -530,9 +1006,7 @@ async function main() {
|
|
|
530
1006
|
description: 'Get total, unread, and recent email counts for any specific mailbox/folder',
|
|
531
1007
|
inputSchema: {
|
|
532
1008
|
type: 'object',
|
|
533
|
-
properties: {
|
|
534
|
-
mailbox: { type: 'string', description: 'Mailbox path to summarize (e.g. Newsletters, Archive)' }
|
|
535
|
-
},
|
|
1009
|
+
properties: { mailbox: { type: 'string', description: 'Mailbox path to summarize (e.g. Newsletters, Archive)' } },
|
|
536
1010
|
required: ['mailbox']
|
|
537
1011
|
}
|
|
538
1012
|
},
|
|
@@ -625,7 +1099,7 @@ async function main() {
|
|
|
625
1099
|
},
|
|
626
1100
|
{
|
|
627
1101
|
name: 'bulk_move',
|
|
628
|
-
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.',
|
|
629
1103
|
inputSchema: {
|
|
630
1104
|
type: 'object',
|
|
631
1105
|
properties: {
|
|
@@ -639,7 +1113,7 @@ async function main() {
|
|
|
639
1113
|
},
|
|
640
1114
|
{
|
|
641
1115
|
name: 'bulk_delete',
|
|
642
|
-
description: 'Delete emails matching any combination of filters.
|
|
1116
|
+
description: 'Delete emails matching any combination of filters. Processes in chunks of 250 for reliability. Use dryRun: true to preview without making changes.',
|
|
643
1117
|
inputSchema: {
|
|
644
1118
|
type: 'object',
|
|
645
1119
|
properties: {
|
|
@@ -808,9 +1282,7 @@ async function main() {
|
|
|
808
1282
|
description: 'Create a new mailbox/folder',
|
|
809
1283
|
inputSchema: {
|
|
810
1284
|
type: 'object',
|
|
811
|
-
properties: {
|
|
812
|
-
name: { type: 'string', description: 'Name of the new mailbox' }
|
|
813
|
-
},
|
|
1285
|
+
properties: { name: { type: 'string', description: 'Name of the new mailbox' } },
|
|
814
1286
|
required: ['name']
|
|
815
1287
|
}
|
|
816
1288
|
},
|
|
@@ -831,9 +1303,7 @@ async function main() {
|
|
|
831
1303
|
description: 'Delete a mailbox/folder. The folder must be empty first.',
|
|
832
1304
|
inputSchema: {
|
|
833
1305
|
type: 'object',
|
|
834
|
-
properties: {
|
|
835
|
-
name: { type: 'string', description: 'Mailbox path to delete' }
|
|
836
|
-
},
|
|
1306
|
+
properties: { name: { type: 'string', description: 'Mailbox path to delete' } },
|
|
837
1307
|
required: ['name']
|
|
838
1308
|
}
|
|
839
1309
|
},
|
|
@@ -841,6 +1311,37 @@ async function main() {
|
|
|
841
1311
|
name: 'empty_trash',
|
|
842
1312
|
description: 'Permanently delete all emails in Deleted Messages',
|
|
843
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: {} }
|
|
844
1345
|
}
|
|
845
1346
|
]
|
|
846
1347
|
}));
|
|
@@ -910,6 +1411,16 @@ async function main() {
|
|
|
910
1411
|
result = await deleteMailbox(args.name);
|
|
911
1412
|
} else if (name === 'empty_trash') {
|
|
912
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();
|
|
913
1424
|
} else {
|
|
914
1425
|
throw new Error(`Unknown tool: ${name}`);
|
|
915
1426
|
}
|
|
@@ -937,4 +1448,4 @@ process.on('unhandledRejection', (reason) => {
|
|
|
937
1448
|
main().catch((err) => {
|
|
938
1449
|
process.stderr.write(`Fatal error: ${err.message}\n${err.stack}\n`);
|
|
939
1450
|
process.exit(1);
|
|
940
|
-
});
|
|
1451
|
+
});
|
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,22 +377,151 @@ 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`);
|
|
401
|
+
});
|
|
402
|
+
|
|
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}`);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// āāā Safe Move (live) āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
411
|
+
console.log('\nš Safe Move Test (live ā newsletters ā test)');
|
|
412
|
+
|
|
413
|
+
test('bulk_move newsletters ā test (fingerprint verified)', () => {
|
|
414
|
+
const beforeSource = callTool('count_emails', { mailbox: 'newsletters' });
|
|
415
|
+
assert(beforeSource.count > 0, 'newsletters should have emails');
|
|
416
|
+
console.log(`\n ā newsletters before: ${beforeSource.count}`);
|
|
417
|
+
|
|
418
|
+
const moveResult = callTool('bulk_move', { sourceMailbox: 'newsletters', targetMailbox: 'test' });
|
|
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}`);
|
|
427
|
+
|
|
428
|
+
const afterTarget = callTool('count_emails', { mailbox: 'test' });
|
|
429
|
+
console.log(`\n ā test folder after: ${afterTarget.count}`);
|
|
430
|
+
assert(afterTarget.count === beforeSource.count, `test should have ${beforeSource.count}, has ${afterTarget.count}`);
|
|
431
|
+
});
|
|
432
|
+
|
|
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)', () => {
|
|
446
|
+
const beforeSource = callTool('count_emails', { mailbox: 'test' });
|
|
447
|
+
assert(beforeSource.count > 0, 'test folder should have emails to restore');
|
|
448
|
+
console.log(`\n ā test before restore: ${beforeSource.count}`);
|
|
449
|
+
|
|
450
|
+
const moveBack = callTool('bulk_move', { sourceMailbox: 'test', targetMailbox: 'newsletters' });
|
|
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}`);
|
|
322
512
|
});
|
|
323
513
|
|
|
324
|
-
test('
|
|
325
|
-
const result = callTool('
|
|
326
|
-
assert(result.
|
|
327
|
-
|
|
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`);
|
|
328
521
|
});
|
|
329
522
|
|
|
330
523
|
// āāā Destructive (skipped) āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
331
524
|
console.log('\nā ļø Destructive Tests (skipped by default)');
|
|
332
|
-
console.log(' Skipping: bulk_move (live)');
|
|
333
525
|
console.log(' Skipping: bulk_delete (live)');
|
|
334
526
|
console.log(' Skipping: bulk_mark_read (live)');
|
|
335
527
|
console.log(' Skipping: bulk_mark_unread (live)');
|
|
@@ -346,4 +538,4 @@ console.log(`\nā
Passed: ${passed}`);
|
|
|
346
538
|
console.log(`ā Failed: ${failed}`);
|
|
347
539
|
console.log(`š Total: ${passed + failed}\n`);
|
|
348
540
|
|
|
349
|
-
if (failed > 0) process.exit(1);
|
|
541
|
+
if (failed > 0) process.exit(1);
|