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.
Files changed (2) hide show
  1. package/index.js +123 -11
  2. 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
- const uids = (await client.search(finalQuery, { uid: true })) ?? [];
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
- if (filters.hasAttachment) {
1348
- query.or = [
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
- const uids = (await client.search(query, { uid: true })) ?? [];
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
- const uids = (await client.search(query, { uid: true })) ?? [];
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.8.0' },
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));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "icloud-mcp",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "A Model Context Protocol (MCP) server for iCloud Mail",
5
5
  "main": "index.js",
6
6
  "bin": {