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.
- package/index.js +208 -12
- 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(/ /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
|
+
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
|
-
|
|
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
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
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
|
-
|
|
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)
|
|
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.
|
|
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));
|