icloud-mcp 1.0.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 (4) hide show
  1. package/README.md +118 -0
  2. package/index.js +686 -0
  3. package/package.json +21 -0
  4. package/test.js +211 -0
package/README.md ADDED
@@ -0,0 +1,118 @@
1
+ # icloud-mcp
2
+
3
+ A Model Context Protocol (MCP) server that connects Claude Desktop to your iCloud Mail account. Manage, search, and organize your inbox directly through Claude.
4
+
5
+ ## Features
6
+
7
+ - ๐Ÿ“ฌ Read and paginate through your inbox
8
+ - ๐Ÿ” Search emails by keyword, sender, or date range
9
+ - ๐Ÿ—‘๏ธ Bulk delete emails by sender or subject
10
+ - ๐Ÿ“ Move emails between folders
11
+ - ๐Ÿ“Š Analyze top senders to identify inbox clutter
12
+ - โœ… Mark emails as read/unread, flag/unflag
13
+ - ๐Ÿ—‚๏ธ List and create mailboxes
14
+
15
+ ## Prerequisites
16
+
17
+ - [Claude Desktop](https://claude.ai/download)
18
+ - Node.js v20 or higher
19
+ - An iCloud account with an app-specific password
20
+
21
+ ## Setup
22
+
23
+ ### 1. Generate an Apple App-Specific Password
24
+
25
+ 1. Go to [appleid.apple.com](https://appleid.apple.com)
26
+ 2. Sign in and navigate to **Sign-In and Security โ†’ App-Specific Passwords**
27
+ 3. Click **+** to generate a new password
28
+ 4. Label it something like `Claude MCP` and save the generated password
29
+
30
+ ### 2. Install the server
31
+
32
+ ```bash
33
+ git clone https://github.com/YOUR_USERNAME/icloud-mcp.git
34
+ cd icloud-mcp
35
+ npm install
36
+ ```
37
+
38
+ ### 3. Configure Claude Desktop
39
+
40
+ Open your Claude Desktop config file:
41
+
42
+ ```bash
43
+ open ~/Library/Application\ Support/Claude/claude_desktop_config.json
44
+ ```
45
+
46
+ Add the following under `mcpServers`:
47
+
48
+ ```json
49
+ {
50
+ "mcpServers": {
51
+ "icloud-mail": {
52
+ "command": "/opt/homebrew/bin/node",
53
+ "args": ["/path/to/icloud-mcp/index.js"],
54
+ "env": {
55
+ "IMAP_USER": "you@icloud.com",
56
+ "IMAP_PASSWORD": "your-app-specific-password"
57
+ }
58
+ }
59
+ }
60
+ }
61
+ ```
62
+
63
+ > **Note:** Replace `/path/to/icloud-mcp` with the actual path where you cloned the repo, and `/opt/homebrew/bin/node` with the output of `which node`.
64
+
65
+ ### 4. Restart Claude Desktop
66
+
67
+ Fully quit Claude Desktop (Cmd+Q) and reopen it. You should now be able to manage your iCloud inbox through Claude.
68
+
69
+ ## Available Tools
70
+
71
+ | Tool | Description |
72
+ |------|-------------|
73
+ | `get_inbox_summary` | Total, unread, and recent email counts |
74
+ | `get_top_senders` | Top senders by volume from a sample of recent emails |
75
+ | `get_unread_senders` | Top senders of unread emails |
76
+ | `read_inbox` | Paginated inbox with sender, subject, date |
77
+ | `get_email` | Full content of a specific email by UID |
78
+ | `get_emails_by_sender` | All emails from a specific address |
79
+ | `get_emails_by_date_range` | Emails between two dates |
80
+ | `search_emails` | Search by keyword across subject, sender, and body |
81
+ | `flag_email` | Flag or unflag an email |
82
+ | `mark_as_read` | Mark an email as read or unread |
83
+ | `delete_email` | Move an email to Deleted Messages |
84
+ | `bulk_delete_by_sender` | Delete all emails from a sender |
85
+ | `bulk_delete_by_subject` | Delete all emails matching a subject keyword |
86
+ | `bulk_move_by_sender` | Move all emails from a sender to a folder |
87
+ | `bulk_mark_read` | Mark all emails (or all from a sender) as read |
88
+ | `delete_older_than` | Delete all emails older than N days |
89
+ | `move_email` | Move a single email to a folder |
90
+ | `list_mailboxes` | List all folders in your iCloud Mail |
91
+ | `create_mailbox` | Create a new folder |
92
+ | `empty_trash` | Permanently delete all emails in Deleted Messages |
93
+
94
+ ## Example Usage
95
+
96
+ Once configured, you can ask Claude things like:
97
+
98
+ - *"Show me the top senders in my iCloud inbox"*
99
+ - *"Delete all emails from no-reply@instagram.com"*
100
+ - *"How many unread emails do I have?"*
101
+ - *"Move all emails from newsletters@substack.com to my newsletters folder"*
102
+ - *"Show me emails from the last week"*
103
+
104
+ ## Running Tests
105
+
106
+ ```bash
107
+ IMAP_USER="you@icloud.com" IMAP_PASSWORD="your-app-specific-password" /opt/homebrew/bin/node test.js
108
+ ```
109
+
110
+ ## Security
111
+
112
+ - Your credentials are stored only in your local Claude Desktop config file
113
+ - The server runs entirely on your machine โ€” no data is sent to any third party
114
+ - App-specific passwords can be revoked at any time from [appleid.apple.com](https://appleid.apple.com)
115
+
116
+ ## License
117
+
118
+ MIT
package/index.js ADDED
@@ -0,0 +1,686 @@
1
+ #!/usr/bin/env node
2
+ import { ImapFlow } from 'imapflow';
3
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
4
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
+ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
6
+
7
+ const IMAP_USER = process.env.IMAP_USER;
8
+ const IMAP_PASSWORD = process.env.IMAP_PASSWORD;
9
+
10
+ if (!IMAP_USER || !IMAP_PASSWORD) {
11
+ process.stderr.write('Error: IMAP_USER and IMAP_PASSWORD environment variables are required\n');
12
+ process.exit(1);
13
+ }
14
+
15
+ function createClient() {
16
+ return new ImapFlow({
17
+ host: 'imap.mail.me.com',
18
+ port: 993,
19
+ secure: true,
20
+ auth: { user: IMAP_USER, pass: IMAP_PASSWORD },
21
+ logger: false
22
+ });
23
+ }
24
+
25
+ async function fetchEmails(mailbox = 'INBOX', limit = 10, onlyUnread = false, page = 1) {
26
+ const client = createClient();
27
+ await client.connect();
28
+ const mb = await client.mailboxOpen(mailbox);
29
+ const total = mb.exists;
30
+ const emails = [];
31
+
32
+ if (total === 0) {
33
+ await client.logout();
34
+ return { emails, page, limit, total, totalPages: 0, hasMore: false };
35
+ }
36
+
37
+ if (onlyUnread) {
38
+ const uids = (await client.search({ seen: false }, { uid: true })) ?? [];
39
+ const totalUnread = uids.length;
40
+ const skip = (page - 1) * limit;
41
+ const pageUids = uids.reverse().slice(skip, skip + limit);
42
+ for (const uid of pageUids) {
43
+ const msg = await client.fetchOne(uid, { envelope: true, flags: true }, { uid: true });
44
+ if (msg) {
45
+ emails.push({
46
+ uid,
47
+ subject: msg.envelope.subject,
48
+ from: msg.envelope.from?.[0]?.address,
49
+ date: msg.envelope.date,
50
+ flagged: msg.flags.has('\\Flagged'),
51
+ seen: msg.flags.has('\\Seen')
52
+ });
53
+ }
54
+ }
55
+ await client.logout();
56
+ return { emails, page, limit, total: totalUnread, totalPages: Math.ceil(totalUnread / limit), hasMore: (page * limit) < totalUnread };
57
+ }
58
+
59
+ const end = Math.max(1, total - ((page - 1) * limit));
60
+ const start = Math.max(1, end - limit + 1);
61
+ const range = `${start}:${end}`;
62
+
63
+ for await (const msg of client.fetch(range, { envelope: true, flags: true })) {
64
+ emails.push({
65
+ uid: msg.uid,
66
+ subject: msg.envelope.subject,
67
+ from: msg.envelope.from?.[0]?.address,
68
+ date: msg.envelope.date,
69
+ flagged: msg.flags.has('\\Flagged'),
70
+ seen: msg.flags.has('\\Seen')
71
+ });
72
+ }
73
+
74
+ await client.logout();
75
+ emails.reverse();
76
+ return { emails, page, limit, total, totalPages: Math.ceil(total / limit), hasMore: (page * limit) < total };
77
+ }
78
+
79
+ async function getInboxSummary(mailbox = 'INBOX') {
80
+ const client = createClient();
81
+ await client.connect();
82
+ const status = await client.status(mailbox, { messages: true, unseen: true, recent: true });
83
+ await client.logout();
84
+ return { mailbox, total: status.messages, unread: status.unseen, recent: status.recent };
85
+ }
86
+
87
+ async function getTopSenders(mailbox = 'INBOX', sampleSize = 500) {
88
+ const client = createClient();
89
+ await client.connect();
90
+ const mb = await client.mailboxOpen(mailbox);
91
+ const total = mb.exists;
92
+ const senderCounts = {};
93
+ const senderDomains = {};
94
+
95
+ const end = total;
96
+ const start = Math.max(1, total - sampleSize + 1);
97
+ const range = `${start}:${end}`;
98
+ let count = 0;
99
+
100
+ for await (const msg of client.fetch(range, { envelope: true })) {
101
+ const address = msg.envelope.from?.[0]?.address;
102
+ if (address) {
103
+ senderCounts[address] = (senderCounts[address] || 0) + 1;
104
+ const domain = address.split('@')[1];
105
+ if (domain) senderDomains[domain] = (senderDomains[domain] || 0) + 1;
106
+ }
107
+ count++;
108
+ }
109
+
110
+ await client.logout();
111
+ const topAddresses = Object.entries(senderCounts).sort((a, b) => b[1] - a[1]).slice(0, 20).map(([address, count]) => ({ address, count }));
112
+ const topDomains = Object.entries(senderDomains).sort((a, b) => b[1] - a[1]).slice(0, 20).map(([domain, count]) => ({ domain, count }));
113
+ return { sampledEmails: count, topAddresses, topDomains };
114
+ }
115
+
116
+ async function getUnreadSenders(mailbox = 'INBOX', sampleSize = 500) {
117
+ const client = createClient();
118
+ await client.connect();
119
+ await client.mailboxOpen(mailbox);
120
+ const uids = (await client.search({ seen: false }, { uid: true })) ?? [];
121
+ const recentUids = uids.reverse().slice(0, sampleSize);
122
+ const senderCounts = {};
123
+
124
+ for (const uid of recentUids) {
125
+ const msg = await client.fetchOne(uid, { envelope: true }, { uid: true });
126
+ if (msg) {
127
+ const address = msg.envelope.from?.[0]?.address;
128
+ if (address) senderCounts[address] = (senderCounts[address] || 0) + 1;
129
+ }
130
+ }
131
+
132
+ await client.logout();
133
+ return Object.entries(senderCounts).sort((a, b) => b[1] - a[1]).slice(0, 20).map(([address, count]) => ({ address, count }));
134
+ }
135
+
136
+ async function getEmailsBySender(sender, mailbox = 'INBOX', limit = 10) {
137
+ const client = createClient();
138
+ await client.connect();
139
+ await client.mailboxOpen(mailbox);
140
+ const uids = (await client.search({ from: sender }, { uid: true })) ?? [];
141
+ const total = uids.length;
142
+ const recentUids = uids.slice(-limit).reverse();
143
+ const emails = [];
144
+ for (const uid of recentUids) {
145
+ const msg = await client.fetchOne(uid, { envelope: true, flags: true }, { uid: true });
146
+ if (msg) {
147
+ emails.push({
148
+ uid,
149
+ subject: msg.envelope.subject,
150
+ from: msg.envelope.from?.[0]?.address,
151
+ date: msg.envelope.date,
152
+ flagged: msg.flags.has('\\Flagged'),
153
+ seen: msg.flags.has('\\Seen')
154
+ });
155
+ }
156
+ }
157
+ await client.logout();
158
+ return { total, showing: emails.length, emails };
159
+ }
160
+
161
+ async function bulkDeleteBySender(sender, mailbox = 'INBOX') {
162
+ const client = createClient();
163
+ await client.connect();
164
+ await client.mailboxOpen(mailbox);
165
+ const uids = (await client.search({ from: sender }, { uid: true })) ?? [];
166
+ if (uids.length === 0) { await client.logout(); return { deleted: 0 }; }
167
+ await client.messageDelete(uids, { uid: true });
168
+ await client.logout();
169
+ return { deleted: uids.length, sender };
170
+ }
171
+
172
+ async function bulkMoveBySender(sender, targetMailbox, sourceMailbox = 'INBOX') {
173
+ const client = createClient();
174
+ await client.connect();
175
+ await client.mailboxOpen(sourceMailbox);
176
+ const uids = (await client.search({ from: sender }, { uid: true })) ?? [];
177
+ if (uids.length === 0) { await client.logout(); return { moved: 0 }; }
178
+ await client.messageMove(uids, targetMailbox, { uid: true });
179
+ await client.logout();
180
+ return { moved: uids.length, sender, targetMailbox };
181
+ }
182
+
183
+ async function bulkDeleteBySubject(subject, mailbox = 'INBOX') {
184
+ const client = createClient();
185
+ await client.connect();
186
+ await client.mailboxOpen(mailbox);
187
+ const uids = (await client.search({ subject }, { uid: true })) ?? [];
188
+ if (uids.length === 0) { await client.logout(); return { deleted: 0 }; }
189
+ await client.messageDelete(uids, { uid: true });
190
+ await client.logout();
191
+ return { deleted: uids.length, subject };
192
+ }
193
+
194
+ async function deleteOlderThan(days, mailbox = 'INBOX') {
195
+ const client = createClient();
196
+ await client.connect();
197
+ await client.mailboxOpen(mailbox);
198
+ const date = new Date();
199
+ date.setDate(date.getDate() - days);
200
+ const uids = (await client.search({ before: date }, { uid: true })) ?? [];
201
+ if (uids.length === 0) { await client.logout(); return { deleted: 0 }; }
202
+ await client.messageDelete(uids, { uid: true });
203
+ await client.logout();
204
+ return { deleted: uids.length, olderThan: date.toISOString() };
205
+ }
206
+
207
+ async function getEmailsByDateRange(startDate, endDate, mailbox = 'INBOX', limit = 10) {
208
+ const client = createClient();
209
+ await client.connect();
210
+ await client.mailboxOpen(mailbox);
211
+ const uids = (await client.search({ since: new Date(startDate), before: new Date(endDate) }, { uid: true })) ?? [];
212
+ const total = uids.length;
213
+ const recentUids = uids.slice(-limit).reverse();
214
+ const emails = [];
215
+ for (const uid of recentUids) {
216
+ const msg = await client.fetchOne(uid, { envelope: true, flags: true }, { uid: true });
217
+ if (msg) {
218
+ emails.push({
219
+ uid,
220
+ subject: msg.envelope.subject,
221
+ from: msg.envelope.from?.[0]?.address,
222
+ date: msg.envelope.date,
223
+ flagged: msg.flags.has('\\Flagged'),
224
+ seen: msg.flags.has('\\Seen')
225
+ });
226
+ }
227
+ }
228
+ await client.logout();
229
+ return { total, showing: emails.length, emails };
230
+ }
231
+
232
+ async function bulkMarkRead(mailbox = 'INBOX', sender = null) {
233
+ const client = createClient();
234
+ await client.connect();
235
+ await client.mailboxOpen(mailbox);
236
+ const query = sender ? { from: sender, seen: false } : { seen: false };
237
+ const uids = (await client.search(query, { uid: true })) ?? [];
238
+ if (uids.length === 0) { await client.logout(); return { marked: 0 }; }
239
+ await client.messageFlagsAdd(uids, ['\\Seen'], { uid: true });
240
+ await client.logout();
241
+ return { marked: uids.length, sender: sender || 'all' };
242
+ }
243
+
244
+ async function emptyTrash() {
245
+ const client = createClient();
246
+ await client.connect();
247
+ await client.mailboxOpen('Deleted Messages');
248
+ const uids = (await client.search({ all: true }, { uid: true })) ?? [];
249
+ if (uids.length === 0) { await client.logout(); return { deleted: 0 }; }
250
+ await client.messageDelete(uids, { uid: true });
251
+ await client.logout();
252
+ return { deleted: uids.length };
253
+ }
254
+
255
+ async function createMailbox(name) {
256
+ const client = createClient();
257
+ await client.connect();
258
+ await client.mailboxCreate(name);
259
+ await client.logout();
260
+ return { created: name };
261
+ }
262
+
263
+ async function getEmailContent(uid, mailbox = 'INBOX') {
264
+ const client = createClient();
265
+ await client.connect();
266
+ await client.mailboxOpen(mailbox);
267
+ const meta = await client.fetchOne(uid, { envelope: true, flags: true }, { uid: true });
268
+ let body = '(body unavailable)';
269
+ try {
270
+ const sourceMsg = await Promise.race([
271
+ client.fetchOne(uid, { source: true }, { uid: true }),
272
+ new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 10000))
273
+ ]);
274
+ if (sourceMsg?.source) {
275
+ const raw = sourceMsg.source.toString();
276
+ const bodyStart = raw.indexOf('\r\n\r\n');
277
+ body = bodyStart > -1 ? raw.slice(bodyStart + 4, bodyStart + 2000) : raw.slice(0, 2000);
278
+ }
279
+ } catch {
280
+ body = '(body unavailable - email may be too large)';
281
+ }
282
+ await client.logout();
283
+ return {
284
+ uid: meta.uid,
285
+ subject: meta.envelope.subject,
286
+ from: meta.envelope.from?.[0]?.address,
287
+ date: meta.envelope.date,
288
+ flags: [...meta.flags],
289
+ body
290
+ };
291
+ }
292
+
293
+ async function flagEmail(uid, flagged, mailbox = 'INBOX') {
294
+ const client = createClient();
295
+ await client.connect();
296
+ await client.mailboxOpen(mailbox);
297
+ if (flagged) {
298
+ await client.messageFlagsAdd(uid, ['\\Flagged'], { uid: true });
299
+ } else {
300
+ await client.messageFlagsRemove(uid, ['\\Flagged'], { uid: true });
301
+ }
302
+ await client.logout();
303
+ return true;
304
+ }
305
+
306
+ async function markAsRead(uid, seen, mailbox = 'INBOX') {
307
+ const client = createClient();
308
+ await client.connect();
309
+ await client.mailboxOpen(mailbox);
310
+ if (seen) {
311
+ await client.messageFlagsAdd(uid, ['\\Seen'], { uid: true });
312
+ } else {
313
+ await client.messageFlagsRemove(uid, ['\\Seen'], { uid: true });
314
+ }
315
+ await client.logout();
316
+ return true;
317
+ }
318
+
319
+ async function deleteEmail(uid, mailbox = 'INBOX') {
320
+ const client = createClient();
321
+ await client.connect();
322
+ await client.mailboxOpen(mailbox);
323
+ await client.messageDelete(uid, { uid: true });
324
+ await client.logout();
325
+ return true;
326
+ }
327
+
328
+ async function listMailboxes() {
329
+ const client = createClient();
330
+ await client.connect();
331
+ const tree = await client.listTree();
332
+ const mailboxes = [];
333
+ function walk(items) {
334
+ for (const item of items) {
335
+ mailboxes.push({ name: item.name, path: item.path });
336
+ if (item.folders && item.folders.length > 0) walk(item.folders);
337
+ }
338
+ }
339
+ walk(tree.folders);
340
+ await client.logout();
341
+ return mailboxes;
342
+ }
343
+
344
+ async function searchEmails(query, mailbox = 'INBOX', limit = 10) {
345
+ const client = createClient();
346
+ await client.connect();
347
+ await client.mailboxOpen(mailbox);
348
+ const uids = (await client.search(
349
+ { or: [{ subject: query }, { from: query }, { body: query }] },
350
+ { uid: true }
351
+ )) ?? [];
352
+ const emails = [];
353
+ const recentUids = uids.slice(-limit).reverse();
354
+ for (const uid of recentUids) {
355
+ const msg = await client.fetchOne(uid, { envelope: true, flags: true }, { uid: true });
356
+ if (msg) {
357
+ emails.push({
358
+ uid,
359
+ subject: msg.envelope.subject,
360
+ from: msg.envelope.from?.[0]?.address,
361
+ date: msg.envelope.date,
362
+ flagged: msg.flags.has('\\Flagged'),
363
+ seen: msg.flags.has('\\Seen')
364
+ });
365
+ }
366
+ }
367
+ await client.logout();
368
+ return emails;
369
+ }
370
+
371
+ async function moveEmail(uid, targetMailbox, sourceMailbox = 'INBOX') {
372
+ const client = createClient();
373
+ await client.connect();
374
+ await client.mailboxOpen(sourceMailbox);
375
+ await client.messageMove(uid, targetMailbox, { uid: true });
376
+ await client.logout();
377
+ return true;
378
+ }
379
+
380
+ async function main() {
381
+ const server = new Server(
382
+ { name: 'icloud-mail', version: '1.0.8' },
383
+ { capabilities: { tools: {} } }
384
+ );
385
+
386
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
387
+ tools: [
388
+ {
389
+ name: 'get_inbox_summary',
390
+ description: 'Get a summary of a mailbox including total, unread, and recent email counts',
391
+ inputSchema: { type: 'object', properties: { mailbox: { type: 'string', description: 'Mailbox name (default INBOX)' } } }
392
+ },
393
+ {
394
+ name: 'get_top_senders',
395
+ description: 'Get the top senders by email count from a sample of the inbox',
396
+ inputSchema: {
397
+ type: 'object',
398
+ properties: {
399
+ mailbox: { type: 'string', description: 'Mailbox to analyze (default INBOX)' },
400
+ sampleSize: { type: 'number', description: 'Number of emails to sample (default 500)' }
401
+ }
402
+ }
403
+ },
404
+ {
405
+ name: 'get_unread_senders',
406
+ description: 'Get top senders of unread emails',
407
+ inputSchema: {
408
+ type: 'object',
409
+ properties: {
410
+ mailbox: { type: 'string', description: 'Mailbox to analyze (default INBOX)' },
411
+ sampleSize: { type: 'number', description: 'Number of emails to sample (default 500)' }
412
+ }
413
+ }
414
+ },
415
+ {
416
+ name: 'get_emails_by_sender',
417
+ description: 'Get all emails from a specific sender',
418
+ inputSchema: {
419
+ type: 'object',
420
+ properties: {
421
+ sender: { type: 'string', description: 'Sender email address or domain' },
422
+ mailbox: { type: 'string', description: 'Mailbox to search (default INBOX)' },
423
+ limit: { type: 'number', description: 'Max results to show (default 10)' }
424
+ },
425
+ required: ['sender']
426
+ }
427
+ },
428
+ {
429
+ name: 'read_inbox',
430
+ description: 'Read emails from iCloud inbox with pagination',
431
+ inputSchema: {
432
+ type: 'object',
433
+ properties: {
434
+ limit: { type: 'number', description: 'Number of emails per page (default 10)' },
435
+ page: { type: 'number', description: 'Page number (default 1)' },
436
+ onlyUnread: { type: 'boolean', description: 'Only fetch unread emails' },
437
+ mailbox: { type: 'string', description: 'Mailbox to read (default INBOX)' }
438
+ }
439
+ }
440
+ },
441
+ {
442
+ name: 'get_email',
443
+ description: 'Get full content of a specific email by UID',
444
+ inputSchema: {
445
+ type: 'object',
446
+ properties: {
447
+ uid: { type: 'number', description: 'Email UID' },
448
+ mailbox: { type: 'string', description: 'Mailbox name (default INBOX)' }
449
+ },
450
+ required: ['uid']
451
+ }
452
+ },
453
+ {
454
+ name: 'search_emails',
455
+ description: 'Search emails by keyword',
456
+ inputSchema: {
457
+ type: 'object',
458
+ properties: {
459
+ query: { type: 'string', description: 'Search query' },
460
+ mailbox: { type: 'string', description: 'Mailbox to search (default INBOX)' },
461
+ limit: { type: 'number', description: 'Max results (default 10)' }
462
+ },
463
+ required: ['query']
464
+ }
465
+ },
466
+ {
467
+ name: 'bulk_delete_by_sender',
468
+ description: 'Delete all emails from a specific sender',
469
+ inputSchema: {
470
+ type: 'object',
471
+ properties: {
472
+ sender: { type: 'string', description: 'Sender email address' },
473
+ mailbox: { type: 'string', description: 'Mailbox (default INBOX)' }
474
+ },
475
+ required: ['sender']
476
+ }
477
+ },
478
+ {
479
+ name: 'bulk_move_by_sender',
480
+ description: 'Move all emails from a specific sender to a folder',
481
+ inputSchema: {
482
+ type: 'object',
483
+ properties: {
484
+ sender: { type: 'string', description: 'Sender email address' },
485
+ targetMailbox: { type: 'string', description: 'Destination folder' },
486
+ sourceMailbox: { type: 'string', description: 'Source mailbox (default INBOX)' }
487
+ },
488
+ required: ['sender', 'targetMailbox']
489
+ }
490
+ },
491
+ {
492
+ name: 'bulk_delete_by_subject',
493
+ description: 'Delete all emails matching a subject pattern',
494
+ inputSchema: {
495
+ type: 'object',
496
+ properties: {
497
+ subject: { type: 'string', description: 'Subject keyword to match' },
498
+ mailbox: { type: 'string', description: 'Mailbox (default INBOX)' }
499
+ },
500
+ required: ['subject']
501
+ }
502
+ },
503
+ {
504
+ name: 'delete_older_than',
505
+ description: 'Delete all emails older than a certain number of days',
506
+ inputSchema: {
507
+ type: 'object',
508
+ properties: {
509
+ days: { type: 'number', description: 'Delete emails older than this many days' },
510
+ mailbox: { type: 'string', description: 'Mailbox (default INBOX)' }
511
+ },
512
+ required: ['days']
513
+ }
514
+ },
515
+ {
516
+ name: 'get_emails_by_date_range',
517
+ description: 'Get emails between two dates',
518
+ inputSchema: {
519
+ type: 'object',
520
+ properties: {
521
+ startDate: { type: 'string', description: 'Start date (YYYY-MM-DD)' },
522
+ endDate: { type: 'string', description: 'End date (YYYY-MM-DD)' },
523
+ mailbox: { type: 'string', description: 'Mailbox (default INBOX)' },
524
+ limit: { type: 'number', description: 'Max results (default 10)' }
525
+ },
526
+ required: ['startDate', 'endDate']
527
+ }
528
+ },
529
+ {
530
+ name: 'bulk_mark_read',
531
+ description: 'Mark all emails as read, optionally filtered by sender',
532
+ inputSchema: {
533
+ type: 'object',
534
+ properties: {
535
+ mailbox: { type: 'string', description: 'Mailbox (default INBOX)' },
536
+ sender: { type: 'string', description: 'Optional: only mark emails from this sender as read' }
537
+ }
538
+ }
539
+ },
540
+ {
541
+ name: 'empty_trash',
542
+ description: 'Permanently delete all emails in Deleted Messages',
543
+ inputSchema: { type: 'object', properties: {} }
544
+ },
545
+ {
546
+ name: 'create_mailbox',
547
+ description: 'Create a new mailbox/folder',
548
+ inputSchema: {
549
+ type: 'object',
550
+ properties: {
551
+ name: { type: 'string', description: 'Name of the new mailbox' }
552
+ },
553
+ required: ['name']
554
+ }
555
+ },
556
+ {
557
+ name: 'flag_email',
558
+ description: 'Flag or unflag an email',
559
+ inputSchema: {
560
+ type: 'object',
561
+ properties: {
562
+ uid: { type: 'number', description: 'Email UID' },
563
+ flagged: { type: 'boolean', description: 'True to flag, false to unflag' },
564
+ mailbox: { type: 'string', description: 'Mailbox name (default INBOX)' }
565
+ },
566
+ required: ['uid', 'flagged']
567
+ }
568
+ },
569
+ {
570
+ name: 'mark_as_read',
571
+ description: 'Mark an email as read or unread',
572
+ inputSchema: {
573
+ type: 'object',
574
+ properties: {
575
+ uid: { type: 'number', description: 'Email UID' },
576
+ seen: { type: 'boolean', description: 'True to mark as read, false for unread' },
577
+ mailbox: { type: 'string', description: 'Mailbox name (default INBOX)' }
578
+ },
579
+ required: ['uid', 'seen']
580
+ }
581
+ },
582
+ {
583
+ name: 'delete_email',
584
+ description: 'Delete an email',
585
+ inputSchema: {
586
+ type: 'object',
587
+ properties: {
588
+ uid: { type: 'number', description: 'Email UID' },
589
+ mailbox: { type: 'string', description: 'Mailbox name (default INBOX)' }
590
+ },
591
+ required: ['uid']
592
+ }
593
+ },
594
+ {
595
+ name: 'move_email',
596
+ description: 'Move an email to a different mailbox/folder',
597
+ inputSchema: {
598
+ type: 'object',
599
+ properties: {
600
+ uid: { type: 'number', description: 'Email UID' },
601
+ targetMailbox: { type: 'string', description: 'Destination mailbox path' },
602
+ sourceMailbox: { type: 'string', description: 'Source mailbox (default INBOX)' }
603
+ },
604
+ required: ['uid', 'targetMailbox']
605
+ }
606
+ },
607
+ {
608
+ name: 'list_mailboxes',
609
+ description: 'List all mailboxes/folders in iCloud Mail',
610
+ inputSchema: { type: 'object', properties: {} }
611
+ }
612
+ ]
613
+ }));
614
+
615
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
616
+ const { name, arguments: args } = request.params;
617
+ try {
618
+ let result;
619
+ if (name === 'get_inbox_summary') {
620
+ result = await getInboxSummary(args.mailbox || 'INBOX');
621
+ } else if (name === 'get_top_senders') {
622
+ result = await getTopSenders(args.mailbox || 'INBOX', args.sampleSize || 500);
623
+ } else if (name === 'get_unread_senders') {
624
+ result = await getUnreadSenders(args.mailbox || 'INBOX', args.sampleSize || 500);
625
+ } else if (name === 'get_emails_by_sender') {
626
+ result = await getEmailsBySender(args.sender, args.mailbox || 'INBOX', args.limit || 10);
627
+ } else if (name === 'read_inbox') {
628
+ result = await fetchEmails(args.mailbox || 'INBOX', args.limit || 10, args.onlyUnread || false, args.page || 1);
629
+ } else if (name === 'get_email') {
630
+ result = await getEmailContent(args.uid, args.mailbox || 'INBOX');
631
+ } else if (name === 'search_emails') {
632
+ result = await searchEmails(args.query, args.mailbox || 'INBOX', args.limit || 10);
633
+ } else if (name === 'bulk_delete_by_sender') {
634
+ result = await bulkDeleteBySender(args.sender, args.mailbox || 'INBOX');
635
+ } else if (name === 'bulk_move_by_sender') {
636
+ result = await bulkMoveBySender(args.sender, args.targetMailbox, args.sourceMailbox || 'INBOX');
637
+ } else if (name === 'bulk_delete_by_subject') {
638
+ result = await bulkDeleteBySubject(args.subject, args.mailbox || 'INBOX');
639
+ } else if (name === 'delete_older_than') {
640
+ result = await deleteOlderThan(args.days, args.mailbox || 'INBOX');
641
+ } else if (name === 'get_emails_by_date_range') {
642
+ result = await getEmailsByDateRange(args.startDate, args.endDate, args.mailbox || 'INBOX', args.limit || 10);
643
+ } else if (name === 'bulk_mark_read') {
644
+ result = await bulkMarkRead(args.mailbox || 'INBOX', args.sender || null);
645
+ } else if (name === 'empty_trash') {
646
+ result = await emptyTrash();
647
+ } else if (name === 'create_mailbox') {
648
+ result = await createMailbox(args.name);
649
+ } else if (name === 'flag_email') {
650
+ result = await flagEmail(args.uid, args.flagged, args.mailbox || 'INBOX');
651
+ } else if (name === 'mark_as_read') {
652
+ result = await markAsRead(args.uid, args.seen, args.mailbox || 'INBOX');
653
+ } else if (name === 'delete_email') {
654
+ result = await deleteEmail(args.uid, args.mailbox || 'INBOX');
655
+ } else if (name === 'move_email') {
656
+ result = await moveEmail(args.uid, args.targetMailbox, args.sourceMailbox || 'INBOX');
657
+ } else if (name === 'list_mailboxes') {
658
+ result = await listMailboxes();
659
+ } else {
660
+ throw new Error(`Unknown tool: ${name}`);
661
+ }
662
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
663
+ } catch (error) {
664
+ return { content: [{ type: 'text', text: `Error: ${error.message}` }], isError: true };
665
+ }
666
+ });
667
+
668
+ const transport = new StdioServerTransport();
669
+ await server.connect(transport);
670
+ process.stderr.write('iCloud Mail MCP Server running\n');
671
+ }
672
+
673
+ process.on('uncaughtException', (err) => {
674
+ process.stderr.write(`Uncaught exception: ${err.message}\n${err.stack}\n`);
675
+ process.exit(1);
676
+ });
677
+
678
+ process.on('unhandledRejection', (reason) => {
679
+ process.stderr.write(`Unhandled rejection: ${reason}\n`);
680
+ process.exit(1);
681
+ });
682
+
683
+ main().catch((err) => {
684
+ process.stderr.write(`Fatal error: ${err.message}\n${err.stack}\n`);
685
+ process.exit(1);
686
+ });
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "icloud-mcp",
3
+ "version": "1.0.0",
4
+ "description": "A Model Context Protocol (MCP) server for iCloud Mail",
5
+ "main": "index.js",
6
+ "bin": {
7
+ "icloud-mcp": "./index.js"
8
+ },
9
+ "type": "module",
10
+ "scripts": {
11
+ "start": "node index.js",
12
+ "test": "node test.js"
13
+ },
14
+ "keywords": ["mcp", "icloud", "email", "imap", "claude"],
15
+ "author": "Adam Zaidi",
16
+ "license": "MIT",
17
+ "dependencies": {
18
+ "@modelcontextprotocol/sdk": "^1.27.1",
19
+ "imapflow": "^1.2.10"
20
+ }
21
+ }
package/test.js ADDED
@@ -0,0 +1,211 @@
1
+ import { spawnSync } from 'child_process';
2
+ import { writeFileSync, unlinkSync } from 'fs';
3
+ import { tmpdir } from 'os';
4
+ import { join } from 'path';
5
+ import { fileURLToPath } from 'url';
6
+
7
+ const IMAP_USER = process.env.IMAP_USER;
8
+ const IMAP_PASSWORD = process.env.IMAP_PASSWORD;
9
+
10
+ if (!IMAP_USER || !IMAP_PASSWORD) {
11
+ console.error('Error: IMAP_USER and IMAP_PASSWORD environment variables are required');
12
+ process.exit(1);
13
+ }
14
+
15
+ const projectDir = fileURLToPath(new URL('.', import.meta.url));
16
+
17
+ function callTool(name, args = {}) {
18
+ const messages = [
19
+ { jsonrpc: '2.0', id: 0, method: 'initialize', params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'test', version: '1.0.0' } } },
20
+ { jsonrpc: '2.0', method: 'notifications/initialized' },
21
+ { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args } }
22
+ ];
23
+
24
+ const input = messages.map(m => JSON.stringify(m)).join('\n') + '\n';
25
+ const tmpFile = join(tmpdir(), `mcp-test-${Date.now()}.txt`);
26
+ writeFileSync(tmpFile, input);
27
+
28
+ try {
29
+ const result = spawnSync(
30
+ '/bin/sh',
31
+ ['-c', `cat "${tmpFile}" | /opt/homebrew/bin/node index.js`],
32
+ {
33
+ cwd: projectDir,
34
+ encoding: 'utf8',
35
+ timeout: 180000,
36
+ env: { ...process.env, IMAP_USER, IMAP_PASSWORD }
37
+ }
38
+ );
39
+
40
+ if (result.error) throw new Error(`Spawn error: ${result.error.message}`);
41
+ if (result.status !== 0) throw new Error(`Process exited with code ${result.status}: ${result.stderr}`);
42
+
43
+ const lines = (result.stdout || '').trim().split('\n').filter(l => l.trim().startsWith('{'));
44
+ const responses = lines.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
45
+ const toolResponse = responses.find(r => r.id === 1);
46
+ if (!toolResponse) throw new Error(`No response for tool: ${name}`);
47
+ const content = toolResponse.result?.content?.[0]?.text;
48
+ if (!content) throw new Error(`No content in response for: ${name}`);
49
+ if (toolResponse.result?.isError) throw new Error(`Tool error: ${content}`);
50
+ return JSON.parse(content);
51
+ } finally {
52
+ try { unlinkSync(tmpFile); } catch {}
53
+ }
54
+ }
55
+
56
+ let passed = 0;
57
+ let failed = 0;
58
+
59
+ function test(name, fn) {
60
+ process.stdout.write(` Testing ${name}... `);
61
+ try {
62
+ fn();
63
+ console.log('โœ… passed');
64
+ passed++;
65
+ } catch (err) {
66
+ console.log(`โŒ failed: ${err.message}`);
67
+ failed++;
68
+ }
69
+ }
70
+
71
+ function assert(condition, message) {
72
+ if (!condition) throw new Error(message);
73
+ }
74
+
75
+ console.log('\n๐Ÿงช iCloud MCP Server Tests\n');
76
+
77
+ console.log('๐Ÿ“ฌ Mailbox & Summary');
78
+
79
+ test('get_inbox_summary', () => {
80
+ const result = callTool('get_inbox_summary');
81
+ assert(typeof result.total === 'number', 'total should be a number');
82
+ assert(typeof result.unread === 'number', 'unread should be a number');
83
+ assert(result.mailbox === 'INBOX', 'mailbox should be INBOX');
84
+ console.log(`\n โ†’ ${result.total} total, ${result.unread} unread`);
85
+ });
86
+
87
+ test('list_mailboxes', () => {
88
+ const result = callTool('list_mailboxes');
89
+ assert(Array.isArray(result), 'result should be an array');
90
+ assert(result.length > 0, 'should have at least one mailbox');
91
+ assert(result.some(m => m.path === 'INBOX'), 'INBOX should exist');
92
+ console.log(`\n โ†’ ${result.length} mailboxes found`);
93
+ });
94
+
95
+ test('get_top_senders (sample 50)', () => {
96
+ const result = callTool('get_top_senders', { sampleSize: 50 });
97
+ assert(Array.isArray(result.topAddresses), 'topAddresses should be an array');
98
+ assert(Array.isArray(result.topDomains), 'topDomains should be an array');
99
+ assert(result.sampledEmails <= 50, 'should not exceed sample size');
100
+ console.log(`\n โ†’ top sender: ${result.topAddresses[0]?.address} (${result.topAddresses[0]?.count})`);
101
+ });
102
+
103
+ test('get_unread_senders (sample 50)', () => {
104
+ const result = callTool('get_unread_senders', { sampleSize: 50 });
105
+ assert(Array.isArray(result), 'result should be an array');
106
+ console.log(`\n โ†’ ${result.length} unread senders found`);
107
+ });
108
+
109
+ console.log('\n๐Ÿ“ง Reading Emails');
110
+
111
+ test('read_inbox (page 1, limit 5)', () => {
112
+ const result = callTool('read_inbox', { limit: 5, page: 1 });
113
+ assert(Array.isArray(result.emails), 'emails should be an array');
114
+ assert(result.emails.length <= 5, 'should not exceed limit');
115
+ assert(typeof result.total === 'number', 'total should be a number');
116
+ assert(typeof result.hasMore === 'boolean', 'hasMore should be a boolean');
117
+ console.log(`\n โ†’ ${result.emails.length} emails, ${result.total} total`);
118
+ });
119
+
120
+ test('read_inbox (page 2)', () => {
121
+ const p1 = callTool('read_inbox', { limit: 5, page: 1 });
122
+ const p2 = callTool('read_inbox', { limit: 5, page: 2 });
123
+ assert(Array.isArray(p2.emails), 'page 2 emails should be an array');
124
+ if (p1.emails.length > 0 && p2.emails.length > 0) {
125
+ assert(p1.emails[0].uid !== p2.emails[0].uid, 'pages should have different emails');
126
+ }
127
+ console.log(`\n โ†’ page 2 has ${p2.emails.length} emails`);
128
+ });
129
+
130
+ test('read_inbox (unread only)', () => {
131
+ const result = callTool('read_inbox', { limit: 5, onlyUnread: true });
132
+ assert(Array.isArray(result.emails), 'emails should be an array');
133
+ result.emails.forEach(e => assert(!e.seen, 'all emails should be unread'));
134
+ console.log(`\n โ†’ ${result.emails.length} unread emails`);
135
+ });
136
+
137
+ test('search_emails', () => {
138
+ const result = callTool('search_emails', { query: 'test', limit: 5 });
139
+ assert(Array.isArray(result), 'result should be an array');
140
+ console.log(`\n โ†’ ${result.length} results`);
141
+ });
142
+
143
+ test('get_emails_by_sender', () => {
144
+ const senders = callTool('get_top_senders', { sampleSize: 20 });
145
+ const topSender = senders.topAddresses[0]?.address;
146
+ assert(topSender, 'should have at least one sender');
147
+ const result = callTool('get_emails_by_sender', { sender: topSender, limit: 5 });
148
+ assert(typeof result.total === 'number', 'total should be a number');
149
+ assert(Array.isArray(result.emails), 'emails should be an array');
150
+ console.log(`\n โ†’ ${result.total} emails from ${topSender}`);
151
+ });
152
+
153
+ test('get_emails_by_date_range', () => {
154
+ const result = callTool('get_emails_by_date_range', {
155
+ startDate: '2025-01-01',
156
+ endDate: '2025-12-31',
157
+ limit: 5
158
+ });
159
+ assert(typeof result.total === 'number', 'total should be a number');
160
+ assert(Array.isArray(result.emails), 'emails should be an array');
161
+ console.log(`\n โ†’ ${result.total} emails in 2025`);
162
+ });
163
+
164
+ test('get_email (fetch first email content)', () => {
165
+ const inbox = callTool('read_inbox', { limit: 1 });
166
+ assert(inbox.emails.length > 0, 'inbox should have at least one email');
167
+ const uid = inbox.emails[0].uid;
168
+ const result = callTool('get_email', { uid });
169
+ assert(result.uid === uid, 'uid should match');
170
+ assert(typeof result.body === 'string', 'body should be a string');
171
+ console.log(`\n โ†’ fetched email: "${result.subject?.slice(0, 40)}..."`);
172
+ });
173
+
174
+ console.log('\nโœ๏ธ Write Operations (flag/mark only โ€” no deletions)');
175
+
176
+ test('flag_email and unflag_email', () => {
177
+ const inbox = callTool('read_inbox', { limit: 1 });
178
+ assert(inbox.emails.length > 0, 'inbox should have at least one email');
179
+ const uid = inbox.emails[0].uid;
180
+ const flagResult = callTool('flag_email', { uid, flagged: true });
181
+ assert(flagResult === true, 'flag should return true');
182
+ const unflagResult = callTool('flag_email', { uid, flagged: false });
183
+ assert(unflagResult === true, 'unflag should return true');
184
+ console.log(`\n โ†’ flagged and unflagged uid ${uid}`);
185
+ });
186
+
187
+ test('mark_as_read and mark_as_unread', () => {
188
+ const inbox = callTool('read_inbox', { limit: 1 });
189
+ assert(inbox.emails.length > 0, 'inbox should have at least one email');
190
+ const uid = inbox.emails[0].uid;
191
+ const readResult = callTool('mark_as_read', { uid, seen: true });
192
+ assert(readResult === true, 'mark read should return true');
193
+ const unreadResult = callTool('mark_as_read', { uid, seen: false });
194
+ assert(unreadResult === true, 'mark unread should return true');
195
+ console.log(`\n โ†’ marked read/unread uid ${uid}`);
196
+ });
197
+
198
+ console.log('\nโš ๏ธ Destructive Tests (skipped by default)');
199
+ console.log(' Skipping: bulk_delete_by_sender');
200
+ console.log(' Skipping: bulk_delete_by_subject');
201
+ console.log(' Skipping: delete_older_than');
202
+ console.log(' Skipping: delete_email');
203
+ console.log(' Skipping: empty_trash');
204
+ console.log(' Run with --destructive flag to enable these\n');
205
+
206
+ console.log('โ”€'.repeat(40));
207
+ console.log(`\nโœ… Passed: ${passed}`);
208
+ console.log(`โŒ Failed: ${failed}`);
209
+ console.log(`๐Ÿ“Š Total: ${passed + failed}\n`);
210
+
211
+ if (failed > 0) process.exit(1);