icloud-mcp 2.0.0 → 2.2.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/.claude/settings.local.json +26 -0
- package/.mcp.json.example +12 -0
- package/README.md +68 -8
- package/index.js +191 -1963
- package/lib/imap.js +1944 -0
- package/lib/mime.js +134 -0
- package/lib/session.js +28 -0
- package/lib/smtp.js +220 -0
- package/package.json +3 -2
package/lib/mime.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// ─── MIME parsing helpers ─────────────────────────────────────────────────────
|
|
2
|
+
// Pure functions — no IMAP, no side effects.
|
|
3
|
+
|
|
4
|
+
export function decodeTransferEncoding(buffer, encoding) {
|
|
5
|
+
const enc = (encoding || '7bit').toLowerCase().trim();
|
|
6
|
+
if (enc === 'base64') {
|
|
7
|
+
return Buffer.from(buffer.toString('ascii').replace(/\s/g, ''), 'base64');
|
|
8
|
+
}
|
|
9
|
+
if (enc === 'quoted-printable') {
|
|
10
|
+
const str = buffer.toString('binary')
|
|
11
|
+
.replace(/[\t ]+$/gm, '')
|
|
12
|
+
.replace(/=(?:\r?\n|$)/g, '');
|
|
13
|
+
const result = Buffer.alloc(str.length);
|
|
14
|
+
let pos = 0;
|
|
15
|
+
for (let i = 0; i < str.length; i++) {
|
|
16
|
+
if (str[i] === '=' && i + 2 < str.length) {
|
|
17
|
+
const hex = str.slice(i + 1, i + 3);
|
|
18
|
+
if (/^[\da-fA-F]{2}$/.test(hex)) {
|
|
19
|
+
result[pos++] = parseInt(hex, 16);
|
|
20
|
+
i += 2;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
result[pos++] = str.charCodeAt(i) & 0xff;
|
|
25
|
+
}
|
|
26
|
+
return result.slice(0, pos);
|
|
27
|
+
}
|
|
28
|
+
return buffer;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function decodeCharset(buffer, charset) {
|
|
32
|
+
const cs = (charset || 'utf-8').toLowerCase().trim();
|
|
33
|
+
const nativeMap = { 'utf-8': 'utf8', 'utf8': 'utf8', 'us-ascii': 'ascii',
|
|
34
|
+
'ascii': 'ascii', 'latin1': 'latin1', 'iso-8859-1': 'latin1', 'binary': 'binary' };
|
|
35
|
+
if (nativeMap[cs]) return buffer.toString(nativeMap[cs]);
|
|
36
|
+
try {
|
|
37
|
+
const { default: iconv } = await import('iconv-lite');
|
|
38
|
+
if (iconv.encodingExists(cs)) return iconv.decode(buffer, cs);
|
|
39
|
+
} catch { /* iconv unavailable */ }
|
|
40
|
+
return buffer.toString('utf8');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function stripHtml(html) {
|
|
44
|
+
return html
|
|
45
|
+
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, ' ')
|
|
46
|
+
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, ' ')
|
|
47
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
48
|
+
.replace(/<\/p>/gi, '\n\n')
|
|
49
|
+
.replace(/<\/div>/gi, '\n')
|
|
50
|
+
.replace(/<\/li>/gi, '\n')
|
|
51
|
+
.replace(/<[^>]+>/g, '')
|
|
52
|
+
.replace(/ /gi, ' ')
|
|
53
|
+
.replace(/&/gi, '&')
|
|
54
|
+
.replace(/</gi, '<')
|
|
55
|
+
.replace(/>/gi, '>')
|
|
56
|
+
.replace(/"/gi, '"')
|
|
57
|
+
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n)))
|
|
58
|
+
.replace(/[ \t]+/g, ' ')
|
|
59
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
60
|
+
.trim();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Extract a specific header from imapflow's headers property.
|
|
64
|
+
// imapflow returns headers as a raw Buffer (BODY[HEADER.FIELDS ...] response bytes),
|
|
65
|
+
// so we parse it as text with MIME unfolding. Falls back to .get() if it's a Map.
|
|
66
|
+
export function extractRawHeader(headers, name) {
|
|
67
|
+
if (!headers) return '';
|
|
68
|
+
let str;
|
|
69
|
+
if (Buffer.isBuffer(headers)) {
|
|
70
|
+
str = headers.toString();
|
|
71
|
+
} else if (typeof headers.get === 'function') {
|
|
72
|
+
return (headers.get(name) ?? '').toString().trim();
|
|
73
|
+
} else {
|
|
74
|
+
str = headers.toString();
|
|
75
|
+
}
|
|
76
|
+
// Unfold MIME-folded header values (CRLF + whitespace = continuation)
|
|
77
|
+
const unfolded = str.replace(/\r?\n[ \t]+/g, ' ');
|
|
78
|
+
return unfolded.match(new RegExp(`^${name}:\\s*(.+)`, 'im'))?.[1]?.trim() ?? '';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function findTextPart(node) {
|
|
82
|
+
if (!node.childNodes) {
|
|
83
|
+
if (node.type && node.type.startsWith('text/') && node.disposition !== 'attachment') {
|
|
84
|
+
return { partId: null, type: node.type, encoding: node.encoding, charset: node.parameters?.charset, size: node.size };
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
if (node.type === 'multipart/alternative') {
|
|
89
|
+
let plainPart = null, htmlPart = null;
|
|
90
|
+
for (const child of node.childNodes) {
|
|
91
|
+
if (child.childNodes || child.disposition === 'attachment') continue;
|
|
92
|
+
if (child.type === 'text/plain') plainPart = child;
|
|
93
|
+
else if (child.type === 'text/html') htmlPart = child;
|
|
94
|
+
}
|
|
95
|
+
const chosen = plainPart || htmlPart;
|
|
96
|
+
if (chosen) return { partId: chosen.part, type: chosen.type, encoding: chosen.encoding, charset: chosen.parameters?.charset, size: chosen.size };
|
|
97
|
+
}
|
|
98
|
+
for (const child of node.childNodes) {
|
|
99
|
+
if (child.disposition === 'attachment') continue;
|
|
100
|
+
const found = findTextPart(child);
|
|
101
|
+
if (found) return found;
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function findAttachments(node, parts = []) {
|
|
107
|
+
if (node.childNodes) {
|
|
108
|
+
for (const child of node.childNodes) findAttachments(child, parts);
|
|
109
|
+
} else {
|
|
110
|
+
const filename = node.dispositionParameters?.filename ?? node.parameters?.name ?? null;
|
|
111
|
+
const isTextBody = (node.type === 'text/plain' || node.type === 'text/html') && node.disposition !== 'attachment';
|
|
112
|
+
if (node.disposition === 'attachment' || node.disposition === 'inline' || (filename && !isTextBody)) {
|
|
113
|
+
parts.push({
|
|
114
|
+
partId: node.part ?? 'TEXT',
|
|
115
|
+
filename,
|
|
116
|
+
mimeType: node.type ?? 'application/octet-stream',
|
|
117
|
+
size: node.size ?? 0,
|
|
118
|
+
encoding: node.encoding ?? '7bit',
|
|
119
|
+
disposition: node.disposition ?? 'attachment'
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return parts;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function estimateEmailSize(node) {
|
|
127
|
+
if (node.childNodes) return node.childNodes.reduce((s, c) => s + estimateEmailSize(c), 0);
|
|
128
|
+
return node.size || 0;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function stripSubjectPrefixes(subject) {
|
|
132
|
+
if (!subject) return '';
|
|
133
|
+
return subject.replace(/^(Re:|RE:|Fwd:|FWD:|Fw:|FW:|AW:|回复:|转发:)\s*/i, '').trim();
|
|
134
|
+
}
|
package/lib/session.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// ─── Session Log ──────────────────────────────────────────────────────────────
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
|
|
6
|
+
const LOG_FILE = join(homedir(), '.icloud-mcp-session.json');
|
|
7
|
+
|
|
8
|
+
export function logRead() {
|
|
9
|
+
if (!existsSync(LOG_FILE)) return { steps: [], startedAt: null };
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(readFileSync(LOG_FILE, 'utf8'));
|
|
12
|
+
} catch {
|
|
13
|
+
return { steps: [], startedAt: null };
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function logWrite(step) {
|
|
18
|
+
const log = logRead();
|
|
19
|
+
if (!log.startedAt) log.startedAt = new Date().toISOString();
|
|
20
|
+
log.steps.push({ time: new Date().toISOString(), step });
|
|
21
|
+
writeFileSync(LOG_FILE, JSON.stringify(log, null, 2));
|
|
22
|
+
return log;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function logClear() {
|
|
26
|
+
writeFileSync(LOG_FILE, JSON.stringify({ steps: [], startedAt: null }, null, 2));
|
|
27
|
+
return { cleared: true };
|
|
28
|
+
}
|
package/lib/smtp.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import nodemailer from 'nodemailer';
|
|
2
|
+
import { ImapFlow } from 'imapflow';
|
|
3
|
+
|
|
4
|
+
const SMTP_HOST = 'smtp.mail.me.com';
|
|
5
|
+
const SMTP_PORT = 587;
|
|
6
|
+
|
|
7
|
+
function getCredentials() {
|
|
8
|
+
const user = process.env.IMAP_USER;
|
|
9
|
+
const pass = process.env.IMAP_PASSWORD;
|
|
10
|
+
if (!user || !pass) throw new Error('IMAP_USER and IMAP_PASSWORD are required for SMTP operations');
|
|
11
|
+
return { user, pass };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function createTransport() {
|
|
15
|
+
const { user, pass } = getCredentials();
|
|
16
|
+
return nodemailer.createTransport({
|
|
17
|
+
host: SMTP_HOST,
|
|
18
|
+
port: SMTP_PORT,
|
|
19
|
+
secure: false, // STARTTLS on port 587
|
|
20
|
+
auth: { user, pass },
|
|
21
|
+
connectionTimeout: 15_000,
|
|
22
|
+
socketTimeout: 30_000,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function normalizeAddresses(val) {
|
|
27
|
+
if (!val) return undefined;
|
|
28
|
+
return Array.isArray(val) ? val.join(', ') : val;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Convert HTML to a readable plain-text fallback for multipart/alternative
|
|
32
|
+
function htmlToText(html) {
|
|
33
|
+
return html
|
|
34
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
35
|
+
.replace(/<\/p>/gi, '\n\n')
|
|
36
|
+
.replace(/<\/h[1-6]>/gi, '\n\n')
|
|
37
|
+
.replace(/<\/li>/gi, '\n')
|
|
38
|
+
.replace(/<\/tr>/gi, '\n')
|
|
39
|
+
.replace(/<\/t[dh]>/gi, '\t')
|
|
40
|
+
.replace(/<[^>]+>/g, '')
|
|
41
|
+
.replace(/&/g, '&')
|
|
42
|
+
.replace(/</g, '<')
|
|
43
|
+
.replace(/>/g, '>')
|
|
44
|
+
.replace(/ /g, ' ')
|
|
45
|
+
.replace(/[ \t]+\n/g, '\n')
|
|
46
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
47
|
+
.trim();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function applyBody(mailOptions, body, html) {
|
|
51
|
+
if (html && body) {
|
|
52
|
+
// Both provided: multipart/alternative, clients choose which to render
|
|
53
|
+
mailOptions.text = body;
|
|
54
|
+
mailOptions.html = html;
|
|
55
|
+
} else if (html) {
|
|
56
|
+
// HTML only: auto-generate plain text fallback
|
|
57
|
+
mailOptions.html = html;
|
|
58
|
+
mailOptions.text = htmlToText(html);
|
|
59
|
+
} else {
|
|
60
|
+
mailOptions.text = body;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ─── compose_email ────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
export async function composeEmail(to, subject, body, opts = {}) {
|
|
67
|
+
const { user } = getCredentials();
|
|
68
|
+
const transport = createTransport();
|
|
69
|
+
const mailOptions = { from: user, to: normalizeAddresses(to), subject };
|
|
70
|
+
applyBody(mailOptions, body, opts.html);
|
|
71
|
+
if (opts.cc) mailOptions.cc = normalizeAddresses(opts.cc);
|
|
72
|
+
if (opts.bcc) mailOptions.bcc = normalizeAddresses(opts.bcc);
|
|
73
|
+
if (opts.replyTo) mailOptions.replyTo = opts.replyTo;
|
|
74
|
+
|
|
75
|
+
const info = await transport.sendMail(mailOptions);
|
|
76
|
+
return {
|
|
77
|
+
sent: true,
|
|
78
|
+
messageId: info.messageId,
|
|
79
|
+
accepted: info.accepted,
|
|
80
|
+
rejected: info.rejected,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ─── reply_to_email ───────────────────────────────────────────────────────────
|
|
85
|
+
// email = getEmailContent(uid, mailbox, maxChars, includeHeaders: true) result
|
|
86
|
+
|
|
87
|
+
export async function replyToEmail(email, body, opts = {}) {
|
|
88
|
+
const { user } = getCredentials();
|
|
89
|
+
const transport = createTransport();
|
|
90
|
+
|
|
91
|
+
const originalSubject = email.subject ?? '';
|
|
92
|
+
const originalMessageId = email.headers?.messageId ?? null;
|
|
93
|
+
const originalFrom = email.from ?? '';
|
|
94
|
+
const originalReplyTo = email.headers?.replyTo ?? null;
|
|
95
|
+
const existingRefs = email.headers?.references ?? [];
|
|
96
|
+
|
|
97
|
+
const subject = /^re:/i.test(originalSubject) ? originalSubject : `Re: ${originalSubject}`;
|
|
98
|
+
|
|
99
|
+
// Build RFC 2822 References chain: existing refs + original message-id
|
|
100
|
+
const references = [...existingRefs, ...(originalMessageId ? [originalMessageId] : [])]
|
|
101
|
+
.filter(Boolean)
|
|
102
|
+
.join(' ');
|
|
103
|
+
|
|
104
|
+
// Who to reply to: prefer Reply-To over From
|
|
105
|
+
const replyTarget = originalReplyTo ?? originalFrom;
|
|
106
|
+
|
|
107
|
+
let toAddresses;
|
|
108
|
+
if (opts.replyAll) {
|
|
109
|
+
const originalTo = email.headers?.to ?? [];
|
|
110
|
+
const originalCc = email.headers?.cc ?? [];
|
|
111
|
+
// Reply-all: reply target + original To/Cc, excluding ourselves
|
|
112
|
+
toAddresses = [replyTarget, ...originalTo, ...originalCc]
|
|
113
|
+
.filter(a => a && a !== user);
|
|
114
|
+
} else {
|
|
115
|
+
toAddresses = [replyTarget].filter(Boolean);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const mailOptions = {
|
|
119
|
+
from: user,
|
|
120
|
+
to: toAddresses.join(', '),
|
|
121
|
+
subject,
|
|
122
|
+
inReplyTo: originalMessageId,
|
|
123
|
+
references,
|
|
124
|
+
};
|
|
125
|
+
applyBody(mailOptions, body, opts.html);
|
|
126
|
+
if (opts.cc) mailOptions.cc = normalizeAddresses(opts.cc);
|
|
127
|
+
|
|
128
|
+
const info = await transport.sendMail(mailOptions);
|
|
129
|
+
return {
|
|
130
|
+
sent: true,
|
|
131
|
+
messageId: info.messageId,
|
|
132
|
+
accepted: info.accepted,
|
|
133
|
+
rejected: info.rejected,
|
|
134
|
+
inReplyTo: originalMessageId,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ─── forward_email ────────────────────────────────────────────────────────────
|
|
139
|
+
// email = getEmailContent result (no need for includeHeaders)
|
|
140
|
+
|
|
141
|
+
export async function forwardEmail(email, to, note = '', opts = {}) {
|
|
142
|
+
const { user } = getCredentials();
|
|
143
|
+
const transport = createTransport();
|
|
144
|
+
|
|
145
|
+
const originalSubject = email.subject ?? '';
|
|
146
|
+
const subject = /^fwd:/i.test(originalSubject) ? originalSubject : `Fwd: ${originalSubject}`;
|
|
147
|
+
|
|
148
|
+
const forwardHeader = [
|
|
149
|
+
'---------- Forwarded message ----------',
|
|
150
|
+
`From: ${email.from ?? '(unknown)'}`,
|
|
151
|
+
`Date: ${email.date ? new Date(email.date).toUTCString() : '(unknown)'}`,
|
|
152
|
+
`Subject: ${originalSubject}`,
|
|
153
|
+
'',
|
|
154
|
+
].join('\n');
|
|
155
|
+
|
|
156
|
+
const forwardBody = note
|
|
157
|
+
? `${note}\n\n${forwardHeader}\n${email.body ?? ''}`
|
|
158
|
+
: `${forwardHeader}\n${email.body ?? ''}`;
|
|
159
|
+
|
|
160
|
+
const mailOptions = { from: user, to: normalizeAddresses(to), subject };
|
|
161
|
+
applyBody(mailOptions, forwardBody, opts.html);
|
|
162
|
+
if (opts.cc) mailOptions.cc = normalizeAddresses(opts.cc);
|
|
163
|
+
|
|
164
|
+
const info = await transport.sendMail(mailOptions);
|
|
165
|
+
return {
|
|
166
|
+
sent: true,
|
|
167
|
+
messageId: info.messageId,
|
|
168
|
+
accepted: info.accepted,
|
|
169
|
+
rejected: info.rejected,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ─── save_draft ───────────────────────────────────────────────────────────────
|
|
174
|
+
// Builds the raw MIME message without sending, then APPENDs to Drafts via IMAP.
|
|
175
|
+
|
|
176
|
+
export async function saveDraft(to, subject, body, opts = {}) {
|
|
177
|
+
const { user, pass } = getCredentials();
|
|
178
|
+
|
|
179
|
+
const mailOptions = { from: user, to: normalizeAddresses(to), subject };
|
|
180
|
+
applyBody(mailOptions, body, opts.html);
|
|
181
|
+
if (opts.cc) mailOptions.cc = normalizeAddresses(opts.cc);
|
|
182
|
+
if (opts.bcc) mailOptions.bcc = normalizeAddresses(opts.bcc);
|
|
183
|
+
|
|
184
|
+
// Use nodemailer stream transport to produce raw MIME bytes without sending
|
|
185
|
+
const streamTransport = nodemailer.createTransport({ streamTransport: true, buffer: true });
|
|
186
|
+
const { message: rawMessage } = await streamTransport.sendMail(mailOptions);
|
|
187
|
+
|
|
188
|
+
// APPEND the raw message to the Drafts folder via IMAP
|
|
189
|
+
const client = new ImapFlow({
|
|
190
|
+
host: 'imap.mail.me.com',
|
|
191
|
+
port: 993,
|
|
192
|
+
secure: true,
|
|
193
|
+
auth: { user, pass },
|
|
194
|
+
logger: false,
|
|
195
|
+
connectionTimeout: 15_000,
|
|
196
|
+
greetingTimeout: 15_000,
|
|
197
|
+
socketTimeout: 60_000,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
await client.connect();
|
|
201
|
+
|
|
202
|
+
let draftMailbox = 'Drafts';
|
|
203
|
+
try {
|
|
204
|
+
await client.append(draftMailbox, rawMessage, ['\\Draft', '\\Seen']);
|
|
205
|
+
} catch (err) {
|
|
206
|
+
// Folder might have a different name — scan the list for the \Drafts attribute
|
|
207
|
+
if (err.message?.includes('NONEXISTENT') || err.message?.includes('does not exist') || err.message?.includes('NO ')) {
|
|
208
|
+
const mailboxes = await client.list();
|
|
209
|
+
const draftMb = mailboxes.find(mb => mb.flags?.has('\\Drafts'));
|
|
210
|
+
draftMailbox = draftMb?.path ?? 'Drafts';
|
|
211
|
+
await client.append(draftMailbox, rawMessage, ['\\Draft', '\\Seen']);
|
|
212
|
+
} else {
|
|
213
|
+
throw err;
|
|
214
|
+
}
|
|
215
|
+
} finally {
|
|
216
|
+
try { await client.logout(); } catch { client.close(); }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return { saved: true, mailbox: draftMailbox, to: mailOptions.to, subject };
|
|
220
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "icloud-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "A Model Context Protocol (MCP) server for iCloud Mail",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"license": "MIT",
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
25
|
-
"imapflow": "^1.2.10"
|
|
25
|
+
"imapflow": "^1.2.10",
|
|
26
|
+
"nodemailer": "^8.0.2"
|
|
26
27
|
}
|
|
27
28
|
}
|