apple-mail-mcp 2.16.1 → 2.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -937,10 +937,21 @@ List all mailboxes for an account.
937
937
  |-----------|------|----------|-------------|
938
938
  | `account` | string | No | Account to list from, or `"On My Mac"` for the local store |
939
939
 
940
- **Returns:** List of mailbox names with message and unread counts. A source that
941
- could not be read is **named** (`partial: true` + `failedAccounts`) rather than
942
- dropped, and a listing Mail refused outright returns an error naming the accounts
943
- that do exist never an empty list.
940
+ **Returns:** List of mailbox **paths** (account-relative, e.g. `Archive/Inbox` for
941
+ a nested mailbox a top-level `Inbox` stays `Inbox`) with message and unread
942
+ counts. A source that could not be read is **named** (`partial: true` +
943
+ `failedAccounts`) rather than dropped, and a listing Mail refused outright
944
+ returns an error naming the accounts that do exist — never an empty list.
945
+
946
+ **Nested mailboxes and Gmail labels.** Every `mailbox` parameter across this
947
+ server (search-messages, list-messages, get-unread-count, move-message,
948
+ delete-mailbox, rename-mailbox, create-rule's `moveTo`) accepts either the full
949
+ path or a leaf name that is unique across the account — the same rule
950
+ move-message has always used. A leaf name that matches more than one mailbox
951
+ (e.g. a top-level `Inbox` and an `Archive/Inbox` on an Exchange account) is
952
+ refused rather than guessed; pass the full path to disambiguate. This also
953
+ means Gmail's nested special mailboxes now report their real path, e.g.
954
+ `[Gmail]/All Mail` rather than `All Mail` — a visible change from before 2.17.0.
944
955
 
945
956
  **Mail's local "On My Mac" mailboxes** are not children of any account — they
946
957
  hang off the application — so they are reported under the synthetic account label
package/build/index.js CHANGED
@@ -79291,6 +79291,41 @@ function buildAppLevelScript(command) {
79291
79291
  end tell
79292
79292
  `;
79293
79293
  }
79294
+ function mailboxPathFragment(mailboxVar, outputVar) {
79295
+ return `
79296
+ set ${outputVar} to name of ${mailboxVar}
79297
+ set _pathParent to missing value
79298
+ try
79299
+ set _pathParent to container of ${mailboxVar}
79300
+ end try
79301
+ repeat while _pathParent is not missing value
79302
+ set _parentClass to missing value
79303
+ try
79304
+ set _parentClass to class of _pathParent
79305
+ end try
79306
+ if _parentClass is not mailbox and _parentClass is not container then exit repeat
79307
+ set ${outputVar} to (name of _pathParent) & "/" & ${outputVar}
79308
+ set _pathNext to missing value
79309
+ try
79310
+ set _pathNext to container of _pathParent
79311
+ end try
79312
+ set _pathParent to _pathNext
79313
+ end repeat`;
79314
+ }
79315
+ function mailboxLookupFragment(collExpr, path, outputVar) {
79316
+ return `
79317
+ set ${outputVar} to missing value
79318
+ repeat with _mbc in (${collExpr})
79319
+ set _mbcPath to ""
79320
+ ${mailboxPathFragment("_mbc", "_mbcPath")}
79321
+ ignoring case
79322
+ if _mbcPath is "${escapeForAppleScript(path)}" then
79323
+ set ${outputVar} to _mbc
79324
+ exit repeat
79325
+ end if
79326
+ end ignoring
79327
+ end repeat`;
79328
+ }
79294
79329
  var MAILBOX_ALIASES = {
79295
79330
  inbox: ["INBOX", "Inbox", "inbox"],
79296
79331
  sent: ["Sent", "Sent Items", "Sent Messages", "SENT", "sent"],
@@ -79299,13 +79334,39 @@ var MAILBOX_ALIASES = {
79299
79334
  junk: ["Junk", "Junk Email", "Spam", "JUNK", "junk"],
79300
79335
  archive: ["Archive", "ARCHIVE", "archive", "All Mail"]
79301
79336
  };
79337
+ function mailboxLeaf(path) {
79338
+ return path.split("/").at(-1) ?? path;
79339
+ }
79340
+ function resolveAppleMailboxPath(mailbox, actualPaths) {
79341
+ if (actualPaths.length === 0) return mailbox;
79342
+ const candidates = [mailbox, ...MAILBOX_ALIASES[mailbox.toLowerCase()] ?? []];
79343
+ for (const candidate of candidates) {
79344
+ const exact = actualPaths.find((path) => path === candidate);
79345
+ if (exact) return exact;
79346
+ const folded = actualPaths.find((path) => path.toLowerCase() === candidate.toLowerCase());
79347
+ if (folded) return folded;
79348
+ }
79349
+ for (const candidate of candidates) {
79350
+ const leafMatches = actualPaths.filter(
79351
+ (path) => mailboxLeaf(path).toLowerCase() === candidate.toLowerCase()
79352
+ );
79353
+ if (leafMatches.length === 1) return leafMatches[0];
79354
+ if (leafMatches.length > 1) {
79355
+ const paths = [...leafMatches].sort().map((path) => `"${path}"`).join(" and ");
79356
+ throw new Error(
79357
+ `Mailbox "${mailbox}" is ambiguous \u2014 it matches ${paths}. Pass the full path.`
79358
+ );
79359
+ }
79360
+ }
79361
+ return mailbox;
79362
+ }
79302
79363
  var GMAIL_INBOX_MAILBOXES = ["All Mail", "Important"];
79303
79364
  var INBOX_SCOPE_NAMES = /* @__PURE__ */ new Set(["inbox"]);
79304
79365
  function isInboxScope(mailbox) {
79305
79366
  return INBOX_SCOPE_NAMES.has(mailbox.trim().toLowerCase());
79306
79367
  }
79307
79368
  function gmailReceivingMailboxes(mailboxNames) {
79308
- const lower = mailboxNames.map((n) => n.toLowerCase());
79369
+ const lower = mailboxNames.map((n) => mailboxLeaf(n).toLowerCase());
79309
79370
  if (!lower.includes("all mail")) return null;
79310
79371
  const present = GMAIL_INBOX_MAILBOXES.filter((want) => lower.includes(want.toLowerCase()));
79311
79372
  return present.length > 0 ? present : null;
@@ -79429,7 +79490,11 @@ var AppleMailManager = class {
79429
79490
  if (count of _acctM) is 1 then
79430
79491
  set _mbM to {}
79431
79492
  repeat with _m in (mailboxes of (item 1 of _acctM))
79432
- if (name of _m) is "${escapeForAppleScript(resolved)}" then set end of _mbM to _m
79493
+ set _mPath to ""
79494
+ ${mailboxPathFragment("_m", "_mPath")}
79495
+ ignoring case
79496
+ if _mPath is "${escapeForAppleScript(resolved)}" then set end of _mbM to _m
79497
+ end ignoring
79433
79498
  end repeat
79434
79499
  if (count of _mbM) is 1 then set _tmb to item 1 of _mbM
79435
79500
  end if`;
@@ -79539,11 +79604,16 @@ ${indent}set _out to _out & (_idx as string) & "${FIELD_SEP}error:" & _zErr & "$
79539
79604
  * are read from Mail at runtime (`_uacct`, `mailbox of _msg`) instead of being
79540
79605
  * interpolated as literals from here — so they get the same delimiter
79541
79606
  * stripping the literal paths get in TypeScript.
79607
+ *
79608
+ * Emits the canonical container-walked path, not the leaf — otherwise
79609
+ * `Inbox` and `Archive/Inbox` collapse into the same RECON record, and the
79610
+ * forensics comparison that reads it back (`sameMailbox` in
79611
+ * `recordSingleForensics`/the batch path) can be handed an ambiguous leaf.
79542
79612
  */
79543
79613
  reconEmitFromMessage(beforeVar, afterVar, posExpr = '""', indent = " ") {
79544
79614
  return `set _umbName to ""
79545
79615
  ${indent}try
79546
- ${indent} set _umbName to (name of _umb)
79616
+ ${mailboxPathFragment("_umb", "_umbName")}
79547
79617
  ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragment("_umbName", indent)}${this.reconEmit("_uacct", "_umbName", beforeVar, afterVar, posExpr)}`;
79548
79618
  }
79549
79619
  /**
@@ -80018,8 +80088,8 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
80018
80088
  return accounts;
80019
80089
  }
80020
80090
  /**
80021
- * Returns cached mailbox names for an account, or fetches fresh.
80022
- * This caches only the name list used by resolveMailbox(), not the
80091
+ * Returns cached canonical mailbox paths for an account, or fetches fresh.
80092
+ * This caches only the path list used by resolveMailbox(), not the
80023
80093
  * full Mailbox objects with counts (which change frequently).
80024
80094
  */
80025
80095
  getCachedMailboxNames(account) {
@@ -80221,31 +80291,22 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
80221
80291
  * @returns Actual mailbox name, or original if not found
80222
80292
  */
80223
80293
  resolveMailbox(mailbox, account) {
80224
- const actualMailboxes = this.getCachedMailboxNames(account);
80225
- if (actualMailboxes.length === 0) {
80226
- return mailbox;
80227
- }
80228
- if (actualMailboxes.includes(mailbox)) {
80294
+ return resolveAppleMailboxPath(mailbox, this.getCachedMailboxNames(account));
80295
+ }
80296
+ /**
80297
+ * Non-throwing `resolveMailbox`, for callers comparing mailbox names AFTER
80298
+ * a destructive op already ran (the forensics `sameMailbox` closures). An
80299
+ * ambiguous leaf there must not raise — the op already happened, and a
80300
+ * thrown error would misreport a successful move as a failure while the
80301
+ * message sits safely in its new mailbox. Falls back to the unresolved
80302
+ * input, which only degrades the self-move comparison, never the mutation.
80303
+ */
80304
+ resolveMailboxSafe(mailbox, account) {
80305
+ try {
80306
+ return this.resolveMailbox(mailbox, account);
80307
+ } catch {
80229
80308
  return mailbox;
80230
80309
  }
80231
- const lowerMailbox = mailbox.toLowerCase();
80232
- const caseMatch = actualMailboxes.find((mb) => mb.toLowerCase() === lowerMailbox);
80233
- if (caseMatch) {
80234
- return caseMatch;
80235
- }
80236
- const aliases = MAILBOX_ALIASES[lowerMailbox];
80237
- if (aliases) {
80238
- for (const alias of aliases) {
80239
- if (actualMailboxes.includes(alias)) {
80240
- return alias;
80241
- }
80242
- const aliasMatch = actualMailboxes.find((mb) => mb.toLowerCase() === alias.toLowerCase());
80243
- if (aliasMatch) {
80244
- return aliasMatch;
80245
- }
80246
- }
80247
- }
80248
- return mailbox;
80249
80310
  }
80250
80311
  // ===========================================================================
80251
80312
  // Message Operations
@@ -80304,20 +80365,26 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
80304
80365
  for (const acct of accounts) {
80305
80366
  if (allMessages.length >= limit) break;
80306
80367
  const remaining = limit - allMessages.length;
80307
- const res = this.searchMessagesWithDiagnostics(
80308
- query,
80309
- mailbox,
80310
- acct.name,
80311
- remaining,
80312
- dateFrom,
80313
- dateTo,
80314
- from,
80315
- subject,
80316
- isRead,
80317
- isFlagged
80318
- );
80319
- allMessages.push(...res.messages);
80320
- mergeSearchDiagnostics(diagnostics, res.diagnostics);
80368
+ try {
80369
+ const res = this.searchMessagesWithDiagnostics(
80370
+ query,
80371
+ mailbox,
80372
+ acct.name,
80373
+ remaining,
80374
+ dateFrom,
80375
+ dateTo,
80376
+ from,
80377
+ subject,
80378
+ isRead,
80379
+ isFlagged
80380
+ );
80381
+ allMessages.push(...res.messages);
80382
+ mergeSearchDiagnostics(diagnostics, res.diagnostics);
80383
+ } catch (err) {
80384
+ diagnostics.partial = true;
80385
+ const message = err instanceof Error ? err.message : String(err);
80386
+ diagnostics.notSearchedMailboxes.push(`${acct.name} / ${mailbox ?? "*"}: ${message}`);
80387
+ }
80321
80388
  }
80322
80389
  return { messages: allMessages.slice(0, limit), diagnostics };
80323
80390
  }
@@ -80343,10 +80410,14 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
80343
80410
  }
80344
80411
  const scanThreshold = getMailboxScanThreshold();
80345
80412
  let searchCommand;
80413
+ let resultMailbox = mailbox || "INBOX";
80414
+ let rowsIncludeMailbox = !mailbox;
80346
80415
  if (mailbox) {
80347
80416
  const targetMailbox = this.resolveMailbox(mailbox, targetAccount);
80417
+ resultMailbox = targetMailbox;
80348
80418
  const gmailInbox = isInboxScope(mailbox) ? gmailReceivingMailboxes(this.getCachedMailboxNames(targetAccount)) : null;
80349
80419
  if (gmailInbox) {
80420
+ rowsIncludeMailbox = true;
80350
80421
  const nameList = appleScriptLowerNameList(gmailInbox);
80351
80422
  searchCommand = `
80352
80423
  ${dateSetup}set outputText to ""
@@ -80359,12 +80430,12 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
80359
80430
  if msgCount >= ${limit} then exit repeat
80360
80431
  set mbName to ""
80361
80432
  try
80362
- set mbName to name of mb
80433
+ ${mailboxPathFragment("mb", "mbName")}
80363
80434
  end try
80364
80435
  ignoring case
80365
- if _wantNames contains mbName then
80436
+ if _wantNames contains (name of mb) then
80366
80437
  try
80367
- ${buildMessageRowLoop({ collection: `messages of mb ${searchCondition}`, limit, dedup: true, dateFilter })}
80438
+ ${buildMessageRowLoop({ collection: `messages of mb ${searchCondition}`, limit, dedup: true, dateFilter, trailing: ` & "${FIELD_SEP}" & mbName` })}
80368
80439
  on error _errMsg number _errNum
80369
80440
  set _timedOut to true
80370
80441
  set _notSearched to _notSearched & mbName & "${DIAG_ITEM_SEP}"
@@ -80379,14 +80450,19 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
80379
80450
  ${dateSetup}set outputText to ""
80380
80451
  set _timedOut to false
80381
80452
  set _notSearched to ""
80382
- set theMailbox to mailbox "${escapeForAppleScript(targetMailbox)}"
80453
+ ${mailboxLookupFragment(mbIter, targetMailbox, "theMailbox")}
80383
80454
  set msgCount to 0
80384
- try
80385
- ${buildMessageRowLoop({ collection: `messages of theMailbox ${searchCondition}`, limit, dateFilter })}
80386
- on error _errMsg number _errNum
80455
+ if theMailbox is missing value then
80387
80456
  set _timedOut to true
80388
80457
  set _notSearched to "${escapeForAppleScript(targetMailbox)}${DIAG_ITEM_SEP}"
80389
- end try
80458
+ else
80459
+ try
80460
+ ${buildMessageRowLoop({ collection: `messages of theMailbox ${searchCondition}`, limit, dateFilter })}
80461
+ on error _errMsg number _errNum
80462
+ set _timedOut to true
80463
+ set _notSearched to "${escapeForAppleScript(targetMailbox)}${DIAG_ITEM_SEP}"
80464
+ end try
80465
+ end if
80390
80466
  return outputText & "${DIAG_MARKER}timedOut=" & (_timedOut as string) & "${DIAG_FIELD_SEP}skipped=${DIAG_FIELD_SEP}notSearched=" & _notSearched
80391
80467
  `;
80392
80468
  }
@@ -80404,7 +80480,7 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
80404
80480
  if msgCount >= ${limit} then exit repeat
80405
80481
  set mbName to ""
80406
80482
  try
80407
- set mbName to name of mb
80483
+ ${mailboxPathFragment("mb", "mbName")}
80408
80484
  end try
80409
80485
  if ((current date) - _startedAt) > ${SEARCH_ACCOUNT_BUDGET_SECONDS} then
80410
80486
  set _timedOut to true
@@ -80444,15 +80520,15 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
80444
80520
  }
80445
80521
  };
80446
80522
  }
