icloud-mcp 1.6.0 → 1.7.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 +153 -11
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -1033,25 +1033,167 @@ 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
+ // ─── Email content fetcher (MIME-aware) ───────────────────────────────────────
1123
+
1036
1124
  async function getEmailContent(uid, mailbox = 'INBOX') {
1037
1125
  const client = createRateLimitedClient();
1038
1126
  await client.connect();
1039
1127
  await client.mailboxOpen(mailbox);
1040
- const meta = await client.fetchOne(uid, { envelope: true, flags: true }, { uid: true });
1128
+
1129
+ const meta = await client.fetchOne(uid, { envelope: true, flags: true, bodyStructure: true }, { uid: true });
1130
+ if (!meta) {
1131
+ await client.logout();
1132
+ return { uid, subject: null, from: null, date: null, flags: [], body: '(email not found)' };
1133
+ }
1134
+
1041
1135
  let body = '(body unavailable)';
1136
+
1042
1137
  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);
1138
+ const struct = meta.bodyStructure;
1139
+ if (!struct) throw new Error('no bodyStructure');
1140
+
1141
+ const textPart = findTextPart(struct);
1142
+
1143
+ if (!textPart) {
1144
+ body = '(no readable text — email may be image-only or have no text parts)';
1145
+ } else {
1146
+ // Single-part messages use 'TEXT'; multipart use dot-notation part id (e.g. '1', '1.1')
1147
+ const imapKey = textPart.partId ?? 'TEXT';
1148
+
1149
+ // For large parts, cap the fetch at 12KB to avoid downloading multi-MB newsletters
1150
+ const fetchSpec = (textPart.size && textPart.size > 150_000)
1151
+ ? [{ key: imapKey, start: 0, maxLength: 12_000 }]
1152
+ : [imapKey];
1153
+
1154
+ const partMsg = await Promise.race([
1155
+ client.fetchOne(uid, { bodyParts: fetchSpec }, { uid: true }),
1156
+ new Promise((_, reject) => setTimeout(() => reject(new Error('body fetch timeout')), 10_000))
1157
+ ]);
1158
+
1159
+ // bodyParts is a Map — try the key as-is, then uppercase, then lowercase
1160
+ const partBuffer = partMsg?.bodyParts?.get(imapKey)
1161
+ ?? partMsg?.bodyParts?.get(imapKey.toUpperCase())
1162
+ ?? partMsg?.bodyParts?.get(imapKey.toLowerCase());
1163
+
1164
+ if (!partBuffer || partBuffer.length === 0) throw new Error('empty body part');
1165
+
1166
+ const decoded = decodeTransferEncoding(partBuffer, textPart.encoding);
1167
+ let text = await decodeCharset(decoded, textPart.charset);
1168
+
1169
+ if (textPart.type === 'text/html') text = stripHtml(text);
1170
+
1171
+ const MAX_BODY = 8_000;
1172
+ if (text.length > MAX_BODY) {
1173
+ text = text.slice(0, MAX_BODY) + `\n\n[... truncated — ${text.length.toLocaleString()} chars total]`;
1174
+ }
1175
+
1176
+ body = text.trim() || '(empty body)';
1177
+
1178
+ if (textPart.size && textPart.size > 150_000) {
1179
+ body += `\n\n[Note: email body is large (${Math.round(textPart.size / 1024)}KB) — showing first 12KB]`;
1180
+ }
1051
1181
  }
1052
1182
  } catch {
1053
- body = '(body unavailable - email may be too large)';
1183
+ // Fallback: raw source slice (original behaviour)
1184
+ try {
1185
+ const sourceMsg = await Promise.race([
1186
+ client.fetchOne(uid, { source: true }, { uid: true }),
1187
+ new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 5_000))
1188
+ ]);
1189
+ if (sourceMsg?.source) {
1190
+ const raw = sourceMsg.source.toString();
1191
+ const bodyStart = raw.indexOf('\r\n\r\n');
1192
+ body = '[raw fallback]\n' + (bodyStart > -1 ? raw.slice(bodyStart + 4, bodyStart + 2000) : raw.slice(0, 2000));
1193
+ }
1194
+ } catch { /* leave as unavailable */ }
1054
1195
  }
1196
+
1055
1197
  await client.logout();
1056
1198
  return {
1057
1199
  uid: meta.uid,
@@ -1279,7 +1421,7 @@ function logClear() {
1279
1421
 
1280
1422
  async function main() {
1281
1423
  const server = new Server(
1282
- { name: 'icloud-mail', version: '1.6.0' },
1424
+ { name: 'icloud-mail', version: '1.7.0' },
1283
1425
  { capabilities: { tools: {} } }
1284
1426
  );
1285
1427
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "icloud-mcp",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "A Model Context Protocol (MCP) server for iCloud Mail",
5
5
  "main": "index.js",
6
6
  "bin": {