icloud-mcp 1.8.0 → 1.9.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/index.js +123 -11
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -89,6 +89,8 @@ async function reconnect(client, mailbox) {
|
|
|
89
89
|
|
|
90
90
|
const CHUNK_SIZE = 500;
|
|
91
91
|
const CHUNK_SIZE_RETRY = 100;
|
|
92
|
+
const ATTACHMENT_SCAN_LIMIT = 500; // max UIDs to scan client-side for hasAttachment filter
|
|
93
|
+
const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024; // 20 MB cap for get_attachment downloads
|
|
92
94
|
|
|
93
95
|
function readManifest() {
|
|
94
96
|
if (!existsSync(MANIFEST_FILE)) return { current: null, history: [] };
|
|
@@ -1131,6 +1133,7 @@ function findAttachments(node, parts = []) {
|
|
|
1131
1133
|
filename,
|
|
1132
1134
|
mimeType: node.type ?? 'application/octet-stream',
|
|
1133
1135
|
size: node.size ?? 0,
|
|
1136
|
+
encoding: node.encoding ?? '7bit',
|
|
1134
1137
|
disposition: node.disposition ?? 'attachment'
|
|
1135
1138
|
});
|
|
1136
1139
|
}
|
|
@@ -1240,6 +1243,65 @@ async function listAttachments(uid, mailbox = 'INBOX') {
|
|
|
1240
1243
|
};
|
|
1241
1244
|
}
|
|
1242
1245
|
|
|
1246
|
+
async function getAttachment(uid, partId, mailbox = 'INBOX') {
|
|
1247
|
+
const client = createRateLimitedClient();
|
|
1248
|
+
await client.connect();
|
|
1249
|
+
await client.mailboxOpen(mailbox);
|
|
1250
|
+
|
|
1251
|
+
// First fetch bodyStructure to find the attachment and validate size
|
|
1252
|
+
const meta = await client.fetchOne(uid, { bodyStructure: true }, { uid: true });
|
|
1253
|
+
if (!meta) throw new Error(`Email UID ${uid} not found`);
|
|
1254
|
+
|
|
1255
|
+
const attachments = meta.bodyStructure ? findAttachments(meta.bodyStructure) : [];
|
|
1256
|
+
const att = attachments.find(a => a.partId === partId);
|
|
1257
|
+
if (!att) throw new Error(`Part ID "${partId}" not found in email UID ${uid}. Use list_attachments to see available parts.`);
|
|
1258
|
+
|
|
1259
|
+
if (att.size > MAX_ATTACHMENT_BYTES) {
|
|
1260
|
+
await client.logout();
|
|
1261
|
+
return {
|
|
1262
|
+
error: `Attachment too large to download (${Math.round(att.size / 1024 / 1024 * 10) / 10} MB). Maximum allowed: ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB.`,
|
|
1263
|
+
filename: att.filename,
|
|
1264
|
+
mimeType: att.mimeType,
|
|
1265
|
+
size: att.size
|
|
1266
|
+
};
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
// Fetch the raw body part bytes
|
|
1270
|
+
const rawChunks = [];
|
|
1271
|
+
for await (const msg of client.fetch({ uid }, { bodyParts: [partId] }, { uid: true })) {
|
|
1272
|
+
const buf = msg.bodyParts?.get(partId);
|
|
1273
|
+
if (buf) rawChunks.push(buf);
|
|
1274
|
+
}
|
|
1275
|
+
await client.logout();
|
|
1276
|
+
|
|
1277
|
+
if (rawChunks.length === 0) throw new Error(`No data returned for part "${partId}" of UID ${uid}`);
|
|
1278
|
+
|
|
1279
|
+
const raw = Buffer.concat(rawChunks);
|
|
1280
|
+
const encoding = att.encoding.toLowerCase();
|
|
1281
|
+
|
|
1282
|
+
let decoded;
|
|
1283
|
+
if (encoding === 'base64') {
|
|
1284
|
+
decoded = Buffer.from(raw.toString('ascii').replace(/\s/g, ''), 'base64');
|
|
1285
|
+
} else if (encoding === 'quoted-printable') {
|
|
1286
|
+
// decode QP: replace soft line breaks then decode =XX sequences
|
|
1287
|
+
const qp = raw.toString('binary').replace(/=\r?\n/g, '').replace(/=([0-9A-Fa-f]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16)));
|
|
1288
|
+
decoded = Buffer.from(qp, 'binary');
|
|
1289
|
+
} else {
|
|
1290
|
+
decoded = raw; // 7bit / 8bit / binary — use as-is
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
return {
|
|
1294
|
+
uid,
|
|
1295
|
+
partId,
|
|
1296
|
+
filename: att.filename,
|
|
1297
|
+
mimeType: att.mimeType,
|
|
1298
|
+
size: decoded.length,
|
|
1299
|
+
encoding: att.encoding,
|
|
1300
|
+
data: decoded.toString('base64'),
|
|
1301
|
+
dataEncoding: 'base64'
|
|
1302
|
+
};
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1243
1305
|
async function flagEmail(uid, flagged, mailbox = 'INBOX') {
|
|
1244
1306
|
const client = createRateLimitedClient();
|
|
1245
1307
|
await client.connect();
|
|
@@ -1302,7 +1364,14 @@ async function searchEmails(query, mailbox = 'INBOX', limit = 10, filters = {})
|
|
|
1302
1364
|
? { ...textQuery, ...extraQuery }
|
|
1303
1365
|
: textQuery;
|
|
1304
1366
|
|
|
1305
|
-
|
|
1367
|
+
let uids = (await client.search(finalQuery, { uid: true })) ?? [];
|
|
1368
|
+
if (filters.hasAttachment) {
|
|
1369
|
+
if (uids.length > ATTACHMENT_SCAN_LIMIT) {
|
|
1370
|
+
await client.logout();
|
|
1371
|
+
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.` };
|
|
1372
|
+
}
|
|
1373
|
+
uids = await filterUidsByAttachment(client, uids);
|
|
1374
|
+
}
|
|
1306
1375
|
const emails = [];
|
|
1307
1376
|
const recentUids = uids.slice(-limit).reverse();
|
|
1308
1377
|
for (const uid of recentUids) {
|
|
@@ -1344,16 +1413,23 @@ function buildQuery(filters) {
|
|
|
1344
1413
|
if (filters.flagged === false) query.unflagged = true;
|
|
1345
1414
|
if (filters.larger) query.larger = filters.larger * 1024;
|
|
1346
1415
|
if (filters.smaller) query.smaller = filters.smaller * 1024;
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
{ header: { 'Content-Disposition': 'attachment' } },
|
|
1350
|
-
{ header: { 'Content-Type': 'multipart/mixed' } }
|
|
1351
|
-
];
|
|
1352
|
-
}
|
|
1416
|
+
// hasAttachment is handled as a client-side post-filter (see filterUidsByAttachment)
|
|
1417
|
+
// iCloud does not support SEARCH HEADER or reliable size-based attachment detection
|
|
1353
1418
|
if (Object.keys(query).length === 0) query.all = true;
|
|
1354
1419
|
return query;
|
|
1355
1420
|
}
|
|
1356
1421
|
|
|
1422
|
+
async function filterUidsByAttachment(client, uids) {
|
|
1423
|
+
if (uids.length === 0) return [];
|
|
1424
|
+
const result = [];
|
|
1425
|
+
for await (const msg of client.fetch(uids, { bodyStructure: true }, { uid: true })) {
|
|
1426
|
+
if (msg.bodyStructure && findAttachments(msg.bodyStructure).length > 0) {
|
|
1427
|
+
result.push(msg.uid);
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
return result;
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1357
1433
|
async function ensureMailbox(name) {
|
|
1358
1434
|
const client = createRateLimitedClient();
|
|
1359
1435
|
await client.connect();
|
|
@@ -1367,6 +1443,13 @@ async function bulkMove(filters, targetMailbox, sourceMailbox = 'INBOX', dryRun
|
|
|
1367
1443
|
await client.mailboxOpen(sourceMailbox);
|
|
1368
1444
|
const query = buildQuery(filters);
|
|
1369
1445
|
let uids = (await client.search(query, { uid: true })) ?? [];
|
|
1446
|
+
if (filters.hasAttachment) {
|
|
1447
|
+
if (uids.length > ATTACHMENT_SCAN_LIMIT) {
|
|
1448
|
+
await client.logout();
|
|
1449
|
+
return { error: `hasAttachment requires narrower filters first — ${uids.length} candidates exceeds scan limit of ${ATTACHMENT_SCAN_LIMIT}.` };
|
|
1450
|
+
}
|
|
1451
|
+
uids = await filterUidsByAttachment(client, uids);
|
|
1452
|
+
}
|
|
1370
1453
|
await client.logout();
|
|
1371
1454
|
|
|
1372
1455
|
if (limit !== null) uids = uids.slice(0, limit);
|
|
@@ -1391,7 +1474,14 @@ async function bulkDelete(filters, sourceMailbox = 'INBOX', dryRun = false) {
|
|
|
1391
1474
|
await client.connect();
|
|
1392
1475
|
await client.mailboxOpen(sourceMailbox);
|
|
1393
1476
|
const query = buildQuery(filters);
|
|
1394
|
-
|
|
1477
|
+
let uids = (await client.search(query, { uid: true })) ?? [];
|
|
1478
|
+
if (filters.hasAttachment) {
|
|
1479
|
+
if (uids.length > ATTACHMENT_SCAN_LIMIT) {
|
|
1480
|
+
await client.logout();
|
|
1481
|
+
return { error: `hasAttachment requires narrower filters first — ${uids.length} candidates exceeds scan limit of ${ATTACHMENT_SCAN_LIMIT}.` };
|
|
1482
|
+
}
|
|
1483
|
+
uids = await filterUidsByAttachment(client, uids);
|
|
1484
|
+
}
|
|
1395
1485
|
|
|
1396
1486
|
if (dryRun) {
|
|
1397
1487
|
await client.logout();
|
|
@@ -1428,7 +1518,14 @@ async function countEmails(filters, mailbox = 'INBOX') {
|
|
|
1428
1518
|
await client.connect();
|
|
1429
1519
|
await client.mailboxOpen(mailbox);
|
|
1430
1520
|
const query = buildQuery(filters);
|
|
1431
|
-
|
|
1521
|
+
let uids = (await client.search(query, { uid: true })) ?? [];
|
|
1522
|
+
if (filters.hasAttachment) {
|
|
1523
|
+
if (uids.length > ATTACHMENT_SCAN_LIMIT) {
|
|
1524
|
+
await client.logout();
|
|
1525
|
+
return { count: null, 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.` };
|
|
1526
|
+
}
|
|
1527
|
+
uids = await filterUidsByAttachment(client, uids);
|
|
1528
|
+
}
|
|
1432
1529
|
await client.logout();
|
|
1433
1530
|
return { count: uids.length, mailbox, filters };
|
|
1434
1531
|
}
|
|
@@ -1461,7 +1558,7 @@ function logClear() {
|
|
|
1461
1558
|
|
|
1462
1559
|
async function main() {
|
|
1463
1560
|
const server = new Server(
|
|
1464
|
-
{ name: 'icloud-mail', version: '1.
|
|
1561
|
+
{ name: 'icloud-mail', version: '1.9.0' },
|
|
1465
1562
|
{ capabilities: { tools: {} } }
|
|
1466
1563
|
);
|
|
1467
1564
|
|
|
@@ -1475,7 +1572,7 @@ async function main() {
|
|
|
1475
1572
|
flagged: { type: 'boolean', description: 'True for flagged only, false for unflagged only' },
|
|
1476
1573
|
larger: { type: 'number', description: 'Only emails larger than this size in KB' },
|
|
1477
1574
|
smaller: { type: 'number', description: 'Only emails smaller than this size in KB' },
|
|
1478
|
-
hasAttachment: { type: 'boolean', description: 'Only emails with attachments' }
|
|
1575
|
+
hasAttachment: { type: 'boolean', description: 'Only emails with attachments (client-side BODYSTRUCTURE scan — must be combined with other filters that narrow results to under 500 emails first)' }
|
|
1479
1576
|
};
|
|
1480
1577
|
|
|
1481
1578
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
@@ -1840,6 +1937,19 @@ async function main() {
|
|
|
1840
1937
|
},
|
|
1841
1938
|
required: ['uid']
|
|
1842
1939
|
}
|
|
1940
|
+
},
|
|
1941
|
+
{
|
|
1942
|
+
name: 'get_attachment',
|
|
1943
|
+
description: 'Download a specific attachment from an email. Returns the file content as base64-encoded data. Use list_attachments first to get the partId. Maximum 20 MB per attachment.',
|
|
1944
|
+
inputSchema: {
|
|
1945
|
+
type: 'object',
|
|
1946
|
+
properties: {
|
|
1947
|
+
uid: { type: 'number', description: 'Email UID' },
|
|
1948
|
+
partId: { type: 'string', description: 'IMAP body part ID from list_attachments (e.g. "2", "1.2")' },
|
|
1949
|
+
mailbox: { type: 'string', description: 'Mailbox name (default INBOX)' }
|
|
1950
|
+
},
|
|
1951
|
+
required: ['uid', 'partId']
|
|
1952
|
+
}
|
|
1843
1953
|
}
|
|
1844
1954
|
]
|
|
1845
1955
|
}));
|
|
@@ -1871,6 +1981,8 @@ async function main() {
|
|
|
1871
1981
|
result = await withTimeout('get_email', TIMEOUT.FETCH, () => getEmailContent(args.uid, args.mailbox || 'INBOX'));
|
|
1872
1982
|
} else if (name === 'list_attachments') {
|
|
1873
1983
|
result = await withTimeout('list_attachments', TIMEOUT.FETCH, () => listAttachments(args.uid, args.mailbox || 'INBOX'));
|
|
1984
|
+
} else if (name === 'get_attachment') {
|
|
1985
|
+
result = await withTimeout('get_attachment', TIMEOUT.FETCH, () => getAttachment(args.uid, args.partId, args.mailbox || 'INBOX'));
|
|
1874
1986
|
} else if (name === 'search_emails') {
|
|
1875
1987
|
const { query, mailbox, limit, ...filters } = args;
|
|
1876
1988
|
result = await withTimeout('search_emails', TIMEOUT.FETCH, () => searchEmails(query, mailbox || 'INBOX', limit || 10, filters));
|