80447
- return this.parseSearchResult(result.output, mailbox || "INBOX", targetAccount);
80523
+ return this.parseSearchResult(result.output, resultMailbox, targetAccount, rowsIncludeMailbox);
80448
80524
  }
80449
80525
  /**
80450
80526
  * Split a per-account search payload into its message list and the DIAG
80451
80527
  * trailer, parse both, and return a SearchResult. See searchMessagesWithDiagnostics.
80452
80528
  */
80453
- parseSearchResult(output, mailbox, account) {
80529
+ parseSearchResult(output, mailbox, account, rowsIncludeMailbox = false) {
80454
80530
  const { payload, diagnostics } = splitSearchDiagnostics(output, account);
80455
- const messages = payload.trim() ? this.parseMessageList(payload, mailbox, account) : [];
80531
+ const messages = payload.trim() ? this.parseMessageList(payload, mailbox, account, rowsIncludeMailbox) : [];
80456
80532
  return { messages, diagnostics };
80457
80533
  }
80458
80534
  /**
@@ -80484,7 +80560,8 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
80484
80560
  set msgFlagged to flagged status of msg as string
80485
80561
  set msgJunk to junk mail status of msg as string
80486
80562
  set msgDeleted to deleted status of msg as string
80487
- set msgMailbox to name of mb
80563
+ set msgMailbox to ""
80564
+ ${mailboxPathFragment("mb", "msgMailbox")}
80488
80565
  set msgAccount to name of acct
80489
80566
  set hasAtt to "false"
80490
80567
  try
@@ -80545,7 +80622,9 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
80545
80622
  set targetMb to missing value
80546
80623
  ignoring case
80547
80624
  repeat with mb in _mbs
80548
- if (name of mb) is "${escapeForAppleScript(resolved)}" then
80625
+ set _mbPath to ""
80626
+ ${mailboxPathFragment("mb", "_mbPath")}
80627
+ if _mbPath is "${escapeForAppleScript(resolved)}" then
80549
80628
  set targetMb to mb
80550
80629
  exit repeat
80551
80630
  end if
@@ -80554,7 +80633,9 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
80554
80633
  set targetMb to missing value
80555
80634
  ignoring case
80556
80635
  repeat with mb in mailboxes of acct
80557
- if (name of mb) is "${escapeForAppleScript(resolved)}" then
80636
+ set _mbPath to ""
80637
+ ${mailboxPathFragment("mb", "_mbPath")}
80638
+ if _mbPath is "${escapeForAppleScript(resolved)}" then
80558
80639
  set targetMb to mb
80559
80640
  exit repeat
80560
80641
  end if
@@ -80799,9 +80880,15 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
80799
80880
  for (const acct of accounts) {
80800
80881
  if (allMessages.length >= limit) break;
80801
80882
  const remaining = limit - allMessages.length;
80802
- const res = this.listMessagesWithDiagnostics(mailbox, acct.name, remaining, from, offset);
80803
- allMessages.push(...res.messages);
80804
- mergeSearchDiagnostics(diagnostics, res.diagnostics);
80883
+ try {
80884
+ const res = this.listMessagesWithDiagnostics(mailbox, acct.name, remaining, from, offset);
80885
+ allMessages.push(...res.messages);
80886
+ mergeSearchDiagnostics(diagnostics, res.diagnostics);
80887
+ } catch (err) {
80888
+ diagnostics.partial = true;
80889
+ const message = err instanceof Error ? err.message : String(err);
80890
+ diagnostics.notSearchedMailboxes.push(`${acct.name} / ${mailbox ?? "*"}: ${message}`);
80891
+ }
80805
80892
  }
80806
80893
  return { messages: allMessages.slice(0, limit), diagnostics };
80807
80894
  }
@@ -80812,10 +80899,14 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
80812
80899
  const scanThreshold = getMailboxScanThreshold();
80813
80900
  const mbIter = local ? "_mbs" : "mailboxes";
80814
80901
  let listCommand;
80902
+ let resultMailbox = mailbox || "INBOX";
80903
+ let rowsIncludeMailbox = !mailbox;
80815
80904
  if (mailbox) {
80816
80905
  const targetMailbox = this.resolveMailbox(mailbox, targetAccount);
80906
+ resultMailbox = targetMailbox;
80817
80907
  const gmailInbox = isInboxScope(mailbox) ? gmailReceivingMailboxes(this.getCachedMailboxNames(targetAccount)) : null;
80818
80908
  if (gmailInbox) {
80909
+ rowsIncludeMailbox = true;
80819
80910
  const nameList = appleScriptLowerNameList(gmailInbox);
80820
80911
  listCommand = `
80821
80912
  set outputText to ""
@@ -80829,10 +80920,10 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
80829
80920
  if msgCount >= ${limit} then exit repeat
80830
80921
  set mbName to ""
80831
80922
  try
80832
- set mbName to name of mb
80923
+ ${mailboxPathFragment("mb", "mbName")}
80833
80924
  end try
80834
80925
  ignoring case
80835
- if _wantNames contains mbName then
80926
+ if _wantNames contains (name of mb) then
80836
80927
  try
80837
80928
  ${buildMessageRowLoop({ collection: `messages of mb ${fromFilter}`, limit, offset, dedup: true, withAttachments: true, trailing: ` & "${FIELD_SEP}" & mbName` })}
80838
80929
  on error _errMsg number _errNum
@@ -80849,15 +80940,20 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
80849
80940
  set outputText to ""
80850
80941
  set _timedOut to false
80851
80942
  set _notSearched to ""
80852
- set theMailbox to mailbox "${escapeForAppleScript(targetMailbox)}"
80943
+ ${mailboxLookupFragment(mbIter, targetMailbox, "theMailbox")}
80853
80944
  set msgCount to 0
80854
80945
  set skipped to 0
80855
- try
80856
- ${buildMessageRowLoop({ collection: `messages of theMailbox ${fromFilter}`, limit, offset, withAttachments: true })}
80857
- on error _errMsg number _errNum
80946
+ if theMailbox is missing value then
80858
80947
  set _timedOut to true
80859
80948
  set _notSearched to "${escapeForAppleScript(targetMailbox)}${DIAG_ITEM_SEP}"
80860
- end try
80949
+ else
80950
+ try
80951
+ ${buildMessageRowLoop({ collection: `messages of theMailbox ${fromFilter}`, limit, offset, withAttachments: true })}
80952
+ on error _errMsg number _errNum
80953
+ set _timedOut to true
80954
+ set _notSearched to "${escapeForAppleScript(targetMailbox)}${DIAG_ITEM_SEP}"
80955
+ end try
80956
+ end if
80861
80957
  return outputText & "${DIAG_MARKER}timedOut=" & (_timedOut as string) & "${DIAG_FIELD_SEP}skipped=${DIAG_FIELD_SEP}notSearched=" & _notSearched
80862
80958
  `;
80863
80959
  }
@@ -80876,7 +80972,7 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
80876
80972
  if msgCount >= ${limit} then exit repeat
80877
80973
  set mbName to ""
80878
80974
  try
80879
- set mbName to name of mb
80975
+ ${mailboxPathFragment("mb", "mbName")}
80880
80976
  end try
80881
80977
  if ((current date) - _startedAt) > ${SEARCH_ACCOUNT_BUDGET_SECONDS} then
80882
80978
  set _timedOut to true
@@ -80916,7 +81012,7 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
80916
81012
  }
80917
81013
  };
80918
81014
  }
80919
- return this.parseSearchResult(result.output, mailbox || "INBOX", targetAccount);
81015
+ return this.parseSearchResult(result.output, resultMailbox, targetAccount, rowsIncludeMailbox);
80920
81016
  }
80921
81017
  /**
80922
81018
  * Parse message list output from AppleScript.
@@ -80929,7 +81025,7 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
80929
81025
  * false-negative for MIME-embedded attachments (a known AppleScript
80930
81026
  * limitation). Use getMessage or list-attachments for authoritative info.
80931
81027
  */
80932
- parseMessageList(output, mailbox, account) {
81028
+ parseMessageList(output, mailbox, account, rowsIncludeMailbox = false) {
80933
81029
  const items = output.split(RECORD_SEP);
80934
81030
  const messages = [];
80935
81031
  for (const item of items) {
@@ -80937,9 +81033,9 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
80937
81033
  if (parts.length < 6) continue;
80938
81034
  let msgMailbox = mailbox;
80939
81035
  let hasAttachments = false;
80940
- if (parts.length >= 8) {
81036
+ if (rowsIncludeMailbox && parts.length >= 7) {
80941
81037
  msgMailbox = parts[6];
80942
- hasAttachments = parts[7] === "true";
81038
+ hasAttachments = parts.length >= 8 ? parts[7] === "true" : false;
80943
81039
  } else if (parts.length === 7) {
80944
81040
  hasAttachments = parts[6] === "true";
80945
81041
  }
@@ -81367,7 +81463,7 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
81367
81463
  const valid = [{ id, num: Number(id) }];
81368
81464
  const parsed = this.parseForensicStream(output, valid);
81369
81465
  const succeeded = parsed.okPositions.has(1);
81370
- const sameMailbox = (account, mailbox) => destination !== void 0 && destination.account === account && this.resolveMailbox(destination.mailbox, destination.account) === this.resolveMailbox(mailbox, account);
81466
+ const sameMailbox = (account, mailbox) => destination !== void 0 && destination.account === account && this.resolveMailboxSafe(destination.mailbox, destination.account) === this.resolveMailboxSafe(mailbox, account);
81371
81467
  const home = parsed.recons[0];
81372
81468
  this.lastForensics = this.buildForensicReport(
81373
81469
  parsed,
@@ -81540,9 +81636,9 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
81540
81636
  * Move a message to a destination mailbox, with full nested-mailbox support.
81541
81637
  *
81542
81638
  * Resolving the destination as `mailbox "X" of account "Y"` only finds
81543
- * top-level mailboxes, so nested destinations (e.g. a "Moore" subfolder)
81544
- * silently failed. Instead we walk the target account's full mailbox tree and
81545
- * match by name. Resolution is:
81639
+ * top-level mailboxes on some stores. Instead we walk the account's recursive
81640
+ * mailbox collection, reconstruct each container path, and match by path.
81641
+ * Resolution is:
81546
81642
  * - account-scoped (won't move to a same-named mailbox in another account)
81547
81643
  * - ambiguity-aware: if the name matches more than one mailbox in the
81548
81644
  * account we refuse to guess and return an error — silently moving mail to
@@ -81613,15 +81709,17 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
81613
81709
  return "1${FIELD_SEP}ok" & _pre & "${RECORD_SEP}" & _out`;
81614
81710
  const script = buildAppLevelScript(`
81615
81711
  try
81616
- -- \`mailboxes of account\` is already flat: it includes nested mailboxes
81617
- -- (named by path, e.g. "Processed/Vendors"). Descending via \`mailboxes of mb\`
81618
- -- is unreliable (it double-prepends the parent path), so we DON'T recurse \u2014
81619
- -- we match against this flat list by exact name and use the reference directly
81620
- -- (addressing \`mailbox "X" of account "Y"\` only finds some top-level mailboxes).
81712
+ -- \`mailboxes of account\` recursively includes nested mailboxes, but
81713
+ -- \`name of mb\` is only the leaf. Reconstruct each path from its
81714
+ -- container chain so Inbox and Archive/Inbox remain distinct.
81621
81715
  set destName to "${safeMailbox}"
81622
81716
  set destMatches to {}
81623
81717
  repeat with mb in (mailboxes of account "${safeAccount}")
81624
- if (name of mb) is destName then set end of destMatches to mb
81718
+ set _destPath to ""
81719
+ ${mailboxPathFragment("mb", "_destPath")}
81720
+ ignoring case
81721
+ if _destPath is destName then set end of destMatches to mb
81722
+ end ignoring
81625
81723
  end repeat
81626
81724
  if (count of destMatches) is 0 then return "error:Destination mailbox \\"" & destName & "\\" not found in account \\"${safeAccount}\\""
81627
81725
  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"
@@ -81927,7 +82025,7 @@ ${this.errorEmit(" ")}
81927
82025
  }
81928
82026
  const { okPositions } = parsed;
81929
82027
  const dest = forensics?.destination;
81930
- const sameMailbox = (account, mailbox) => dest !== void 0 && dest.account === account && this.resolveMailbox(dest.mailbox, dest.account) === this.resolveMailbox(mailbox, account);
82028
+ const sameMailbox = (account, mailbox) => dest !== void 0 && dest.account === account && this.resolveMailboxSafe(dest.mailbox, dest.account) === this.resolveMailboxSafe(mailbox, account);
81931
82029
  const expectedFor = (account, mailbox, pos) => {
81932
82030
  if (sameMailbox(account, mailbox)) return null;
81933
82031
  if (pos !== null) return okPositions.has(pos) ? 1 : 0;
@@ -81973,7 +82071,11 @@ ${this.errorEmit(" ")}
81973
82071
  set destName to "${safeMailbox}"
81974
82072
  set destMatches to {}
81975
82073
  repeat with _dmb in (mailboxes of account "${safeAccount}")
81976
- if (name of _dmb) is destName then set end of destMatches to _dmb
82074
+ set _destPath to ""
82075
+ ${mailboxPathFragment("_dmb", "_destPath")}
82076
+ ignoring case
82077
+ if _destPath is destName then set end of destMatches to _dmb
82078
+ end ignoring
81977
82079
  end repeat
81978
82080
  if (count of destMatches) is 0 then return "${BATCH_FATAL}Destination mailbox \\"" & destName & "\\" not found in account \\"${safeAccount}\\""
81979
82081
  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"
@@ -82206,10 +82308,11 @@ ${this.errorEmit(" ")}
82206
82308
  const listCommand = (iterExpr) => `
82207
82309
  set mailboxList to {}
82208
82310
  repeat with mb in ${iterExpr}
82209
- set mbName to name of mb
82311
+ set mbPath to ""
82312
+ ${mailboxPathFragment("mb", "mbPath")}
82210
82313
  set mbUnread to unread count of mb
82211
82314
  set mbCount to count of messages of mb
82212
- set end of mailboxList to mbName & "${FIELD_SEP}" & mbUnread & "${FIELD_SEP}" & mbCount
82315
+ set end of mailboxList to mbPath & "${FIELD_SEP}" & mbUnread & "${FIELD_SEP}" & mbCount
82213
82316
  end repeat
82214
82317
  set AppleScript's text item delimiters to "${RECORD_SEP}"
82215
82318
  return mailboxList as text
@@ -82257,8 +82360,10 @@ ${this.errorEmit(" ")}
82257
82360
  getUnreadCount(mailbox, account) {
82258
82361
  const targetAccount = this.resolveAccount(account);
82259
82362
  const targetMailbox = this.resolveMailbox(mailbox || "INBOX", targetAccount);
82260
- const safeMailbox = escapeForAppleScript(targetMailbox);
82261
- const command = `return unread count of mailbox "${safeMailbox}"`;
82363
+ const command = `
82364
+ ${mailboxLookupFragment("mailboxes", targetMailbox, "theMailbox")}
82365
+ if theMailbox is missing value then error "Mailbox \\"${escapeForAppleScript(targetMailbox)}\\" not found"
82366
+ return unread count of theMailbox`;
82262
82367
  const script = buildAccountScopedScript(targetAccount, command);
82263
82368
  const result = executeAppleScript(script, { timeoutMs: 6e4 });
82264
82369
  if (!result.success) {
@@ -82314,11 +82419,12 @@ ${this.errorEmit(" ")}
82314
82419
  return { success: false, error: disabled };
82315
82420
  }
82316
82421
  const targetMailbox = this.resolveMailbox(name, targetAccount);
82317
- const safeName = escapeForAppleScript(targetMailbox);
82318
82422
  const safeAccount = escapeForAppleScript(targetAccount);
82319
82423
  const script = buildAppLevelScript(`
82320
82424
  try
82321
- delete mailbox "${safeName}" of account "${safeAccount}"
82425
+ ${mailboxLookupFragment(`mailboxes of account "${safeAccount}"`, targetMailbox, "theMailbox")}
82426
+ if theMailbox is missing value then error "Mailbox \\"${escapeForAppleScript(targetMailbox)}\\" not found in account \\"${safeAccount}\\""
82427
+ delete theMailbox
82322
82428
  return "ok"
82323
82429
  on error errMsg
82324
82430
  return "error:" & errMsg
@@ -82344,6 +82450,14 @@ ${this.errorEmit(" ")}
82344
82450
  console.error(`Refusing to rename mailbox: ${serverSide}`);
82345
82451
  return { success: false, error: serverSide };
82346
82452
  }
82453
+ let resolvedOld;
82454
+ try {
82455
+ resolvedOld = this.resolveMailbox(oldName, targetAccount);
82456
+ } catch (err) {
82457
+ const error2 = err instanceof Error ? err.message : String(err);
82458
+ console.error(`Refusing to rename mailbox: ${error2}`);
82459
+ return { success: false, error: error2 };
82460
+ }
82347
82461
  const created = this.createMailbox(newName, targetAccount);
82348
82462
  if (!created.success) {
82349
82463
  return {
@@ -82351,15 +82465,25 @@ ${this.errorEmit(" ")}
82351
82465
  error: created.error ?? `Could not create the destination mailbox "${newName}" needed for the rename.`
82352
82466
  };
82353
82467
  }
82354
- const resolvedOld = this.resolveMailbox(oldName, targetAccount);
82355
- const resolvedNew = this.resolveMailbox(newName, targetAccount);
82356
- const safeOld = escapeForAppleScript(resolvedOld);
82357
- const safeNew = escapeForAppleScript(resolvedNew);
82468
+ let resolvedNew;
82469
+ try {
82470
+ resolvedNew = this.resolveMailbox(newName, targetAccount);
82471
+ } catch (err) {
82472
+ const rolledBack = this.deleteMailboxIfEmpty(newName, targetAccount);
82473
+ let error2 = err instanceof Error ? err.message : String(err);
82474
+ error2 += rolledBack ? ` The empty destination mailbox "${newName}" was rolled back, so no orphan was left.` : ` The destination mailbox "${newName}" was created and could not be auto-removed; delete it manually if it is an empty leftover.`;
82475
+ console.error(`Failed to rename mailbox: ${error2}`);
82476
+ this.invalidateCache();
82477
+ return { success: false, error: error2 };
82478
+ }
82358
82479
  const safeAccount = escapeForAppleScript(targetAccount);
82480
+ const mbColl = `mailboxes of account "${safeAccount}"`;
82359
82481
  const moveScript = buildAppLevelScript(`
82360
82482
  try
82361
- set srcMailbox to mailbox "${safeOld}" of account "${safeAccount}"
82362
- set destMailbox to mailbox "${safeNew}" of account "${safeAccount}"
82483
+ ${mailboxLookupFragment(mbColl, resolvedOld, "srcMailbox")}
82484
+ ${mailboxLookupFragment(mbColl, resolvedNew, "destMailbox")}
82485
+ if srcMailbox is missing value then error "Mailbox \\"${escapeForAppleScript(resolvedOld)}\\" not found in account \\"${safeAccount}\\""
82486
+ if destMailbox is missing value then error "Mailbox \\"${escapeForAppleScript(resolvedNew)}\\" not found in account \\"${safeAccount}\\""
82363
82487
  set srcCount to count of messages of srcMailbox
82364
82488
  set msgs to (every message of srcMailbox)
82365
82489
  repeat with m in msgs
@@ -82369,7 +82493,7 @@ ${this.errorEmit(" ")}
82369
82493
  end repeat
82370
82494
  set srcAfter to count of messages of srcMailbox
82371
82495
  if srcAfter is 0 then
82372
- delete mailbox "${safeOld}" of account "${safeAccount}"
82496
+ delete srcMailbox
82373
82497
  return "ok${FIELD_SEP}" & srcCount
82374
82498
  else
82375
82499
  return "partial${FIELD_SEP}" & (srcCount - srcAfter) & "${FIELD_SEP}" & srcCount & "${FIELD_SEP}" & srcAfter
@@ -82866,14 +82990,16 @@ end tell`;
82866
82990
  return accounts;
82867
82991
  }
82868
82992
  /**
82869
- * Fetches mailbox names for an account directly from Mail.app.
82993
+ * Fetches canonical mailbox paths for an account directly from Mail.app.
82870
82994
  * Used internally by the cache; prefer getCachedMailboxNames().
82871
82995
  */
82872
82996
  fetchMailboxNames(account) {
82873
82997
  const body = `
82874
82998
  set mbNames to {}
82875
82999
  repeat with mb in ${isLocalStoreLabel(account) ? "_mbs" : "mailboxes"}
82876
- set end of mbNames to name of mb
83000
+ set mbPath to ""
83001
+ ${mailboxPathFragment("mb", "mbPath")}
83002
+ set end of mbNames to mbPath
82877
83003
  end repeat
82878
83004
  return mbNames
82879
83005
  `;
@@ -82977,10 +83103,22 @@ end tell`;
82977
83103
  if (a.markFlagged) actionStmts.push(` set mark flagged of newRule to true`);
82978
83104
  if (a.delete) actionStmts.push(` set delete message of newRule to true`);
82979
83105
  if (a.moveTo) {
82980
- const safeMbox = escapeForAppleScript(a.moveTo);
82981
- const mboxRef = a.moveToAccount ? `mailbox "${safeMbox}" of account "${escapeForAppleScript(a.moveToAccount)}"` : `mailbox "${safeMbox}"`;
82982
83106
  actionStmts.push(` set should move message of newRule to true`);
82983
- actionStmts.push(` set move message of newRule to ${mboxRef}`);
83107
+ if (a.moveToAccount) {
83108
+ const resolvedMbox = this.resolveMailbox(a.moveTo, a.moveToAccount);
83109
+ const safeAccount = escapeForAppleScript(a.moveToAccount);
83110
+ actionStmts.push(
83111
+ ` ${mailboxLookupFragment(`mailboxes of account "${safeAccount}"`, resolvedMbox, "_ruleDestMb")}`
83112
+ );
83113
+ actionStmts.push(
83114
+ ` if _ruleDestMb is missing value then error "Mailbox \\"${escapeForAppleScript(resolvedMbox)}\\" not found in account \\"${safeAccount}\\""`
83115
+ );
83116
+ actionStmts.push(` set move message of newRule to _ruleDestMb`);
83117
+ } else {
83118
+ actionStmts.push(
83119
+ ` set move message of newRule to mailbox "${escapeForAppleScript(a.moveTo)}"`
83120
+ );
83121
+ }
82984
83122
  }
82985
83123
  if (!actionStmts.length) {
82986
83124
  return { success: false, error: "A rule needs at least one action." };
@@ -86996,7 +87134,7 @@ function appendLocalStoreRows(rows, failedAccounts) {
86996
87134
  registerTool(
86997
87135
  "list-mailboxes",
86998
87136
  {
86999
- description: '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. A source that could not be read is NAMED \u2014 the result carries `partial: true` + `failedAccounts` and the list is a floor, not the complete set \u2014 and a listing Mail refused outright (e.g. an account that does not exist) returns an ERROR naming the accounts that do exist, never an empty list.\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).\nNote: Mail\'s local "On My Mac" mailboxes are not part of any account, so they are reported under the synthetic account label "On My Mac" \u2014 an unscoped call includes them, and `account: "On My Mac"` lists only them. They will not appear in list-accounts, which reports real accounts only.',
87137
+ description: 'Use when: discovering the mailbox/folder paths (and unread/message counts) available in an account, e.g. before moving messages or searching a specific mailbox.\nReturns: each mailbox\'s canonical account-relative path in `name`, unread/message counts, and a total count. Use the full path for nested mailboxes (for example `Archive/Inbox`); a top-level `Inbox` remains `Inbox`. A source that could not be read is NAMED \u2014 the result carries `partial: true` + `failedAccounts` and the list is a floor, not the complete set \u2014 and a listing Mail refused outright (e.g. an account that does not exist) returns an ERROR naming the accounts that do exist, never an empty list.\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).\nNote: Mail\'s local "On My Mac" mailboxes are not part of any account, so they are reported under the synthetic account label "On My Mac" \u2014 an unscoped call includes them, and `account: "On My Mac"` lists only them. They will not appear in list-accounts, which reports real accounts only.',
87000
87138
  inputSchema: {
87001
87139
  account: external_exports.string().optional().describe("Account to list mailboxes from")
87002
87140
  },
@@ -60,8 +60,8 @@ the MCP runtime from your Homebrew/dev Node, which can keep updating freely.
60
60
  curl -O https://nodejs.org/dist/$VER/node-$VER-$ARCH.tar.gz
61
61
  curl -O https://nodejs.org/dist/$VER/SHASUMS256.txt
62
62
  grep " node-$VER-$ARCH.tar.gz$" SHASUMS256.txt | shasum -a 256 -c - # must print OK
63
- tar -xzf node-$VER-$ARCH.tar.gz
64
- ln -sfn node-$VER-$ARCH node-current
63
+ mkdir -p node-current
64
+ tar -xzf node-$VER-$ARCH.tar.gz --strip-components=1 -C node-current
65
65
  ```
66
66
 
67
67
  2. Confirm it's Developer-ID signed:
@@ -98,12 +98,36 @@ the MCP runtime from your Homebrew/dev Node, which can keep updating freely.
98
98
  - *Automation*: the first time the server drives an app you'll get a one-time
99
99
  `"node" wants to control "<App>"` prompt — click **Allow**.
100
100
 
101
- Both grants are keyed to the official Node's stable signature, so you should
102
- not be asked again including after future Node updates. You can delete the
103
- stale "node" rows from the Full Disk Access list.
101
+ ⚠️ **A TCC grant is keyed to the binary's resolved *path*, not only to its
102
+ signature.** Because the steps above unpack Node into a fixed directory
103
+ (`~/mcp-runtime/node-current`) rather than a versioned one, that path never
104
+ moves and the grants survive Node updates — see "Updating" below. If you
105
+ instead point `node-current` at a `node-vX.Y.Z-…` directory, every update
106
+ changes the resolved path, presents a brand-new ungranted identity, and you
107
+ will be re-prompted for all of it.
108
+
109
+ You can delete any stale "node" rows from the Full Disk Access list.
104
110
 
105
111
  ### Updating the dedicated Node later
106
112
 
107
- Drop a newer official LTS tarball into `~/mcp-runtime/`, repoint the
108
- `node-current` symlink, and restart your client. The signing identity is
109
- unchanged, so existing grants carry over — no re-approval.
113
+ Replace the **contents** of the same directory do not create a new one and do
114
+ not repoint anything:
115
+
116
+ ```bash
117
+ VER=v24.19.0 ARCH=darwin-arm64
118
+ cd ~/mcp-runtime
119
+ curl -O https://nodejs.org/dist/$VER/node-$VER-$ARCH.tar.gz
120
+ curl -O https://nodejs.org/dist/$VER/SHASUMS256.txt
121
+ grep " node-$VER-$ARCH.tar.gz$" SHASUMS256.txt | shasum -a 256 -c - # must print OK
122
+ rm -rf node-current && mkdir -p node-current
123
+ tar -xzf node-$VER-$ARCH.tar.gz --strip-components=1 -C node-current
124
+ ```
125
+
126
+ The path is unchanged and the official builds are Developer-ID signed with a
127
+ requirement that pins identifier + Team ID (no cdhash), so **both** halves of
128
+ the grant still match: existing grants carry over with no re-approval.
129
+
130
+ ⚠️ **Restart your MCP client afterwards.** Replacing the binary unlinks the one
131
+ any already-running server is executing; macOS then cannot validate that path,
132
+ so those processes fail with *"Permission denied"* until they are restarted.
133
+ Nothing is wrong with your grants — a freshly launched server works fine.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-mail-mcp",
3
- "version": "2.16.1",
3
+ "version": "2.17.0",
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",