apple-mail-mcp 2.10.14 → 2.10.16

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 +39 -0
  2. package/build/index.js +426 -186
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -829,11 +829,35 @@ Save a message attachment to disk.
829
829
 
830
830
  All batch operations accept an array of message IDs (max 100 per batch) and return per-item success/failure results.
831
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
+
841
+ **Say which mailbox with `sourceMailbox` / `sourceAccount`.** The binding above is remembered
842
+ per running server, so a client that reconnects, restarts, or replays a saved list of ids has
843
+ nothing recorded and every id takes the slower whole-tree path — where, on a label store, it is
844
+ likely to be refused as ambiguous. Passing the source mailbox explicitly is the reliable way to
845
+ stay scoped, and it overrides the remembered location. These parameters name where the ids **came
846
+ from**; for `batch-move-messages` that is distinct from `mailbox`, the destination.
847
+
848
+ `sourceMailbox` is the one that scopes, and it works on its own: omitting `sourceAccount` means
849
+ that mailbox **in the default account**, exactly as an omitted `account` does elsewhere. If the
850
+ default account cannot be determined, the ids fail with an error asking for an explicit
851
+ `sourceAccount` — a scope the server can't honor is never quietly downgraded to the guess-the-copy
852
+ walk. `sourceAccount` by itself pins nothing, since the mailbox is what an id is scoped to.
853
+
832
854
  #### `batch-delete-messages`
833
855
 
834
856
  | Parameter | Type | Required | Description |
835
857
  |-----------|------|----------|-------------|
836
858
  | `ids` | string[] | Yes | Message IDs to delete (max 100) |
859
+ | `sourceMailbox` | string | No | Mailbox the **numeric** ids were listed from — pins them to it. Ignored for `imap:` ids. |
860
+ | `sourceAccount` | string | No | Account the numeric ids were listed from. Defaults to the default account; on its own it pins nothing — pair it with `sourceMailbox`. |
837
861
 
838
862
  **⚠️ Safety:** Destructive. Requires explicit user confirmation; search/list first to confirm the message ids.
839
863
 
@@ -844,12 +868,16 @@ All batch operations accept an array of message IDs (max 100 per batch) and retu
844
868
  | `ids` | string[] | Yes | Message IDs to move (max 100) |
845
869
  | `mailbox` | string | Yes | Destination mailbox |
846
870
  | `account` | string | No | Account containing mailbox |
871
+ | `sourceMailbox` | string | No | Mailbox the **numeric** ids were listed from — pins them to it. Ignored for `imap:` ids. |
872
+ | `sourceAccount` | string | No | Account the numeric ids were listed from. Defaults to the default account; on its own it pins nothing — pair it with `sourceMailbox`. |
847
873
 
848
874
  #### `batch-mark-as-read` / `batch-mark-as-unread`
849
875
 
850
876
  | Parameter | Type | Required | Description |
851
877
  |-----------|------|----------|-------------|
852
878
  | `ids` | string[] | Yes | Message IDs (max 100) |
879
+ | `sourceMailbox` | string | No | Mailbox the **numeric** ids were listed from — pins them to it. Ignored for `imap:` ids. |
880
+ | `sourceAccount` | string | No | Account the numeric ids were listed from. Defaults to the default account; on its own it pins nothing — pair it with `sourceMailbox`. |
853
881
 
854
882
  #### `batch-flag-messages` / `batch-unflag-messages`
855
883
 
@@ -857,6 +885,8 @@ All batch operations accept an array of message IDs (max 100 per batch) and retu
857
885
  |-----------|------|----------|-------------|
858
886
  | `ids` | string[] | Yes | Message IDs (max 100) |
859
887
  | `color` | string | No | (`batch-flag-messages` only) Flag color — see [`flag-message`](#flag-message--unflag-message). Applied on both routes, so a mixed batch of numeric and `imap:` ids all end up colored. |
888
+ | `sourceMailbox` | string | No | Mailbox the **numeric** ids were listed from — pins them to it. Ignored for `imap:` ids. |
889
+ | `sourceAccount` | string | No | Account the numeric ids were listed from. Defaults to the default account; on its own it pins nothing — pair it with `sourceMailbox`. |
860
890
 
861
891
  ---
862
892
 
@@ -1438,6 +1468,15 @@ In a JSON string literal, `\\` — two characters — denotes **one** literal ba
1438
1468
  - Message IDs change if the message is moved between mailboxes
1439
1469
  - Use `search-messages` to find the current message ID
1440
1470
 
1471
+ ### "... is present in more than one mailbox"
1472
+ - A bare numeric ID identifies a message only *within a mailbox*, and a label store (Gmail, iCloud)
1473
+ reports the same message under the same ID in `INBOX`, `Important` and `All Mail` at once. The
1474
+ server refuses rather than guessing which copy you meant.
1475
+ - Fix it by running `list-messages`/`search-messages` on the mailbox you actually want to act on,
1476
+ then using the IDs from that result — the operation is then scoped to that mailbox.
1477
+ - It only affects IDs the server hasn't seen listed (carried over from an earlier session, or typed
1478
+ by hand). `imap:…` IDs encode their own mailbox and never hit this.
1479
+
1441
1480
  ### `search-messages` says "Partial results" or skips a mailbox
1442
1481
  - 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.
1443
1482
  - 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,44 @@ 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
