icloud-mcp 1.8.1 โ†’ 2.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 (3) hide show
  1. package/README.md +81 -43
  2. package/index.js +651 -30
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,19 +1,23 @@
1
1
  # icloud-mcp
2
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.
3
+ A Model Context Protocol (MCP) server that connects Claude Desktop to your iCloud Mail account. Manage, search, organize, and analyze your inbox directly through Claude.
4
4
 
5
5
  ## Features
6
6
 
7
- - ๐Ÿ“ฌ Read and paginate through your inbox
8
- - ๐Ÿ” Search emails by keyword, sender, date range, and more
7
+ - ๐Ÿ“ฌ Read and paginate through any mailbox
8
+ - ๐Ÿ” Search emails by keyword, sender, subject, body, date range, and more
9
+ - ๐Ÿงต Find email threads by References/In-Reply-To chain
9
10
  - ๐Ÿ—‘๏ธ Bulk delete emails by any combination of filters
10
- - ๐Ÿ“ Bulk move emails between folders with flexible filtering
11
- - ๐Ÿ“Š Analyze top senders to identify inbox clutter
11
+ - ๐Ÿ“ Bulk move emails between folders with safe copy-verify-delete
12
+ - ๐Ÿ“ฆ Archive emails older than N days to any folder
13
+ - ๐Ÿ“Š Analyze top senders and storage usage to identify inbox clutter
12
14
  - ๐Ÿ”ข Count emails matching any filter before taking action
13
15
  - โœ… Mark emails as read/unread, flag/unflag in bulk or individually
16
+ - ๐Ÿ“Ž List and download email attachments (supports paginated byte-range fetching for large files)
17
+ - ๐Ÿ”— Extract List-Unsubscribe links for AI-assisted cleanup
14
18
  - ๐Ÿ—‚๏ธ List, create, rename, and delete mailboxes
15
19
  - ๐Ÿ”„ Dry run mode for bulk operations โ€” preview before committing
16
- - ๐Ÿ” Safe move โ€” emails are fingerprinted and verified in the destination before being removed from the source
20
+ - ๐Ÿ” Safe move โ€” emails are fingerprinted and verified in the destination before removal from source
17
21
  - ๐Ÿ“ Session logging โ€” Claude tracks progress across long multi-step operations
18
22
 
19
23
  ## Prerequisites
@@ -118,45 +122,82 @@ When using icloud-mail tools:
118
122
 
119
123
  Fully quit Claude Desktop (Cmd+Q) and reopen it. You should now be able to manage your iCloud inbox through Claude.
120
124
 
121
- ## Available Tools
125
+ ## Available Tools (46)
126
+
127
+ ### Read & Search
122
128
 
123
129
  | Tool | Description |
124
130
  |------|-------------|
125
131
  | `get_inbox_summary` | Total, unread, and recent email counts for INBOX |
126
132
  | `get_mailbox_summary` | Total, unread, and recent email counts for any folder |
127
- | `get_top_senders` | Top senders by volume from a sample of recent emails (supports `sampleSize` and `maxResults`) |
128
- | `get_unread_senders` | Top senders of unread emails (supports `sampleSize` and `maxResults`) |
129
- | `read_inbox` | Paginated inbox with sender, subject, date |
130
- | `get_email` | Full content of a specific email by UID |
133
+ | `list_mailboxes` | List all folders in your iCloud Mail |
134
+ | `read_inbox` | Paginated inbox with sender, subject, date (supports unread filter) |
135
+ | `get_email` | Full email content by UID โ€” MIME-aware, returns body + attachments list; supports `maxChars`, `includeHeaders` |
136
+ | `get_email_raw` | Raw RFC 2822 source as base64 (headers + MIME body, 1 MB cap) |
131
137
  | `get_emails_by_sender` | All emails from a specific address |
132
138
  | `get_emails_by_date_range` | Emails between two dates |
133
- | `search_emails` | Search by keyword with optional filters (date, unread, domain, etc.) |
134
- | `count_emails` | Count emails matching any combination of filters without modifying them |
135
- | `bulk_move` | Move emails matching any combination of filters between folders (supports `dryRun` and `limit`) |
136
- | `bulk_delete` | Delete emails matching any combination of filters (supports `dryRun`) |
137
- | `bulk_flag` | Flag or unflag emails matching any combination of filters |
138
- | `bulk_mark_read` | Mark all emails (or all from a sender) as read |
139
- | `bulk_mark_unread` | Mark all emails (or all from a sender) as unread |
140
- | `bulk_delete_by_sender` | Delete all emails from a sender |
141
- | `bulk_delete_by_subject` | Delete all emails matching a subject keyword |
142
- | `bulk_move_by_sender` | Move all emails from a sender to a folder |
139
+ | `search_emails` | Search by keyword with filters; supports `subjectQuery`, `bodyQuery`, `fromQuery`, `queryMode` (and/or), `includeSnippet` |
140
+ | `get_thread` | Find all emails in the same thread (subject + References/In-Reply-To matching) |
141
+ | `count_emails` | Count emails matching any combination of filters |
142
+ | `get_top_senders` | Top senders by volume from a sample of recent emails |
143
+ | `get_unread_senders` | Top senders of unread emails |
144
+ | `get_storage_report` | Estimate storage usage by size bucket and identify top large-email senders |
145
+ | `get_unsubscribe_info` | Extract List-Unsubscribe links (email + URL) from an email |
146
+ | `list_attachments` | List all attachments in an email (filename, MIME type, size, partId) |
147
+ | `get_attachment` | Download an attachment as base64 (max 20 MB); supports `offset`/`length` for paginated byte-range fetching |
148
+
149
+ ### Write
150
+
151
+ | Tool | Description |
152
+ |------|-------------|
143
153
  | `flag_email` | Flag or unflag a single email |
144
154
  | `mark_as_read` | Mark a single email as read or unread |
145
155
  | `delete_email` | Move an email to Deleted Messages |
146
- | `move_email` | Move a single email to a folder |
156
+ | `move_email` | Move a single email to any folder |
157
+
158
+ ### Bulk Operations
159
+
160
+ | Tool | Description |
161
+ |------|-------------|
162
+ | `bulk_move` | Move emails matching any combination of filters (safe copy-verify-delete); supports `dryRun`, `limit` |
163
+ | `bulk_move_by_sender` | Move all emails from a sender to a folder; supports `dryRun` |
164
+ | `bulk_move_by_domain` | Move all emails from a domain to a folder; supports `dryRun` |
165
+ | `archive_older_than` | Safely move emails older than N days to an archive folder; supports `dryRun` |
166
+ | `bulk_delete` | Delete emails matching any combination of filters; supports `dryRun` |
167
+ | `bulk_delete_by_sender` | Delete all emails from a sender |
168
+ | `bulk_delete_by_subject` | Delete all emails matching a subject keyword |
147
169
  | `delete_older_than` | Delete all emails older than N days |
148
- | `list_mailboxes` | List all folders in your iCloud Mail |
170
+ | `bulk_mark_read` | Mark all (or all from a sender) as read |
171
+ | `bulk_mark_unread` | Mark all (or all from a sender) as unread |
172
+ | `mark_older_than_read` | Mark all unread emails older than N days as read |
173
+ | `bulk_flag` | Flag or unflag emails matching any combination of filters |
174
+ | `bulk_flag_by_sender` | Flag or unflag all emails from a specific sender |
175
+ | `empty_trash` | Permanently delete all emails in trash; supports `dryRun` |
176
+
177
+ ### Mailbox Management
178
+
179
+ | Tool | Description |
180
+ |------|-------------|
149
181
  | `create_mailbox` | Create a new folder |
150
182
  | `rename_mailbox` | Rename an existing folder |
151
183
  | `delete_mailbox` | Delete a folder (must be empty first) |
