apple-mail-mcp 2.14.1 → 2.15.1

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 +17 -6
  2. package/build/index.js +107 -37
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1444,12 +1444,23 @@ Three more honesty rules:
1444
1444
  than list positions — and `expected` stays comparable with the mailbox instead
1445
1445
  of double-counting a duplicate into a false `over`.
1446
1446
 
1447
- `imap:` ids are not reconciled by `countDelta`. An IMAP UID names exactly one
1448
- message in exactly one mailbox, so the mis-targeting class this exists for cannot
1449
- occur there; a batch of only `imap:` ids returns no `countDelta` rather than a
1450
- fabricated one.
1451
-
1452
- They carry their own post-condition check instead. `delete-message` and
1447
+ **`imap:` ids are reconciled too, as of 2.15.0.** `batch-delete-messages` and
1448
+ `batch-move-messages` return the same `countDelta` structure on the IMAP path, so
1449
+ one shape covers both backends and a mixed batch reports an entry per source
1450
+ mailbox from whichever backend handled it. The entries are **concatenated, never
1451
+ summed** — Mail's own count can lag (#155) while the server's `STATUS` cannot, and
1452
+ averaging the two would hide which reading you were looking at.
1453
+
1454
+ Only the operations that actually remove messages from their source reconcile.
1455
+ `batch-mark-as-read` and the flag tools change no count, so emitting
1456
+ `expected: N, observed: 0` for them would manufacture an alarm; they report no
1457
+ `countDelta` at all.
1458
+
1459
+ Note the mis-targeting class `countDelta` was originally built for cannot occur
1460
+ on the IMAP path — a UID names exactly one message in exactly one mailbox — so
1461
+ there the value is effect confirmation rather than target confirmation.
1462
+
1463
+ Single-message tools carry a post-condition check instead. `delete-message` and
1453
1464
  `move-message` on an `imap:` id return a **`verification`** object in
1454
1465
  `structuredContent`:
1455
1466
 
package/build/index.js CHANGED
@@ -78828,6 +78828,14 @@ function auditSnapshotChunk() {
78828
78828
  if (!Number.isFinite(n) || n < 1) return DEFAULT_SNAPSHOT_CHUNK;
78829
78829
  return Math.floor(n);
78830
78830
  }
