apple-mail-mcp 2.10.13 → 2.10.15

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
@@ -579,7 +579,7 @@ Dropped connections reconnect with backoff, and the watchers shut down cleanly o
579
579
 
580
580
  Enable it in your MCP client config alongside the IMAP settings:
581
581
 
582
- ```jsonc
582
+ ```json
583
583
  {
584
584
  "mcpServers": {
585
585
  "apple-mail": {
@@ -680,6 +680,20 @@ Return an attachment's bytes as base64 (the read counterpart to inline-base64 se
680
680
 
681
681
  ---
682
682
 
683
+ #### `resolve-message-id`
684
+
685
+ Map `imap:` message IDs to their numeric Mail.app IDs, via each message's RFC 5322 `Message-ID` (the join key both backends share). Needed only for the two tools that are numeric-ID-only — `reply-to-message` and `forward-message`. Numeric IDs pass through unchanged.
686
+
687
+ | Parameter | Type | Required | Description |
688
+ |-----------|------|----------|-------------|
689
+ | `ids` | string[] | Yes | 1–100 message IDs, each numeric or `imap:…` |
690
+
691
+ **Returns:** For each input ID, its `numericId` (or `null` when it can't be resolved) and the `messageId` used, plus `count` and `resolvedCount`. The lookup scopes to the message's account and checks its INBOX first, to avoid scanning a large All Mail/Archive mailbox.
692
+
693
+ > **You do not need this for flag colors (v2.10.0+).** Colors used to require the numeric-ID path, and older docs and tool descriptions said so. `flag-message` and `batch-flag-messages` now write the color over IMAP directly, as Mail.app's `$MailFlagBit0/1/2` keywords, so a smart mailbox keyed on flag color matches an IMAP-flagged message. Resolving IDs just to apply a color reintroduces the AppleScript/TCC dependency 2.10.0 removed. Flag, move, mark, and delete all accept `imap:` IDs as-is.
694
+
695
+ ---
696
+
683
697
  #### `reply-to-message`
684
698
 
685
699
  Reply to an existing message.
@@ -815,6 +829,15 @@ Save a message attachment to disk.
815
829
 
816
830
  All batch operations accept an array of message IDs (max 100 per batch) and return per-item success/failure results.
817
831
 
832
+ **Numeric IDs are scoped to the mailbox you listed them from.** Mail.app numbers messages per
833
+ mailbox, so on a label store (Gmail, iCloud) one message answers to the same id in `INBOX`,
834
+ `Important` and `All Mail` at once — and deleting the `All Mail` copy is not the same operation as
835
+ deleting the `INBOX` copy. Each id is therefore bound to the mailbox it was listed/searched from and
836
+ the operation is applied only there, so **list or search the mailbox immediately before acting on
837
+ it**. An id the server hasn't seen listed is accepted only when exactly one mailbox holds it;
838
+ if several do, that id fails with the candidate mailboxes named instead of being applied to an
839
+ arbitrary copy. `imap:…` ids carry their own account + mailbox + UID and are never ambiguous.
840
+
818
841
  #### `batch-delete-messages`
819
842
 
820
843
  | Parameter | Type | Required | Description |
@@ -1309,7 +1332,7 @@ This repo ships a `.mcp.json` at its root so that, when you run `claude` from in
1309
1332
 
1310
1333
  The entrypoint is written as:
1311
1334
 
1312
- ```json
1335
+ ```text
1313
1336
  "args": ["${CLAUDE_PROJECT_DIR:-.}/build/index.js"]
1314
1337
  ```
1315
1338
 
@@ -1424,6 +1447,15 @@ In a JSON string literal, `\\` — two characters — denotes **one** literal ba
1424
1447
  - Message IDs change if the message is moved between mailboxes
1425
1448
  - Use `search-messages` to find the current message ID
1426
1449
 
1450
+ ### "... is present in more than one mailbox"
1451
+ - A bare numeric ID identifies a message only *within a mailbox*, and a label store (Gmail, iCloud)
1452
+ reports the same message under the same ID in `INBOX`, `Important` and `All Mail` at once. The
1453
+ server refuses rather than guessing which copy you meant.
1454
+ - Fix it by running `list-messages`/`search-messages` on the mailbox you actually want to act on,
1455
+ then using the IDs from that result — the operation is then scoped to that mailbox.
1456
+ - It only affects IDs the server hasn't seen listed (carried over from an earlier session, or typed
1457
+ by hand). `imap:…` IDs encode their own mailbox and never hit this.
1458
+
1427
1459
  ### `search-messages` says "Partial results" or skips a mailbox
1428
1460
  - This is expected for very large IMAP/Gmail mailboxes (e.g. Gmail's `All Mail`, `Important`): Apple Mail can't scan them via AppleScript before timing out, so they're skipped and named in the result rather than silently returning empty.
1429
1461
  - To search inside one, scope the call with `mailbox` **and** a `dateFrom`/`dateTo` window.
package/build/index.js CHANGED
@@ -78237,6 +78237,8 @@ var CONTENT_MARKER = "CONTENT";
78237
78237
  var MSGID_MARKER = "MSGID";
78238
78238
  var HTML_MARKER = "HTML";
78239
78239
  var BATCH_FATAL = "FATAL";
78240
+ var AMBIGUOUS_ID_PREFIX = "Message id ";
78241
+ var AMBIGUOUS_ID_BATCH = "This message id is present in more than one mailbox ";
78240
78242
  function normalizeRfcMessageId(mid) {
78241
78243
  return (mid || "").trim().replace(/^<+/, "").replace(/>+$/, "").trim();
78242
78244
  }
@@ -78575,6 +78577,32 @@ var AppleMailManager = class {
78575
78577
  if (oldest !== void 0) this.idLocationIndex.delete(oldest);
78576
78578
  }
78577
78579
  }
78580
+ /** Where a message id was last listed/searched from, if we've seen it. */
78581
+ locationFor(id) {
78582
+ return this.idLocationIndex.get(String(id));
78583
+ }
78584
+ /**
78585
+ * AppleScript fragment resolving `account` + `mailbox` into `_tmb`, leaving
78586
+ * `_tmb` as `missing value` when it can't be pinned down. Exact-name match
78587
+ * only, and a name matching more than one mailbox resolves to nothing rather
78588
+ * than guessing — the same rule the move destination already applies.
78589
+ */
78590
+ resolveMailboxFragment(account, mailbox) {
78591
+ const resolved = this.resolveMailbox(mailbox, account);
78592
+ return `
78593
+ set _tmb to missing value
78594
+ set _acctM to {}
78595
+ repeat with _a in accounts
78596
+ if (name of _a) is "${escapeForAppleScript(account)}" then set end of _acctM to _a
78597
+ end repeat
78598
+ if (count of _acctM) is 1 then
78599
+ set _mbM to {}
78600
+ repeat with _m in (mailboxes of (item 1 of _acctM))
78601
+ if (name of _m) is "${escapeForAppleScript(resolved)}" then set end of _mbM to _m
78602
+ end repeat
78603
+ if (count of _mbM) is 1 then set _tmb to item 1 of _mbM
78604
+ end if`;
78605
+ }
78578
78606
  /**
78579
78607
  * Returns cached accounts or fetches fresh data if cache is expired/empty.
78580
78608
  */
@@ -79781,24 +79809,61 @@ var AppleMailManager = class {
79781
79809
  return true;
79782
79810
  }
79783
79811
  /**
79784
- * Helper to find and operate on a message by ID.
79812
+ * Helper to find and operate on a message by ID, scoped to the mailbox the id
79813
+ * was listed from.
79814
+ *
79815
+ * Mail.app numeric ids are per-mailbox, and on a label store (Gmail, iCloud)
79816
+ * ONE message is present in several mailboxes under the SAME id — INBOX,
79817
+ * "Important" and "All Mail" all report id 75816 for the same mail. This used
79818
+ * to walk every account's every mailbox and mutate the FIRST hit; because
79819
+ * `mailboxes of account` yields INBOX late, an id listed from INBOX was
79820
+ * reliably mutated in "Important" instead, so the op reported success while
79821
+ * the INBOX copy stayed put and a different copy was moved/deleted (#152).
79822
+ *
79823
+ * Which mailbox a mutation lands in is semantic — deleting the INBOX copy and
79824
+ * deleting the "All Mail" copy are different operations — so scope to the
79825
+ * mailbox the id actually came from (`idLocationIndex`, populated by every
79826
+ * list/search) and never guess.
79785
79827
  */
79786
79828
  findMessageScript(id, operation) {
79829
+ const loc = this.locationFor(id);
79830
+ if (loc) {
79831
+ return buildAppLevelScript(`
79832
+ try
79833
+ ${this.resolveMailboxFragment(loc.account, loc.mailbox)}
79834
+ if _tmb is missing value then return "error:Message not found"
79835
+ set matchingMsgs to (messages of _tmb whose id is ${Number(id)})
79836
+ if (count of matchingMsgs) > 0 then
79837
+ set msg to item 1 of matchingMsgs
79838
+ ${operation}
79839
+ return "ok"
79840
+ end if
79841
+ return "error:Message not found"
79842
+ on error errMsg
79843
+ return "error:" & errMsg
79844
+ end try
79845
+ `);
79846
+ }
79787
79847
  return buildAppLevelScript(`
79788
79848
  try
79849
+ set _hits to {}
79850
+ set _names to ""
79789
79851
  repeat with acct in accounts
79790
79852
  repeat with mb in mailboxes of acct
79791
79853
  try
79792
79854
  set matchingMsgs to (messages of mb whose id is ${Number(id)})
79793
79855
  if (count of matchingMsgs) > 0 then
79794
- set msg to item 1 of matchingMsgs
79795
- ${operation}
79796
- return "ok"
79856
+ set end of _hits to (item 1 of matchingMsgs)
79857
+ set _names to _names & (name of acct) & "/" & (name of mb) & ", "
79797
79858
  end if
79798
79859
  end try
79799
79860
  end repeat
79800
79861
  end repeat
79801
- return "error:Message not found"
79862
+ if (count of _hits) is 0 then return "error:Message not found"
79863
+ if (count of _hits) > 1 then return "error:${AMBIGUOUS_ID_PREFIX}${Number(id)} is present in more than one mailbox (" & _names & "); list or search that mailbox first so the operation targets the right copy"
79864
+ set msg to item 1 of _hits
79865
+ ${operation}
79866
+ return "ok"
79802
79867
  on error errMsg
79803
79868
  return "error:" & errMsg
79804
79869
  end try
@@ -79983,6 +80048,31 @@ var AppleMailManager = class {
79983
80048
  const targetMailbox = this.resolveMailbox(mailbox, targetAccount);
79984
80049
  const safeMailbox = escapeForAppleScript(targetMailbox);
79985
80050
  const safeAccount = escapeForAppleScript(targetAccount);
80051
+ const loc = this.locationFor(id);
80052
+ const findAndMove = loc ? `
80053
+ ${this.resolveMailboxFragment(loc.account, loc.mailbox)}
80054
+ if _tmb is missing value then return "error:Message not found"
80055
+ set matchingMsgs to (messages of _tmb whose id is ${Number(id)})
80056
+ if (count of matchingMsgs) is 0 then return "error:Message not found"
80057
+ move (item 1 of matchingMsgs) to destMailbox
80058
+ return "ok"` : `
80059
+ set _hits to {}
80060
+ set _names to ""
80061
+ repeat with acct in accounts
80062
+ repeat with mb in (mailboxes of acct)
80063
+ try
80064
+ set matchingMsgs to (messages of mb whose id is ${Number(id)})
80065
+ if (count of matchingMsgs) > 0 then
80066
+ set end of _hits to (item 1 of matchingMsgs)
80067
+ set _names to _names & (name of acct) & "/" & (name of mb) & ", "
80068
+ end if
80069
+ end try
80070
+ end repeat
80071
+ end repeat
80072
+ if (count of _hits) is 0 then return "error:Message not found"
80073
+ if (count of _hits) > 1 then return "error:${AMBIGUOUS_ID_PREFIX}${Number(id)} is present in more than one mailbox (" & _names & "); list or search that mailbox first so the move targets the right copy"
80074
+ move (item 1 of _hits) to destMailbox
80075
+ return "ok"`;
79986
80076
  const script = buildAppLevelScript(`
79987
80077
  try
79988
80078
  -- \`mailboxes of account\` is already flat: it includes nested mailboxes
@@ -79998,21 +80088,7 @@ var AppleMailManager = class {
79998
80088
  if (count of destMatches) is 0 then return "error:Destination mailbox \\"" & destName & "\\" not found in account \\"${safeAccount}\\""
79999
80089
  if (count of destMatches) > 1 then return "error:Destination mailbox \\"" & destName & "\\" is ambiguous (" & (count of destMatches) & " matches) in account \\"${safeAccount}\\"; disambiguate or move by full path"
80000
80090
  set destMailbox to item 1 of destMatches
80001
-
80002
- -- Find the message by id. The flat mailbox list already covers nested
80003
- -- mailboxes, so this reaches messages in subfolders without recursing.
80004
- repeat with acct in accounts
80005
- repeat with mb in (mailboxes of acct)
80006
- try
80007
- set matchingMsgs to (messages of mb whose id is ${Number(id)})
80008
- if (count of matchingMsgs) > 0 then
80009
- move (item 1 of matchingMsgs) to destMailbox
80010
- return "ok"
80011
- end if
80012
- end try
80013
- end repeat
80014
- end repeat
80015
- return "error:Message not found"
80091
+ ${findAndMove}
80016
80092
  on error errMsg
80017
80093
  return "error:" & errMsg
80018
80094
  end try
@@ -80046,15 +80122,26 @@ var AppleMailManager = class {
80046
80122
  * Previously each batch method looped and called the per-id method, so a
80047
80123
  * 100-id batch spawned 100 osascript processes — each one re-resolving
80048
80124
  * accounts and walking the whole account→mailbox tree — all serialized
80049
- * through the gate (issue #31). This walks the tree exactly once: for each
80050
- * mailbox it probes the still-pending IDs with `whose id is` (indexed, so
80051
- * effectively free) and applies `operation` to any match, tracking found IDs
80052
- * so it can stop early once all are accounted for. Per-id outcomes come back
80053
- * as control-char-delimited `id<FS>status` records (status: `ok`,
80054
- * `notfound`, or `error:<msg>`), and results are returned in input order.
80125
+ * through the gate (issue #31). Still one osascript invocation, but the ids
80126
+ * are now grouped by the mailbox they were listed from and each group opens
80127
+ * exactly that one mailbox. Per-id outcomes come back as control-char
80128
+ * delimited `position<FS>status` records (status: `ok`, `notfound`, or
80129
+ * `error:<msg>`), and results are returned in input order.
80130
+ *
80131
+ * Scoping is a CORRECTNESS requirement, not an optimization (#152). A Mail.app
80132
+ * numeric id is unique only within a mailbox, and a label store (Gmail,
80133
+ * iCloud) exposes one message in several mailboxes under the same id — INBOX,
80134
+ * "Important" and "All Mail" all report id 75816 for the same mail. The old
80135
+ * tree walk applied `operation` to the FIRST mailbox that matched, and
80136
+ * `mailboxes of account` yields INBOX late, so a batch of ids listed from
80137
+ * INBOX was reliably applied to the "Important" copies instead: every id
80138
+ * reported `ok` while the INBOX messages stayed put and other copies were
80139
+ * moved/deleted. Grouping by recorded source mailbox makes the op land on the
80140
+ * copy the caller actually listed; ids with no recorded mailbox are refused
80141
+ * when ambiguous rather than applied to an arbitrary copy.
80055
80142
  *
80056
- * `setup` runs once before the walk (used by move to resolve the destination);
80057
- * it may bail the whole batch by returning a `BATCH_FATAL`-prefixed string.
80143
+ * `setup` runs once up front (used by move to resolve the destination); it may
80144
+ * bail the whole batch by returning a `BATCH_FATAL`-prefixed string.
80058
80145
  */
80059
80146
  runBatchOperation(ids, operation, setup = "") {
80060
80147
  const valid = [];
@@ -80065,39 +80152,96 @@ var AppleMailManager = class {
80065
80152
  if (valid.length === 0) {
80066
80153
  return ids.map((id) => ({ id, success: false, error: "Invalid message ID" }));
80067
80154
  }
80068
- const script = buildAppLevelScript(`
80069
- try
80070
- ${setup}
80071
- set _out to ""
80072
- set _done to {}
80073
- set _ids to {${valid.map((v) => v.num).join(", ")}}
80074
- set _total to count of _ids
80155
+ const groups = /* @__PURE__ */ new Map();
80156
+ const unlocated = [];
80157
+ valid.forEach((v, i) => {
80158
+ const pos = i + 1;
80159
+ const loc = this.locationFor(v.id);
80160
+ if (!loc) {
80161
+ unlocated.push({ num: v.num, pos });
80162
+ return;
80163
+ }
80164
+ const key = `${loc.account}\0${loc.mailbox}`;
80165
+ const g = groups.get(key) ?? { account: loc.account, mailbox: loc.mailbox, items: [] };
80166
+ g.items.push({ num: v.num, pos });
80167
+ groups.set(key, g);
80168
+ });
80169
+ const asList = (nums) => `{${nums.join(", ")}}`;
80170
+ const scopedBlocks = [...groups.values()].map(
80171
+ (g) => `
80172
+ ${this.resolveMailboxFragment(g.account, g.mailbox)}
80173
+ set _gids to ${asList(g.items.map((it) => it.num))}
80174
+ set _gpos to ${asList(g.items.map((it) => it.pos))}
80175
+ if _tmb is missing value then
80176
+ repeat with _k from 1 to (count of _gpos)
80177
+ set _out to _out & ((item _k of _gpos) as string) & "${FIELD_SEP}error:source mailbox \\"${escapeForAppleScript(g.mailbox)}\\" not found in account \\"${escapeForAppleScript(g.account)}\\"${RECORD_SEP}"
80178
+ end repeat
80179
+ else
80180
+ repeat with _k from 1 to (count of _gids)
80181
+ set _idx to item _k of _gpos
80182
+ try
80183
+ set _m to (messages of _tmb whose id is (item _k of _gids))
80184
+ if (count of _m) > 0 then
80185
+ set _msg to item 1 of _m
80186
+ ${operation}
80187
+ set _out to _out & (_idx as string) & "${FIELD_SEP}ok${RECORD_SEP}"
80188
+ else
80189
+ set _out to _out & (_idx as string) & "${FIELD_SEP}notfound${RECORD_SEP}"
80190
+ end if
80191
+ on error _e
80192
+ set _out to _out & (_idx as string) & "${FIELD_SEP}error:" & _e & "${RECORD_SEP}"
80193
+ end try
80194
+ end repeat
80195
+ end if`
80196
+ ).join("\n");
80197
+ const unlocatedBlock = unlocated.length ? `
80198
+ set _uids to ${asList(unlocated.map((it) => it.num))}
80199
+ set _upos to ${asList(unlocated.map((it) => it.pos))}
80200
+ set _ucount to count of _uids
80201
+ set _uhit to {}
80202
+ set _umsg to {}
80203
+ set _unames to {}
80204
+ repeat with _k from 1 to _ucount
80205
+ set end of _uhit to 0
80206
+ set end of _umsg to missing value
80207
+ set end of _unames to ""
80208
+ end repeat
80075
80209
  repeat with acct in accounts
80076
- if (count of _done) is _total then exit repeat
80077
80210
  repeat with mb in (mailboxes of acct)
80078
- if (count of _done) is _total then exit repeat
80079
- repeat with _idx from 1 to _total
80080
- if _idx is not in _done then
80081
- set _theId to item _idx of _ids
80082
- try
80083
- set _m to (messages of mb whose id is _theId)
80084
- if (count of _m) > 0 then
80085
- set _msg to item 1 of _m
80086
- ${operation}
80087
- set end of _done to _idx
80088
- set _out to _out & (_idx as string) & "${FIELD_SEP}ok${RECORD_SEP}"
80089
- end if
80090
- on error _e
80091
- set end of _done to _idx
80092
- set _out to _out & (_idx as string) & "${FIELD_SEP}error:" & _e & "${RECORD_SEP}"
80093
- end try
80094
- end if
80211
+ repeat with _k from 1 to _ucount
80212
+ try
80213
+ set _m to (messages of mb whose id is (item _k of _uids))
80214
+ if (count of _m) > 0 then
80215
+ set item _k of _uhit to ((item _k of _uhit) + 1)
80216
+ if (item _k of _uhit) is 1 then set item _k of _umsg to (item 1 of _m)
80217
+ set item _k of _unames to ((item _k of _unames) & (name of acct) & "/" & (name of mb) & ", ")
80218
+ end if
80219
+ end try
80095
80220
  end repeat
80096
80221
  end repeat
80097
80222
  end repeat
80098
- repeat with _idx from 1 to _total
80099
- if _idx is not in _done then set _out to _out & (_idx as string) & "${FIELD_SEP}notfound${RECORD_SEP}"
80100
- end repeat
80223
+ repeat with _k from 1 to _ucount
80224
+ set _idx to item _k of _upos
80225
+ if (item _k of _uhit) is 0 then
80226
+ set _out to _out & (_idx as string) & "${FIELD_SEP}notfound${RECORD_SEP}"
80227
+ else if (item _k of _uhit) > 1 then
80228
+ set _out to _out & (_idx as string) & "${FIELD_SEP}error:${AMBIGUOUS_ID_BATCH}(" & (item _k of _unames) & "); list or search that mailbox first so the operation targets the right copy${RECORD_SEP}"
80229
+ else
80230
+ try
80231
+ set _msg to item _k of _umsg
80232
+ ${operation}
80233
+ set _out to _out & (_idx as string) & "${FIELD_SEP}ok${RECORD_SEP}"
80234
+ on error _e
80235
+ set _out to _out & (_idx as string) & "${FIELD_SEP}error:" & _e & "${RECORD_SEP}"
80236
+ end try
80237
+ end if
80238
+ end repeat` : "";
80239
+ const script = buildAppLevelScript(`
80240
+ try
80241
+ ${setup}
80242
+ set _out to ""
80243
+ ${scopedBlocks}
80244
+ ${unlocatedBlock}
80101
80245
  return _out
80102
80246
  on error errMsg
80103
80247
  return "${BATCH_FATAL}" & errMsg
@@ -111,7 +111,7 @@ The server reads `APPLE_MAIL_MCP_*` settings. There are two ways to supply them;
111
111
  Works with clients that pass an `env` block through to the server (e.g. **Claude
112
112
  Code** via `~/.claude.json`, and most standard `mcpServers` configs).
113
113
 
114
- ```jsonc
114
+ ```json
115
115
  {
116
116
  "mcpServers": {
117
117
  "apple-mail": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-mail-mcp",
3
- "version": "2.10.13",
3
+ "version": "2.10.15",
4
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",