apple-mail-mcp 2.1.1 → 2.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +48 -3
  2. package/build/index.js +45 -45
  3. package/package.json +6 -5
package/README.md CHANGED
@@ -6,6 +6,10 @@ A [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that e
6
6
  [![CI](https://github.com/sweetrb/apple-mail-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/sweetrb/apple-mail-mcp/actions/workflows/ci.yml)
7
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
8
8
 
9
+ <p align="center">
10
+ <img src="codex/assets/screenshot.png" alt="Apple Mail MCP — read, search, send, and organize Apple Mail from Codex, Claude, and other AI assistants" width="680">
11
+ </p>
12
+
9
13
  > **Note:** This is the **npm/Node.js** package — install with `npx` or `npm`. There is an unrelated Python project of the same name on PyPI ([`imdinu/apple-mail-mcp`](https://github.com/imdinu/apple-mail-mcp)) installed via `pipx`/`uvx`. If you're using `uvx` and seeing a `cyclopts` dependency error, you're looking for that project, not this one.
10
14
 
11
15
  ## What is This?
@@ -45,6 +49,21 @@ Install as a Claude Code plugin for automatic configuration and enhanced AI beha
45
49
 
46
50
  This method also installs a **skill** that teaches Claude when and how to use Apple Mail effectively.
47
51
 
52
+ ### Using the Codex Marketplace
53
+
54
+ Install the same public marketplace in Codex:
55
+
56
+ ```bash
57
+ codex plugin marketplace add sweetrb/apple-mail-mcp
58
+ codex plugin add apple-mail@apple-mail-mcp
59
+ ```
60
+
61
+ The Codex package registers the same `apple-mail` MCP server through `npx -y github:sweetrb/apple-mail-mcp` and includes the Apple Mail skill guidance.
62
+
63
+ ### Other Hosts (Hermes, Antigravity)
64
+
65
+ Plugin packaging for the Hermes and Antigravity hosts is also included (`.hermes-plugin/` and `.antigravity-plugin/`). Each registers the same `apple-mail` MCP server (launched via `npx -y github:sweetrb/apple-mail-mcp`) and bundles the Apple Mail skill, so behavior matches the Claude Code and Codex plugins. Install them through each host's plugin/marketplace mechanism pointed at this repository.
66
+
48
67
  ### Manual Installation
49
68
 
50
69
  **1. Install the server:**
@@ -218,6 +237,8 @@ List messages in a mailbox.
218
237
 
219
238
  Send a new email immediately.
220
239
 
240
+ **⚠️ Safety:** Sends real mail immediately and cannot be unsent. Confirm the recipients, subject, and body with the user before calling.
241
+
221
242
  | Parameter | Type | Required | Description |
222
243
  |-----------|------|----------|-------------|
223
244
  | `to` | string[] | Yes | Recipient addresses |
@@ -284,6 +305,8 @@ The default `applescript` transport is unchanged; SMTP is opt-in per call.
284
305
 
285
306
  ##### IMAP backend — opt-in
286
307
 
308
+ > 📘 **For step-by-step setup (app passwords, Keychain, config methods, multi-account, upgrading, troubleshooting), see the [IMAP / SMTP Setup Guide](docs/IMAP-SETUP.md).** The summary below is the reference; the guide is the walkthrough.
309
+
287
310
  AppleScript runs `search`/`list` predicates client-side over the Apple Event
288
311
  bridge, which is slow and can time out (false-empty) on large Gmail/IMAP
289
312
  mailboxes (see [#24](https://github.com/sweetrb/apple-mail-mcp/issues/24)), and
@@ -448,6 +471,8 @@ Each recipient object:
448
471
 
449
472
  **Returns:** Per-recipient success/failure results with a summary count.
450
473
 
474
+ **⚠️ Safety:** Sends real mail immediately to every recipient and cannot be unsent. Confirm the recipient list, subject, and body with the user before calling.
475
+
451
476
  ---
452
477
 
453
478
  #### `create-draft`
@@ -521,6 +546,8 @@ Reply to an existing message.
521
546
  }
522
547
  ```
523
548
 
549
+ **⚠️ Safety:** With the default `send: true`, sends real mail immediately and cannot be unsent. Confirm the recipients, subject, and body with the user before calling (or pass `send: false` to save a draft for review).
550
+
524
551
  ---
525
552
 
526
553
  #### `forward-message`
@@ -534,6 +561,8 @@ Forward a message to new recipients.
534
561
  | `body` | string | No | Message to prepend |
535
562
  | `send` | boolean | No | Send immediately (default: true, false = save as draft) |
536
563
 
564
+ **⚠️ Safety:** With the default `send: true`, sends real mail immediately and cannot be unsent. Confirm the recipients, subject, and body with the user before calling (or pass `send: false` to save a draft for review).
565
+
537
566
  ---
538
567
 
539
568
  #### `mark-as-read` / `mark-as-unread`
@@ -564,6 +593,8 @@ Delete a message (move to trash).
564
593
  |-----------|------|----------|-------------|
565
594
  | `id` | string | Yes | Message ID |
566
595
 
596
+ **⚠️ Safety:** Destructive. Requires explicit user confirmation; search/list first to confirm the message id.
597
+
567
598
  ---
568
599
 
569
600
  #### `move-message`
@@ -612,6 +643,8 @@ All batch operations accept an array of message IDs (max 100 per batch) and retu
612
643
  |-----------|------|----------|-------------|
613
644
  | `ids` | string[] | Yes | Message IDs to delete (max 100) |
614
645
 
646
+ **⚠️ Safety:** Destructive. Requires explicit user confirmation; search/list first to confirm the message ids.
647
+
615
648
  #### `batch-move-messages`
616
649
 
617
650
  | Parameter | Type | Required | Description |
@@ -679,6 +712,8 @@ Delete a mailbox.
679
712
  | `name` | string | Yes | Mailbox name |
680
713
  | `account` | string | No | Account containing mailbox |
681
714
 
715
+ **⚠️ Safety:** Destructive — deletes the mailbox and its contents. Requires explicit user confirmation; list mailboxes first to confirm the name.
716
+
682
717
  ---
683
718
 
684
719
  #### `rename-mailbox`
@@ -760,6 +795,8 @@ Delete a mail rule by name.
760
795
  |-----------|------|----------|-------------|
761
796
  | `name` | string | Yes | Rule name |
762
797
 
798
+ **⚠️ Safety:** Destructive. Requires explicit user confirmation; list rules first to confirm the name.
799
+
763
800
  ---
764
801
 
765
802
  ### Contacts
@@ -822,6 +859,8 @@ Delete a template.
822
859
  |-----------|------|----------|-------------|
823
860
  | `id` | string | Yes | Template ID |
824
861
 
862
+ **⚠️ Safety:** Destructive — removes the template from the on-disk store. Requires explicit user confirmation; list templates first to confirm the id.
863
+
825
864
  ---
826
865
 
827
866
  #### `use-template`
@@ -1140,6 +1179,12 @@ Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for gui
1140
1179
 
1141
1180
  ## Related Projects
1142
1181
 
1143
- - [apple-notes-mcp](https://github.com/sweetrb/apple-notes-mcp) MCP server for Apple Notes
1144
- - [apple-numbers-mcp](https://github.com/sweetrb/apple-numbers-mcp) — MCP server for Apple Numbers spreadsheets
1145
- - [apple-photos-mcp](https://github.com/sweetrb/apple-photos-mcp) — MCP server for Apple Photos
1182
+ Part of a family of macOS MCP servers:
1183
+
1184
+ - [apple-notes-mcp](https://github.com/sweetrb/apple-notes-mcp) — MCP server for Apple Notes (create, search, update, and export notes)
1185
+ - [apple-numbers-mcp](https://github.com/sweetrb/apple-numbers-mcp) — MCP server for Apple Numbers (read and write .numbers spreadsheets)
1186
+ - [apple-photos-mcp](https://github.com/sweetrb/apple-photos-mcp) — MCP server for Apple Photos (query metadata and export originals)
1187
+
1188
+ ## Recurring macOS permission prompts
1189
+
1190
+ If macOS keeps re-prompting for Full Disk Access or Automation for `node` (often after a `brew upgrade`), see [docs/NODE-RUNTIME-AND-TCC-PERMISSIONS.md](docs/NODE-RUNTIME-AND-TCC-PERMISSIONS.md) — the fix is to run this server under the official, Developer-ID-signed Node so the grant survives Node updates.
package/build/index.js CHANGED
@@ -132,7 +132,7 @@ async function hybridBatchCounts(ids, appleFn, imapFn) {
132
132
  // Message Tools
133
133
  // =============================================================================
134
134
  // --- search-messages ---
135
- server.tool("search-messages", {
135
+ server.tool("search-messages", "Use when: finding messages by query/sender/subject/date/read/flag filters and you need their ids for follow-up operations.\nReturns: matching messages with id, date, subject, sender, and read state (plus partial-coverage diagnostics when some mailboxes were skipped).\nDo not use when: you want a plain mailbox listing without filters (use list-messages), already have an id and want the body (use get-message), or want a whole conversation (use get-thread).\nPrefer this first to obtain the message ids that get-message/mark-as-read/delete-message/move-message and the batch tools require.", {
136
136
  query: z.string().optional().describe("Text to search for in subject, sender, or content"),
137
137
  from: z
138
138
  .string()
@@ -188,7 +188,7 @@ server.tool("search-messages", {
188
188
  return successResponse(`Found ${messages.length} message(s):\n${messageList}${coverageBlock}`, structured);
189
189
  }, "Error searching messages"));
190
190
  // --- get-message ---
191
- server.tool("get-message", {
191
+ server.tool("get-message", "Use when: reading the full body of one message whose id you already have (numeric or imap:…); set preferHtml to get the HTML body instead of plain text.\nReturns: the message subject and body (plain text by default, HTML when preferHtml is true).\nDo not use when: you don't yet have an id (use search-messages or list-messages first), or you want the whole conversation (use get-thread).", {
192
192
  id: MESSAGE_ID_SCHEMA,
193
193
  preferHtml: z
194
194
  .boolean()
@@ -215,7 +215,7 @@ server.tool("get-message", {
215
215
  fail: `Message with ID "${id}" not found`,
216
216
  }), "Error retrieving message"));
217
217
  // --- get-thread ---
218
- server.tool("get-thread", {
218
+ server.tool("get-thread", "Use when: you have one message id and want the whole conversation it belongs to, oldest-first. With an imap: id it threads by References/Message-ID; otherwise it groups by normalized subject.\nReturns: the thread's normalized subject and its messages (id, date, subject, sender, read state).\nDo not use when: you only need the single message (use get-message) or are searching by arbitrary criteria (use search-messages).", {
219
219
  id: MESSAGE_ID_SCHEMA.describe("A message ID in the conversation (numeric or imap:…)"),
220
220
  account: z.string().optional().describe("Account to search (omit to search all)"),
221
221
  mailbox: z.string().optional().describe("Mailbox to search (omit to search all)"),
@@ -273,7 +273,7 @@ server.tool("get-thread", {
273
273
  return successResponse(`Thread "${base}" — ${ordered.length} message(s), oldest first:\n${list}${coverageBlock}`, structured);
274
274
  }, "Error retrieving thread"));
275
275
  // --- list-messages ---
276
- server.tool("list-messages", {
276
+ server.tool("list-messages", "Use when: browsing a mailbox's recent messages (optionally filtered by sender or unread-only) with pagination via limit/offset, and you need their ids.\nReturns: messages with id, date, subject, and sender (plus partial-coverage diagnostics when some mailboxes were skipped).\nDo not use when: you have specific search criteria like subject/date/flags (use search-messages) or already have an id and want the body (use get-message).\nLike search-messages, use this to obtain the ids that read/mark/delete/move and batch tools require.", {
277
277
  mailbox: z
278
278
  .string()
279
279
  .optional()
@@ -311,7 +311,7 @@ server.tool("list-messages", {
311
311
  return successResponse(`Found ${messages.length} message(s):\n${messageList}${coverageBlock}`, structured);
312
312
  }, "Error listing messages"));
313
313
  // --- send-email ---
314
- server.tool("send-email", {
314
+ server.tool("send-email", "Use when: the user has explicitly confirmed they want to send a single email now to the given recipients (to/cc/bcc are arrays), optionally with attachments and a chosen transport.\nReturns: a confirmation naming the recipients and attachment count.\nDo not use when: the user wants to review first (use create-draft), is replying to or forwarding an existing message (use reply-to-message / forward-message), or wants per-recipient personalized copies (use send-serial-email).\nSafety: this SENDS real email immediately and it cannot be unsent — require explicit user confirmation of the exact recipients, subject, and body before calling. Prefer create-draft when there is any doubt.", {
315
315
  to: z.array(z.string()).min(1, "At least one recipient is required"),
316
316
  subject: z.string().min(1, "Subject is required"),
317
317
  body: z.string().min(1, "Body is required"),
@@ -341,7 +341,7 @@ server.tool("send-email", {
341
341
  return successResponse(`Email sent to ${to.join(", ")}${attachInfo}`);
342
342
  }, "Error sending email"));
343
343
  // --- send-serial-email ---
344
- server.tool("send-serial-email", {
344
+ server.tool("send-serial-email", "Use when: the user has confirmed a mail-merge — sending individually personalized copies to many recipients (max 100), with {{Key}} placeholders in subject/body replaced per-recipient from each recipient's variables. Recipients do not see each other.\nReturns: a per-recipient sent/failed report with counts.\nDo not use when: sending one message to a shared recipient list (use send-email) or saving for review (use create-draft).\nSafety: this SENDS many real emails immediately and they cannot be unsent — require explicit user confirmation of the recipient list, the subject/body template, and the placeholder substitutions before calling.", {
345
345
  recipients: z
346
346
  .array(z.object({
347
347
  email: z.string().min(1, "Recipient email is required"),
@@ -385,7 +385,7 @@ server.tool("send-serial-email", {
385
385
  }
386
386
  }, "Error sending serial emails"));
387
387
  // --- create-draft ---
388
- server.tool("create-draft", {
388
+ server.tool("create-draft", "Use when: composing an email the user should review in Mail.app before sending — the safe default for any new message (to/cc/bcc are arrays, optional attachments).\nReturns: a confirmation that the draft was created, with recipients and attachment count.\nDo not use when: the user has already confirmed they want it sent now (use send-email).\nSafety: low risk — creates a draft only and sends nothing; the user must open Mail.app and send it themselves.", {
389
389
  to: z.array(z.string()).min(1, "At least one recipient is required"),
390
390
  subject: z.string().min(1, "Subject is required"),
391
391
  body: z.string().min(1, "Body is required"),
@@ -402,7 +402,7 @@ server.tool("create-draft", {
402
402
  return successResponse(`Draft created for ${to.join(", ")}${attachInfo}`);
403
403
  }, "Error creating draft"));
404
404
  // --- reply-to-message ---
405
- server.tool("reply-to-message", {
405
+ server.tool("reply-to-message", "Use when: replying to an existing message by id, preserving its threading headers. Set replyAll for all recipients; set send=false to save as a draft instead of sending.\nReturns: a confirmation that the reply was sent or saved as a draft.\nDo not use when: composing a brand-new message (use send-email / create-draft) or forwarding to new recipients (use forward-message).\nSafety: with the default send=true this SENDS real email immediately and cannot be unsent — require explicit user confirmation of the recipients and body, or pass send=false to let the user review.", {
406
406
  id: MESSAGE_ID_SCHEMA,
407
407
  body: z.string().min(1, "Reply body is required"),
408
408
  replyAll: z.boolean().optional().default(false).describe("Reply to all recipients"),
@@ -415,7 +415,7 @@ server.tool("reply-to-message", {
415
415
  return successResponse(send ? "Reply sent" : "Reply saved as draft");
416
416
  }, "Error replying to message"));
417
417
  // --- forward-message ---
418
- server.tool("forward-message", {
418
+ server.tool("forward-message", "Use when: forwarding an existing message (by id) to new recipients (to is an array), with an optional body to prepend. Set send=false to save as a draft.\nReturns: a confirmation that the message was forwarded or saved as a draft.\nDo not use when: replying to the sender/recipients (use reply-to-message) or composing a new message (use send-email / create-draft).\nSafety: with the default send=true this SENDS real email immediately and cannot be unsent — require explicit user confirmation of the recipients and any prepended body, or pass send=false to let the user review.", {
419
419
  id: MESSAGE_ID_SCHEMA,
420
420
  to: z.array(z.string()).min(1, "At least one recipient is required"),
421
421
  body: z.string().optional().describe("Optional message to prepend"),
@@ -428,7 +428,7 @@ server.tool("forward-message", {
428
428
  return successResponse(send ? `Message forwarded to ${to.join(", ")}` : "Forward saved as draft");
429
429
  }, "Error forwarding message"));
430
430
  // --- mark-as-read ---
431
- server.tool("mark-as-read", {
431
+ server.tool("mark-as-read", "Use when: marking a single message (by id) as read.\nReturns: a confirmation that the message was marked read.\nDo not use when: marking several at once (use batch-mark-as-read) or marking unread (use mark-as-unread). Get the id from search-messages or list-messages first.", {
432
432
  id: MESSAGE_ID_SCHEMA,
433
433
  }, withErrorHandling(({ id }) => routeMessage(id, {
434
434
  imap: () => imapMarkRead(id),
@@ -439,7 +439,7 @@ server.tool("mark-as-read", {
439
439
  fail: `Failed to mark message "${id}" as read`,
440
440
  }), "Error marking message as read"));
441
441
  // --- mark-as-unread ---
442
- server.tool("mark-as-unread", {
442
+ server.tool("mark-as-unread", "Use when: marking a single message (by id) as unread.\nReturns: a confirmation that the message was marked unread.\nDo not use when: marking several at once (use batch-mark-as-unread) or marking read (use mark-as-read). Get the id from search-messages or list-messages first.", {
443
443
  id: MESSAGE_ID_SCHEMA,
444
444
  }, withErrorHandling(({ id }) => routeMessage(id, {
445
445
  imap: () => imapMarkUnread(id),
@@ -450,7 +450,7 @@ server.tool("mark-as-unread", {
450
450
  fail: `Failed to mark message "${id}" as unread`,
451
451
  }), "Error marking message as unread"));
452
452
  // --- flag-message ---
453
- server.tool("flag-message", {
453
+ server.tool("flag-message", "Use when: flagging a single message (by id).\nReturns: a confirmation that the message was flagged.\nDo not use when: flagging several at once (use batch-flag-messages) or removing a flag (use unflag-message). Get the id from search-messages or list-messages first.", {
454
454
  id: MESSAGE_ID_SCHEMA,
455
455
  }, withErrorHandling(({ id }) => routeMessage(id, {
456
456
  imap: () => imapFlagMessage(id),
@@ -461,7 +461,7 @@ server.tool("flag-message", {
461
461
  fail: `Failed to flag message "${id}"`,
462
462
  }), "Error flagging message"));
463
463
  // --- unflag-message ---
464
- server.tool("unflag-message", {
464
+ server.tool("unflag-message", "Use when: removing the flag from a single message (by id).\nReturns: a confirmation that the message was unflagged.\nDo not use when: unflagging several at once (use batch-unflag-messages) or adding a flag (use flag-message). Get the id from search-messages or list-messages first.", {
465
465
  id: MESSAGE_ID_SCHEMA,
466
466
  }, withErrorHandling(({ id }) => routeMessage(id, {
467
467
  imap: () => imapUnflagMessage(id),
@@ -472,7 +472,7 @@ server.tool("unflag-message", {
472
472
  fail: `Failed to unflag message "${id}"`,
473
473
  }), "Error unflagging message"));
474
474
  // --- delete-message ---
475
- server.tool("delete-message", {
475
+ server.tool("delete-message", "Use when: deleting a single message by id (moves it to Trash).\nReturns: a confirmation that the message was deleted.\nDo not use when: deleting several at once (use batch-delete-messages) or just filing it away (use move-message).\nSafety: destructive — require explicit user confirmation, and search-messages/list-messages first to confirm you have the right id before deleting.", {
476
476
  id: MESSAGE_ID_SCHEMA,
477
477
  }, withErrorHandling(({ id }) => routeMessage(id, {
478
478
  imap: () => imapDeleteMessageById(id),
@@ -486,7 +486,7 @@ server.tool("delete-message", {
486
486
  fail: `Failed to delete message "${id}"`,
487
487
  }), "Error deleting message"));
488
488
  // --- move-message ---
489
- server.tool("move-message", {
489
+ server.tool("move-message", "Use when: moving a single message (by id) into another mailbox/folder, e.g. archiving or filing.\nReturns: a confirmation naming the destination mailbox.\nDo not use when: moving several at once (use batch-move-messages) or deleting (use delete-message). Use list-mailboxes to confirm the destination name exists.\nSafety: moves a real message between folders — confirm the destination mailbox, and search-messages/list-messages first to confirm the id.", {
490
490
  id: MESSAGE_ID_SCHEMA,
491
491
  mailbox: z.string().min(1, "Destination mailbox is required"),
492
492
  account: z.string().optional().describe("Account containing the destination mailbox"),
@@ -502,7 +502,7 @@ server.tool("move-message", {
502
502
  fail: `Failed to move message to "${mailbox}"`,
503
503
  }), "Error moving message"));
504
504
  // --- batch-delete-messages ---
505
- server.tool("batch-delete-messages", {
505
+ server.tool("batch-delete-messages", "Use when: deleting multiple messages in one call (1–100 ids; moves them to Trash).\nReturns: counts of how many were deleted and how many failed.\nDo not use when: deleting just one (use delete-message) or filing messages away (use batch-move-messages).\nSafety: destructive and applies to many messages at once — require explicit user confirmation, and search-messages/list-messages first to confirm every id is correct before deleting.", {
506
506
  ids: BATCH_IDS_SCHEMA,
507
507
  }, withErrorHandling(async ({ ids }) => {
508
508
  const { success: successCount, fail: failCount } = await hybridBatchCounts(ids, (n) => mailManager.batchDeleteMessages(n), (im) => imapBatchDelete(im));
@@ -517,7 +517,7 @@ server.tool("batch-delete-messages", {
517
517
  }
518
518
  }, "Error batch deleting messages"));
519
519
  // --- batch-move-messages ---
520
- server.tool("batch-move-messages", {
520
+ server.tool("batch-move-messages", "Use when: moving multiple messages (1–100 ids) into the same destination mailbox/folder in one call, e.g. bulk archiving.\nReturns: counts of how many were moved and how many failed.\nDo not use when: moving just one (use move-message) or deleting (use batch-delete-messages). Use list-mailboxes to confirm the destination name exists.\nSafety: moves many real messages at once — confirm the destination mailbox, and search-messages/list-messages first to confirm the ids.", {
521
521
  ids: BATCH_IDS_SCHEMA,
522
522
  mailbox: z.string().min(1, "Destination mailbox is required"),
523
523
  account: z.string().optional().describe("Account containing the destination mailbox"),
@@ -534,7 +534,7 @@ server.tool("batch-move-messages", {
534
534
  }
535
535
  }, "Error batch moving messages"));
536
536
  // --- batch-mark-as-read ---
537
- server.tool("batch-mark-as-read", {
537
+ server.tool("batch-mark-as-read", "Use when: marking multiple messages (1–100 ids) as read in one call.\nReturns: counts of how many were marked read and how many failed.\nDo not use when: marking just one (use mark-as-read) or marking unread (use batch-mark-as-unread). Get the ids from search-messages or list-messages first.", {
538
538
  ids: BATCH_IDS_SCHEMA,
539
539
  }, withErrorHandling(async ({ ids }) => {
540
540
  const { success: successCount, fail: failCount } = await hybridBatchCounts(ids, (n) => mailManager.batchMarkAsRead(n), (im) => imapBatchMarkRead(im));
@@ -549,7 +549,7 @@ server.tool("batch-mark-as-read", {
549
549
  }
550
550
  }, "Error batch marking messages as read"));
551
551
  // --- batch-mark-as-unread ---
552
- server.tool("batch-mark-as-unread", {
552
+ server.tool("batch-mark-as-unread", "Use when: marking multiple messages (1–100 ids) as unread in one call.\nReturns: counts of how many were marked unread and how many failed.\nDo not use when: marking just one (use mark-as-unread) or marking read (use batch-mark-as-read). Get the ids from search-messages or list-messages first.", {
553
553
  ids: BATCH_IDS_SCHEMA,
554
554
  }, withErrorHandling(async ({ ids }) => {
555
555
  const { success: successCount, fail: failCount } = await hybridBatchCounts(ids, (n) => mailManager.batchMarkAsUnread(n), (im) => imapBatchMarkUnread(im));
@@ -564,7 +564,7 @@ server.tool("batch-mark-as-unread", {
564
564
  }
565
565
  }, "Error batch marking messages as unread"));
566
566
  // --- batch-flag-messages ---
567
- server.tool("batch-flag-messages", {
567
+ server.tool("batch-flag-messages", "Use when: flagging multiple messages (1–100 ids) in one call.\nReturns: counts of how many were flagged and how many failed.\nDo not use when: flagging just one (use flag-message) or removing flags (use batch-unflag-messages). Get the ids from search-messages or list-messages first.", {
568
568
  ids: BATCH_IDS_SCHEMA,
569
569
  }, withErrorHandling(async ({ ids }) => {
570
570
  const { success: successCount, fail: failCount } = await hybridBatchCounts(ids, (n) => mailManager.batchFlagMessages(n), (im) => imapBatchFlag(im));
@@ -579,7 +579,7 @@ server.tool("batch-flag-messages", {
579
579
  }
580
580
  }, "Error batch flagging messages"));
581
581
  // --- batch-unflag-messages ---
582
- server.tool("batch-unflag-messages", {
582
+ server.tool("batch-unflag-messages", "Use when: removing flags from multiple messages (1–100 ids) in one call.\nReturns: counts of how many were unflagged and how many failed.\nDo not use when: unflagging just one (use unflag-message) or adding flags (use batch-flag-messages). Get the ids from search-messages or list-messages first.", {
583
583
  ids: BATCH_IDS_SCHEMA,
584
584
  }, withErrorHandling(async ({ ids }) => {
585
585
  const { success: successCount, fail: failCount } = await hybridBatchCounts(ids, (n) => mailManager.batchUnflagMessages(n), (im) => imapBatchUnflag(im));
@@ -594,7 +594,7 @@ server.tool("batch-unflag-messages", {
594
594
  }
595
595
  }, "Error batch unflagging messages"));
596
596
  // --- list-attachments ---
597
- server.tool("list-attachments", {
597
+ server.tool("list-attachments", "Use when: enumerating a message's attachments (by id) to discover their names, MIME types, and sizes — typically before saving or fetching one.\nReturns: each attachment's name, MIME type, and size, plus a count.\nDo not use when: you want the bytes (use fetch-attachment for inline base64, or save-attachment to write to disk). Get the message id from search-messages or list-messages first.", {
598
598
  id: MESSAGE_ID_SCHEMA,
599
599
  }, withErrorHandling(async ({ id }) => {
600
600
  // IMAP (I1): BODYSTRUCTURE enumerates parts (incl. MIME attachments
@@ -620,7 +620,7 @@ server.tool("list-attachments", {
620
620
  return successResponse(`Found ${attachments.length} attachment(s):\n${attachmentList}`, structured);
621
621
  }, "Error listing attachments"));
622
622
  // --- save-attachment ---
623
- server.tool("save-attachment", {
623
+ server.tool("save-attachment", "Use when: writing one of a message's attachments to disk, by message id and attachmentName, into the savePath directory (saved as savePath/attachmentName).\nReturns: a confirmation of the saved file path.\nDo not use when: you don't know the attachment name (use list-attachments first) or want the bytes inline rather than on disk (use fetch-attachment).\nSafety: writes a file to disk — savePath must be a directory inside the configured allowed roots, and attachmentName may not contain path separators or '..'; calls outside those constraints are rejected.", {
624
624
  id: MESSAGE_ID_SCHEMA,
625
625
  attachmentName: z.string().min(1, "Attachment name is required"),
626
626
  savePath: z.string().min(1, "Save directory path is required"),
@@ -650,7 +650,7 @@ server.tool("save-attachment", {
650
650
  return successResponse(`Attachment "${attachmentName}" saved to ${savePath}`);
651
651
  }, "Error saving attachment"));
652
652
  // --- fetch-attachment ---
653
- server.tool("fetch-attachment", {
653
+ server.tool("fetch-attachment", "Use when: retrieving an attachment's raw bytes inline as base64 (by message id and attachmentName), e.g. to process its contents without touching disk.\nReturns: the attachment's bytes base64-encoded, with its size and (for IMAP) MIME type.\nDo not use when: you don't know the attachment name (use list-attachments first) or you just want it saved to disk (use save-attachment).", {
654
654
  id: MESSAGE_ID_SCHEMA,
655
655
  attachmentName: z.string().min(1, "Attachment name is required"),
656
656
  }, withErrorHandling(async ({ id, attachmentName }) => {
@@ -674,7 +674,7 @@ server.tool("fetch-attachment", {
674
674
  // Mailbox Tools
675
675
  // =============================================================================
676
676
  // --- list-mailboxes ---
677
- server.tool("list-mailboxes", {
677
+ server.tool("list-mailboxes", "Use when: discovering the mailbox/folder names (and unread/message counts) available in an account, e.g. before moving messages or searching a specific mailbox.\nReturns: each mailbox's name with its unread (and, for IMAP, total message) count, plus a count.\nDo not use when: you want the messages inside a mailbox (use list-messages or search-messages) or the list of accounts (use list-accounts).", {
678
678
  account: z.string().optional().describe("Account to list mailboxes from"),
679
679
  }, withErrorHandling(async ({ account }) => {
680
680
  // IMAP (I6): LIST + per-mailbox STATUS — sees the true server hierarchy and
@@ -703,7 +703,7 @@ server.tool("list-mailboxes", {
703
703
  return successResponse(`Found ${mailboxes.length} mailbox(es):\n${mailboxList}`, structured);
704
704
  }, "Error listing mailboxes"));
705
705
  // --- get-unread-count ---
706
- server.tool("get-unread-count", {
706
+ server.tool("get-unread-count", "Use when: you only need the number of unread messages (optionally scoped to one mailbox and/or account), without listing the messages themselves.\nReturns: the unread count for the requested scope.\nDo not use when: you need the actual unread messages and their ids (use list-messages with unreadOnly, or search-messages with isRead=false) or broader totals (use get-mail-stats).", {
707
707
  mailbox: z.string().optional().describe("Mailbox to check (default: all)"),
708
708
  account: z.string().optional().describe("Account to check"),
709
709
  }, withErrorHandling(async ({ mailbox, account }) => {
@@ -720,7 +720,7 @@ server.tool("get-unread-count", {
720
720
  });
721
721
  }, "Error getting unread count"));
722
722
  // --- create-mailbox ---
723
- server.tool("create-mailbox", {
723
+ server.tool("create-mailbox", "Use when: creating a new mailbox/folder in an account.\nReturns: a confirmation that the mailbox was created.\nDo not use when: renaming an existing one (use rename-mailbox) or deleting one (use delete-mailbox). Use list-mailboxes to see what already exists.\nSafety: creates a real folder in the mail account — confirm the name and target account first.", {
724
724
  name: z.string().min(1, "Mailbox name is required"),
725
725
  account: z.string().optional().describe("Account to create the mailbox in"),
726
726
  }, withErrorHandling(async ({ name, account }) => {
@@ -739,7 +739,7 @@ server.tool("create-mailbox", {
739
739
  return successResponse(`Mailbox "${name}" created`);
740
740
  }, "Error creating mailbox"));
741
741
  // --- delete-mailbox ---
742
- server.tool("delete-mailbox", {
742
+ server.tool("delete-mailbox", "Use when: deleting a mailbox/folder from an account.\nReturns: a confirmation that the mailbox was deleted.\nDo not use when: renaming it (use rename-mailbox) or deleting messages within it (use delete-message / batch-delete-messages).\nSafety: destructive — deleting a mailbox removes the folder and any messages it contains. Require explicit user confirmation and use list-mailboxes first to confirm the exact name.", {
743
743
  name: z.string().min(1, "Mailbox name is required"),
744
744
  account: z.string().optional().describe("Account containing the mailbox"),
745
745
  }, withErrorHandling(async ({ name, account }) => {
@@ -756,7 +756,7 @@ server.tool("delete-mailbox", {
756
756
  return successResponse(`Mailbox "${name}" deleted`);
757
757
  }, "Error deleting mailbox"));
758
758
  // --- rename-mailbox ---
759
- server.tool("rename-mailbox", {
759
+ server.tool("rename-mailbox", "Use when: renaming an existing mailbox/folder from oldName to newName within an account.\nReturns: a confirmation naming the old and new mailbox names.\nDo not use when: creating a new folder (use create-mailbox) or deleting one (use delete-mailbox). Use list-mailboxes to confirm the current name.\nSafety: renames a real folder in the mail account — confirm oldName matches exactly (case-sensitive) before calling.", {
760
760
  oldName: z.string().min(1, "Current mailbox name is required"),
761
761
  newName: z.string().min(1, "New mailbox name is required"),
762
762
  account: z.string().optional().describe("Account containing the mailbox"),
@@ -778,7 +778,7 @@ server.tool("rename-mailbox", {
778
778
  // Account Tools
779
779
  // =============================================================================
780
780
  // --- list-accounts ---
781
- server.tool("list-accounts", {}, withErrorHandling(() => {
781
+ server.tool("list-accounts", "Use when: discovering the configured Mail accounts (e.g. iCloud, Gmail) so you can pass an exact account name to other tools.\nReturns: the account names and a count.\nDo not use when: you want the folders within an account (use list-mailboxes) or messages (use list-messages / search-messages).", {}, withErrorHandling(() => {
782
782
  const accounts = mailManager.listAccounts();
783
783
  const structured = { accounts, count: accounts.length };
784
784
  if (accounts.length === 0) {
@@ -791,7 +791,7 @@ server.tool("list-accounts", {}, withErrorHandling(() => {
791
791
  // Mail Rules Tools
792
792
  // =============================================================================
793
793
  // --- list-rules ---
794
- server.tool("list-rules", {}, withErrorHandling(() => {
794
+ server.tool("list-rules", "Use when: discovering the Mail rules that exist and whether each is enabled or disabled, e.g. before enabling/disabling/deleting one.\nReturns: each rule's name and enabled/disabled state.\nDo not use when: you want to change a rule (use enable-rule / disable-rule / create-rule / delete-rule).", {}, withErrorHandling(() => {
795
795
  const rules = mailManager.listRules();
796
796
  if (rules.length === 0) {
797
797
  return successResponse("No mail rules found");
@@ -802,7 +802,7 @@ server.tool("list-rules", {}, withErrorHandling(() => {
802
802
  return successResponse(`Found ${rules.length} rule(s):\n${ruleList}`);
803
803
  }, "Error listing rules"));
804
804
  // --- enable-rule ---
805
- server.tool("enable-rule", {
805
+ server.tool("enable-rule", "Use when: turning on an existing Mail rule by name.\nReturns: a confirmation that the rule was enabled.\nDo not use when: turning a rule off (use disable-rule), creating one (use create-rule), or deleting one (use delete-rule). Use list-rules to confirm the exact rule name.", {
806
806
  name: z.string().min(1, "Rule name is required"),
807
807
  }, withErrorHandling(({ name }) => {
808
808
  const success = mailManager.setRuleEnabled(name, true);
@@ -812,7 +812,7 @@ server.tool("enable-rule", {
812
812
  return successResponse(`Rule "${name}" enabled`);
813
813
  }, "Error enabling rule"));
814
814
  // --- disable-rule ---
815
- server.tool("disable-rule", {
815
+ server.tool("disable-rule", "Use when: turning off an existing Mail rule by name (without deleting it).\nReturns: a confirmation that the rule was disabled.\nDo not use when: turning a rule on (use enable-rule), creating one (use create-rule), or removing it permanently (use delete-rule). Use list-rules to confirm the exact rule name.", {
816
816
  name: z.string().min(1, "Rule name is required"),
817
817
  }, withErrorHandling(({ name }) => {
818
818
  const success = mailManager.setRuleEnabled(name, false);
@@ -822,7 +822,7 @@ server.tool("disable-rule", {
822
822
  return successResponse(`Rule "${name}" disabled`);
823
823
  }, "Error disabling rule"));
824
824
  // --- create-rule ---
825
- server.tool("create-rule", {
825
+ server.tool("create-rule", "Use when: creating a new Mail rule with one or more conditions (field/operator/value) and at least one action (markRead, markFlagged, delete, or moveTo). Set matchAll to require all conditions vs. any.\nReturns: a confirmation naming the rule and its condition count.\nDo not use when: toggling an existing rule (use enable-rule / disable-rule) or removing one (use delete-rule). Use list-rules to avoid duplicating an existing rule.\nSafety: creates a rule that automatically acts on real mail (including delete/move actions) on an ongoing basis — confirm the conditions and actions with the user before calling.", {
826
826
  name: z.string().min(1, "Rule name is required"),
827
827
  conditions: z
828
828
  .array(z.object({
@@ -852,7 +852,7 @@ server.tool("create-rule", {
852
852
  return successResponse(`Rule "${args.name}" created with ${args.conditions.length} condition(s).`, { name: args.name, created: true });
853
853
  }, "Error creating rule"));
854
854
  // --- delete-rule ---
855
- server.tool("delete-rule", {
855
+ server.tool("delete-rule", "Use when: permanently removing a Mail rule by name.\nReturns: a confirmation that the rule was deleted.\nDo not use when: you only want to pause it (use disable-rule) or create one (use create-rule).\nSafety: destructive — the rule is removed permanently. Require explicit user confirmation and use list-rules first to confirm the exact name.", {
856
856
  name: z.string().min(1, "Rule name is required"),
857
857
  }, withErrorHandling(({ name }) => {
858
858
  const success = mailManager.deleteRule(name);
@@ -865,7 +865,7 @@ server.tool("delete-rule", {
865
865
  // Contacts Tools
866
866
  // =============================================================================
867
867
  // --- search-contacts ---
868
- server.tool("search-contacts", {
868
+ server.tool("search-contacts", "Use when: looking up a person in Contacts.app by name to find their email address(es) before composing or sending mail.\nReturns: matching contacts with their names and email addresses.\nDo not use when: searching email messages (use search-messages) — this queries Contacts, not the mailbox.", {
869
869
  query: z.string().min(1, "Search query is required"),
870
870
  }, withErrorHandling(({ query }) => {
871
871
  const contacts = mailManager.searchContacts(query);
@@ -884,7 +884,7 @@ server.tool("search-contacts", {
884
884
  // Email Template Tools
885
885
  // =============================================================================
886
886
  // --- save-template ---
887
- server.tool("save-template", {
887
+ server.tool("save-template", "Use when: creating a reusable email template (name, subject, body, optional default to/cc), or updating one by passing its existing id. Subject/body may contain placeholders for later use.\nReturns: the saved template's name and id (reuse the id with use-template / get-template / delete-template).\nDo not use when: composing a one-off message (use create-draft / send-email) or filling in a template to send (use use-template).\nSafety: writes the template to the on-disk templates store (APPLE_MAIL_MCP_TEMPLATES_FILE) and persists across restarts; passing an existing id overwrites that template.", {
888
888
  name: z.string().min(1, "Template name is required"),
889
889
  subject: z.string().min(1, "Subject is required"),
890
890
  body: z.string().min(1, "Body is required"),
@@ -896,7 +896,7 @@ server.tool("save-template", {
896
896
  return successResponse(`Template "${template.name}" saved with ID: ${template.id}`);
897
897
  }, "Error saving template"));
898
898
  // --- list-templates ---
899
- server.tool("list-templates", {}, withErrorHandling(() => {
899
+ server.tool("list-templates", "Use when: discovering the saved email templates and their ids, e.g. before using or editing one.\nReturns: each template's id, name, and subject.\nDo not use when: you want a single template's full body (use get-template) or want to apply one (use use-template).", {}, withErrorHandling(() => {
900
900
  const templates = mailManager.listTemplates();
901
901
  if (templates.length === 0) {
902
902
  return successResponse("No templates saved");
@@ -907,7 +907,7 @@ server.tool("list-templates", {}, withErrorHandling(() => {
907
907
  return successResponse(`Found ${templates.length} template(s):\n${templateList}`);
908
908
  }, "Error listing templates"));
909
909
  // --- get-template ---
910
- server.tool("get-template", {
910
+ server.tool("get-template", "Use when: reading the full contents of one saved template by id — its name, subject, default to/cc, and body.\nReturns: the template's name, subject, default recipients, and body text.\nDo not use when: you don't have the id (use list-templates first) or want to apply the template into a draft (use use-template).", {
911
911
  id: z.string().min(1, "Template ID is required"),
912
912
  }, withErrorHandling(({ id }) => {
913
913
  const template = mailManager.getTemplate(id);
@@ -926,7 +926,7 @@ server.tool("get-template", {
926
926
  return successResponse(lines);
927
927
  }, "Error getting template"));
928
928
  // --- delete-template ---
929
- server.tool("delete-template", {
929
+ server.tool("delete-template", "Use when: permanently removing a saved email template by id.\nReturns: a confirmation that the template was deleted.\nDo not use when: you only want to view it (use get-template) or update it (use save-template with the existing id).\nSafety: destructive — removes the template from the on-disk store permanently. Require explicit user confirmation and use list-templates first to confirm the id.", {
930
930
  id: z.string().min(1, "Template ID is required"),
931
931
  }, withErrorHandling(({ id }) => {
932
932
  const success = mailManager.deleteTemplate(id);
@@ -936,7 +936,7 @@ server.tool("delete-template", {
936
936
  return successResponse(`Template "${id}" deleted`);
937
937
  }, "Error deleting template"));
938
938
  // --- use-template ---
939
- server.tool("use-template", {
939
+ server.tool("use-template", "Use when: composing a new draft from a saved template (by id), optionally overriding the recipients, subject, or body. Creates a draft in Mail.app for the user to review and send.\nReturns: a confirmation that a draft was created from the template.\nDo not use when: you want to inspect the template without composing (use get-template) or send immediately without a draft (use send-email).", {
940
940
  id: z.string().min(1, "Template ID is required"),
941
941
  to: z.array(z.string()).optional().describe("Override recipients"),
942
942
  cc: z.array(z.string()).optional().describe("Override CC recipients"),
@@ -953,7 +953,7 @@ server.tool("use-template", {
953
953
  // Diagnostics Tools
954
954
  // =============================================================================
955
955
  // --- health-check ---
956
- server.tool("health-check", {}, withErrorHandling(() => {
956
+ server.tool("health-check", "Use when: doing a quick check that Mail.app is reachable and the server's basic checks pass.\nReturns: an overall healthy/unhealthy status with a pass/fail line per check.\nDo not use when: you need detailed permission/account/IMAP/SMTP diagnostics with remediation steps (use doctor).", {}, withErrorHandling(() => {
957
957
  const result = mailManager.healthCheck();
958
958
  const statusIcon = result.healthy ? "✓" : "✗";
959
959
  const statusText = result.healthy ? "All checks passed" : "Issues detected";
@@ -966,14 +966,14 @@ server.tool("health-check", {}, withErrorHandling(() => {
966
966
  return successResponse(`${statusIcon} ${statusText}\n\n${checkLines}`);
967
967
  }, "Error running health check"));
968
968
  // --- doctor ---
969
- server.tool("doctor", {}, withErrorHandling(async () => {
969
+ server.tool("doctor", "Use when: troubleshooting setup problems — diagnoses Mail.app automation permissions, account state, and the IMAP/SMTP backends with actionable remediation messages.\nReturns: a detailed diagnostic report (formatted text plus structured checks).\nDo not use when: you just want a quick up/down status (use health-check) or message counts (use get-mail-stats).", {}, withErrorHandling(async () => {
970
970
  // Diagnoses Mail.app permission, account state, and the IMAP/SMTP backends
971
971
  // with actionable messages (C3). structuredContent carries the raw checks.
972
972
  const report = await runDoctor(mailManager);
973
973
  return successResponse(formatDoctorReport(report), { ...report });
974
974
  }, "Error running doctor"));
975
975
  // --- get-mail-stats ---
976
- server.tool("get-mail-stats", {
976
+ server.tool("get-mail-stats", "Use when: you want aggregate mailbox statistics — total and unread message counts, recently-received counts (last 24h/7d/30d), and (for the all-accounts path) a per-account breakdown.\nReturns: totals, unread counts, recent-activity counts, and per-account figures.\nDo not use when: you only need a single unread number (use get-unread-count) or want to list the messages themselves (use list-messages / search-messages).", {
977
977
  account: z
978
978
  .string()
979
979
  .optional()
@@ -1019,7 +1019,7 @@ server.tool("get-mail-stats", {
1019
1019
  return successResponse(lines.join("\n"), { ...stats });
1020
1020
  }, "Error getting mail statistics"));
1021
1021
  // --- get-sync-status ---
1022
- server.tool("get-sync-status", {}, withErrorHandling(() => {
1022
+ server.tool("get-sync-status", "Use when: checking whether Mail.app is running and actively syncing, e.g. to explain why new mail hasn't appeared yet.\nReturns: whether Mail.app is running and whether sync activity was detected.\nDo not use when: you need message counts (use get-mail-stats) or a full setup diagnosis (use doctor).", {}, withErrorHandling(() => {
1023
1023
  const status = mailManager.getSyncStatus();
1024
1024
  const lines = [];
1025
1025
  lines.push(`🔄 Mail Sync Status`);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "apple-mail-mcp",
3
- "version": "2.1.1",
4
- "description": "MCP server for Apple Mail - read, search, send, and manage emails via Claude",
3
+ "version": "2.1.3",
4
+ "description": "MCP server for Apple Mail - read, search, send, and manage emails via Claude and other AI assistants",
5
5
  "type": "module",
6
6
  "main": "build/index.js",
7
7
  "types": "build/index.d.ts",
@@ -28,14 +28,15 @@
28
28
  "format": "prettier --write src",
29
29
  "format:check": "prettier --check src",
30
30
  "typecheck": "tsc --noEmit",
31
- "version": "node -e \"const p=require('./package.json'); const f='.claude-plugin/plugin.json'; const c=JSON.parse(require('fs').readFileSync(f,'utf8')); c.version=p.version; require('fs').writeFileSync(f,JSON.stringify(c,null,2)+'\\n')\" && git add .claude-plugin/plugin.json",
31
+ "version": "node scripts/sync-plugin-version.mjs && git add .claude-plugin .agents/plugins codex .hermes-plugin .antigravity-plugin",
32
32
  "prepublishOnly": "npm run lint && npm run test && npm run build",
33
- "prepare": "husky"
33
+ "prepare": "husky; npm run build"
34
34
  },
35
35
  "keywords": [
36
36
  "mcp",
37
37
  "apple-mail",
38
38
  "claude",
39
+ "codex",
39
40
  "ai",
40
41
  "applescript",
41
42
  "macos",
@@ -82,7 +83,7 @@
82
83
  "vitest": "^4.1.9"
83
84
  },
84
85
  "volta": {
85
- "node": "22.13.1"
86
+ "node": "24.17.0"
86
87
  },
87
88
  "lint-staged": {
88
89
  "src/**/*.ts": [