152
- | `empty_trash` | Permanently delete all emails in Deleted Messages |
153
- | `get_move_status` | Check the status of the current or most recent bulk move operation |
154
- | `abandon_move` | Abandon an in-progress move operation so a new one can start |
184
+
185
+ ### Move Tracking
186
+
187
+ | Tool | Description |
188
+ |------|-------------|
189
+ | `get_move_status` | Check the status of the current or most recent bulk move; includes stale warning for operations >24h old |
190
+ | `abandon_move` | Abandon an in-progress move so a new one can start |
191
+
192
+ ### Session Log
193
+
194
+ | Tool | Description |
195
+ |------|-------------|
155
196
  | `log_write` | Write a step to the session log |
156
- | `log_read` | Read the session log to see what has been done so far |
197
+ | `log_read` | Read the session log |
157
198
  | `log_clear` | Clear the session log and start fresh |
158
199
 
159
- ## Bulk Move, Delete & Flag Filters
200
+ ## Filters
160
201
 
161
202
  `bulk_move`, `bulk_delete`, `bulk_flag`, `search_emails`, and `count_emails` all accept any combination of these filters:
162
203
 
@@ -171,36 +212,33 @@ Fully quit Claude Desktop (Cmd+Q) and reopen it. You should now be able to manag
171
212
  | `flagged` | boolean | `true` for flagged only, `false` for unflagged only |
172
213
  | `larger` | number | Only emails larger than this size in KB |
173
214
  | `smaller` | number | Only emails smaller than this size in KB |
174
- | `hasAttachment` | boolean | Only emails with attachments |
175
-
176
- ## Dry Run Mode
177
-
178
- Pass `dryRun: true` to `bulk_move` or `bulk_delete` to preview how many emails would be affected without making any changes:
179
-
180
- > *"How many emails would be deleted if I removed everything from linkedin.com before 2022?"*
215
+ | `hasAttachment` | boolean | Only emails with attachments (requires narrow pre-filters โ€” scans up to 500 candidates) |
181
216
 
182
217
  ## Safe Move
183
218
 
184
- All bulk move operations use a copy-verify-delete approach. Emails are fingerprinted before copying, confirmed present in the destination, and only then removed from the source. A persistent manifest at `~/.icloud-mcp-move-manifest.json` tracks progress across chunks so that a crash or connection drop mid-operation never results in data loss. Use `get_move_status` to inspect any operation and `abandon_move` to clear a stuck one.
219
+ All bulk move operations (`bulk_move`, `bulk_move_by_sender`, `bulk_move_by_domain`, `archive_older_than`) use a three-phase copy-verify-delete approach:
185
220
 
186
- ## Session Log
221
+ 1. **Copy** โ€” all emails are copied to the destination in chunks
222
+ 2. **Verify** โ€” every email is fingerprinted and confirmed present in the destination
223
+ 3. **Delete** โ€” source emails are removed in a single EXPUNGE only after verification passes
187
224
 
188
- The session log persists to `~/.icloud-mcp-session.json` on your Mac โ€” outside Claude's context window โ€” so progress is never lost during long operations. Claude can write its plan at the start, log each completed step, and read the log back at any point to reorient itself.
225
+ A persistent manifest at `~/.icloud-mcp-move-manifest.json` tracks progress so a crash or dropped connection never results in data loss. Use `get_move_status` to inspect any operation and `abandon_move` to clear a stuck one.
189
226
 
190
227
  ## Example Usage
191
228
 
192
229
  Once configured, you can ask Claude things like:
193
230
 
194
231
  - *"Show me the top senders in my iCloud inbox"*
232
+ - *"What's eating the most storage in my inbox?"*
195
233
  - *"How many unread emails do I have from substack.com?"*
196
- - *"How many emails would be moved if I archived everything from linkedin.com before 2022?"*
234
+ - *"Find all emails in this thread and summarize the conversation"*
197
235
  - *"Move all emails from substack.com older than 2023 to my Newsletters folder"*
236
+ - *"Archive everything in my inbox older than 1 year"*
198
237
  - *"Delete all unread emails from linkedin.com before 2022"*
199
- - *"Move everything in my old_folders/college folder to Archive"*
200
- - *"How many emails do I have with attachments larger than 5MB?"*
238
+ - *"What's the unsubscribe link for this newsletter?"*
239
+ - *"Show me the 3 largest attachments in my inbox this month"*
201
240
  - *"Flag all unread emails from my bank"*
202
- - *"Rename my Newsletters folder to Old Newsletters"*
203
- - *"Show me emails from the last week"*
241
+ - *"How many emails would be moved if I archived everything older than 6 months?"*
204
242
 
205
243
  ## Security
206
244
 