+ * Publicly record where a message id lives.
78586
+ *
78587
+ * The index fills itself from list/search results, but that is per-process
78588
+ * state: a caller that carried ids across a process boundary (a stored triage
78589
+ * list, a scheduled job resuming) starts with an empty index, so every id is
78590
+ * "unlocated" and a label-store id gets refused as ambiguous. Registering the
78591
+ * known location restores scoped resolution.
78592
+ */
78593
+ noteMessageLocation(id, account, mailbox) {
78594
+ this.rememberLocation(id, account, mailbox);
78595
+ }
78596
+ /**
78597
+ * AppleScript fragment resolving `account` + `mailbox` into `_tmb`, leaving
78598
+ * `_tmb` as `missing value` when it can't be pinned down. Exact-name match
78599
+ * only, and a name matching more than one mailbox resolves to nothing rather
78600
+ * than guessing — the same rule the move destination already applies.
78601
+ */
78602
+ resolveMailboxFragment(account, mailbox) {
78603
+ const resolved = this.resolveMailbox(mailbox, account);
78604
+ return `
78605
+ set _tmb to missing value
78606
+ set _acctM to {}
78607
+ repeat with _a in accounts
78608
+ if (name of _a) is "${escapeForAppleScript(account)}" then set end of _acctM to _a
78609
+ end repeat
78610
+ if (count of _acctM) is 1 then
78611
+ set _mbM to {}
78612
+ repeat with _m in (mailboxes of (item 1 of _acctM))
78613
+ if (name of _m) is "${escapeForAppleScript(resolved)}" then set end of _mbM to _m
78614
+ end repeat
78615
+ if (count of _mbM) is 1 then set _tmb to item 1 of _mbM
78616
+ end if`;
78617
+ }
78578
78618
  /**
78579
78619
  * Returns cached accounts or fetches fresh data if cache is expired/empty.
78580
78620
  */
@@ -79781,24 +79821,63 @@ var AppleMailManager = class {
79781
79821
  return true;
79782
79822
  }
79783
79823
  /**
79784
- * Helper to find and operate on a message by ID.
79824
+ * Helper to find and operate on a message by ID, scoped to the mailbox the id
79825
+ * was listed from.
79826
+ *
79827
+ * Mail.app numeric ids are per-mailbox, and on a label store (Gmail, iCloud)
79828
+ * ONE message is present in several mailboxes under the SAME id — INBOX,
79829
+ * "Important" and "All Mail" all report id 75816 for the same mail. This used
79830
+ * to walk every account's every mailbox and mutate the FIRST hit, so whichever
79831
+ * copy `mailboxes of <account>` happened to reach first won and the mailbox the
79832
+ * id was listed from lost whenever an alias came earlier in that (store-
79833
+ * dependent) order — the op reported success while the copy the caller meant
79834
+ * stayed put and a different one was moved/deleted (#152). See
79835
+ * runBatchOperation for the observed ordering on the reporting account.
79836
+ *
79837
+ * Which mailbox a mutation lands in is semantic — deleting the INBOX copy and
79838
+ * deleting the "All Mail" copy are different operations — so scope to the
79839
+ * mailbox the id actually came from (`idLocationIndex`, populated by every
79840
+ * list/search) and never guess.
79785
79841
  */
