icloud-mcp 1.0.0 → 1.0.2

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 +14 -14
  2. package/index.js +126 -11
  3. package/package.json +9 -3
  4. package/test.js +50 -1
package/README.md CHANGED
@@ -27,14 +27,20 @@ A Model Context Protocol (MCP) server that connects Claude Desktop to your iClou
27
27
  3. Click **+** to generate a new password
28
28
  4. Label it something like `Claude MCP` and save the generated password
29
29
 
30
- ### 2. Install the server
30
+ ### 2. Install the package
31
31
 
32
32
  ```bash
33
- git clone https://github.com/YOUR_USERNAME/icloud-mcp.git
34
- cd icloud-mcp
35
- npm install
33
+ npm install -g icloud-mcp
36
34
  ```
37
35
 
36
+ Then find the install location:
37
+
38
+ ```bash
39
+ npm root -g
40
+ ```
41
+
42
+ This will return a path like `/opt/homebrew/lib/node_modules` or `/usr/local/lib/node_modules`.
43
+
38
44
  ### 3. Configure Claude Desktop
39
45
 
40
46
  Open your Claude Desktop config file:
@@ -43,14 +49,14 @@ Open your Claude Desktop config file:
43
49
  open ~/Library/Application\ Support/Claude/claude_desktop_config.json
44
50
  ```
45
51
 
46
- Add the following under `mcpServers`:
52
+ Add the following under `mcpServers`, replacing the path with your npm root path from the previous step:
47
53
 
48
54
  ```json
