icloud-mcp 1.6.0 → 1.8.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 +208 -12
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -1033,25 +1033,186 @@ async function getMailboxSummary(mailbox) {
1033
1033
  return { mailbox, total: status.messages, unread: status.unseen, recent: status.recent };
1034
1034
  }
1035
1035
 
1036
+ // ─── MIME body parsing helpers ────────────────────────────────────────────────
1037
+
1038
+ function decodeTransferEncoding(buffer, encoding) {
1039
+ const enc = (encoding || '7bit').toLowerCase().trim();
1040
+ if (enc === 'base64') {
1041
+ return Buffer.from(buffer.toString('ascii').replace(/\s/g, ''), 'base64');
1042
+ }
1043
+ if (enc === 'quoted-printable') {
1044
+ const str = buffer.toString('binary')
1045
+ .replace(/[\t ]+$/gm, '')
1046
+ .replace(/=(?:\r?\n|$)/g, '');
1047
+ const result = Buffer.alloc(str.length);
1048
+ let pos = 0;
1049
+ for (let i = 0; i < str.length; i++) {
1050
+ if (str[i] === '=' && i + 2 < str.length) {
1051
+ const hex = str.slice(i + 1, i + 3);
1052
+ if (/^[\da-fA-F]{2}$/.test(hex)) {
1053
+ result[pos++] = parseInt(hex, 16);
1054
+ i += 2;
1055
+ continue;
1056
+ }
1057
+ }
1058
+ result[pos++] = str.charCodeAt(i) & 0xff;
1059
+ }
1060
+ return result.slice(0, pos);
1061
+ }
1062
+ return buffer;
1063
+ }
1064
+
1065
+ async function decodeCharset(buffer, charset) {
1066
+ const cs = (charset || 'utf-8').toLowerCase().trim();
1067
+ const nativeMap = { 'utf-8': 'utf8', 'utf8': 'utf8', 'us-ascii': 'ascii',
1068
+ 'ascii': 'ascii', 'latin1': 'latin1', 'iso-8859-1': 'latin1', 'binary': 'binary' };
1069
+ if (nativeMap[cs]) return buffer.toString(nativeMap[cs]);
1070
+ try {
1071
+ const { default: iconv } = await import('iconv-lite');
1072
+ if (iconv.encodingExists(cs)) return iconv.decode(buffer, cs);
1073
+ } catch { /* iconv unavailable */ }
1074
+ return buffer.toString('utf8');
1075
+ }
1076
+
1077
+ function stripHtml(html) {
1078
+ return html
1079
+ .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, ' ')
1080
+ .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, ' ')
1081
+ .replace(/<br\s*\/?>/gi, '\n')
1082
+ .replace(/<\/p>/gi, '\n\n')
1083
+ .replace(/<\/div>/gi, '\n')
1084
+ .replace(/<\/li>/gi, '\n')
1085
+ .replace(/<[^>]+>/g, '')
1086
+ .replace(/&nbsp;/gi, ' ')
1087
+ .replace(/&amp;/gi, '&')
1088
+ .replace(/&lt;/gi, '<')
1089
+ .replace(/&gt;/gi, '>')
1090
+ .replace(/&quot;/gi, '"')
1091
+ .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n)))
1092
+ .replace(/[ \t]+/g, ' ')
1093
+ .replace(/\n{3,}/g, '\n\n')
1094
+ .trim();
1095
+ }
1096
+
1097
+ function findTextPart(node) {
1098
+ if (!node.childNodes) {
1099
+ if (node.type && node.type.startsWith('text/') && node.disposition !== 'attachment') {
1100
+ return { partId: null, type: node.type, encoding: node.encoding, charset: node.parameters?.charset, size: node.size };
1101
+ }
1102
+ return null;
1103
+ }
1104
+ if (node.type === 'multipart/alternative') {
1105
+ let plainPart = null, htmlPart = null;
1106
+ for (const child of node.childNodes) {
1107
+ if (child.childNodes || child.disposition === 'attachment') continue;
1108
+ if (child.type === 'text/plain') plainPart = child;
1109
+ else if (child.type === 'text/html') htmlPart = child;
1110
+ }
1111
+ const chosen = plainPart || htmlPart;
1112
+ if (chosen) return { partId: chosen.part, type: chosen.type, encoding: chosen.encoding, charset: chosen.parameters?.charset, size: chosen.size };
1113
+ }
1114
+ for (const child of node.childNodes) {
1115
+ if (child.disposition === 'attachment') continue;
1116
+ const found = findTextPart(child);
1117
+ if (found) return found;
1118
+ }
1119
+ return null;
1120
+ }
1121
+
1122
+ function findAttachments(node, parts = []) {
1123
+ if (node.childNodes) {
1124
+ for (const child of node.childNodes) findAttachments(child, parts);
1125
+ } else {
1126
+ const filename = node.dispositionParameters?.filename ?? node.parameters?.name ?? null;
1127
+ const isTextBody = (node.type === 'text/plain' || node.type === 'text/html') && node.disposition !== 'attachment';
1128
+ if (node.disposition === 'attachment' || node.disposition === 'inline' || (filename && !isTextBody)) {
1129
+ parts.push({
1130
+ partId: node.part ?? 'TEXT',
1131
+ filename,
1132
+ mimeType: node.type ?? 'application/octet-stream',
1133
+ size: node.size ?? 0,
1134
+ disposition: node.disposition ?? 'attachment'
1135
+ });
1136
+ }
1137
+ }
1138
+ return parts;
1139
+ }
1140
+
1141
+ // ─── Email content fetcher (MIME-aware) ───────────────────────────────────────
1142
+
1036
1143
  async function getEmailContent(uid, mailbox = 'INBOX') {
1037
1144
  const client = createRateLimitedClient();
1038
1145
  await client.connect();
1039
1146
  await client.mailboxOpen(mailbox);
1040
- const meta = await client.fetchOne(uid, { envelope: true, flags: true }, { uid: true });
1147
+
1148
+ const meta = await client.fetchOne(uid, { envelope: true, flags: true, bodyStructure: true }, { uid: true });
1149
+ if (!meta) {
1150
+ await client.logout();
1151
+ return { uid, subject: null, from: null, date: null, flags: [], body: '(email not found)' };
1152
+ }
1153
+
1041
1154
  let body = '(body unavailable)';
1155
+
1042
1156
  try {
1043
- const sourceMsg = await Promise.race([
1044
- client.fetchOne(uid, { source: true }, { uid: true }),
1045
- new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 10000))
1046
- ]);
1047
- if (sourceMsg?.source) {
1048
- const raw = sourceMsg.source.toString();
1049
- const bodyStart = raw.indexOf('\r\n\r\n');
1050
- body = bodyStart > -1 ? raw.slice(bodyStart + 4, bodyStart + 2000) : raw.slice(0, 2000);
1157
+ const struct = meta.bodyStructure;
1158
+ if (!struct) throw new Error('no bodyStructure');
1159
+
1160
+ const textPart = findTextPart(struct);
1161
+
1162
+ if (!textPart) {
1163
+ body = '(no readable text — email may be image-only or have no text parts)';
1164
+ } else {
1165
+ // Single-part messages use 'TEXT'; multipart use dot-notation part id (e.g. '1', '1.1')
1166
+ const imapKey = textPart.partId ?? 'TEXT';
1167
+
1168
+ // For large parts, cap the fetch at 12KB to avoid downloading multi-MB newsletters
1169
+ const fetchSpec = (textPart.size && textPart.size > 150_000)
1170
+ ? [{ key: imapKey, start: 0, maxLength: 12_000 }]
1171
+ : [imapKey];
1172
+
1173
+ const partMsg = await Promise.race([
1174
+ client.fetchOne(uid, { bodyParts: fetchSpec }, { uid: true }),
1175
+ new Promise((_, reject) => setTimeout(() => reject(new Error('body fetch timeout')), 10_000))
1176
+ ]);
1177
+
1178
+ // bodyParts is a Map — try the key as-is, then uppercase, then lowercase
1179
+ const partBuffer = partMsg?.bodyParts?.get(imapKey)
1180
+ ?? partMsg?.bodyParts?.get(imapKey.toUpperCase())
1181
+ ?? partMsg?.bodyParts?.get(imapKey.toLowerCase());
1182
+
1183
+ if (!partBuffer || partBuffer.length === 0) throw new Error('empty body part');
1184
+
1185
+ const decoded = decodeTransferEncoding(partBuffer, textPart.encoding);
1186
+ let text = await decodeCharset(decoded, textPart.charset);
1187
+
1188
+ if (textPart.type === 'text/html') text = stripHtml(text);
1189
+
1190
+ const MAX_BODY = 8_000;
1191
+ if (text.length > MAX_BODY) {
1192
+ text = text.slice(0, MAX_BODY) + `\n\n[... truncated — ${text.length.toLocaleString()} chars total]`;
1193
+ }
1194
+
1195
+ body = text.trim() || '(empty body)';
1196
+
1197
+ if (textPart.size && textPart.size > 150_000) {
1198
+ body += `\n\n[Note: email body is large (${Math.round(textPart.size / 1024)}KB) — showing first 12KB]`;
1199
+ }
1051
1200
  }
1052
1201
  } catch {
1053
- body = '(body unavailable - email may be too large)';
1202
+ // Fallback: raw source slice (original behaviour)
1203
+ try {
1204
+ const sourceMsg = await Promise.race([
1205
+ client.fetchOne(uid, { source: true }, { uid: true }),
1206
+ new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 5_000))
1207
+ ]);
1208
+ if (sourceMsg?.source) {
1209
+ const raw = sourceMsg.source.toString();
1210
+ const bodyStart = raw.indexOf('\r\n\r\n');
1211
+ body = '[raw fallback]\n' + (bodyStart > -1 ? raw.slice(bodyStart + 4, bodyStart + 2000) : raw.slice(0, 2000));
1212
+ }
1213
+ } catch { /* leave as unavailable */ }
1054
1214
  }
