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.
- package/index.js +153 -11
- 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(/ /gi, ' ')
|
|
1087
|
+
.replace(/&/gi, '&')
|
|
1088
|
+
.replace(/</gi, '<')
|
|
1089
|
+
.replace(/>/gi, '>')
|
|
1090
|
+
.replace(/"/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
|
-
|
|
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
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
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
|
-
|
|
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.
|
|
1424
|
+
{ name: 'icloud-mail', version: '1.7.0' },
|
|
1283
1425
|
{ capabilities: { tools: {} } }
|
|
1284
1426
|
);
|
|
1285
1427
|
|