icloud-mcp 2.0.0 → 2.2.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/.claude/settings.local.json +26 -0
- package/.mcp.json.example +12 -0
- package/README.md +68 -8
- package/index.js +191 -1963
- package/lib/imap.js +1944 -0
- package/lib/mime.js +134 -0
- package/lib/session.js +28 -0
- package/lib/smtp.js +220 -0
- package/package.json +3 -2
package/lib/imap.js
ADDED
|
@@ -0,0 +1,1944 @@
|
|
|
1
|
+
import { ImapFlow } from 'imapflow';
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import {
|
|
6
|
+
decodeTransferEncoding, decodeCharset, stripHtml, extractRawHeader,
|
|
7
|
+
findTextPart, findAttachments, estimateEmailSize, stripSubjectPrefixes
|
|
8
|
+
} from './mime.js';
|
|
9
|
+
|
|
10
|
+
const IMAP_USER = process.env.IMAP_USER;
|
|
11
|
+
const IMAP_PASSWORD = process.env.IMAP_PASSWORD;
|
|
12
|
+
|
|
13
|
+
const MANIFEST_FILE = join(homedir(), '.icloud-mcp-move-manifest.json');
|
|
14
|
+
const RULES_FILE = join(homedir(), '.icloud-mcp-rules.json');
|
|
15
|
+
const MAX_HISTORY = 5;
|
|
16
|
+
|
|
17
|
+
// ─── IMPROVEMENT 1: Connection-level timeout on createClient ──────────────────
|
|
18
|
+
// ImapFlow supports connectionTimeout and greetingTimeout options.
|
|
19
|
+
// This ensures we don't hang forever waiting for iCloud to respond.
|
|
20
|
+
|
|
21
|
+
function createClient() {
|
|
22
|
+
return new ImapFlow({
|
|
23
|
+
host: 'imap.mail.me.com',
|
|
24
|
+
port: 993,
|
|
25
|
+
secure: true,
|
|
26
|
+
auth: { user: IMAP_USER, pass: IMAP_PASSWORD },
|
|
27
|
+
logger: false,
|
|
28
|
+
connectionTimeout: 15_000, // 15s to establish TCP+TLS connection
|
|
29
|
+
greetingTimeout: 15_000, // 15s to receive IMAP greeting after connect
|
|
30
|
+
socketTimeout: 60_000, // 60s of inactivity before socket is killed
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ─── Managed client helpers ───────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
// Rate limit: space out connection initiations within a single server process
|
|
37
|
+
// to avoid triggering iCloud's connection throttle under concurrent tool calls.
|
|
38
|
+
// Wraps connect() on every client returned by createClient() so the gate
|
|
39
|
+
// applies regardless of whether tools use openClient() or createClient() directly.
|
|
40
|
+
// Uses a serialized gate — concurrent callers queue up; each waits 200ms after
|
|
41
|
+
// the previous before initiating its connection. Connections run concurrently
|
|
42
|
+
// after passing the gate.
|
|
43
|
+
let _lastConnectTime = 0;
|
|
44
|
+
let _connectGate = Promise.resolve();
|
|
45
|
+
const MIN_CONNECT_INTERVAL = 10; // ms between connection initiations
|
|
46
|
+
|
|
47
|
+
export function createRateLimitedClient() {
|
|
48
|
+
const client = createClient();
|
|
49
|
+
const originalConnect = client.connect.bind(client);
|
|
50
|
+
client.connect = async () => {
|
|
51
|
+
await new Promise(resolve => {
|
|
52
|
+
_connectGate = _connectGate.then(async () => {
|
|
53
|
+
const wait = MIN_CONNECT_INTERVAL - (Date.now() - _lastConnectTime);
|
|
54
|
+
if (wait > 0) await new Promise(r => setTimeout(r, wait));
|
|
55
|
+
_lastConnectTime = Date.now();
|
|
56
|
+
}).then(resolve, resolve);
|
|
57
|
+
});
|
|
58
|
+
return originalConnect();
|
|
59
|
+
};
|
|
60
|
+
return client;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function openClient(mailbox) {
|
|
64
|
+
const client = createRateLimitedClient();
|
|
65
|
+
await client.connect();
|
|
66
|
+
if (mailbox) await client.mailboxOpen(mailbox);
|
|
67
|
+
return client;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function safeClose(client) {
|
|
71
|
+
try { await client.logout(); } catch { try { client.close(); } catch { /* already gone */ } }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function reconnect(client, mailbox) {
|
|
75
|
+
safeClose(client);
|
|
76
|
+
return openClient(mailbox);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ─── Move Manifest ────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
const CHUNK_SIZE = 500;
|
|
82
|
+
const CHUNK_SIZE_RETRY = 100;
|
|
83
|
+
const ATTACHMENT_SCAN_LIMIT = 500; // max UIDs to scan client-side for hasAttachment filter
|
|
84
|
+
const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024; // 20 MB cap for get_attachment downloads
|
|
85
|
+
|
|
86
|
+
function readManifest() {
|
|
87
|
+
if (!existsSync(MANIFEST_FILE)) return { current: null, history: [] };
|
|
88
|
+
try {
|
|
89
|
+
return JSON.parse(readFileSync(MANIFEST_FILE, 'utf8'));
|
|
90
|
+
} catch {
|
|
91
|
+
return { current: null, history: [] };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function writeManifest(data) {
|
|
96
|
+
writeFileSync(MANIFEST_FILE, JSON.stringify(data, null, 2));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function updateManifest(updater) {
|
|
100
|
+
const data = readManifest();
|
|
101
|
+
if (!data.current) return data; // guard: operation already archived/failed
|
|
102
|
+
updater(data);
|
|
103
|
+
if (!data.current) return data; // guard: updater may have archived it
|
|
104
|
+
data.current.updatedAt = new Date().toISOString();
|
|
105
|
+
writeManifest(data);
|
|
106
|
+
return data;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function archiveCurrent(data) {
|
|
110
|
+
if (data.current) {
|
|
111
|
+
data.history.unshift(data.current);
|
|
112
|
+
if (data.history.length > MAX_HISTORY) data.history = data.history.slice(0, MAX_HISTORY);
|
|
113
|
+
data.current = null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function getMoveStatus() {
|
|
118
|
+
const data = readManifest();
|
|
119
|
+
if (!data.current) return { status: 'no_operation', history: data.history.map(summarizeOp) };
|
|
120
|
+
|
|
121
|
+
const result = {
|
|
122
|
+
current: formatOperation(data.current),
|
|
123
|
+
history: data.history.map(summarizeOp)
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// Stale warning: in_progress but updatedAt is more than 24h ago
|
|
127
|
+
if (data.current.status === 'in_progress') {
|
|
128
|
+
const ageMs = Date.now() - new Date(data.current.updatedAt).getTime();
|
|
129
|
+
if (ageMs > 24 * 60 * 60 * 1000) {
|
|
130
|
+
result.staleWarning = `Operation ${data.current.operationId} has not been updated in ${Math.round(ageMs / 3_600_000)}h — it may be stale. Call abandon_move to discard it if you want to start a new operation.`;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function abandonMove() {
|
|
138
|
+
const data = readManifest();
|
|
139
|
+
if (!data.current) return { abandoned: false, message: 'No in-progress operation to abandon' };
|
|
140
|
+
if (data.current.status !== 'in_progress') {
|
|
141
|
+
return { abandoned: false, message: `Current operation is already '${data.current.status}', nothing to abandon` };
|
|
142
|
+
}
|
|
143
|
+
const operationId = data.current.operationId;
|
|
144
|
+
data.current.status = 'abandoned';
|
|
145
|
+
data.current.updatedAt = new Date().toISOString();
|
|
146
|
+
archiveCurrent(data);
|
|
147
|
+
writeManifest(data);
|
|
148
|
+
return { abandoned: true, operationId };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function startOperation(source, target, uids) {
|
|
152
|
+
const data = readManifest();
|
|
153
|
+
|
|
154
|
+
if (data.current && data.current.status === 'in_progress') {
|
|
155
|
+
const op = data.current;
|
|
156
|
+
throw new Error(
|
|
157
|
+
`Incomplete move operation detected (${op.operationId}): ` +
|
|
158
|
+
`${op.summary.emailsMoved} of ${op.totalUids} emails moved from '${op.source}' to '${op.target}' ` +
|
|
159
|
+
`started at ${op.startedAt}. ` +
|
|
160
|
+
`Call abandon_move to discard it or get_move_status to inspect it before starting a new operation.`
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
archiveCurrent(data);
|
|
165
|
+
|
|
166
|
+
const operationId = `move_${Date.now()}`;
|
|
167
|
+
const chunks = [];
|
|
168
|
+
|
|
169
|
+
for (let i = 0; i < uids.length; i += CHUNK_SIZE) {
|
|
170
|
+
chunks.push({
|
|
171
|
+
index: chunks.length,
|
|
172
|
+
uids: uids.slice(i, i + CHUNK_SIZE),
|
|
173
|
+
fingerprints: [],
|
|
174
|
+
status: 'pending',
|
|
175
|
+
copiedAt: null,
|
|
176
|
+
verifiedAt: null,
|
|
177
|
+
deletedAt: null,
|
|
178
|
+
failureReason: null
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
data.current = {
|
|
183
|
+
operationId,
|
|
184
|
+
startedAt: new Date().toISOString(),
|
|
185
|
+
updatedAt: new Date().toISOString(),
|
|
186
|
+
source,
|
|
187
|
+
target,
|
|
188
|
+
totalUids: uids.length,
|
|
189
|
+
status: 'in_progress',
|
|
190
|
+
phase: 'copying',
|
|
191
|
+
verifiedAt: null,
|
|
192
|
+
deletedAt: null,
|
|
193
|
+
allFingerprints: null,
|
|
194
|
+
chunks,
|
|
195
|
+
summary: {
|
|
196
|
+
chunksComplete: 0,
|
|
197
|
+
emailsMoved: 0,
|
|
198
|
+
emailsPending: uids.length,
|
|
199
|
+
emailsFailed: 0
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
writeManifest(data);
|
|
204
|
+
return data.current;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function updateChunk(index, updates) {
|
|
208
|
+
updateManifest((data) => {
|
|
209
|
+
if (!data.current) return; // guard: operation already archived
|
|
210
|
+
const chunk = data.current.chunks[index];
|
|
211
|
+
if (!chunk) return; // guard: chunk index out of range
|
|
212
|
+
Object.assign(chunk, updates);
|
|
213
|
+
|
|
214
|
+
let moved = 0, failed = 0, pending = 0;
|
|
215
|
+
for (const c of data.current.chunks) {
|
|
216
|
+
if (c.status === 'complete') moved += c.uids.length;
|
|
217
|
+
else if (c.status === 'failed') failed += c.uids.length;
|
|
218
|
+
else pending += c.uids.length;
|
|
219
|
+
}
|
|
220
|
+
data.current.summary = {
|
|
221
|
+
chunksComplete: data.current.chunks.filter(c => c.status === 'complete').length,
|
|
222
|
+
emailsMoved: moved,
|
|
223
|
+
emailsPending: pending,
|
|
224
|
+
emailsFailed: failed
|
|
225
|
+
};
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function updateOperationPhase(phase, extraFields = {}) {
|
|
230
|
+
updateManifest((data) => {
|
|
231
|
+
if (!data.current) return;
|
|
232
|
+
data.current.phase = phase;
|
|
233
|
+
Object.assign(data.current, extraFields);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function completeOperation() {
|
|
238
|
+
const data = readManifest();
|
|
239
|
+
if (!data.current) return;
|
|
240
|
+
data.current.status = 'complete';
|
|
241
|
+
data.current.updatedAt = new Date().toISOString();
|
|
242
|
+
archiveCurrent(data);
|
|
243
|
+
writeManifest(data);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function failOperation(reason) {
|
|
247
|
+
const data = readManifest();
|
|
248
|
+
if (!data.current) return;
|
|
249
|
+
data.current.status = 'failed';
|
|
250
|
+
data.current.failureReason = reason;
|
|
251
|
+
data.current.updatedAt = new Date().toISOString();
|
|
252
|
+
archiveCurrent(data);
|
|
253
|
+
writeManifest(data);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function formatOperation(op) {
|
|
257
|
+
return {
|
|
258
|
+
operationId: op.operationId,
|
|
259
|
+
status: op.status,
|
|
260
|
+
phase: op.phase ?? null,
|
|
261
|
+
source: op.source,
|
|
262
|
+
target: op.target,
|
|
263
|
+
startedAt: op.startedAt,
|
|
264
|
+
updatedAt: op.updatedAt,
|
|
265
|
+
verifiedAt: op.verifiedAt ?? null,
|
|
266
|
+
deletedAt: op.deletedAt ?? null,
|
|
267
|
+
summary: op.summary,
|
|
268
|
+
failedChunks: op.chunks.filter(c => c.status === 'failed').map(c => ({
|
|
269
|
+
index: c.index,
|
|
270
|
+
uids: c.uids.length,
|
|
271
|
+
reason: c.failureReason
|
|
272
|
+
}))
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function summarizeOp(op) {
|
|
277
|
+
return {
|
|
278
|
+
operationId: op.operationId,
|
|
279
|
+
status: op.status,
|
|
280
|
+
source: op.source,
|
|
281
|
+
target: op.target,
|
|
282
|
+
startedAt: op.startedAt,
|
|
283
|
+
moved: op.summary.emailsMoved,
|
|
284
|
+
failed: op.summary.emailsFailed,
|
|
285
|
+
total: op.totalUids
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ─── Fingerprinting ───────────────────────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
function buildFingerprint(msg) {
|
|
292
|
+
const messageId = msg.envelope?.messageId ?? null;
|
|
293
|
+
const sender = msg.envelope?.from?.[0]?.address ?? '';
|
|
294
|
+
const rawDate = msg.envelope?.date;
|
|
295
|
+
let date = '';
|
|
296
|
+
if (rawDate) { try { const d = new Date(rawDate); if (!isNaN(d.getTime())) date = d.toISOString(); } catch { /* malformed date */ } }
|
|
297
|
+
const subject = msg.envelope?.subject ?? '';
|
|
298
|
+
const fallback = [sender, date, subject].join('|');
|
|
299
|
+
return { uid: msg.uid, messageId, fallback };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function fingerprintToKey(fp) {
|
|
303
|
+
return fp.messageId ?? fp.fallback;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ─── Transient error detection ────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
function isTransient(err) {
|
|
309
|
+
const msg = err.message ?? '';
|
|
310
|
+
return msg.includes('ECONNRESET') ||
|
|
311
|
+
msg.includes('ECONNREFUSED') ||
|
|
312
|
+
msg.includes('ETIMEDOUT') ||
|
|
313
|
+
msg.includes('EPIPE') ||
|
|
314
|
+
msg.includes('socket hang up') ||
|
|
315
|
+
msg.includes('Connection not available') ||
|
|
316
|
+
msg.includes('BAD') ||
|
|
317
|
+
msg.includes('NO ');
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export async function withRetry(label, fn, maxAttempts = 3) {
|
|
321
|
+
let lastErr;
|
|
322
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
323
|
+
try {
|
|
324
|
+
return await fn();
|
|
325
|
+
} catch (err) {
|
|
326
|
+
lastErr = err;
|
|
327
|
+
if (!isTransient(err) || attempt === maxAttempts) throw err;
|
|
328
|
+
const delay = attempt * 2000;
|
|
329
|
+
process.stderr.write(`[retry] ${label} failed (attempt ${attempt}/${maxAttempts}): ${err.message} — retrying in ${delay}ms\n`);
|
|
330
|
+
await new Promise(r => setTimeout(r, delay));
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
throw lastErr;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ─── Per-operation timeouts ───────────────────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
const COPY_CHUNK_DELAY_MS = 500; // ms between COPY chunks — mitigates iCloud copy throttling
|
|
339
|
+
|
|
340
|
+
export const TIMEOUT = {
|
|
341
|
+
METADATA: 15_000,
|
|
342
|
+
FETCH: 30_000,
|
|
343
|
+
SCAN: 60_000,
|
|
344
|
+
BULK_OP: 60_000,
|
|
345
|
+
CHUNK: 300_000,
|
|
346
|
+
SINGLE: 15_000,
|
|
347
|
+
VERIFY_ALL: 120_000, // full envelope scan for all N emails
|
|
348
|
+
DELETE_ALL: 600_000, // flag all + single UID EXPUNGE (measured up to 521s at 5k)
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
export function withTimeout(label, ms, fn) {
|
|
352
|
+
let timer;
|
|
353
|
+
return Promise.race([
|
|
354
|
+
fn().finally(() => clearTimeout(timer)),
|
|
355
|
+
new Promise((_, reject) => {
|
|
356
|
+
timer = setTimeout(() => {
|
|
357
|
+
process.stderr.write(`[timeout] ${label} timed out after ${ms / 1000}s\n`);
|
|
358
|
+
reject(new Error(`${label} timed out after ${ms / 1000}s`));
|
|
359
|
+
}, ms);
|
|
360
|
+
})
|
|
361
|
+
]);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ─── Move logging ─────────────────────────────────────────────────────────────
|
|
365
|
+
|
|
366
|
+
function elapsed(startMs) {
|
|
367
|
+
return ((Date.now() - startMs) / 1000).toFixed(1) + 's';
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function moveLog(chunkIndex, msg) {
|
|
371
|
+
process.stderr.write(`[move] chunk ${chunkIndex}: ${msg}\n`);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ─── Verification (v3: envelope scan first, Message-ID fallback) ──────────────
|
|
375
|
+
// Strategy: one bulk FETCH of recent envelopes in the target is far faster than
|
|
376
|
+
// N individual SEARCH commands. We use envelope scan as the primary check, then
|
|
377
|
+
// only fall back to per-email Message-ID SEARCH for the few that didn't match
|
|
378
|
+
// (which can happen if the envelope fingerprint differs slightly between source
|
|
379
|
+
// and target, e.g. date normalization).
|
|
380
|
+
|
|
381
|
+
async function verifyByEnvelopeScan(client, fingerprints, chunkIndex, knownTotal = null) {
|
|
382
|
+
if (fingerprints.length === 0) return { missing: [], found: 0 };
|
|
383
|
+
|
|
384
|
+
const t0 = Date.now();
|
|
385
|
+
const total = knownTotal ?? (await client.status(client.mailbox.path, { messages: true })).messages;
|
|
386
|
+
const fetchCount = Math.min(total, fingerprints.length + 150);
|
|
387
|
+
const start = Math.max(1, total - fetchCount + 1);
|
|
388
|
+
const range = `${start}:${total}`;
|
|
389
|
+
|
|
390
|
+
const targetKeys = new Set();
|
|
391
|
+
let scanned = 0;
|
|
392
|
+
for await (const msg of client.fetch(range, { envelope: true })) {
|
|
393
|
+
const fp = buildFingerprint(msg);
|
|
394
|
+
targetKeys.add(fingerprintToKey(fp));
|
|
395
|
+
scanned++;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const missing = [];
|
|
399
|
+
for (const fp of fingerprints) {
|
|
400
|
+
if (!targetKeys.has(fingerprintToKey(fp))) missing.push(fp);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
moveLog(chunkIndex, `envelope scan: ${scanned} scanned, ${fingerprints.length - missing.length}/${fingerprints.length} matched (${elapsed(t0)})`);
|
|
404
|
+
return { missing, found: fingerprints.length - missing.length };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async function verifyByMessageId(client, fingerprints, chunkIndex) {
|
|
408
|
+
if (fingerprints.length === 0) return { missing: [], verified: 0 };
|
|
409
|
+
|
|
410
|
+
const t0 = Date.now();
|
|
411
|
+
const missing = [];
|
|
412
|
+
let verified = 0;
|
|
413
|
+
|
|
414
|
+
for (const fp of fingerprints) {
|
|
415
|
+
if (!fp.messageId) {
|
|
416
|
+
// No Message-ID — can't verify this way, count as missing
|
|
417
|
+
missing.push(fp);
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
const uids = (await client.search({ header: ['Message-ID', fp.messageId] }, { uid: true })) ?? [];
|
|
421
|
+
if (uids.length === 0) {
|
|
422
|
+
missing.push(fp);
|
|
423
|
+
} else {
|
|
424
|
+
verified++;
|
|
425
|
+
}
|
|
426
|
+
// Progress logging every 25 emails
|
|
427
|
+
const checked = verified + missing.length;
|
|
428
|
+
if (checked % 25 === 0) {
|
|
429
|
+
moveLog(chunkIndex, `Message-ID fallback: ${checked}/${fingerprints.length} checked (${verified} found, ${missing.length} missing, ${elapsed(t0)})`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
moveLog(chunkIndex, `Message-ID fallback: ${verified}/${fingerprints.length} verified, ${missing.length} still missing (${elapsed(t0)})`);
|
|
434
|
+
return { missing, verified };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async function verifyInTarget(targetClient, fingerprints, chunkIndex, knownTotal = null) {
|
|
438
|
+
// Primary: fast envelope scan (one FETCH command)
|
|
439
|
+
const { missing: afterScan } = await verifyByEnvelopeScan(targetClient, fingerprints, chunkIndex, knownTotal);
|
|
440
|
+
|
|
441
|
+
if (afterScan.length === 0) {
|
|
442
|
+
return { verified: true, missing: [], found: fingerprints.length, expected: fingerprints.length };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (afterScan.length > 200) {
|
|
446
|
+
moveLog(chunkIndex, `${afterScan.length} unmatched after envelope scan — too many for Message-ID fallback, treating as failed`);
|
|
447
|
+
return { verified: false, missing: afterScan, found: fingerprints.length - afterScan.length, expected: fingerprints.length };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Secondary: Message-ID search only for the ones envelope scan missed
|
|
451
|
+
moveLog(chunkIndex, `${afterScan.length} unmatched after envelope scan — trying Message-ID search`);
|
|
452
|
+
const withMessageId = afterScan.filter(fp => fp.messageId);
|
|
453
|
+
const noMessageId = afterScan.filter(fp => !fp.messageId);
|
|
454
|
+
|
|
455
|
+
if (withMessageId.length > 0) {
|
|
456
|
+
const { missing: stillMissing } = await verifyByMessageId(targetClient, withMessageId, chunkIndex);
|
|
457
|
+
const allMissing = [...stillMissing, ...noMessageId];
|
|
458
|
+
return {
|
|
459
|
+
verified: allMissing.length === 0,
|
|
460
|
+
missing: allMissing,
|
|
461
|
+
found: fingerprints.length - allMissing.length,
|
|
462
|
+
expected: fingerprints.length
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// No Message-IDs to try — whatever envelope scan missed is truly missing
|
|
467
|
+
return {
|
|
468
|
+
verified: noMessageId.length === 0,
|
|
469
|
+
missing: noMessageId,
|
|
470
|
+
found: fingerprints.length - noMessageId.length,
|
|
471
|
+
expected: fingerprints.length
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// ─── Option B phase helpers ────────────────────────────────────────────────────
|
|
476
|
+
|
|
477
|
+
// Phase 1: Copy all chunks to target without deleting.
|
|
478
|
+
// Returns { success, totalCopied, srcClient, errorResult }
|
|
479
|
+
async function copyAllChunks(operation, srcClient, targetMailbox, sourceMailbox) {
|
|
480
|
+
let totalCopied = 0;
|
|
481
|
+
|
|
482
|
+
for (const chunk of operation.chunks) {
|
|
483
|
+
const chunkUids = chunk.uids;
|
|
484
|
+
const chunkStart = Date.now();
|
|
485
|
+
moveLog(chunk.index, `starting copy (${chunkUids.length} emails)`);
|
|
486
|
+
|
|
487
|
+
try {
|
|
488
|
+
await withTimeout(`copy chunk ${chunk.index}`, TIMEOUT.CHUNK, async () => {
|
|
489
|
+
// Step 1: fetch envelopes → fingerprints
|
|
490
|
+
let t = Date.now();
|
|
491
|
+
const envelopes = [];
|
|
492
|
+
try {
|
|
493
|
+
for await (const msg of srcClient.fetch(chunkUids, { envelope: true }, { uid: true })) {
|
|
494
|
+
envelopes.push(msg);
|
|
495
|
+
}
|
|
496
|
+
} catch (err) {
|
|
497
|
+
if (!isTransient(err)) throw err;
|
|
498
|
+
moveLog(chunk.index, `fetch envelopes failed (${err.message}), reconnecting...`);
|
|
499
|
+
srcClient = await reconnect(srcClient, sourceMailbox);
|
|
500
|
+
for await (const msg of srcClient.fetch(chunkUids, { envelope: true }, { uid: true })) {
|
|
501
|
+
envelopes.push(msg);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
const fingerprints = envelopes.map(buildFingerprint);
|
|
505
|
+
const withMsgId = fingerprints.filter(fp => fp.messageId).length;
|
|
506
|
+
moveLog(chunk.index, `fetched ${envelopes.length} envelopes (${withMsgId} with Message-ID) (${elapsed(t)})`);
|
|
507
|
+
|
|
508
|
+
// Update in-memory chunk so verifyAllChunks can flatMap fingerprints later
|
|
509
|
+
chunk.fingerprints = fingerprints;
|
|
510
|
+
updateManifest((data) => {
|
|
511
|
+
if (!data.current) return;
|
|
512
|
+
const c = data.current.chunks[chunk.index];
|
|
513
|
+
if (!c) return;
|
|
514
|
+
c.fingerprints = fingerprints;
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
// Step 2: copy to target
|
|
518
|
+
t = Date.now();
|
|
519
|
+
try {
|
|
520
|
+
await srcClient.messageCopy(chunkUids, targetMailbox, { uid: true });
|
|
521
|
+
} catch (err) {
|
|
522
|
+
if (!isTransient(err)) throw err;
|
|
523
|
+
moveLog(chunk.index, `copy failed (${err.message}), reconnecting...`);
|
|
524
|
+
srcClient = await reconnect(srcClient, sourceMailbox);
|
|
525
|
+
await srcClient.messageCopy(chunkUids, targetMailbox, { uid: true });
|
|
526
|
+
}
|
|
527
|
+
moveLog(chunk.index, `copied ${chunkUids.length} emails to target (${elapsed(t)})`);
|
|
528
|
+
updateChunk(chunk.index, { status: 'copied_not_verified', copiedAt: new Date().toISOString() });
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
totalCopied += chunkUids.length;
|
|
532
|
+
moveLog(chunk.index, `copy complete (${elapsed(chunkStart)})`);
|
|
533
|
+
} catch (err) {
|
|
534
|
+
moveLog(chunk.index, `copy FAILED: ${err.message}`);
|
|
535
|
+
updateChunk(chunk.index, { status: 'failed', failureReason: err.message });
|
|
536
|
+
return {
|
|
537
|
+
success: false,
|
|
538
|
+
totalCopied,
|
|
539
|
+
srcClient,
|
|
540
|
+
errorResult: {
|
|
541
|
+
status: 'partial',
|
|
542
|
+
moved: 0,
|
|
543
|
+
failed: operation.totalUids,
|
|
544
|
+
message: `Copy failed on chunk ${chunk.index}: ${err.message}. No emails deleted from source. ${totalCopied} emails were copied to target but not verified — call get_move_status for details.`
|
|
545
|
+
}
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Delay between chunks to mitigate iCloud copy throttling
|
|
550
|
+
if (chunk.index < operation.chunks.length - 1) {
|
|
551
|
+
await new Promise(r => setTimeout(r, COPY_CHUNK_DELAY_MS));
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
return { success: true, totalCopied, srcClient, errorResult: null };
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Phase 2: Verify all copied emails are present in target.
|
|
559
|
+
// Returns { verification, tgtClient }
|
|
560
|
+
async function verifyAllChunks(tgtClient, operation, targetMailbox) {
|
|
561
|
+
const allFingerprints = operation.chunks.flatMap(c => c.fingerprints);
|
|
562
|
+
|
|
563
|
+
updateManifest((data) => {
|
|
564
|
+
if (!data.current) return;
|
|
565
|
+
data.current.allFingerprints = allFingerprints;
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
let tgtMb;
|
|
569
|
+
try {
|
|
570
|
+
tgtMb = await tgtClient.mailboxOpen(targetMailbox);
|
|
571
|
+
} catch (err) {
|
|
572
|
+
if (!isTransient(err)) throw err;
|
|
573
|
+
moveLog('global', `mailboxOpen failed (${err.message}), reconnecting...`);
|
|
574
|
+
tgtClient = await reconnect(tgtClient, targetMailbox);
|
|
575
|
+
tgtMb = await tgtClient.mailboxOpen(targetMailbox);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
let verification;
|
|
579
|
+
try {
|
|
580
|
+
verification = await verifyInTarget(tgtClient, allFingerprints, 'global', tgtMb.exists);
|
|
581
|
+
} catch (err) {
|
|
582
|
+
if (!isTransient(err)) throw err;
|
|
583
|
+
moveLog('global', `verify failed (${err.message}), reconnecting...`);
|
|
584
|
+
tgtClient = await reconnect(tgtClient, targetMailbox);
|
|
585
|
+
verification = await verifyInTarget(tgtClient, allFingerprints, 'global');
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
return { verification, tgtClient };
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Phase 3: Delete all source emails in a single EXPUNGE.
|
|
592
|
+
// Returns { srcClient }
|
|
593
|
+
async function deleteAllChunks(srcClient, operation, sourceMailbox) {
|
|
594
|
+
const allUids = operation.chunks.flatMap(c => c.uids);
|
|
595
|
+
const t = Date.now();
|
|
596
|
+
|
|
597
|
+
try {
|
|
598
|
+
await srcClient.messageDelete(allUids, { uid: true });
|
|
599
|
+
} catch (err) {
|
|
600
|
+
if (!isTransient(err)) throw err;
|
|
601
|
+
moveLog('global', `delete failed (${err.message}), reconnecting...`);
|
|
602
|
+
srcClient = await reconnect(srcClient, sourceMailbox);
|
|
603
|
+
// Retry is idempotent — expunging already-gone UIDs is a no-op
|
|
604
|
+
await srcClient.messageDelete(allUids, { uid: true });
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
moveLog('global', `deleted ${allUids.length} from source — single EXPUNGE (${elapsed(t)})`);
|
|
608
|
+
return { srcClient };
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// ─── Safe Move (Option B: COPY-all → VERIFY-all → single EXPUNGE) ─────────────
|
|
612
|
+
|
|
613
|
+
async function safeMoveEmails(uids, sourceMailbox, targetMailbox) {
|
|
614
|
+
const operation = startOperation(sourceMailbox, targetMailbox, uids);
|
|
615
|
+
const opStart = Date.now();
|
|
616
|
+
|
|
617
|
+
process.stderr.write(`[move] starting: ${uids.length} emails, ${operation.chunks.length} chunks, ${sourceMailbox} → ${targetMailbox}\n`);
|
|
618
|
+
|
|
619
|
+
let srcClient = await openClient(sourceMailbox);
|
|
620
|
+
let tgtClient = await openClient(targetMailbox);
|
|
621
|
+
|
|
622
|
+
try {
|
|
623
|
+
// Phase 1: COPY all chunks to target (no delete yet)
|
|
624
|
+
process.stderr.write(`[move] phase 1/3: copying ${uids.length} emails in ${operation.chunks.length} chunks\n`);
|
|
625
|
+
const copyResult = await copyAllChunks(operation, srcClient, targetMailbox, sourceMailbox);
|
|
626
|
+
srcClient = copyResult.srcClient;
|
|
627
|
+
|
|
628
|
+
if (!copyResult.success) {
|
|
629
|
+
failOperation(`Copy phase failed: ${copyResult.errorResult.message}`);
|
|
630
|
+
return copyResult.errorResult;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Phase 2: VERIFY all emails are present in target
|
|
634
|
+
process.stderr.write(`[move] phase 2/3: verifying all ${copyResult.totalCopied} emails in target\n`);
|
|
635
|
+
updateOperationPhase('verifying');
|
|
636
|
+
|
|
637
|
+
let verifyResult;
|
|
638
|
+
try {
|
|
639
|
+
verifyResult = await withTimeout('verify all', TIMEOUT.VERIFY_ALL, () =>
|
|
640
|
+
verifyAllChunks(tgtClient, operation, targetMailbox)
|
|
641
|
+
);
|
|
642
|
+
tgtClient = verifyResult.tgtClient;
|
|
643
|
+
} catch (err) {
|
|
644
|
+
moveLog('global', `verify phase FAILED: ${err.message}`);
|
|
645
|
+
failOperation(`Verify phase failed: ${err.message}`);
|
|
646
|
+
return {
|
|
647
|
+
status: 'failed',
|
|
648
|
+
moved: 0,
|
|
649
|
+
message: `Verification timed out or failed: ${err.message}. All ${copyResult.totalCopied} emails remain in source (not deleted). Call get_move_status for details.`
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const { verification } = verifyResult;
|
|
654
|
+
moveLog('global', `verification: ${verification.found}/${verification.expected} confirmed`);
|
|
655
|
+
|
|
656
|
+
if (!verification.verified) {
|
|
657
|
+
moveLog('global', `FAILED: ${verification.missing.length} emails missing from target after copy`);
|
|
658
|
+
failOperation(`Verification failed: ${verification.missing.length} emails missing from target`);
|
|
659
|
+
return {
|
|
660
|
+
status: 'failed',
|
|
661
|
+
moved: 0,
|
|
662
|
+
message: `Verification failed: ${verification.missing.length} of ${verification.expected} emails did not arrive in target. Source emails untouched. Call get_move_status for details.`
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
updateOperationPhase('verifying', { verifiedAt: new Date().toISOString() });
|
|
667
|
+
|
|
668
|
+
// Mark all chunks as verified
|
|
669
|
+
for (const chunk of operation.chunks) {
|
|
670
|
+
updateChunk(chunk.index, { status: 'verified_not_deleted', verifiedAt: new Date().toISOString() });
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Phase 3: DELETE all source emails — single EXPUNGE
|
|
674
|
+
process.stderr.write(`[move] phase 3/3: deleting all ${uids.length} emails from source (1 EXPUNGE)\n`);
|
|
675
|
+
updateOperationPhase('deleting');
|
|
676
|
+
|
|
677
|
+
let deleteResult;
|
|
678
|
+
try {
|
|
679
|
+
deleteResult = await withTimeout('delete all', TIMEOUT.DELETE_ALL, () =>
|
|
680
|
+
deleteAllChunks(srcClient, operation, sourceMailbox)
|
|
681
|
+
);
|
|
682
|
+
srcClient = deleteResult.srcClient;
|
|
683
|
+
} catch (err) {
|
|
684
|
+
moveLog('global', `delete phase FAILED: ${err.message}`);
|
|
685
|
+
// Emails are safe in target (verified). Source may still have them.
|
|
686
|
+
failOperation(`Delete phase failed: ${err.message}`);
|
|
687
|
+
return {
|
|
688
|
+
status: 'failed',
|
|
689
|
+
moved: 0,
|
|
690
|
+
message: `Delete phase failed: ${err.message}. All ${copyResult.totalCopied} emails exist in target (verified) but may still exist in source. Call get_move_status for details.`
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// Mark all chunks complete
|
|
695
|
+
const now = new Date().toISOString();
|
|
696
|
+
for (const chunk of operation.chunks) {
|
|
697
|
+
updateChunk(chunk.index, { status: 'complete', deletedAt: now });
|
|
698
|
+
}
|
|
699
|
+
updateOperationPhase('deleting', { deletedAt: now });
|
|
700
|
+
completeOperation();
|
|
701
|
+
|
|
702
|
+
process.stderr.write(`[move] COMPLETE: ${copyResult.totalCopied}/${operation.totalUids} emails moved (${elapsed(opStart)})\n`);
|
|
703
|
+
return { status: 'complete', moved: copyResult.totalCopied, total: operation.totalUids };
|
|
704
|
+
} finally {
|
|
705
|
+
await safeClose(srcClient);
|
|
706
|
+
await safeClose(tgtClient);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// ─── Email Functions ──────────────────────────────────────────────────────────
|
|
711
|
+
|
|
712
|
+
export async function fetchEmails(mailbox = 'INBOX', limit = 10, onlyUnread = false, page = 1) {
|
|
713
|
+
const client = createRateLimitedClient();
|
|
714
|
+
await client.connect();
|
|
715
|
+
const mb = await client.mailboxOpen(mailbox);
|
|
716
|
+
const total = mb.exists;
|
|
717
|
+
const emails = [];
|
|
718
|
+
|
|
719
|
+
if (total === 0) {
|
|
720
|
+
await client.logout();
|
|
721
|
+
return { emails, page, limit, total, totalPages: 0, hasMore: false };
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
if (onlyUnread) {
|
|
725
|
+
const uids = (await client.search({ seen: false }, { uid: true })) ?? [];
|
|
726
|
+
const totalUnread = uids.length;
|
|
727
|
+
const skip = (page - 1) * limit;
|
|
728
|
+
const pageUids = uids.reverse().slice(skip, skip + limit);
|
|
729
|
+
for (const uid of pageUids) {
|
|
730
|
+
const msg = await client.fetchOne(uid, { envelope: true, flags: true }, { uid: true });
|
|
731
|
+
if (msg) {
|
|
732
|
+
emails.push({
|
|
733
|
+
uid,
|
|
734
|
+
subject: msg.envelope.subject,
|
|
735
|
+
from: msg.envelope.from?.[0]?.address,
|
|
736
|
+
date: msg.envelope.date,
|
|
737
|
+
flagged: msg.flags.has('\\Flagged'),
|
|
738
|
+
seen: msg.flags.has('\\Seen')
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
await client.logout();
|
|
743
|
+
return { emails, page, limit, total: totalUnread, totalPages: Math.ceil(totalUnread / limit), hasMore: (page * limit) < totalUnread };
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const end = Math.max(1, total - ((page - 1) * limit));
|
|
747
|
+
const start = Math.max(1, end - limit + 1);
|
|
748
|
+
const range = `${start}:${end}`;
|
|
749
|
+
|
|
750
|
+
for await (const msg of client.fetch(range, { envelope: true, flags: true })) {
|
|
751
|
+
emails.push({
|
|
752
|
+
uid: msg.uid,
|
|
753
|
+
subject: msg.envelope.subject,
|
|
754
|
+
from: msg.envelope.from?.[0]?.address,
|
|
755
|
+
date: msg.envelope.date,
|
|
756
|
+
flagged: msg.flags.has('\\Flagged'),
|
|
757
|
+
seen: msg.flags.has('\\Seen')
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
await client.logout();
|
|
762
|
+
emails.reverse();
|
|
763
|
+
return { emails, page, limit, total, totalPages: Math.ceil(total / limit), hasMore: (page * limit) < total };
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
export async function getInboxSummary(mailbox = 'INBOX') {
|
|
767
|
+
const client = createRateLimitedClient();
|
|
768
|
+
await client.connect();
|
|
769
|
+
const status = await client.status(mailbox, { messages: true, unseen: true, recent: true });
|
|
770
|
+
await client.logout();
|
|
771
|
+
return { mailbox, total: status.messages, unread: status.unseen, recent: status.recent };
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
export async function getTopSenders(mailbox = 'INBOX', sampleSize = 500, maxResults = 20) {
|
|
775
|
+
const client = createRateLimitedClient();
|
|
776
|
+
await client.connect();
|
|
777
|
+
const mb = await client.mailboxOpen(mailbox);
|
|
778
|
+
const total = mb.exists;
|
|
779
|
+
const senderCounts = {};
|
|
780
|
+
const senderDomains = {};
|
|
781
|
+
|
|
782
|
+
const end = total;
|
|
783
|
+
const start = Math.max(1, total - sampleSize + 1);
|
|
784
|
+
const range = `${start}:${end}`;
|
|
785
|
+
let count = 0;
|
|
786
|
+
|
|
787
|
+
for await (const msg of client.fetch(range, { envelope: true })) {
|
|
788
|
+
const address = msg.envelope.from?.[0]?.address;
|
|
789
|
+
if (address) {
|
|
790
|
+
senderCounts[address] = (senderCounts[address] || 0) + 1;
|
|
791
|
+
const domain = address.split('@')[1];
|
|
792
|
+
if (domain) senderDomains[domain] = (senderDomains[domain] || 0) + 1;
|
|
793
|
+
}
|
|
794
|
+
count++;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
await client.logout();
|
|
798
|
+
const topAddresses = Object.entries(senderCounts).sort((a, b) => b[1] - a[1]).slice(0, maxResults).map(([address, count]) => ({ address, count }));
|
|
799
|
+
const topDomains = Object.entries(senderDomains).sort((a, b) => b[1] - a[1]).slice(0, maxResults).map(([domain, count]) => ({ domain, count }));
|
|
800
|
+
return { sampledEmails: count, topAddresses, topDomains };
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
export async function getUnreadSenders(mailbox = 'INBOX', sampleSize = 500, maxResults = 20) {
|
|
804
|
+
const client = createRateLimitedClient();
|
|
805
|
+
await client.connect();
|
|
806
|
+
await client.mailboxOpen(mailbox);
|
|
807
|
+
const uids = (await client.search({ seen: false }, { uid: true })) ?? [];
|
|
808
|
+
const recentUids = uids.reverse().slice(0, sampleSize);
|
|
809
|
+
const senderCounts = {};
|
|
810
|
+
|
|
811
|
+
if (recentUids.length === 0) {
|
|
812
|
+
await client.logout();
|
|
813
|
+
return [];
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
for await (const msg of client.fetch(recentUids, { envelope: true }, { uid: true })) {
|
|
817
|
+
const address = msg.envelope.from?.[0]?.address;
|
|
818
|
+
if (address) senderCounts[address] = (senderCounts[address] || 0) + 1;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
await client.logout();
|
|
822
|
+
return Object.entries(senderCounts).sort((a, b) => b[1] - a[1]).slice(0, maxResults).map(([address, count]) => ({ address, count }));
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
export async function getEmailsBySender(sender, mailbox = 'INBOX', limit = 10) {
|
|
826
|
+
const client = createRateLimitedClient();
|
|
827
|
+
await client.connect();
|
|
828
|
+
await client.mailboxOpen(mailbox);
|
|
829
|
+
const uids = (await client.search({ from: sender }, { uid: true })) ?? [];
|
|
830
|
+
const total = uids.length;
|
|
831
|
+
const recentUids = uids.slice(-limit).reverse();
|
|
832
|
+
const emails = [];
|
|
833
|
+
for (const uid of recentUids) {
|
|
834
|
+
const msg = await client.fetchOne(uid, { envelope: true, flags: true }, { uid: true });
|
|
835
|
+
if (msg) {
|
|
836
|
+
emails.push({
|
|
837
|
+
uid,
|
|
838
|
+
subject: msg.envelope.subject,
|
|
839
|
+
from: msg.envelope.from?.[0]?.address,
|
|
840
|
+
date: msg.envelope.date,
|
|
841
|
+
flagged: msg.flags.has('\\Flagged'),
|
|
842
|
+
seen: msg.flags.has('\\Seen')
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
await client.logout();
|
|
847
|
+
return { total, showing: emails.length, emails };
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
export async function bulkDeleteBySender(sender, mailbox = 'INBOX') {
|
|
851
|
+
const client = createRateLimitedClient();
|
|
852
|
+
await client.connect();
|
|
853
|
+
await client.mailboxOpen(mailbox);
|
|
854
|
+
const uids = (await client.search({ from: sender }, { uid: true })) ?? [];
|
|
855
|
+
if (uids.length === 0) { await client.logout(); return { deleted: 0 }; }
|
|
856
|
+
let deleted = 0;
|
|
857
|
+
for (let i = 0; i < uids.length; i += CHUNK_SIZE) {
|
|
858
|
+
const chunk = uids.slice(i, i + CHUNK_SIZE);
|
|
859
|
+
await client.messageDelete(chunk, { uid: true });
|
|
860
|
+
deleted += chunk.length;
|
|
861
|
+
}
|
|
862
|
+
await client.logout();
|
|
863
|
+
return { deleted, sender };
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
export async function markOlderThanRead(days, mailbox = 'INBOX') {
|
|
867
|
+
const client = createRateLimitedClient();
|
|
868
|
+
await client.connect();
|
|
869
|
+
await client.mailboxOpen(mailbox);
|
|
870
|
+
const date = new Date();
|
|
871
|
+
date.setDate(date.getDate() - days);
|
|
872
|
+
const raw = await client.search({ before: date, seen: false }, { uid: true });
|
|
873
|
+
const uids = Array.isArray(raw) ? raw : [];
|
|
874
|
+
if (uids.length === 0) { await client.logout(); return { marked: 0, olderThan: date.toISOString() }; }
|
|
875
|
+
await client.messageFlagsAdd(uids, ['\\Seen'], { uid: true });
|
|
876
|
+
await client.logout();
|
|
877
|
+
return { marked: uids.length, olderThan: date.toISOString() };
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
export async function bulkMoveByDomain(domain, targetMailbox, sourceMailbox = 'INBOX', dryRun = false) {
|
|
881
|
+
const result = await bulkMove({ domain }, targetMailbox, sourceMailbox, dryRun);
|
|
882
|
+
return { ...result, domain };
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
export async function bulkMoveBySender(sender, targetMailbox, sourceMailbox = 'INBOX', dryRun = false) {
|
|
886
|
+
const client = createRateLimitedClient();
|
|
887
|
+
await client.connect();
|
|
888
|
+
await client.mailboxOpen(sourceMailbox);
|
|
889
|
+
const uids = (await client.search({ from: sender }, { uid: true })) ?? [];
|
|
890
|
+
await client.logout();
|
|
891
|
+
if (dryRun) return { dryRun: true, wouldMove: uids.length, sender, sourceMailbox, targetMailbox };
|
|
892
|
+
if (uids.length === 0) return { moved: 0 };
|
|
893
|
+
await ensureMailbox(targetMailbox);
|
|
894
|
+
const result = await safeMoveEmails(uids, sourceMailbox, targetMailbox);
|
|
895
|
+
return { ...result, sender, targetMailbox };
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
export async function bulkDeleteBySubject(subject, mailbox = 'INBOX') {
|
|
899
|
+
const client = createRateLimitedClient();
|
|
900
|
+
await client.connect();
|
|
901
|
+
await client.mailboxOpen(mailbox);
|
|
902
|
+
const uids = (await client.search({ subject }, { uid: true })) ?? [];
|
|
903
|
+
if (uids.length === 0) { await client.logout(); return { deleted: 0 }; }
|
|
904
|
+
let deleted = 0;
|
|
905
|
+
for (let i = 0; i < uids.length; i += CHUNK_SIZE) {
|
|
906
|
+
const chunk = uids.slice(i, i + CHUNK_SIZE);
|
|
907
|
+
await client.messageDelete(chunk, { uid: true });
|
|
908
|
+
deleted += chunk.length;
|
|
909
|
+
}
|
|
910
|
+
await client.logout();
|
|
911
|
+
return { deleted, subject };
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
export async function deleteOlderThan(days, mailbox = 'INBOX') {
|
|
915
|
+
const client = createRateLimitedClient();
|
|
916
|
+
await client.connect();
|
|
917
|
+
await client.mailboxOpen(mailbox);
|
|
918
|
+
const date = new Date();
|
|
919
|
+
date.setDate(date.getDate() - days);
|
|
920
|
+
const uids = (await client.search({ before: date }, { uid: true })) ?? [];
|
|
921
|
+
if (uids.length === 0) { await client.logout(); return { deleted: 0 }; }
|
|
922
|
+
let deleted = 0;
|
|
923
|
+
for (let i = 0; i < uids.length; i += CHUNK_SIZE) {
|
|
924
|
+
const chunk = uids.slice(i, i + CHUNK_SIZE);
|
|
925
|
+
await client.messageDelete(chunk, { uid: true });
|
|
926
|
+
deleted += chunk.length;
|
|
927
|
+
}
|
|
928
|
+
await client.logout();
|
|
929
|
+
return { deleted, olderThan: date.toISOString() };
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
export async function getEmailsByDateRange(startDate, endDate, mailbox = 'INBOX', limit = 10) {
|
|
933
|
+
const client = createRateLimitedClient();
|
|
934
|
+
await client.connect();
|
|
935
|
+
await client.mailboxOpen(mailbox);
|
|
936
|
+
const uids = (await client.search({ since: new Date(startDate), before: new Date(endDate) }, { uid: true })) ?? [];
|
|
937
|
+
const total = uids.length;
|
|
938
|
+
const recentUids = uids.slice(-limit).reverse();
|
|
939
|
+
const emails = [];
|
|
940
|
+
for (const uid of recentUids) {
|
|
941
|
+
const msg = await client.fetchOne(uid, { envelope: true, flags: true }, { uid: true });
|
|
942
|
+
if (msg) {
|
|
943
|
+
emails.push({
|
|
944
|
+
uid,
|
|
945
|
+
subject: msg.envelope.subject,
|
|
946
|
+
from: msg.envelope.from?.[0]?.address,
|
|
947
|
+
date: msg.envelope.date,
|
|
948
|
+
flagged: msg.flags.has('\\Flagged'),
|
|
949
|
+
seen: msg.flags.has('\\Seen')
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
await client.logout();
|
|
954
|
+
return { total, showing: emails.length, emails };
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
export async function bulkMarkRead(mailbox = 'INBOX', sender = null) {
|
|
958
|
+
const client = createRateLimitedClient();
|
|
959
|
+
await client.connect();
|
|
960
|
+
await client.mailboxOpen(mailbox);
|
|
961
|
+
const query = sender ? { from: sender, seen: false } : { seen: false };
|
|
962
|
+
const uids = (await client.search(query, { uid: true })) ?? [];
|
|
963
|
+
if (uids.length === 0) { await client.logout(); return { marked: 0 }; }
|
|
964
|
+
await client.messageFlagsAdd(uids, ['\\Seen'], { uid: true });
|
|
965
|
+
await client.logout();
|
|
966
|
+
return { marked: uids.length, sender: sender || 'all' };
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
export async function bulkMarkUnread(mailbox = 'INBOX', sender = null) {
|
|
970
|
+
const client = createRateLimitedClient();
|
|
971
|
+
await client.connect();
|
|
972
|
+
await client.mailboxOpen(mailbox);
|
|
973
|
+
const query = sender ? { from: sender, seen: true } : { seen: true };
|
|
974
|
+
const uids = (await client.search(query, { uid: true })) ?? [];
|
|
975
|
+
if (uids.length === 0) { await client.logout(); return { marked: 0 }; }
|
|
976
|
+
await client.messageFlagsRemove(uids, ['\\Seen'], { uid: true });
|
|
977
|
+
await client.logout();
|
|
978
|
+
return { marked: uids.length, sender: sender || 'all' };
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
export async function bulkFlag(filters, flagged, mailbox = 'INBOX') {
|
|
982
|
+
const client = createRateLimitedClient();
|
|
983
|
+
await client.connect();
|
|
984
|
+
await client.mailboxOpen(mailbox);
|
|
985
|
+
const query = buildQuery(filters);
|
|
986
|
+
const uids = (await client.search(query, { uid: true })) ?? [];
|
|
987
|
+
if (uids.length === 0) { await client.logout(); return { flagged: 0 }; }
|
|
988
|
+
if (flagged) {
|
|
989
|
+
await client.messageFlagsAdd(uids, ['\\Flagged'], { uid: true });
|
|
990
|
+
} else {
|
|
991
|
+
await client.messageFlagsRemove(uids, ['\\Flagged'], { uid: true });
|
|
992
|
+
}
|
|
993
|
+
await client.logout();
|
|
994
|
+
return { [flagged ? 'flagged' : 'unflagged']: uids.length, filters };
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
export async function bulkFlagBySender(sender, flagged, mailbox = 'INBOX') {
|
|
998
|
+
const client = createRateLimitedClient();
|
|
999
|
+
await client.connect();
|
|
1000
|
+
await client.mailboxOpen(mailbox);
|
|
1001
|
+
const raw = await client.search({ from: sender }, { uid: true });
|
|
1002
|
+
const uids = Array.isArray(raw) ? raw : [];
|
|
1003
|
+
if (uids.length === 0) { await client.logout(); return { [flagged ? 'flagged' : 'unflagged']: 0, sender }; }
|
|
1004
|
+
if (flagged) {
|
|
1005
|
+
await client.messageFlagsAdd(uids, ['\\Flagged'], { uid: true });
|
|
1006
|
+
} else {
|
|
1007
|
+
await client.messageFlagsRemove(uids, ['\\Flagged'], { uid: true });
|
|
1008
|
+
}
|
|
1009
|
+
await client.logout();
|
|
1010
|
+
return { [flagged ? 'flagged' : 'unflagged']: uids.length, sender };
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
export async function emptyTrash(dryRun = false) {
|
|
1014
|
+
const t0 = Date.now();
|
|
1015
|
+
const trashFolders = ['Deleted Messages', 'Trash'];
|
|
1016
|
+
const client = createRateLimitedClient();
|
|
1017
|
+
await client.connect();
|
|
1018
|
+
|
|
1019
|
+
let mailbox = null;
|
|
1020
|
+
for (const folder of trashFolders) {
|
|
1021
|
+
try {
|
|
1022
|
+
await client.mailboxOpen(folder);
|
|
1023
|
+
mailbox = folder;
|
|
1024
|
+
break;
|
|
1025
|
+
} catch (err) {
|
|
1026
|
+
if (!err.message.includes('Mailbox does not exist') && !err.message.includes('NONEXISTENT') && !err.message.includes('does not exist')) {
|
|
1027
|
+
await safeClose(client);
|
|
1028
|
+
throw err;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
if (!mailbox) {
|
|
1034
|
+
await safeClose(client);
|
|
1035
|
+
throw new Error('No trash folder found — tried: ' + trashFolders.join(', '));
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
const raw = await client.search({ all: true }, { uid: true });
|
|
1039
|
+
const uids = Array.isArray(raw) ? raw : [];
|
|
1040
|
+
|
|
1041
|
+
if (dryRun) {
|
|
1042
|
+
await safeClose(client);
|
|
1043
|
+
return { dryRun: true, wouldDelete: uids.length, mailbox };
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
if (uids.length === 0) {
|
|
1047
|
+
await safeClose(client);
|
|
1048
|
+
return { deleted: 0, mailbox, timeTaken: ((Date.now() - t0) / 1000).toFixed(1) + 's' };
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
let deleted = 0;
|
|
1052
|
+
for (let i = 0; i < uids.length; i += CHUNK_SIZE) {
|
|
1053
|
+
const chunk = uids.slice(i, i + CHUNK_SIZE);
|
|
1054
|
+
await client.messageDelete(chunk, { uid: true });
|
|
1055
|
+
deleted += chunk.length;
|
|
1056
|
+
}
|
|
1057
|
+
await safeClose(client);
|
|
1058
|
+
return { deleted, mailbox, timeTaken: ((Date.now() - t0) / 1000).toFixed(1) + 's' };
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
export async function archiveOlderThan(days, targetMailbox, sourceMailbox = 'INBOX', dryRun = false) {
|
|
1062
|
+
const client = createRateLimitedClient();
|
|
1063
|
+
await client.connect();
|
|
1064
|
+
await client.mailboxOpen(sourceMailbox);
|
|
1065
|
+
const date = new Date();
|
|
1066
|
+
date.setDate(date.getDate() - days);
|
|
1067
|
+
const raw = await client.search({ before: date }, { uid: true });
|
|
1068
|
+
const uids = Array.isArray(raw) ? raw : [];
|
|
1069
|
+
await client.logout();
|
|
1070
|
+
if (dryRun) return { dryRun: true, wouldMove: uids.length, olderThan: date.toISOString(), sourceMailbox, targetMailbox };
|
|
1071
|
+
if (uids.length === 0) return { moved: 0, olderThan: date.toISOString(), sourceMailbox, targetMailbox };
|
|
1072
|
+
await ensureMailbox(targetMailbox);
|
|
1073
|
+
const result = await safeMoveEmails(uids, sourceMailbox, targetMailbox);
|
|
1074
|
+
return { ...result, olderThan: date.toISOString(), sourceMailbox, targetMailbox };
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
export async function getStorageReport(mailbox = 'INBOX', sampleSize = 100) {
|
|
1078
|
+
const client = createRateLimitedClient();
|
|
1079
|
+
await client.connect();
|
|
1080
|
+
await client.mailboxOpen(mailbox);
|
|
1081
|
+
|
|
1082
|
+
// Count emails by size bucket using 4x SEARCH LARGER
|
|
1083
|
+
const thresholds = [10 * 1024, 100 * 1024, 1024 * 1024, 10 * 1024 * 1024];
|
|
1084
|
+
const counts = [];
|
|
1085
|
+
for (const thresh of thresholds) {
|
|
1086
|
+
const r = await client.search({ larger: thresh }, { uid: true }).catch(() => []);
|
|
1087
|
+
counts.push(Array.isArray(r) ? r.length : 0);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
const buckets = [
|
|
1091
|
+
{ range: '10KB–100KB', count: counts[0] - counts[1] },
|
|
1092
|
+
{ range: '100KB–1MB', count: counts[1] - counts[2] },
|
|
1093
|
+
{ range: '1MB–10MB', count: counts[2] - counts[3] },
|
|
1094
|
+
{ range: '10MB+', count: counts[3] }
|
|
1095
|
+
];
|
|
1096
|
+
|
|
1097
|
+
// Sample top senders among large emails (> 100 KB)
|
|
1098
|
+
const largeRaw = await client.search({ larger: 100 * 1024 }, { uid: true }).catch(() => []);
|
|
1099
|
+
const largeUids = Array.isArray(largeRaw) ? largeRaw : [];
|
|
1100
|
+
const sampleUids = largeUids.slice(-sampleSize);
|
|
1101
|
+
|
|
1102
|
+
const senderSizes = {};
|
|
1103
|
+
if (sampleUids.length > 0) {
|
|
1104
|
+
for await (const msg of client.fetch(sampleUids, { envelope: true, bodyStructure: true }, { uid: true })) {
|
|
1105
|
+
const address = msg.envelope?.from?.[0]?.address;
|
|
1106
|
+
if (address && msg.bodyStructure) {
|
|
1107
|
+
senderSizes[address] = (senderSizes[address] || 0) + estimateEmailSize(msg.bodyStructure);
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
await client.logout();
|
|
1113
|
+
|
|
1114
|
+
const topSendersBySize = Object.entries(senderSizes)
|
|
1115
|
+
.sort((a, b) => b[1] - a[1])
|
|
1116
|
+
.slice(0, 10)
|
|
1117
|
+
.map(([address, estimateBytes]) => ({ address, estimateKB: Math.round(estimateBytes / 1024) }));
|
|
1118
|
+
|
|
1119
|
+
const midpoints = [50, 512, 5120, 15360]; // rough KB midpoint for each bucket
|
|
1120
|
+
const estimatedTotalKB = buckets.reduce((sum, b, i) => sum + b.count * midpoints[i], 0);
|
|
1121
|
+
|
|
1122
|
+
return {
|
|
1123
|
+
mailbox,
|
|
1124
|
+
buckets,
|
|
1125
|
+
estimatedTotalKB,
|
|
1126
|
+
topSendersBySize,
|
|
1127
|
+
...(sampleUids.length < largeUids.length && {
|
|
1128
|
+
note: `Sender analysis sampled ${sampleUids.length} of ${largeUids.length} large emails (>100 KB)`
|
|
1129
|
+
})
|
|
1130
|
+
};
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
export async function getThread(uid, mailbox = 'INBOX') {
|
|
1134
|
+
const THREAD_CANDIDATE_CAP = 100;
|
|
1135
|
+
const client = createRateLimitedClient();
|
|
1136
|
+
await client.connect();
|
|
1137
|
+
await client.mailboxOpen(mailbox);
|
|
1138
|
+
|
|
1139
|
+
// Fetch target email's envelope + raw headers for threading
|
|
1140
|
+
const meta = await client.fetchOne(uid, {
|
|
1141
|
+
envelope: true,
|
|
1142
|
+
flags: true,
|
|
1143
|
+
headers: new Set(['references', 'in-reply-to'])
|
|
1144
|
+
}, { uid: true });
|
|
1145
|
+
if (!meta) throw new Error(`Email UID ${uid} not found`);
|
|
1146
|
+
|
|
1147
|
+
const targetMessageId = meta.envelope?.messageId ?? null;
|
|
1148
|
+
const rawRefs = extractRawHeader(meta.headers, 'references');
|
|
1149
|
+
const rawInReplyTo = extractRawHeader(meta.headers, 'in-reply-to');
|
|
1150
|
+
|
|
1151
|
+
// Build full reference set for this email
|
|
1152
|
+
const threadRefs = new Set();
|
|
1153
|
+
if (targetMessageId) threadRefs.add(targetMessageId.trim());
|
|
1154
|
+
if (rawInReplyTo) threadRefs.add(rawInReplyTo.trim());
|
|
1155
|
+
if (rawRefs) {
|
|
1156
|
+
rawRefs.split(/\s+/).filter(s => s.startsWith('<') && s.endsWith('>')).forEach(r => threadRefs.add(r));
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
const normalizedSubject = stripSubjectPrefixes(meta.envelope?.subject ?? '');
|
|
1160
|
+
|
|
1161
|
+
// SEARCH SUBJECT for candidates (iCloud doesn't support SEARCH HEADER)
|
|
1162
|
+
let candidateUids = [];
|
|
1163
|
+
if (normalizedSubject) {
|
|
1164
|
+
const raw = await client.search({ subject: normalizedSubject }, { uid: true });
|
|
1165
|
+
candidateUids = Array.isArray(raw) ? raw : [];
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
const candidatesCapped = candidateUids.length > THREAD_CANDIDATE_CAP;
|
|
1169
|
+
if (candidatesCapped) candidateUids = candidateUids.slice(-THREAD_CANDIDATE_CAP);
|
|
1170
|
+
|
|
1171
|
+
// Fetch envelopes + headers for candidates to filter by References overlap
|
|
1172
|
+
const threadEmails = [];
|
|
1173
|
+
if (candidateUids.length > 0) {
|
|
1174
|
+
for await (const msg of client.fetch(candidateUids, {
|
|
1175
|
+
envelope: true,
|
|
1176
|
+
flags: true,
|
|
1177
|
+
headers: new Set(['references', 'in-reply-to'])
|
|
1178
|
+
}, { uid: true })) {
|
|
1179
|
+
const msgId = msg.envelope?.messageId ?? null;
|
|
1180
|
+
const msgRefs = extractRawHeader(msg.headers, 'references');
|
|
1181
|
+
const msgInReplyTo = extractRawHeader(msg.headers, 'in-reply-to');
|
|
1182
|
+
|
|
1183
|
+
// Build this message's reference set
|
|
1184
|
+
const msgRefSet = new Set();
|
|
1185
|
+
if (msgId) msgRefSet.add(msgId.trim());
|
|
1186
|
+
if (msgInReplyTo) msgRefSet.add(msgInReplyTo.trim());
|
|
1187
|
+
if (msgRefs) msgRefs.split(/\s+/).filter(s => s.startsWith('<')).forEach(r => msgRefSet.add(r));
|
|
1188
|
+
|
|
1189
|
+
// Include if there's any Reference chain overlap
|
|
1190
|
+
const hasOverlap = (msgId && threadRefs.has(msgId.trim())) ||
|
|
1191
|
+
[...threadRefs].some(r => msgRefSet.has(r));
|
|
1192
|
+
|
|
1193
|
+
if (hasOverlap) {
|
|
1194
|
+
threadEmails.push({
|
|
1195
|
+
uid: msg.uid,
|
|
1196
|
+
subject: msg.envelope?.subject,
|
|
1197
|
+
from: msg.envelope?.from?.[0]?.address,
|
|
1198
|
+
date: msg.envelope?.date,
|
|
1199
|
+
seen: msg.flags?.has('\\Seen') ?? false,
|
|
1200
|
+
flagged: msg.flags?.has('\\Flagged') ?? false,
|
|
1201
|
+
messageId: msgId
|
|
1202
|
+
});
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
await client.logout();
|
|
1208
|
+
|
|
1209
|
+
// Sort by date ascending
|
|
1210
|
+
threadEmails.sort((a, b) => {
|
|
1211
|
+
const da = a.date ? new Date(a.date).getTime() : 0;
|
|
1212
|
+
const db = b.date ? new Date(b.date).getTime() : 0;
|
|
1213
|
+
return da - db;
|
|
1214
|
+
});
|
|
1215
|
+
|
|
1216
|
+
return {
|
|
1217
|
+
uid,
|
|
1218
|
+
subject: normalizedSubject || meta.envelope?.subject,
|
|
1219
|
+
count: threadEmails.length,
|
|
1220
|
+
emails: threadEmails,
|
|
1221
|
+
...(candidatesCapped && {
|
|
1222
|
+
candidatesCapped: true,
|
|
1223
|
+
note: `Subject search returned more than ${THREAD_CANDIDATE_CAP} candidates — thread results may be incomplete`
|
|
1224
|
+
})
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
export async function createMailbox(name) {
|
|
1229
|
+
const client = createRateLimitedClient();
|
|
1230
|
+
await client.connect();
|
|
1231
|
+
await client.mailboxCreate(name);
|
|
1232
|
+
await client.logout();
|
|
1233
|
+
return { created: name };
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
export async function renameMailbox(oldName, newName) {
|
|
1237
|
+
const client = createRateLimitedClient();
|
|
1238
|
+
await client.connect();
|
|
1239
|
+
try {
|
|
1240
|
+
await Promise.race([
|
|
1241
|
+
client.mailboxRename(oldName, newName),
|
|
1242
|
+
new Promise((_, reject) =>
|
|
1243
|
+
setTimeout(() => reject(new Error('rename timed out after 15s — Apple IMAP may not support renaming this folder')), 15000)
|
|
1244
|
+
)
|
|
1245
|
+
]);
|
|
1246
|
+
} finally {
|
|
1247
|
+
try { await client.logout(); } catch { client.close(); }
|
|
1248
|
+
}
|
|
1249
|
+
return { renamed: { from: oldName, to: newName } };
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
export async function deleteMailbox(name) {
|
|
1253
|
+
const client = createRateLimitedClient();
|
|
1254
|
+
await client.connect();
|
|
1255
|
+
try {
|
|
1256
|
+
await Promise.race([
|
|
1257
|
+
client.mailboxDelete(name),
|
|
1258
|
+
new Promise((_, reject) =>
|
|
1259
|
+
setTimeout(() => reject(new Error('delete timed out after 15s — Apple IMAP may not support deleting this folder')), 15000)
|
|
1260
|
+
)
|
|
1261
|
+
]);
|
|
1262
|
+
} finally {
|
|
1263
|
+
try { await client.logout(); } catch { client.close(); }
|
|
1264
|
+
}
|
|
1265
|
+
return { deleted: name };
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
export async function getMailboxSummary(mailbox) {
|
|
1269
|
+
const client = createRateLimitedClient();
|
|
1270
|
+
await client.connect();
|
|
1271
|
+
const status = await client.status(mailbox, { messages: true, unseen: true, recent: true });
|
|
1272
|
+
await client.logout();
|
|
1273
|
+
return { mailbox, total: status.messages, unread: status.unseen, recent: status.recent };
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
// ─── Email content fetcher (MIME-aware) ───────────────────────────────────────
|
|
1277
|
+
|
|
1278
|
+
export async function getEmailContent(uid, mailbox = 'INBOX', maxChars = 8000, includeHeaders = false) {
|
|
1279
|
+
const client = createRateLimitedClient();
|
|
1280
|
+
await client.connect();
|
|
1281
|
+
await client.mailboxOpen(mailbox);
|
|
1282
|
+
|
|
1283
|
+
const fetchOpts = { envelope: true, flags: true, bodyStructure: true };
|
|
1284
|
+
if (includeHeaders) fetchOpts.headers = new Set(['references', 'list-unsubscribe']);
|
|
1285
|
+
const meta = await client.fetchOne(uid, fetchOpts, { uid: true });
|
|
1286
|
+
if (!meta) {
|
|
1287
|
+
await client.logout();
|
|
1288
|
+
return { uid, subject: null, from: null, date: null, flags: [], body: '(email not found)' };
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
let body = '(body unavailable)';
|
|
1292
|
+
|
|
1293
|
+
try {
|
|
1294
|
+
const struct = meta.bodyStructure;
|
|
1295
|
+
if (!struct) throw new Error('no bodyStructure');
|
|
1296
|
+
|
|
1297
|
+
const textPart = findTextPart(struct);
|
|
1298
|
+
|
|
1299
|
+
if (!textPart) {
|
|
1300
|
+
body = '(no readable text — email may be image-only or have no text parts)';
|
|
1301
|
+
} else {
|
|
1302
|
+
// Single-part messages use 'TEXT'; multipart use dot-notation part id (e.g. '1', '1.1')
|
|
1303
|
+
const imapKey = textPart.partId ?? 'TEXT';
|
|
1304
|
+
|
|
1305
|
+
// For large parts, cap the fetch at 12KB to avoid downloading multi-MB newsletters
|
|
1306
|
+
const fetchSpec = (textPart.size && textPart.size > 150_000)
|
|
1307
|
+
? [{ key: imapKey, start: 0, maxLength: 12_000 }]
|
|
1308
|
+
: [imapKey];
|
|
1309
|
+
|
|
1310
|
+
const partMsg = await Promise.race([
|
|
1311
|
+
client.fetchOne(uid, { bodyParts: fetchSpec }, { uid: true }),
|
|
1312
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('body fetch timeout')), 10_000))
|
|
1313
|
+
]);
|
|
1314
|
+
|
|
1315
|
+
// bodyParts is a Map — try the key as-is, then uppercase, then lowercase
|
|
1316
|
+
const partBuffer = partMsg?.bodyParts?.get(imapKey)
|
|
1317
|
+
?? partMsg?.bodyParts?.get(imapKey.toUpperCase())
|
|
1318
|
+
?? partMsg?.bodyParts?.get(imapKey.toLowerCase());
|
|
1319
|
+
|
|
1320
|
+
if (!partBuffer || partBuffer.length === 0) throw new Error('empty body part');
|
|
1321
|
+
|
|
1322
|
+
const decoded = decodeTransferEncoding(partBuffer, textPart.encoding);
|
|
1323
|
+
let text = await decodeCharset(decoded, textPart.charset);
|
|
1324
|
+
|
|
1325
|
+
if (textPart.type === 'text/html') text = stripHtml(text);
|
|
1326
|
+
|
|
1327
|
+
const clampedMaxChars = Math.min(maxChars, 50_000);
|
|
1328
|
+
if (text.length > clampedMaxChars) {
|
|
1329
|
+
text = text.slice(0, clampedMaxChars) + `\n\n[... truncated — ${text.length.toLocaleString()} chars total]`;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
body = text.trim() || '(empty body)';
|
|
1333
|
+
|
|
1334
|
+
if (textPart.size && textPart.size > 150_000) {
|
|
1335
|
+
body += `\n\n[Note: email body is large (${Math.round(textPart.size / 1024)}KB) — showing first 12KB]`;
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
} catch {
|
|
1339
|
+
// Fallback: raw source slice (original behaviour)
|
|
1340
|
+
try {
|
|
1341
|
+
const sourceMsg = await Promise.race([
|
|
1342
|
+
client.fetchOne(uid, { source: true }, { uid: true }),
|
|
1343
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 5_000))
|
|
1344
|
+
]);
|
|
1345
|
+
if (sourceMsg?.source) {
|
|
1346
|
+
const raw = sourceMsg.source.toString();
|
|
1347
|
+
const bodyStart = raw.indexOf('\r\n\r\n');
|
|
1348
|
+
body = '[raw fallback]\n' + (bodyStart > -1 ? raw.slice(bodyStart + 4, bodyStart + 2000) : raw.slice(0, 2000));
|
|
1349
|
+
}
|
|
1350
|
+
} catch { /* leave as unavailable */ }
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
await client.logout();
|
|
1354
|
+
|
|
1355
|
+
const attachments = meta.bodyStructure ? findAttachments(meta.bodyStructure) : [];
|
|
1356
|
+
const result = {
|
|
1357
|
+
uid: meta.uid,
|
|
1358
|
+
subject: meta.envelope.subject,
|
|
1359
|
+
from: meta.envelope.from?.[0]?.address,
|
|
1360
|
+
date: meta.envelope.date,
|
|
1361
|
+
flags: [...meta.flags],
|
|
1362
|
+
attachments: {
|
|
1363
|
+
count: attachments.length,
|
|
1364
|
+
items: attachments.map(a => ({ partId: a.partId, filename: a.filename, mimeType: a.mimeType, size: a.size }))
|
|
1365
|
+
},
|
|
1366
|
+
body
|
|
1367
|
+
};
|
|
1368
|
+
|
|
1369
|
+
if (includeHeaders) {
|
|
1370
|
+
// imapflow returns headers as a raw Buffer — parse it as text
|
|
1371
|
+
const rawRefs = extractRawHeader(meta.headers, 'references');
|
|
1372
|
+
const rawUnsub = extractRawHeader(meta.headers, 'list-unsubscribe');
|
|
1373
|
+
result.headers = {
|
|
1374
|
+
to: meta.envelope.to?.map(a => a.address) ?? [],
|
|
1375
|
+
cc: meta.envelope.cc?.map(a => a.address) ?? [],
|
|
1376
|
+
replyTo: meta.envelope.replyTo?.[0]?.address ?? null,
|
|
1377
|
+
messageId: meta.envelope.messageId ?? null,
|
|
1378
|
+
inReplyTo: meta.envelope.inReplyTo ?? null,
|
|
1379
|
+
references: rawRefs ? rawRefs.split(/\s+/).filter(s => s.startsWith('<')) : [],
|
|
1380
|
+
listUnsubscribe: rawUnsub || null
|
|
1381
|
+
};
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
return result;
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
export async function listAttachments(uid, mailbox = 'INBOX') {
|
|
1388
|
+
const client = createRateLimitedClient();
|
|
1389
|
+
await client.connect();
|
|
1390
|
+
await client.mailboxOpen(mailbox);
|
|
1391
|
+
const meta = await client.fetchOne(uid, { envelope: true, bodyStructure: true }, { uid: true });
|
|
1392
|
+
await client.logout();
|
|
1393
|
+
if (!meta) return { uid, subject: null, attachmentCount: 0, attachments: [] };
|
|
1394
|
+
const attachments = meta.bodyStructure ? findAttachments(meta.bodyStructure) : [];
|
|
1395
|
+
return {
|
|
1396
|
+
uid: meta.uid,
|
|
1397
|
+
subject: meta.envelope.subject,
|
|
1398
|
+
attachmentCount: attachments.length,
|
|
1399
|
+
attachments
|
|
1400
|
+
};
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
export async function getUnsubscribeInfo(uid, mailbox = 'INBOX') {
|
|
1404
|
+
const client = createRateLimitedClient();
|
|
1405
|
+
await client.connect();
|
|
1406
|
+
await client.mailboxOpen(mailbox);
|
|
1407
|
+
const meta = await client.fetchOne(uid, { headers: new Set(['list-unsubscribe', 'list-unsubscribe-post']) }, { uid: true });
|
|
1408
|
+
await client.logout();
|
|
1409
|
+
if (!meta) return { uid, email: null, url: null, raw: null };
|
|
1410
|
+
const raw = extractRawHeader(meta.headers, 'list-unsubscribe') || null;
|
|
1411
|
+
if (!raw) return { uid, email: null, url: null, raw: null };
|
|
1412
|
+
const email = raw.match(/<mailto:([^>]+)>/i)?.[1] ?? null;
|
|
1413
|
+
const url = raw.match(/<(https?:[^>]+)>/i)?.[1] ?? null;
|
|
1414
|
+
return { uid, email, url, raw };
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
export async function getEmailRaw(uid, mailbox = 'INBOX') {
|
|
1418
|
+
const MAX_RAW_BYTES = 1 * 1024 * 1024; // 1 MB cap
|
|
1419
|
+
const client = createRateLimitedClient();
|
|
1420
|
+
await client.connect();
|
|
1421
|
+
await client.mailboxOpen(mailbox);
|
|
1422
|
+
const msg = await client.fetchOne(uid, { source: true }, { uid: true });
|
|
1423
|
+
await client.logout();
|
|
1424
|
+
if (!msg || !msg.source) throw new Error(`Email UID ${uid} not found`);
|
|
1425
|
+
const source = msg.source;
|
|
1426
|
+
const truncated = source.length > MAX_RAW_BYTES;
|
|
1427
|
+
const slice = truncated ? source.slice(0, MAX_RAW_BYTES) : source;
|
|
1428
|
+
return {
|
|
1429
|
+
uid,
|
|
1430
|
+
size: source.length,
|
|
1431
|
+
truncated,
|
|
1432
|
+
data: slice.toString('base64'),
|
|
1433
|
+
dataEncoding: 'base64'
|
|
1434
|
+
};
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
export async function getAttachment(uid, partId, mailbox = 'INBOX', offset = null, length = null) {
|
|
1438
|
+
const client = createRateLimitedClient();
|
|
1439
|
+
await client.connect();
|
|
1440
|
+
await client.mailboxOpen(mailbox);
|
|
1441
|
+
|
|
1442
|
+
// First fetch bodyStructure to find the attachment and validate size
|
|
1443
|
+
const meta = await client.fetchOne(uid, { bodyStructure: true }, { uid: true });
|
|
1444
|
+
if (!meta) throw new Error(`Email UID ${uid} not found`);
|
|
1445
|
+
|
|
1446
|
+
const attachments = meta.bodyStructure ? findAttachments(meta.bodyStructure) : [];
|
|
1447
|
+
const att = attachments.find(a => a.partId === partId);
|
|
1448
|
+
if (!att) throw new Error(`Part ID "${partId}" not found in email UID ${uid}. Use list_attachments to see available parts.`);
|
|
1449
|
+
|
|
1450
|
+
const isPaginated = offset !== null || length !== null;
|
|
1451
|
+
|
|
1452
|
+
if (!isPaginated && att.size > MAX_ATTACHMENT_BYTES) {
|
|
1453
|
+
await client.logout();
|
|
1454
|
+
return {
|
|
1455
|
+
error: `Attachment too large to download in one request (${Math.round(att.size / 1024 / 1024 * 10) / 10} MB). Use offset and length params to download in chunks (max ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB per request).`,
|
|
1456
|
+
filename: att.filename,
|
|
1457
|
+
mimeType: att.mimeType,
|
|
1458
|
+
size: att.size,
|
|
1459
|
+
totalSize: att.size
|
|
1460
|
+
};
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
// Build fetch spec
|
|
1464
|
+
let fetchSpec;
|
|
1465
|
+
if (isPaginated) {
|
|
1466
|
+
const start = offset ?? 0;
|
|
1467
|
+
const maxLength = length ?? MAX_ATTACHMENT_BYTES;
|
|
1468
|
+
fetchSpec = [{ key: partId, start, maxLength }];
|
|
1469
|
+
} else {
|
|
1470
|
+
fetchSpec = [partId];
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
// Fetch the raw body part bytes
|
|
1474
|
+
const rawChunks = [];
|
|
1475
|
+
for await (const msg of client.fetch({ uid }, { bodyParts: fetchSpec }, { uid: true })) {
|
|
1476
|
+
const buf = msg.bodyParts?.get(partId)
|
|
1477
|
+
?? msg.bodyParts?.get(partId.toUpperCase())
|
|
1478
|
+
?? msg.bodyParts?.get(partId.toLowerCase());
|
|
1479
|
+
if (buf) rawChunks.push(buf);
|
|
1480
|
+
}
|
|
1481
|
+
await client.logout();
|
|
1482
|
+
|
|
1483
|
+
if (rawChunks.length === 0) throw new Error(`No data returned for part "${partId}" of UID ${uid}`);
|
|
1484
|
+
|
|
1485
|
+
const raw = Buffer.concat(rawChunks);
|
|
1486
|
+
|
|
1487
|
+
if (isPaginated) {
|
|
1488
|
+
// Paginated: return raw encoded bytes without transfer-encoding decode
|
|
1489
|
+
const fetchOffset = offset ?? 0;
|
|
1490
|
+
const actualLength = raw.length;
|
|
1491
|
+
const hasMore = att.size ? (fetchOffset + actualLength < att.size) : false;
|
|
1492
|
+
return {
|
|
1493
|
+
uid, partId,
|
|
1494
|
+
filename: att.filename,
|
|
1495
|
+
mimeType: att.mimeType,
|
|
1496
|
+
encoding: att.encoding,
|
|
1497
|
+
totalSize: att.size,
|
|
1498
|
+
offset: fetchOffset,
|
|
1499
|
+
length: actualLength,
|
|
1500
|
+
hasMore,
|
|
1501
|
+
data: raw.toString('base64'),
|
|
1502
|
+
dataEncoding: 'base64'
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
// Full download: decode transfer encoding
|
|
1507
|
+
const encoding = att.encoding.toLowerCase();
|
|
1508
|
+
let decoded;
|
|
1509
|
+
if (encoding === 'base64') {
|
|
1510
|
+
decoded = Buffer.from(raw.toString('ascii').replace(/\s/g, ''), 'base64');
|
|
1511
|
+
} else if (encoding === 'quoted-printable') {
|
|
1512
|
+
const qp = raw.toString('binary').replace(/=\r?\n/g, '').replace(/=([0-9A-Fa-f]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16)));
|
|
1513
|
+
decoded = Buffer.from(qp, 'binary');
|
|
1514
|
+
} else {
|
|
1515
|
+
decoded = raw; // 7bit / 8bit / binary
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
return {
|
|
1519
|
+
uid,
|
|
1520
|
+
partId,
|
|
1521
|
+
filename: att.filename,
|
|
1522
|
+
mimeType: att.mimeType,
|
|
1523
|
+
size: decoded.length,
|
|
1524
|
+
encoding: att.encoding,
|
|
1525
|
+
data: decoded.toString('base64'),
|
|
1526
|
+
dataEncoding: 'base64'
|
|
1527
|
+
};
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
export async function flagEmail(uid, flagged, mailbox = 'INBOX') {
|
|
1531
|
+
const client = createRateLimitedClient();
|
|
1532
|
+
await client.connect();
|
|
1533
|
+
await client.mailboxOpen(mailbox);
|
|
1534
|
+
if (flagged) {
|
|
1535
|
+
await client.messageFlagsAdd(uid, ['\\Flagged'], { uid: true });
|
|
1536
|
+
} else {
|
|
1537
|
+
await client.messageFlagsRemove(uid, ['\\Flagged'], { uid: true });
|
|
1538
|
+
}
|
|
1539
|
+
await client.logout();
|
|
1540
|
+
return true;
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
export async function markAsRead(uid, seen, mailbox = 'INBOX') {
|
|
1544
|
+
const client = createRateLimitedClient();
|
|
1545
|
+
await client.connect();
|
|
1546
|
+
await client.mailboxOpen(mailbox);
|
|
1547
|
+
if (seen) {
|
|
1548
|
+
await client.messageFlagsAdd(uid, ['\\Seen'], { uid: true });
|
|
1549
|
+
} else {
|
|
1550
|
+
await client.messageFlagsRemove(uid, ['\\Seen'], { uid: true });
|
|
1551
|
+
}
|
|
1552
|
+
await client.logout();
|
|
1553
|
+
return true;
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
export async function deleteEmail(uid, mailbox = 'INBOX') {
|
|
1557
|
+
const client = createRateLimitedClient();
|
|
1558
|
+
await client.connect();
|
|
1559
|
+
await client.mailboxOpen(mailbox);
|
|
1560
|
+
await client.messageDelete(uid, { uid: true });
|
|
1561
|
+
await client.logout();
|
|
1562
|
+
return true;
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
export async function listMailboxes() {
|
|
1566
|
+
const client = createRateLimitedClient();
|
|
1567
|
+
await client.connect();
|
|
1568
|
+
const tree = await client.listTree();
|
|
1569
|
+
const mailboxes = [];
|
|
1570
|
+
function walk(items) {
|
|
1571
|
+
for (const item of items) {
|
|
1572
|
+
mailboxes.push({ name: item.name, path: item.path });
|
|
1573
|
+
if (item.folders && item.folders.length > 0) walk(item.folders);
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
walk(tree.folders);
|
|
1577
|
+
await client.logout();
|
|
1578
|
+
return mailboxes;
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
export async function searchEmails(query, mailbox = 'INBOX', limit = 10, filters = {}, options = {}) {
|
|
1582
|
+
const { queryMode = 'or', subjectQuery, bodyQuery, fromQuery, includeSnippet = false } = options;
|
|
1583
|
+
const client = createRateLimitedClient();
|
|
1584
|
+
await client.connect();
|
|
1585
|
+
await client.mailboxOpen(mailbox);
|
|
1586
|
+
|
|
1587
|
+
// Build text query
|
|
1588
|
+
let textQuery;
|
|
1589
|
+
const targetedParts = [];
|
|
1590
|
+
if (subjectQuery) targetedParts.push({ subject: subjectQuery });
|
|
1591
|
+
if (bodyQuery) targetedParts.push({ body: bodyQuery });
|
|
1592
|
+
if (fromQuery) targetedParts.push({ from: fromQuery });
|
|
1593
|
+
|
|
1594
|
+
if (targetedParts.length > 0) {
|
|
1595
|
+
// Targeted field queries
|
|
1596
|
+
if (queryMode === 'and') {
|
|
1597
|
+
textQuery = Object.assign({}, ...targetedParts); // IMAP AND is implicit
|
|
1598
|
+
} else {
|
|
1599
|
+
textQuery = targetedParts.length === 1 ? targetedParts[0] : { or: targetedParts };
|
|
1600
|
+
}
|
|
1601
|
+
} else if (query) {
|
|
1602
|
+
// Original OR across subject/from/body
|
|
1603
|
+
textQuery = { or: [{ subject: query }, { from: query }, { body: query }] };
|
|
1604
|
+
} else {
|
|
1605
|
+
textQuery = null;
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
const extraQuery = buildQuery(filters);
|
|
1609
|
+
const hasExtra = Object.keys(extraQuery).length > 0 && !extraQuery.all;
|
|
1610
|
+
const finalQuery = textQuery
|
|
1611
|
+
? (hasExtra ? { ...textQuery, ...extraQuery } : textQuery)
|
|
1612
|
+
: (hasExtra ? extraQuery : { all: true });
|
|
1613
|
+
|
|
1614
|
+
let uids = (await client.search(finalQuery, { uid: true })) ?? [];
|
|
1615
|
+
if (!Array.isArray(uids)) uids = [];
|
|
1616
|
+
|
|
1617
|
+
if (filters.hasAttachment) {
|
|
1618
|
+
if (uids.length > ATTACHMENT_SCAN_LIMIT) {
|
|
1619
|
+
await client.logout();
|
|
1620
|
+
return { total: null, showing: 0, emails: [], error: `hasAttachment requires narrower filters first — ${uids.length} candidates exceeds scan limit of ${ATTACHMENT_SCAN_LIMIT}. Add from/since/before/subject filters to reduce the set.` };
|
|
1621
|
+
}
|
|
1622
|
+
uids = await filterUidsByAttachment(client, uids);
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
const emails = [];
|
|
1626
|
+
const recentUids = uids.slice(-limit).reverse();
|
|
1627
|
+
for (const uid of recentUids) {
|
|
1628
|
+
const msg = await client.fetchOne(uid, { envelope: true, flags: true }, { uid: true });
|
|
1629
|
+
if (msg) {
|
|
1630
|
+
emails.push({
|
|
1631
|
+
uid,
|
|
1632
|
+
subject: msg.envelope.subject,
|
|
1633
|
+
from: msg.envelope.from?.[0]?.address,
|
|
1634
|
+
date: msg.envelope.date,
|
|
1635
|
+
flagged: msg.flags.has('\\Flagged'),
|
|
1636
|
+
seen: msg.flags.has('\\Seen')
|
|
1637
|
+
});
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
// Fetch body snippets if requested (max 10 emails to avoid timeout)
|
|
1642
|
+
if (includeSnippet && emails.length > 0) {
|
|
1643
|
+
for (const email of emails.slice(0, 10)) {
|
|
1644
|
+
try {
|
|
1645
|
+
const meta = await client.fetchOne(email.uid, { bodyStructure: true }, { uid: true });
|
|
1646
|
+
if (!meta?.bodyStructure) continue;
|
|
1647
|
+
const textPart = findTextPart(meta.bodyStructure);
|
|
1648
|
+
if (!textPart) continue;
|
|
1649
|
+
const imapKey = textPart.partId ?? 'TEXT';
|
|
1650
|
+
const partMsg = await client.fetchOne(email.uid, {
|
|
1651
|
+
bodyParts: [{ key: imapKey, start: 0, maxLength: 400 }]
|
|
1652
|
+
}, { uid: true });
|
|
1653
|
+
const buf = partMsg?.bodyParts?.get(imapKey)
|
|
1654
|
+
?? partMsg?.bodyParts?.get(imapKey.toUpperCase())
|
|
1655
|
+
?? partMsg?.bodyParts?.get(imapKey.toLowerCase());
|
|
1656
|
+
if (!buf) continue;
|
|
1657
|
+
const decoded = decodeTransferEncoding(buf, textPart.encoding);
|
|
1658
|
+
let text = await decodeCharset(decoded, textPart.charset);
|
|
1659
|
+
if (textPart.type === 'text/html') text = stripHtml(text);
|
|
1660
|
+
email.snippet = text.replace(/\s+/g, ' ').slice(0, 200).trim();
|
|
1661
|
+
} catch { /* skip snippet on error */ }
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
await client.logout();
|
|
1666
|
+
return { total: uids.length, showing: emails.length, emails };
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
export async function moveEmail(uid, targetMailbox, sourceMailbox = 'INBOX') {
|
|
1670
|
+
const client = createRateLimitedClient();
|
|
1671
|
+
await client.connect();
|
|
1672
|
+
await client.mailboxOpen(sourceMailbox);
|
|
1673
|
+
await client.messageMove(uid, targetMailbox, { uid: true });
|
|
1674
|
+
await client.logout();
|
|
1675
|
+
return true;
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
function buildQuery(filters) {
|
|
1679
|
+
const query = {};
|
|
1680
|
+
if (filters.sender) query.from = filters.sender;
|
|
1681
|
+
if (filters.domain) query.from = filters.domain.replace(/^@/, '');
|
|
1682
|
+
if (filters.subject) query.subject = filters.subject;
|
|
1683
|
+
if (filters.before) query.before = new Date(filters.before);
|
|
1684
|
+
if (filters.since) query.since = new Date(filters.since);
|
|
1685
|
+
if (filters.unread === true) query.seen = false;
|
|
1686
|
+
if (filters.unread === false) query.seen = true;
|
|
1687
|
+
if (filters.flagged === true) query.flagged = true;
|
|
1688
|
+
if (filters.flagged === false) query.unflagged = true;
|
|
1689
|
+
if (filters.larger) query.larger = filters.larger * 1024;
|
|
1690
|
+
if (filters.smaller) query.smaller = filters.smaller * 1024;
|
|
1691
|
+
// hasAttachment is handled as a client-side post-filter (see filterUidsByAttachment)
|
|
1692
|
+
// iCloud does not support SEARCH HEADER or reliable size-based attachment detection
|
|
1693
|
+
if (Object.keys(query).length === 0) query.all = true;
|
|
1694
|
+
return query;
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
async function filterUidsByAttachment(client, uids) {
|
|
1698
|
+
if (uids.length === 0) return [];
|
|
1699
|
+
const result = [];
|
|
1700
|
+
for await (const msg of client.fetch(uids, { bodyStructure: true }, { uid: true })) {
|
|
1701
|
+
if (msg.bodyStructure && findAttachments(msg.bodyStructure).length > 0) {
|
|
1702
|
+
result.push(msg.uid);
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
return result;
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
async function ensureMailbox(name) {
|
|
1709
|
+
const client = createRateLimitedClient();
|
|
1710
|
+
await client.connect();
|
|
1711
|
+
try { await client.mailboxCreate(name); } catch { /* already exists */ }
|
|
1712
|
+
await client.logout();
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
export async function bulkMove(filters, targetMailbox, sourceMailbox = 'INBOX', dryRun = false, limit = null) {
|
|
1716
|
+
const client = createRateLimitedClient();
|
|
1717
|
+
await client.connect();
|
|
1718
|
+
await client.mailboxOpen(sourceMailbox);
|
|
1719
|
+
const query = buildQuery(filters);
|
|
1720
|
+
let uids = (await client.search(query, { uid: true })) ?? [];
|
|
1721
|
+
if (filters.hasAttachment) {
|
|
1722
|
+
if (uids.length > ATTACHMENT_SCAN_LIMIT) {
|
|
1723
|
+
await client.logout();
|
|
1724
|
+
return { error: `hasAttachment requires narrower filters first — ${uids.length} candidates exceeds scan limit of ${ATTACHMENT_SCAN_LIMIT}.` };
|
|
1725
|
+
}
|
|
1726
|
+
uids = await filterUidsByAttachment(client, uids);
|
|
1727
|
+
}
|
|
1728
|
+
await client.logout();
|
|
1729
|
+
|
|
1730
|
+
if (limit !== null) uids = uids.slice(0, limit);
|
|
1731
|
+
|
|
1732
|
+
if (dryRun) {
|
|
1733
|
+
return { dryRun: true, wouldMove: uids.length, sourceMailbox, targetMailbox, filters };
|
|
1734
|
+
}
|
|
1735
|
+
if (uids.length === 0) return { moved: 0, sourceMailbox, targetMailbox };
|
|
1736
|
+
|
|
1737
|
+
await ensureMailbox(targetMailbox);
|
|
1738
|
+
const result = await safeMoveEmails(uids, sourceMailbox, targetMailbox);
|
|
1739
|
+
return { ...result, sourceMailbox, targetMailbox, filters };
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
// ─── IMPROVEMENT 3: bulk_delete now has per-chunk timeout ─────────────────────
|
|
1743
|
+
// Previously the chunk loop could run unbounded. Now each chunk gets a BULK_OP
|
|
1744
|
+
// timeout. If a single chunk hangs, we bail with a partial result instead of
|
|
1745
|
+
// hanging forever.
|
|
1746
|
+
|
|
1747
|
+
export async function bulkDelete(filters, sourceMailbox = 'INBOX', dryRun = false) {
|
|
1748
|
+
const client = createRateLimitedClient();
|
|
1749
|
+
await client.connect();
|
|
1750
|
+
await client.mailboxOpen(sourceMailbox);
|
|
1751
|
+
const query = buildQuery(filters);
|
|
1752
|
+
let uids = (await client.search(query, { uid: true })) ?? [];
|
|
1753
|
+
if (filters.hasAttachment) {
|
|
1754
|
+
if (uids.length > ATTACHMENT_SCAN_LIMIT) {
|
|
1755
|
+
await client.logout();
|
|
1756
|
+
return { error: `hasAttachment requires narrower filters first — ${uids.length} candidates exceeds scan limit of ${ATTACHMENT_SCAN_LIMIT}.` };
|
|
1757
|
+
}
|
|
1758
|
+
uids = await filterUidsByAttachment(client, uids);
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
if (dryRun) {
|
|
1762
|
+
await client.logout();
|
|
1763
|
+
return { dryRun: true, wouldDelete: uids.length, sourceMailbox, filters };
|
|
1764
|
+
}
|
|
1765
|
+
if (uids.length === 0) { await client.logout(); return { deleted: 0, sourceMailbox }; }
|
|
1766
|
+
|
|
1767
|
+
let deleted = 0;
|
|
1768
|
+
for (let i = 0; i < uids.length; i += CHUNK_SIZE) {
|
|
1769
|
+
const chunk = uids.slice(i, i + CHUNK_SIZE);
|
|
1770
|
+
const chunkIndex = Math.floor(i / CHUNK_SIZE);
|
|
1771
|
+
try {
|
|
1772
|
+
await withTimeout(`bulk_delete chunk ${chunkIndex}`, TIMEOUT.BULK_OP, async () => {
|
|
1773
|
+
await client.messageDelete(chunk, { uid: true });
|
|
1774
|
+
});
|
|
1775
|
+
deleted += chunk.length;
|
|
1776
|
+
} catch (err) {
|
|
1777
|
+
await safeClose(client);
|
|
1778
|
+
return {
|
|
1779
|
+
deleted,
|
|
1780
|
+
failed: uids.length - deleted,
|
|
1781
|
+
sourceMailbox,
|
|
1782
|
+
filters,
|
|
1783
|
+
error: `Chunk ${chunkIndex} failed: ${err.message}. ${deleted} deleted so far, ${uids.length - deleted} remaining.`
|
|
1784
|
+
};
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
await client.logout();
|
|
1788
|
+
return { deleted, sourceMailbox, filters };
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
export async function countEmails(filters, mailbox = 'INBOX') {
|
|
1792
|
+
const client = createRateLimitedClient();
|
|
1793
|
+
await client.connect();
|
|
1794
|
+
await client.mailboxOpen(mailbox);
|
|
1795
|
+
const query = buildQuery(filters);
|
|
1796
|
+
let uids = (await client.search(query, { uid: true })) ?? [];
|
|
1797
|
+
if (filters.hasAttachment) {
|
|
1798
|
+
if (uids.length > ATTACHMENT_SCAN_LIMIT) {
|
|
1799
|
+
await client.logout();
|
|
1800
|
+
return { count: null, candidateCount: uids.length, mailbox, filters, error: `hasAttachment requires narrower filters first — ${uids.length} candidates exceeds scan limit of ${ATTACHMENT_SCAN_LIMIT}. Add from/since/before/subject filters to reduce the set.` };
|
|
1801
|
+
}
|
|
1802
|
+
uids = await filterUidsByAttachment(client, uids);
|
|
1803
|
+
}
|
|
1804
|
+
await client.logout();
|
|
1805
|
+
return { count: uids.length, mailbox, filters };
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
// ─── Saved Rules ─────────────────────────────────────────────────────────────
|
|
1809
|
+
|
|
1810
|
+
function readRules() {
|
|
1811
|
+
if (!existsSync(RULES_FILE)) return { rules: [] };
|
|
1812
|
+
try { return JSON.parse(readFileSync(RULES_FILE, 'utf8')); }
|
|
1813
|
+
catch { return { rules: [] }; }
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
function writeRules(data) {
|
|
1817
|
+
writeFileSync(RULES_FILE, JSON.stringify(data, null, 2));
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
export function createRule(name, filters, action, description = '') {
|
|
1821
|
+
const data = readRules();
|
|
1822
|
+
if (data.rules.find(r => r.name === name)) {
|
|
1823
|
+
throw new Error(`Rule '${name}' already exists. Delete it first to update it.`);
|
|
1824
|
+
}
|
|
1825
|
+
const validActions = ['move', 'delete', 'mark_read', 'mark_unread', 'flag', 'unflag'];
|
|
1826
|
+
if (!validActions.includes(action.type)) {
|
|
1827
|
+
throw new Error(`Invalid action type '${action.type}'. Must be one of: ${validActions.join(', ')}`);
|
|
1828
|
+
}
|
|
1829
|
+
if (action.type === 'move' && !action.targetMailbox) {
|
|
1830
|
+
throw new Error(`Action type 'move' requires targetMailbox`);
|
|
1831
|
+
}
|
|
1832
|
+
const rule = {
|
|
1833
|
+
name,
|
|
1834
|
+
description,
|
|
1835
|
+
filters,
|
|
1836
|
+
action,
|
|
1837
|
+
createdAt: new Date().toISOString(),
|
|
1838
|
+
lastRun: null,
|
|
1839
|
+
runCount: 0,
|
|
1840
|
+
};
|
|
1841
|
+
data.rules.push(rule);
|
|
1842
|
+
writeRules(data);
|
|
1843
|
+
return rule;
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
export function listRules() {
|
|
1847
|
+
return { rules: readRules().rules };
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
export function deleteRule(name) {
|
|
1851
|
+
const data = readRules();
|
|
1852
|
+
const idx = data.rules.findIndex(r => r.name === name);
|
|
1853
|
+
if (idx === -1) throw new Error(`Rule '${name}' not found.`);
|
|
1854
|
+
data.rules.splice(idx, 1);
|
|
1855
|
+
writeRules(data);
|
|
1856
|
+
return { deleted: true, name };
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
async function bulkMarkByFilters(filters, read, mailbox = 'INBOX') {
|
|
1860
|
+
const client = createRateLimitedClient();
|
|
1861
|
+
await client.connect();
|
|
1862
|
+
await client.mailboxOpen(mailbox);
|
|
1863
|
+
const base = buildQuery(filters);
|
|
1864
|
+
const query = { ...base, ...(read ? { seen: false } : { seen: true }) };
|
|
1865
|
+
const uids = (await client.search(query, { uid: true })) ?? [];
|
|
1866
|
+
if (uids.length === 0) { await client.logout(); return { marked: 0 }; }
|
|
1867
|
+
if (read) {
|
|
1868
|
+
await client.messageFlagsAdd(uids, ['\\Seen'], { uid: true });
|
|
1869
|
+
} else {
|
|
1870
|
+
await client.messageFlagsRemove(uids, ['\\Seen'], { uid: true });
|
|
1871
|
+
}
|
|
1872
|
+
await client.logout();
|
|
1873
|
+
return { marked: uids.length };
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
async function executeRule(rule, dryRun = false) {
|
|
1877
|
+
const { filters, action } = rule;
|
|
1878
|
+
const sourceMailbox = action.sourceMailbox || 'INBOX';
|
|
1879
|
+
switch (action.type) {
|
|
1880
|
+
case 'move':
|
|
1881
|
+
return bulkMove(filters, action.targetMailbox, sourceMailbox, dryRun);
|
|
1882
|
+
case 'delete':
|
|
1883
|
+
return bulkDelete(filters, sourceMailbox, dryRun);
|
|
1884
|
+
case 'mark_read': {
|
|
1885
|
+
if (dryRun) {
|
|
1886
|
+
const { count } = await countEmails(filters, sourceMailbox);
|
|
1887
|
+
return { dryRun: true, wouldAffect: count ?? 0 };
|
|
1888
|
+
}
|
|
1889
|
+
return bulkMarkByFilters(filters, true, sourceMailbox);
|
|
1890
|
+
}
|
|
1891
|
+
case 'mark_unread': {
|
|
1892
|
+
if (dryRun) {
|
|
1893
|
+
const { count } = await countEmails(filters, sourceMailbox);
|
|
1894
|
+
return { dryRun: true, wouldAffect: count ?? 0 };
|
|
1895
|
+
}
|
|
1896
|
+
return bulkMarkByFilters(filters, false, sourceMailbox);
|
|
1897
|
+
}
|
|
1898
|
+
case 'flag': {
|
|
1899
|
+
if (dryRun) {
|
|
1900
|
+
const { count } = await countEmails(filters, sourceMailbox);
|
|
1901
|
+
return { dryRun: true, wouldAffect: count ?? 0 };
|
|
1902
|
+
}
|
|
1903
|
+
return bulkFlag(filters, true, sourceMailbox);
|
|
1904
|
+
}
|
|
1905
|
+
case 'unflag': {
|
|
1906
|
+
if (dryRun) {
|
|
1907
|
+
const { count } = await countEmails(filters, sourceMailbox);
|
|
1908
|
+
return { dryRun: true, wouldAffect: count ?? 0 };
|
|
1909
|
+
}
|
|
1910
|
+
return bulkFlag(filters, false, sourceMailbox);
|
|
1911
|
+
}
|
|
1912
|
+
default:
|
|
1913
|
+
throw new Error(`Unknown action type: ${action.type}`);
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
export async function runRule(name, dryRun = false) {
|
|
1918
|
+
const data = readRules();
|
|
1919
|
+
const rule = data.rules.find(r => r.name === name);
|
|
1920
|
+
if (!rule) throw new Error(`Rule '${name}' not found.`);
|
|
1921
|
+
const result = await executeRule(rule, dryRun);
|
|
1922
|
+
if (!dryRun) {
|
|
1923
|
+
rule.lastRun = new Date().toISOString();
|
|
1924
|
+
rule.runCount = (rule.runCount || 0) + 1;
|
|
1925
|
+
writeRules(data);
|
|
1926
|
+
}
|
|
1927
|
+
return { rule: name, action: rule.action.type, ...result };
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
export async function runAllRules(dryRun = false) {
|
|
1931
|
+
const data = readRules();
|
|
1932
|
+
if (data.rules.length === 0) return { results: [], ran: 0 };
|
|
1933
|
+
const results = [];
|
|
1934
|
+
for (const rule of data.rules) {
|
|
1935
|
+
const result = await executeRule(rule, dryRun);
|
|
1936
|
+
if (!dryRun) {
|
|
1937
|
+
rule.lastRun = new Date().toISOString();
|
|
1938
|
+
rule.runCount = (rule.runCount || 0) + 1;
|
|
1939
|
+
}
|
|
1940
|
+
results.push({ rule: rule.name, action: rule.action.type, ...result });
|
|
1941
|
+
}
|
|
1942
|
+
if (!dryRun) writeRules(data);
|
|
1943
|
+
return { results, ran: data.rules.length };
|
|
1944
|
+
}
|