1215
+
1055
1216
  await client.logout();
1056
1217
  return {
1057
1218
  uid: meta.uid,
@@ -1063,6 +1224,22 @@ async function getEmailContent(uid, mailbox = 'INBOX') {
1063
1224
  };
1064
1225
  }
1065
1226
 
1227
+ async function listAttachments(uid, mailbox = 'INBOX') {
1228
+ const client = createRateLimitedClient();
1229
+ await client.connect();
1230
+ await client.mailboxOpen(mailbox);
1231
+ const meta = await client.fetchOne(uid, { envelope: true, bodyStructure: true }, { uid: true });
1232
+ await client.logout();
1233
+ if (!meta) return { uid, subject: null, attachmentCount: 0, attachments: [] };
1234
+ const attachments = meta.bodyStructure ? findAttachments(meta.bodyStructure) : [];
1235
+ return {
1236
+ uid: meta.uid,
1237
+ subject: meta.envelope.subject,
1238
+ attachmentCount: attachments.length,
1239
+ attachments
1240
+ };
1241
+ }
1242
+
1066
1243
  async function flagEmail(uid, flagged, mailbox = 'INBOX') {
1067
1244
  const client = createRateLimitedClient();
1068
1245
  await client.connect();
@@ -1167,7 +1344,12 @@ function buildQuery(filters) {
1167
1344
  if (filters.flagged === false) query.unflagged = true;
1168
1345
  if (filters.larger) query.larger = filters.larger * 1024;
1169
1346
  if (filters.smaller) query.smaller = filters.smaller * 1024;
1170
- if (filters.hasAttachment) query.header = ['Content-Type', 'multipart/mixed'];
1347
+ if (filters.hasAttachment) {
1348
+ query.or = [
1349
+ { header: { 'Content-Disposition': 'attachment' } },
1350
+ { header: { 'Content-Type': 'multipart/mixed' } }
1351
+ ];
1352
+ }
1171
1353
  if (Object.keys(query).length === 0) query.all = true;
1172
1354
  return query;
1173
1355
  }
@@ -1279,7 +1461,7 @@ function logClear() {
1279
1461
 
1280
1462
  async function main() {
1281
1463
  const server = new Server(
1282
- { name: 'icloud-mail', version: '1.6.0' },
1464
+ { name: 'icloud-mail', version: '1.8.0' },
1283
1465
  { capabilities: { tools: {} } }
1284
1466
  );
1285
1467
 
@@ -1646,6 +1828,18 @@ async function main() {
1646
1828
  name: 'log_clear',
1647
1829
  description: 'Clear the session log and start fresh. Use this at the start of a new task.',
1648
1830
  inputSchema: { type: 'object', properties: {} }
1831
+ },
1832
+ {
1833
+ name: 'list_attachments',
1834
+ description: 'List all attachments in an email without downloading them. Returns filename, MIME type, size, and IMAP part ID for each attachment.',
1835
+ inputSchema: {
1836
+ type: 'object',
1837
+ properties: {
1838
+ uid: { type: 'number', description: 'Email UID' },
1839
+ mailbox: { type: 'string', description: 'Mailbox name (default INBOX)' }
1840
+ },
1841
+ required: ['uid']
1842
+ }
1649
1843
  }
1650
1844
  ]
1651
1845
  }));
@@ -1675,6 +1869,8 @@ async function main() {
1675
1869
  result = await withTimeout('read_inbox', TIMEOUT.FETCH, () => fetchEmails(args.mailbox || 'INBOX', args.limit || 10, args.onlyUnread || false, args.page || 1));
1676
1870
  } else if (name === 'get_email') {
1677
1871
  result = await withTimeout('get_email', TIMEOUT.FETCH, () => getEmailContent(args.uid, args.mailbox || 'INBOX'));
1872
+ } else if (name === 'list_attachments') {
1873
+ result = await withTimeout('list_attachments', TIMEOUT.FETCH, () => listAttachments(args.uid, args.mailbox || 'INBOX'));
1678
1874
  } else if (name === 'search_emails') {
1679
1875
  const { query, mailbox, limit, ...filters } = args;
1680
1876
  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.6.0",
3
+ "version": "1.8.0",
4
4
  "description": "A Model Context Protocol (MCP) server for iCloud Mail",
5
5
  "main": "index.js",
6
6
  "bin": {