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/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(/&nbsp;/gi, ' ')
53
+ .replace(/&amp;/gi, '&')
54
+ .replace(/&lt;/gi, '<')
55
+ .replace(/&gt;/gi, '>')
56
+ .replace(/&quot;/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(/&amp;/g, '&')
42
+ .replace(/&lt;/g, '<')
43
+ .replace(/&gt;/g, '>')
44
+ .replace(/&nbsp;/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.0.0",
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
  }