apple-mail-mcp 2.20.6 → 2.20.8

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
@@ -331,7 +331,7 @@ Get the full content of a message.
331
331
 
332
332
  **Returns:** Subject line and message body (plain text by default, HTML if `preferHtml` is true and HTML content is available). `structuredContent` also carries `rfcMessageId` and, since 2.19.0, two dates: `dateSent` (the message's `Date:` header — Mail's `date sent`) and `dateReceived` (arrival in the mailbox — Mail's `date received` / IMAP `INTERNALDATE`). They differ legitimately by transit time; when they differ by **years**, the mailbox was migrated or re-imported and the arrival timestamp was reset — trust `dateSent` for chronology ([#224](https://github.com/sweetrb/apple-mail-mcp/issues/224)). Since 2.19.6 `dateSent` is **omitted** when it is more than 7 days later than `dateReceived`: a message cannot be sent after it arrived, and Mail.app substitutes a timestamp of its own for a `Date:` header it cannot parse ([#234](https://github.com/sweetrb/apple-mail-mcp/issues/234)). `isHtml` reports what was actually returned — a message with no `text/plain` part returns its HTML part with `isHtml: true`. Bodies are decoded by each part's declared `charset`, falling back to windows-1252 for bytes that are not valid UTF-8.
333
333
 
334
- **Numeric ids — how the message is found ([#270](https://github.com/sweetrb/apple-mail-mcp/issues/270)):** with `account` + `mailbox` the read opens that one mailbox and stops there. Otherwise it opens the mailbox the id was last listed or searched from (a remembered location that turns out stale — the message was moved, e.g. by another client — is forgotten), and only then scans every mailbox. That cross-mailbox scan skips any mailbox holding more than `APPLE_MAIL_MAX_BYID_SCAN_MAILBOX` messages (default `50000`; `0` disables the guard) and stops after ~9s, because Mail's `whose id is` probe costs seconds per hundred thousand messages; a miss then names the mailboxes it did not scan. Pass `account` + `mailbox` to read from one of those, or use the message's `imap:` id, which never scans. The same resolution backs `get-message-headers`, `get-thread`'s seed lookup, and the original-message read of `reply-to-message` / `forward-message` and the attachment tools.
334
+ **Numeric ids — how the message is found ([#270](https://github.com/sweetrb/apple-mail-mcp/issues/270)):** with `account` + `mailbox` the read opens that one mailbox and stops there. Otherwise it opens the mailbox the id was last listed or searched from (a remembered location that turns out stale — the message was moved, e.g. by another client — is forgotten), and only then scans every mailbox. That cross-mailbox scan skips any mailbox holding more than `APPLE_MAIL_MAX_BYID_SCAN_MAILBOX` messages (default `50000`; `0` disables the guard) and stops after ~9s, because Mail's `whose id is` probe costs seconds per hundred thousand messages; a miss then names the mailboxes it skipped and, if it stopped early, the ones it never reached. A mailbox whose size count does not answer within 3s is remembered — on disk, for 7 days, across restarts (`APPLE_MAIL_MCP_STALLED_MAILBOXES_FILE`, default `~/Library/Application Support/apple-mail-mcp/stalled-mailboxes.json`; delete it to forget) — and never counted again, since Mail.app keeps working on an abandoned count and everything after it would queue; the scan stops the moment such a count times out rather than queue behind it. If Mail.app is not running these reads say so instead of launching it. Pass `account` + `mailbox` to read from one of those, or use the message's `imap:` id, which never scans. The same resolution backs `get-message-headers`, `get-thread`'s seed lookup, and the original-message read of `reply-to-message` / `forward-message` and the attachment tools.
335
335
 
336
336
  > **Large messages / attachments:** reading a full message routes through
337
337
  > `osascript`, whose captured output buffer defaults to **64 MB**. Override it
@@ -2147,6 +2147,13 @@ In a JSON string literal, `\\` — two characters — denotes **one** literal ba
2147
2147
  skipped those because they hold more than `APPLE_MAIL_MAX_BYID_SCAN_MAILBOX` messages (default
2148
2148
  `50000`). The message may be in one of them: pass `account` + `mailbox` to read it from there, or
2149
2149
  use its `imap:` id ([#270](https://github.com/sweetrb/apple-mail-mcp/issues/270)).
2150
+ - "**stopped … before reaching: …**" lists the mailboxes the scan never got to (its ~9s budget ran
2151
+ out, or a mailbox's size count timed out and Mail.app was left busy finishing it). A timed-out
2152
+ mailbox is remembered for 7 days and skipped from then on, so retrying shortly usually covers the
2153
+ rest. "**Mail.app did not answer within 3s**" means Mail.app was still busy with such a count when
2154
+ the lookup started — retry in a little while.
2155
+ - "**Mail.app is not running**" — numeric-id reads need Mail.app open; the server never launches it
2156
+ for them. `imap:` ids read over IMAP without Mail.app.
2150
2157
 
2151
2158
  ### "... is present in more than one mailbox"
2152
2159
 
package/build/index.js CHANGED
@@ -7739,8 +7739,8 @@ var init_attachmentLimits = __esm({
7739
7739
 
7740
7740
  // src/utils/attachmentReadPolicy.ts
7741
7741
  import { realpathSync, statSync } from "fs";
7742
- import { homedir as homedir2, tmpdir } from "os";
7743
- import { delimiter, isAbsolute, join as join2, resolve, sep } from "path";
7742
+ import { homedir as homedir3, tmpdir } from "os";
7743
+ import { delimiter, isAbsolute, join as join3, resolve, sep } from "path";
7744
7744
  function canonicalize(path3) {
7745
7745
  return realpathSync.native(path3);
7746
7746
  }
@@ -7764,9 +7764,9 @@ function isProtectedPath(candidate) {
7764
7764
  if (sensitiveRoots().some((root) => isWithinRoot(candidate, root))) return true;
7765
7765
  let home;
7766
7766
  try {
7767
- home = canonicalize(homedir2());
7767
+ home = canonicalize(homedir3());
7768
7768
  } catch {
7769
- home = resolve(homedir2());
7769
+ home = resolve(homedir3());
7770
7770
  }
7771
7771
  if (!isWithinRoot(candidate, home)) return false;
7772
7772
  const relative = candidate.slice(home.length).split(sep).filter(Boolean);
@@ -7826,12 +7826,12 @@ var init_attachmentReadPolicy = __esm({
7826
7826
  "src/utils/attachmentReadPolicy.ts"() {
7827
7827
  "use strict";
7828
7828
  ATTACHMENT_READ_ROOTS_ENV = "APPLE_MAIL_MCP_ATTACHMENT_READ_ROOTS";
7829
- DEFAULT_ATTACHMENT_READ_ROOTS = [homedir2(), "/Volumes", tmpdir(), "/tmp", "/private/tmp"];
7829
+ DEFAULT_ATTACHMENT_READ_ROOTS = [homedir3(), "/Volumes", tmpdir(), "/tmp", "/private/tmp"];
7830
7830
  SENSITIVE_HOME_ROOTS = [
7831
- join2(homedir2(), ".ssh"),
7832
- join2(homedir2(), ".aws"),
7833
- join2(homedir2(), ".config", "gh"),
7834
- join2(homedir2(), "Library", "Keychains")
7831
+ join3(homedir3(), ".ssh"),
7832
+ join3(homedir3(), ".aws"),
7833
+ join3(homedir3(), ".config", "gh"),
7834
+ join3(homedir3(), "Library", "Keychains")
7835
7835
  ];
7836
7836
  }
7837
7837
  });
@@ -22061,7 +22061,7 @@ var require_thread_stream = __commonJS({
22061
22061
  var { version: version4 } = require_package();
22062
22062
  var { EventEmitter: EventEmitter7 } = __require("events");
22063
22063
  var { Worker } = __require("worker_threads");
22064
- var { join: join7 } = __require("path");
22064
+ var { join: join8 } = __require("path");
22065
22065
  var { pathToFileURL } = __require("url");
22066
22066
  var { wait } = require_wait();
22067
22067
  var {
@@ -22112,7 +22112,7 @@ var require_thread_stream = __commonJS({
22112
22112
  function createWorker(stream, opts) {
22113
22113
  const { filename, workerData } = opts;
22114
22114
  const bundlerOverrides = "__bundlerPathsOverrides" in globalThis ? globalThis.__bundlerPathsOverrides : {};
22115
- const toExecute = bundlerOverrides["thread-stream-worker"] || join7(__dirname, "lib", "worker.js");
22115
+ const toExecute = bundlerOverrides["thread-stream-worker"] || join8(__dirname, "lib", "worker.js");
22116
22116
  const worker = new Worker(toExecute, {
22117
22117
  ...opts.workerOpts,
22118
22118
  name: opts.workerOpts?.name || "thread-stream",
@@ -22580,7 +22580,7 @@ var require_transport = __commonJS({
22580
22580
  var { createRequire: createRequire2 } = __require("module");
22581
22581
  var { existsSync: existsSync5 } = __require("node:fs");
22582
22582
  var getCallers = require_caller();
22583
- var { join: join7, isAbsolute: isAbsolute2, sep: sep3 } = __require("node:path");
22583
+ var { join: join8, isAbsolute: isAbsolute2, sep: sep3 } = __require("node:path");
22584
22584
  var { fileURLToPath } = __require("node:url");
22585
22585
  var sleep2 = require_atomic_sleep();
22586
22586
  var onExit = require_on_exit_leak_free();
@@ -22733,7 +22733,7 @@ var require_transport = __commonJS({
22733
22733
  throw new Error("only one of target or targets can be specified");
22734
22734
  }
22735
22735
  if (targets) {
22736
- target = bundlerOverrides["pino-worker"] || join7(__dirname, "worker.js");
22736
+ target = bundlerOverrides["pino-worker"] || join8(__dirname, "worker.js");
22737
22737
  options.targets = targets.filter((dest) => dest.target).map((dest) => {
22738
22738
  return {
22739
22739
  ...dest,
@@ -22751,7 +22751,7 @@ var require_transport = __commonJS({
22751
22751
  });
22752
22752
  });
22753
22753
  } else if (pipeline) {
22754
- target = bundlerOverrides["pino-worker"] || join7(__dirname, "worker.js");
22754
+ target = bundlerOverrides["pino-worker"] || join8(__dirname, "worker.js");
22755
22755
  options.pipelines = [pipeline.map((dest) => {
22756
22756
  return {
22757
22757
  ...dest,
@@ -22774,7 +22774,7 @@ var require_transport = __commonJS({
22774
22774
  return origin;
22775
22775
  }
22776
22776
  if (origin === "pino/file") {
22777
- return join7(__dirname, "..", "file.js");
22777
+ return join8(__dirname, "..", "file.js");
22778
22778
  }
22779
22779
  let fixTarget2;
22780
22780
  for (const filePath of callers) {
@@ -23754,7 +23754,7 @@ var require_safe_stable_stringify = __commonJS({
23754
23754
  return circularValue;
23755
23755
  }
23756
23756
  let res = "";
23757
- let join7 = ",";
23757
+ let join8 = ",";
23758
23758
  const originalIndentation = indentation;
23759
23759
  if (Array.isArray(value)) {
23760
23760
  if (value.length === 0) {
@@ -23768,7 +23768,7 @@ var require_safe_stable_stringify = __commonJS({
23768
23768
  indentation += spacer;
23769
23769
  res += `
23770
23770
  ${indentation}`;
23771
- join7 = `,
23771
+ join8 = `,
23772
23772
  ${indentation}`;
23773
23773
  }
23774
23774
  const maximumValuesToStringify = Math.min(value.length, maximumBreadth);
@@ -23776,13 +23776,13 @@ ${indentation}`;
23776
23776
  for (; i < maximumValuesToStringify - 1; i++) {
23777
23777
  const tmp2 = stringifyFnReplacer(String(i), value, stack, replacer, spacer, indentation);
23778
23778
  res += tmp2 !== void 0 ? tmp2 : "null";
23779
- res += join7;
23779
+ res += join8;
23780
23780
  }
23781
23781
  const tmp = stringifyFnReplacer(String(i), value, stack, replacer, spacer, indentation);
23782
23782
  res += tmp !== void 0 ? tmp : "null";
23783
23783
  if (value.length - 1 > maximumBreadth) {
23784
23784
  const removedKeys = value.length - maximumBreadth - 1;
23785
- res += `${join7}"... ${getItemCount(removedKeys)} not stringified"`;
23785
+ res += `${join8}"... ${getItemCount(removedKeys)} not stringified"`;
23786
23786
  }
23787
23787
  if (spacer !== "") {
23788
23788
  res += `
@@ -23803,7 +23803,7 @@ ${originalIndentation}`;
23803
23803
  let separator = "";
23804
23804
  if (spacer !== "") {
23805
23805
  indentation += spacer;
23806
- join7 = `,
23806
+ join8 = `,
23807
23807
  ${indentation}`;
23808
23808
  whitespace = " ";
23809
23809
  }
@@ -23817,13 +23817,13 @@ ${indentation}`;
23817
23817
  const tmp = stringifyFnReplacer(key2, value, stack, replacer, spacer, indentation);
23818
23818
  if (tmp !== void 0) {
23819
23819
  res += `${separator}${strEscape(key2)}:${whitespace}${tmp}`;
23820
- separator = join7;
23820
+ separator = join8;
23821
23821
  }
23822
23822
  }
23823
23823
  if (keyLength > maximumBreadth) {
23824
23824
  const removedKeys = keyLength - maximumBreadth;
23825
23825
  res += `${separator}"...":${whitespace}"${getItemCount(removedKeys)} not stringified"`;
23826
- separator = join7;
23826
+ separator = join8;
23827
23827
  }
23828
23828
  if (spacer !== "" && separator.length > 1) {
23829
23829
  res = `
@@ -23864,7 +23864,7 @@ ${originalIndentation}`;
23864
23864
  }
23865
23865
  const originalIndentation = indentation;
23866
23866
  let res = "";
23867
- let join7 = ",";
23867
+ let join8 = ",";
23868
23868
  if (Array.isArray(value)) {
23869
23869
  if (value.length === 0) {
23870
23870
  return "[]";
@@ -23877,7 +23877,7 @@ ${originalIndentation}`;
23877
23877
  indentation += spacer;
23878
23878
  res += `
23879
23879
  ${indentation}`;
23880
- join7 = `,
23880
+ join8 = `,
23881
23881
  ${indentation}`;
23882
23882
  }
23883
23883
  const maximumValuesToStringify = Math.min(value.length, maximumBreadth);
@@ -23885,13 +23885,13 @@ ${indentation}`;
23885
23885
  for (; i < maximumValuesToStringify - 1; i++) {
23886
23886
  const tmp2 = stringifyArrayReplacer(String(i), value[i], stack, replacer, spacer, indentation);
23887
23887
  res += tmp2 !== void 0 ? tmp2 : "null";
23888
- res += join7;
23888
+ res += join8;
23889
23889
  }
23890
23890
  const tmp = stringifyArrayReplacer(String(i), value[i], stack, replacer, spacer, indentation);
23891
23891
  res += tmp !== void 0 ? tmp : "null";
23892
23892
  if (value.length - 1 > maximumBreadth) {
23893
23893
  const removedKeys = value.length - maximumBreadth - 1;
23894
- res += `${join7}"... ${getItemCount(removedKeys)} not stringified"`;
23894
+ res += `${join8}"... ${getItemCount(removedKeys)} not stringified"`;
23895
23895
  }
23896
23896
  if (spacer !== "") {
23897
23897
  res += `
@@ -23904,7 +23904,7 @@ ${originalIndentation}`;
23904
23904
  let whitespace = "";
23905
23905
  if (spacer !== "") {
23906
23906
  indentation += spacer;
23907
- join7 = `,
23907
+ join8 = `,
23908
23908
  ${indentation}`;
23909
23909
  whitespace = " ";
23910
23910
  }
@@ -23913,7 +23913,7 @@ ${indentation}`;
23913
23913
  const tmp = stringifyArrayReplacer(key2, value[key2], stack, replacer, spacer, indentation);
23914
23914
  if (tmp !== void 0) {
23915
23915
  res += `${separator}${strEscape(key2)}:${whitespace}${tmp}`;
23916
- separator = join7;
23916
+ separator = join8;
23917
23917
  }
23918
23918
  }
23919
23919
  if (spacer !== "" && separator.length > 1) {
@@ -23971,20 +23971,20 @@ ${originalIndentation}`;
23971
23971
  indentation += spacer;
23972
23972
  let res2 = `
23973
23973
  ${indentation}`;
23974
- const join8 = `,
23974
+ const join9 = `,
23975
23975
  ${indentation}`;
23976
23976
  const maximumValuesToStringify = Math.min(value.length, maximumBreadth);
23977
23977
  let i = 0;
23978
23978
  for (; i < maximumValuesToStringify - 1; i++) {
23979
23979
  const tmp2 = stringifyIndent(String(i), value[i], stack, spacer, indentation);
23980
23980
  res2 += tmp2 !== void 0 ? tmp2 : "null";
23981
- res2 += join8;
23981
+ res2 += join9;
23982
23982
  }
23983
23983
  const tmp = stringifyIndent(String(i), value[i], stack, spacer, indentation);
23984
23984
  res2 += tmp !== void 0 ? tmp : "null";
23985
23985
  if (value.length - 1 > maximumBreadth) {
23986
23986
  const removedKeys = value.length - maximumBreadth - 1;
23987
- res2 += `${join8}"... ${getItemCount(removedKeys)} not stringified"`;
23987
+ res2 += `${join9}"... ${getItemCount(removedKeys)} not stringified"`;
23988
23988
  }
23989
23989
  res2 += `
23990
23990
  ${originalIndentation}`;
@@ -24000,16 +24000,16 @@ ${originalIndentation}`;
24000
24000
  return '"[Object]"';
24001
24001
  }
24002
24002
  indentation += spacer;
24003
- const join7 = `,
24003
+ const join8 = `,
24004
24004
  ${indentation}`;
24005
24005
  let res = "";
24006
24006
  let separator = "";
24007
24007
  let maximumPropertiesToStringify = Math.min(keyLength, maximumBreadth);
24008
24008
  if (isTypedArrayWithEntries(value)) {
24009
- res += stringifyTypedArray(value, join7, maximumBreadth);
24009
+ res += stringifyTypedArray(value, join8, maximumBreadth);
24010
24010
  keys = keys.slice(value.length);
24011
24011
  maximumPropertiesToStringify -= value.length;
24012
- separator = join7;
24012
+ separator = join8;
24013
24013
  }
24014
24014
  if (deterministic) {
24015
24015
  keys = sort(keys, comparator);
@@ -24020,13 +24020,13 @@ ${indentation}`;
24020
24020
  const tmp = stringifyIndent(key2, value[key2], stack, spacer, indentation);
24021
24021
  if (tmp !== void 0) {
24022
24022
  res += `${separator}${strEscape(key2)}: ${tmp}`;
24023
- separator = join7;
24023
+ separator = join8;
24024
24024
  }
24025
24025
  }
24026
24026
  if (keyLength > maximumBreadth) {
24027
24027
  const removedKeys = keyLength - maximumBreadth;
24028
24028
  res += `${separator}"...": "${getItemCount(removedKeys)} not stringified"`;
24029
- separator = join7;
24029
+ separator = join8;
24030
24030
  }
24031
24031
  if (separator !== "") {
24032
24032
  res = `
@@ -82202,19 +82202,19 @@ import {
82202
82202
  constants as fsConstants,
82203
82203
  chmodSync,
82204
82204
  existsSync as existsSync3,
82205
- writeFileSync as writeFileSync3,
82206
- readFileSync as readFileSync2,
82205
+ writeFileSync as writeFileSync4,
82206
+ readFileSync as readFileSync3,
82207
82207
  readdirSync as readdirSync2,
82208
- unlinkSync,
82208
+ unlinkSync as unlinkSync2,
82209
82209
  copyFileSync,
82210
- renameSync,
82210
+ renameSync as renameSync2,
82211
82211
  mkdtempSync as mkdtempSync2,
82212
82212
  rmSync as rmSync2,
82213
82213
  realpathSync as realpathSync2,
82214
82214
  lstatSync
82215
82215
  } from "fs";
82216
- import { resolve as resolve2, sep as sep2, join as join5 } from "path";
82217
- import { homedir as homedir4 } from "os";
82216
+ import { resolve as resolve2, sep as sep2, join as join6 } from "path";
82217
+ import { homedir as homedir5 } from "os";
82218
82218
  import { randomUUID } from "crypto";
82219
82219
 
82220
82220
  // src/utils/applescript.ts
@@ -82548,11 +82548,120 @@ var TemplateStore = class {
82548
82548
  }
82549
82549
  };
82550
82550
 
82551
+ // src/services/stalledMailboxStore.ts
82552
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, renameSync, unlinkSync, writeFileSync as writeFileSync2 } from "fs";
82553
+ import { dirname as dirname2, join as join2 } from "path";
82554
+ import { homedir as homedir2 } from "os";
82555
+ var STALLED_MAILBOX_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
82556
+ var STALLED_KEY_SEP = "";
82557
+ function defaultStalledMailboxesFile() {
82558
+ const env = process.env.APPLE_MAIL_MCP_STALLED_MAILBOXES_FILE;
82559
+ if (env && env.trim()) return env.trim();
82560
+ return join2(
82561
+ homedir2(),
82562
+ "Library",
82563
+ "Application Support",
82564
+ "apple-mail-mcp",
82565
+ "stalled-mailboxes.json"
82566
+ );
82567
+ }
82568
+ var StalledMailboxStore = class {
82569
+ fileOverride;
82570
+ ttlMs;
82571
+ now;
82572
+ /** key → recordedAt. Holds everything learned, even when the disk write failed. */
82573
+ memory = /* @__PURE__ */ new Map();
82574
+ constructor(opts = {}) {
82575
+ this.fileOverride = opts.file;
82576
+ this.ttlMs = opts.ttlMs ?? STALLED_MAILBOX_TTL_MS;
82577
+ this.now = opts.now ?? Date.now;
82578
+ }
82579
+ /** Resolved per call so an env override set after construction (tests) is honoured. */
82580
+ get file() {
82581
+ return this.fileOverride ?? defaultStalledMailboxesFile();
82582
+ }
82583
+ /**
82584
+ * Every live (unexpired) key, merging what is on disk — possibly written by
82585
+ * another server process — with what this process learned. Re-read on each
82586
+ * call: the file is a handful of entries and is only consulted once per
82587
+ * unscoped scan, which itself costs seconds.
82588
+ */
82589
+ keys() {
82590
+ const merged = this.merged();
82591
+ return Array.from(merged.keys());
82592
+ }
82593
+ /** Record keys whose probe just timed out, and persist them. */
82594
+ record(keys) {
82595
+ const fresh = keys.filter((k) => k.includes(STALLED_KEY_SEP));
82596
+ if (fresh.length === 0) return;
82597
+ const at = this.now();
82598
+ for (const k of fresh) this.memory.set(k, at);
82599
+ this.persist(this.merged());
82600
+ }
82601
+ /** Disk ∪ memory, newest timestamp per key, expired entries dropped. */
82602
+ merged() {
82603
+ const cutoff = this.now() - this.ttlMs;
82604
+ const out = /* @__PURE__ */ new Map();
82605
+ const put = (k, at) => {
82606
+ if (at < cutoff) return;
82607
+ const prev = out.get(k);
82608
+ if (prev === void 0 || at > prev) out.set(k, at);
82609
+ };
82610
+ for (const [k, at] of this.readDisk()) put(k, at);
82611
+ for (const [k, at] of this.memory) put(k, at);
82612
+ for (const [k, at] of this.memory) if (at < cutoff) this.memory.delete(k);
82613
+ return out;
82614
+ }
82615
+ readDisk() {
82616
+ const out = /* @__PURE__ */ new Map();
82617
+ let text;
82618
+ try {
82619
+ text = readFileSync2(this.file, "utf8");
82620
+ } catch {
82621
+ return out;
82622
+ }
82623
+ try {
82624
+ const parsed = JSON.parse(text);
82625
+ const entries = parsed && Array.isArray(parsed.entries) ? parsed.entries : [];
82626
+ for (const e of entries) {
82627
+ if (e && typeof e.account === "string" && typeof e.mailbox === "string" && e.account && e.mailbox && typeof e.recordedAt === "number" && Number.isFinite(e.recordedAt)) {
82628
+ out.set(`${e.account}${STALLED_KEY_SEP}${e.mailbox}`, e.recordedAt);
82629
+ }
82630
+ }
82631
+ } catch (err) {
82632
+ console.error(`Ignoring unreadable stalled-mailbox list ${this.file}: ${String(err)}`);
82633
+ }
82634
+ return out;
82635
+ }
82636
+ persist(entries) {
82637
+ const data = {
82638
+ version: 1,
82639
+ entries: Array.from(entries, ([k, recordedAt]) => {
82640
+ const i = k.indexOf(STALLED_KEY_SEP);
82641
+ return { account: k.slice(0, i), mailbox: k.slice(i + 1), recordedAt };
82642
+ })
82643
+ };
82644
+ const file = this.file;
82645
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
82646
+ try {
82647
+ mkdirSync2(dirname2(file), { recursive: true });
82648
+ writeFileSync2(tmp, JSON.stringify(data, null, 2) + "\n");
82649
+ renameSync(tmp, file);
82650
+ } catch (err) {
82651
+ try {
82652
+ unlinkSync(tmp);
82653
+ } catch {
82654
+ }
82655
+ console.error(`Failed to persist stalled-mailbox list to ${file}: ${String(err)}`);
82656
+ }
82657
+ }
82658
+ };
82659
+
82551
82660
  // src/utils/attachmentMaterialize.ts
82552
82661
  init_attachmentLimits();
82553
82662
  init_attachmentReadPolicy();
82554
- import { writeFileSync as writeFileSync2, rmSync, mkdtempSync } from "fs";
82555
- import { join as join3 } from "path";
82663
+ import { writeFileSync as writeFileSync3, rmSync, mkdtempSync } from "fs";
82664
+ import { join as join4 } from "path";
82556
82665
  import { tmpdir as tmpdir2 } from "os";
82557
82666
  function materializeAttachments(attachments) {
82558
82667
  if (!attachments || attachments.length === 0) {
@@ -82566,10 +82675,10 @@ function materializeAttachments(attachments) {
82566
82675
  if (!a.filename || !a.contentBase64) {
82567
82676
  throw new Error("Inline attachment requires both filename and contentBase64.");
82568
82677
  }
82569
- if (!dir) dir = mkdtempSync(join3(tmpdir2(), "amcp-att-"));
82678
+ if (!dir) dir = mkdtempSync(join4(tmpdir2(), "amcp-att-"));
82570
82679
  const safeName = a.filename.replace(/[/\\]/g, "_");
82571
- const p = join3(dir, safeName);
82572
- writeFileSync2(p, decodeInlineAttachment(a.contentBase64), { mode: 384 });
82680
+ const p = join4(dir, safeName);
82681
+ writeFileSync3(p, decodeInlineAttachment(a.contentBase64), { mode: 384 });
82573
82682
  return p;
82574
82683
  });
82575
82684
  } catch (error3) {
@@ -82601,8 +82710,8 @@ init_attachmentReadPolicy();
82601
82710
 
82602
82711
  // src/utils/contactsDb.ts
82603
82712
  import { existsSync as existsSync2, readdirSync } from "fs";
82604
- import { join as join4 } from "path";
82605
- import { homedir as homedir3 } from "os";
82713
+ import { join as join5 } from "path";
82714
+ import { homedir as homedir4 } from "os";
82606
82715
  function loadSqlite() {
82607
82716
  try {
82608
82717
  const mod = __require("node:sqlite");
@@ -82621,11 +82730,11 @@ function loadSqlite() {
82621
82730
  }
82622
82731
  }
82623
82732
  function resolveContactsDbPaths(baseDir) {
82624
- const root = baseDir ?? join4(homedir3(), "Library", "Application Support", "AddressBook");
82733
+ const root = baseDir ?? join5(homedir4(), "Library", "Application Support", "AddressBook");
82625
82734
  const paths = [];
82626
- const topLevel = join4(root, "AddressBook-v22.abcddb");
82735
+ const topLevel = join5(root, "AddressBook-v22.abcddb");
82627
82736
  if (existsSync2(topLevel)) paths.push(topLevel);
82628
- const sourcesDir = join4(root, "Sources");
82737
+ const sourcesDir = join5(root, "Sources");
82629
82738
  if (existsSync2(sourcesDir)) {
82630
82739
  let entries = [];
82631
82740
  try {
@@ -82634,7 +82743,7 @@ function resolveContactsDbPaths(baseDir) {
82634
82743
  entries = [];
82635
82744
  }
82636
82745
  for (const entry of entries) {
82637
- const candidate = join4(sourcesDir, entry, "AddressBook-v22.abcddb");
82746
+ const candidate = join5(sourcesDir, entry, "AddressBook-v22.abcddb");
82638
82747
  if (existsSync2(candidate)) paths.push(candidate);
82639
82748
  }
82640
82749
  }
@@ -82737,31 +82846,69 @@ function getByIdScanThreshold() {
82737
82846
  var BYID_SCAN_BUDGET_SECONDS = 9;
82738
82847
  var BYID_PROBE_TIMEOUT_SECONDS = 3;
82739
82848
  var BYID_COVERAGE_MARKER = "COV";
82849
+ var BYID_BUSY_MARKER = "BUSY";
82850
+ var MAIL_NOT_RUNNING_MARKER = "NOTRUNNING";
82851
+ var UNREACHED_LIST_MAX = 15;
82740
82852
  var SCOPED_MISS_MARKER = "SCOPED";
82853
+ function mailNotRunningForId(id) {
82854
+ return `Mail.app is not running, so message ${Number(id)} could not be looked up by its numeric Mail.app id (this server does not launch Mail.app by itself). Open Mail.app and retry, or use the message's imap: id from list-messages/search-messages, which reads over IMAP without Mail.app.`;
82855
+ }
82856
+ function mailBusyForId(id) {
82857
+ return `Mail.app did not answer within ${BYID_PROBE_TIMEOUT_SECONDS}s, so message ${Number(id)} was not looked up \u2014 it is still busy with an earlier request (most often a size count on a very large mailbox that a previous lookup gave up on; Mail.app finishes it regardless). Retry in a little while, or use the message's imap: id from list-messages/search-messages.`;
82858
+ }
82859
+ function summariseList(items, max) {
82860
+ if (items.length <= max) return items.join(", ");
82861
+ return `${items.slice(0, max).join(", ")} and ${items.length - max} more`;
82862
+ }
82741
82863
  function describeUnscopedMiss(id, raw) {
82742
82864
  const covIdx = raw.indexOf(BYID_COVERAGE_MARKER);
82743
82865
  if (covIdx === -1) return raw.trim();
82744
82866
  const base2 = raw.slice(0, covIdx).trim();
82745
- const [skippedRaw = "", budgetRaw = ""] = raw.slice(covIdx + BYID_COVERAGE_MARKER.length).split(DIAG_FIELD_SEP);
82746
- const skipped = skippedRaw.split(DIAG_ITEM_SEP).map((s) => s.trim()).filter(Boolean);
82867
+ const [skippedRaw = "", budgetRaw = "", stalledRaw = "", unreachedRaw = ""] = raw.slice(covIdx + BYID_COVERAGE_MARKER.length).split(DIAG_FIELD_SEP);
82868
+ const list = (s) => s.split(DIAG_ITEM_SEP).map((x) => x.trim()).filter(Boolean);
82869
+ const skipped = list(skippedRaw);
82870
+ const unreached = list(unreachedRaw);
82747
82871
  const budgetHit = budgetRaw.trim() === "true";
82748
- if (skipped.length === 0 && !budgetHit) return base2;
82872
+ const probeStalled = stalledRaw.trim() === "true";
82873
+ if (skipped.length === 0 && !budgetHit && !probeStalled) return base2;
82749
82874
  const parts = [`Message ${Number(id)} not found in the mailboxes scanned.`];
82750
82875
  if (skipped.length > 0) {
82751
82876
  parts.push(
82752
82877
  `Not scanned \u2014 too large for a cross-mailbox id scan (over ${getByIdScanThreshold()} messages; APPLE_MAIL_MAX_BYID_SCAN_MAILBOX): ${skipped.join(", ")}.`
82753
82878
  );
82754
82879
  }
82755
- if (budgetHit) {
82880
+ const reach = unreached.length > 0 ? `before reaching: ${summariseList(unreached, UNREACHED_LIST_MAX)}.` : "before reaching every mailbox.";
82881
+ if (probeStalled) {
82756
82882
  parts.push(
82757
- `The scan also stopped after its ${BYID_SCAN_BUDGET_SECONDS}s budget before reaching every mailbox.`
82883
+ `The scan stopped early, ${reach} A mailbox's size count did not answer within ${BYID_PROBE_TIMEOUT_SECONDS}s, and Mail.app keeps working on an abandoned count, so anything asked after it would only queue behind it. That mailbox is now remembered and skipped for ${Math.round(STALLED_MAILBOX_TTL_MS / 864e5)} days \u2014 retry in a little while to scan the rest.`
82758
82884
  );
82885
+ } else if (budgetHit) {
82886
+ parts.push(`The scan also stopped after its ${BYID_SCAN_BUDGET_SECONDS}s budget ${reach}`);
82759
82887
  }
82760
82888
  parts.push(
82761
82889
  "If the message is in a mailbox that was not scanned, pass account + mailbox to read it from that mailbox directly, or use its imap: id from list-messages/search-messages."
82762
82890
  );
82763
82891
  return parts.join(" ");
82764
82892
  }
82893
+ var STALLED_PROBE_SUFFIX = ` (size unknown (count probe exceeded ${BYID_PROBE_TIMEOUT_SECONDS}s))`;
82894
+ function extractFreshlyStalledMailboxes(raw) {
82895
+ const covIdx = raw.indexOf(BYID_COVERAGE_MARKER);
82896
+ if (covIdx === -1) return [];
82897
+ const [skippedRaw = ""] = raw.slice(covIdx + BYID_COVERAGE_MARKER.length).split(DIAG_FIELD_SEP);
82898
+ const out = [];
82899
+ for (const entry of skippedRaw.split(DIAG_ITEM_SEP)) {
82900
+ const trimmed = entry.trim();
82901
+ if (!trimmed.endsWith(STALLED_PROBE_SUFFIX)) continue;
82902
+ const withoutSuffix = trimmed.slice(0, -STALLED_PROBE_SUFFIX.length);
82903
+ const sepIdx = withoutSuffix.indexOf(" / ");
82904
+ if (sepIdx === -1) continue;
82905
+ const account = withoutSuffix.slice(0, sepIdx);
82906
+ const path3 = withoutSuffix.slice(sepIdx + 3);
82907
+ const leaf = path3.split("/").pop();
82908
+ if (account && leaf) out.push(`${account}${STALLED_KEY_SEP}${leaf}`);
82909
+ }
82910
+ return out;
82911
+ }
82765
82912
  var GROUP_SEP = "";
82766
82913
  var FIELD_SEP = "";
82767
82914
  var RECORD_SEP = "";
@@ -82826,7 +82973,7 @@ function splitSearchDiagnostics(output, account) {
82826
82973
  }
82827
82974
  return { payload, diagnostics };
82828
82975
  }
82829
- var ALLOWED_SAVE_ROOTS = [homedir4(), "/tmp", "/private/tmp", "/Volumes"];
82976
+ var ALLOWED_SAVE_ROOTS = [homedir5(), "/tmp", "/private/tmp", "/Volumes"];
82830
82977
  function isPathWithinAllowedRoots(resolvedPath) {
82831
82978
  return ALLOWED_SAVE_ROOTS.some((root) => {
82832
82979
  const base2 = root.endsWith(sep2) ? root.slice(0, -1) : root;
@@ -83120,6 +83267,24 @@ function buildAppLevelScript(command) {
83120
83267
  end tell
83121
83268
  `;
83122
83269
  }
83270
+ function buildAppLevelScriptIfRunning(command) {
83271
+ return guardMailRunning(buildAppLevelScript(command));
83272
+ }
83273
+ function guardMailRunning(script) {
83274
+ return `
83275
+ if application "Mail" is running then
83276
+ ${script}
83277
+ else
83278
+ return "${MAIL_NOT_RUNNING_MARKER}"
83279
+ end if
83280
+ `;
83281
+ }
83282
+ function looksLikeMailNotRunning(error3) {
83283
+ if (!error3) return false;
83284
+ return /application isn't running|\(-600\)|Mail\.app is not responding\. Try opening Mail\.app/i.test(
83285
+ error3
83286
+ );
83287
+ }
83123
83288
  function mailboxPathFragment(mailboxVar, outputVar) {
83124
83289
  return `
83125
83290
  set ${outputVar} to name of ${mailboxVar}
@@ -83272,6 +83437,23 @@ var AppleMailManager = class {
83272
83437
  * cross-mailbox scan — see `resolveScopedById` (#270).
83273
83438
  */
83274
83439
  idLocationIndex = /* @__PURE__ */ new Map();
83440
+ /**
83441
+ * "Account\x1fLeafMailboxName" pairs whose `count of messages` size-guard
83442
+ * probe has previously exceeded BYID_PROBE_TIMEOUT_SECONDS (#270 follow-up,
83443
+ * @j5pu). The 3s `with timeout of` wrapper only stops the SERVER's script
83444
+ * from waiting — Mail.app handles Apple events one at a time and keeps
83445
+ * running the abandoned count to completion regardless, so re-probing the
83446
+ * same mailbox on the next call doesn't just waste its own 3s, it queues
83447
+ * behind whatever is still draining inside Mail.app. Once a mailbox has
83448
+ * demonstrated this, the unscoped scan skips the probe for it entirely —
83449
+ * no `count of messages` call at all — straight to "size unknown,
83450
+ * previously timed out". Keyed on the leaf mailbox name (not the
83451
+ * container-walked path `mailboxPathFragment` builds for display), matched
83452
+ * against names the scan prefetches once per account, so checking it costs
83453
+ * no Apple event per mailbox visited. Persisted across restarts and shared
83454
+ * between server processes, with a 7-day TTL (see StalledMailboxStore).
83455
+ */
83456
+ stalledMailboxes = new StalledMailboxStore();
83275
83457
  /** Error from the most recent numeric message read, if it was refused. */
83276
83458
  lastMessageLookupError;
83277
83459
  /** Cap on the id→location index so a long-lived process can't grow unbounded. */
@@ -83934,6 +84116,7 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
83934
84116
  return cached2.data;
83935
84117
  }
83936
84118
  const names = this.fetchMailboxNames(account);
84119
+ if (names === null) return [];
83937
84120
  this.cache.mailboxNames.set(account, { data: names, expiry: now + this.CACHE_TTL_MS });
83938
84121
  return names;
83939
84122
  }
@@ -84458,15 +84641,24 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84458
84641
  if (!result.success || !result.output.trim()) {
84459
84642
  if (!result.success) {
84460
84643
  if (result.timedOut) this.lastMessageLookupError = timedOutScanningForId(id);
84644
+ else if (looksLikeMailNotRunning(result.error))
84645
+ this.lastMessageLookupError = mailNotRunningForId(id);
84461
84646
  console.error(`Failed to resolve message ${id}: ${result.error}`);
84462
84647
  }
84463
84648
  return null;
84464
84649
  }
84650
+ if (result.output.startsWith(MAIL_NOT_RUNNING_MARKER)) {
84651
+ this.lastMessageLookupError = mailNotRunningForId(id);
84652
+ return null;
84653
+ }
84654
+ if (result.output.startsWith(LOOKUP_ERROR_MARKER + BYID_BUSY_MARKER)) {
84655
+ this.lastMessageLookupError = mailBusyForId(id);
84656
+ return null;
84657
+ }
84465
84658
  if (result.output.startsWith(LOOKUP_ERROR_MARKER)) {
84466
- this.lastMessageLookupError = describeUnscopedMiss(
84467
- id,
84468
- result.output.slice(LOOKUP_ERROR_MARKER.length)
84469
- );
84659
+ const rawTail = result.output.slice(LOOKUP_ERROR_MARKER.length);
84660
+ this.stalledMailboxes.record(extractFreshlyStalledMailboxes(rawTail));
84661
+ this.lastMessageLookupError = describeUnscopedMiss(id, rawTail);
84470
84662
  return null;
84471
84663
  }
84472
84664
  return result.output;
@@ -84483,6 +84675,10 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84483
84675
  { timeoutMs: READ_ONLY_BYID_TIMEOUT_MS }
84484
84676
  );
84485
84677
  const where = `${loc.account} / ${loc.mailbox}`;
84678
+ if (result.success && result.output.startsWith(MAIL_NOT_RUNNING_MARKER) || !result.success && !result.timedOut && looksLikeMailNotRunning(result.error)) {
84679
+ this.lastMessageLookupError = mailNotRunningForId(id);
84680
+ return { kind: "done" };
84681
+ }
84486
84682
  if (!result.success) {
84487
84683
  console.error(`Failed to read message ${id} from ${where}: ${result.error}`);
84488
84684
  if (result.timedOut) {
@@ -84534,69 +84730,171 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84534
84730
  * instead of a false flat "not found". A copy that sits only in a skipped
84535
84731
  * mailbox therefore no longer counts toward the ambiguity check — harmless
84536
84732
  * for these read-only lookups, whose copies are the same message.
84733
+ *
84734
+ * #270 follow-up (@j5pu, 2.20.7 retest) — three refinements:
84735
+ *
84736
+ * - Names are prefetched: account names once (`name of every account`)
84737
+ * and each account's mailbox leaf names once (`name of mailboxes of
84738
+ * acct`), so the remembered-stalled check is a local string comparison
84739
+ * instead of two Apple events per remembered entry per mailbox visited,
84740
+ * and an early stop can name every mailbox it never reached without
84741
+ * asking Mail.app anything more.
84742
+ * - The account-name prefetch is itself capped at BYID_PROBE_TIMEOUT_SECONDS.
84743
+ * It is trivially cheap, so if it does not answer Mail.app is still busy
84744
+ * (usually draining a count an earlier call abandoned): return
84745
+ * BYID_BUSY_MARKER rather than walk on and let small mailboxes' probes
84746
+ * time out behind it — which would wrongly remember THEM as stalled.
84747
+ * - A probe that times out (-1712) STOPS the walk on the spot. Mail.app
84748
+ * keeps running the abandoned count, so every later event — the next
84749
+ * probe, a `whose` scan, even the container walk that used to build the
84750
+ * skip entry's display path — would queue behind it until the 15s
84751
+ * SIGKILL, losing the very result that records the stall. The skip entry
84752
+ * is built from the prefetched leaf name (no Apple event) and the script
84753
+ * returns at once, so the caller always learns and persists it.
84537
84754
  */
84538
84755
  unscopedByIdScript(id, innerAction, opts = {}) {
84539
84756
  const threshold = getByIdScanThreshold();
84540
- const sizeGuard = (mbVar, acctNameExpr) => threshold > 0 ? `set _mbCount to -1
84757
+ const stalledKeys = threshold > 0 ? this.stalledMailboxes.keys() : [];
84758
+ const stalledList = `{${stalledKeys.map((key) => {
84759
+ const i = key.indexOf(STALLED_KEY_SEP);
84760
+ return `"${escapeForAppleScript(key.slice(0, i))}${FIELD_SEP}${escapeForAppleScript(key.slice(i + 1))}"`;
84761
+ }).join(", ")}}`;
84762
+ const stalledLabel = `size unknown (count probe exceeded ${BYID_PROBE_TIMEOUT_SECONDS}s)`;
84763
+ const sizeGuard = (mbVar, acctNameExpr, mbNameExpr) => {
84764
+ if (threshold <= 0) return "";
84765
+ const skipEntry = (sizeLabelExpr) => `
84766
+ set _skipPath to ""
84767
+ try
84768
+ ${mailboxPathFragment(mbVar, "_skipPath")}
84769
+ end try
84770
+ set _skipped to _skipped & ${acctNameExpr} & " / " & _skipPath & " (" & ${sizeLabelExpr} & ")${DIAG_ITEM_SEP}"
84771
+ set _probe to false`;
84772
+ const probeBlock = `set _mbCount to -1
84773
+ set _probeErr to 0
84541
84774
  try
84542
84775
  with timeout of ${BYID_PROBE_TIMEOUT_SECONDS} seconds
84543
84776
  set _mbCount to count of messages of ${mbVar}
84544
84777
  end timeout
84778
+ on error number _probeErrNum
84779
+ set _probeErr to _probeErrNum
84545
84780
  end try
84546
- if _mbCount < 0 or _mbCount > ${threshold} then
84547
- set _skipPath to ""
84548
- try
84549
- ${mailboxPathFragment(mbVar, "_skipPath")}
84550
- end try
84551
- set _sizeLabel to "size unknown (count probe exceeded ${BYID_PROBE_TIMEOUT_SECONDS}s)"
84552
- if _mbCount > -1 then set _sizeLabel to (_mbCount as string)
84553
- set _skipped to _skipped & ${acctNameExpr} & " / " & _skipPath & " (" & _sizeLabel & ")${DIAG_ITEM_SEP}"
84781
+ if _probeErr is -1712 then
84782
+ set _skipped to _skipped & ${acctNameExpr} & " / " & ${mbNameExpr} & " (${stalledLabel})${DIAG_ITEM_SEP}"
84554
84783
  set _probe to false
84555
- end if` : "";
84556
- const budgetCheck = `if ((current date) - _startedAt) > ${BYID_SCAN_BUDGET_SECONDS} then
84784
+ set _probeStalled to true
84785
+ else if _mbCount < 0 or _mbCount > ${threshold} then
84786
+ set _sizeLabel to "size unknown (count failed)"
84787
+ if _mbCount > -1 then set _sizeLabel to (_mbCount as string)
84788
+ ${skipEntry("_sizeLabel")}
84789
+ end if`;
84790
+ if (stalledKeys.length === 0) return probeBlock;
84791
+ return `if _stalledKeys contains {(${acctNameExpr} & "${FIELD_SEP}" & ${mbNameExpr})} then
84792
+ ${skipEntry('"size unknown (previously timed out)"')}
84793
+ else
84794
+ ${probeBlock}
84795
+ end if`;
84796
+ };
84797
+ const budgetCheck = (stopAtExpr) => `if ((current date) - _startedAt) > ${BYID_SCAN_BUDGET_SECONDS} then
84557
84798
  set _budgetHit to true
84799
+ set _stopAt to ${stopAtExpr}
84558
84800
  exit repeat
84559
84801
  end if`;
84802
+ const hitFound = opts.firstHit ? "(count of _hits) > 0" : "false";
84560
84803
  const stopOnHit = opts.firstHit ? `if (count of _hits) > 0 then exit repeat` : "";
84561
- return buildAppLevelScript(`
84804
+ return buildAppLevelScriptIfRunning(`
84562
84805
  try
84563
84806
  set _hits to {}
84564
84807
  set _hitMbs to {}
84565
84808
  set _hitAccts to {}
84566
84809
  set _names to ""
84567
84810
  set _skipped to ""
84811
+ set _unreached to ""
84568
84812
  set _budgetHit to false
84813
+ set _probeStalled to false
84814
+ set _stopAt to 0
84815
+ set _stalledKeys to ${stalledList}
84569
84816
  set _startedAt to current date
84817
+ set _acctNames to {}
84818
+ try
84819
+ with timeout of ${BYID_PROBE_TIMEOUT_SECONDS} seconds
84820
+ set _acctNames to name of every account
84821
+ end timeout
84822
+ on error number _busyErrNum
84823
+ if _busyErrNum is -1712 then return "${LOOKUP_ERROR_MARKER}${BYID_BUSY_MARKER}"
84824
+ end try
84825
+ set _ai to 0
84570
84826
  repeat with acct in accounts
84571
- if _budgetHit then exit repeat
84572
- ${stopOnHit}
84573
- repeat with mb in mailboxes of acct
84574
- ${budgetCheck}
84575
- set _probe to true
84576
- ${sizeGuard("mb", "(name of acct)")}
84577
- if _probe then
84578
- try
84579
- set matchingMsgs to (messages of mb whose id is ${Number(id)})
84580
- if (count of matchingMsgs) > 0 then
84581
- set end of _hits to item 1 of matchingMsgs
84582
- set end of _hitMbs to (contents of mb)
84583
- set end of _hitAccts to (name of acct)
84584
- set _names to _names & (name of acct) & "/" & (name of mb) & ", "
84585
- end if
84586
- end try
84827
+ set _ai to _ai + 1
84828
+ if ${hitFound} then exit repeat
84829
+ set _acctName to ""
84830
+ if _ai <= (length of _acctNames) then set _acctName to item _ai of _acctNames
84831
+ if _acctName is "" then set _acctName to (name of acct)
84832
+ if _budgetHit or _probeStalled then
84833
+ -- Stopped early: name what is left without asking Mail.app.
84834
+ set _unreached to _unreached & _acctName & " (every mailbox)${DIAG_ITEM_SEP}"
84835
+ else
84836
+ set _mbNames to {}
84837
+ try
84838
+ set _mbNames to name of mailboxes of acct
84839
+ end try
84840
+ set _mi to 0
84841
+ repeat with mb in mailboxes of acct
84842
+ set _mi to _mi + 1
84843
+ ${budgetCheck("_mi")}
84844
+ set _mbName to ""
84845
+ if _mi <= (length of _mbNames) then set _mbName to item _mi of _mbNames
84846
+ if _mbName is "" then set _mbName to (name of mb)
84847
+ set _probe to true
84848
+ ${sizeGuard("mb", "_acctName", "_mbName")}
84849
+ if _probeStalled then
84850
+ set _stopAt to _mi + 1
84851
+ exit repeat
84852
+ end if
84853
+ if _probe then
84854
+ try
84855
+ set matchingMsgs to (messages of mb whose id is ${Number(id)})
84856
+ if (count of matchingMsgs) > 0 then
84857
+ set end of _hits to item 1 of matchingMsgs
84858
+ set end of _hitMbs to (contents of mb)
84859
+ set end of _hitAccts to _acctName
84860
+ set _names to _names & _acctName & "/" & _mbName & ", "
84861
+ end if
84862
+ end try
84863
+ end if
84864
+ ${stopOnHit}
84865
+ end repeat
84866
+ if _budgetHit or _probeStalled then
84867
+ if (length of _mbNames) > 0 then
84868
+ repeat with _ri from _stopAt to (length of _mbNames)
84869
+ set _unreached to _unreached & _acctName & " / " & (item _ri of _mbNames) & "${DIAG_ITEM_SEP}"
84870
+ end repeat
84871
+ else
84872
+ set _unreached to _unreached & _acctName & " (remaining mailboxes)${DIAG_ITEM_SEP}"
84873
+ end if
84587
84874
  end if
84588
- ${stopOnHit}
84589
- end repeat
84875
+ end if
84590
84876
  end repeat
84591
84877
  -- #183: local mailboxes belong to no account, so the walk above cannot
84592
84878
  -- reach them. Collect into the SAME _hits/_names, which means an id
84593
84879
  -- present both in an account and locally is now correctly reported as
84594
84880
  -- ambiguous rather than silently resolving to the account copy.
84595
- if (not _budgetHit) and ${opts.firstHit ? "(count of _hits) is 0" : "true"} then${localMailboxBindingFragment()}
84881
+ if _budgetHit or _probeStalled then
84882
+ set _unreached to _unreached & "${LOCAL_STORE_LABEL} (any local mailboxes)${DIAG_ITEM_SEP}"
84883
+ else if not (${hitFound}) then${localMailboxBindingFragment()}
84884
+ set _mi to 0
84596
84885
  repeat with mb in _mbs
84597
- ${budgetCheck}
84886
+ set _mi to _mi + 1
84887
+ ${budgetCheck("_mi")}
84888
+ set _mbName to ""
84889
+ try
84890
+ set _mbName to (name of mb)
84891
+ end try
84598
84892
  set _probe to true
84599
- ${sizeGuard("mb", `"${LOCAL_STORE_LABEL}"`)}
84893
+ ${sizeGuard("mb", `"${LOCAL_STORE_LABEL}"`, "_mbName")}
84894
+ if _probeStalled then
84895
+ set _stopAt to _mi + 1
84896
+ exit repeat
84897
+ end if
84600
84898
  if _probe then
84601
84899
  try
84602
84900
  set matchingMsgs to (messages of mb whose id is ${Number(id)})
@@ -84604,14 +84902,17 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84604
84902
  set end of _hits to item 1 of matchingMsgs
84605
84903
  set end of _hitMbs to (contents of mb)
84606
84904
  set end of _hitAccts to "${LOCAL_STORE_LABEL}"
84607
- set _names to _names & "${LOCAL_STORE_LABEL}/" & (name of mb) & ", "
84905
+ set _names to _names & "${LOCAL_STORE_LABEL}/" & _mbName & ", "
84608
84906
  end if
84609
84907
  end try
84610
84908
  end if
84611
84909
  ${stopOnHit}
84612
84910
  end repeat
84911
+ if (_budgetHit or _probeStalled) and _stopAt <= (length of _mbs) then
84912
+ set _unreached to _unreached & "${LOCAL_STORE_LABEL} (" & ((length of _mbs) - _stopAt + 1) & " more local mailboxes)${DIAG_ITEM_SEP}"
84913
+ end if
84613
84914
  end if
84614
- if (count of _hits) is 0 then return "${LOOKUP_ERROR_MARKER}Message not found${BYID_COVERAGE_MARKER}" & _skipped & "${DIAG_FIELD_SEP}" & (_budgetHit as string)
84915
+ if (count of _hits) is 0 then return "${LOOKUP_ERROR_MARKER}Message not found${BYID_COVERAGE_MARKER}" & _skipped & "${DIAG_FIELD_SEP}" & (_budgetHit as string) & "${DIAG_FIELD_SEP}" & (_probeStalled as string) & "${DIAG_FIELD_SEP}" & _unreached
84615
84916
  ${opts.firstHit ? "" : `if (count of _hits) > 1 then return "${LOOKUP_ERROR_MARKER}${AMBIGUOUS_ID_PREFIX}${Number(id)} is present in more than one mailbox (" & _names & "); list or search that mailbox first so the read targets the right copy"`}
84616
84917
  if (count of _hits) ${opts.firstHit ? "> 0" : "is 1"} then
84617
84918
  set msg to item 1 of _hits
@@ -84671,7 +84972,7 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84671
84972
  end repeat
84672
84973
  end ignoring`;
84673
84974
  const miss = `${LOOKUP_ERROR_MARKER}${SCOPED_MISS_MARKER}`;
84674
- return buildAppLevelScript(`
84975
+ return buildAppLevelScriptIfRunning(`
84675
84976
  try
84676
84977
  ${bind}
84677
84978
  if targetMb is missing value then return "${miss}nomailbox"
@@ -86174,12 +86475,12 @@ ${this.errorEmit(" ")}
86174
86475
  const safeName = escapeForAppleScript(attachmentName);
86175
86476
  let temporaryDirectory;
86176
86477
  try {
86177
- temporaryDirectory = mkdtempSync2(join5(target.saveDirectory, ".apple-mail-mcp-"));
86478
+ temporaryDirectory = mkdtempSync2(join6(target.saveDirectory, ".apple-mail-mcp-"));
86178
86479
  } catch (error3) {
86179
86480
  console.error(`Failed to create attachment staging directory: ${error3}`);
86180
86481
  return false;
86181
86482
  }
86182
- const temporaryPath = join5(temporaryDirectory, "attachment");
86483
+ const temporaryPath = join6(temporaryDirectory, "attachment");
86183
86484
  const safeTemporaryPath = escapeForAppleScript(temporaryPath);
86184
86485
  const cleanupTemporaryDirectory = () => {
86185
86486
  try {
@@ -86239,9 +86540,9 @@ ${this.errorEmit(" ")}
86239
86540
  }
86240
86541
  let mimeTemporaryDirectory;
86241
86542
  try {
86242
- mimeTemporaryDirectory = mkdtempSync2(join5(target.saveDirectory, ".apple-mail-mcp-"));
86243
- const mimeTemporaryPath = join5(mimeTemporaryDirectory, "attachment");
86244
- writeFileSync3(mimeTemporaryPath, attachment.data, { flag: "wx", mode: 384 });
86543
+ mimeTemporaryDirectory = mkdtempSync2(join6(target.saveDirectory, ".apple-mail-mcp-"));
86544
+ const mimeTemporaryPath = join6(mimeTemporaryDirectory, "attachment");
86545
+ writeFileSync4(mimeTemporaryPath, attachment.data, { flag: "wx", mode: 384 });
86245
86546
  copyFileSync(mimeTemporaryPath, target.savedPath, fsConstants.COPYFILE_EXCL);
86246
86547
  chmodSync(target.savedPath, 384);
86247
86548
  return true;
@@ -86266,7 +86567,7 @@ ${this.errorEmit(" ")}
86266
86567
  let dir = null;
86267
86568
  try {
86268
86569
  dir = mkdtempSync2("/private/tmp/amcp-fetch-");
86269
- const dest = join5(dir, attachmentName.replace(/[/\\]/g, "_"));
86570
+ const dest = join6(dir, attachmentName.replace(/[/\\]/g, "_"));
86270
86571
  const ok = this.saveAttachment(id, attachmentName, dir);
86271
86572
  if (!ok) {
86272
86573
  return {
@@ -86274,7 +86575,7 @@ ${this.errorEmit(" ")}
86274
86575
  error: `Attachment "${attachmentName}" not found on message ${id}`
86275
86576
  };
86276
86577
  }
86277
- const buf = readFileSync2(dest);
86578
+ const buf = readFileSync3(dest);
86278
86579
  return { success: true, base64: buf.toString("base64"), bytes: buf.length };
86279
86580
  } catch (e) {
86280
86581
  return { success: false, error: e instanceof Error ? e.message : String(e) };
@@ -86518,12 +86819,12 @@ ${this.errorEmit(" ")}
86518
86819
  // gives full control over criteria without UI/GUI scripting.
86519
86820
  // ===========================================================================
86520
86821
  findSyncedSmartPlist() {
86521
- const base2 = join5(homedir4(), "Library", "Mail");
86822
+ const base2 = join6(homedir5(), "Library", "Mail");
86522
86823
  try {
86523
86824
  const versions = readdirSync2(base2).filter((d) => d.startsWith("V"));
86524
86825
  versions.sort().reverse();
86525
86826
  for (const v of versions) {
86526
- const p = join5(base2, v, "MailData", "SyncedSmartMailboxes.plist");
86827
+ const p = join6(base2, v, "MailData", "SyncedSmartMailboxes.plist");
86527
86828
  if (existsSync3(p)) return p;
86528
86829
  }
86529
86830
  } catch {
@@ -86611,12 +86912,12 @@ ${this.errorEmit(" ")}
86611
86912
  const lint = spawnSync2("plutil", ["-lint", temp], { encoding: "utf8" });
86612
86913
  if (lint.status !== 0) {
86613
86914
  try {
86614
- unlinkSync(temp);
86915
+ unlinkSync2(temp);
86615
86916
  } catch {
86616
86917
  }
86617
86918
  return false;
86618
86919
  }
86619
- renameSync(temp, plistPath);
86920
+ renameSync2(temp, plistPath);
86620
86921
  return true;
86621
86922
  }
86622
86923
  buildSmartMailboxEntry(name2, fromContains = "", subjectContains = "", bodyContains = "") {
@@ -86709,19 +87010,19 @@ ${this.errorEmit(" ")}
86709
87010
  const cleanupEntryFiles = () => {
86710
87011
  for (const f of [entryJson, entryPlist]) {
86711
87012
  try {
86712
- unlinkSync(f);
87013
+ unlinkSync2(f);
86713
87014
  } catch {
86714
87015
  }
86715
87016
  }
86716
87017
  };
86717
- writeFileSync3(entryJson, JSON.stringify([entry]), "utf8");
87018
+ writeFileSync4(entryJson, JSON.stringify([entry]), "utf8");
86718
87019
  const conv = spawnSync2("plutil", ["-convert", "xml1", "-o", entryPlist, entryJson], {
86719
87020
  encoding: "utf8"
86720
87021
  });
86721
87022
  if (conv.status !== 0) {
86722
87023
  cleanupEntryFiles();
86723
87024
  try {
86724
- unlinkSync(temp);
87025
+ unlinkSync2(temp);
86725
87026
  } catch {
86726
87027
  }
86727
87028
  return {
@@ -86736,7 +87037,7 @@ ${this.errorEmit(" ")}
86736
87037
  cleanupEntryFiles();
86737
87038
  if (ins.status !== 0) {
86738
87039
  try {
86739
- unlinkSync(temp);
87040
+ unlinkSync2(temp);
86740
87041
  } catch {
86741
87042
  }
86742
87043
  return {
@@ -86779,7 +87080,7 @@ ${this.errorEmit(" ")}
86779
87080
  });
86780
87081
  if (del.status !== 0) {
86781
87082
  try {
86782
- unlinkSync(temp);
87083
+ unlinkSync2(temp);
86783
87084
  } catch {
86784
87085
  }
86785
87086
  return { deleted: false, error: (del.stderr || "PlistBuddy delete failed").trim() };
@@ -87002,7 +87303,10 @@ end tell`;
87002
87303
  }
87003
87304
  /**
87004
87305
  * Fetches canonical mailbox paths for an account directly from Mail.app.
87005
- * Used internally by the cache; prefer getCachedMailboxNames().
87306
+ * Used internally by the cache; prefer getCachedMailboxNames(). Returns null
87307
+ * when Mail.app is not running — checked without launching it, so resolving
87308
+ * a mailbox name (e.g. for a scoped by-id read, #270 follow-up) never starts
87309
+ * Mail.app as a side effect.
87006
87310
  */
87007
87311
  fetchMailboxNames(account) {
87008
87312
  const body = `
@@ -87015,7 +87319,8 @@ end tell`;
87015
87319
  return mbNames
87016
87320
  `;
87017
87321
  const script = isLocalStoreLabel(account) ? buildAppLevelScript(`${localMailboxBindingFragment()}${body}`) : buildAccountScopedScript(account, body);
87018
- const result = executeAppleScript(script);
87322
+ const result = executeAppleScript(guardMailRunning(script));
87323
+ if (result.success && result.output.startsWith(MAIL_NOT_RUNNING_MARKER)) return null;
87019
87324
  if (!result.success || !result.output) {
87020
87325
  return [];
87021
87326
  }
@@ -87501,7 +87806,7 @@ ${actionStmts.join("\n")}
87501
87806
 
87502
87807
  // src/index.ts
87503
87808
  init_smtpMailer();
87504
- import { writeFileSync as writeFileSync4 } from "fs";
87809
+ import { writeFileSync as writeFileSync5 } from "fs";
87505
87810
  import { join as joinPath } from "path";
87506
87811
 
87507
87812
  // src/tools/compose.ts
@@ -88656,19 +88961,19 @@ var ImapIdleWatcher = class {
88656
88961
  };
88657
88962
 
88658
88963
  // src/services/fileConfig.ts
88659
- import { existsSync as existsSync4, readFileSync as readFileSync3 } from "fs";
88660
- import { join as join6 } from "path";
88661
- import { homedir as homedir5 } from "os";
88964
+ import { existsSync as existsSync4, readFileSync as readFileSync4 } from "fs";
88965
+ import { join as join7 } from "path";
88966
+ import { homedir as homedir6 } from "os";
88662
88967
  function fileConfigPath(env = process.env) {
88663
88968
  const override = env.APPLE_MAIL_MCP_CONFIG_FILE;
88664
88969
  if (override && override.trim()) return override.trim();
88665
- return join6(homedir5(), "Library", "Application Support", "apple-mail-mcp", "config.json");
88970
+ return join7(homedir6(), "Library", "Application Support", "apple-mail-mcp", "config.json");
88666
88971
  }
88667
88972
  function loadFileConfig(env = process.env, path3 = fileConfigPath(env)) {
88668
88973
  const applied = [];
88669
88974
  try {
88670
88975
  if (!existsSync4(path3)) return applied;
88671
- const parsed = JSON.parse(readFileSync3(path3, "utf8"));
88976
+ const parsed = JSON.parse(readFileSync4(path3, "utf8"));
88672
88977
  if (!parsed || typeof parsed !== "object") return applied;
88673
88978
  for (const [k, v] of Object.entries(parsed)) {
88674
88979
  if (typeof v !== "string") continue;
@@ -89414,7 +89719,7 @@ registerTool(
89414
89719
  } catch (error3) {
89415
89720
  return errorResponse(error3 instanceof Error ? error3.message : String(error3));
89416
89721
  }
89417
- writeFileSync4(target.savedPath, a.bytes, { flag: "wx", mode: 384 });
89722
+ writeFileSync5(target.savedPath, a.bytes, { flag: "wx", mode: 384 });
89418
89723
  return successResponse(`${summary}. Written to ${target.savedPath}.`, {
89419
89724
  ...record2,
89420
89725
  savedPath: target.savedPath
@@ -90343,7 +90648,7 @@ registerTool(
90343
90648
  if (!r.success || !r.base64) {
90344
90649
  return errorResponse(r.error || `Failed to fetch attachment "${attachmentName}"`);
90345
90650
  }
90346
- writeFileSync4(target.savedPath, Buffer.from(r.base64, "base64"), { flag: "wx", mode: 384 });
90651
+ writeFileSync5(target.savedPath, Buffer.from(r.base64, "base64"), { flag: "wx", mode: 384 });
90347
90652
  return successResponse(`Attachment "${attachmentName}" saved to ${savePath}`, {
90348
90653
  ok: true,
90349
90654
  attachmentName,
@@ -370,6 +370,7 @@ GUI is ignoring.
370
370
  | `APPLE_MAIL_MCP_SMTP_KEYCHAIN_SERVICE` / `_KEYCHAIN_ACCOUNT` | SMTP Keychain reference. |
371
371
  | `APPLE_MAIL_MCP_CONFIG_FILE` | Path to the config JSON (default app-support dir). |
372
372
  | `APPLE_MAIL_MCP_TEMPLATES_FILE` | Email-templates store path (default `~/Library/Application Support/apple-mail-mcp/templates.json`). |
373
+ | `APPLE_MAIL_MCP_STALLED_MAILBOXES_FILE` | Where the numeric-id scan remembers mailboxes whose size count timed out, so they are skipped for 7 days across restarts (default `~/Library/Application Support/apple-mail-mcp/stalled-mailboxes.json`; delete it to forget them). |
373
374
  | `APPLE_MAIL_MCP_MAX_BUFFER` | Max AppleScript (`osascript`) output buffer in bytes (default 64 MiB). |
374
375
  | `APPLE_MAIL_MAX_SEARCH_MAILBOX` | Per-mailbox message-count guard for unscoped AppleScript search (default `5000`; `0` disables). Note: no `_MCP` in the name. |
375
376
  | `APPLE_MAIL_MAX_BYID_SCAN_MAILBOX` | Per-mailbox message-count guard for the cross-mailbox scan a numeric message id falls back to (default `50000`; `0` disables). Skipped mailboxes are named in the not-found error. Note: no `_MCP` in the name. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-mail-mcp",
3
- "version": "2.20.6",
3
+ "version": "2.20.8",
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",