apple-mail-mcp 2.20.7 → 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,25 +82846,44 @@ 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."
@@ -82777,7 +82905,7 @@ function extractFreshlyStalledMailboxes(raw) {
82777
82905
  const account = withoutSuffix.slice(0, sepIdx);
82778
82906
  const path3 = withoutSuffix.slice(sepIdx + 3);
82779
82907
  const leaf = path3.split("/").pop();
82780
- if (account && leaf) out.push(`${account}${leaf}`);
82908
+ if (account && leaf) out.push(`${account}${STALLED_KEY_SEP}${leaf}`);
82781
82909
  }
82782
82910
  return out;
82783
82911
  }
@@ -82845,7 +82973,7 @@ function splitSearchDiagnostics(output, account) {
82845
82973
  }
82846
82974
  return { payload, diagnostics };
82847
82975
  }
82848
- var ALLOWED_SAVE_ROOTS = [homedir4(), "/tmp", "/private/tmp", "/Volumes"];
82976
+ var ALLOWED_SAVE_ROOTS = [homedir5(), "/tmp", "/private/tmp", "/Volumes"];
82849
82977
  function isPathWithinAllowedRoots(resolvedPath) {
82850
82978
  return ALLOWED_SAVE_ROOTS.some((root) => {
82851
82979
  const base2 = root.endsWith(sep2) ? root.slice(0, -1) : root;
@@ -83139,6 +83267,24 @@ function buildAppLevelScript(command) {
83139
83267
  end tell
83140
83268
  `;
83141
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
+ }
83142
83288
  function mailboxPathFragment(mailboxVar, outputVar) {
83143
83289
  return `
83144
83290
  set ${outputVar} to name of ${mailboxVar}
@@ -83302,13 +83448,12 @@ var AppleMailManager = class {
83302
83448
  * demonstrated this, the unscoped scan skips the probe for it entirely —
83303
83449
  * no `count of messages` call at all — straight to "size unknown,
83304
83450
  * previously timed out". Keyed on the leaf mailbox name (not the
83305
- * container-walked path `mailboxPathFragment` builds for display) so
83306
- * checking it costs nothing extra per mailbox visited. Remembered for the
83307
- * life of the process: nothing ever evicts an entry, because there is no
83308
- * signal that an abandoned Apple event has finished, and a mailbox that
83309
- * stalled once on a store this large has no reason to get faster.
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).
83310
83455
  */
83311
- knownStalledMailboxes = /* @__PURE__ */ new Set();
83456
+ stalledMailboxes = new StalledMailboxStore();
83312
83457
  /** Error from the most recent numeric message read, if it was refused. */
83313
83458
  lastMessageLookupError;
83314
83459
  /** Cap on the id→location index so a long-lived process can't grow unbounded. */
@@ -83971,6 +84116,7 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
83971
84116
  return cached2.data;
83972
84117
  }
83973
84118
  const names = this.fetchMailboxNames(account);
84119
+ if (names === null) return [];
83974
84120
  this.cache.mailboxNames.set(account, { data: names, expiry: now + this.CACHE_TTL_MS });
83975
84121
  return names;
83976
84122
  }
@@ -84495,14 +84641,23 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84495
84641
  if (!result.success || !result.output.trim()) {
84496
84642
  if (!result.success) {
84497
84643
  if (result.timedOut) this.lastMessageLookupError = timedOutScanningForId(id);
84644
+ else if (looksLikeMailNotRunning(result.error))
84645
+ this.lastMessageLookupError = mailNotRunningForId(id);
84498
84646
  console.error(`Failed to resolve message ${id}: ${result.error}`);
84499
84647
  }
84500
84648
  return null;
84501
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
+ }
84502
84658
  if (result.output.startsWith(LOOKUP_ERROR_MARKER)) {
84503
84659
  const rawTail = result.output.slice(LOOKUP_ERROR_MARKER.length);
84504
- for (const key of extractFreshlyStalledMailboxes(rawTail))
84505
- this.knownStalledMailboxes.add(key);
84660
+ this.stalledMailboxes.record(extractFreshlyStalledMailboxes(rawTail));
84506
84661
  this.lastMessageLookupError = describeUnscopedMiss(id, rawTail);
84507
84662
  return null;
84508
84663
  }
@@ -84520,6 +84675,10 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84520
84675
  { timeoutMs: READ_ONLY_BYID_TIMEOUT_MS }
84521
84676
  );
