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 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` or any message mutation and it routes to
306
- IMAP automatically; bare numeric ids continue to use AppleScript. So an agent
307
- never has to know which backend a message came from the id carries it. (Batch
308
- operations remain AppleScript-only and accept numeric ids; use the single-message
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: each call currently opens its own IMAP connection (no pooling yet), so
345
- > expect a few seconds of connection overhead per call — the one remaining
346
- > follow-up on [#43](https://github.com/sweetrb/apple-mail-mcp/issues/43).
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 stored in memory for the duration of the server session.
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 are AppleScript-only and capped to prevent unbounded loops /
49
- * DoS. IMAP ids are rejected here use the single-message tools for those. */
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(NUMERIC_MESSAGE_ID_SCHEMA)
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 results = mailManager.batchDeleteMessages(ids);
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 results = mailManager.batchMoveMessages(ids, mailbox, account);
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 results = mailManager.batchMarkAsRead(ids);
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 results = mailManager.batchMarkAsUnread(ids);
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 results = mailManager.batchFlagMessages(ids);
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 results = mailManager.batchUnflagMessages(ids);
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
- const attachments = mailManager.listAttachments(id);
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: NUMERIC_MESSAGE_ID_SCHEMA,
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, for clients that want the bytes directly
607
- // rather than writing to disk via save-attachment.
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
- const count = mailManager.getUnreadCount(mailbox, account);
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", {}, withErrorHandling(() => {
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":"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"}
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-mail-mcp",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "MCP server for Apple Mail - read, search, send, and manage emails via Claude",
5
5
  "type": "module",
6
6
  "main": "build/index.js",