apple-mail-mcp 2.10.16 → 2.10.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +209 -0
  2. package/build/index.js +860 -87
  3. package/package.json +1 -1
package/build/index.js CHANGED
@@ -78057,6 +78057,7 @@ import { tmpdir } from "os";
78057
78057
 
78058
78058
  // src/utils/attachmentLimits.ts
78059
78059
  var MAX_INLINE_ATTACHMENT_BYTES = 25 * 1024 * 1024;
78060
+ var MAX_IMAP_ATTACHMENT_BYTES = MAX_INLINE_ATTACHMENT_BYTES;
78060
78061
  var MAX_INLINE_ATTACHMENT_BASE64_CHARS = Math.ceil(MAX_INLINE_ATTACHMENT_BYTES / 3) * 4;
78061
78062
  var MAX_INLINE_ATTACHMENT_BASE64_INPUT_CHARS = MAX_INLINE_ATTACHMENT_BASE64_CHARS * 2;
78062
78063
  function isInlineAttachmentBase64WithinLimit(contentBase64) {
@@ -78217,6 +78218,68 @@ function searchContactsDb(query, opts) {
78217
78218
  return results;
78218
78219
  }
78219
78220
 
78221
+ // src/services/auditLog.ts
78222
+ import { appendFileSync } from "node:fs";
78223
+ var AUDIT_LOG_ENV = "APPLE_MAIL_MCP_AUDIT_LOG";
78224
+ var AUDIT_SUBJECTS_ENV = "APPLE_MAIL_MCP_AUDIT_SUBJECTS";
78225
+ var AUDIT_SNAPSHOT_MAX_ENV = "APPLE_MAIL_MCP_AUDIT_SNAPSHOT_MAX";
78226
+ var DEFAULT_SNAPSHOT_MAX = 2e3;
78227
+ function isOn(raw) {
78228
+ return /^(1|true|yes|on)$/i.test((raw ?? "").trim());
78229
+ }
78230
+ function auditLogPath() {
78231
+ const raw = process.env[AUDIT_LOG_ENV]?.trim();
78232
+ return raw ? raw : null;
78233
+ }
78234
+ function isAuditEnabled() {
78235
+ return auditLogPath() !== null;
78236
+ }
78237
+ function auditSubjectsEnabled() {
78238
+ return isAuditEnabled() && isOn(process.env[AUDIT_SUBJECTS_ENV]);
78239
+ }
78240
+ function auditSnapshotMax() {
78241
+ const raw = process.env[AUDIT_SNAPSHOT_MAX_ENV]?.trim();
78242
+ if (raw === void 0 || raw === "") return DEFAULT_SNAPSHOT_MAX;
78243
+ const n = Number(raw);
78244
+ if (!Number.isFinite(n) || n < 0) return DEFAULT_SNAPSHOT_MAX;
78245
+ return Math.floor(n);
78246
+ }
78247
+ function writeAuditRecord(record2) {
78248
+ const path = auditLogPath();
78249
+ if (!path) return;
78250
+ try {
78251
+ appendFileSync(path, `${JSON.stringify(record2)}
78252
+ `, "utf8");
78253
+ } catch (err) {
78254
+ console.error(
78255
+ `[apple-mail-mcp] audit log write failed (${path}): ${err instanceof Error ? err.message : String(err)}`
78256
+ );
78257
+ }
78258
+ }
78259
+ function writeDestructiveAudit(ctx, report) {
78260
+ if (!isAuditEnabled()) return;
78261
+ writeAuditRecord({
78262
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
78263
+ tool: ctx.tool,
78264
+ serverVersion: ctx.serverVersion,
78265
+ args: ctx.args,
78266
+ preImages: report.preImages,
78267
+ outcomes: report.outcomes,
78268
+ countDeltas: report.countDeltas,
78269
+ collateral: report.collateral,
78270
+ subjectsLogged: auditSubjectsEnabled()
78271
+ });
78272
+ }
78273
+ function countDeltaWarning(d) {
78274
+ if (d.status !== "over" || d.expected === null) return null;
78275
+ const extra = (d.observed ?? 0) - d.expected;
78276
+ const where = d.account ? `"${d.mailbox}" in account "${d.account}"` : `"${d.mailbox}"`;
78277
+ return `\u26A0\uFE0F Effect mismatch in ${where}: ${d.observed} message(s) left the mailbox but only ${d.expected} were operated on (count ${d.before} \u2192 ${d.after}). ${extra} message(s) are unaccounted for. Anything else removing mail from this mailbox at the same moment \u2014 a Mail rule, a server-side filter, another client, an IMAP expunge \u2014 reads the same way, so rule that out first. If nothing else was touching it, this is the signature of https://github.com/sweetrb/apple-mail-mcp/issues/155 \u2014 please report it there, and set ${AUDIT_LOG_ENV}=/path/to/audit.ndjson to capture which messages disappeared.`;
78278
+ }
78279
+ function reconciliationWarnings(report) {
78280
+ return report.countDeltas.map(countDeltaWarning).filter((w) => w !== null);
78281
+ }
78282
+
78220
78283
  // src/services/appleMailManager.ts
78221
78284
  function getMailboxScanThreshold() {
78222
78285
  const raw = process.env.APPLE_MAIL_MAX_SEARCH_MAILBOX;
@@ -78228,6 +78291,7 @@ function getMailboxScanThreshold() {
78228
78291
  }
78229
78292
  var SEARCH_ACCOUNT_BUDGET_SECONDS = 30;
78230
78293
  var SEARCH_ACCOUNT_TIMEOUT_MS = 45e3;
78294
+ var GROUP_SEP = "";
78231
78295
  var FIELD_SEP = "";
78232
78296
  var RECORD_SEP = "";
78233
78297
  var DIAG_MARKER = "DIAG";
@@ -78237,6 +78301,17 @@ var CONTENT_MARKER = "CONTENT";
78237
78301
  var MSGID_MARKER = "MSGID";
78238
78302
  var HTML_MARKER = "HTML";
78239
78303
  var BATCH_FATAL = "FATAL";
78304
+ var RECON_TAG = "RECON";
78305
+ var SNAP_TAG = "SNAP";
78306
+ var SNAP_PAIR = "P";
78307
+ var SNAP_ITEM = "I";
78308
+ var DELIMITER_REPLACEMENT = "\uFFFD";
78309
+ function stripStreamDelimiters(value) {
78310
+ let out = value;
78311
+ for (const d of [GROUP_SEP, RECORD_SEP, FIELD_SEP])
78312
+ out = out.split(d).join(DELIMITER_REPLACEMENT);
78313
+ return out;
78314
+ }
78240
78315
  var AMBIGUOUS_ID_PREFIX = "Message id ";
78241
78316
  var AMBIGUOUS_ID_BATCH = "This message id is present in more than one mailbox ";
78242
78317
  function normalizeRfcMessageId(mid) {
@@ -78473,6 +78548,19 @@ function buildAccountScopedScript(account, command) {
78473
78548
  end tell
78474
78549
  `;
78475
78550
  }
78551
+ function groupKey(account, mailbox) {
78552
+ return `${account}\0${mailbox}`;
78553
+ }
78554
+ function canonicalNumericId(raw) {
78555
+ const trimmed = (raw ?? "").trim();
78556
+ if (trimmed === "") return "";
78557
+ const n = Number(trimmed);
78558
+ return Number.isFinite(n) ? String(n) : trimmed;
78559
+ }
78560
+ var SELF_MOVE_NOTE = "Destination is the source mailbox, so no message should leave it. What Mail does to the count when a message is re-filed into the mailbox it already occupies is unspecified, so there is no expected delta to compare against: this mailbox is reported without a comparison and is never warned about.";
78561
+ function snapshotKey(entry) {
78562
+ return `${entry.id}\0${entry.messageId}`;
78563
+ }
78476
78564
  function buildAppLevelScript(command) {
78477
78565
  return `
78478
78566
  tell application "Mail"
@@ -78615,6 +78703,428 @@ var AppleMailManager = class {
78615
78703
  if (count of _mbM) is 1 then set _tmb to item 1 of _mbM
78616
78704
  end if`;
78617
78705
  }
78706
+ // ===========================================================================
78707
+ // Destructive-operation forensics (#155)
78708
+ // ===========================================================================
78709
+ /**
78710
+ * What the last destructive operation observed about its own effect.
78711
+ *
78712
+ * Read once, by the tool layer, immediately after the call — every
78713
+ * AppleScript path in this class is synchronous (`spawnSync`), so there is no
78714
+ * await between the mutation and the read and no other operation can land in
78715
+ * between.
78716
+ *
78717
+ * ## Lifetime (one rule, no exceptions)
78718
+ *
78719
+ * The report belongs to the MOST RECENT message mutation, whatever it was.
78720
+ * `beginMutation()` clears it at the start of EVERY message mutation —
78721
+ * destructive or not, instrumented or not — and `consumeLastForensics()`
78722
+ * clears it on read. So the only two answers a caller can get are "the report
78723
+ * for the call I just made" and `undefined`; a mutation that produces no
78724
+ * report can never hand back the previous one's.
78725
+ *
78726
+ * It used to be cleared only by the instrumented paths, which left
78727
+ * `batch-mark-as-read` returning the preceding `batch-delete-messages`'
78728
+ * evidence if nobody had consumed it.
78729
+ */
78730
+ lastForensics;
78731
+ /**
78732
+ * Start of a message mutation: invalidate whatever the previous one observed.
78733
+ *
78734
+ * Called by every single-message mutation (via `findMessageScript`), by
78735
+ * `moveMessage` (which builds its own script) and by `runBatchOperation`.
78736
+ */
78737
+ beginMutation() {
78738
+ this.lastForensics = void 0;
78739
+ }
78740
+ /** Take (and clear) the forensic report for the destructive op just run. */
78741
+ consumeLastForensics() {
78742
+ const r = this.lastForensics;
78743
+ this.lastForensics = void 0;
78744
+ return r;
78745
+ }
78746
+ /**
78747
+ * AppleScript that reads a mailbox's message count into `varName`, leaving
78748
+ * `-1` when Mail will not answer. Two Apple Events per mutation group, inside
78749
+ * the script that is already running: no extra `osascript`.
78750
+ */
78751
+ countFragment(varName, mbVar = "_tmb") {
78752
+ return `
78753
+ set ${varName} to -1
78754
+ try
78755
+ set ${varName} to (count of messages of ${mbVar})
78756
+ end try`;
78757
+ }
78758
+ /**
78759
+ * AppleScript that strips the stream's structural bytes out of `varName`,
78760
+ * in place, before it is appended to the record stream.
78761
+ *
78762
+ * This is the source-side half of the defence described on
78763
+ * `stripStreamDelimiters`: the values that go into a pre-image or a snapshot
78764
+ * (RFC Message-ID, `date received`, subject, mailbox and account names) are
78765
+ * attacker-influenced — a Message-ID is whatever the sender put in the
78766
+ * header — and a crafted one containing a RECORD_SEP plus a forged `RECON`
78767
+ * tag would otherwise inject a reconciliation record, fabricating an `over`
78768
+ * warning on an operation that did exactly the right thing.
78769
+ *
78770
+ * One pass: AppleScript accepts a LIST of text item delimiters when splitting
78771
+ * and uses the first when joining, so all three characters are replaced in a
78772
+ * single `text items` round trip. Verified with `osascript` directly.
78773
+ *
78774
+ * Deliberately distinct variable names (`_zTid`, `_zParts`) — AppleScript
78775
+ * identifiers are case-insensitive, so `_stid` would be the same variable as
78776
+ * the snapshot fragment's `_sTid`.
78777
+ */
78778
+ sanitizeFragment(varName, indent = " ") {
78779
+ return `
78780
+ ${indent}set _zTid to AppleScript's text item delimiters
78781
+ ${indent}set AppleScript's text item delimiters to {"${GROUP_SEP}", "${RECORD_SEP}", "${FIELD_SEP}"}
78782
+ ${indent}set _zParts to text items of ${varName}
78783
+ ${indent}set AppleScript's text item delimiters to "${DELIMITER_REPLACEMENT}"
78784
+ ${indent}set ${varName} to _zParts as string
78785
+ ${indent}set AppleScript's text item delimiters to _zTid`;
78786
+ }
78787
+ /**
78788
+ * AppleScript emitting one `error:` outcome record into `_out`, with the
78789
+ * runtime error text sanitised first.
78790
+ *
78791
+ * Mail composes that text, and it routinely quotes back a mailbox or message
78792
+ * property, so it is a runtime-read value like any other — the same invariant
78793
+ * that covers the Message-ID and the snapshot covers it. `_zErr` (not `_e`)
78794
+ * because `sanitizeFragment` rewrites its variable in place and the handler's
78795
+ * own binding should be left alone.
78796
+ */
78797
+ errorEmit(indent) {
78798
+ return `${indent}set _zErr to (_e as string)${this.sanitizeFragment("_zErr", indent)}
78799
+ ${indent}set _out to _out & (_idx as string) & "${FIELD_SEP}error:" & _zErr & "${RECORD_SEP}"`;
78800
+ }
78801
+ /** AppleScript emitting one RECON record into `_out`. */
78802
+ reconEmit(acctExpr, mbExpr, beforeVar, afterVar, posExpr = '""') {
78803
+ return `
78804
+ set _out to _out & "${RECON_TAG}${FIELD_SEP}" & ${acctExpr} & "${FIELD_SEP}" & ${mbExpr} & "${FIELD_SEP}" & (${beforeVar} as string) & "${FIELD_SEP}" & (${afterVar} as string) & "${FIELD_SEP}" & ${posExpr} & "${RECORD_SEP}"`;
78805
+ }
78806
+ /**
78807
+ * RECON emission for the unlocated paths, where the account and mailbox names
78808
+ * are read from Mail at runtime (`_uacct`, `mailbox of _msg`) instead of being
78809
+ * interpolated as literals from here — so they get the same delimiter
78810
+ * stripping the literal paths get in TypeScript.
78811
+ */
78812
+ reconEmitFromMessage(beforeVar, afterVar, posExpr = '""', indent = " ") {
78813
+ return `set _umbName to ""
78814
+ ${indent}try
78815
+ ${indent} set _umbName to (name of _umb)
78816
+ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragment("_umbName", indent)}${this.reconEmit("_uacct", "_umbName", beforeVar, afterVar, posExpr)}`;
78817
+ }
78818
+ /**
78819
+ * AppleScript capturing every (numeric id, RFC Message-ID) pair in a mailbox
78820
+ * into a SNAP record — the before/after pair the collateral diff subtracts.
78821
+ *
78822
+ * Empty string when the audit log is off or the snapshot is disabled, so the
78823
+ * whole layer costs literally nothing by default. The two property reads are
78824
+ * BULK (`id of messages of mb`, `message id of messages of mb`) — two Apple
78825
+ * Events for the whole mailbox rather than two per message — and the joining
78826
+ * is pure in-memory AppleScript.
78827
+ *
78828
+ * Above `APPLE_MAIL_MCP_AUDIT_SNAPSHOT_MAX` messages the snapshot is skipped,
78829
+ * and the skip is EMITTED as a record with its reason. A silently skipped
78830
+ * snapshot would read as "nothing collateral happened".
78831
+ *
78832
+ * Caveat recorded in the docs: `(id of msg) as string` renders a Mail id above
78833
+ * AppleScript's 2^29 integer range in scientific notation. The Message-ID is
78834
+ * the authoritative key in this record for exactly that reason; the numeric id
78835
+ * is a convenience — and it is put back into decimal form by
78836
+ * `canonicalNumericId` in `parseSnapshot`, because the raw exponential string
78837
+ * would otherwise fail the `unrequested` membership test and name a REQUESTED
78838
+ * message as collateral.
78839
+ */
78840
+ snapshotFragment(phase, acctExpr, mbExpr, countVar, mbVar = "_tmb") {
78841
+ const max = auditSnapshotMax();
78842
+ if (!isAuditEnabled() || max <= 0) return "";
78843
+ return `
78844
+ set _sStatus to "ok"
78845
+ set _sPayload to ""
78846
+ set _sIds to {}
78847
+ set _sMids to {}
78848
+ if ${countVar} < 0 then
78849
+ set _sStatus to "unavailable"
78850
+ else if ${countVar} > ${max} then
78851
+ set _sStatus to "skipped"
78852
+ set _sPayload to "mailbox holds " & (${countVar} as string) & " messages, above ${AUDIT_SNAPSHOT_MAX_ENV}=${max}"
78853
+ else
78854
+ try
78855
+ set _sIds to (id of messages of ${mbVar})
78856
+ set _sMids to (message id of messages of ${mbVar})
78857
+ on error
78858
+ set _sStatus to "unavailable"
78859
+ end try
78860
+ if _sStatus is "ok" then
78861
+ if (count of _sIds) is not (count of _sMids) then
78862
+ set _sStatus to "unavailable"
78863
+ else
78864
+ set _sPairs to {}
78865
+ repeat with _q from 1 to (count of _sIds)
78866
+ set _sOne to ""
78867
+ try
78868
+ set _zSnapMid to ((item _q of _sMids) as string)${this.sanitizeFragment("_zSnapMid", " ")}
78869
+ set _sOne to ((item _q of _sIds) as string) & "${SNAP_PAIR}" & _zSnapMid
78870
+ on error
78871
+ set _sOne to ((item _q of _sIds) as string) & "${SNAP_PAIR}"
78872
+ end try
78873
+ set end of _sPairs to _sOne
78874
+ end repeat
78875
+ set _sTid to AppleScript's text item delimiters
78876
+ set AppleScript's text item delimiters to "${SNAP_ITEM}"
78877
+ set _sPayload to _sPairs as string
78878
+ set AppleScript's text item delimiters to _sTid
78879
+ end if
78880
+ end if
78881
+ end if
78882
+ set _out to _out & "${SNAP_TAG}${FIELD_SEP}" & ${acctExpr} & "${FIELD_SEP}" & ${mbExpr} & "${FIELD_SEP}${phase}${FIELD_SEP}" & _sStatus & "${FIELD_SEP}" & _sPayload & "${RECORD_SEP}"`;
78883
+ }
78884
+ /**
78885
+ * AppleScript capturing the message the op is ABOUT to touch into `_pre`,
78886
+ * appended to that id's outcome record.
78887
+ *
78888
+ * Empty when the audit log is off — the pre-image is the only per-message cost
78889
+ * in this feature, and it must not exist by default. Subjects need the second,
78890
+ * separate opt-in; message bodies are never read.
78891
+ *
78892
+ * Every value here is EXTERNALLY CONTROLLED (the Message-ID and the subject
78893
+ * are whatever the sender wrote), so each one is stripped of the stream's
78894
+ * structural bytes before it is appended — see `sanitizeFragment`.
78895
+ */
78896
+ preImageFragment(msgVar = "_msg") {
78897
+ if (!isAuditEnabled()) return "";
78898
+ const ind = " ";
78899
+ const subject = auditSubjectsEnabled() ? `
78900
+ if _pre is not "" then
78901
+ try
78902
+ set _zSub to ((subject of ${msgVar}) as string)${this.sanitizeFragment("_zSub", ind + " ")}
78903
+ set _pre to _pre & "${FIELD_SEP}" & _zSub
78904
+ end try
78905
+ end if` : "";
78906
+ return `
78907
+ try
78908
+ set _zMid to ((message id of ${msgVar}) as string)${this.sanitizeFragment("_zMid", ind)}
78909
+ set _zDate to ((date received of ${msgVar}) as string)${this.sanitizeFragment("_zDate", ind)}
78910
+ set _pre to "${FIELD_SEP}" & _zMid & "${FIELD_SEP}" & _zDate
78911
+ end try${subject}`;
78912
+ }
78913
+ /**
78914
+ * Parse the delimited stream a destructive AppleScript returns: per-id
78915
+ * outcomes (with their optional pre-image), RECON records and SNAP records.
78916
+ *
78917
+ * `valid` maps 1-based positions back to the id strings the caller passed —
78918
+ * outcomes are reported BY POSITION because a Mail id past 2^29 does not
78919
+ * survive `as string` (see runBatchOperation).
78920
+ */
78921
+ parseForensicStream(output, valid) {
78922
+ const byId = /* @__PURE__ */ new Map();
78923
+ const okPositions = /* @__PURE__ */ new Set();
78924
+ const outcomes = [];
78925
+ const preImages = /* @__PURE__ */ new Map();
78926
+ const recons = [];
78927
+ const snaps = [];
78928
+ for (const rec of output.split(RECORD_SEP)) {
78929
+ if (!rec) continue;
78930
+ const f = rec.split(FIELD_SEP);
78931
+ if (f.length < 2) continue;
78932
+ if (f[0] === RECON_TAG) {
78933
+ recons.push({
78934
+ account: f[1] ?? "",
78935
+ mailbox: f[2] ?? "",
78936
+ before: Number(f[3]),
78937
+ after: Number(f[4]),
78938
+ pos: f[5] ? Number(f[5]) : null
78939
+ });
78940
+ continue;
78941
+ }
78942
+ if (f[0] === SNAP_TAG) {
78943
+ snaps.push({
78944
+ account: f[1] ?? "",
78945
+ mailbox: f[2] ?? "",
78946
+ phase: f[3] === "after" ? "after" : "before",
78947
+ status: f[4] ?? "",
78948
+ payload: f[5] ?? ""
78949
+ });
78950
+ continue;
78951
+ }
78952
+ const pos = Number(f[0]);
78953
+ const entry = valid[pos - 1];
78954
+ if (!entry) continue;
78955
+ const status = f[1];
78956
+ const id = entry.id;
78957
+ if (status === "ok") {
78958
+ byId.set(id, { id, success: true });
78959
+ okPositions.add(pos);
78960
+ outcomes.push({ id, status: "ok" });
78961
+ if (f.length >= 4) {
78962
+ preImages.set(pos, {
78963
+ messageId: f[2] || null,
78964
+ date: f[3] || null,
78965
+ ...f.length >= 5 ? { subject: f[4] } : {}
78966
+ });
78967
+ }
78968
+ } else if (status === "notfound") {
78969
+ byId.set(id, { id, success: false, error: "Message not found" });
78970
+ outcomes.push({ id, status: "notfound" });
78971
+ } else if (status.startsWith("error:")) {
78972
+ const error2 = f.slice(1).join(FIELD_SEP).slice("error:".length);
78973
+ byId.set(id, { id, success: false, error: error2 });
78974
+ outcomes.push({ id, status: "error", error: error2 });
78975
+ } else {
78976
+ const error2 = status || "Unknown error";
78977
+ byId.set(id, { id, success: false, error: error2 });
78978
+ outcomes.push({ id, status: "error", error: error2 });
78979
+ }
78980
+ }
78981
+ return { byId, okPositions, outcomes, preImages, recons, snaps };
78982
+ }
78983
+ /**
78984
+ * Parse one SNAP payload into (numeric id → RFC Message-ID) entries.
78985
+ *
78986
+ * The id is CANONICALISED as it is parsed (`canonicalNumericId`), because
78987
+ * AppleScript renders a Mail id above 2^29 in scientific notation. That is the
78988
+ * only point where the AppleScript representation and the caller's own id
78989
+ * strings meet, so normalising here fixes both the `unrequested` membership
78990
+ * test and the id the report hands back to a human.
78991
+ */
78992
+ parseSnapshot(payload) {
78993
+ if (!payload) return [];
78994
+ return payload.split(SNAP_ITEM).map((entry) => {
78995
+ const i = entry.indexOf(SNAP_PAIR);
78996
+ return i < 0 ? { id: canonicalNumericId(entry), messageId: "" } : {
78997
+ id: canonicalNumericId(entry.slice(0, i)),
78998
+ messageId: entry.slice(i + SNAP_PAIR.length)
78999
+ };
79000
+ });
79001
+ }
79002
+ /**
79003
+ * Turn the raw RECON/SNAP records into the report the tool layer reports on.
79004
+ *
79005
+ * `expectedFor(account, mailbox, pos)` says how many messages the operation
79006
+ * should have removed from that mailbox — the caller knows this because only
79007
+ * the caller knows which ids succeeded and whether a move's destination IS the
79008
+ * source mailbox.
79009
+ *
79010
+ * It returns **null** for "not predictable", and null propagates: the mailbox
79011
+ * is classified `unknown` and no comparison is made. That is the only honest
79012
+ * answer for a self-move — Mail's behaviour when a message is re-filed into
79013
+ * the mailbox it already occupies is unspecified, so any number here would be
79014
+ * a guess, and a guess is what turns this instrumentation into a false alarm.
79015
+ *
79016
+ * `requestedNumericIds` MUST already be canonical (`canonicalNumericId`): it is
79017
+ * compared against ids that came back through AppleScript, where a value above
79018
+ * 2^29 arrives in scientific notation.
79019
+ */
79020
+ buildForensicReport(parsed, valid, expectedFor, locationFor, noteFor, requestedNumericIds) {
79021
+ const merged = /* @__PURE__ */ new Map();
79022
+ for (const r of parsed.recons) {
79023
+ const key = groupKey(r.account, r.mailbox);
79024
+ const expected = expectedFor(r.account, r.mailbox, r.pos);
79025
+ const prev = merged.get(key);
79026
+ if (prev) {
79027
+ prev.after = r.after;
79028
+ prev.expected = prev.expected === null || expected === null ? null : prev.expected + expected;
79029
+ } else {
79030
+ merged.set(key, {
79031
+ account: r.account,
79032
+ mailbox: r.mailbox,
79033
+ before: r.before,
79034
+ after: r.after,
79035
+ expected
79036
+ });
79037
+ }
79038
+ }
79039
+ const countDeltas = [...merged.values()].map((m) => {
79040
+ const readable = m.before >= 0 && m.after >= 0;
79041
+ const observed = readable ? m.before - m.after : null;
79042
+ const note = noteFor(m.account, m.mailbox);
79043
+ let status;
79044
+ if (!readable || m.expected === null) status = "unknown";
79045
+ else if (observed === m.expected) status = "match";
79046
+ else if ((observed ?? 0) > m.expected) status = "over";
79047
+ else status = "under";
79048
+ return {
79049
+ account: m.account,
79050
+ mailbox: m.mailbox,
79051
+ before: readable ? m.before : null,
79052
+ after: readable ? m.after : null,
79053
+ expected: m.expected,
79054
+ observed,
79055
+ status,
79056
+ ...note ? { note } : {},
79057
+ ...status === "unknown" && readable === false ? { note: note ?? "Mail did not report a message count for this mailbox" } : {},
79058
+ ...status === "under" && !note ? {
79059
+ note: "Fewer messages left the mailbox than were operated on. This is normal on a store that flags deletions instead of removing them (and when new mail arrives mid-operation), so it is reported but not warned about."
79060
+ } : {}
79061
+ };
79062
+ });
79063
+ const preImages = [];
79064
+ for (const [pos, pre] of parsed.preImages) {
79065
+ const entry = valid[pos - 1];
79066
+ if (!entry) continue;
79067
+ const loc = locationFor(pos);
79068
+ preImages.push({
79069
+ id: entry.id,
79070
+ account: loc.account,
79071
+ mailbox: loc.mailbox,
79072
+ messageId: pre.messageId,
79073
+ date: pre.date,
79074
+ ...pre.subject !== void 0 ? { subject: pre.subject } : {}
79075
+ });
79076
+ }
79077
+ const collateral = [];
79078
+ const byMailbox = /* @__PURE__ */ new Map();
79079
+ for (const s of parsed.snaps) {
79080
+ const key = groupKey(s.account, s.mailbox);
79081
+ const g = byMailbox.get(key) ?? { account: s.account, mailbox: s.mailbox };
79082
+ if (s.phase === "before") g.before = s;
79083
+ else g.after = s;
79084
+ byMailbox.set(key, g);
79085
+ }
79086
+ for (const g of byMailbox.values()) {
79087
+ const b = g.before;
79088
+ const a = g.after;
79089
+ if (!b || !a) {
79090
+ collateral.push({
79091
+ account: g.account,
79092
+ mailbox: g.mailbox,
79093
+ snapshot: "unavailable",
79094
+ skipReason: "only one of the before/after snapshots was produced"
79095
+ });
79096
+ continue;
79097
+ }
79098
+ if (b.status !== "ok" || a.status !== "ok") {
79099
+ const bad = b.status !== "ok" ? b : a;
79100
+ collateral.push({
79101
+ account: g.account,
79102
+ mailbox: g.mailbox,
79103
+ snapshot: bad.status === "skipped" ? "skipped" : "unavailable",
79104
+ skipReason: bad.payload || `Mail would not produce the ${bad === b ? "before" : "after"} snapshot for this mailbox`
79105
+ });
79106
+ continue;
79107
+ }
79108
+ const beforeEntries = this.parseSnapshot(b.payload);
79109
+ const afterEntries = this.parseSnapshot(a.payload);
79110
+ const afterKeys = new Set(afterEntries.map((e) => snapshotKey(e)));
79111
+ const beforeKeys = new Set(beforeEntries.map((e) => snapshotKey(e)));
79112
+ const disappeared = beforeEntries.filter((e) => !afterKeys.has(snapshotKey(e)));
79113
+ const appeared = afterEntries.filter((e) => !beforeKeys.has(snapshotKey(e)));
79114
+ const unrequested = disappeared.filter(
79115
+ (e) => !requestedNumericIds.has(canonicalNumericId(e.id))
79116
+ );
79117
+ collateral.push({
79118
+ account: g.account,
79119
+ mailbox: g.mailbox,
79120
+ snapshot: "ok",
79121
+ disappeared,
79122
+ unrequested,
79123
+ appeared
79124
+ });
79125
+ }
79126
+ return { countDeltas, preImages, outcomes: parsed.outcomes, collateral };
79127
+ }
78618
79128
  /**
78619
79129
  * Returns cached accounts or fetches fresh data if cache is expired/empty.
78620
79130
  */
@@ -79838,19 +80348,30 @@ var AppleMailManager = class {
79838
80348
  * deleting the "All Mail" copy are different operations — so scope to the
79839
80349
  * mailbox the id actually came from (`idLocationIndex`, populated by every
79840
80350
  * list/search) and never guess.
80351
+ *
80352
+ * Every single-message mutation in this class builds its script here and runs
80353
+ * it immediately, so this is also where the previous operation's forensic
80354
+ * report is invalidated — see `beginMutation()`.
79841
80355
  */
79842
- findMessageScript(id, operation) {
80356
+ findMessageScript(id, operation, instrument = false) {
80357
+ this.beginMutation();
79843
80358
  const loc = this.locationFor(id);
79844
80359
  if (loc) {
80360
+ const acctLit = `"${escapeForAppleScript(stripStreamDelimiters(loc.account))}"`;
80361
+ const mbLit = `"${escapeForAppleScript(stripStreamDelimiters(loc.mailbox))}"`;
79845
80362
  return buildAppLevelScript(`
79846
80363
  try
79847
80364
  ${this.resolveMailboxFragment(loc.account, loc.mailbox)}
79848
80365
  if _tmb is missing value then return "error:Message not found"
79849
80366
  set matchingMsgs to (messages of _tmb whose id is ${Number(id)})
79850
80367
  if (count of matchingMsgs) > 0 then
79851
- set msg to item 1 of matchingMsgs
80368
+ set msg to item 1 of matchingMsgs${instrument ? `
80369
+ set _out to ""
80370
+ set _pre to ""${this.countFragment("_cb")}${this.snapshotFragment("before", acctLit, mbLit, "_cb")}${this.preImageFragment("msg")}
80371
+ ${operation}${this.countFragment("_ca")}${this.snapshotFragment("after", acctLit, mbLit, "_ca")}${this.reconEmit(acctLit, mbLit, "_cb", "_ca")}
80372
+ return "1${FIELD_SEP}ok" & _pre & "${RECORD_SEP}" & _out` : `
79852
80373
  ${operation}
79853
- return "ok"
80374
+ return "ok"`}
79854
80375
  end if
79855
80376
  return "error:Message not found"
79856
80377
  on error errMsg
@@ -79875,14 +80396,61 @@ var AppleMailManager = class {
79875
80396
  end repeat
79876
80397
  if (count of _hits) is 0 then return "error:Message not found"
79877
80398
  if (count of _hits) > 1 then return "error:${AMBIGUOUS_ID_PREFIX}${Number(id)} is present in more than one mailbox (" & _names & "); list or search that mailbox first so the operation targets the right copy"
79878
- set msg to item 1 of _hits
80399
+ set msg to item 1 of _hits${instrument ? `
80400
+ set _out to ""
80401
+ set _pre to ""
80402
+ set _umb to missing value
80403
+ set _uacct to ""
80404
+ try
80405
+ set _umb to (mailbox of msg)
80406
+ set _uacct to (name of (account of _umb))
80407
+ end try
80408
+ set _cb to -1
80409
+ set _ca to -1
80410
+ if _umb is not missing value then
80411
+ try
80412
+ set _cb to (count of messages of _umb)
80413
+ end try
80414
+ end if${this.preImageFragment("msg")}
79879
80415
  ${operation}
79880
- return "ok"
80416
+ if _umb is not missing value then
80417
+ try
80418
+ set _ca to (count of messages of _umb)
80419
+ end try
80420
+ ${this.reconEmitFromMessage("_cb", "_ca")}
80421
+ end if
80422
+ return "1${FIELD_SEP}ok" & _pre & "${RECORD_SEP}" & _out` : `
80423
+ ${operation}
80424
+ return "ok"`}
79881
80425
  on error errMsg
79882
80426
  return "error:" & errMsg
79883
80427
  end try
79884
80428
  `);
79885
80429
  }
80430
+ /**
80431
+ * Build and stash the forensic report for a SINGLE-message destructive op.
80432
+ *
80433
+ * Same record stream, same parser and same reconciliation rules as the batch
80434
+ * path — a single-message delete is just a one-id batch as far as the evidence
80435
+ * is concerned, so there is exactly one implementation of "what did this
80436
+ * actually do".
80437
+ */
80438
+ recordSingleForensics(output, id, destination) {
80439
+ const valid = [{ id, num: Number(id) }];
80440
+ const parsed = this.parseForensicStream(output, valid);
80441
+ const succeeded = parsed.okPositions.has(1);
80442
+ const sameMailbox = (account, mailbox) => destination !== void 0 && destination.account === account && this.resolveMailbox(destination.mailbox, destination.account) === this.resolveMailbox(mailbox, account);
80443
+ const home = parsed.recons[0];
80444
+ this.lastForensics = this.buildForensicReport(
80445
+ parsed,
80446
+ valid,
80447
+ // null, not 0, for a self-move — see SELF_MOVE_NOTE.
80448
+ (account, mailbox) => sameMailbox(account, mailbox) ? null : succeeded ? 1 : 0,
80449
+ () => home ? { account: home.account, mailbox: home.mailbox } : this.locationFor(id) ?? { account: "", mailbox: "" },
80450
+ (account, mailbox) => sameMailbox(account, mailbox) ? SELF_MOVE_NOTE : void 0,
80451
+ /* @__PURE__ */ new Set([canonicalNumericId(String(Number(id)))])
80452
+ );
80453
+ }
79886
80454
  /**
79887
80455
  * Mark a message as read.
79888
80456
  */
@@ -80003,9 +80571,10 @@ var AppleMailManager = class {
80003
80571
  * Delete a message.
80004
80572
  */
80005
80573
  deleteMessage(id) {
80006
- const script = this.findMessageScript(id, "delete msg");
80574
+ const script = this.findMessageScript(id, "delete msg", true);
80007
80575
  const result = executeAppleScript(script, { timeoutMs: 6e4 });
80008
80576
  if (result.success && !result.output.startsWith("error:")) {
80577
+ this.recordSingleForensics(result.output, id);
80009
80578
  return { success: true };
80010
80579
  }
80011
80580
  const raw = result.success ? result.output.replace(/^error:/, "") : result.error || "Unknown error";
@@ -80063,13 +80632,18 @@ var AppleMailManager = class {
80063
80632
  const safeMailbox = escapeForAppleScript(targetMailbox);
80064
80633
  const safeAccount = escapeForAppleScript(targetAccount);
80065
80634
  const loc = this.locationFor(id);
80635
+ const srcAcctLit = loc ? `"${escapeForAppleScript(stripStreamDelimiters(loc.account))}"` : '""';
80636
+ const srcMbLit = loc ? `"${escapeForAppleScript(stripStreamDelimiters(loc.mailbox))}"` : '""';
80066
80637
  const findAndMove = loc ? `
80067
80638
  ${this.resolveMailboxFragment(loc.account, loc.mailbox)}
80068
80639
  if _tmb is missing value then return "error:Message not found"
80069
80640
  set matchingMsgs to (messages of _tmb whose id is ${Number(id)})
80070
80641
  if (count of matchingMsgs) is 0 then return "error:Message not found"
80071
- move (item 1 of matchingMsgs) to destMailbox
80072
- return "ok"` : `
80642
+ set msg to item 1 of matchingMsgs
80643
+ set _out to ""
80644
+ set _pre to ""${this.countFragment("_cb")}${this.snapshotFragment("before", srcAcctLit, srcMbLit, "_cb")}${this.preImageFragment("msg")}
80645
+ move msg to destMailbox${this.countFragment("_ca")}${this.snapshotFragment("after", srcAcctLit, srcMbLit, "_ca")}${this.reconEmit(srcAcctLit, srcMbLit, "_cb", "_ca")}
80646
+ return "1${FIELD_SEP}ok" & _pre & "${RECORD_SEP}" & _out` : `
80073
80647
  set _hits to {}
80074
80648
  set _names to ""
80075
80649
  repeat with acct in accounts
@@ -80085,8 +80659,30 @@ var AppleMailManager = class {
80085
80659
  end repeat
80086
80660
  if (count of _hits) is 0 then return "error:Message not found"
80087
80661
  if (count of _hits) > 1 then return "error:${AMBIGUOUS_ID_PREFIX}${Number(id)} is present in more than one mailbox (" & _names & "); list or search that mailbox first so the move targets the right copy"
80088
- move (item 1 of _hits) to destMailbox
80089
- return "ok"`;
80662
+ set msg to item 1 of _hits
80663
+ set _out to ""
80664
+ set _pre to ""
80665
+ set _umb to missing value
80666
+ set _uacct to ""
80667
+ try
80668
+ set _umb to (mailbox of msg)
80669
+ set _uacct to (name of (account of _umb))
80670
+ end try
80671
+ set _cb to -1
80672
+ set _ca to -1
80673
+ if _umb is not missing value then
80674
+ try
80675
+ set _cb to (count of messages of _umb)
80676
+ end try
80677
+ end if${this.preImageFragment("msg")}
80678
+ move msg to destMailbox
80679
+ if _umb is not missing value then
80680
+ try
80681
+ set _ca to (count of messages of _umb)
80682
+ end try
80683
+ ${this.reconEmitFromMessage("_cb", "_ca")}
80684
+ end if
80685
+ return "1${FIELD_SEP}ok" & _pre & "${RECORD_SEP}" & _out`;
80090
80686
  const script = buildAppLevelScript(`
80091
80687
  try
80092
80688
  -- \`mailboxes of account\` is already flat: it includes nested mailboxes
@@ -80114,9 +80710,14 @@ var AppleMailManager = class {
80114
80710
  if (result.output.startsWith("error:")) {
80115
80711
  return { success: false, error: result.output.slice("error:".length) };
80116
80712
  }
80713
+ this.recordSingleForensics(result.output, id, {
80714
+ account: targetAccount,
80715
+ mailbox: targetMailbox
80716
+ });
80117
80717
  return { success: true };
80118
80718
  }
80119
80719
  moveMessage(id, mailbox, account) {
80720
+ this.beginMutation();
80120
80721
  const res = this.moveMessageInternal(id, mailbox, account);
80121
80722
  if (res.success) return { success: true };
80122
80723
  const error2 = this.classifyMessageMutationError(
@@ -80198,19 +80799,48 @@ var AppleMailManager = class {
80198
80799
  *
80199
80800
  * `setup` runs once up front (used by move to resolve the destination); it may
80200
80801
  * bail the whole batch by returning a `BATCH_FATAL`-prefixed string.
80802
+ *
80803
+ * ## A repeated id names ONE message, and is operated on once
80804
+ *
80805
+ * A batch is a set of messages, not a multiset: two occurrences of id `75811`
80806
+ * are the same message, and Mail can only delete it once. So the id list is
80807
+ * DEDUPED on the numeric value actually sent to AppleScript (`"75811"` and
80808
+ * `" 75811"` are the same target), and the returned array carries one entry
80809
+ * per distinct id, in first-seen order — hence `success` counts distinct
80810
+ * messages rather than list positions.
80811
+ *
80812
+ * This is a correctness requirement for the #155 reconciliation, not a
80813
+ * tidy-up. Counting a repeat as a second operand makes `expected` disagree
80814
+ * with the mailbox — the duplicate can only be reported `notfound` (the
80815
+ * message is already gone) or `ok` twice (on a flag-only store) — and either
80816
+ * way the always-on warning fires on an operation that did exactly the right
80817
+ * thing. A warning users learn to ignore is worse than no warning.
80201
80818
  */
80202
- runBatchOperation(ids, operation, setup = "", scope) {
80819
+ runBatchOperation(ids, operation, setup = "", scope, forensics) {
80820
+ const instrument = forensics !== void 0;
80821
+ this.beginMutation();
80203
80822
  const valid = [];
80823
+ const operands = [];
80824
+ const seenNums = /* @__PURE__ */ new Set();
80825
+ const seenInvalid = /* @__PURE__ */ new Set();
80204
80826
  for (const id of ids) {
80205
80827
  const num = Number(id);
80206
- if (Number.isFinite(num)) valid.push({ id, num });
80828
+ if (Number.isFinite(num)) {
80829
+ if (seenNums.has(num)) continue;
80830
+ seenNums.add(num);
80831
+ valid.push({ id, num });
80832
+ } else {
80833
+ if (seenInvalid.has(id)) continue;
80834
+ seenInvalid.add(id);
80835
+ }
80836
+ operands.push(id);
80207
80837
  }
80208
80838
  if (valid.length === 0) {
80209
- return ids.map((id) => ({ id, success: false, error: "Invalid message ID" }));
80839
+ return operands.map((id) => ({ id, success: false, error: "Invalid message ID" }));
80210
80840
  }
80211
80841
  const resolved = this.resolveBatchScope(scope);
80212
80842
  if (resolved.kind === "unresolvable") {
80213
- return ids.map((id) => ({ id, success: false, error: resolved.error }));
80843
+ return operands.map((id) => ({ id, success: false, error: resolved.error }));
80214
80844
  }
80215
80845
  const callerScope = resolved.kind === "scoped" ? resolved : void 0;
80216
80846
  const groups = /* @__PURE__ */ new Map();
@@ -80222,39 +80852,45 @@ var AppleMailManager = class {
80222
80852
  unlocated.push({ num: v.num, pos });
80223
80853
  return;
80224
80854
  }
80225
- const key = `${loc.account}\0${loc.mailbox}`;
80855
+ const key = groupKey(loc.account, loc.mailbox);
80226
80856
  const g = groups.get(key) ?? { account: loc.account, mailbox: loc.mailbox, items: [] };
80227
80857
  g.items.push({ num: v.num, pos });
80228
80858
  groups.set(key, g);
80229
80859
  });
80230
80860
  const asList = (nums) => `{${nums.join(", ")}}`;
80231
- const scopedBlocks = [...groups.values()].map(
80232
- (g) => `
80861
+ const scopedBlocks = [...groups.values()].map((g) => {
80862
+ const acctLit = `"${escapeForAppleScript(stripStreamDelimiters(g.account))}"`;
80863
+ const mbLit = `"${escapeForAppleScript(stripStreamDelimiters(g.mailbox))}"`;
80864
+ const acctInProse = escapeForAppleScript(stripStreamDelimiters(g.account));
80865
+ const mbInProse = escapeForAppleScript(stripStreamDelimiters(g.mailbox));
80866
+ const pre = instrument ? this.preImageFragment("_msg") : "";
80867
+ return `
80233
80868
  ${this.resolveMailboxFragment(g.account, g.mailbox)}
80234
80869
  set _gids to ${asList(g.items.map((it) => it.num))}
80235
80870
  set _gpos to ${asList(g.items.map((it) => it.pos))}
80236
80871
  if _tmb is missing value then
80237
80872
  repeat with _k from 1 to (count of _gpos)
80238
- set _out to _out & ((item _k of _gpos) as string) & "${FIELD_SEP}error:source mailbox \\"${escapeForAppleScript(g.mailbox)}\\" not found in account \\"${escapeForAppleScript(g.account)}\\"${RECORD_SEP}"
80873
+ set _out to _out & ((item _k of _gpos) as string) & "${FIELD_SEP}error:source mailbox \\"${mbInProse}\\" not found in account \\"${acctInProse}\\"${RECORD_SEP}"
80239
80874
  end repeat
80240
- else
80875
+ else${instrument ? `${this.countFragment("_cb")}${this.snapshotFragment("before", acctLit, mbLit, "_cb")}` : ""}
80241
80876
  repeat with _k from 1 to (count of _gids)
80242
80877
  set _idx to item _k of _gpos
80878
+ set _pre to ""
80243
80879
  try
80244
80880
  set _m to (messages of _tmb whose id is (item _k of _gids))
80245
80881
  if (count of _m) > 0 then
80246
- set _msg to item 1 of _m
80882
+ set _msg to item 1 of _m${pre}
80247
80883
  ${operation}
80248
- set _out to _out & (_idx as string) & "${FIELD_SEP}ok${RECORD_SEP}"
80884
+ set _out to _out & (_idx as string) & "${FIELD_SEP}ok" & _pre & "${RECORD_SEP}"
80249
80885
  else
80250
80886
  set _out to _out & (_idx as string) & "${FIELD_SEP}notfound${RECORD_SEP}"
80251
80887
  end if
80252
80888
  on error _e
80253
- set _out to _out & (_idx as string) & "${FIELD_SEP}error:" & _e & "${RECORD_SEP}"
80889
+ ${this.errorEmit(" ")}
80254
80890
  end try
80255
- end repeat
80256
- end if`
80257
- ).join("\n");
80891
+ end repeat${instrument ? `${this.countFragment("_ca")}${this.snapshotFragment("after", acctLit, mbLit, "_ca")}${this.reconEmit(acctLit, mbLit, "_cb", "_ca")}` : ""}
80892
+ end if`;
80893
+ }).join("\n");
80258
80894
  const unlocatedBlock = unlocated.length ? `
80259
80895
  set _uids to ${asList(unlocated.map((it) => it.num))}
80260
80896
  set _upos to ${asList(unlocated.map((it) => it.pos))}
@@ -80286,14 +80922,35 @@ var AppleMailManager = class {
80286
80922
  if (item _k of _uhit) is 0 then
80287
80923
  set _out to _out & (_idx as string) & "${FIELD_SEP}notfound${RECORD_SEP}"
80288
80924
  else if (item _k of _uhit) > 1 then
80289
- set _out to _out & (_idx as string) & "${FIELD_SEP}error:${AMBIGUOUS_ID_BATCH}(" & (item _k of _unames) & "); list or search that mailbox first so the operation targets the right copy${RECORD_SEP}"
80925
+ set _uname to (item _k of _unames)${this.sanitizeFragment("_uname", " ")}
80926
+ set _out to _out & (_idx as string) & "${FIELD_SEP}error:${AMBIGUOUS_ID_BATCH}(" & _uname & "); list or search that mailbox first so the operation targets the right copy${RECORD_SEP}"
80290
80927
  else
80928
+ set _pre to ""
80291
80929
  try
80292
- set _msg to item _k of _umsg
80930
+ set _msg to item _k of _umsg${instrument ? `
80931
+ set _umb to missing value
80932
+ set _uacct to ""
80933
+ try
80934
+ set _umb to (mailbox of _msg)
80935
+ set _uacct to (name of (account of _umb))
80936
+ end try
80937
+ set _ucb to -1
80938
+ set _uca to -1
80939
+ if _umb is not missing value then
80940
+ try
80941
+ set _ucb to (count of messages of _umb)
80942
+ end try
80943
+ end if${this.preImageFragment("_msg")}` : ""}
80293
80944
  ${operation}
80294
- set _out to _out & (_idx as string) & "${FIELD_SEP}ok${RECORD_SEP}"
80945
+ set _out to _out & (_idx as string) & "${FIELD_SEP}ok" & _pre & "${RECORD_SEP}"${instrument ? `
80946
+ if _umb is not missing value then
80947
+ try
80948
+ set _uca to (count of messages of _umb)
80949
+ end try
80950
+ ${this.reconEmitFromMessage("_ucb", "_uca", "(_idx as string)", " ")}
80951
+ end if` : ""}
80295
80952
  on error _e
80296
- set _out to _out & (_idx as string) & "${FIELD_SEP}error:" & _e & "${RECORD_SEP}"
80953
+ ${this.errorEmit(" ")}
80297
80954
  end try
80298
80955
  end if
80299
80956
  end repeat` : "";
@@ -80312,33 +80969,46 @@ var AppleMailManager = class {
80312
80969
  const result = executeAppleScript(script, { timeoutMs });
80313
80970
  if (!result.success) {
80314
80971
  const err = result.error || "Batch operation failed";
80315
- return ids.map((id) => ({ id, success: false, error: err }));
80972
+ return operands.map((id) => ({ id, success: false, error: err }));
80316
80973
  }
80317
80974
  if (result.output.startsWith(BATCH_FATAL)) {
80318
80975
  const err = result.output.slice(BATCH_FATAL.length);
80319
- return ids.map((id) => ({ id, success: false, error: err }));
80320
- }
80321
- const byId = /* @__PURE__ */ new Map();
80322
- for (const rec of result.output.split(RECORD_SEP)) {
80323
- if (!rec) continue;
80324
- const sep2 = rec.indexOf(FIELD_SEP);
80325
- if (sep2 < 0) continue;
80326
- const pos = Number(rec.slice(0, sep2));
80327
- const status = rec.slice(sep2 + FIELD_SEP.length);
80328
- const entry = valid[pos - 1];
80329
- if (!entry) continue;
80330
- const id = entry.id;
80331
- if (status === "ok") {
80332
- byId.set(id, { id, success: true });
80333
- } else if (status === "notfound") {
80334
- byId.set(id, { id, success: false, error: "Message not found" });
80335
- } else if (status.startsWith("error:")) {
80336
- byId.set(id, { id, success: false, error: status.slice("error:".length) });
80337
- } else {
80338
- byId.set(id, { id, success: false, error: status || "Unknown error" });
80339
- }
80976
+ return operands.map((id) => ({ id, success: false, error: err }));
80977
+ }
80978
+ const parsed = this.parseForensicStream(result.output, valid);
80979
+ const { byId } = parsed;
80980
+ if (instrument) {
80981
+ const posLocation = /* @__PURE__ */ new Map();
80982
+ for (const g of groups.values()) {
80983
+ for (const it of g.items)
80984
+ posLocation.set(it.pos, { account: g.account, mailbox: g.mailbox });
80985
+ }
80986
+ for (const r of parsed.recons) {
80987
+ if (r.pos !== null) posLocation.set(r.pos, { account: r.account, mailbox: r.mailbox });
80988
+ }
80989
+ const { okPositions } = parsed;
80990
+ const dest = forensics?.destination;
80991
+ const sameMailbox = (account, mailbox) => dest !== void 0 && dest.account === account && this.resolveMailbox(dest.mailbox, dest.account) === this.resolveMailbox(mailbox, account);
80992
+ const expectedFor = (account, mailbox, pos) => {
80993
+ if (sameMailbox(account, mailbox)) return null;
80994
+ if (pos !== null) return okPositions.has(pos) ? 1 : 0;
80995
+ const group = groups.get(groupKey(account, mailbox));
80996
+ if (!group) return 0;
80997
+ return group.items.filter((it) => okPositions.has(it.pos)).length;
80998
+ };
80999
+ const noteFor = (account, mailbox) => sameMailbox(account, mailbox) ? SELF_MOVE_NOTE : void 0;
81000
+ this.lastForensics = this.buildForensicReport(
81001
+ parsed,
81002
+ valid,
81003
+ expectedFor,
81004
+ (pos) => posLocation.get(pos) ?? { account: "", mailbox: "" },
81005
+ noteFor,
81006
+ // Canonicalised, because the ids this is compared against come back
81007
+ // from AppleScript — see canonicalNumericId.
81008
+ new Set(valid.map((v) => canonicalNumericId(String(v.num))))
81009
+ );
80340
81010
  }
80341
- return ids.map(
81011
+ return operands.map(
80342
81012
  (id) => byId.get(id) ?? (Number.isFinite(Number(id)) ? { id, success: false, error: "No result returned" } : { id, success: false, error: "Invalid message ID" })
80343
81013
  );
80344
81014
  }
@@ -80346,7 +81016,7 @@ var AppleMailManager = class {
80346
81016
  * Delete multiple messages at once (single tree walk — see runBatchOperation).
80347
81017
  */
80348
81018
  batchDeleteMessages(ids, scope) {
80349
- return this.runBatchOperation(ids, "delete _msg", "", scope);
81019
+ return this.runBatchOperation(ids, "delete _msg", "", scope, {});
80350
81020
  }
80351
81021
  /**
80352
81022
  * Move multiple messages to a mailbox at once (single tree walk).
@@ -80369,7 +81039,9 @@ var AppleMailManager = class {
80369
81039
  if (count of destMatches) is 0 then return "${BATCH_FATAL}Destination mailbox \\"" & destName & "\\" not found in account \\"${safeAccount}\\""
80370
81040
  if (count of destMatches) > 1 then return "${BATCH_FATAL}Destination mailbox \\"" & destName & "\\" is ambiguous (" & (count of destMatches) & " matches) in account \\"${safeAccount}\\"; move by full path"
80371
81041
  set destMailbox to item 1 of destMatches`;
80372
- return this.runBatchOperation(ids, "move _msg to destMailbox", setup, scope);
81042
+ return this.runBatchOperation(ids, "move _msg to destMailbox", setup, scope, {
81043
+ destination: { account: targetAccount, mailbox: targetMailbox }
81044
+ });
80373
81045
  }
80374
81046
  /**
80375
81047
  * Mark multiple messages as read at once (single tree walk).
@@ -82783,9 +83455,16 @@ function collectAttachments(node, out = []) {
82783
83455
  for (const child of node.childNodes ?? []) collectAttachments(child, out);
82784
83456
  return out;
82785
83457
  }
82786
- async function streamToBuffer(content) {
83458
+ async function streamToBuffer(content, maxBytes) {
82787
83459
  const chunks = [];
82788
- for await (const chunk of content) chunks.push(Buffer.from(chunk));
83460
+ let total = 0;
83461
+ for await (const chunk of content) {
83462
+ total += chunk.byteLength;
83463
+ if (total > maxBytes) {
83464
+ throw new Error(`IMAP attachment exceeds the ${maxBytes / 1024 / 1024} MiB size limit.`);
83465
+ }
83466
+ chunks.push(Buffer.from(chunk));
83467
+ }
82789
83468
  return Buffer.concat(chunks);
82790
83469
  }
82791
83470
  async function imapListAttachments(id, deps = {}) {
@@ -82822,14 +83501,24 @@ async function imapFetchAttachment(id, attachmentName, deps = {}) {
82822
83501
  error: `Attachment "${attachmentName}" not found on UID ${ref.uid}. Available: ${names}.`
82823
83502
  };
82824
83503
  }
82825
- const dl = await client.download(String(ref.uid), match.part, { uid: true });
82826
- const buf = await streamToBuffer(dl.content);
82827
- return {
82828
- success: true,
82829
- base64: buf.toString("base64"),
82830
- bytes: buf.length,
82831
- mimeType: match.mimeType
82832
- };
83504
+ if (match.size > MAX_IMAP_ATTACHMENT_BYTES) {
83505
+ return {
83506
+ success: false,
83507
+ error: `IMAP attachment "${attachmentName}" is ${match.size} bytes; the maximum is ${MAX_IMAP_ATTACHMENT_BYTES} bytes (25 MiB).`
83508
+ };
83509
+ }
83510
+ try {
83511
+ const dl = await client.download(String(ref.uid), match.part, { uid: true });
83512
+ const buf = await streamToBuffer(dl.content, MAX_IMAP_ATTACHMENT_BYTES);
83513
+ return {
83514
+ success: true,
83515
+ base64: buf.toString("base64"),
83516
+ bytes: buf.length,
83517
+ mimeType: match.mimeType
83518
+ };
83519
+ } catch (e) {
83520
+ return { success: false, error: `IMAP attachment fetch failed: ${errText(e)}` };
83521
+ }
82833
83522
  });
82834
83523
  }
82835
83524
  async function imapBatch(ids, deps, op) {
@@ -83060,8 +83749,9 @@ function withErrorHandling(handler, errorPrefix) {
83060
83749
 
83061
83750
  // src/tools/batchResults.ts
83062
83751
  async function hybridBatchCounts(ids, appleFn, imapFn) {
83063
- const imapIds = ids.filter((i) => i.startsWith("imap:"));
83064
- const numericIds = ids.filter((i) => !i.startsWith("imap:"));
83752
+ const distinctIds = [...new Set(ids)];
83753
+ const imapIds = distinctIds.filter((i) => i.startsWith("imap:"));
83754
+ const numericIds = distinctIds.filter((i) => !i.startsWith("imap:"));
83065
83755
  let success = 0;
83066
83756
  let fail = 0;
83067
83757
  const errors = [];
@@ -83091,7 +83781,7 @@ function formatBatchErrors(errors, max = 5) {
83091
83781
  return `: ${shown.join("; ")}${more > 0 ? ` (+${more} more)` : ""}`;
83092
83782
  }
83093
83783
  var MAX_STRUCTURED_BATCH_ERRORS = 20;
83094
- function batchResponse(counts, messages, extra = {}) {
83784
+ function batchResponse(counts, messages, extra = {}, warnings = []) {
83095
83785
  const { success, fail, errors } = counts;
83096
83786
  const distinct = distinctErrors(errors);
83097
83787
  const reported = distinct.slice(0, MAX_STRUCTURED_BATCH_ERRORS);
@@ -83104,9 +83794,13 @@ function batchResponse(counts, messages, extra = {}) {
83104
83794
  ...distinct.length > reported.length ? { errorsTruncated: true } : {}
83105
83795
  };
83106
83796
  const suffix = formatBatchErrors(distinct);
83107
- if (fail === 0) return successResponse(messages.allSucceeded(success), structured);
83108
- if (success === 0) return errorResponse(`${messages.allFailed(fail)}${suffix}`, structured);
83109
- return successResponse(`${messages.partial(success, fail)}${suffix}`, structured);
83797
+ const warn = warnings.length > 0 ? `
83798
+
83799
+ ${warnings.join("\n")}` : "";
83800
+ if (fail === 0) return successResponse(`${messages.allSucceeded(success)}${warn}`, structured);
83801
+ if (success === 0)
83802
+ return errorResponse(`${messages.allFailed(fail)}${suffix}${warn}`, structured);
83803
+ return successResponse(`${messages.partial(success, fail)}${suffix}${warn}`, structured);
83110
83804
  }
83111
83805
 
83112
83806
  // src/services/imapMultiAccount.ts
@@ -83769,6 +84463,22 @@ var BATCH_COUNT_OUTPUT_SCHEMA = {
83769
84463
  // so a short list is never mistaken for the complete one.
83770
84464
  errorsTruncated: external_exports.boolean().optional()
83771
84465
  };
84466
+ var COUNT_DELTA_OUTPUT_SCHEMA = external_exports.array(
84467
+ external_exports.object({
84468
+ account: external_exports.string().optional(),
84469
+ mailbox: external_exports.string().optional(),
84470
+ before: external_exports.number().nullable().optional(),
84471
+ after: external_exports.number().nullable().optional(),
84472
+ // Nullable for the same reason before/after/observed are: null means no
84473
+ // comparison was possible. For `expected` that is a move whose
84474
+ // destination IS the source mailbox — it always pairs with
84475
+ // `status: "unknown"`, and never with a warning.
84476
+ expected: external_exports.number().nullable().optional(),
84477
+ observed: external_exports.number().nullable().optional(),
84478
+ status: external_exports.enum(["match", "over", "under", "unknown"]).optional(),
84479
+ note: external_exports.string().optional()
84480
+ })
84481
+ ).optional();
83772
84482
  var CHECK_ITEM_SCHEMA = external_exports.object({}).passthrough();
83773
84483
  var require2 = createRequire(import.meta.url);
83774
84484
  var { version: version2 } = require2("../package.json");
@@ -83848,6 +84558,15 @@ function registerTool(name, config2, cb) {
83848
84558
  }
83849
84559
  var mailManager = new AppleMailManager();
83850
84560
  registerResourcesAndPrompts(server, mailManager);
84561
+ function collectForensics(tool, args) {
84562
+ const report = mailManager.consumeLastForensics();
84563
+ if (!report) return { warnings: [] };
84564
+ writeDestructiveAudit({ tool, args, serverVersion: version2 }, report);
84565
+ return {
84566
+ ...report.countDeltas.length > 0 ? { countDelta: report.countDeltas } : {},
84567
+ warnings: reconciliationWarnings(report)
84568
+ };
84569
+ }
83851
84570
  registerTool(
83852
84571
  "search-messages",
83853
84572
  {
@@ -84577,14 +85296,28 @@ registerTool(
84577
85296
  inputSchema: {
84578
85297
  id: MESSAGE_ID_SCHEMA
84579
85298
  },
84580
- outputSchema: { ok: external_exports.boolean().optional(), id: external_exports.string().optional() }
85299
+ outputSchema: {
85300
+ ok: external_exports.boolean().optional(),
85301
+ id: external_exports.string().optional(),
85302
+ countDelta: COUNT_DELTA_OUTPUT_SCHEMA
85303
+ }
84581
85304
  },
84582
85305
  withErrorHandling(
84583
85306
  ({ id }) => routeMessage(id, {
84584
85307
  imap: () => imapDeleteMessageById(id),
84585
85308
  apple: () => {
84586
85309
  const { success, error: error2 } = mailManager.deleteMessage(id);
84587
- return success ? successResponse("Message deleted", { ok: true, id }) : errorResponse(error2 || `Failed to delete message "${id}"`);
85310
+ const { countDelta, warnings } = collectForensics("delete-message", { id });
85311
+ return success ? successResponse(
85312
+ `Message deleted${warnings.length ? `
85313
+
85314
+ ${warnings.join("\n")}` : ""}`,
85315
+ {
85316
+ ok: true,
85317
+ id,
85318
+ ...countDelta ? { countDelta } : {}
85319
+ }
85320
+ ) : errorResponse(error2 || `Failed to delete message "${id}"`);
84588
85321
  },
84589
85322
  ok: "Message deleted",
84590
85323
  fail: `Failed to delete message "${id}"`,
@@ -84605,7 +85338,8 @@ registerTool(
84605
85338
  outputSchema: {
84606
85339
  ok: external_exports.boolean().optional(),
84607
85340
  id: external_exports.string().optional(),
84608
- mailbox: external_exports.string().optional()
85341
+ mailbox: external_exports.string().optional(),
85342
+ countDelta: COUNT_DELTA_OUTPUT_SCHEMA
84609
85343
  }
84610
85344
  },
84611
85345
  withErrorHandling(
@@ -84613,7 +85347,17 @@ registerTool(
84613
85347
  imap: () => imapMoveMessageById(id, mailbox),
84614
85348
  apple: () => {
84615
85349
  const { success, error: error2 } = mailManager.moveMessage(id, mailbox, account);
84616
- return success ? successResponse(`Message moved to "${mailbox}"`, { ok: true, id, mailbox }) : errorResponse(error2 || `Failed to move message to "${mailbox}"`);
85350
+ const { countDelta, warnings } = collectForensics("move-message", {
85351
+ id,
85352
+ mailbox,
85353
+ account
85354
+ });
85355
+ return success ? successResponse(
85356
+ `Message moved to "${mailbox}"${warnings.length ? `
85357
+
85358
+ ${warnings.join("\n")}` : ""}`,
85359
+ { ok: true, id, mailbox, ...countDelta ? { countDelta } : {} }
85360
+ ) : errorResponse(error2 || `Failed to move message to "${mailbox}"`);
84617
85361
  },
84618
85362
  ok: `Message moved to "${mailbox}"`,
84619
85363
  fail: `Failed to move message to "${mailbox}"`,
@@ -84631,19 +85375,36 @@ registerTool(
84631
85375
  sourceMailbox: BATCH_SOURCE_MAILBOX_SCHEMA,
84632
85376
  sourceAccount: BATCH_SOURCE_ACCOUNT_SCHEMA
84633
85377
  },
84634
- outputSchema: BATCH_COUNT_OUTPUT_SCHEMA
85378
+ outputSchema: { ...BATCH_COUNT_OUTPUT_SCHEMA, countDelta: COUNT_DELTA_OUTPUT_SCHEMA }
84635
85379
  },
84636
85380
  withErrorHandling(async ({ ids, sourceMailbox, sourceAccount }) => {
85381
+ let forensics = { warnings: [] };
84637
85382
  const counts = await hybridBatchCounts(
84638
85383
  ids,
84639
- (n) => mailManager.batchDeleteMessages(n, { account: sourceAccount, mailbox: sourceMailbox }),
85384
+ (n) => {
85385
+ const res = mailManager.batchDeleteMessages(n, {
85386
+ account: sourceAccount,
85387
+ mailbox: sourceMailbox
85388
+ });
85389
+ forensics = collectForensics("batch-delete-messages", {
85390
+ ids,
85391
+ sourceMailbox,
85392
+ sourceAccount
85393
+ });
85394
+ return res;
85395
+ },
84640
85396
  (im) => imapBatchDelete(im)
84641
85397
  );
84642
- return batchResponse(counts, {
84643
- allSucceeded: (n) => `Successfully deleted ${n} message(s)`,
84644
- allFailed: (n) => `Failed to delete all ${n} message(s)`,
84645
- partial: (ok, failed) => `Deleted ${ok} message(s), ${failed} failed`
84646
- });
85398
+ return batchResponse(
85399
+ counts,
85400
+ {
85401
+ allSucceeded: (n) => `Successfully deleted ${n} message(s)`,
85402
+ allFailed: (n) => `Failed to delete all ${n} message(s)`,
85403
+ partial: (ok, failed) => `Deleted ${ok} message(s), ${failed} failed`
85404
+ },
85405
+ forensics.countDelta ? { countDelta: forensics.countDelta } : {},
85406
+ forensics.warnings
85407
+ );
84647
85408
  }, "Error batch deleting messages")
84648
85409
  );
84649
85410
  registerTool(
@@ -84657,15 +85418,26 @@ registerTool(
84657
85418
  sourceMailbox: BATCH_SOURCE_MAILBOX_SCHEMA,
84658
85419
  sourceAccount: BATCH_SOURCE_ACCOUNT_SCHEMA
84659
85420
  },
84660
- outputSchema: BATCH_COUNT_OUTPUT_SCHEMA
85421
+ outputSchema: { ...BATCH_COUNT_OUTPUT_SCHEMA, countDelta: COUNT_DELTA_OUTPUT_SCHEMA }
84661
85422
  },
84662
85423
  withErrorHandling(async ({ ids, mailbox, account, sourceMailbox, sourceAccount }) => {
85424
+ let forensics = { warnings: [] };
84663
85425
  const counts = await hybridBatchCounts(
84664
85426
  ids,
84665
- (n) => mailManager.batchMoveMessages(n, mailbox, account, {
84666
- account: sourceAccount,
84667
- mailbox: sourceMailbox
84668
- }),
85427
+ (n) => {
85428
+ const res = mailManager.batchMoveMessages(n, mailbox, account, {
85429
+ account: sourceAccount,
85430
+ mailbox: sourceMailbox
85431
+ });
85432
+ forensics = collectForensics("batch-move-messages", {
85433
+ ids,
85434
+ mailbox,
85435
+ account,
85436
+ sourceMailbox,
85437
+ sourceAccount
85438
+ });
85439
+ return res;
85440
+ },
84669
85441
  (im) => imapBatchMove(im, mailbox, { account })
84670
85442
  );
84671
85443
  return batchResponse(
@@ -84675,7 +85447,8 @@ registerTool(
84675
85447
  allFailed: (n) => `Failed to move all ${n} message(s)`,
84676
85448
  partial: (ok, failed) => `Moved ${ok} message(s) to "${mailbox}", ${failed} failed`
84677
85449
  },
84678
- { mailbox }
85450
+ { mailbox, ...forensics.countDelta ? { countDelta: forensics.countDelta } : {} },
85451
+ forensics.warnings
84679
85452
  );
84680
85453
  }, "Error batch moving messages")
84681
85454
  );