icloud-mcp 1.7.0 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/index.js +96 -6
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -89,6 +89,7 @@ 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
92
93
 
93
94
  function readManifest() {
94
95
  if (!existsSync(MANIFEST_FILE)) return { current: null, history: [] };
@@ -1119,6 +1120,25 @@ function findTextPart(node) {
1119
1120
  return null;
1120
1121
  }
1121
1122
 
1123
+ function findAttachments(node, parts = []) {
1124
+ if (node.childNodes) {
1125
+ for (const child of node.childNodes) findAttachments(child, parts);
1126
+ } else {
1127
+ const filename = node.dispositionParameters?.filename ?? node.parameters?.name ?? null;
1128
+ const isTextBody = (node.type === 'text/plain' || node.type === 'text/html') && node.disposition !== 'attachment';
1129
+ if (node.disposition === 'attachment' || node.disposition === 'inline' || (filename && !isTextBody)) {
1130
+ parts.push({
1131
+ partId: node.part ?? 'TEXT',
1132
+ filename,
1133
+ mimeType: node.type ?? 'application/octet-stream',
1134
+ size: node.size ?? 0,
1135
+ disposition: node.disposition ?? 'attachment'
1136
+ });
1137
+ }
1138
+ }
1139
+ return parts;
1140
+ }
1141
+
1122
1142
  // ─── Email content fetcher (MIME-aware) ───────────────────────────────────────
1123
1143
 
1124
1144
  async function getEmailContent(uid, mailbox = 'INBOX') {
@@ -1205,6 +1225,22 @@ async function getEmailContent(uid, mailbox = 'INBOX') {
1205
1225
  };
1206
1226
  }
1207
1227
 
1228
+ async function listAttachments(uid, mailbox = 'INBOX') {
1229
+ const client = createRateLimitedClient();
1230
+ await client.connect();
1231
+ await client.mailboxOpen(mailbox);
1232
+ const meta = await client.fetchOne(uid, { envelope: true, bodyStructure: true }, { uid: true });
1233
+ await client.logout();
1234
+ if (!meta) return { uid, subject: null, attachmentCount: 0, attachments: [] };
1235
+ const attachments = meta.bodyStructure ? findAttachments(meta.bodyStructure) : [];
1236
+ return {
1237
+ uid: meta.uid,
1238
+ subject: meta.envelope.subject,
1239
+ attachmentCount: attachments.length,
1240
+ attachments
1241
+ };
1242
+ }
1243
+
1208
1244
  async function flagEmail(uid, flagged, mailbox = 'INBOX') {
1209
1245
  const client = createRateLimitedClient();
1210
1246
  await client.connect();
@@ -1267,7 +1303,14 @@ async function searchEmails(query, mailbox = 'INBOX', limit = 10, filters = {})
1267
1303
  ? { ...textQuery, ...extraQuery }
1268
1304
  : textQuery;
1269
1305
 
1270
- const uids = (await client.search(finalQuery, { uid: true })) ?? [];
1306
+ let uids = (await client.search(finalQuery, { uid: true })) ?? [];
1307
+ if (filters.hasAttachment) {
1308
+ if (uids.length > ATTACHMENT_SCAN_LIMIT) {
1309
+ await client.logout();
1310
+ 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.` };
1311
+ }
1312
+ uids = await filterUidsByAttachment(client, uids);
1313
+ }
1271
1314
  const emails = [];
1272
1315
  const recentUids = uids.slice(-limit).reverse();
1273
1316
  for (const uid of recentUids) {
@@ -1309,11 +1352,23 @@ function buildQuery(filters) {
1309
1352
  if (filters.flagged === false) query.unflagged = true;
1310
1353
  if (filters.larger) query.larger = filters.larger * 1024;
1311
1354
  if (filters.smaller) query.smaller = filters.smaller * 1024;
1312
- if (filters.hasAttachment) query.header = ['Content-Type', 'multipart/mixed'];
1355
+ // hasAttachment is handled as a client-side post-filter (see filterUidsByAttachment)
1356
+ // iCloud does not support SEARCH HEADER or reliable size-based attachment detection
1313
1357
  if (Object.keys(query).length === 0) query.all = true;
1314
1358
  return query;
1315
1359
  }
1316
1360
 
1361
+ async function filterUidsByAttachment(client, uids) {
1362
+ if (uids.length === 0) return [];
1363
+ const result = [];
1364
+ for await (const msg of client.fetch(uids, { bodyStructure: true }, { uid: true })) {
1365
+ if (msg.bodyStructure && findAttachments(msg.bodyStructure).length > 0) {
1366
+ result.push(msg.uid);
1367
+ }
1368
+ }
1369
+ return result;
1370
+ }
1371
+
1317
1372
  async function ensureMailbox(name) {
1318
1373
  const client = createRateLimitedClient();
1319
1374
  await client.connect();
@@ -1327,6 +1382,13 @@ async function bulkMove(filters, targetMailbox, sourceMailbox = 'INBOX', dryRun
1327
1382
  await client.mailboxOpen(sourceMailbox);
1328
1383
  const query = buildQuery(filters);
1329
1384
  let uids = (await client.search(query, { uid: true })) ?? [];
1385
+ if (filters.hasAttachment) {
1386
+ if (uids.length > ATTACHMENT_SCAN_LIMIT) {
1387
+ await client.logout();
1388
+ return { error: `hasAttachment requires narrower filters first — ${uids.length} candidates exceeds scan limit of ${ATTACHMENT_SCAN_LIMIT}.` };
1389
+ }
1390
+ uids = await filterUidsByAttachment(client, uids);
1391
+ }
1330
1392
  await client.logout();
1331
1393
 
1332
1394
  if (limit !== null) uids = uids.slice(0, limit);
@@ -1351,7 +1413,14 @@ async function bulkDelete(filters, sourceMailbox = 'INBOX', dryRun = false) {
1351
1413
  await client.connect();
1352
1414
  await client.mailboxOpen(sourceMailbox);
1353
1415
  const query = buildQuery(filters);
1354
- const uids = (await client.search(query, { uid: true })) ?? [];
1416
+ let uids = (await client.search(query, { uid: true })) ?? [];
1417
+ if (filters.hasAttachment) {
1418
+ if (uids.length > ATTACHMENT_SCAN_LIMIT) {
1419
+ await client.logout();
1420
+ return { error: `hasAttachment requires narrower filters first — ${uids.length} candidates exceeds scan limit of ${ATTACHMENT_SCAN_LIMIT}.` };
1421
+ }
1422
+ uids = await filterUidsByAttachment(client, uids);
1423
+ }
1355
1424
 
1356
1425
  if (dryRun) {
1357
1426
  await client.logout();
@@ -1388,7 +1457,14 @@ async function countEmails(filters, mailbox = 'INBOX') {
1388
1457
  await client.connect();
1389
1458
  await client.mailboxOpen(mailbox);
1390
1459
  const query = buildQuery(filters);
1391
- const uids = (await client.search(query, { uid: true })) ?? [];
1460
+ let uids = (await client.search(query, { uid: true })) ?? [];
1461
+ if (filters.hasAttachment) {
1462
+ if (uids.length > ATTACHMENT_SCAN_LIMIT) {
1463
+ await client.logout();
1464
+ 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.` };
1465
+ }
1466
+ uids = await filterUidsByAttachment(client, uids);
1467
+ }
1392
1468
  await client.logout();
1393
1469
  return { count: uids.length, mailbox, filters };
1394
1470
  }