79786
79842
  findMessageScript(id, operation) {
79843
+ const loc = this.locationFor(id);
79844
+ if (loc) {
79845
+ return buildAppLevelScript(`
79846
+ try
79847
+ ${this.resolveMailboxFragment(loc.account, loc.mailbox)}
79848
+ if _tmb is missing value then return "error:Message not found"
79849
+ set matchingMsgs to (messages of _tmb whose id is ${Number(id)})
79850
+ if (count of matchingMsgs) > 0 then
79851
+ set msg to item 1 of matchingMsgs
79852
+ ${operation}
79853
+ return "ok"
79854
+ end if
79855
+ return "error:Message not found"
79856
+ on error errMsg
79857
+ return "error:" & errMsg
79858
+ end try
79859
+ `);
79860
+ }
79787
79861
  return buildAppLevelScript(`
79788
79862
  try
79863
+ set _hits to {}
79864
+ set _names to ""
79789
79865
  repeat with acct in accounts
79790
79866
  repeat with mb in mailboxes of acct
79791
79867
  try
79792
79868
  set matchingMsgs to (messages of mb whose id is ${Number(id)})
79793
79869
  if (count of matchingMsgs) > 0 then
79794
- set msg to item 1 of matchingMsgs
79795
- ${operation}
79796
- return "ok"
79870
+ set end of _hits to (item 1 of matchingMsgs)
79871
+ set _names to _names & (name of acct) & "/" & (name of mb) & ", "
79797
79872
  end if
79798
79873
  end try
79799
79874
  end repeat
79800
79875
  end repeat
79801
- return "error:Message not found"
79876
+ if (count of _hits) is 0 then return "error:Message not found"
79877
+ 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"
79878
+ set msg to item 1 of _hits
79879
+ ${operation}
79880
+ return "ok"
79802
79881
  on error errMsg
79803
79882
  return "error:" & errMsg
79804
79883
  end try
@@ -79983,6 +80062,31 @@ var AppleMailManager = class {
79983
80062
  const targetMailbox = this.resolveMailbox(mailbox, targetAccount);
79984
80063
  const safeMailbox = escapeForAppleScript(targetMailbox);
79985
80064
  const safeAccount = escapeForAppleScript(targetAccount);
80065
+ const loc = this.locationFor(id);
80066
+ const findAndMove = loc ? `
80067
+ ${this.resolveMailboxFragment(loc.account, loc.mailbox)}
80068
+ if _tmb is missing value then return "error:Message not found"
80069
+ set matchingMsgs to (messages of _tmb whose id is ${Number(id)})
80070
+ if (count of matchingMsgs) is 0 then return "error:Message not found"
80071
+ move (item 1 of matchingMsgs) to destMailbox
80072
+ return "ok"` : `
80073
+ set _hits to {}
80074
+ set _names to ""
80075
+ repeat with acct in accounts
80076
+ repeat with mb in (mailboxes of acct)
80077
+ try
80078
+ set matchingMsgs to (messages of mb whose id is ${Number(id)})
80079
+ if (count of matchingMsgs) > 0 then
80080
+ set end of _hits to (item 1 of matchingMsgs)
80081
+ set _names to _names & (name of acct) & "/" & (name of mb) & ", "
80082
+ end if
80083
+ end try
80084
+ end repeat
80085
+ end repeat
80086
+ if (count of _hits) is 0 then return "error:Message not found"
80087
+ 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"
80088
+ move (item 1 of _hits) to destMailbox
80089
+ return "ok"`;
79986
80090
  const script = buildAppLevelScript(`
79987
80091
  try
79988
80092
  -- \`mailboxes of account\` is already flat: it includes nested mailboxes
@@ -79998,21 +80102,7 @@ var AppleMailManager = class {
79998
80102
  if (count of destMatches) is 0 then return "error:Destination mailbox \\"" & destName & "\\" not found in account \\"${safeAccount}\\""
79999
80103
  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
80104
  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"
80105
+ ${findAndMove}
80016
80106
  on error errMsg
80017
80107
  return "error:" & errMsg
80018
80108
  end try
@@ -80040,23 +80130,76 @@ var AppleMailManager = class {
80040
80130
  // ===========================================================================
80041
80131
  // Batch Operations
80042
80132
  // ===========================================================================
80133
+ /**
80134
+ * Turn a caller-supplied batch source scope into an account+mailbox pair.
80135
+ *
80136
+ * `mailbox` is what does the scoping, and it works ON ITS OWN: an omitted
80137
+ * `account` means "the default account" here exactly as it does for every
80138
+ * other tool in this server (see resolveAccount), never "ignore the argument
80139
+ * you were given". Requiring both used to make a lone `sourceMailbox` a silent
80140
+ * no-op — the ids fell back to the whole-tree path and were then refused as
80141
+ * ambiguous, which is the very failure the parameter exists to prevent.
80142
+ *
80143
+ * The safety property is absolute: when the account cannot be determined there
80144
+ * is NO fallback to the scan-and-guess walk. The caller gets an error naming
80145
+ * the mailbox it asked for, so it can retry with an explicit `sourceAccount`.
80146
+ * (An `account` with no `mailbox` cannot pin anything, so it scopes nothing —
80147
+ * those ids still go through the index / ambiguity-checked path.)
80148
+ */
80149
+ resolveBatchScope(scope) {
80150
+ const mailbox = scope?.mailbox?.trim();
80151
+ if (!mailbox) return { kind: "none" };
80152
+ const account = scope?.account?.trim();
80153
+ if (account) return { kind: "scoped", account, mailbox };
80154
+ const known = this.getCachedAccounts();
80155
+ if (known.length === 0) {
80156
+ return {
80157
+ kind: "unresolvable",
80158
+ error: `Cannot scope to source mailbox "${mailbox}": no sourceAccount was given and no Mail account could be read (Mail returned none, or the AppleScript transport failed). Retry with an explicit sourceAccount.`
80159
+ };
80160
+ }
80161
+ const chosen = this.resolveAccount();
80162
+ if (!known.some((a) => a.name === chosen)) {
80163
+ return {
80164
+ kind: "unresolvable",
80165
+ error: `Cannot scope to source mailbox "${mailbox}": no sourceAccount was given and the default account could not be determined. Retry with an explicit sourceAccount (available: ${known.map((a) => a.name).join(", ")}).`
80166
+ };
80167
+ }
80168
+ return { kind: "scoped", account: chosen, mailbox };
80169
+ }
80043
80170
  /**
80044
80171
  * Run one operation over many message IDs in a SINGLE osascript invocation.
80045
80172
  *
80046
80173
  * Previously each batch method looped and called the per-id method, so a
80047
80174
  * 100-id batch spawned 100 osascript processes — each one re-resolving
80048
80175
  * 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.
80176
+ * through the gate (issue #31). Still one osascript invocation, but the ids
80177
+ * are now grouped by the mailbox they were listed from and each group opens
80178
+ * exactly that one mailbox. Per-id outcomes come back as control-char
80179
+ * delimited `position<FS>status` records (status: `ok`, `notfound`, or
80180
+ * `error:<msg>`), and results are returned in input order.
80055
80181
  *
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.
80182
+ * Scoping is a CORRECTNESS requirement, not an optimization (#152). A Mail.app
80183
+ * numeric id is unique only within a mailbox, and a label store (Gmail,
80184
+ * iCloud) exposes one message in several mailboxes under the same id — INBOX,
80185
+ * "Important" and "All Mail" all report id 75816 for the same mail. The old
80186
+ * tree walk applied `operation` to the FIRST mailbox that matched while
80187
+ * iterating `mailboxes of <account>`, so whichever copy that iteration reached
80188
+ * first won — and the ids' real source mailbox lost whenever an alias came
80189
+ * earlier. Observed on the reporting account (`list-mailboxes`, 2026-08-13):
80190
+ * INBOX 1, "[Gmail]/All Mail" 5, "[Gmail]/Important" 9, "Sales Spam" 12 — so a
80191
+ * batch listed from "Sales Spam" was applied to the All Mail copies while the
80192
+ * Sales Spam messages stayed put, and every id still reported `ok`. That order
80193
+ * is a property of the store, not a guarantee: do not rely on it in either
80194
+ * direction — any mailbox the walk reaches late loses the same way. Grouping by
80195
+ * recorded source mailbox makes the op land on the copy the caller actually
80196
+ * listed; ids with no recorded mailbox are refused when ambiguous rather than
80197
+ * applied to an arbitrary copy.
80198
+ *
80199
+ * `setup` runs once up front (used by move to resolve the destination); it may
80200
+ * bail the whole batch by returning a `BATCH_FATAL`-prefixed string.
80058
80201
  */
80059
- runBatchOperation(ids, operation, setup = "") {
80202
+ runBatchOperation(ids, operation, setup = "", scope) {
80060
80203
  const valid = [];
80061
80204
  for (const id of ids) {
80062
80205
  const num = Number(id);
@@ -80065,39 +80208,101 @@ var AppleMailManager = class {
80065
80208
  if (valid.length === 0) {
80066
80209
  return ids.map((id) => ({ id, success: false, error: "Invalid message ID" }));
80067
80210
  }
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
80211
+ const resolved = this.resolveBatchScope(scope);
80212
+ if (resolved.kind === "unresolvable") {
80213
+ return ids.map((id) => ({ id, success: false, error: resolved.error }));
80214
+ }
80215
+ const callerScope = resolved.kind === "scoped" ? resolved : void 0;
80216
+ const groups = /* @__PURE__ */ new Map();
80217
+ const unlocated = [];
80218
+ valid.forEach((v, i) => {
80219
+ const pos = i + 1;
80220
+ const loc = callerScope ?? this.locationFor(v.id);
80221
+ if (!loc) {
80222
+ unlocated.push({ num: v.num, pos });
80223
+ return;
80224
+ }
80225
+ const key = `${loc.account}\0${loc.mailbox}`;
80226
+ const g = groups.get(key) ?? { account: loc.account, mailbox: loc.mailbox, items: [] };
80227
+ g.items.push({ num: v.num, pos });
80228
+ groups.set(key, g);
80229
+ });
80230
+ const asList = (nums) => `{${nums.join(", ")}}`;
80231
+ const scopedBlocks = [...groups.values()].map(
80232
+ (g) => `
80233
+ ${this.resolveMailboxFragment(g.account, g.mailbox)}
80234
+ set _gids to ${asList(g.items.map((it) => it.num))}
80235
+ set _gpos to ${asList(g.items.map((it) => it.pos))}
80236
+ if _tmb is missing value then
80237
+ repeat with _k from 1 to (count of _gpos)
80238
+ 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}"
80239
+ end repeat
80240
+ else
80241
+ repeat with _k from 1 to (count of _gids)
80242
+ set _idx to item _k of _gpos
80243
+ try
80244
+ set _m to (messages of _tmb whose id is (item _k of _gids))
80245
+ if (count of _m) > 0 then
80246
+ set _msg to item 1 of _m
80247
+ ${operation}
80248
+ set _out to _out & (_idx as string) & "${FIELD_SEP}ok${RECORD_SEP}"
80249
+ else
80250
+ set _out to _out & (_idx as string) & "${FIELD_SEP}notfound${RECORD_SEP}"
80251
+ end if
80252
+ on error _e
80253
+ set _out to _out & (_idx as string) & "${FIELD_SEP}error:" & _e & "${RECORD_SEP}"
80254
+ end try
80255
+ end repeat
80256
+ end if`
80257
+ ).join("\n");
80258
+ const unlocatedBlock = unlocated.length ? `
80259
+ set _uids to ${asList(unlocated.map((it) => it.num))}
80260
+ set _upos to ${asList(unlocated.map((it) => it.pos))}
80261
+ set _ucount to count of _uids
80262
+ set _uhit to {}
80263
+ set _umsg to {}
80264
+ set _unames to {}
80265
+ repeat with _k from 1 to _ucount
80266
+ set end of _uhit to 0
80267
+ set end of _umsg to missing value
80268
+ set end of _unames to ""
80269
+ end repeat
80075
80270
  repeat with acct in accounts
80076
- if (count of _done) is _total then exit repeat
80077
80271
  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
80272
+ repeat with _k from 1 to _ucount
80273
+ try
80274
+ set _m to (messages of mb whose id is (item _k of _uids))
80275
+ if (count of _m) > 0 then
80276
+ set item _k of _uhit to ((item _k of _uhit) + 1)
80277
+ if (item _k of _uhit) is 1 then set item _k of _umsg to (item 1 of _m)
80278
+ set item _k of _unames to ((item _k of _unames) & (name of acct) & "/" & (name of mb) & ", ")
80279
+ end if
80280
+ end try
80095
80281
  end repeat
80096
80282
  end repeat
80097
80283
  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
80284
+ repeat with _k from 1 to _ucount
80285
+ set _idx to item _k of _upos
80286
+ if (item _k of _uhit) is 0 then
80287
+ set _out to _out & (_idx as string) & "${FIELD_SEP}notfound${RECORD_SEP}"
80288
+ else if (item _k of _uhit) > 1 then
80289
+ 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}"
80290
+ else
80291
+ try
80292
+ set _msg to item _k of _umsg
80293
+ ${operation}
80294
+ set _out to _out & (_idx as string) & "${FIELD_SEP}ok${RECORD_SEP}"
80295
+ on error _e
80296
+ set _out to _out & (_idx as string) & "${FIELD_SEP}error:" & _e & "${RECORD_SEP}"
80297
+ end try
80298
+ end if
80299
+ end repeat` : "";
80300
+ const script = buildAppLevelScript(`
80301
+ try
80302
+ ${setup}
80303
+ set _out to ""
80304
+ ${scopedBlocks}
80305
+ ${unlocatedBlock}
80101
80306
  return _out