78831
+ function classifyCountStatus(readable, expected, observed) {
78832
+ if (!readable) return { status: "unknown", unknownReason: "count-unreadable" };
78833
+ if (expected === null) return { status: "unknown", unknownReason: "no-expectation" };
78834
+ if (observed === expected) return { status: "match" };
78835
+ if ((observed ?? 0) > expected) return { status: "over" };
78836
+ if (observed === 0) return { status: "unknown", unknownReason: "count-did-not-move" };
78837
+ return { status: "unknown", unknownReason: "count-partial" };
78838
+ }
78831
78839
  function writeAuditRecord(record2) {
78832
78840
  const path = auditLogPath();
78833
78841
  if (!path) return;
@@ -79162,6 +79170,26 @@ var COUNT_PARTIAL_NOTE = `Mail's count moved by less than this operation account
79162
79170
  function snapshotKey(entry) {
79163
79171
  return `${entry.id}\0${entry.messageId}`;
79164
79172
  }
79173
+ function crossCheckRenumbered(disappeared, appeared) {
79174
+ const index = (entries) => {
79175
+ const m = /* @__PURE__ */ new Map();
79176
+ for (const e of entries) {
79177
+ if (!e.messageId) continue;
79178
+ m.set(e.messageId, m.has(e.messageId) ? null : e);
79179
+ }
79180
+ return m;
79181
+ };
79182
+ const gone = index(disappeared);
79183
+ const came = index(appeared);
79184
+ const out = [];
79185
+ for (const [mid, before] of gone) {
79186
+ const after = came.get(mid);
79187
+ if (!before || !after) continue;
79188
+ if (before.id === after.id) continue;
79189
+ out.push({ messageId: mid, before: before.id, after: after.id });
79190
+ }
79191
+ return out;
79192
+ }
79165
79193
  function buildAppLevelScript(command) {
79166
79194
  return `
79167
79195
  tell application "Mail"
@@ -79771,25 +79799,7 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
79771
79799
  const readable = m.before >= 0 && m.after >= 0;
79772
79800
  const observed = readable ? m.before - m.after : null;
79773
79801
  const note = noteFor(m.account, m.mailbox);
79774
- let status;
79775
- let unknownReason;
79776
- if (!readable) {
79777
- status = "unknown";
79778
- unknownReason = "count-unreadable";
79779
- } else if (m.expected === null) {
79780
- status = "unknown";
79781
- unknownReason = "no-expectation";
79782
- } else if (observed === m.expected) {
79783
- status = "match";
79784
- } else if ((observed ?? 0) > m.expected) {
79785
- status = "over";
79786
- } else if (observed === 0) {
79787
- status = "unknown";
79788
- unknownReason = "count-did-not-move";
79789
- } else {
79790
- status = "unknown";
79791
- unknownReason = "count-partial";
79792
- }
79802
+ const { status, unknownReason } = classifyCountStatus(readable, m.expected, observed);
79793
79803
  return {
79794
79804
  account: m.account,
79795
79805
  mailbox: m.mailbox,
@@ -79855,8 +79865,12 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
79855
79865
  const afterEntries = this.parseSnapshot(a.payload);
79856
79866
  const afterKeys = new Set(afterEntries.map((e) => snapshotKey(e)));
79857
79867
  const beforeKeys = new Set(beforeEntries.map((e) => snapshotKey(e)));
79858
- const disappeared = beforeEntries.filter((e) => !afterKeys.has(snapshotKey(e)));
79859
- const appeared = afterEntries.filter((e) => !beforeKeys.has(snapshotKey(e)));
79868
+ const rawDisappeared = beforeEntries.filter((e) => !afterKeys.has(snapshotKey(e)));
79869
+ const rawAppeared = afterEntries.filter((e) => !beforeKeys.has(snapshotKey(e)));
79870
+ const renumbered = crossCheckRenumbered(rawDisappeared, rawAppeared);
79871
+ const renumberedMids = new Set(renumbered.map((r) => r.messageId));
79872
+ const disappeared = rawDisappeared.filter((e) => !renumberedMids.has(e.messageId));
79873
+ const appeared = rawAppeared.filter((e) => !renumberedMids.has(e.messageId));
79860
79874
  const unrequested = disappeared.filter(
79861
79875
  (e) => !requestedNumericIds.has(canonicalNumericId(e.id))
79862
79876
  );
@@ -79870,7 +79884,8 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
79870
79884
  disappeared,
79871
79885
  unrequested,
79872
79886
  appeared,
79873
- ...countStale.length ? { countStale } : {}
79887
+ ...countStale.length ? { countStale } : {},
79888
+ ...renumbered.length ? { renumbered } : {}
79874
79889
  });
79875
79890
  continue;
79876
79891
  }
@@ -79883,6 +79898,7 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
79883
79898
  mailbox: g.mailbox,
79884
79899
  snapshot: "partial",
79885
79900
  ...countStale.length ? { countStale } : {},
79901
+ ...renumbered.length ? { renumbered } : {},
79886
79902
  skipReason: `Mail would not read ${holes.map((h) => `${h.ranges} (${h.phase})`).join(", ")} of this mailbox, so the snapshot has a hole in it. ` + (derivable.length > 0 ? `Still derivable and reported: ${derivable.join(" and ")}. ` : `Neither half of the diff is derivable from it. `) + `Anything the unread range could refute is omitted rather than guessed \u2014 an absent field here means "not computable", not "empty".`,
79887
79903
  unobserved: holes,
79888
79904
  ...a.miss === "" ? { disappeared, unrequested } : {},
@@ -84475,7 +84491,15 @@ async function imapFetchAttachment(id, attachmentName, deps = {}) {
84475
84491
  }
84476
84492
  });
84477
84493
  }
