apple-mail-mcp 2.0.0 → 2.1.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 +99 -24
- package/build/index.js +141 -43
- package/build/services/imapClient.d.ts +108 -0
- package/build/services/imapClient.d.ts.map +1 -1
- package/build/services/imapClient.js +291 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -299,14 +299,17 @@ What routes to IMAP when an account is IMAP-configured:
|
|
|
299
299
|
- **Read:** `search-messages`, `list-messages` (server-side `SEARCH`, typically sub-second), and `get-message`.
|
|
300
300
|
- **Folder ops:** `create-mailbox`, `rename-mailbox`, `delete-mailbox` — IMAP's `CREATE`/`RENAME`/`DELETE` succeed on the iCloud/Gmail/Workspace/Exchange mailboxes Mail.app's AppleScript bridge can't touch (#42).
|
|
301
301
|
- **Message mutations:** `mark-as-read`/`unread`, `flag-message`/`unflag-message`, `move-message`, `delete-message`.
|
|
302
|
+
- **Batch mutations (2.1):** `batch-mark-as-read`/`unread`, `batch-flag`/`unflag-messages`, `batch-move-messages`, `batch-delete-messages` — `imap:` ids are grouped by mailbox and applied as a single `UID STORE`/`UID MOVE`; numeric ids in the same batch still use AppleScript.
|
|
303
|
+
- **Counts & stats (2.1):** `get-unread-count` and `list-mailboxes` use `STATUS`; `get-mail-stats` (with an `account`) uses `STATUS` + `SEARCH SINCE` — authoritative and fast even on huge mailboxes.
|
|
304
|
+
- **Attachments (2.1):** `list-attachments`, `save-attachment`, `fetch-attachment` use `BODYSTRUCTURE` + `FETCH BODY[part]` for `imap:` ids — faster and able to see MIME-embedded attachments AppleScript misses.
|
|
305
|
+
- **Threading (2.1):** `get-thread` links a conversation via `References`/`Message-ID` (`HEADER SEARCH`) for an `imap:` seed, falling back to subject grouping otherwise.
|
|
302
306
|
|
|
303
307
|
**Message ids are backend-tagged.** The IMAP read path emits self-describing ids
|
|
304
308
|
of the form `imap:<token>` (the token encodes the account, mailbox path, and
|
|
305
|
-
UID). Pass that id back to `get-message
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
tools for IMAP ids.)
|
|
309
|
+
UID). Pass that id back to `get-message`, a message mutation, a batch op, or the
|
|
310
|
+
attachment/thread tools and it routes to IMAP automatically; bare numeric ids
|
|
311
|
+
continue to use AppleScript. So an agent never has to know which backend a
|
|
312
|
+
message came from — the id carries it.
|
|
310
313
|
|
|
311
314
|
Routing is conservative: only a call whose explicit `account` matches the
|
|
312
315
|
configured IMAP account goes to IMAP; everything else falls through to
|
|
@@ -331,25 +334,62 @@ Each entry accepts `account`, `user`, `host`, `port`, `password`, `keychainServi
|
|
|
331
334
|
`keychainAccount`. Calls route to the account matching their `account` argument (or the
|
|
332
335
|
decoded `imap:` id), and each account keeps its own pooled connection.
|
|
333
336
|
|
|
334
|
-
**Push notifications (B5):** with `APPLE_MAIL_MCP_IMAP_IDLE=1`, the server watches each
|
|
335
|
-
account's INBOX and emits an MCP logging message + `notifications/resources/updated` for
|
|
336
|
-
`mail://mailboxes/{account}` when new mail arrives (real-time IDLE where the server
|
|
337
|
-
supports it, polling fallback otherwise).
|
|
338
|
-
|
|
339
337
|
As with SMTP, the password is read from the macOS **Keychain** by default (use
|
|
340
338
|
an app-specific password for Gmail/Workspace/iCloud), so no secret goes in
|
|
341
339
|
config. Gmail label semantics: common names (`All Mail`, `Sent`, `Trash`,
|
|
342
340
|
`Spam`, `Important`, …) map to their `[Gmail]/…` IMAP paths automatically.
|
|
343
341
|
|
|
344
|
-
> Note:
|
|
345
|
-
>
|
|
346
|
-
>
|
|
342
|
+
> Note: IMAP connections are pooled — one kept-alive connection per account is
|
|
343
|
+
> reused across calls (verified with a NOOP, closed after `APPLE_MAIL_MCP_IMAP_IDLE_MS`
|
|
344
|
+
> of inactivity), so there's no per-call connection overhead ([#50](https://github.com/sweetrb/apple-mail-mcp/issues/50)).
|
|
347
345
|
>
|
|
348
346
|
> **iCloud:** set `APPLE_MAIL_MCP_IMAP_HOST=imap.mail.me.com`, `APPLE_MAIL_MCP_IMAP_USER`
|
|
349
347
|
> to your iCloud address, `APPLE_MAIL_MCP_IMAP_ACCOUNT` to the Mail account name
|
|
350
348
|
> (e.g. `iCloud`), and use an **app-specific password** (from appleid.apple.com)
|
|
351
349
|
> stored in the Keychain.
|
|
352
350
|
|
|
351
|
+
##### Push notifications (IMAP IDLE) — opt-in
|
|
352
|
+
|
|
353
|
+
When `APPLE_MAIL_MCP_IMAP_IDLE=1`, the server opens a dedicated, long-lived
|
|
354
|
+
connection to **each configured IMAP account** and watches its INBOX for new
|
|
355
|
+
mail. On arrival it pushes two MCP notifications to the client (no polling by the
|
|
356
|
+
client required):
|
|
357
|
+
|
|
358
|
+
1. **`notifications/message`** (logging) — a human-readable line, e.g.
|
|
359
|
+
`New mail in "Work": 2 new message(s) (INBOX now 1843).`
|
|
360
|
+
2. **`notifications/resources/updated`** — for the affected account's resource
|
|
361
|
+
`mail://mailboxes/{account}`, so a client subscribed to that resource knows to
|
|
362
|
+
re-read it.
|
|
363
|
+
|
|
364
|
+
This requires an IMAP account to be configured (single-account env or
|
|
365
|
+
`APPLE_MAIL_MCP_IMAP_ACCOUNTS`); accounts that only use AppleScript aren't
|
|
366
|
+
watched. Detection is **real-time** via the IMAP IDLE `EXISTS` event where the
|
|
367
|
+
server pushes it, with an automatic **polling fallback** for servers that don't.
|
|
368
|
+
Dropped connections reconnect with backoff, and the watchers shut down cleanly on
|
|
369
|
+
`SIGINT`/`SIGTERM`.
|
|
370
|
+
|
|
371
|
+
Enable it in your MCP client config alongside the IMAP settings:
|
|
372
|
+
|
|
373
|
+
```jsonc
|
|
374
|
+
{
|
|
375
|
+
"mcpServers": {
|
|
376
|
+
"apple-mail": {
|
|
377
|
+
"command": "node",
|
|
378
|
+
"args": ["/path/to/apple-mail-mcp/build/index.js"],
|
|
379
|
+
"env": {
|
|
380
|
+
"APPLE_MAIL_MCP_IMAP_USER": "you@gmail.com",
|
|
381
|
+
"APPLE_MAIL_MCP_IMAP_KEYCHAIN_SERVICE": "imap.gmail.com",
|
|
382
|
+
"APPLE_MAIL_MCP_IMAP_IDLE": "1"
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
> Note: this is most useful with clients that surface MCP logging messages or
|
|
390
|
+
> subscribe to resource-update notifications. Clients that ignore notifications
|
|
391
|
+
> are unaffected — the feature is opt-in and adds no behavior unless enabled.
|
|
392
|
+
|
|
353
393
|
---
|
|
354
394
|
|
|
355
395
|
#### `send-serial-email`
|
|
@@ -427,16 +467,6 @@ Return an attachment's bytes as base64 (the read counterpart to inline-base64 se
|
|
|
427
467
|
|
|
428
468
|
**Returns:** The attachment bytes, base64-encoded (also in `structuredContent.contentBase64`).
|
|
429
469
|
|
|
430
|
-
#### `create-rule` / `delete-rule`
|
|
431
|
-
|
|
432
|
-
Create a Mail rule with conditions and actions, or delete a rule by name.
|
|
433
|
-
|
|
434
|
-
`create-rule` parameters: `name` (string), `conditions` (array of `{field: from|to|cc|subject|content, operator: contains|notContains|equals|beginsWith|endsWith, value}`), `actions` (`{markRead?, markFlagged?, delete?, moveTo?, moveToAccount?}`), `matchAll` (default true), `enabled` (default true). `delete-rule` parameters: `name`.
|
|
435
|
-
|
|
436
|
-
#### `doctor`
|
|
437
|
-
|
|
438
|
-
Run a full setup diagnostic: Mail.app automation permission, account state (flags disabled accounts), and each configured IMAP/SMTP backend, each reported as ok / warn / fail with an actionable message. No parameters.
|
|
439
|
-
|
|
440
470
|
---
|
|
441
471
|
|
|
442
472
|
#### `reply-to-message`
|
|
@@ -674,6 +704,41 @@ Enable or disable a mail rule.
|
|
|
674
704
|
|
|
675
705
|
---
|
|
676
706
|
|
|
707
|
+
#### `create-rule`
|
|
708
|
+
|
|
709
|
+
Create a Mail rule with one or more conditions and actions.
|
|
710
|
+
|
|
711
|
+
| Parameter | Type | Required | Description |
|
|
712
|
+
|-----------|------|----------|-------------|
|
|
713
|
+
| `name` | string | Yes | Rule name (must be unique) |
|
|
714
|
+
| `conditions` | object[] | Yes | One or more `{field, operator, value}` (see below) |
|
|
715
|
+
| `actions` | object | Yes | At least one of `markRead`, `markFlagged`, `delete`, `moveTo` |
|
|
716
|
+
| `matchAll` | boolean | No | `true` (default) = all conditions must match; `false` = any |
|
|
717
|
+
| `enabled` | boolean | No | Whether the rule is enabled on creation (default `true`) |
|
|
718
|
+
|
|
719
|
+
Each condition is `{ field, operator, value }` where `field` is one of `from`, `to`, `cc`, `subject`, `content` and `operator` is one of `contains`, `notContains`, `equals`, `beginsWith`, `endsWith`. Actions: `markRead` / `markFlagged` / `delete` (booleans), `moveTo` (mailbox name) with optional `moveToAccount`.
|
|
720
|
+
|
|
721
|
+
**Example:**
|
|
722
|
+
```json
|
|
723
|
+
{
|
|
724
|
+
"name": "Newsletters",
|
|
725
|
+
"conditions": [{ "field": "from", "operator": "contains", "value": "newsletter" }],
|
|
726
|
+
"actions": { "markRead": true, "moveTo": "Reading" }
|
|
727
|
+
}
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
---
|
|
731
|
+
|
|
732
|
+
#### `delete-rule`
|
|
733
|
+
|
|
734
|
+
Delete a mail rule by name.
|
|
735
|
+
|
|
736
|
+
| Parameter | Type | Required | Description |
|
|
737
|
+
|-----------|------|----------|-------------|
|
|
738
|
+
| `name` | string | Yes | Rule name |
|
|
739
|
+
|
|
740
|
+
---
|
|
741
|
+
|
|
677
742
|
### Contacts
|
|
678
743
|
|
|
679
744
|
#### `search-contacts`
|
|
@@ -691,7 +756,7 @@ Search contacts in Contacts.app.
|
|
|
691
756
|
|
|
692
757
|
### Templates
|
|
693
758
|
|
|
694
|
-
Email templates are
|
|
759
|
+
Email templates are **persisted to disk** so they survive server restarts, stored as JSON at `APPLE_MAIL_MCP_TEMPLATES_FILE` (default `~/Library/Application Support/apple-mail-mcp/templates.json`).
|
|
695
760
|
|
|
696
761
|
#### `save-template`
|
|
697
762
|
|
|
@@ -762,6 +827,16 @@ Verify Mail.app connectivity and permissions.
|
|
|
762
827
|
|
|
763
828
|
---
|
|
764
829
|
|
|
830
|
+
#### `doctor`
|
|
831
|
+
|
|
832
|
+
Run a full setup diagnostic: Mail.app automation permission, account state (flagging disabled accounts), and each configured IMAP/SMTP backend — each reported as ok / warn / fail with an actionable message.
|
|
833
|
+
|
|
834
|
+
**Parameters:** None
|
|
835
|
+
|
|
836
|
+
**Returns:** A per-check report (`structuredContent` carries the raw `{healthy, checks[]}`).
|
|
837
|
+
|
|
838
|
+
---
|
|
839
|
+
|
|
765
840
|
#### `get-mail-stats`
|
|
766
841
|
|
|
767
842
|
Get mail statistics.
|
package/build/index.js
CHANGED
|
@@ -23,9 +23,11 @@ import { createRequire } from "module";
|
|
|
23
23
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
24
24
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
25
25
|
import { z } from "zod";
|
|
26
|
-
import { AppleMailManager } from "./services/appleMailManager.js";
|
|
26
|
+
import { AppleMailManager, isPathWithinAllowedRoots } from "./services/appleMailManager.js";
|
|
27
|
+
import { writeFileSync } from "fs";
|
|
28
|
+
import { resolve as resolvePath, join as joinPath } from "path";
|
|
27
29
|
import { sendViaSmtp } from "./services/smtpMailer.js";
|
|
28
|
-
import { isImapAccount, resolveImapConfigs, imapSearchMessages, imapListMessages, imapCreateMailbox, imapDeleteMailbox, imapRenameMailbox, imapGetMessage, imapMarkRead, imapMarkUnread, imapFlagMessage, imapUnflagMessage, imapMoveMessageById, imapDeleteMessageById, } from "./services/imapClient.js";
|
|
30
|
+
import { isImapAccount, resolveImapConfigs, imapSearchMessages, imapListMessages, imapUnreadCount, imapListMailboxes, imapMailStats, imapListAttachments, imapFetchAttachment, imapBatchMarkRead, imapBatchMarkUnread, imapBatchFlag, imapBatchUnflag, imapBatchDelete, imapBatchMove, imapThread, imapCreateMailbox, imapDeleteMailbox, imapRenameMailbox, imapGetMessage, imapMarkRead, imapMarkUnread, imapFlagMessage, imapUnflagMessage, imapMoveMessageById, imapDeleteMessageById, } from "./services/imapClient.js";
|
|
29
31
|
import { successResponse, errorResponse, partialCoverageBlock, withErrorHandling, messageSummary, } from "./tools/respond.js";
|
|
30
32
|
import { routeMessage } from "./services/messageRouter.js";
|
|
31
33
|
import { runDoctor, formatDoctorReport } from "./tools/doctor.js";
|
|
@@ -35,9 +37,6 @@ import { ImapIdleWatcher } from "./services/imapIdle.js";
|
|
|
35
37
|
// =============================================================================
|
|
36
38
|
// Shared Validation Schemas
|
|
37
39
|
// =============================================================================
|
|
38
|
-
/** AppleScript message IDs are always numeric. Enforced to prevent AppleScript
|
|
39
|
-
* injection via the `whose id is ${id}` interpolation. */
|
|
40
|
-
const NUMERIC_MESSAGE_ID_SCHEMA = z.string().regex(/^\d+$/, "Message ID must be numeric");
|
|
41
40
|
/** A single-message id is EITHER an AppleScript numeric id OR an IMAP composite
|
|
42
41
|
* token (`imap:<base64url>`, emitted by the IMAP read path). The IMAP form is
|
|
43
42
|
* base64url so it stays injection-safe; it never reaches AppleScript (it's
|
|
@@ -45,10 +44,11 @@ const NUMERIC_MESSAGE_ID_SCHEMA = z.string().regex(/^\d+$/, "Message ID must be
|
|
|
45
44
|
const MESSAGE_ID_SCHEMA = z
|
|
46
45
|
.string()
|
|
47
46
|
.regex(/^(\d+|imap:[A-Za-z0-9_-]+)$/, "Message ID must be numeric or an IMAP id (imap:…)");
|
|
48
|
-
/** Batch operations
|
|
49
|
-
*
|
|
47
|
+
/** Batch operations accept numeric (AppleScript) and/or imap: ids (I2) and are
|
|
48
|
+
* capped to prevent unbounded loops / DoS. Numeric ids run via AppleScript;
|
|
49
|
+
* imap: ids are grouped by mailbox and applied in a single UID command. */
|
|
50
50
|
const BATCH_IDS_SCHEMA = z
|
|
51
|
-
.array(
|
|
51
|
+
.array(MESSAGE_ID_SCHEMA)
|
|
52
52
|
.min(1, "At least one message ID is required")
|
|
53
53
|
.max(100, "Cannot process more than 100 messages in a single batch");
|
|
54
54
|
/** Date filter strings must look like natural-language dates (e.g. "March 1, 2026").
|
|
@@ -99,6 +99,31 @@ const mailManager = new AppleMailManager();
|
|
|
99
99
|
registerResourcesAndPrompts(server, mailManager);
|
|
100
100
|
// Response helpers, the AppleScript serial gate, withErrorHandling, and the
|
|
101
101
|
// message backend router now live in @/tools/respond and @/services/messageRouter.
|
|
102
|
+
/**
|
|
103
|
+
* Split a batch of ids into numeric (AppleScript) and imap: (IMAP) groups, run
|
|
104
|
+
* each path, and merge into success/fail counts (I2). imap: ids apply in a
|
|
105
|
+
* single UID command per mailbox; numeric ids use the existing AppleScript batch.
|
|
106
|
+
*/
|
|
107
|
+
async function hybridBatchCounts(ids, appleFn, imapFn) {
|
|
108
|
+
const imapIds = ids.filter((i) => i.startsWith("imap:"));
|
|
109
|
+
const numericIds = ids.filter((i) => !i.startsWith("imap:"));
|
|
110
|
+
let success = 0;
|
|
111
|
+
let fail = 0;
|
|
112
|
+
const errors = [];
|
|
113
|
+
if (numericIds.length > 0) {
|
|
114
|
+
const res = appleFn(numericIds);
|
|
115
|
+
const s = res.filter((r) => r.success).length;
|
|
116
|
+
success += s;
|
|
117
|
+
fail += res.length - s;
|
|
118
|
+
}
|
|
119
|
+
if (imapIds.length > 0) {
|
|
120
|
+
const r = await imapFn(imapIds);
|
|
121
|
+
success += r.success;
|
|
122
|
+
fail += r.failed;
|
|
123
|
+
errors.push(...r.errors);
|
|
124
|
+
}
|
|
125
|
+
return { success, fail, errors };
|
|
126
|
+
}
|
|
102
127
|
// =============================================================================
|
|
103
128
|
// Message Tools
|
|
104
129
|
// =============================================================================
|
|
@@ -192,6 +217,14 @@ server.tool("get-thread", {
|
|
|
192
217
|
mailbox: z.string().optional().describe("Mailbox to search (omit to search all)"),
|
|
193
218
|
limit: z.number().optional().describe("Max messages in the thread (default 50)"),
|
|
194
219
|
}, withErrorHandling(async ({ id, account, mailbox, limit = 50 }) => {
|
|
220
|
+
// True threading via References/Message-ID when we have an imap: id (I5);
|
|
221
|
+
// falls through to subject grouping if the server lacks HEADER search or
|
|
222
|
+
// nothing References-linked is found.
|
|
223
|
+
if (id.startsWith("imap:")) {
|
|
224
|
+
const t = await imapThread(id, { account }, limit);
|
|
225
|
+
if (t && t.count > 1)
|
|
226
|
+
return successResponse(t.text, { ...t.structured });
|
|
227
|
+
}
|
|
195
228
|
// Resolve the seed message's subject, then gather the conversation by
|
|
196
229
|
// normalized subject (B1). Works across the AppleScript and IMAP backends.
|
|
197
230
|
let seedSubject = null;
|
|
@@ -467,10 +500,8 @@ server.tool("move-message", {
|
|
|
467
500
|
// --- batch-delete-messages ---
|
|
468
501
|
server.tool("batch-delete-messages", {
|
|
469
502
|
ids: BATCH_IDS_SCHEMA,
|
|
470
|
-
}, withErrorHandling(({ ids }) => {
|
|
471
|
-
const
|
|
472
|
-
const successCount = results.filter((r) => r.success).length;
|
|
473
|
-
const failCount = results.length - successCount;
|
|
503
|
+
}, withErrorHandling(async ({ ids }) => {
|
|
504
|
+
const { success: successCount, fail: failCount } = await hybridBatchCounts(ids, (n) => mailManager.batchDeleteMessages(n), (im) => imapBatchDelete(im));
|
|
474
505
|
if (failCount === 0) {
|
|
475
506
|
return successResponse(`Successfully deleted ${successCount} message(s)`);
|
|
476
507
|
}
|
|
@@ -486,10 +517,8 @@ server.tool("batch-move-messages", {
|
|
|
486
517
|
ids: BATCH_IDS_SCHEMA,
|
|
487
518
|
mailbox: z.string().min(1, "Destination mailbox is required"),
|
|
488
519
|
account: z.string().optional().describe("Account containing the destination mailbox"),
|
|
489
|
-
}, withErrorHandling(({ ids, mailbox, account }) => {
|
|
490
|
-
const
|
|
491
|
-
const successCount = results.filter((r) => r.success).length;
|
|
492
|
-
const failCount = results.length - successCount;
|
|
520
|
+
}, withErrorHandling(async ({ ids, mailbox, account }) => {
|
|
521
|
+
const { success: successCount, fail: failCount } = await hybridBatchCounts(ids, (n) => mailManager.batchMoveMessages(n, mailbox, account), (im) => imapBatchMove(im, mailbox, { account }));
|
|
493
522
|
if (failCount === 0) {
|
|
494
523
|
return successResponse(`Successfully moved ${successCount} message(s) to "${mailbox}"`);
|
|
495
524
|
}
|
|
@@ -503,10 +532,8 @@ server.tool("batch-move-messages", {
|
|
|
503
532
|
// --- batch-mark-as-read ---
|
|
504
533
|
server.tool("batch-mark-as-read", {
|
|
505
534
|
ids: BATCH_IDS_SCHEMA,
|
|
506
|
-
}, withErrorHandling(({ ids }) => {
|
|
507
|
-
const
|
|
508
|
-
const successCount = results.filter((r) => r.success).length;
|
|
509
|
-
const failCount = results.length - successCount;
|
|
535
|
+
}, withErrorHandling(async ({ ids }) => {
|
|
536
|
+
const { success: successCount, fail: failCount } = await hybridBatchCounts(ids, (n) => mailManager.batchMarkAsRead(n), (im) => imapBatchMarkRead(im));
|
|
510
537
|
if (failCount === 0) {
|
|
511
538
|
return successResponse(`Successfully marked ${successCount} message(s) as read`);
|
|
512
539
|
}
|
|
@@ -520,10 +547,8 @@ server.tool("batch-mark-as-read", {
|
|
|
520
547
|
// --- batch-mark-as-unread ---
|
|
521
548
|
server.tool("batch-mark-as-unread", {
|
|
522
549
|
ids: BATCH_IDS_SCHEMA,
|
|
523
|
-
}, withErrorHandling(({ ids }) => {
|
|
524
|
-
const
|
|
525
|
-
const successCount = results.filter((r) => r.success).length;
|
|
526
|
-
const failCount = results.length - successCount;
|
|
550
|
+
}, withErrorHandling(async ({ ids }) => {
|
|
551
|
+
const { success: successCount, fail: failCount } = await hybridBatchCounts(ids, (n) => mailManager.batchMarkAsUnread(n), (im) => imapBatchMarkUnread(im));
|
|
527
552
|
if (failCount === 0) {
|
|
528
553
|
return successResponse(`Successfully marked ${successCount} message(s) as unread`);
|
|
529
554
|
}
|
|
@@ -537,10 +562,8 @@ server.tool("batch-mark-as-unread", {
|
|
|
537
562
|
// --- batch-flag-messages ---
|
|
538
563
|
server.tool("batch-flag-messages", {
|
|
539
564
|
ids: BATCH_IDS_SCHEMA,
|
|
540
|
-
}, withErrorHandling(({ ids }) => {
|
|
541
|
-
const
|
|
542
|
-
const successCount = results.filter((r) => r.success).length;
|
|
543
|
-
const failCount = results.length - successCount;
|
|
565
|
+
}, withErrorHandling(async ({ ids }) => {
|
|
566
|
+
const { success: successCount, fail: failCount } = await hybridBatchCounts(ids, (n) => mailManager.batchFlagMessages(n), (im) => imapBatchFlag(im));
|
|
544
567
|
if (failCount === 0) {
|
|
545
568
|
return successResponse(`Successfully flagged ${successCount} message(s)`);
|
|
546
569
|
}
|
|
@@ -554,10 +577,8 @@ server.tool("batch-flag-messages", {
|
|
|
554
577
|
// --- batch-unflag-messages ---
|
|
555
578
|
server.tool("batch-unflag-messages", {
|
|
556
579
|
ids: BATCH_IDS_SCHEMA,
|
|
557
|
-
}, withErrorHandling(({ ids }) => {
|
|
558
|
-
const
|
|
559
|
-
const successCount = results.filter((r) => r.success).length;
|
|
560
|
-
const failCount = results.length - successCount;
|
|
580
|
+
}, withErrorHandling(async ({ ids }) => {
|
|
581
|
+
const { success: successCount, fail: failCount } = await hybridBatchCounts(ids, (n) => mailManager.batchUnflagMessages(n), (im) => imapBatchUnflag(im));
|
|
561
582
|
if (failCount === 0) {
|
|
562
583
|
return successResponse(`Successfully unflagged ${successCount} message(s)`);
|
|
563
584
|
}
|
|
@@ -571,8 +592,17 @@ server.tool("batch-unflag-messages", {
|
|
|
571
592
|
// --- list-attachments ---
|
|
572
593
|
server.tool("list-attachments", {
|
|
573
594
|
id: MESSAGE_ID_SCHEMA,
|
|
574
|
-
}, withErrorHandling(({ id }) => {
|
|
575
|
-
|
|
595
|
+
}, withErrorHandling(async ({ id }) => {
|
|
596
|
+
// IMAP (I1): BODYSTRUCTURE enumerates parts (incl. MIME attachments
|
|
597
|
+
// AppleScript can't see) without downloading the message.
|
|
598
|
+
const attachments = id.startsWith("imap:")
|
|
599
|
+
? await (async () => {
|
|
600
|
+
const r = await imapListAttachments(id);
|
|
601
|
+
if (!r.success)
|
|
602
|
+
throw new Error(r.error || "Failed to list attachments via IMAP");
|
|
603
|
+
return r.attachments ?? [];
|
|
604
|
+
})()
|
|
605
|
+
: mailManager.listAttachments(id);
|
|
576
606
|
const structured = { attachments, count: attachments.length };
|
|
577
607
|
if (attachments.length === 0) {
|
|
578
608
|
return successResponse("No attachments found", structured);
|
|
@@ -590,7 +620,25 @@ server.tool("save-attachment", {
|
|
|
590
620
|
id: MESSAGE_ID_SCHEMA,
|
|
591
621
|
attachmentName: z.string().min(1, "Attachment name is required"),
|
|
592
622
|
savePath: z.string().min(1, "Save directory path is required"),
|
|
593
|
-
}, withErrorHandling(({ id, attachmentName, savePath }) => {
|
|
623
|
+
}, withErrorHandling(async ({ id, attachmentName, savePath }) => {
|
|
624
|
+
// IMAP (I1): fetch the part's bytes via IMAP, then write into savePath (a
|
|
625
|
+
// directory) as savePath/attachmentName — mirroring the AppleScript path,
|
|
626
|
+
// with the same name + allowed-roots validation.
|
|
627
|
+
if (id.startsWith("imap:")) {
|
|
628
|
+
if (/[/\\\0]/.test(attachmentName) || attachmentName.includes("..")) {
|
|
629
|
+
return errorResponse(`Invalid attachment name: "${attachmentName}"`);
|
|
630
|
+
}
|
|
631
|
+
const resolvedDir = resolvePath(savePath);
|
|
632
|
+
if (!isPathWithinAllowedRoots(resolvedDir)) {
|
|
633
|
+
return errorResponse(`Save path "${savePath}" is outside allowed directories`);
|
|
634
|
+
}
|
|
635
|
+
const r = await imapFetchAttachment(id, attachmentName);
|
|
636
|
+
if (!r.success || !r.base64) {
|
|
637
|
+
return errorResponse(r.error || `Failed to fetch attachment "${attachmentName}"`);
|
|
638
|
+
}
|
|
639
|
+
writeFileSync(joinPath(resolvedDir, attachmentName), Buffer.from(r.base64, "base64"));
|
|
640
|
+
return successResponse(`Attachment "${attachmentName}" saved to ${savePath}`);
|
|
641
|
+
}
|
|
594
642
|
const success = mailManager.saveAttachment(id, attachmentName, savePath);
|
|
595
643
|
if (!success) {
|
|
596
644
|
return errorResponse(`Failed to save attachment "${attachmentName}"`);
|
|
@@ -599,12 +647,19 @@ server.tool("save-attachment", {
|
|
|
599
647
|
}, "Error saving attachment"));
|
|
600
648
|
// --- fetch-attachment ---
|
|
601
649
|
server.tool("fetch-attachment", {
|
|
602
|
-
id:
|
|
650
|
+
id: MESSAGE_ID_SCHEMA,
|
|
603
651
|
attachmentName: z.string().min(1, "Attachment name is required"),
|
|
604
|
-
}, withErrorHandling(({ id, attachmentName }) => {
|
|
652
|
+
}, withErrorHandling(async ({ id, attachmentName }) => {
|
|
605
653
|
// Returns the attachment bytes as base64 (B4) — the read counterpart to
|
|
606
|
-
// sending inline base64 content
|
|
607
|
-
//
|
|
654
|
+
// sending inline base64 content. IMAP (I1) fetches the part directly; numeric
|
|
655
|
+
// ids fall back to the AppleScript/MIME path.
|
|
656
|
+
if (id.startsWith("imap:")) {
|
|
657
|
+
const r = await imapFetchAttachment(id, attachmentName);
|
|
658
|
+
if (!r.success || !r.base64) {
|
|
659
|
+
return errorResponse(r.error || `Failed to fetch attachment "${attachmentName}"`);
|
|
660
|
+
}
|
|
661
|
+
return successResponse(`Fetched "${attachmentName}" (${r.bytes} bytes, base64-encoded below).\n\n${r.base64}`, { attachmentName, bytes: r.bytes, mimeType: r.mimeType, contentBase64: r.base64 });
|
|
662
|
+
}
|
|
608
663
|
const r = mailManager.getAttachmentBase64(id, attachmentName);
|
|
609
664
|
if (!r.success) {
|
|
610
665
|
return errorResponse(r.error || `Failed to fetch attachment "${attachmentName}"`);
|
|
@@ -617,7 +672,24 @@ server.tool("fetch-attachment", {
|
|
|
617
672
|
// --- list-mailboxes ---
|
|
618
673
|
server.tool("list-mailboxes", {
|
|
619
674
|
account: z.string().optional().describe("Account to list mailboxes from"),
|
|
620
|
-
}, withErrorHandling(({ account }) => {
|
|
675
|
+
}, withErrorHandling(async ({ account }) => {
|
|
676
|
+
// IMAP (I6): LIST + per-mailbox STATUS — sees the true server hierarchy and
|
|
677
|
+
// authoritative counts; falls back to AppleScript for non-IMAP accounts.
|
|
678
|
+
if (isImapAccount(account)) {
|
|
679
|
+
const boxes = await imapListMailboxes({ account });
|
|
680
|
+
const structured = {
|
|
681
|
+
mailboxes: boxes.map((b) => ({
|
|
682
|
+
name: b.path,
|
|
683
|
+
unreadCount: b.unseen,
|
|
684
|
+
messageCount: b.messages,
|
|
685
|
+
})),
|
|
686
|
+
count: boxes.length,
|
|
687
|
+
};
|
|
688
|
+
if (boxes.length === 0)
|
|
689
|
+
return successResponse("No mailboxes found", structured);
|
|
690
|
+
const list = boxes.map((b) => ` - ${b.path} (${b.unseen} unread)`).join("\n");
|
|
691
|
+
return successResponse(`Found ${boxes.length} mailbox(es):\n${list}`, structured);
|
|
692
|
+
}
|
|
621
693
|
const mailboxes = mailManager.listMailboxes(account);
|
|
622
694
|
const structured = { mailboxes, count: mailboxes.length };
|
|
623
695
|
if (mailboxes.length === 0) {
|
|
@@ -630,8 +702,12 @@ server.tool("list-mailboxes", {
|
|
|
630
702
|
server.tool("get-unread-count", {
|
|
631
703
|
mailbox: z.string().optional().describe("Mailbox to check (default: all)"),
|
|
632
704
|
account: z.string().optional().describe("Account to check"),
|
|
633
|
-
}, withErrorHandling(({ mailbox, account }) => {
|
|
634
|
-
|
|
705
|
+
}, withErrorHandling(async ({ mailbox, account }) => {
|
|
706
|
+
// IMAP (I4): STATUS (UNSEEN) is authoritative and fast even on huge
|
|
707
|
+
// mailboxes; falls back to AppleScript for non-IMAP accounts.
|
|
708
|
+
const count = isImapAccount(account)
|
|
709
|
+
? await imapUnreadCount(mailbox, { account })
|
|
710
|
+
: mailManager.getUnreadCount(mailbox, account);
|
|
635
711
|
const location = mailbox ? ` in "${mailbox}"` : "";
|
|
636
712
|
return successResponse(`${count} unread message(s)${location}`, {
|
|
637
713
|
unread: count,
|
|
@@ -893,7 +969,29 @@ server.tool("doctor", {}, withErrorHandling(async () => {
|
|
|
893
969
|
return successResponse(formatDoctorReport(report), { ...report });
|
|
894
970
|
}, "Error running doctor"));
|
|
895
971
|
// --- get-mail-stats ---
|
|
896
|
-
server.tool("get-mail-stats", {
|
|
972
|
+
server.tool("get-mail-stats", {
|
|
973
|
+
account: z
|
|
974
|
+
.string()
|
|
975
|
+
.optional()
|
|
976
|
+
.describe("Limit to one account; uses fast IMAP STATUS if that account is IMAP-configured"),
|
|
977
|
+
}, withErrorHandling(async ({ account }) => {
|
|
978
|
+
// IMAP (I3): for a named IMAP account, STATUS gives authoritative counts and
|
|
979
|
+
// SEARCH SINCE gives recent activity — fast even on huge mailboxes.
|
|
980
|
+
if (account && isImapAccount(account)) {
|
|
981
|
+
const s = await imapMailStats({ account });
|
|
982
|
+
const lines = [
|
|
983
|
+
`📊 Mail Statistics — ${account} (IMAP)`,
|
|
984
|
+
`══════════════════`,
|
|
985
|
+
`Total messages: ${s.totalMessages}`,
|
|
986
|
+
`Unread messages: ${s.totalUnread}`,
|
|
987
|
+
``,
|
|
988
|
+
`📥 Recently Received (INBOX):`,
|
|
989
|
+
` Last 24 hours: ${s.recent.last24h}`,
|
|
990
|
+
` Last 7 days: ${s.recent.last7d}`,
|
|
991
|
+
` Last 30 days: ${s.recent.last30d}`,
|
|
992
|
+
];
|
|
993
|
+
return successResponse(lines.join("\n"), { account, ...s });
|
|
994
|
+
}
|
|
897
995
|
const stats = mailManager.getMailStats();
|
|
898
996
|
const lines = [];
|
|
899
997
|
lines.push(`📊 Mail Statistics`);
|
|
@@ -38,12 +38,33 @@ interface ImapEnvelope {
|
|
|
38
38
|
subject?: string;
|
|
39
39
|
date?: Date | string;
|
|
40
40
|
from?: ImapAddress[];
|
|
41
|
+
messageId?: string;
|
|
42
|
+
inReplyTo?: string;
|
|
43
|
+
}
|
|
44
|
+
export interface ImapBodyStructure {
|
|
45
|
+
part?: string;
|
|
46
|
+
type?: string;
|
|
47
|
+
disposition?: string;
|
|
48
|
+
dispositionParameters?: Record<string, string>;
|
|
49
|
+
parameters?: Record<string, string>;
|
|
50
|
+
size?: number;
|
|
51
|
+
encoding?: string;
|
|
52
|
+
childNodes?: ImapBodyStructure[];
|
|
41
53
|
}
|
|
42
54
|
interface ImapMessage {
|
|
43
55
|
uid: number;
|
|
44
56
|
envelope?: ImapEnvelope;
|
|
45
57
|
flags?: Set<string>;
|
|
46
58
|
source?: Buffer | string;
|
|
59
|
+
bodyStructure?: ImapBodyStructure;
|
|
60
|
+
headers?: Buffer | string;
|
|
61
|
+
}
|
|
62
|
+
interface ImapDownload {
|
|
63
|
+
meta?: {
|
|
64
|
+
filename?: string;
|
|
65
|
+
contentType?: string;
|
|
66
|
+
};
|
|
67
|
+
content: AsyncIterable<Uint8Array>;
|
|
47
68
|
}
|
|
48
69
|
interface MailboxLock {
|
|
49
70
|
release: () => void;
|
|
@@ -68,6 +89,19 @@ export interface ImapClientLike {
|
|
|
68
89
|
uid: true;
|
|
69
90
|
}): Promise<ImapMessage | false>;
|
|
70
91
|
list(): Promise<ImapMailboxListing[]>;
|
|
92
|
+
status(path: string, query: {
|
|
93
|
+
messages?: boolean;
|
|
94
|
+
unseen?: boolean;
|
|
95
|
+
recent?: boolean;
|
|
96
|
+
}): Promise<{
|
|
97
|
+
path: string;
|
|
98
|
+
messages?: number;
|
|
99
|
+
unseen?: number;
|
|
100
|
+
recent?: number;
|
|
101
|
+
}>;
|
|
102
|
+
download(range: string, part: string, opts: {
|
|
103
|
+
uid: true;
|
|
104
|
+
}): Promise<ImapDownload>;
|
|
71
105
|
mailboxCreate(path: string): Promise<{
|
|
72
106
|
path: string;
|
|
73
107
|
created: boolean;
|
|
@@ -123,6 +157,32 @@ export declare function resolveImapConfig(env?: NodeJS.ProcessEnv, account?: str
|
|
|
123
157
|
export declare function resolveMailboxPath(mailbox: string | undefined, mode: "search" | "list"): string;
|
|
124
158
|
export declare function imapSearchMessages(args: ImapSearchArgs, deps?: ImapDeps): Promise<string>;
|
|
125
159
|
export declare function imapListMessages(args: ImapSearchArgs, deps?: ImapDeps): Promise<string>;
|
|
160
|
+
/** Unread count via IMAP STATUS (UNSEEN). No mailbox → sum across all mailboxes. */
|
|
161
|
+
export declare function imapUnreadCount(mailbox: string | undefined, deps?: ImapDeps): Promise<number>;
|
|
162
|
+
export interface ImapMailboxInfo {
|
|
163
|
+
path: string;
|
|
164
|
+
name: string;
|
|
165
|
+
messages: number;
|
|
166
|
+
unseen: number;
|
|
167
|
+
}
|
|
168
|
+
/** List mailboxes with per-mailbox message/unseen counts via LIST + STATUS (I6). */
|
|
169
|
+
export declare function imapListMailboxes(deps?: ImapDeps): Promise<ImapMailboxInfo[]>;
|
|
170
|
+
export interface ImapStats {
|
|
171
|
+
totalMessages: number;
|
|
172
|
+
totalUnread: number;
|
|
173
|
+
perMailbox: {
|
|
174
|
+
mailbox: string;
|
|
175
|
+
messages: number;
|
|
176
|
+
unseen: number;
|
|
177
|
+
}[];
|
|
178
|
+
recent: {
|
|
179
|
+
last24h: number;
|
|
180
|
+
last7d: number;
|
|
181
|
+
last30d: number;
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
/** Aggregate stats via STATUS (counts) + INBOX SEARCH SINCE (recent) (I3). */
|
|
185
|
+
export declare function imapMailStats(deps?: ImapDeps): Promise<ImapStats>;
|
|
126
186
|
export interface ImapOpResult {
|
|
127
187
|
success: boolean;
|
|
128
188
|
error?: string;
|
|
@@ -154,5 +214,53 @@ export declare const imapFlagMessage: (id: string, deps?: {}) => Promise<ImapOpR
|
|
|
154
214
|
export declare const imapUnflagMessage: (id: string, deps?: {}) => Promise<ImapOpResult>;
|
|
155
215
|
export declare function imapMoveMessageById(id: string, destMailbox: string, deps?: ImapDeps): Promise<ImapOpResult>;
|
|
156
216
|
export declare function imapDeleteMessageById(id: string, deps?: ImapDeps): Promise<ImapOpResult>;
|
|
217
|
+
export interface ImapAttachmentInfo {
|
|
218
|
+
id: string;
|
|
219
|
+
name: string;
|
|
220
|
+
mimeType: string;
|
|
221
|
+
size: number;
|
|
222
|
+
}
|
|
223
|
+
/** List a message's attachments via IMAP BODYSTRUCTURE (no full download). */
|
|
224
|
+
export declare function imapListAttachments(id: string, deps?: ImapDeps): Promise<{
|
|
225
|
+
success: boolean;
|
|
226
|
+
attachments?: ImapAttachmentInfo[];
|
|
227
|
+
error?: string;
|
|
228
|
+
}>;
|
|
229
|
+
/** Fetch one attachment's bytes (base64) via IMAP, matched by filename. */
|
|
230
|
+
export declare function imapFetchAttachment(id: string, attachmentName: string, deps?: ImapDeps): Promise<{
|
|
231
|
+
success: boolean;
|
|
232
|
+
base64?: string;
|
|
233
|
+
bytes?: number;
|
|
234
|
+
mimeType?: string;
|
|
235
|
+
error?: string;
|
|
236
|
+
}>;
|
|
237
|
+
export interface ImapBatchResult {
|
|
238
|
+
success: number;
|
|
239
|
+
failed: number;
|
|
240
|
+
errors: string[];
|
|
241
|
+
}
|
|
242
|
+
export declare const imapBatchMarkRead: (ids: string[], deps?: ImapDeps) => Promise<ImapBatchResult>;
|
|
243
|
+
export declare const imapBatchMarkUnread: (ids: string[], deps?: ImapDeps) => Promise<ImapBatchResult>;
|
|
244
|
+
export declare const imapBatchFlag: (ids: string[], deps?: ImapDeps) => Promise<ImapBatchResult>;
|
|
245
|
+
export declare const imapBatchUnflag: (ids: string[], deps?: ImapDeps) => Promise<ImapBatchResult>;
|
|
246
|
+
export declare const imapBatchDelete: (ids: string[], deps?: ImapDeps) => Promise<ImapBatchResult>;
|
|
247
|
+
export declare function imapBatchMove(ids: string[], destMailbox: string, deps?: ImapDeps): Promise<ImapBatchResult>;
|
|
248
|
+
export interface ImapThreadMessage {
|
|
249
|
+
id: string;
|
|
250
|
+
subject: string;
|
|
251
|
+
sender: string;
|
|
252
|
+
date: string;
|
|
253
|
+
isRead: boolean;
|
|
254
|
+
}
|
|
255
|
+
export interface ImapThreadResult {
|
|
256
|
+
count: number;
|
|
257
|
+
text: string;
|
|
258
|
+
structured: {
|
|
259
|
+
subject: string;
|
|
260
|
+
messages: ImapThreadMessage[];
|
|
261
|
+
count: number;
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
export declare function imapThread(id: string, deps?: ImapDeps, limit?: number): Promise<ImapThreadResult | null>;
|
|
157
265
|
export {};
|
|
158
266
|
//# sourceMappingURL=imapClient.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"imapClient.d.ts","sourceRoot":"","sources":["../../src/services/imapClient.ts"],"names":[],"mappings":"AA4BA,eAAO,MAAM,QAAQ;;;;;;;;;CAWX,CAAC;AAEX,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,OAAO,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,cAAc;IAC7B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,UAAU,WAAW;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AACD,UAAU,YAAY;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,IAAI,GAAG,MAAM,CAAC;IACrB,IAAI,CAAC,EAAE,WAAW,EAAE,CAAC;
|
|
1
|
+
{"version":3,"file":"imapClient.d.ts","sourceRoot":"","sources":["../../src/services/imapClient.ts"],"names":[],"mappings":"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"}
|
|
@@ -294,6 +294,97 @@ export function imapSearchMessages(args, deps = {}) {
|
|
|
294
294
|
export function imapListMessages(args, deps = {}) {
|
|
295
295
|
return run(args, true, deps);
|
|
296
296
|
}
|
|
297
|
+
// ===========================================================================
|
|
298
|
+
// Counts & stats via IMAP STATUS (2.1 optimizations I3/I4/I6)
|
|
299
|
+
//
|
|
300
|
+
// STATUS is a single server round-trip that returns authoritative message/unseen
|
|
301
|
+
// counts without enumerating messages — far faster and more reliable than
|
|
302
|
+
// AppleScript on large mailboxes (where the per-message walk times out, #8/#24).
|
|
303
|
+
// ===========================================================================
|
|
304
|
+
/** Unread count via IMAP STATUS (UNSEEN). No mailbox → sum across all mailboxes. */
|
|
305
|
+
export function imapUnreadCount(mailbox, deps = {}) {
|
|
306
|
+
return useClient(deps, async (client) => {
|
|
307
|
+
if (mailbox) {
|
|
308
|
+
const s = await client.status(resolveMailboxPath(mailbox, "list"), { unseen: true });
|
|
309
|
+
return s.unseen ?? 0;
|
|
310
|
+
}
|
|
311
|
+
let total = 0;
|
|
312
|
+
for (const b of await client.list()) {
|
|
313
|
+
try {
|
|
314
|
+
const s = await client.status(b.path, { unseen: true });
|
|
315
|
+
total += s.unseen ?? 0;
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
// skip mailboxes that can't be STATUS'd (e.g. \Noselect parents)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return total;
|
|
322
|
+
}, true);
|
|
323
|
+
}
|
|
324
|
+
/** List mailboxes with per-mailbox message/unseen counts via LIST + STATUS (I6). */
|
|
325
|
+
export function imapListMailboxes(deps = {}) {
|
|
326
|
+
return useClient(deps, async (client) => {
|
|
327
|
+
const out = [];
|
|
328
|
+
for (const b of await client.list()) {
|
|
329
|
+
let messages = 0;
|
|
330
|
+
let unseen = 0;
|
|
331
|
+
try {
|
|
332
|
+
const s = await client.status(b.path, { messages: true, unseen: true });
|
|
333
|
+
messages = s.messages ?? 0;
|
|
334
|
+
unseen = s.unseen ?? 0;
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
// \Noselect or otherwise un-status-able mailbox → report zeros
|
|
338
|
+
}
|
|
339
|
+
out.push({ path: b.path, name: b.name, messages, unseen });
|
|
340
|
+
}
|
|
341
|
+
return out;
|
|
342
|
+
}, true);
|
|
343
|
+
}
|
|
344
|
+
/** Aggregate stats via STATUS (counts) + INBOX SEARCH SINCE (recent) (I3). */
|
|
345
|
+
export function imapMailStats(deps = {}) {
|
|
346
|
+
return useClient(deps, async (client) => {
|
|
347
|
+
const perMailbox = [];
|
|
348
|
+
let totalMessages = 0;
|
|
349
|
+
let totalUnread = 0;
|
|
350
|
+
for (const b of await client.list()) {
|
|
351
|
+
try {
|
|
352
|
+
const s = await client.status(b.path, { messages: true, unseen: true });
|
|
353
|
+
const messages = s.messages ?? 0;
|
|
354
|
+
const unseen = s.unseen ?? 0;
|
|
355
|
+
totalMessages += messages;
|
|
356
|
+
totalUnread += unseen;
|
|
357
|
+
perMailbox.push({ mailbox: b.path, messages, unseen });
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
// skip un-status-able mailbox
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
// Recent counts against INBOX (the meaningful "received" surface).
|
|
364
|
+
const since = (days) => new Date(Date.now() - days * 86_400_000);
|
|
365
|
+
const countSince = async (days) => {
|
|
366
|
+
try {
|
|
367
|
+
const lock = await client.getMailboxLock("INBOX");
|
|
368
|
+
try {
|
|
369
|
+
const found = await client.search({ since: since(days) }, { uid: true });
|
|
370
|
+
return Array.isArray(found) ? found.length : 0;
|
|
371
|
+
}
|
|
372
|
+
finally {
|
|
373
|
+
lock.release();
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
return 0;
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
const [last24h, last7d, last30d] = await Promise.all([
|
|
381
|
+
countSince(1),
|
|
382
|
+
countSince(7),
|
|
383
|
+
countSince(30),
|
|
384
|
+
]);
|
|
385
|
+
return { totalMessages, totalUnread, perMailbox, recent: { last24h, last7d, last30d } };
|
|
386
|
+
}, true);
|
|
387
|
+
}
|
|
297
388
|
function errText(e) {
|
|
298
389
|
return e instanceof Error ? e.message : String(e);
|
|
299
390
|
}
|
|
@@ -618,3 +709,203 @@ export async function imapDeleteMessageById(id, deps = {}) {
|
|
|
618
709
|
}
|
|
619
710
|
});
|
|
620
711
|
}
|
|
712
|
+
/** Walk a BODYSTRUCTURE tree collecting attachment parts (disposition or filename). */
|
|
713
|
+
function collectAttachments(node, out = []) {
|
|
714
|
+
if (!node)
|
|
715
|
+
return out;
|
|
716
|
+
const filename = node.dispositionParameters?.filename || node.parameters?.name;
|
|
717
|
+
const disposition = node.disposition?.toLowerCase();
|
|
718
|
+
const isAttachment = !!node.part && (disposition === "attachment" || (!!filename && disposition !== "inline"));
|
|
719
|
+
if (isAttachment) {
|
|
720
|
+
out.push({
|
|
721
|
+
part: node.part,
|
|
722
|
+
filename: filename || `part-${node.part}`,
|
|
723
|
+
mimeType: node.type || "application/octet-stream",
|
|
724
|
+
size: node.size ?? 0,
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
for (const child of node.childNodes ?? [])
|
|
728
|
+
collectAttachments(child, out);
|
|
729
|
+
return out;
|
|
730
|
+
}
|
|
731
|
+
async function streamToBuffer(content) {
|
|
732
|
+
const chunks = [];
|
|
733
|
+
for await (const chunk of content)
|
|
734
|
+
chunks.push(Buffer.from(chunk));
|
|
735
|
+
return Buffer.concat(chunks);
|
|
736
|
+
}
|
|
737
|
+
/** List a message's attachments via IMAP BODYSTRUCTURE (no full download). */
|
|
738
|
+
export async function imapListAttachments(id, deps = {}) {
|
|
739
|
+
const ref = decodeImapId(id);
|
|
740
|
+
if (!ref)
|
|
741
|
+
return { success: false, error: `Not an IMAP message id: "${id}".` };
|
|
742
|
+
return withMailbox(ref.path, { ...deps, account: deps.account ?? ref.account }, async (client) => {
|
|
743
|
+
const msg = await client.fetchOne(String(ref.uid), { bodyStructure: true }, { uid: true });
|
|
744
|
+
if (!msg || !msg.bodyStructure) {
|
|
745
|
+
return { success: false, error: `IMAP message UID ${ref.uid} not found in "${ref.path}".` };
|
|
746
|
+
}
|
|
747
|
+
const attachments = collectAttachments(msg.bodyStructure).map((a) => ({
|
|
748
|
+
id: `${id}#${a.part}`,
|
|
749
|
+
name: a.filename,
|
|
750
|
+
mimeType: a.mimeType,
|
|
751
|
+
size: a.size,
|
|
752
|
+
}));
|
|
753
|
+
return { success: true, attachments };
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
/** Fetch one attachment's bytes (base64) via IMAP, matched by filename. */
|
|
757
|
+
export async function imapFetchAttachment(id, attachmentName, deps = {}) {
|
|
758
|
+
const ref = decodeImapId(id);
|
|
759
|
+
if (!ref)
|
|
760
|
+
return { success: false, error: `Not an IMAP message id: "${id}".` };
|
|
761
|
+
return withMailbox(ref.path, { ...deps, account: deps.account ?? ref.account }, async (client) => {
|
|
762
|
+
const msg = await client.fetchOne(String(ref.uid), { bodyStructure: true }, { uid: true });
|
|
763
|
+
if (!msg || !msg.bodyStructure) {
|
|
764
|
+
return { success: false, error: `IMAP message UID ${ref.uid} not found in "${ref.path}".` };
|
|
765
|
+
}
|
|
766
|
+
const atts = collectAttachments(msg.bodyStructure);
|
|
767
|
+
const match = atts.find((a) => a.filename === attachmentName);
|
|
768
|
+
if (!match) {
|
|
769
|
+
const names = atts.map((a) => a.filename).join(", ") || "none";
|
|
770
|
+
return {
|
|
771
|
+
success: false,
|
|
772
|
+
error: `Attachment "${attachmentName}" not found on UID ${ref.uid}. Available: ${names}.`,
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
const dl = await client.download(String(ref.uid), match.part, { uid: true });
|
|
776
|
+
const buf = await streamToBuffer(dl.content);
|
|
777
|
+
return {
|
|
778
|
+
success: true,
|
|
779
|
+
base64: buf.toString("base64"),
|
|
780
|
+
bytes: buf.length,
|
|
781
|
+
mimeType: match.mimeType,
|
|
782
|
+
};
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
async function imapBatch(ids, deps, op) {
|
|
786
|
+
const groups = new Map();
|
|
787
|
+
const errors = [];
|
|
788
|
+
let failed = 0;
|
|
789
|
+
for (const id of ids) {
|
|
790
|
+
const ref = decodeImapId(id);
|
|
791
|
+
if (!ref) {
|
|
792
|
+
failed++;
|
|
793
|
+
errors.push(`Not an IMAP id: "${id}"`);
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
const key = `${ref.account}${ref.path}`;
|
|
797
|
+
const g = groups.get(key) ?? { account: ref.account, path: ref.path, uids: [] };
|
|
798
|
+
g.uids.push(ref.uid);
|
|
799
|
+
groups.set(key, g);
|
|
800
|
+
}
|
|
801
|
+
let success = 0;
|
|
802
|
+
for (const g of groups.values()) {
|
|
803
|
+
try {
|
|
804
|
+
await useClient({ ...deps, account: deps.account ?? g.account }, async (client) => {
|
|
805
|
+
const lock = await client.getMailboxLock(g.path);
|
|
806
|
+
try {
|
|
807
|
+
await op(client, g.uids, g.path);
|
|
808
|
+
}
|
|
809
|
+
finally {
|
|
810
|
+
lock.release();
|
|
811
|
+
}
|
|
812
|
+
});
|
|
813
|
+
success += g.uids.length;
|
|
814
|
+
}
|
|
815
|
+
catch (e) {
|
|
816
|
+
failed += g.uids.length;
|
|
817
|
+
errors.push(`${g.path}: ${errText(e)}`);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
return { success, failed, errors };
|
|
821
|
+
}
|
|
822
|
+
export const imapBatchMarkRead = (ids, deps = {}) => imapBatch(ids, deps, async (c, uids) => {
|
|
823
|
+
await c.messageFlagsAdd(uids, ["\\Seen"], { uid: true });
|
|
824
|
+
});
|
|
825
|
+
export const imapBatchMarkUnread = (ids, deps = {}) => imapBatch(ids, deps, async (c, uids) => {
|
|
826
|
+
await c.messageFlagsRemove(uids, ["\\Seen"], { uid: true });
|
|
827
|
+
});
|
|
828
|
+
export const imapBatchFlag = (ids, deps = {}) => imapBatch(ids, deps, async (c, uids) => {
|
|
829
|
+
await c.messageFlagsAdd(uids, ["\\Flagged"], { uid: true });
|
|
830
|
+
});
|
|
831
|
+
export const imapBatchUnflag = (ids, deps = {}) => imapBatch(ids, deps, async (c, uids) => {
|
|
832
|
+
await c.messageFlagsRemove(uids, ["\\Flagged"], { uid: true });
|
|
833
|
+
});
|
|
834
|
+
export const imapBatchDelete = (ids, deps = {}) => imapBatch(ids, deps, async (c, uids) => {
|
|
835
|
+
await c.messageDelete(uids, { uid: true });
|
|
836
|
+
});
|
|
837
|
+
export function imapBatchMove(ids, destMailbox, deps = {}) {
|
|
838
|
+
return imapBatch(ids, deps, async (c, uids) => {
|
|
839
|
+
const dest = (await findMailboxPath(c, destMailbox)) ?? resolveMailboxPath(destMailbox, "list");
|
|
840
|
+
await c.messageMove(uids, dest, { uid: true });
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
function senderName(from) {
|
|
844
|
+
const a = from?.[0];
|
|
845
|
+
if (!a)
|
|
846
|
+
return "(unknown)";
|
|
847
|
+
return a.name ? `${a.name} <${a.address ?? ""}>` : (a.address ?? "(unknown)");
|
|
848
|
+
}
|
|
849
|
+
function dateMs(m) {
|
|
850
|
+
return m.envelope?.date ? new Date(m.envelope.date).getTime() : 0;
|
|
851
|
+
}
|
|
852
|
+
export async function imapThread(id, deps = {}, limit = 50) {
|
|
853
|
+
const ref = decodeImapId(id);
|
|
854
|
+
if (!ref)
|
|
855
|
+
return null;
|
|
856
|
+
return useClient({ ...deps, account: deps.account ?? ref.account }, async (client) => {
|
|
857
|
+
const lock = await client.getMailboxLock(ref.path);
|
|
858
|
+
try {
|
|
859
|
+
const seed = await client.fetchOne(String(ref.uid), { envelope: true, headers: ["references", "in-reply-to", "message-id"] }, { uid: true });
|
|
860
|
+
if (!seed)
|
|
861
|
+
return null;
|
|
862
|
+
const seedMsgId = seed.envelope?.messageId;
|
|
863
|
+
const refIds = new Set();
|
|
864
|
+
const hdr = seed.headers ? seed.headers.toString() : "";
|
|
865
|
+
for (const m of hdr.matchAll(/<[^>]+>/g))
|
|
866
|
+
refIds.add(m[0]);
|
|
867
|
+
if (seed.envelope?.inReplyTo)
|
|
868
|
+
refIds.add(seed.envelope.inReplyTo);
|
|
869
|
+
const uidSet = new Set([ref.uid]);
|
|
870
|
+
const addFound = (found) => {
|
|
871
|
+
if (Array.isArray(found))
|
|
872
|
+
found.forEach((u) => uidSet.add(u));
|
|
873
|
+
};
|
|
874
|
+
// Descendants: anything referencing the seed.
|
|
875
|
+
if (seedMsgId) {
|
|
876
|
+
addFound(await client.search({ header: { references: seedMsgId } }, { uid: true }));
|
|
877
|
+
addFound(await client.search({ header: { "in-reply-to": seedMsgId } }, { uid: true }));
|
|
878
|
+
}
|
|
879
|
+
// Ancestors: messages whose Message-ID is in the seed's References (bounded).
|
|
880
|
+
for (const mid of [...refIds].slice(0, 20)) {
|
|
881
|
+
addFound(await client.search({ header: { "message-id": mid } }, { uid: true }));
|
|
882
|
+
}
|
|
883
|
+
if (uidSet.size <= 1)
|
|
884
|
+
return null; // only the seed → caller falls back to subject
|
|
885
|
+
const uids = [...uidSet].slice(0, limit);
|
|
886
|
+
const msgs = [];
|
|
887
|
+
for await (const msg of client.fetch(uids.join(","), { envelope: true, flags: true }, { uid: true })) {
|
|
888
|
+
msgs.push(msg);
|
|
889
|
+
}
|
|
890
|
+
msgs.sort((a, b) => dateMs(a) - dateMs(b)); // oldest first
|
|
891
|
+
const subject = seed.envelope?.subject || "(no subject)";
|
|
892
|
+
const structured = {
|
|
893
|
+
subject,
|
|
894
|
+
count: msgs.length,
|
|
895
|
+
messages: msgs.map((m) => ({
|
|
896
|
+
id: encodeImapId(ref.account, ref.path, m.uid),
|
|
897
|
+
subject: m.envelope?.subject || "(no subject)",
|
|
898
|
+
sender: senderName(m.envelope?.from),
|
|
899
|
+
date: m.envelope?.date ? new Date(m.envelope.date).toISOString() : "",
|
|
900
|
+
isRead: m.flags?.has("\\Seen") ?? false,
|
|
901
|
+
})),
|
|
902
|
+
};
|
|
903
|
+
const text = `Thread "${subject}" — ${msgs.length} message(s) via IMAP (References-linked, oldest first):\n` +
|
|
904
|
+
msgs.map((m) => formatRow(m, ref.account, ref.path)).join("\n");
|
|
905
|
+
return { count: msgs.length, text, structured };
|
|
906
|
+
}
|
|
907
|
+
finally {
|
|
908
|
+
lock.release();
|
|
909
|
+
}
|
|
910
|
+
}, true);
|
|
911
|
+
}
|