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