84478
- async function imapBatch(ids, deps, op) {
84494
+ async function mailboxCount(client, path) {
84495
+ try {
84496
+ const st = await client.status(path, { messages: true });
84497
+ return typeof st.messages === "number" ? st.messages : null;
84498
+ } catch {
84499
+ return null;
84500
+ }
84501
+ }
84502
+ async function imapBatch(ids, deps, op, opts = {}) {
84479
84503
  const groups = /* @__PURE__ */ new Map();
84480
84504
  const errors = [];
84481
84505
  let failed = 0;
@@ -84492,15 +84516,39 @@ async function imapBatch(ids, deps, op) {
84492
84516
  groups.set(key, g);
84493
84517
  }
84494
84518
  let success = 0;
84519
+ const countDelta = [];
84495
84520
  for (const g of groups.values()) {
84496
84521
  try {
84497
84522
  await useClient(depsForAccount(g.account, deps), async (client) => {
84523
+ const before = opts.reconcile ? await mailboxCount(client, g.path) : null;
84498
84524
  const lock = await client.getMailboxLock(g.path);
84499
84525
  try {
84500
84526
  await op(client, g.uids, g.path);
84501
84527
  } finally {
84502
84528
  lock.release();
84503
84529
  }
84530
+ if (!opts.reconcile) return;
84531
+ const after = await mailboxCount(client, g.path);
84532
+ const readable = before !== null && after !== null;
84533
+ const observed = readable ? before - after : null;
84534
+ const { status, unknownReason } = classifyCountStatus(readable, g.uids.length, observed);
84535
+ countDelta.push({
84536
+ account: g.account,
84537
+ mailbox: g.path,
84538
+ before,
84539
+ after,
84540
+ expected: g.uids.length,
84541
+ observed,
84542
+ status,
84543
+ ...unknownReason ? { unknownReason } : {},
84544
+ ...unknownReason === "count-unreadable" ? { note: "The server did not answer STATUS for this mailbox" } : {},
84545
+ ...unknownReason === "count-did-not-move" ? {
84546
+ note: `The mailbox count did not move. On a label store (Gmail) a message can stay visible in an all-mail view after being moved out of a label, so this is not by itself evidence the operation failed \u2014 check the destination.`
84547
+ } : {},
84548
+ ...unknownReason === "count-partial" ? {
84549
+ note: `Fewer messages left than were operated on. \`observed\` is a LOWER BOUND on what left, not a count of what left \u2014 a concurrent delivery to this mailbox masks departures one-for-one.`
84550
+ } : {}
84551
+ });
84504
84552
  });
84505
84553
  success += g.uids.length;
84506
84554
  } catch (e) {
@@ -84508,7 +84556,7 @@ async function imapBatch(ids, deps, op) {
84508
84556
  errors.push(`${g.path}: ${errText(e)}`);
84509
84557
  }
84510
84558
  }
84511
- return { success, failed, errors };
84559
+ return { success, failed, errors, ...countDelta.length ? { countDelta } : {} };
84512
84560
  }
84513
84561
  var imapBatchMarkRead = (ids, deps = {}) => imapBatch(ids, deps, async (c, uids) => {
84514
84562
  assertMutated(
@@ -84543,17 +84591,27 @@ var imapBatchUnflag = (ids, deps = {}) => imapBatch(ids, deps, async (c, uids) =
84543
84591
  `IMAP unflag of ${uids.length} message(s)`
84544
84592
  );
84545
84593
  });
84546
- var imapBatchDelete = (ids, deps = {}) => imapBatch(ids, deps, async (c, uids, path) => {
84547
- await trashUids(c, uids, path);
84548
- });
84594
+ var imapBatchDelete = (ids, deps = {}) => imapBatch(
84595
+ ids,
84596
+ deps,
84597
+ async (c, uids, path) => {
84598
+ await trashUids(c, uids, path);
84599
+ },
84600
+ { reconcile: true }
84601
+ );
84549
84602
  function imapBatchMove(ids, destMailbox, deps = {}) {
84550
- return imapBatch(ids, deps, async (c, uids) => {
84551
- const dest = await findMailboxPathOrThrow(c, destMailbox) ?? resolveMailboxPath(destMailbox, "list");
84552
- assertMutated(
84553
- await c.messageMove(uids, dest, { uid: true }),
84554
- `IMAP move of ${uids.length} message(s) to "${dest}"`
84555
- );
84556
- });
84603
+ return imapBatch(
84604
+ ids,
84605
+ deps,
84606
+ async (c, uids) => {
84607
+ const dest = await findMailboxPathOrThrow(c, destMailbox) ?? resolveMailboxPath(destMailbox, "list");
84608
+ assertMutated(
84609
+ await c.messageMove(uids, dest, { uid: true }),
84610
+ `IMAP move of ${uids.length} message(s) to "${dest}"`
84611
+ );
84612
+ },
84613
+ { reconcile: true }
84614
+ );
84557
84615
  }
84558
84616
  function senderName(from) {
84559
84617
  const a = from?.[0];
@@ -84752,13 +84810,15 @@ async function hybridBatchCounts(ids, appleFn, imapFn) {
84752
84810
  fail += res.length - s;
84753
84811
  errors.push(...res.filter((r) => !r.success && r.error).map((r) => r.error));
84754
84812
  }
84813
+ let countDelta;
84755
84814
  if (imapIds.length > 0) {
84756
84815
  const r = await imapFn(imapIds);
84757
84816
  success += r.success;
84758
84817
  fail += r.failed;
84759
84818
  errors.push(...r.errors);
84819
+ if (r.countDelta?.length) countDelta = r.countDelta;
84760
84820
  }
84761
- return { success, fail, errors };
84821
+ return { success, fail, errors, ...countDelta ? { countDelta } : {} };
84762
84822
  }
84763
84823
  function distinctErrors(errors) {
84764
84824
  return [...new Set(errors.filter(Boolean))];
@@ -84797,6 +84857,10 @@ ${warnings.join("\n")}` : "";
84797
84857
  function toManagerScope(args) {
84798
84858
  return { account: args.sourceAccount, mailbox: args.sourceMailbox };
84799
84859
  }
84860
+ function mergeCountDeltas(apple, imap) {
84861
+ const all = [...apple ?? [], ...imap ?? []];
84862
+ return all.length ? { countDelta: all } : {};
84863
+ }
84800
84864
  async function runBatchDelete(deps, args) {
84801
84865
  const { ids, sourceMailbox, sourceAccount } = args;
84802
84866
  let forensics = { warnings: [] };
@@ -84820,7 +84884,10 @@ async function runBatchDelete(deps, args) {
84820
84884
  allFailed: (n) => `Failed to delete all ${n} message(s)`,
84821
84885
  partial: (ok, failed) => `Deleted ${ok} message(s), ${failed} failed`
84822
84886
  },
84823
- forensics.countDelta ? { countDelta: forensics.countDelta } : {},
84887
+ // #181: merge both backends' reconciliation. An AppleScript-only batch is
84888
+ // unchanged; an IMAP-only one now reports a delta where it previously
84889
+ // reported nothing; a mixed batch reports both, per source mailbox.
84890
+ mergeCountDeltas(forensics.countDelta, counts.countDelta),
84824
84891
  forensics.warnings
84825
84892
  );
84826
84893
  }
@@ -84854,7 +84921,10 @@ async function runBatchMove(deps, args) {
84854
84921
  allFailed: (n) => `Failed to move all ${n} message(s)`,
84855
84922
  partial: (ok, failed) => `Moved ${ok} message(s) to "${mailbox}", ${failed} failed`
84856
84923
  },
84857
- { mailbox, ...forensics.countDelta ? { countDelta: forensics.countDelta } : {} },
84924
+ {
84925
+ mailbox,
84926
+ ...mergeCountDeltas(forensics.countDelta, counts.countDelta)
84927
+ },
84858
84928
  forensics.warnings
84859
84929
  );
84860
84930
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-mail-mcp",
3
- "version": "2.14.1",
3
+ "version": "2.15.1",
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",