@@ -1421,7 +1497,7 @@ function logClear() {
1421
1497
 
1422
1498
  async function main() {
1423
1499
  const server = new Server(
1424
- { name: 'icloud-mail', version: '1.7.0' },
1500
+ { name: 'icloud-mail', version: '1.8.1' },
1425
1501
  { capabilities: { tools: {} } }
1426
1502
  );
1427
1503
 
@@ -1435,7 +1511,7 @@ async function main() {
1435
1511
  flagged: { type: 'boolean', description: 'True for flagged only, false for unflagged only' },
1436
1512
  larger: { type: 'number', description: 'Only emails larger than this size in KB' },
1437
1513
  smaller: { type: 'number', description: 'Only emails smaller than this size in KB' },
1438
- hasAttachment: { type: 'boolean', description: 'Only emails with attachments' }
1514
+ 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)' }
1439
1515
  };
1440
1516
 
1441
1517
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
@@ -1788,6 +1864,18 @@ async function main() {
1788
1864
  name: 'log_clear',
1789
1865
  description: 'Clear the session log and start fresh. Use this at the start of a new task.',
1790
1866
  inputSchema: { type: 'object', properties: {} }
1867
+ },
1868
+ {
1869
+ name: 'list_attachments',
1870
+ description: 'List all attachments in an email without downloading them. Returns filename, MIME type, size, and IMAP part ID for each attachment.',
1871
+ inputSchema: {
1872
+ type: 'object',
1873
+ properties: {
1874
+ uid: { type: 'number', description: 'Email UID' },
1875
+ mailbox: { type: 'string', description: 'Mailbox name (default INBOX)' }
1876
+ },
1877
+ required: ['uid']
1878
+ }
1791
1879
  }
1792
1880
  ]
1793
1881
  }));
@@ -1817,6 +1905,8 @@ async function main() {
1817
1905
  result = await withTimeout('read_inbox', TIMEOUT.FETCH, () => fetchEmails(args.mailbox || 'INBOX', args.limit || 10, args.onlyUnread || false, args.page || 1));
1818
1906
  } else if (name === 'get_email') {
1819
1907
  result = await withTimeout('get_email', TIMEOUT.FETCH, () => getEmailContent(args.uid, args.mailbox || 'INBOX'));
1908
+ } else if (name === 'list_attachments') {
1909
+ result = await withTimeout('list_attachments', TIMEOUT.FETCH, () => listAttachments(args.uid, args.mailbox || 'INBOX'));
1820
1910
  } else if (name === 'search_emails') {
1821
1911
  const { query, mailbox, limit, ...filters } = args;
1822
1912
  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.7.0",
3
+ "version": "1.8.1",
4
4
  "description": "A Model Context Protocol (MCP) server for iCloud Mail",
5
5
  "main": "index.js",
6
6
  "bin": {