icloud-mcp 1.9.0 โ†’ 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 +587 -42
  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
@@ -126,10 +126,21 @@ function archiveCurrent(data) {
126
126
  function getMoveStatus() {
127
127
  const data = readManifest();
128
128
  if (!data.current) return { status: 'no_operation', history: data.history.map(summarizeOp) };
129
- return {
129
+
130
+ const result = {
130
131
  current: formatOperation(data.current),
131
132
  history: data.history.map(summarizeOp)
132
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;
133
144
  }
134
145
 
135
146
  function abandonMove() {
@@ -859,6 +870,25 @@ async function bulkDeleteBySender(sender, mailbox = 'INBOX') {
859
870
  return { deleted, sender };
860
871
  }
861
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
+
862
892
  async function bulkMoveBySender(sender, targetMailbox, sourceMailbox = 'INBOX', dryRun = false) {
863
893
  const client = createRateLimitedClient();
864
894
  await client.connect();
@@ -971,20 +1001,235 @@ async function bulkFlag(filters, flagged, mailbox = 'INBOX') {
971
1001
  return { [flagged ? 'flagged' : 'unflagged']: uids.length, filters };
972
1002
  }
973
1003
 
974
- async function emptyTrash() {
1004
+ async function bulkFlagBySender(sender, flagged, mailbox = 'INBOX') {
975
1005
  const client = createRateLimitedClient();
976
1006
  await client.connect();
977
- await client.mailboxOpen('Deleted Messages');
978
- const uids = (await client.search({ all: true }, { uid: true })) ?? [];
979
- 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
+
980
1058
  let deleted = 0;
981
1059
  for (let i = 0; i < uids.length; i += CHUNK_SIZE) {
982
1060
  const chunk = uids.slice(i, i + CHUNK_SIZE);
983
1061
  await client.messageDelete(chunk, { uid: true });
984
1062
  deleted += chunk.length;
985
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
+
986
1119
  await client.logout();
987
- 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
+ };
988
1233
  }
989
1234
 
990
1235
  async function createMailbox(name) {
@@ -1096,6 +1341,24 @@ function stripHtml(html) {
1096
1341
  .trim();
1097
1342
  }
1098
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
+
1099
1362
  function findTextPart(node) {
1100
1363
  if (!node.childNodes) {
1101
1364
  if (node.type && node.type.startsWith('text/') && node.disposition !== 'attachment') {
@@ -1141,14 +1404,26 @@ function findAttachments(node, parts = []) {
1141
1404
  return parts;
1142
1405
  }
1143
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
+
1144
1417
  // โ”€โ”€โ”€ Email content fetcher (MIME-aware) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1145
1418
 
1146
- async function getEmailContent(uid, mailbox = 'INBOX') {
1419
+ async function getEmailContent(uid, mailbox = 'INBOX', maxChars = 8000, includeHeaders = false) {
1147
1420
  const client = createRateLimitedClient();
1148
1421
  await client.connect();
1149
1422
  await client.mailboxOpen(mailbox);
1150
1423
 
1151
- 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 });
1152
1427
  if (!meta) {
1153
1428
  await client.logout();
1154
1429
  return { uid, subject: null, from: null, date: null, flags: [], body: '(email not found)' };
@@ -1190,9 +1465,9 @@ async function getEmailContent(uid, mailbox = 'INBOX') {
1190
1465
 
1191
1466
  if (textPart.type === 'text/html') text = stripHtml(text);
1192
1467
 
1193
- const MAX_BODY = 8_000;
1194
- if (text.length > MAX_BODY) {
1195
- 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]`;
1196
1471
  }
1197
1472
 
1198
1473
  body = text.trim() || '(empty body)';
@@ -1217,14 +1492,37 @@ async function getEmailContent(uid, mailbox = 'INBOX') {
1217
1492
  }
1218
1493
 
1219
1494
  await client.logout();
1220
- return {
1495
+
1496
+ const attachments = meta.bodyStructure ? findAttachments(meta.bodyStructure) : [];
1497
+ const result = {
1221
1498
  uid: meta.uid,
1222
1499
  subject: meta.envelope.subject,
1223
1500
  from: meta.envelope.from?.[0]?.address,
1224
1501
  date: meta.envelope.date,
1225
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
+ },
1226
1507
  body
1227
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;
1228
1526
  }
1229
1527
 
1230
1528
  async function listAttachments(uid, mailbox = 'INBOX') {
@@ -1243,7 +1541,41 @@ async function listAttachments(uid, mailbox = 'INBOX') {
1243
1541
  };
1244
1542
  }
1245
1543
 
1246
- async function getAttachment(uid, partId, mailbox = 'INBOX') {
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) {
1247
1579
  const client = createRateLimitedClient();
1248
1580
  await client.connect();
1249
1581
  await client.mailboxOpen(mailbox);
@@ -1256,20 +1588,35 @@ async function getAttachment(uid, partId, mailbox = 'INBOX') {
1256
1588
  const att = attachments.find(a => a.partId === partId);
1257
1589
  if (!att) throw new Error(`Part ID "${partId}" not found in email UID ${uid}. Use list_attachments to see available parts.`);
1258
1590
 
1259
- if (att.size > MAX_ATTACHMENT_BYTES) {
1591
+ const isPaginated = offset !== null || length !== null;
1592
+
1593
+ if (!isPaginated && att.size > MAX_ATTACHMENT_BYTES) {
1260
1594
  await client.logout();
1261
1595
  return {
1262
- error: `Attachment too large to download (${Math.round(att.size / 1024 / 1024 * 10) / 10} MB). Maximum allowed: ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB.`,
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).`,
1263
1597
  filename: att.filename,
1264
1598
  mimeType: att.mimeType,
1265
- size: att.size
1599
+ size: att.size,
1600
+ totalSize: att.size
1266
1601
  };
1267
1602
  }
1268
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
+
1269
1614
  // Fetch the raw body part bytes
1270
1615
  const rawChunks = [];
1271
- for await (const msg of client.fetch({ uid }, { bodyParts: [partId] }, { uid: true })) {
1272
- const buf = msg.bodyParts?.get(partId);
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());
1273
1620
  if (buf) rawChunks.push(buf);
1274
1621
  }
1275
1622
  await client.logout();
@@ -1277,17 +1624,36 @@ async function getAttachment(uid, partId, mailbox = 'INBOX') {
1277
1624
  if (rawChunks.length === 0) throw new Error(`No data returned for part "${partId}" of UID ${uid}`);
1278
1625
 
1279
1626
  const raw = Buffer.concat(rawChunks);
1280
- const encoding = att.encoding.toLowerCase();
1281
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();
1282
1649
  let decoded;
1283
1650
  if (encoding === 'base64') {
1284
1651
  decoded = Buffer.from(raw.toString('ascii').replace(/\s/g, ''), 'base64');
1285
1652
  } else if (encoding === 'quoted-printable') {
1286
- // decode QP: replace soft line breaks then decode =XX sequences
1287
1653
  const qp = raw.toString('binary').replace(/=\r?\n/g, '').replace(/=([0-9A-Fa-f]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16)));
1288
1654
  decoded = Buffer.from(qp, 'binary');
1289
1655
  } else {
1290
- decoded = raw; // 7bit / 8bit / binary โ€” use as-is
1656
+ decoded = raw; // 7bit / 8bit / binary
1291
1657
  }
1292
1658
 
1293
1659
  return {
@@ -1353,18 +1719,42 @@ async function listMailboxes() {
1353
1719
  return mailboxes;
1354
1720
  }
1355
1721
 
1356
- 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;
1357
1724
  const client = createRateLimitedClient();
1358
1725
  await client.connect();
1359
1726
  await client.mailboxOpen(mailbox);
1360
1727
 
1361
- 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
+
1362
1749
  const extraQuery = buildQuery(filters);
1363
- const finalQuery = Object.keys(extraQuery).length > 0 && !extraQuery.all
1364
- ? { ...textQuery, ...extraQuery }
1365
- : 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 });
1366
1754
 
1367
1755
  let uids = (await client.search(finalQuery, { uid: true })) ?? [];
1756
+ if (!Array.isArray(uids)) uids = [];
1757
+
1368
1758
  if (filters.hasAttachment) {
1369
1759
  if (uids.length > ATTACHMENT_SCAN_LIMIT) {
1370
1760
  await client.logout();
@@ -1372,6 +1762,7 @@ async function searchEmails(query, mailbox = 'INBOX', limit = 10, filters = {})
1372
1762
  }
1373
1763
  uids = await filterUidsByAttachment(client, uids);
1374
1764
  }
1765
+
1375
1766
  const emails = [];
1376
1767
  const recentUids = uids.slice(-limit).reverse();
1377
1768
  for (const uid of recentUids) {
@@ -1387,6 +1778,31 @@ async function searchEmails(query, mailbox = 'INBOX', limit = 10, filters = {})
1387
1778
  });
1388
1779
  }
1389
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
+
1390
1806
  await client.logout();
1391
1807
  return { total: uids.length, showing: emails.length, emails };
1392
1808
  }
@@ -1522,7 +1938,7 @@ async function countEmails(filters, mailbox = 'INBOX') {
1522
1938
  if (filters.hasAttachment) {
1523
1939
  if (uids.length > ATTACHMENT_SCAN_LIMIT) {
1524
1940
  await client.logout();
1525
- 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.` };
1526
1942
  }
1527
1943
  uids = await filterUidsByAttachment(client, uids);
1528
1944
  }
@@ -1558,7 +1974,7 @@ function logClear() {
1558
1974
 
1559
1975
  async function main() {
1560
1976
  const server = new Server(
1561
- { name: 'icloud-mail', version: '1.9.0' },
1977
+ { name: 'icloud-mail', version: '2.0.0' },
1562
1978
  { capabilities: { tools: {} } }
1563
1979
  );
1564
1980
 
@@ -1648,23 +2064,29 @@ async function main() {
1648
2064
  type: 'object',
1649
2065
  properties: {
1650
2066
  uid: { type: 'number', description: 'Email UID' },
1651
- 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' }
1652
2070
  },
1653
2071
  required: ['uid']
1654
2072
  }
1655
2073
  },
1656
2074
  {
1657
2075
  name: 'search_emails',
1658
- 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',
1659
2077
  inputSchema: {
1660
2078
  type: 'object',
1661
2079
  properties: {
1662
- 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' },
1663
2085
  mailbox: { type: 'string', description: 'Mailbox to search (default INBOX)' },
1664
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)' },
1665
2088
  ...filtersSchema
1666
- },
1667
- required: ['query']
2089
+ }
1668
2090
  }
1669
2091
  },
1670
2092
  {
@@ -1892,8 +2314,13 @@ async function main() {
1892
2314
  },
1893
2315
  {
1894
2316
  name: 'empty_trash',
1895
- description: 'Permanently delete all emails in Deleted Messages',
1896
- 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
+ }
1897
2324
  },
1898
2325
  {
1899
2326
  name: 'get_move_status',
@@ -1940,16 +2367,118 @@ async function main() {
1940
2367
  },
1941
2368
  {
1942
2369
  name: 'get_attachment',
1943
- 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 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.',
1944
2371
  inputSchema: {
1945
2372
  type: 'object',
1946
2373
  properties: {
1947
2374
  uid: { type: 'number', description: 'Email UID' },
1948
2375
  partId: { type: 'string', description: 'IMAP body part ID from list_attachments (e.g. "2", "1.2")' },
1949
- mailbox: { type: 'string', description: 'Mailbox name (default INBOX)' }
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)' }
1950
2379
  },
1951
2380
  required: ['uid', 'partId']
1952
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
+ }
1953
2482
  }
1954
2483
  ]
1955
2484
  }));
@@ -1978,14 +2507,20 @@ async function main() {
1978
2507
  } else if (name === 'read_inbox') {
1979
2508
  result = await withTimeout('read_inbox', TIMEOUT.FETCH, () => fetchEmails(args.mailbox || 'INBOX', args.limit || 10, args.onlyUnread || false, args.page || 1));
1980
2509
  } else if (name === 'get_email') {
1981
- 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));
1982
2511
  } else if (name === 'list_attachments') {
1983
2512
  result = await withTimeout('list_attachments', TIMEOUT.FETCH, () => listAttachments(args.uid, args.mailbox || 'INBOX'));
1984
2513
  } else if (name === 'get_attachment') {
1985
- result = await withTimeout('get_attachment', TIMEOUT.FETCH, () => getAttachment(args.uid, args.partId, args.mailbox || 'INBOX'));
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'));
1986
2521
  } else if (name === 'search_emails') {
1987
- const { query, mailbox, limit, ...filters } = args;
1988
- 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 }));
1989
2524
  } else if (name === 'get_emails_by_sender') {
1990
2525
  result = await withTimeout('get_emails_by_sender', TIMEOUT.FETCH, () => getEmailsBySender(args.sender, args.mailbox || 'INBOX', args.limit || 10));
1991
2526
  } else if (name === 'get_emails_by_date_range') {
@@ -1995,6 +2530,8 @@ async function main() {
1995
2530
  result = await withTimeout('get_top_senders', TIMEOUT.SCAN, () => getTopSenders(args.mailbox || 'INBOX', args.sampleSize || 500, args.maxResults || 20));
1996
2531
  } else if (name === 'get_unread_senders') {
1997
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));
1998
2535
  // โ”€โ”€ Bulk operation tier (60s) โ”€โ”€
1999
2536
  } else if (name === 'bulk_delete_by_sender') {
2000
2537
  result = await withTimeout('bulk_delete_by_sender', TIMEOUT.BULK_OP, () => bulkDeleteBySender(args.sender, args.mailbox || 'INBOX'));
@@ -2007,16 +2544,24 @@ async function main() {
2007
2544
  } else if (name === 'bulk_flag') {
2008
2545
  const { flagged, mailbox, ...filters } = args;
2009
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'));
2010
2551
  } else if (name === 'delete_older_than') {
2011
2552
  result = await withTimeout('delete_older_than', TIMEOUT.BULK_OP, () => deleteOlderThan(args.days, args.mailbox || 'INBOX'));
2012
2553
  } else if (name === 'empty_trash') {
2013
- result = await withTimeout('empty_trash', TIMEOUT.BULK_OP, () => emptyTrash());
2554
+ result = await withTimeout('empty_trash', TIMEOUT.BULK_OP, () => emptyTrash(args.dryRun || false));
2014
2555
  // โ”€โ”€ No top-level timeout โ€” chunked with internal timeouts โ”€โ”€
2015
2556
  } else if (name === 'bulk_move') {
2016
2557
  const { targetMailbox, sourceMailbox, dryRun, limit, ...filters } = args;
2017
2558
  result = await bulkMove(filters, targetMailbox, sourceMailbox || 'INBOX', dryRun || false, limit ?? null);
2018
2559
  } else if (name === 'bulk_move_by_sender') {
2019
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);
2020
2565
  } else if (name === 'bulk_delete') {
2021
2566
  // IMPROVEMENT 3: bulk_delete now has per-chunk timeouts internally
2022
2567
  const { sourceMailbox, dryRun, ...filters } = args;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "icloud-mcp",
3
- "version": "1.9.0",
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": {