84522
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
+ }
84523
84682
  if (!result.success) {
84524
84683
  console.error(`Failed to read message ${id} from ${where}: ${result.error}`);
84525
84684
  if (result.timedOut) {
@@ -84571,20 +84730,38 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84571
84730
  * instead of a false flat "not found". A copy that sits only in a skipped
84572
84731
  * mailbox therefore no longer counts toward the ambiguity check — harmless
84573
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.
84574
84754
  */
84575
84755
  unscopedByIdScript(id, innerAction, opts = {}) {
84576
84756
  const threshold = getByIdScanThreshold();
84577
- const stalledCheck = (mbVar, acctNameExpr) => {
84578
- if (this.knownStalledMailboxes.size === 0) return void 0;
84579
- const clauses = Array.from(this.knownStalledMailboxes).map((key) => {
84580
- const [acct = "", leaf = ""] = key.split("");
84581
- return `(${acctNameExpr} is "${escapeForAppleScript(acct)}" and (name of ${mbVar}) is "${escapeForAppleScript(leaf)}")`;
84582
- });
84583
- return clauses.join(" or ");
84584
- };
84585
- const sizeGuard = (mbVar, acctNameExpr) => {
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) => {
84586
84764
  if (threshold <= 0) return "";
84587
- const stalled = stalledCheck(mbVar, acctNameExpr);
84588
84765
  const skipEntry = (sizeLabelExpr) => `
84589
84766
  set _skipPath to ""
84590
84767
  try
@@ -84593,67 +84770,131 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84593
84770
  set _skipped to _skipped & ${acctNameExpr} & " / " & _skipPath & " (" & ${sizeLabelExpr} & ")${DIAG_ITEM_SEP}"
84594
84771
  set _probe to false`;
84595
84772
  const probeBlock = `set _mbCount to -1
84773
+ set _probeErr to 0
84596
84774
  try
84597
84775
  with timeout of ${BYID_PROBE_TIMEOUT_SECONDS} seconds
84598
84776
  set _mbCount to count of messages of ${mbVar}
84599
84777
  end timeout
84778
+ on error number _probeErrNum
84779
+ set _probeErr to _probeErrNum
84600
84780
  end try
84601
- if _mbCount < 0 or _mbCount > ${threshold} then
84602
- set _sizeLabel to "size unknown (count probe exceeded ${BYID_PROBE_TIMEOUT_SECONDS}s)"
84781
+ if _probeErr is -1712 then
84782
+ set _skipped to _skipped & ${acctNameExpr} & " / " & ${mbNameExpr} & " (${stalledLabel})${DIAG_ITEM_SEP}"
84783
+ set _probe to false
84784
+ set _probeStalled to true
84785
+ else if _mbCount < 0 or _mbCount > ${threshold} then
84786
+ set _sizeLabel to "size unknown (count failed)"
84603
84787
  if _mbCount > -1 then set _sizeLabel to (_mbCount as string)
84604
84788
  ${skipEntry("_sizeLabel")}
84605
84789
  end if`;
84606
- if (!stalled) return probeBlock;
84607
- return `if ${stalled} then
84790
+ if (stalledKeys.length === 0) return probeBlock;
84791
+ return `if _stalledKeys contains {(${acctNameExpr} & "${FIELD_SEP}" & ${mbNameExpr})} then
84608
84792
  ${skipEntry('"size unknown (previously timed out)"')}
84609
84793
  else
84610
84794
  ${probeBlock}
84611
84795
  end if`;
84612
84796
  };
84613
- const budgetCheck = `if ((current date) - _startedAt) > ${BYID_SCAN_BUDGET_SECONDS} then
84797
+ const budgetCheck = (stopAtExpr) => `if ((current date) - _startedAt) > ${BYID_SCAN_BUDGET_SECONDS} then
84614
84798
  set _budgetHit to true
84799
+ set _stopAt to ${stopAtExpr}
84615
84800
  exit repeat
84616
84801
  end if`;
84802
+ const hitFound = opts.firstHit ? "(count of _hits) > 0" : "false";
84617
84803
  const stopOnHit = opts.firstHit ? `if (count of _hits) > 0 then exit repeat` : "";
84618
- return buildAppLevelScript(`
84804
+ return buildAppLevelScriptIfRunning(`
84619
84805
  try
84620
84806
  set _hits to {}
84621
84807
  set _hitMbs to {}
84622
84808
  set _hitAccts to {}
84623
84809
  set _names to ""
84624
84810
  set _skipped to ""
84811
+ set _unreached to ""
84625
84812
  set _budgetHit to false
84813
+ set _probeStalled to false
84814
+ set _stopAt to 0
84815
+ set _stalledKeys to ${stalledList}
84626
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
84627
84826
  repeat with acct in accounts
84628
- if _budgetHit then exit repeat
84629
- ${stopOnHit}
84630
- repeat with mb in mailboxes of acct
84631
- ${budgetCheck}
84632
- set _probe to true
84633
- ${sizeGuard("mb", "(name of acct)")}
84634
- if _probe then
84635
- try
84636
- set matchingMsgs to (messages of mb whose id is ${Number(id)})
84637
- if (count of matchingMsgs) > 0 then
84638
- set end of _hits to item 1 of matchingMsgs
84639
- set end of _hitMbs to (contents of mb)
84640
- set end of _hitAccts to (name of acct)
84641
- set _names to _names & (name of acct) & "/" & (name of mb) & ", "
84642
- end if
84643
- 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
84644
84874
  end if
84645
- ${stopOnHit}
84646
- end repeat
84875
+ end if
84647
84876
  end repeat
84648
84877
  -- #183: local mailboxes belong to no account, so the walk above cannot
84649
84878
  -- reach them. Collect into the SAME _hits/_names, which means an id
84650
84879
  -- present both in an account and locally is now correctly reported as
84651
84880
  -- ambiguous rather than silently resolving to the account copy.
84652
- 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
84653
84885
  repeat with mb in _mbs
84654
- ${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
84655
84892
  set _probe to true
84656
- ${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
84657
84898
  if _probe then
84658
84899
  try
84659
84900
  set matchingMsgs to (messages of mb whose id is ${Number(id)})
@@ -84661,14 +84902,17 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84661
84902
  set end of _hits to item 1 of matchingMsgs
84662
84903
  set end of _hitMbs to (contents of mb)
84663
84904
  set end of _hitAccts to "${LOCAL_STORE_LABEL}"
84664
- set _names to _names & "${LOCAL_STORE_LABEL}/" & (name of mb) & ", "
84905
+ set _names to _names & "${LOCAL_STORE_LABEL}/" & _mbName & ", "
84665
84906
  end if
84666
84907
  end try
84667
84908
  end if
84668
84909
  ${stopOnHit}
84669
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
84670
84914
  end if
84671
- 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
84672
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"`}
84673
84917
  if (count of _hits) ${opts.firstHit ? "> 0" : "is 1"} then
84674
84918
  set msg to item 1 of _hits
@@ -84728,7 +84972,7 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84728
84972
  end repeat
84729
84973
  end ignoring`;
84730
84974
  const miss = `${LOOKUP_ERROR_MARKER}${SCOPED_MISS_MARKER}`;
84731
- return buildAppLevelScript(`
84975
+ return buildAppLevelScriptIfRunning(`
84732
84976
  try
84733
84977
  ${bind}
84734
84978
  if targetMb is missing value then return "${miss}nomailbox"
@@ -86231,12 +86475,12 @@ ${this.errorEmit(" ")}
86231
86475
  const safeName = escapeForAppleScript(attachmentName);
86232
86476
  let temporaryDirectory;
86233
86477
  try {
86234
- temporaryDirectory = mkdtempSync2(join5(target.saveDirectory, ".apple-mail-mcp-"));
86478
+ temporaryDirectory = mkdtempSync2(join6(target.saveDirectory, ".apple-mail-mcp-"));
86235
86479
  } catch (error3) {
86236
86480
  console.error(`Failed to create attachment staging directory: ${error3}`);
86237
86481
  return false;
86238
86482
  }
86239
- const temporaryPath = join5(temporaryDirectory, "attachment");
86483
+ const temporaryPath = join6(temporaryDirectory, "attachment");
86240
86484
  const safeTemporaryPath = escapeForAppleScript(temporaryPath);
86241
86485
  const cleanupTemporaryDirectory = () => {
86242
86486
  try {
@@ -86296,9 +86540,9 @@ ${this.errorEmit(" ")}
86296
86540
  }
86297
86541
  let mimeTemporaryDirectory;
86298
86542
  try {
86299
- mimeTemporaryDirectory = mkdtempSync2(join5(target.saveDirectory, ".apple-mail-mcp-"));
86300
- const mimeTemporaryPath = join5(mimeTemporaryDirectory, "attachment");
86301
- 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 });
86302
86546
  copyFileSync(mimeTemporaryPath, target.savedPath, fsConstants.COPYFILE_EXCL);
86303
86547
  chmodSync(target.savedPath, 384);
86304
86548
  return true;
@@ -86323,7 +86567,7 @@ ${this.errorEmit(" ")}
86323
86567
  let dir = null;
86324
86568
  try {
86325
86569
  dir = mkdtempSync2("/private/tmp/amcp-fetch-");
86326
- const dest = join5(dir, attachmentName.replace(/[/\\]/g, "_"));
86570
+ const dest = join6(dir, attachmentName.replace(/[/\\]/g, "_"));
86327
86571
  const ok = this.saveAttachment(id, attachmentName, dir);
86328
86572
  if (!ok) {
86329
86573
  return {
@@ -86331,7 +86575,7 @@ ${this.errorEmit(" ")}
86331
86575
  error: `Attachment "${attachmentName}" not found on message ${id}`
86332
86576
  };
86333
86577
  }
86334
- const buf = readFileSync2(dest);
86578
+ const buf = readFileSync3(dest);
86335
86579
  return { success: true, base64: buf.toString("base64"), bytes: buf.length };
86336
86580
  } catch (e) {
86337
86581
  return { success: false, error: e instanceof Error ? e.message : String(e) };
@@ -86575,12 +86819,12 @@ ${this.errorEmit(" ")}
86575
86819
  // gives full control over criteria without UI/GUI scripting.
86576
86820
  // ===========================================================================
86577
86821
  findSyncedSmartPlist() {
86578
- const base2 = join5(homedir4(), "Library", "Mail");
86822
+ const base2 = join6(homedir5(), "Library", "Mail");
86579
86823
  try {
86580
86824
  const versions = readdirSync2(base2).filter((d) => d.startsWith("V"));
86581
86825
  versions.sort().reverse();
86582
86826
  for (const v of versions) {
86583
- const p = join5(base2, v, "MailData", "SyncedSmartMailboxes.plist");
86827
+ const p = join6(base2, v, "MailData", "SyncedSmartMailboxes.plist");
86584
86828
  if (existsSync3(p)) return p;
86585
86829
  }
86586
86830
  } catch {
@@ -86668,12 +86912,12 @@ ${this.errorEmit(" ")}
86668
86912
  const lint = spawnSync2("plutil", ["-lint", temp], { encoding: "utf8" });
86669
86913
  if (lint.status !== 0) {
86670
86914
  try {
86671
- unlinkSync(temp);
86915
+ unlinkSync2(temp);
86672
86916
  } catch {
86673
86917
  }
86674
86918
  return false;
86675
86919
  }
86676
- renameSync(temp, plistPath);
86920
+ renameSync2(temp, plistPath);
86677
86921
  return true;
86678
86922
  }
86679
86923
  buildSmartMailboxEntry(name2, fromContains = "", subjectContains = "", bodyContains = "") {
@@ -86766,19 +87010,19 @@ ${this.errorEmit(" ")}
86766
87010
  const cleanupEntryFiles = () => {
86767
87011
  for (const f of [entryJson, entryPlist]) {
86768
87012
  try {
86769
- unlinkSync(f);
87013
+ unlinkSync2(f);
86770
87014
  } catch {
86771
87015
  }
86772
87016
  }
86773
87017
  };
86774
- writeFileSync3(entryJson, JSON.stringify([entry]), "utf8");
87018
+ writeFileSync4(entryJson, JSON.stringify([entry]), "utf8");
86775
87019
  const conv = spawnSync2("plutil", ["-convert", "xml1", "-o", entryPlist, entryJson], {
86776
87020
  encoding: "utf8"
86777
87021
  });
86778
87022
  if (conv.status !== 0) {
86779
87023
  cleanupEntryFiles();
86780
87024
  try {
86781
- unlinkSync(temp);
87025
+ unlinkSync2(temp);
86782
87026
  } catch {
86783
87027
  }
86784
87028
  return {
@@ -86793,7 +87037,7 @@ ${this.errorEmit(" ")}
86793
87037
  cleanupEntryFiles();
86794
87038
  if (ins.status !== 0) {
86795
87039
  try {
86796
- unlinkSync(temp);
87040
+ unlinkSync2(temp);
86797
87041
  } catch {
86798
87042
  }
86799
87043
  return {
@@ -86836,7 +87080,7 @@ ${this.errorEmit(" ")}
86836
87080
  });
86837
87081
  if (del.status !== 0) {
86838
87082
  try {
86839
- unlinkSync(temp);
87083
+ unlinkSync2(temp);
86840
87084
  } catch {
86841
87085
  }
86842
87086
  return { deleted: false, error: (del.stderr || "PlistBuddy delete failed").trim() };
@@ -87059,7 +87303,10 @@ end tell`;
87059
87303
  }
87060
87304
  /**
87061
87305
  * Fetches canonical mailbox paths for an account directly from Mail.app.
87062
- * 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.
87063
87310
  */
87064
87311
  fetchMailboxNames(account) {
87065
87312
  const body = `
@@ -87072,7 +87319,8 @@ end tell`;
87072
87319
  return mbNames
87073
87320
  `;
87074
87321
  const script = isLocalStoreLabel(account) ? buildAppLevelScript(`${localMailboxBindingFragment()}${body}`) : buildAccountScopedScript(account, body);
87075
- const result = executeAppleScript(script);
87322
+ const result = executeAppleScript(guardMailRunning(script));
87323
+ if (result.success && result.output.startsWith(MAIL_NOT_RUNNING_MARKER)) return null;
87076
87324
  if (!result.success || !result.output) {
87077
87325
  return [];
87078
87326
  }
@@ -87558,7 +87806,7 @@ ${actionStmts.join("\n")}
87558
87806
 
87559
87807
  // src/index.ts
87560
87808
  init_smtpMailer();
87561
- import { writeFileSync as writeFileSync4 } from "fs";
87809
+ import { writeFileSync as writeFileSync5 } from "fs";
87562
87810
  import { join as joinPath } from "path";
87563
87811
 
87564
87812
  // src/tools/compose.ts
@@ -88713,19 +88961,19 @@ var ImapIdleWatcher = class {
88713
88961
  };
88714
88962
 
88715
88963
  // src/services/fileConfig.ts
88716
- import { existsSync as existsSync4, readFileSync as readFileSync3 } from "fs";
88717
- import { join as join6 } from "path";
88718
- 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";
88719
88967
  function fileConfigPath(env = process.env) {
88720
88968
  const override = env.APPLE_MAIL_MCP_CONFIG_FILE;
88721
88969
  if (override && override.trim()) return override.trim();
88722
- return join6(homedir5(), "Library", "Application Support", "apple-mail-mcp", "config.json");
88970
+ return join7(homedir6(), "Library", "Application Support", "apple-mail-mcp", "config.json");
88723
88971
  }
88724
88972
  function loadFileConfig(env = process.env, path3 = fileConfigPath(env)) {
88725
88973
  const applied = [];
88726
88974
  try {
88727
88975
  if (!existsSync4(path3)) return applied;
88728
- const parsed = JSON.parse(readFileSync3(path3, "utf8"));
88976
+ const parsed = JSON.parse(readFileSync4(path3, "utf8"));
88729
88977
  if (!parsed || typeof parsed !== "object") return applied;
88730
88978
  for (const [k, v] of Object.entries(parsed)) {
88731
88979
  if (typeof v !== "string") continue;
@@ -89471,7 +89719,7 @@ registerTool(
89471
89719
  } catch (error3) {
89472
89720
  return errorResponse(error3 instanceof Error ? error3.message : String(error3));
89473
89721
  }
89474
- writeFileSync4(target.savedPath, a.bytes, { flag: "wx", mode: 384 });
89722
+ writeFileSync5(target.savedPath, a.bytes, { flag: "wx", mode: 384 });
89475
89723
  return successResponse(`${summary}. Written to ${target.savedPath}.`, {
89476
89724
  ...record2,
89477
89725
  savedPath: target.savedPath
@@ -90400,7 +90648,7 @@ registerTool(
90400
90648
  if (!r.success || !r.base64) {
90401
90649
  return errorResponse(r.error || `Failed to fetch attachment "${attachmentName}"`);
90402
90650
  }
90403
- 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 });
90404
90652
  return successResponse(`Attachment "${attachmentName}" saved to ${savePath}`, {
90405
90653
  ok: true,
90406
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.7",
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",