package/index.js CHANGED
@@ -90,6 +90,7 @@ async function reconnect(client, mailbox) {
90
90
  const CHUNK_SIZE = 500;
91
91
  const CHUNK_SIZE_RETRY = 100;
92
92
  const ATTACHMENT_SCAN_LIMIT = 500; // max UIDs to scan client-side for hasAttachment filter
93
+ const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024; // 20 MB cap for get_attachment downloads
93
94
 
94
95
  function readManifest() {
95
96
  if (!existsSync(MANIFEST_FILE)) return { current: null, history: [] };
@@ -125,10 +126,21 @@ function archiveCurrent(data) {
125
126
  function getMoveStatus() {
126
127
  const data = readManifest();
127
128
  if (!data.current) return { status: 'no_operation', history: data.history.map(summarizeOp) };
128
- return {
129
+
130
+ const result = {
129
131
  current: formatOperation(data.current),
130
132
  history: data.history.map(summarizeOp)
131
133
  };
134
+
135
+ // Stale warning: in_progress but updatedAt is more than 24h ago
136
+ if (data.current.status === 'in_progress') {
137
+ const ageMs = Date.now() - new Date(data.current.updatedAt).getTime();
138
+ if (ageMs > 24 * 60 * 60 * 1000) {
139
+ result.staleWarning = `Operation ${data.current.operationId} has not been updated in ${Math.round(ageMs / 3_600_000)}h โ€” it may be stale. Call abandon_move to discard it if you want to start a new operation.`;
140
+ }
141
+ }
142
+
143
+ return result;
132
144
  }
133
145
 
134
146
  function abandonMove() {
@@ -858,6 +870,25 @@ async function bulkDeleteBySender(sender, mailbox = 'INBOX') {
858
870
  return { deleted, sender };
859
871
  }
860
872
 
873
+ async function markOlderThanRead(days, mailbox = 'INBOX') {
874
+ const client = createRateLimitedClient();
875
+ await client.connect();
876
+ await client.mailboxOpen(mailbox);
877
+ const date = new Date();
878
+ date.setDate(date.getDate() - days);
879
+ const raw = await client.search({ before: date, seen: false }, { uid: true });
880
+ const uids = Array.isArray(raw) ? raw : [];
881
+ if (uids.length === 0) { await client.logout(); return { marked: 0, olderThan: date.toISOString() }; }
882
+ await client.messageFlagsAdd(uids, ['\\Seen'], { uid: true });
883
+ await client.logout();
884
+ return { marked: uids.length, olderThan: date.toISOString() };
885
+ }
886
+
887
+ async function bulkMoveByDomain(domain, targetMailbox, sourceMailbox = 'INBOX', dryRun = false) {
888
+ const result = await bulkMove({ domain }, targetMailbox, sourceMailbox, dryRun);
889
+ return { ...result, domain };
890
+ }
891
+
861
892
  async function bulkMoveBySender(sender, targetMailbox, sourceMailbox = 'INBOX', dryRun = false) {
862
893
  const client = createRateLimitedClient();
863
894
  await client.connect();
@@ -970,20 +1001,235 @@ async function bulkFlag(filters, flagged, mailbox = 'INBOX') {
970
1001
  return { [flagged ? 'flagged' : 'unflagged']: uids.length, filters };
971
1002
  }
972
1003
 
973
- async function emptyTrash() {
1004
+ async function bulkFlagBySender(sender, flagged, mailbox = 'INBOX') {
974
1005
  const client = createRateLimitedClient();
975
1006
  await client.connect();
976
- await client.mailboxOpen('Deleted Messages');
977
- const uids = (await client.search({ all: true }, { uid: true })) ?? [];
978
- if (uids.length === 0) { await client.logout(); return { deleted: 0 }; }
1007
+ await client.mailboxOpen(mailbox);
1008
+ const raw = await client.search({ from: sender }, { uid: true });
1009
+ const uids = Array.isArray(raw) ? raw : [];
1010
+ if (uids.length === 0) { await client.logout(); return { [flagged ? 'flagged' : 'unflagged']: 0, sender }; }
1011
+ if (flagged) {
1012
+ await client.messageFlagsAdd(uids, ['\\Flagged'], { uid: true });
1013
+ } else {
1014
+ await client.messageFlagsRemove(uids, ['\\Flagged'], { uid: true });
1015
+ }
1016
+ await client.logout();
1017
+ return { [flagged ? 'flagged' : 'unflagged']: uids.length, sender };
1018
+ }
1019
+
1020
+ async function emptyTrash(dryRun = false) {
1021
+ const t0 = Date.now();
1022
+ const trashFolders = ['Deleted Messages', 'Trash'];
1023
+ const client = createRateLimitedClient();
1024
+ await client.connect();
1025
+
1026
+ let mailbox = null;
1027
+ for (const folder of trashFolders) {
1028
+ try {
1029
+ await client.mailboxOpen(folder);
1030
+ mailbox = folder;
1031
+ break;
1032
+ } catch (err) {
1033
+ if (!err.message.includes('Mailbox does not exist') && !err.message.includes('NONEXISTENT') && !err.message.includes('does not exist')) {
1034
+ await safeClose(client);
1035
+ throw err;
1036
+ }
1037
+ }
1038
+ }
1039
+
1040
+ if (!mailbox) {
1041
+ await safeClose(client);
1042
+ throw new Error('No trash folder found โ€” tried: ' + trashFolders.join(', '));
1043
+ }
1044
+
1045
+ const raw = await client.search({ all: true }, { uid: true });
1046
+ const uids = Array.isArray(raw) ? raw : [];
1047
+
1048
+ if (dryRun) {
1049
+ await safeClose(client);
1050
+ return { dryRun: true, wouldDelete: uids.length, mailbox };
1051
+ }
1052
+
1053
+ if (uids.length === 0) {
1054
+ await safeClose(client);
1055
+ return { deleted: 0, mailbox, timeTaken: ((Date.now() - t0) / 1000).toFixed(1) + 's' };
1056
+ }
1057
+
979
1058
  let deleted = 0;
980
1059
  for (let i = 0; i < uids.length; i += CHUNK_SIZE) {
981
1060
  const chunk = uids.slice(i, i + CHUNK_SIZE);
982
1061
  await client.messageDelete(chunk, { uid: true });
983
1062
  deleted += chunk.length;
984
1063
  }
1064
+ await safeClose(client);
1065
+ return { deleted, mailbox, timeTaken: ((Date.now() - t0) / 1000).toFixed(1) + 's' };
1066
+ }
1067
+
1068
+ async function archiveOlderThan(days, targetMailbox, sourceMailbox = 'INBOX', dryRun = false) {
1069
+ const client = createRateLimitedClient();
1070
+ await client.connect();
1071
+ await client.mailboxOpen(sourceMailbox);
1072
+ const date = new Date();
1073
+ date.setDate(date.getDate() - days);
1074
+ const raw = await client.search({ before: date }, { uid: true });
1075
+ const uids = Array.isArray(raw) ? raw : [];
1076
+ await client.logout();
1077
+ if (dryRun) return { dryRun: true, wouldMove: uids.length, olderThan: date.toISOString(), sourceMailbox, targetMailbox };
1078
+ if (uids.length === 0) return { moved: 0, olderThan: date.toISOString(), sourceMailbox, targetMailbox };
1079
+ await ensureMailbox(targetMailbox);
1080
+ const result = await safeMoveEmails(uids, sourceMailbox, targetMailbox);
1081
+ return { ...result, olderThan: date.toISOString(), sourceMailbox, targetMailbox };
1082
+ }
1083
+
1084
+ async function getStorageReport(mailbox = 'INBOX', sampleSize = 100) {
1085
+ const client = createRateLimitedClient();
1086
+ await client.connect();
1087
+ await client.mailboxOpen(mailbox);
1088
+
1089
+ // Count emails by size bucket using 4x SEARCH LARGER
1090
+ const thresholds = [10 * 1024, 100 * 1024, 1024 * 1024, 10 * 1024 * 1024];
1091
+ const counts = [];
1092
+ for (const thresh of thresholds) {
1093
+ const r = await client.search({ larger: thresh }, { uid: true }).catch(() => []);
1094
+ counts.push(Array.isArray(r) ? r.length : 0);
1095
+ }
1096
+
1097
+ const buckets = [
1098
+ { range: '10KBโ€“100KB', count: counts[0] - counts[1] },
1099
+ { range: '100KBโ€“1MB', count: counts[1] - counts[2] },
1100
+ { range: '1MBโ€“10MB', count: counts[2] - counts[3] },
1101
+ { range: '10MB+', count: counts[3] }
1102
+ ];
1103
+
1104
+ // Sample top senders among large emails (> 100 KB)
1105
+ const largeRaw = await client.search({ larger: 100 * 1024 }, { uid: true }).catch(() => []);
1106
+ const largeUids = Array.isArray(largeRaw) ? largeRaw : [];
1107
+ const sampleUids = largeUids.slice(-sampleSize);
1108
+
1109
+ const senderSizes = {};
1110
+ if (sampleUids.length > 0) {
1111
+ for await (const msg of client.fetch(sampleUids, { envelope: true, bodyStructure: true }, { uid: true })) {
1112
+ const address = msg.envelope?.from?.[0]?.address;
1113
+ if (address && msg.bodyStructure) {
1114
+ senderSizes[address] = (senderSizes[address] || 0) + estimateEmailSize(msg.bodyStructure);
1115
+ }
1116
+ }
1117
+ }
1118
+
985
1119
  await client.logout();
986
- return { deleted };
1120
+
1121
+ const topSendersBySize = Object.entries(senderSizes)
1122
+ .sort((a, b) => b[1] - a[1])
1123
+ .slice(0, 10)
1124
+ .map(([address, estimateBytes]) => ({ address, estimateKB: Math.round(estimateBytes / 1024) }));
1125
+
1126
+ const midpoints = [50, 512, 5120, 15360]; // rough KB midpoint for each bucket
1127
+ const estimatedTotalKB = buckets.reduce((sum, b, i) => sum + b.count * midpoints[i], 0);
1128
+
1129
+ return {
1130
+ mailbox,
1131
+ buckets,
1132
+ estimatedTotalKB,
1133
+ topSendersBySize,
1134
+ ...(sampleUids.length < largeUids.length && {
1135
+ note: `Sender analysis sampled ${sampleUids.length} of ${largeUids.length} large emails (>100 KB)`
1136
+ })
1137
+ };
1138
+ }
1139
+
1140
+ async function getThread(uid, mailbox = 'INBOX') {
1141
+ const THREAD_CANDIDATE_CAP = 100;
1142
+ const client = createRateLimitedClient();
1143
+ await client.connect();
1144
+ await client.mailboxOpen(mailbox);
1145
+
1146
+ // Fetch target email's envelope + raw headers for threading
1147
+ const meta = await client.fetchOne(uid, {
1148
+ envelope: true,
1149
+ flags: true,
1150
+ headers: new Set(['references', 'in-reply-to'])
1151
+ }, { uid: true });
1152
+ if (!meta) throw new Error(`Email UID ${uid} not found`);
1153
+
1154
+ const targetMessageId = meta.envelope?.messageId ?? null;
1155
+ const rawRefs = extractRawHeader(meta.headers, 'references');
1156
+ const rawInReplyTo = extractRawHeader(meta.headers, 'in-reply-to');
1157
+
1158
+ // Build full reference set for this email
1159
+ const threadRefs = new Set();
1160
+ if (targetMessageId) threadRefs.add(targetMessageId.trim());
1161
+ if (rawInReplyTo) threadRefs.add(rawInReplyTo.trim());
1162
+ if (rawRefs) {
1163
+ rawRefs.split(/\s+/).filter(s => s.startsWith('<') && s.endsWith('>')).forEach(r => threadRefs.add(r));
1164
+ }
1165
+
1166
+ const normalizedSubject = stripSubjectPrefixes(meta.envelope?.subject ?? '');
1167
+
1168
+ // SEARCH SUBJECT for candidates (iCloud doesn't support SEARCH HEADER)
1169
+ let candidateUids = [];
1170
+ if (normalizedSubject) {
1171
+ const raw = await client.search({ subject: normalizedSubject }, { uid: true });
1172
+ candidateUids = Array.isArray(raw) ? raw : [];
1173
+ }
1174
+
1175
+ const candidatesCapped = candidateUids.length > THREAD_CANDIDATE_CAP;
1176
+ if (candidatesCapped) candidateUids = candidateUids.slice(-THREAD_CANDIDATE_CAP);
1177
+
1178
+ // Fetch envelopes + headers for candidates to filter by References overlap
1179
+ const threadEmails = [];
1180
+ if (candidateUids.length > 0) {
1181
+ for await (const msg of client.fetch(candidateUids, {
1182
+ envelope: true,
1183
+ flags: true,
1184
+ headers: new Set(['references', 'in-reply-to'])
1185
+ }, { uid: true })) {
1186
+ const msgId = msg.envelope?.messageId ?? null;
1187
+ const msgRefs = extractRawHeader(msg.headers, 'references');
1188
+ const msgInReplyTo = extractRawHeader(msg.headers, 'in-reply-to');
1189
+
1190
+ // Build this message's reference set
1191
+ const msgRefSet = new Set();
1192
+ if (msgId) msgRefSet.add(msgId.trim());
1193
+ if (msgInReplyTo) msgRefSet.add(msgInReplyTo.trim());
1194
+ if (msgRefs) msgRefs.split(/\s+/).filter(s => s.startsWith('<')).forEach(r => msgRefSet.add(r));
1195
+
1196
+ // Include if there's any Reference chain overlap
1197
+ const hasOverlap = (msgId && threadRefs.has(msgId.trim())) ||
1198
+ [...threadRefs].some(r => msgRefSet.has(r));
1199
+
1200
+ if (hasOverlap) {
1201
+ threadEmails.push({
1202
+ uid: msg.uid,
1203
+ subject: msg.envelope?.subject,
1204
+ from: msg.envelope?.from?.[0]?.address,
1205
+ date: msg.envelope?.date,
1206
+ seen: msg.flags?.has('\\Seen') ?? false,
1207
+ flagged: msg.flags?.has('\\Flagged') ?? false,
1208
+ messageId: msgId
1209
+ });
1210
+ }
1211
+ }
1212
+ }
1213
+
1214
+ await client.logout();
1215
+
1216
+ // Sort by date ascending
1217
+ threadEmails.sort((a, b) => {
1218
+ const da = a.date ? new Date(a.date).getTime() : 0;
1219
+ const db = b.date ? new Date(b.date).getTime() : 0;
1220
+ return da - db;
1221
+ });
1222
+
1223
+ return {
1224
+ uid,
1225
+ subject: normalizedSubject || meta.envelope?.subject,
1226
+ count: threadEmails.length,
1227
+ emails: threadEmails,
1228
+ ...(candidatesCapped && {
1229
+ candidatesCapped: true,
1230
+ note: `Subject search returned more than ${THREAD_CANDIDATE_CAP} candidates โ€” thread results may be incomplete`
1231
+ })
1232
+ };
987
1233
  }
988
1234
 
989
1235
  async function createMailbox(name) {
@@ -1095,6 +1341,24 @@ function stripHtml(html) {
1095
1341
  .trim();
1096
1342
  }
1097
1343
 
1344
+ // Extract a specific header from imapflow's headers property.
1345
+ // imapflow returns headers as a raw Buffer (BODY[HEADER.FIELDS ...] response bytes),
1346
+ // so we parse it as text with MIME unfolding. Falls back to .get() if it's a Map.
1347
+ function extractRawHeader(headers, name) {
1348
+ if (!headers) return '';
1349
+ let str;
1350
+ if (Buffer.isBuffer(headers)) {
1351
+ str = headers.toString();
1352
+ } else if (typeof headers.get === 'function') {
1353
+ return (headers.get(name) ?? '').toString().trim();
1354
+ } else {
1355
+ str = headers.toString();
1356
+ }
1357
+ // Unfold MIME-folded header values (CRLF + whitespace = continuation)
1358
+ const unfolded = str.replace(/\r?\n[ \t]+/g, ' ');
1359
+ return unfolded.match(new RegExp(`^${name}:\\s*(.+)`, 'im'))?.[1]?.trim() ?? '';
1360
+ }
1361
+
1098
1362
  function findTextPart(node) {
1099
1363
  if (!node.childNodes) {
1100
1364
  if (node.type && node.type.startsWith('text/') && node.disposition !== 'attachment') {
@@ -1132,6 +1396,7 @@ function findAttachments(node, parts = []) {
1132
1396
  filename,
1133
1397
  mimeType: node.type ?? 'application/octet-stream',
1134
1398
  size: node.size ?? 0,
1399
+ encoding: node.encoding ?? '7bit',
1135
1400
  disposition: node.disposition ?? 'attachment'
1136
1401
  });
1137
1402
  }
@@ -1139,14 +1404,26 @@ function findAttachments(node, parts = []) {
1139
1404
  return parts;
1140
1405
  }
1141
1406
 
1407
+ function estimateEmailSize(node) {
1408
+ if (node.childNodes) return node.childNodes.reduce((s, c) => s + estimateEmailSize(c), 0);
1409
+ return node.size || 0;
1410
+ }
1411
+
1412
+ function stripSubjectPrefixes(subject) {
1413
+ if (!subject) return '';
1414
+ return subject.replace(/^(Re:|RE:|Fwd:|FWD:|Fw:|FW:|AW:|ๅ›žๅค:|่ฝฌๅ‘:)\s*/i, '').trim();
1415
+ }
1416
+
1142
1417
  // โ”€โ”€โ”€ Email content fetcher (MIME-aware) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1143
1418
 
1144
- async function getEmailContent(uid, mailbox = 'INBOX') {
1419
+ async function getEmailContent(uid, mailbox = 'INBOX', maxChars = 8000, includeHeaders = false) {
1145
1420
  const client = createRateLimitedClient();
1146
1421
  await client.connect();
1147
1422
  await client.mailboxOpen(mailbox);
1148
1423
 
1149
- const meta = await client.fetchOne(uid, { envelope: true, flags: true, bodyStructure: true }, { uid: true });
1424
+ const fetchOpts = { envelope: true, flags: true, bodyStructure: true };
1425
+ if (includeHeaders) fetchOpts.headers = new Set(['references', 'list-unsubscribe']);
1426
+ const meta = await client.fetchOne(uid, fetchOpts, { uid: true });
1150
1427
  if (!meta) {
1151
1428
  await client.logout();
1152
1429
  return { uid, subject: null, from: null, date: null, flags: [], body: '(email not found)' };
@@ -1188,9 +1465,9 @@ async function getEmailContent(uid, mailbox = 'INBOX') {
1188
1465
 
1189
1466
  if (textPart.type === 'text/html') text = stripHtml(text);
1190
1467
 
1191
- const MAX_BODY = 8_000;
1192
- if (text.length > MAX_BODY) {
1193
- text = text.slice(0, MAX_BODY) + `\n\n[... truncated โ€” ${text.length.toLocaleString()} chars total]`;
1468
+ const clampedMaxChars = Math.min(maxChars, 50_000);
1469
+ if (text.length > clampedMaxChars) {
1470
+ text = text.slice(0, clampedMaxChars) + `\n\n[... truncated โ€” ${text.length.toLocaleString()} chars total]`;
1194
1471
  }
1195
1472
 
1196
1473
  body = text.trim() || '(empty body)';
@@ -1215,14 +1492,37 @@ async function getEmailContent(uid, mailbox = 'INBOX') {
1215
1492
  }
1216
1493
 
1217
1494
  await client.logout();
1218
- return {
1495
+
1496
+ const attachments = meta.bodyStructure ? findAttachments(meta.bodyStructure) : [];
1497
+ const result = {
1219
1498
  uid: meta.uid,
1220
1499
  subject: meta.envelope.subject,
1221
1500
  from: meta.envelope.from?.[0]?.address,
1222
1501
  date: meta.envelope.date,
1223
1502
  flags: [...meta.flags],
1503
+ attachments: {
1504
+ count: attachments.length,
1505
+ items: attachments.map(a => ({ partId: a.partId, filename: a.filename, mimeType: a.mimeType, size: a.size }))
1506
+ },
1224
1507
  body
1225
1508
  };
1509
+
1510
+ if (includeHeaders) {
1511
+ // imapflow returns headers as a raw Buffer โ€” parse it as text
1512
+ const rawRefs = extractRawHeader(meta.headers, 'references');
1513
+ const rawUnsub = extractRawHeader(meta.headers, 'list-unsubscribe');
1514
+ result.headers = {
1515
+ to: meta.envelope.to?.map(a => a.address) ?? [],
1516
+ cc: meta.envelope.cc?.map(a => a.address) ?? [],
1517
+ replyTo: meta.envelope.replyTo?.[0]?.address ?? null,
1518
+ messageId: meta.envelope.messageId ?? null,
1519
+ inReplyTo: meta.envelope.inReplyTo ?? null,
1520
+ references: rawRefs ? rawRefs.split(/\s+/).filter(s => s.startsWith('<')) : [],
1521
+ listUnsubscribe: rawUnsub || null
1522
+ };
1523
+ }
1524
+
1525
+ return result;
1226
1526
  }
1227
1527
 
1228
1528
  async function listAttachments(uid, mailbox = 'INBOX') {
@@ -1241,6 +1541,133 @@ async function listAttachments(uid, mailbox = 'INBOX') {
1241
1541
  };
1242
1542
  }
1243
1543
 
1544
+ async function getUnsubscribeInfo(uid, mailbox = 'INBOX') {
1545
+ const client = createRateLimitedClient();
1546
+ await client.connect();
1547
+ await client.mailboxOpen(mailbox);
1548
+ const meta = await client.fetchOne(uid, { headers: new Set(['list-unsubscribe', 'list-unsubscribe-post']) }, { uid: true });
1549
+ await client.logout();
1550
+ if (!meta) return { uid, email: null, url: null, raw: null };
1551
+ const raw = extractRawHeader(meta.headers, 'list-unsubscribe') || null;
1552
+ if (!raw) return { uid, email: null, url: null, raw: null };
1553
+ const email = raw.match(/<mailto:([^>]+)>/i)?.[1] ?? null;
1554
+ const url = raw.match(/<(https?:[^>]+)>/i)?.[1] ?? null;
1555
+ return { uid, email, url, raw };
1556
+ }
1557
+
1558
+ async function getEmailRaw(uid, mailbox = 'INBOX') {
1559
+ const MAX_RAW_BYTES = 1 * 1024 * 1024; // 1 MB cap
1560
+ const client = createRateLimitedClient();
1561
+ await client.connect();
1562
+ await client.mailboxOpen(mailbox);
1563
+ const msg = await client.fetchOne(uid, { source: true }, { uid: true });
1564
+ await client.logout();
1565
+ if (!msg || !msg.source) throw new Error(`Email UID ${uid} not found`);
1566
+ const source = msg.source;
1567
+ const truncated = source.length > MAX_RAW_BYTES;
1568
+ const slice = truncated ? source.slice(0, MAX_RAW_BYTES) : source;
1569
+ return {
1570
+ uid,
1571
+ size: source.length,
1572
+ truncated,
1573
+ data: slice.toString('base64'),
1574
+ dataEncoding: 'base64'
1575
+ };
1576
+ }
1577
+
1578
+ async function getAttachment(uid, partId, mailbox = 'INBOX', offset = null, length = null) {
1579
+ const client = createRateLimitedClient();
1580
+ await client.connect();
1581
+ await client.mailboxOpen(mailbox);
1582
+
1583
+ // First fetch bodyStructure to find the attachment and validate size
1584
+ const meta = await client.fetchOne(uid, { bodyStructure: true }, { uid: true });
1585
+ if (!meta) throw new Error(`Email UID ${uid} not found`);
1586
+
1587
+ const attachments = meta.bodyStructure ? findAttachments(meta.bodyStructure) : [];
1588
+ const att = attachments.find(a => a.partId === partId);
1589
+ if (!att) throw new Error(`Part ID "${partId}" not found in email UID ${uid}. Use list_attachments to see available parts.`);
1590
+
1591
+ const isPaginated = offset !== null || length !== null;
1592
+
1593
+ if (!isPaginated && att.size > MAX_ATTACHMENT_BYTES) {
1594
+ await client.logout();
1595
+ return {
1596
+ error: `Attachment too large to download in one request (${Math.round(att.size / 1024 / 1024 * 10) / 10} MB). Use offset and length params to download in chunks (max ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB per request).`,
1597
+ filename: att.filename,
1598
+ mimeType: att.mimeType,
1599
+ size: att.size,
1600
+ totalSize: att.size
1601
+ };
1602
+ }
1603
+
1604
+ // Build fetch spec
1605
+ let fetchSpec;
1606
+ if (isPaginated) {
1607
+ const start = offset ?? 0;
1608
+ const maxLength = length ?? MAX_ATTACHMENT_BYTES;
1609
+ fetchSpec = [{ key: partId, start, maxLength }];
1610
+ } else {
1611
+ fetchSpec = [partId];
1612
+ }
1613
+
1614
+ // Fetch the raw body part bytes
1615
+ const rawChunks = [];
1616
+ for await (const msg of client.fetch({ uid }, { bodyParts: fetchSpec }, { uid: true })) {
1617
+ const buf = msg.bodyParts?.get(partId)
1618
+ ?? msg.bodyParts?.get(partId.toUpperCase())
1619
+ ?? msg.bodyParts?.get(partId.toLowerCase());
1620
+ if (buf) rawChunks.push(buf);
1621
+ }
1622
+ await client.logout();
1623
+
1624
+ if (rawChunks.length === 0) throw new Error(`No data returned for part "${partId}" of UID ${uid}`);
1625
+
1626
+ const raw = Buffer.concat(rawChunks);
1627
+
1628
+ if (isPaginated) {
1629
+ // Paginated: return raw encoded bytes without transfer-encoding decode
1630
+ const fetchOffset = offset ?? 0;
1631
+ const actualLength = raw.length;
1632
+ const hasMore = att.size ? (fetchOffset + actualLength < att.size) : false;
1633
+ return {
1634
+ uid, partId,
1635
+ filename: att.filename,
1636
+ mimeType: att.mimeType,
1637
+ encoding: att.encoding,
1638
+ totalSize: att.size,
1639
+ offset: fetchOffset,
1640
+ length: actualLength,
1641
+ hasMore,
1642
+ data: raw.toString('base64'),
1643
+ dataEncoding: 'base64'
1644
+ };
1645
+ }
1646
+
1647
+ // Full download: decode transfer encoding
1648
+ const encoding = att.encoding.toLowerCase();
1649
+ let decoded;
1650
+ if (encoding === 'base64') {
1651
+ decoded = Buffer.from(raw.toString('ascii').replace(/\s/g, ''), 'base64');
1652
+ } else if (encoding === 'quoted-printable') {
1653
+ const qp = raw.toString('binary').replace(/=\r?\n/g, '').replace(/=([0-9A-Fa-f]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16)));
1654
+ decoded = Buffer.from(qp, 'binary');
1655
+ } else {
1656
+ decoded = raw; // 7bit / 8bit / binary
1657
+ }
1658
+
1659
+ return {
1660
+ uid,
1661
+ partId,
1662
+ filename: att.filename,
1663
+ mimeType: att.mimeType,
1664
+ size: decoded.length,
1665
+ encoding: att.encoding,
1666
+ data: decoded.toString('base64'),
1667
+ dataEncoding: 'base64'
1668
+ };
1669
+ }
1670
+
1244
1671
  async function flagEmail(uid, flagged, mailbox = 'INBOX') {
1245
1672
  const client = createRateLimitedClient();
1246
1673
  await client.connect();
@@ -1292,18 +1719,42 @@ async function listMailboxes() {
1292
1719
  return mailboxes;
1293
1720
  }
1294
1721
 
1295
- async function searchEmails(query, mailbox = 'INBOX', limit = 10, filters = {}) {
1722
+ async function searchEmails(query, mailbox = 'INBOX', limit = 10, filters = {}, options = {}) {
1723
+ const { queryMode = 'or', subjectQuery, bodyQuery, fromQuery, includeSnippet = false } = options;
1296
1724
  const client = createRateLimitedClient();
1297
1725
  await client.connect();
1298
1726
  await client.mailboxOpen(mailbox);
1299
1727
 
1300
- const textQuery = { or: [{ subject: query }, { from: query }, { body: query }] };
1728
+ // Build text query
1729
+ let textQuery;
1730
+ const targetedParts = [];
1731
+ if (subjectQuery) targetedParts.push({ subject: subjectQuery });
1732
+ if (bodyQuery) targetedParts.push({ body: bodyQuery });
1733
+ if (fromQuery) targetedParts.push({ from: fromQuery });
1734
+
1735
+ if (targetedParts.length > 0) {
1736
+ // Targeted field queries
1737
+ if (queryMode === 'and') {
1738
+ textQuery = Object.assign({}, ...targetedParts); // IMAP AND is implicit
1739
+ } else {
1740
+ textQuery = targetedParts.length === 1 ? targetedParts[0] : { or: targetedParts };
1741
+ }
1742
+ } else if (query) {
1743
+ // Original OR across subject/from/body
1744
+ textQuery = { or: [{ subject: query }, { from: query }, { body: query }] };
1745
+ } else {
1746
+ textQuery = null;
1747
+ }
1748
+
1301
1749
  const extraQuery = buildQuery(filters);
1302
- const finalQuery = Object.keys(extraQuery).length > 0 && !extraQuery.all
1303
- ? { ...textQuery, ...extraQuery }
1304
- : textQuery;
1750
+ const hasExtra = Object.keys(extraQuery).length > 0 && !extraQuery.all;
1751
+ const finalQuery = textQuery
1752
+ ? (hasExtra ? { ...textQuery, ...extraQuery } : textQuery)
1753
+ : (hasExtra ? extraQuery : { all: true });
1305
1754
 
1306
1755
  let uids = (await client.search(finalQuery, { uid: true })) ?? [];
1756
+ if (!Array.isArray(uids)) uids = [];
1757
+
1307
1758
  if (filters.hasAttachment) {
1308
1759
  if (uids.length > ATTACHMENT_SCAN_LIMIT) {
1309
1760
  await client.logout();
@@ -1311,6 +1762,7 @@ async function searchEmails(query, mailbox = 'INBOX', limit = 10, filters = {})
1311
1762
  }
1312
1763
  uids = await filterUidsByAttachment(client, uids);
1313
1764
  }
1765
+
1314
1766
  const emails = [];
1315
1767
  const recentUids = uids.slice(-limit).reverse();
1316
1768
  for (const uid of recentUids) {
@@ -1326,6 +1778,31 @@ async function searchEmails(query, mailbox = 'INBOX', limit = 10, filters = {})
1326
1778
  });
1327
1779
  }
1328
1780
  }
1781
+
1782
+ // Fetch body snippets if requested (max 10 emails to avoid timeout)
1783
+ if (includeSnippet && emails.length > 0) {
1784
+ for (const email of emails.slice(0, 10)) {
1785
+ try {
1786
+ const meta = await client.fetchOne(email.uid, { bodyStructure: true }, { uid: true });
1787
+ if (!meta?.bodyStructure) continue;
1788
+ const textPart = findTextPart(meta.bodyStructure);
1789
+ if (!textPart) continue;
1790
+ const imapKey = textPart.partId ?? 'TEXT';
1791
+ const partMsg = await client.fetchOne(email.uid, {
1792
+ bodyParts: [{ key: imapKey, start: 0, maxLength: 400 }]
1793
+ }, { uid: true });
1794
+ const buf = partMsg?.bodyParts?.get(imapKey)
1795
+ ?? partMsg?.bodyParts?.get(imapKey.toUpperCase())
1796
+ ?? partMsg?.bodyParts?.get(imapKey.toLowerCase());
1797
+ if (!buf) continue;
1798
+ const decoded = decodeTransferEncoding(buf, textPart.encoding);
1799
+ let text = await decodeCharset(decoded, textPart.charset);
1800
+ if (textPart.type === 'text/html') text = stripHtml(text);
1801
+ email.snippet = text.replace(/\s+/g, ' ').slice(0, 200).trim();
1802
+ } catch { /* skip snippet on error */ }
1803
+ }
1804
+ }
1805
+
1329
1806
  await client.logout();
1330
1807
  return { total: uids.length, showing: emails.length, emails };
1331
1808
  }
@@ -1461,7 +1938,7 @@ async function countEmails(filters, mailbox = 'INBOX') {
1461
1938
  if (filters.hasAttachment) {
1462
1939
  if (uids.length > ATTACHMENT_SCAN_LIMIT) {
1463
1940
  await client.logout();
1464
- return { count: null, mailbox, filters, error: `hasAttachment requires narrower filters first โ€” ${uids.length} candidates exceeds scan limit of ${ATTACHMENT_SCAN_LIMIT}. Add from/since/before/subject filters to reduce the set.` };
1941
+ return { count: null, candidateCount: uids.length, mailbox, filters, error: `hasAttachment requires narrower filters first โ€” ${uids.length} candidates exceeds scan limit of ${ATTACHMENT_SCAN_LIMIT}. Add from/since/before/subject filters to reduce the set.` };
1465
1942
  }
1466
1943
  uids = await filterUidsByAttachment(client, uids);
1467
1944
  }
@@ -1497,7 +1974,7 @@ function logClear() {
1497
1974
 
1498
1975
  async function main() {
1499
1976
  const server = new Server(
1500
- { name: 'icloud-mail', version: '1.8.1' },
1977
+ { name: 'icloud-mail', version: '2.0.0' },
1501
1978
  { capabilities: { tools: {} } }
1502
1979
  );
1503
1980
 
@@ -1587,23 +2064,29 @@ async function main() {
1587
2064
  type: 'object',
1588
2065
  properties: {
1589
2066
  uid: { type: 'number', description: 'Email UID' },
1590
- mailbox: { type: 'string', description: 'Mailbox name (default INBOX)' }
2067
+ mailbox: { type: 'string', description: 'Mailbox name (default INBOX)' },
2068
+ maxChars: { type: 'number', description: 'Max body characters to return (default 8000, max 50000)' },
2069
+ includeHeaders: { type: 'boolean', description: 'If true, include a headers object with to/cc/replyTo/messageId/inReplyTo/references/listUnsubscribe' }
1591
2070
  },
1592
2071
  required: ['uid']
1593
2072
  }
1594
2073
  },
1595
2074
  {
1596
2075
  name: 'search_emails',
1597
- description: 'Search emails by keyword, with optional filters for date, read status, domain, and more',
2076
+ description: 'Search emails by keyword or targeted field queries, with optional filters for date, read status, domain, and more',
1598
2077
  inputSchema: {
1599
2078
  type: 'object',
1600
2079
  properties: {
1601
- query: { type: 'string', description: 'Search keyword (matches subject, sender, body)' },
2080
+ query: { type: 'string', description: 'Search keyword (matches subject, sender, body โ€” use OR across all fields)' },
2081
+ subjectQuery: { type: 'string', description: 'Match only in subject field' },
2082
+ bodyQuery: { type: 'string', description: 'Match only in body field' },
2083
+ fromQuery: { type: 'string', description: 'Match only in from/sender field' },
2084
+ queryMode: { type: 'string', enum: ['or', 'and'], description: 'How to combine subjectQuery/bodyQuery/fromQuery: or (default) or and' },
1602
2085
  mailbox: { type: 'string', description: 'Mailbox to search (default INBOX)' },
1603
2086
  limit: { type: 'number', description: 'Max results (default 10)' },
2087
+ includeSnippet: { type: 'boolean', description: 'If true, include a 200-char body preview snippet for each result (max 10 emails)' },
1604
2088
  ...filtersSchema
1605
- },
1606
- required: ['query']
2089
+ }
1607
2090
  }
1608
2091
  },
1609
2092
  {
@@ -1831,8 +2314,13 @@ async function main() {
1831
2314
  },
1832
2315
  {
1833
2316
  name: 'empty_trash',
1834
- description: 'Permanently delete all emails in Deleted Messages',
1835
- inputSchema: { type: 'object', properties: {} }
2317
+ description: 'Permanently delete all emails in the trash (Deleted Messages or Trash folder). Use dryRun: true to preview first.',
2318
+ inputSchema: {
2319
+ type: 'object',
2320
+ properties: {
2321
+ dryRun: { type: 'boolean', description: 'If true, preview how many emails would be deleted without deleting' }
2322
+ }
2323
+ }
1836
2324
  },
1837
2325
  {
1838
2326
  name: 'get_move_status',
@@ -1876,6 +2364,121 @@ async function main() {
1876
2364
  },
1877
2365
  required: ['uid']
1878
2366
  }
2367
+ },
2368
+ {
2369
+ name: 'get_attachment',
2370
+ description: 'Download a specific attachment from an email. Returns the file content as base64-encoded data. Use list_attachments first to get the partId. Maximum 20 MB per request; use offset+length for larger files.',
2371
+ inputSchema: {
2372
+ type: 'object',
2373
+ properties: {
2374
+ uid: { type: 'number', description: 'Email UID' },
2375
+ partId: { type: 'string', description: 'IMAP body part ID from list_attachments (e.g. "2", "1.2")' },
2376
+ mailbox: { type: 'string', description: 'Mailbox name (default INBOX)' },
2377
+ offset: { type: 'number', description: 'Byte offset for paginated download (returns raw encoded bytes, not decoded)' },
2378
+ length: { type: 'number', description: 'Max bytes to return for paginated download (default 20 MB)' }
2379
+ },
2380
+ required: ['uid', 'partId']
2381
+ }
2382
+ },
2383
+ {
2384
+ name: 'get_unsubscribe_info',
2385
+ description: 'Get the List-Unsubscribe header from an email, parsed into email and URL components. Useful for AI-assisted inbox cleanup.',
2386
+ inputSchema: {
2387
+ type: 'object',
2388
+ properties: {
2389
+ uid: { type: 'number', description: 'Email UID' },
2390
+ mailbox: { type: 'string', description: 'Mailbox name (default INBOX)' }
2391
+ },
2392
+ required: ['uid']
2393
+ }
2394
+ },
2395
+ {
2396
+ name: 'mark_older_than_read',
2397
+ description: 'Mark all unread emails older than N days as read. Useful for bulk triage of a cluttered inbox.',
2398
+ inputSchema: {
2399
+ type: 'object',
2400
+ properties: {
2401
+ days: { type: 'number', description: 'Mark emails older than this many days as read' },
2402
+ mailbox: { type: 'string', description: 'Mailbox (default INBOX)' }
2403
+ },
2404
+ required: ['days']
2405
+ }
2406
+ },
2407
+ {
2408
+ name: 'bulk_move_by_domain',
2409
+ description: 'Move all emails from a specific domain to a folder. Convenience wrapper around bulk_move with a domain filter.',
2410
+ inputSchema: {
2411
+ type: 'object',
2412
+ properties: {
2413
+ domain: { type: 'string', description: 'Sender domain to match (e.g. github.com, substack.com)' },
2414
+ targetMailbox: { type: 'string', description: 'Destination folder' },
2415
+ sourceMailbox: { type: 'string', description: 'Source mailbox (default INBOX)' },
2416
+ dryRun: { type: 'boolean', description: 'Preview only โ€” return count without moving' }
2417
+ },
2418
+ required: ['domain', 'targetMailbox']
2419
+ }
2420
+ },
2421
+ {
2422
+ name: 'get_email_raw',
2423
+ description: 'Get the raw RFC 2822 source of an email (full headers + MIME body) as base64-encoded data. Useful for debugging or export. Capped at 1 MB.',
2424
+ inputSchema: {
2425
+ type: 'object',
2426
+ properties: {
2427
+ uid: { type: 'number', description: 'Email UID' },
2428
+ mailbox: { type: 'string', description: 'Mailbox name (default INBOX)' }
2429
+ },
2430
+ required: ['uid']
2431
+ }
2432
+ },
2433
+ {
2434
+ name: 'bulk_flag_by_sender',
2435
+ description: 'Flag or unflag all emails from a specific sender',
2436
+ inputSchema: {
2437
+ type: 'object',
2438
+ properties: {
2439
+ sender: { type: 'string', description: 'Sender email address' },
2440
+ flagged: { type: 'boolean', description: 'True to flag, false to unflag' },
2441
+ mailbox: { type: 'string', description: 'Mailbox (default INBOX)' }
2442
+ },
2443
+ required: ['sender', 'flagged']
2444
+ }
2445
+ },
2446
+ {
2447
+ name: 'archive_older_than',
2448
+ description: 'Safely move emails older than N days from a source mailbox to an archive folder. Uses the same safe copy-verify-delete pipeline as bulk_move. Use dryRun: true to preview.',
2449
+ inputSchema: {
2450
+ type: 'object',
2451
+ properties: {
2452
+ days: { type: 'number', description: 'Archive emails older than this many days' },
2453
+ targetMailbox: { type: 'string', description: 'Destination archive folder (e.g. Archive)' },
2454
+ sourceMailbox: { type: 'string', description: 'Source mailbox (default INBOX)' },
2455
+ dryRun: { type: 'boolean', description: 'If true, preview what would be moved without moving' }
2456
+ },
2457
+ required: ['days', 'targetMailbox']
2458
+ }
2459
+ },
2460
+ {
2461
+ name: 'get_storage_report',
2462
+ description: 'Estimate storage usage by size bucket and identify top senders by email size. Uses SEARCH LARGER queries for bucketing and samples large emails for sender analysis.',
2463
+ inputSchema: {
2464
+ type: 'object',
2465
+ properties: {
2466
+ mailbox: { type: 'string', description: 'Mailbox to analyze (default INBOX)' },
2467
+ sampleSize: { type: 'number', description: 'Max number of large emails to sample for sender analysis (default 100)' }
2468
+ }
2469
+ }
2470
+ },
2471
+ {
2472
+ name: 'get_thread',
2473
+ description: 'Find all emails in the same thread as a given email. Uses subject matching + References/In-Reply-To header filtering. Note: iCloud does not support server-side threading โ€” results are approximate.',
2474
+ inputSchema: {
2475
+ type: 'object',
2476
+ properties: {
2477
+ uid: { type: 'number', description: 'Email UID to find the thread for' },
2478
+ mailbox: { type: 'string', description: 'Mailbox to search (default INBOX)' }
2479
+ },
2480
+ required: ['uid']
2481
+ }
1879
2482
  }
1880
2483
  ]
1881
2484
  }));
@@ -1904,12 +2507,20 @@ async function main() {
1904
2507
  } else if (name === 'read_inbox') {
1905
2508
  result = await withTimeout('read_inbox', TIMEOUT.FETCH, () => fetchEmails(args.mailbox || 'INBOX', args.limit || 10, args.onlyUnread || false, args.page || 1));
1906
2509
  } else if (name === 'get_email') {
1907
- result = await withTimeout('get_email', TIMEOUT.FETCH, () => getEmailContent(args.uid, args.mailbox || 'INBOX'));
2510
+ result = await withTimeout('get_email', TIMEOUT.FETCH, () => getEmailContent(args.uid, args.mailbox || 'INBOX', args.maxChars || 8000, args.includeHeaders || false));
1908
2511
  } else if (name === 'list_attachments') {
1909
2512
  result = await withTimeout('list_attachments', TIMEOUT.FETCH, () => listAttachments(args.uid, args.mailbox || 'INBOX'));
2513
+ } else if (name === 'get_attachment') {
2514
+ result = await withTimeout('get_attachment', TIMEOUT.FETCH, () => getAttachment(args.uid, args.partId, args.mailbox || 'INBOX', args.offset ?? null, args.length ?? null));
2515
+ } else if (name === 'get_unsubscribe_info') {
2516
+ result = await withTimeout('get_unsubscribe_info', TIMEOUT.FETCH, () => getUnsubscribeInfo(args.uid, args.mailbox || 'INBOX'));
2517
+ } else if (name === 'get_email_raw') {
2518
+ result = await withTimeout('get_email_raw', TIMEOUT.FETCH, () => getEmailRaw(args.uid, args.mailbox || 'INBOX'));
2519
+ } else if (name === 'get_thread') {
2520
+ result = await withTimeout('get_thread', TIMEOUT.FETCH, () => getThread(args.uid, args.mailbox || 'INBOX'));
1910
2521
  } else if (name === 'search_emails') {
1911
- const { query, mailbox, limit, ...filters } = args;
1912
- result = await withTimeout('search_emails', TIMEOUT.FETCH, () => searchEmails(query, mailbox || 'INBOX', limit || 10, filters));
2522
+ const { query, mailbox, limit, queryMode, subjectQuery, bodyQuery, fromQuery, includeSnippet, ...filters } = args;
2523
+ result = await withTimeout('search_emails', TIMEOUT.FETCH, () => searchEmails(query, mailbox || 'INBOX', limit || 10, filters, { queryMode, subjectQuery, bodyQuery, fromQuery, includeSnippet }));
1913
2524
  } else if (name === 'get_emails_by_sender') {
1914
2525
  result = await withTimeout('get_emails_by_sender', TIMEOUT.FETCH, () => getEmailsBySender(args.sender, args.mailbox || 'INBOX', args.limit || 10));
1915
2526
  } else if (name === 'get_emails_by_date_range') {
@@ -1919,6 +2530,8 @@ async function main() {
1919
2530
  result = await withTimeout('get_top_senders', TIMEOUT.SCAN, () => getTopSenders(args.mailbox || 'INBOX', args.sampleSize || 500, args.maxResults || 20));
1920
2531
  } else if (name === 'get_unread_senders') {
1921
2532
  result = await withTimeout('get_unread_senders', TIMEOUT.SCAN, () => getUnreadSenders(args.mailbox || 'INBOX', args.sampleSize || 500, args.maxResults || 20));
2533
+ } else if (name === 'get_storage_report') {
2534
+ result = await withTimeout('get_storage_report', TIMEOUT.SCAN, () => getStorageReport(args.mailbox || 'INBOX', args.sampleSize || 100));
1922
2535
  // โ”€โ”€ Bulk operation tier (60s) โ”€โ”€
1923
2536
  } else if (name === 'bulk_delete_by_sender') {
1924
2537
  result = await withTimeout('bulk_delete_by_sender', TIMEOUT.BULK_OP, () => bulkDeleteBySender(args.sender, args.mailbox || 'INBOX'));
@@ -1931,16 +2544,24 @@ async function main() {
1931
2544
  } else if (name === 'bulk_flag') {
1932
2545
  const { flagged, mailbox, ...filters } = args;
1933
2546
  result = await withTimeout('bulk_flag', TIMEOUT.BULK_OP, () => bulkFlag(filters, flagged, mailbox || 'INBOX'));
2547
+ } else if (name === 'mark_older_than_read') {
2548
+ result = await withTimeout('mark_older_than_read', TIMEOUT.BULK_OP, () => markOlderThanRead(args.days, args.mailbox || 'INBOX'));
2549
+ } else if (name === 'bulk_flag_by_sender') {
2550
+ result = await withTimeout('bulk_flag_by_sender', TIMEOUT.BULK_OP, () => bulkFlagBySender(args.sender, args.flagged, args.mailbox || 'INBOX'));
1934
2551
  } else if (name === 'delete_older_than') {
1935
2552
  result = await withTimeout('delete_older_than', TIMEOUT.BULK_OP, () => deleteOlderThan(args.days, args.mailbox || 'INBOX'));
1936
2553
  } else if (name === 'empty_trash') {
1937
- result = await withTimeout('empty_trash', TIMEOUT.BULK_OP, () => emptyTrash());
2554
+ result = await withTimeout('empty_trash', TIMEOUT.BULK_OP, () => emptyTrash(args.dryRun || false));
1938
2555
  // โ”€โ”€ No top-level timeout โ€” chunked with internal timeouts โ”€โ”€
1939
2556
  } else if (name === 'bulk_move') {
1940
2557
  const { targetMailbox, sourceMailbox, dryRun, limit, ...filters } = args;
1941
2558
  result = await bulkMove(filters, targetMailbox, sourceMailbox || 'INBOX', dryRun || false, limit ?? null);
1942
2559
  } else if (name === 'bulk_move_by_sender') {
1943
2560
  result = await bulkMoveBySender(args.sender, args.targetMailbox, args.sourceMailbox || 'INBOX', args.dryRun || false);
2561
+ } else if (name === 'bulk_move_by_domain') {
2562
+ result = await bulkMoveByDomain(args.domain, args.targetMailbox, args.sourceMailbox || 'INBOX', args.dryRun || false);
2563
+ } else if (name === 'archive_older_than') {
2564
+ result = await archiveOlderThan(args.days, args.targetMailbox, args.sourceMailbox || 'INBOX', args.dryRun || false);
1944
2565
  } else if (name === 'bulk_delete') {
1945
2566
  // IMPROVEMENT 3: bulk_delete now has per-chunk timeouts internally
1946
2567
  const { sourceMailbox, dryRun, ...filters } = args;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "icloud-mcp",
3
- "version": "1.8.1",
3
+ "version": "2.0.0",
4
4
  "description": "A Model Context Protocol (MCP) server for iCloud Mail",
5
5
  "main": "index.js",
6
6
  "bin": {