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.
- package/README.md +81 -43
- package/index.js +587 -42
- 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
|
|
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
|
|
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
|
|
11
|
-
-
|
|
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
|
|
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
|
-
| `
|
|
128
|
-
| `
|
|
129
|
-
| `
|
|
130
|
-
| `
|
|
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
|
|
134
|
-
| `
|
|
135
|
-
| `
|
|
136
|
-
| `
|
|
137
|
-
| `
|
|
138
|
-
| `
|
|
139
|
-
| `
|
|
140
|
-
| `
|
|
141
|
-
| `
|
|
142
|
-
|
|
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
|
|
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
|
-
| `
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
197
|
+
| `log_read` | Read the session log |
|
|
157
198
|
| `log_clear` | Clear the session log and start fresh |
|
|
158
199
|
|
|
159
|
-
##
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
- *"
|
|
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
|
-
- *"
|
|
200
|
-
- *"
|
|
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
|
-
- *"
|
|
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
|
-
|
|
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
|
|
1004
|
+
async function bulkFlagBySender(sender, flagged, mailbox = 'INBOX') {
|
|
975
1005
|
const client = createRateLimitedClient();
|
|
976
1006
|
await client.connect();
|
|
977
|
-
await client.mailboxOpen(
|
|
978
|
-
const
|
|
979
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1194
|
-
if (text.length >
|
|
1195
|
-
text = text.slice(0,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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).
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
1364
|
-
|
|
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: '
|
|
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: {
|
|
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
|
|
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;
|