49
55
  {
50
56
  "mcpServers": {
51
57
  "icloud-mail": {
52
- "command": "/opt/homebrew/bin/node",
53
- "args": ["/path/to/icloud-mcp/index.js"],
58
+ "command": "node",
59
+ "args": ["/opt/homebrew/lib/node_modules/icloud-mcp/index.js"],
54
60
  "env": {
55
61
  "IMAP_USER": "you@icloud.com",
56
62
  "IMAP_PASSWORD": "your-app-specific-password"
@@ -60,7 +66,7 @@ Add the following under `mcpServers`:
60
66
  }
61
67
  ```
62
68
 
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`.
69
+ > **Note:** If your `npm root -g` returned a different path, replace `/opt/homebrew/lib/node_modules` with that path.
64
70
 
65
71
  ### 4. Restart Claude Desktop
66
72
 
@@ -101,12 +107,6 @@ Once configured, you can ask Claude things like:
101
107
  - *"Move all emails from newsletters@substack.com to my newsletters folder"*
102
108
  - *"Show me emails from the last week"*
103
109
 
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
110
  ## Security
111
111
 
112
112
  - Your credentials are stored only in your local Claude Desktop config file
package/index.js CHANGED
@@ -113,7 +113,7 @@ async function getTopSenders(mailbox = 'INBOX', sampleSize = 500) {
113
113
  return { sampledEmails: count, topAddresses, topDomains };
114
114
  }
115
115
 
116
- async function getUnreadSenders(mailbox = 'INBOX', sampleSize = 500) {
116
+ async function getUnreadSenders(mailbox = 'INBOX', sampleSize = 500, maxResults = 20) {
117
117
  const client = createClient();
118
118
  await client.connect();
119
119
  await client.mailboxOpen(mailbox);
@@ -121,16 +121,19 @@ async function getUnreadSenders(mailbox = 'INBOX', sampleSize = 500) {
121
121
  const recentUids = uids.reverse().slice(0, sampleSize);
122
122
  const senderCounts = {};
123
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
- }
124
+ if (recentUids.length === 0) {
125
+ await client.logout();
126
+ return [];
127
+ }
128
+
129
+ // Fetch all envelopes in a single batch instead of one by one
130
+ for await (const msg of client.fetch(recentUids, { envelope: true }, { uid: true })) {
131
+ const address = msg.envelope.from?.[0]?.address;
132
+ if (address) senderCounts[address] = (senderCounts[address] || 0) + 1;
130
133
  }
131
134
 
132
135
  await client.logout();
133
- return Object.entries(senderCounts).sort((a, b) => b[1] - a[1]).slice(0, 20).map(([address, count]) => ({ address, count }));
136
+ return Object.entries(senderCounts).sort((a, b) => b[1] - a[1]).slice(0, maxResults).map(([address, count]) => ({ address, count }));
134
137
  }
135
138
 
136
139
  async function getEmailsBySender(sender, mailbox = 'INBOX', limit = 10) {
@@ -377,12 +380,79 @@ async function moveEmail(uid, targetMailbox, sourceMailbox = 'INBOX') {
377
380
  return true;
378
381
  }
379
382
 
383
+ // Build an IMAP search query from a filters object
384
+ function buildQuery(filters) {
385
+ const query = {};
386
+ if (filters.sender) query.from = filters.sender;
387
+ if (filters.domain) query.from = filters.domain.replace(/^@/, '');
388
+ if (filters.subject) query.subject = filters.subject;
389
+ if (filters.before) query.before = new Date(filters.before);
390
+ if (filters.since) query.since = new Date(filters.since);
391
+ if (filters.unread === true) query.seen = false;
392
+ if (filters.unread === false) query.seen = true;
393
+ if (filters.flagged === true) query.flagged = true;
394
+ if (filters.flagged === false) query.unflagged = true;
395
+ if (filters.larger) query.larger = filters.larger * 1024;
396
+ if (filters.smaller) query.smaller = filters.smaller * 1024;
397
+ if (filters.hasAttachment) query.header = ['Content-Type', 'multipart/mixed'];
398
+ // If no filters set, match all
399
+ if (Object.keys(query).length === 0) query.all = true;
400
+ return query;
401
+ }
402
+
403
+ async function bulkMove(filters, targetMailbox, sourceMailbox = 'INBOX') {
404
+ const client = createClient();
405
+ await client.connect();
406
+ await client.mailboxOpen(sourceMailbox);
407
+ const query = buildQuery(filters);
408
+ const uids = (await client.search(query, { uid: true })) ?? [];
409
+ if (uids.length === 0) { await client.logout(); return { moved: 0, sourceMailbox, targetMailbox }; }
410
+ await client.messageMove(uids, targetMailbox, { uid: true });
411
+ await client.logout();
412
+ return { moved: uids.length, sourceMailbox, targetMailbox, filters };
413
+ }
414
+
415
+ async function bulkDelete(filters, sourceMailbox = 'INBOX') {
416
+ const client = createClient();
417
+ await client.connect();
418
+ await client.mailboxOpen(sourceMailbox);
419
+ const query = buildQuery(filters);
420
+ const uids = (await client.search(query, { uid: true })) ?? [];
421
+ if (uids.length === 0) { await client.logout(); return { deleted: 0, sourceMailbox }; }
422
+ await client.messageDelete(uids, { uid: true });
423
+ await client.logout();
424
+ return { deleted: uids.length, sourceMailbox, filters };
425
+ }
426
+
427
+ async function countEmails(filters, mailbox = 'INBOX') {
428
+ const client = createClient();
429
+ await client.connect();
430
+ await client.mailboxOpen(mailbox);
431
+ const query = buildQuery(filters);
432
+ const uids = (await client.search(query, { uid: true })) ?? [];
433
+ await client.logout();
434
+ return { count: uids.length, mailbox, filters };
435
+ }
436
+
380
437
  async function main() {
381
438
  const server = new Server(
382
- { name: 'icloud-mail', version: '1.0.8' },
439
+ { name: 'icloud-mail', version: '1.1.0' },
383
440
  { capabilities: { tools: {} } }
384
441
  );
385
442
 
443
+ const filtersSchema = {
444
+ sender: { type: 'string', description: 'Match exact sender email address' },
445
+ domain: { type: 'string', description: 'Match any sender from this domain (e.g. substack.com)' },
446
+ subject: { type: 'string', description: 'Keyword to match in subject' },
447
+ before: { type: 'string', description: 'Only emails before this date (YYYY-MM-DD)' },
448
+ since: { type: 'string', description: 'Only emails since this date (YYYY-MM-DD)' },
449
+ unread: { type: 'boolean', description: 'True for unread only, false for read only' },
450
+ flagged: { type: 'boolean', description: 'True for flagged only, false for unflagged only' },
451
+ larger: { type: 'number', description: 'Only emails larger than this size in KB' },
452
+ smaller: { type: 'number', description: 'Only emails smaller than this size in KB' },
453
+ hasAttachment: { type: 'boolean', description: 'Only emails with attachments' }
454
+ };
455
+
386
456
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
387
457
  tools: [
388
458
  {
@@ -408,7 +478,8 @@ async function main() {
408
478
  type: 'object',
409
479
  properties: {
410
480
  mailbox: { type: 'string', description: 'Mailbox to analyze (default INBOX)' },
411
- sampleSize: { type: 'number', description: 'Number of emails to sample (default 500)' }
481
+ sampleSize: { type: 'number', description: 'Number of emails to sample (default 500)' },
482
+ maxResults: { type: 'number', description: 'Max number of senders to return (default 20)' }
412
483
  }
413
484
  }
414
485
  },
@@ -608,6 +679,41 @@ async function main() {
608
679
  name: 'list_mailboxes',
609
680
  description: 'List all mailboxes/folders in iCloud Mail',
610
681
  inputSchema: { type: 'object', properties: {} }
682
+ },
683
+ {
684
+ name: 'bulk_move',
685
+ description: 'Move emails matching any combination of filters from one mailbox to another. Use this for large-scale moves — it operates on ALL matching emails in a single IMAP operation regardless of count. Prefer this over read_inbox + move_email for any bulk operation.',
686
+ inputSchema: {
687
+ type: 'object',
688
+ properties: {
689
+ targetMailbox: { type: 'string', description: 'Destination mailbox path' },
690
+ sourceMailbox: { type: 'string', description: 'Source mailbox (default INBOX)' },
691
+ ...filtersSchema
692
+ },
693
+ required: ['targetMailbox']
694
+ }
695
+ },
696
+ {
697
+ name: 'bulk_delete',
698
+ description: 'Delete emails matching any combination of filters. Use this for large-scale deletes — it operates on ALL matching emails in a single IMAP operation regardless of count. Prefer this over read_inbox + delete_email for any bulk operation.',
699
+ inputSchema: {
700
+ type: 'object',
701
+ properties: {
702
+ sourceMailbox: { type: 'string', description: 'Mailbox to delete from (default INBOX)' },
703
+ ...filtersSchema
704
+ }
705
+ }
706
+ },
707
+ {
708
+ name: 'count_emails',
709
+ description: 'Count how many emails match a set of filters without moving or deleting them. Use this before bulk_move or bulk_delete to preview how many emails will be affected.',
710
+ inputSchema: {
711
+ type: 'object',
712
+ properties: {
713
+ mailbox: { type: 'string', description: 'Mailbox to count in (default INBOX)' },
714
+ ...filtersSchema
715
+ }
716
+ }
611
717
  }
612
718
  ]
613
719
  }));
@@ -621,7 +727,7 @@ async function main() {
621
727
  } else if (name === 'get_top_senders') {
622
728
  result = await getTopSenders(args.mailbox || 'INBOX', args.sampleSize || 500);
623
729
  } else if (name === 'get_unread_senders') {
624
- result = await getUnreadSenders(args.mailbox || 'INBOX', args.sampleSize || 500);
730
+ result = await getUnreadSenders(args.mailbox || 'INBOX', args.sampleSize || 500, args.maxResults || 20);
625
731
  } else if (name === 'get_emails_by_sender') {
626
732
  result = await getEmailsBySender(args.sender, args.mailbox || 'INBOX', args.limit || 10);
627
733
  } else if (name === 'read_inbox') {
@@ -656,6 +762,15 @@ async function main() {
656
762
  result = await moveEmail(args.uid, args.targetMailbox, args.sourceMailbox || 'INBOX');
657
763
  } else if (name === 'list_mailboxes') {
658
764
  result = await listMailboxes();
765
+ } else if (name === 'bulk_move') {
766
+ const { targetMailbox, sourceMailbox, ...filters } = args;
767
+ result = await bulkMove(filters, targetMailbox, sourceMailbox || 'INBOX');
768
+ } else if (name === 'bulk_delete') {
769
+ const { sourceMailbox, ...filters } = args;
770
+ result = await bulkDelete(filters, sourceMailbox || 'INBOX');
771
+ } else if (name === 'count_emails') {
772
+ const { mailbox, ...filters } = args;
773
+ result = await countEmails(filters, mailbox || 'INBOX');
659
774
  } else {
660
775
  throw new Error(`Unknown tool: ${name}`);
661
776
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "icloud-mcp",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "A Model Context Protocol (MCP) server for iCloud Mail",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -11,11 +11,17 @@
11
11
  "start": "node index.js",
12
12
  "test": "node test.js"
13
13
  },
14
- "keywords": ["mcp", "icloud", "email", "imap", "claude"],
14
+ "keywords": [
15
+ "mcp",
16
+ "icloud",
17
+ "email",
18
+ "imap",
19
+ "claude"
20
+ ],
15
21
  "author": "Adam Zaidi",
16
22
  "license": "MIT",
17
23
  "dependencies": {
18
24
  "@modelcontextprotocol/sdk": "^1.27.1",
19
25
  "imapflow": "^1.2.10"
20
26
  }
21
- }
27
+ }
package/test.js CHANGED
@@ -100,12 +100,27 @@ test('get_top_senders (sample 50)', () => {
100
100
  console.log(`\n → top sender: ${result.topAddresses[0]?.address} (${result.topAddresses[0]?.count})`);
101
101
  });
102
102
 
103
- test('get_unread_senders (sample 50)', () => {
103
+ test('get_unread_senders (sample 50, default maxResults)', () => {
104
104
  const result = callTool('get_unread_senders', { sampleSize: 50 });
105
105
  assert(Array.isArray(result), 'result should be an array');
106
+ assert(result.length <= 20, 'should not exceed default maxResults of 20');
106
107
  console.log(`\n → ${result.length} unread senders found`);
107
108
  });
108
109
 
110
+ test('get_unread_senders (sample 50, maxResults 5)', () => {
111
+ const result = callTool('get_unread_senders', { sampleSize: 50, maxResults: 5 });
112
+ assert(Array.isArray(result), 'result should be an array');
113
+ assert(result.length <= 5, 'should not exceed maxResults of 5');
114
+ console.log(`\n → ${result.length} unread senders found (capped at 5)`);
115
+ });
116
+
117
+ test('get_unread_senders (sample 50, maxResults 50)', () => {
118
+ const result = callTool('get_unread_senders', { sampleSize: 50, maxResults: 50 });
119
+ assert(Array.isArray(result), 'result should be an array');
120
+ assert(result.length <= 50, 'should not exceed maxResults of 50');
121
+ console.log(`\n → ${result.length} unread senders found (capped at 50)`);
122
+ });
123
+
109
124
  console.log('\n📧 Reading Emails');
110
125
 
111
126
  test('read_inbox (page 1, limit 5)', () => {
@@ -171,6 +186,37 @@ test('get_email (fetch first email content)', () => {
171
186
  console.log(`\n → fetched email: "${result.subject?.slice(0, 40)}..."`);
172
187
  });
173
188
 
189
+ console.log('\n🔍 Count & Bulk Query');
190
+
191
+ test('count_emails (all in INBOX)', () => {
192
+ const result = callTool('count_emails', { mailbox: 'INBOX' });
193
+ assert(typeof result.count === 'number', 'count should be a number');
194
+ assert(result.count > 0, 'should have emails in INBOX');
195
+ console.log(`\n → ${result.count} emails match`);
196
+ });
197
+
198
+ test('count_emails (unread only)', () => {
199
+ const result = callTool('count_emails', { unread: true });
200
+ assert(typeof result.count === 'number', 'count should be a number');
201
+ console.log(`\n → ${result.count} unread emails`);
202
+ });
203
+
204
+ test('count_emails (by domain)', () => {
205
+ const senders = callTool('get_top_senders', { sampleSize: 20 });
206
+ const topDomain = senders.topDomains[0]?.domain;
207
+ assert(topDomain, 'should have at least one domain');
208
+ const result = callTool('count_emails', { domain: topDomain });
209
+ console.log('\n → raw result:', JSON.stringify(result));
210
+ assert(typeof result.count === 'number', 'count should be a number');
211
+ console.log(`\n → ${result.count} emails from @${topDomain}`);
212
+ });
213
+
214
+ test('count_emails (before date)', () => {
215
+ const result = callTool('count_emails', { before: '2020-01-01' });
216
+ assert(typeof result.count === 'number', 'count should be a number');
217
+ console.log(`\n → ${result.count} emails before 2020`);
218
+ });
219
+
174
220
  console.log('\n✏️ Write Operations (flag/mark only — no deletions)');
175
221
 
176
222
  test('flag_email and unflag_email', () => {
@@ -196,6 +242,9 @@ test('mark_as_read and mark_as_unread', () => {
196
242
  });
197
243
 
198
244
  console.log('\n⚠️ Destructive Tests (skipped by default)');
245
+ console.log(' Skipping: bulk_move (sender → target folder)');
246
+ console.log(' Skipping: bulk_move (folder → folder)');
247
+ console.log(' Skipping: bulk_delete (by domain)');
199
248
  console.log(' Skipping: bulk_delete_by_sender');
200
249
  console.log(' Skipping: bulk_delete_by_subject');
201
250
  console.log(' Skipping: delete_older_than');