80102
80307
  on error errMsg
80103
80308
  return "${BATCH_FATAL}" & errMsg
@@ -80140,8 +80345,8 @@ var AppleMailManager = class {
80140
80345
  /**
80141
80346
  * Delete multiple messages at once (single tree walk — see runBatchOperation).
80142
80347
  */
80143
- batchDeleteMessages(ids) {
80144
- return this.runBatchOperation(ids, "delete _msg");
80348
+ batchDeleteMessages(ids, scope) {
80349
+ return this.runBatchOperation(ids, "delete _msg", "", scope);
80145
80350
  }
80146
80351
  /**
80147
80352
  * Move multiple messages to a mailbox at once (single tree walk).
@@ -80150,7 +80355,7 @@ var AppleMailManager = class {
80150
80355
  * matching more than one mailbox fails the whole batch rather than guessing),
80151
80356
  * then every matched message is moved in the same walk.
80152
80357
  */
80153
- batchMoveMessages(ids, mailbox, account) {
80358
+ batchMoveMessages(ids, mailbox, account, scope) {
80154
80359
  const targetAccount = this.resolveAccount(account);
80155
80360
  const targetMailbox = this.resolveMailbox(mailbox, targetAccount);
80156
80361
  const safeMailbox = escapeForAppleScript(targetMailbox);
@@ -80164,31 +80369,31 @@ var AppleMailManager = class {
80164
80369
  if (count of destMatches) is 0 then return "${BATCH_FATAL}Destination mailbox \\"" & destName & "\\" not found in account \\"${safeAccount}\\""
80165
80370
  if (count of destMatches) > 1 then return "${BATCH_FATAL}Destination mailbox \\"" & destName & "\\" is ambiguous (" & (count of destMatches) & " matches) in account \\"${safeAccount}\\"; move by full path"
80166
80371
  set destMailbox to item 1 of destMatches`;
80167
- return this.runBatchOperation(ids, "move _msg to destMailbox", setup);
80372
+ return this.runBatchOperation(ids, "move _msg to destMailbox", setup, scope);
80168
80373
  }
80169
80374
  /**
80170
80375
  * Mark multiple messages as read at once (single tree walk).
80171
80376
  */
80172
- batchMarkAsRead(ids) {
80173
- return this.runBatchOperation(ids, "set read status of _msg to true");
80377
+ batchMarkAsRead(ids, scope) {
80378
+ return this.runBatchOperation(ids, "set read status of _msg to true", "", scope);
80174
80379
  }
80175
80380
  /**
80176
80381
  * Mark multiple messages as unread at once (single tree walk).
80177
80382
  */
80178
- batchMarkAsUnread(ids) {
80179
- return this.runBatchOperation(ids, "set read status of _msg to false");
80383
+ batchMarkAsUnread(ids, scope) {
80384
+ return this.runBatchOperation(ids, "set read status of _msg to false", "", scope);
80180
80385
  }
80181
80386
  /**
80182
80387
  * Flag multiple messages at once (single tree walk).
80183
80388
  */
80184
- batchFlagMessages(ids, colorIndex) {
80185
- return this.runBatchOperation(ids, this.flagOperation("_msg", colorIndex));
80389
+ batchFlagMessages(ids, colorIndex, scope) {
80390
+ return this.runBatchOperation(ids, this.flagOperation("_msg", colorIndex), "", scope);
80186
80391
  }
80187
80392
  /**
80188
80393
  * Unflag multiple messages at once (single tree walk).
80189
80394
  */
80190
- batchUnflagMessages(ids) {
80191
- return this.runBatchOperation(ids, "set flagged status of _msg to false");
80395
+ batchUnflagMessages(ids, scope) {
80396
+ return this.runBatchOperation(ids, "set flagged status of _msg to false", "", scope);
80192
80397
  }
80193
80398
  /**
80194
80399
  * List attachments for a message.
@@ -82853,6 +83058,57 @@ function withErrorHandling(handler, errorPrefix) {
82853
83058
  };
82854
83059
  }
82855
83060
 
83061
+ // src/tools/batchResults.ts
83062
+ async function hybridBatchCounts(ids, appleFn, imapFn) {
83063
+ const imapIds = ids.filter((i) => i.startsWith("imap:"));
83064
+ const numericIds = ids.filter((i) => !i.startsWith("imap:"));
83065
+ let success = 0;
83066
+ let fail = 0;
83067
+ const errors = [];
83068
+ if (numericIds.length > 0) {
83069
+ const res = appleFn(numericIds);
83070
+ const s = res.filter((r) => r.success).length;
83071
+ success += s;
83072
+ fail += res.length - s;
83073
+ errors.push(...res.filter((r) => !r.success && r.error).map((r) => r.error));
83074
+ }
83075
+ if (imapIds.length > 0) {
83076
+ const r = await imapFn(imapIds);
83077
+ success += r.success;
83078
+ fail += r.failed;
83079
+ errors.push(...r.errors);
83080
+ }
83081
+ return { success, fail, errors };
83082
+ }
83083
+ function distinctErrors(errors) {
83084
+ return [...new Set(errors.filter(Boolean))];
83085
+ }
83086
+ function formatBatchErrors(errors, max = 5) {
83087
+ const distinct = distinctErrors(errors);
83088
+ if (distinct.length === 0) return "";
83089
+ const shown = distinct.slice(0, max);
83090
+ const more = distinct.length - shown.length;
83091
+ return `: ${shown.join("; ")}${more > 0 ? ` (+${more} more)` : ""}`;
83092
+ }
83093
+ var MAX_STRUCTURED_BATCH_ERRORS = 20;
83094
+ function batchResponse(counts, messages, extra = {}) {
83095
+ const { success, fail, errors } = counts;
83096
+ const distinct = distinctErrors(errors);
83097
+ const reported = distinct.slice(0, MAX_STRUCTURED_BATCH_ERRORS);
83098
+ const structured = {
83099
+ ok: fail === 0,
83100
+ success,
83101
+ failed: fail,
83102
+ ...extra,
83103
+ ...reported.length > 0 ? { errors: reported } : {},
83104
+ ...distinct.length > reported.length ? { errorsTruncated: true } : {}
83105
+ };
83106
+ const suffix = formatBatchErrors(distinct);
83107
+ if (fail === 0) return successResponse(messages.allSucceeded(success), structured);
83108
+ if (success === 0) return errorResponse(`${messages.allFailed(fail)}${suffix}`, structured);
83109
+ return successResponse(`${messages.partial(success, fail)}${suffix}`, structured);
83110
+ }
83111
+
82856
83112
  // src/services/imapMultiAccount.ts
82857
83113
  function normalizeMessageId2(row) {
82858
83114
  const raw = typeof row.messageId === "string" ? row.messageId.trim() : "";
@@ -83444,6 +83700,12 @@ function withJsonSchema2020_12(transport2) {
83444
83700
  loadFileConfig();
83445
83701
  var MESSAGE_ID_SCHEMA = external_exports.string().regex(/^(\d+|imap:[A-Za-z0-9_-]+)$/, "Message ID must be numeric or an IMAP id (imap:\u2026)");
83446
83702
  var BATCH_IDS_SCHEMA = external_exports.array(MESSAGE_ID_SCHEMA).min(1, "At least one message ID is required").max(100, "Cannot process more than 100 messages in a single batch");
83703
+ var BATCH_SOURCE_MAILBOX_SCHEMA = external_exports.string().optional().describe(
83704
+ "Mailbox the numeric ids were listed from (e.g. 'INBOX'). Pins each id to that mailbox \u2014 strongly recommended, since one numeric id can match in several mailboxes. Works on its own: without sourceAccount it means that mailbox in the default account. Ignored for imap: ids."
83705
+ );
83706
+ var BATCH_SOURCE_ACCOUNT_SCHEMA = external_exports.string().optional().describe(
83707
+ "Account the numeric ids were listed from. Defaults to the default account. On its own it pins nothing \u2014 pair it with sourceMailbox."
83708
+ );
83447
83709
  var FLAG_COLOR_INDEX = {
83448
83710
  red: 0,
83449
83711
  orange: 1,
@@ -83493,7 +83755,19 @@ var BATCH_COUNT_OUTPUT_SCHEMA = {
83493
83755
  ok: external_exports.boolean().optional(),
83494
83756
  success: external_exports.number().optional(),
83495
83757
  failed: external_exports.number().optional(),
83496
- mailbox: external_exports.string().optional()
83758
+ mailbox: external_exports.string().optional(),
83759
+ // Declared so the failure channel is part of the tool's advertised CONTRACT:
83760
+ // a client can rely on `errors` being string[] and code against it, and it
83761
+ // shows up in generated types and docs. Declaring is not what makes it
83762
+ // deliverable — registerTool() wraps every outputSchema in
83763
+ // `z.object(shape).passthrough()`, so these tools advertise
83764
+ // `additionalProperties: true` (verified against the built server) and an
83765
+ // undeclared key would be carried through, not rejected. Enumerating it is a
83766
+ // promise to callers, not a workaround for a validator.
83767
+ errors: external_exports.array(external_exports.string()).optional(),
83768
+ // Set when `errors` was capped (MAX_STRUCTURED_BATCH_ERRORS distinct reasons),
83769
+ // so a short list is never mistaken for the complete one.
83770
+ errorsTruncated: external_exports.boolean().optional()
83497
83771
  };
83498
83772
  var CHECK_ITEM_SCHEMA = external_exports.object({}).passthrough();
83499
83773
  var require2 = createRequire(import.meta.url);
@@ -83574,26 +83848,6 @@ function registerTool(name, config2, cb) {
83574
83848
  }
83575
83849
  var mailManager = new AppleMailManager();
83576
83850
  registerResourcesAndPrompts(server, mailManager);
83577
- async function hybridBatchCounts(ids, appleFn, imapFn) {
83578
- const imapIds = ids.filter((i) => i.startsWith("imap:"));
83579
- const numericIds = ids.filter((i) => !i.startsWith("imap:"));
83580
- let success = 0;
83581
- let fail = 0;
83582
- const errors = [];
83583
- if (numericIds.length > 0) {
83584
- const res = appleFn(numericIds);
83585
- const s = res.filter((r) => r.success).length;
83586
- success += s;
83587
- fail += res.length - s;
83588
- }
83589
- if (imapIds.length > 0) {
83590
- const r = await imapFn(imapIds);
83591
- success += r.success;
83592
- fail += r.failed;
83593
- errors.push(...r.errors);
83594
- }
83595
- return { success, fail, errors };
83596
- }
83597
83851
  registerTool(
83598
83852
  "search-messages",
83599
83853
  {
@@ -84371,173 +84625,159 @@ registerTool(
84371
84625
  registerTool(
84372
84626
  "batch-delete-messages",
84373
84627
  {
84374
- description: "Use when: deleting multiple messages in one call (1\u2013100 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 \u2014 require explicit user confirmation, and search-messages/list-messages first to confirm every id is correct before deleting.",
84628
+ description: "Use when: deleting multiple messages in one call (1\u2013100 ids; moves them to Trash).\nReturns: counts of how many were deleted and how many failed, plus the distinct reasons for any failures.\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 \u2014 require explicit user confirmation, and search-messages/list-messages first to confirm every id is correct before deleting. Pass sourceMailbox/sourceAccount (the mailbox you listed the ids from) so each numeric id is pinned to that mailbox; an id that matches in several mailboxes is refused, not guessed.",
84375
84629
  inputSchema: {
84376
- ids: BATCH_IDS_SCHEMA
84630
+ ids: BATCH_IDS_SCHEMA,
84631
+ sourceMailbox: BATCH_SOURCE_MAILBOX_SCHEMA,
84632
+ sourceAccount: BATCH_SOURCE_ACCOUNT_SCHEMA
84377
84633
  },
84378
84634
  outputSchema: BATCH_COUNT_OUTPUT_SCHEMA
84379
84635
  },
84380
- withErrorHandling(async ({ ids }) => {
84381
- const { success: successCount, fail: failCount } = await hybridBatchCounts(
84636
+ withErrorHandling(async ({ ids, sourceMailbox, sourceAccount }) => {
84637
+ const counts = await hybridBatchCounts(
84382
84638
  ids,
84383
- (n) => mailManager.batchDeleteMessages(n),
84639
+ (n) => mailManager.batchDeleteMessages(n, { account: sourceAccount, mailbox: sourceMailbox }),
84384
84640
  (im) => imapBatchDelete(im)
84385
84641
  );
84386
- const structured = { ok: failCount === 0, success: successCount, failed: failCount };
84387
- if (failCount === 0) {
84388
- return successResponse(`Successfully deleted ${successCount} message(s)`, structured);
84389
- } else if (successCount === 0) {
84390
- return errorResponse(`Failed to delete all ${failCount} message(s)`);
84391
- } else {
84392
- return successResponse(`Deleted ${successCount} message(s), ${failCount} failed`, structured);
84393
- }
84642
+ return batchResponse(counts, {
84643
+ allSucceeded: (n) => `Successfully deleted ${n} message(s)`,
84644
+ allFailed: (n) => `Failed to delete all ${n} message(s)`,
84645
+ partial: (ok, failed) => `Deleted ${ok} message(s), ${failed} failed`
84646
+ });
84394
84647
  }, "Error batch deleting messages")
84395
84648
  );
84396
84649
  registerTool(
84397
84650
  "batch-move-messages",
84398
84651
  {
84399
- description: "Use when: moving multiple messages (1\u2013100 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 \u2014 confirm the destination mailbox, and search-messages/list-messages first to confirm the ids.",
84652
+ description: "Use when: moving multiple messages (1\u2013100 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, plus the distinct reasons for any failures.\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 \u2014 confirm the destination mailbox, and search-messages/list-messages first to confirm the ids. Pass sourceMailbox/sourceAccount (the mailbox you listed the ids from \u2014 not the destination) so each numeric id is pinned to that mailbox; an id that matches in several mailboxes is refused, not guessed.",
84400
84653
  inputSchema: {
84401
84654
  ids: BATCH_IDS_SCHEMA,
84402
84655
  mailbox: external_exports.string().min(1, "Destination mailbox is required"),
84403
- account: external_exports.string().optional().describe("Account containing the destination mailbox")
84656
+ account: external_exports.string().optional().describe("Account containing the destination mailbox"),
84657
+ sourceMailbox: BATCH_SOURCE_MAILBOX_SCHEMA,
84658
+ sourceAccount: BATCH_SOURCE_ACCOUNT_SCHEMA
84404
84659
  },
84405
84660
  outputSchema: BATCH_COUNT_OUTPUT_SCHEMA
84406
84661
  },
84407
- withErrorHandling(async ({ ids, mailbox, account }) => {
84408
- const { success: successCount, fail: failCount } = await hybridBatchCounts(
84662
+ withErrorHandling(async ({ ids, mailbox, account, sourceMailbox, sourceAccount }) => {
84663
+ const counts = await hybridBatchCounts(
84409
84664
  ids,
84410
- (n) => mailManager.batchMoveMessages(n, mailbox, account),
84665
+ (n) => mailManager.batchMoveMessages(n, mailbox, account, {
84666
+ account: sourceAccount,
84667
+ mailbox: sourceMailbox
84668
+ }),
84411
84669
  (im) => imapBatchMove(im, mailbox, { account })
84412
84670
  );
84413
- const structured = { ok: failCount === 0, success: successCount, failed: failCount, mailbox };
84414
- if (failCount === 0) {
84415
- return successResponse(
84416
- `Successfully moved ${successCount} message(s) to "${mailbox}"`,
84417
- structured
84418
- );
84419
- } else if (successCount === 0) {
84420
- return errorResponse(`Failed to move all ${failCount} message(s)`);
84421
- } else {
84422
- return successResponse(
84423
- `Moved ${successCount} message(s) to "${mailbox}", ${failCount} failed`,
84424
- structured
84425
- );
84426
- }
84671
+ return batchResponse(
84672
+ counts,
84673
+ {
84674
+ allSucceeded: (n) => `Successfully moved ${n} message(s) to "${mailbox}"`,
84675
+ allFailed: (n) => `Failed to move all ${n} message(s)`,
84676
+ partial: (ok, failed) => `Moved ${ok} message(s) to "${mailbox}", ${failed} failed`
84677
+ },
84678
+ { mailbox }
84679
+ );
84427
84680
  }, "Error batch moving messages")
84428
84681
  );
84429
84682
  registerTool(
84430
84683
  "batch-mark-as-read",
84431
84684
  {
84432
- description: "Use when: marking multiple messages (1\u2013100 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.",
84685
+ description: "Use when: marking multiple messages (1\u2013100 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. Pass sourceMailbox/sourceAccount (the mailbox you listed the ids from) so each numeric id is pinned to that mailbox; an id that matches in several mailboxes is refused, not guessed.",
84433
84686
  inputSchema: {
84434
- ids: BATCH_IDS_SCHEMA
84687
+ ids: BATCH_IDS_SCHEMA,
84688
+ sourceMailbox: BATCH_SOURCE_MAILBOX_SCHEMA,
84689
+ sourceAccount: BATCH_SOURCE_ACCOUNT_SCHEMA
84435
84690
  },
84436
84691
  outputSchema: BATCH_COUNT_OUTPUT_SCHEMA
84437
84692
  },
84438
- withErrorHandling(async ({ ids }) => {
84439
- const { success: successCount, fail: failCount } = await hybridBatchCounts(
84693
+ withErrorHandling(async ({ ids, sourceMailbox, sourceAccount }) => {
84694
+ const counts = await hybridBatchCounts(
84440
84695
  ids,
84441
- (n) => mailManager.batchMarkAsRead(n),
84696
+ (n) => mailManager.batchMarkAsRead(n, { account: sourceAccount, mailbox: sourceMailbox }),
84442
84697
  (im) => imapBatchMarkRead(im)
84443
84698
  );
84444
- const structured = { ok: failCount === 0, success: successCount, failed: failCount };
84445
- if (failCount === 0) {
84446
- return successResponse(`Successfully marked ${successCount} message(s) as read`, structured);
84447
- } else if (successCount === 0) {
84448
- return errorResponse(`Failed to mark all ${failCount} message(s) as read`);
84449
- } else {
84450
- return successResponse(
84451
- `Marked ${successCount} message(s) as read, ${failCount} failed`,
84452
- structured
84453
- );
84454
- }
84699
+ return batchResponse(counts, {
84700
+ allSucceeded: (n) => `Successfully marked ${n} message(s) as read`,
84701
+ allFailed: (n) => `Failed to mark all ${n} message(s) as read`,
84702
+ partial: (ok, failed) => `Marked ${ok} message(s) as read, ${failed} failed`
84703
+ });
84455
84704
  }, "Error batch marking messages as read")
84456
84705
  );
84457
84706
  registerTool(
84458
84707
  "batch-mark-as-unread",
84459
84708
  {
84460
- description: "Use when: marking multiple messages (1\u2013100 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.",
84709
+ description: "Use when: marking multiple messages (1\u2013100 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. Pass sourceMailbox/sourceAccount (the mailbox you listed the ids from) so each numeric id is pinned to that mailbox; an id that matches in several mailboxes is refused, not guessed.",
84461
84710
  inputSchema: {
84462
- ids: BATCH_IDS_SCHEMA
84711
+ ids: BATCH_IDS_SCHEMA,
84712
+ sourceMailbox: BATCH_SOURCE_MAILBOX_SCHEMA,
84713
+ sourceAccount: BATCH_SOURCE_ACCOUNT_SCHEMA
84463
84714
  },
84464
84715
  outputSchema: BATCH_COUNT_OUTPUT_SCHEMA
84465
84716
  },
84466
- withErrorHandling(async ({ ids }) => {
84467
- const { success: successCount, fail: failCount } = await hybridBatchCounts(
84717
+ withErrorHandling(async ({ ids, sourceMailbox, sourceAccount }) => {
84718
+ const counts = await hybridBatchCounts(
84468
84719
  ids,
84469
- (n) => mailManager.batchMarkAsUnread(n),
84720
+ (n) => mailManager.batchMarkAsUnread(n, { account: sourceAccount, mailbox: sourceMailbox }),
84470
84721
  (im) => imapBatchMarkUnread(im)
84471
84722
  );
84472
- const structured = { ok: failCount === 0, success: successCount, failed: failCount };
84473
- if (failCount === 0) {
84474
- return successResponse(
84475
- `Successfully marked ${successCount} message(s) as unread`,
84476
- structured
84477
- );
84478
- } else if (successCount === 0) {
84479
- return errorResponse(`Failed to mark all ${failCount} message(s) as unread`);
84480
- } else {
84481
- return successResponse(
84482
- `Marked ${successCount} message(s) as unread, ${failCount} failed`,
84483
- structured
84484
- );
84485
- }
84723
+ return batchResponse(counts, {
84724
+ allSucceeded: (n) => `Successfully marked ${n} message(s) as unread`,
84725
+ allFailed: (n) => `Failed to mark all ${n} message(s) as unread`,
84726
+ partial: (ok, failed) => `Marked ${ok} message(s) as unread, ${failed} failed`
84727
+ });
84486
84728
  }, "Error batch marking messages as unread")
84487
84729
  );
84488
84730
  registerTool(
84489
84731
  "batch-flag-messages",
84490
84732
  {
84491
- description: "Use when: flagging multiple messages (1\u2013100 ids) in one call, optionally with a color (red/orange/yellow/green/blue/purple/gray).\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.\nNote: the color is applied on both routes \u2014 AppleScript sets the flag index, IMAP writes the equivalent $MailFlagBit0/1/2 keywords Mail.app reads \u2014 so a mixed batch of numeric and `imap:` ids all end up colored.",
84733
+ description: "Use when: flagging multiple messages (1\u2013100 ids) in one call, optionally with a color (red/orange/yellow/green/blue/purple/gray).\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. Pass sourceMailbox/sourceAccount (the mailbox you listed the ids from) so each numeric id is pinned to that mailbox; an id that matches in several mailboxes is refused, not guessed.\nNote: the color is applied on both routes \u2014 AppleScript sets the flag index, IMAP writes the equivalent $MailFlagBit0/1/2 keywords Mail.app reads \u2014 so a mixed batch of numeric and `imap:` ids all end up colored.",
84492
84734
  inputSchema: {
84493
84735
  ids: BATCH_IDS_SCHEMA,
84494
- color: FLAG_COLOR_SCHEMA
84736
+ color: FLAG_COLOR_SCHEMA,
84737
+ sourceMailbox: BATCH_SOURCE_MAILBOX_SCHEMA,
84738
+ sourceAccount: BATCH_SOURCE_ACCOUNT_SCHEMA
84495
84739
  },
84496
84740
  outputSchema: BATCH_COUNT_OUTPUT_SCHEMA
84497
84741
  },
84498
- withErrorHandling(async ({ ids, color }) => {
84742
+ withErrorHandling(async ({ ids, color, sourceMailbox, sourceAccount }) => {
84499
84743
  const colorIndex = color ? FLAG_COLOR_INDEX[color] : void 0;
84500
- const { success: successCount, fail: failCount } = await hybridBatchCounts(
84744
+ const counts = await hybridBatchCounts(
84501
84745
  ids,
84502
- (n) => mailManager.batchFlagMessages(n, colorIndex),
84746
+ (n) => mailManager.batchFlagMessages(n, colorIndex, {
84747
+ account: sourceAccount,
84748
+ mailbox: sourceMailbox
84749
+ }),
84503
84750
  (im) => imapBatchFlag(im, colorIndex)
84504
84751
  );
84505
- const structured = { ok: failCount === 0, success: successCount, failed: failCount };
84506
- if (failCount === 0) {
84507
- return successResponse(`Successfully flagged ${successCount} message(s)`, structured);
84508
- } else if (successCount === 0) {
84509
- return errorResponse(`Failed to flag all ${failCount} message(s)`);
84510
- } else {
84511
- return successResponse(`Flagged ${successCount} message(s), ${failCount} failed`, structured);
84512
- }
84752
+ return batchResponse(counts, {
84753
+ allSucceeded: (n) => `Successfully flagged ${n} message(s)`,
84754
+ allFailed: (n) => `Failed to flag all ${n} message(s)`,
84755
+ partial: (ok, failed) => `Flagged ${ok} message(s), ${failed} failed`
84756
+ });
84513
84757
  }, "Error batch flagging messages")
84514
84758
  );
84515
84759
  registerTool(
84516
84760
  "batch-unflag-messages",
84517
84761
  {
84518
- description: "Use when: removing flags from multiple messages (1\u2013100 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.",
84762
+ description: "Use when: removing flags from multiple messages (1\u2013100 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. Pass sourceMailbox/sourceAccount (the mailbox you listed the ids from) so each numeric id is pinned to that mailbox; an id that matches in several mailboxes is refused, not guessed.",
84519
84763
  inputSchema: {
84520
- ids: BATCH_IDS_SCHEMA
84764
+ ids: BATCH_IDS_SCHEMA,
84765
+ sourceMailbox: BATCH_SOURCE_MAILBOX_SCHEMA,
84766
+ sourceAccount: BATCH_SOURCE_ACCOUNT_SCHEMA
84521
84767
  },
84522
84768
  outputSchema: BATCH_COUNT_OUTPUT_SCHEMA
84523
84769
  },
84524
- withErrorHandling(async ({ ids }) => {
84525
- const { success: successCount, fail: failCount } = await hybridBatchCounts(
84770
+ withErrorHandling(async ({ ids, sourceMailbox, sourceAccount }) => {
84771
+ const counts = await hybridBatchCounts(
84526
84772
  ids,
84527
- (n) => mailManager.batchUnflagMessages(n),
84773
+ (n) => mailManager.batchUnflagMessages(n, { account: sourceAccount, mailbox: sourceMailbox }),
84528
84774
  (im) => imapBatchUnflag(im)
84529
84775
  );
84530
- const structured = { ok: failCount === 0, success: successCount, failed: failCount };
84531
- if (failCount === 0) {
84532
- return successResponse(`Successfully unflagged ${successCount} message(s)`, structured);
84533
- } else if (successCount === 0) {
84534
- return errorResponse(`Failed to unflag all ${failCount} message(s)`);
84535
- } else {
84536
- return successResponse(
84537
- `Unflagged ${successCount} message(s), ${failCount} failed`,
84538
- structured
84539
- );
84540
- }
84776
+ return batchResponse(counts, {
84777
+ allSucceeded: (n) => `Successfully unflagged ${n} message(s)`,
84778
+ allFailed: (n) => `Failed to unflag all ${n} message(s)`,
84779
+ partial: (ok, failed) => `Unflagged ${ok} message(s), ${failed} failed`
84780
+ });
84541
84781
  }, "Error batch unflagging messages")
84542
84782
  );
84543
84783
  registerTool(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-mail-mcp",
3
- "version": "2.10.14",
3
+ "version": "2.10.16",
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",