apple-mail-mcp 2.14.0 → 2.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +17 -6
  2. package/build/index.js +135 -41
  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;
@@ -79470,16 +79478,61 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
79470
79478
  set _sMiss to ""
79471
79479
  set _sPairs to {}
79472
79480
  set _sChunk to ${chunk}
79481
+ -- The mailbox's MEASURED length, emitted only when it disagrees with the
79482
+ -- count (#187). -1 = "not measured", which is the normal case: the probe
79483
+ -- only runs a binary search when the count's last position is unreadable.
79484
+ -- Initialised here, not in the else-branch, or the skipped/unavailable
79485
+ -- paths would reference an unbound variable when emitting.
79486
+ set _sTrue to -1
79473
79487
  if ${countVar} < 0 then
79474
79488
  set _sStatus to "unavailable"
79475
79489
  else if ${countVar} > ${max} then
79476
79490
  set _sStatus to "skipped"
79477
79491
  set _sPayload to "mailbox holds " & (${countVar} as string) & " messages, above ${AUDIT_SNAPSHOT_MAX_ENV}=${max}"
79478
79492
  else
79493
+ -- #187: the count can read HIGH, and an out-of-range range RAISES as
79494
+ -- a whole rather than clamping. On a mailbox smaller than one chunk
79495
+ -- there is only ONE slice, so a high count made it fail entirely:
79496
+ -- _sPairs stayed empty, the status collapsed to "unavailable", and
79497
+ -- the record carried no holes and no warning. The collateral
79498
+ -- instrument switched itself off in exactly the stale direction #155
79499
+ -- evidences, silently.
79500
+ --
79501
+ -- So establish a bound that actually EXISTS before slicing. If the
79502
+ -- last position the count claims is readable, the count is not high
79503
+ -- and this costs one probe. Otherwise binary-search the true end,
79504
+ -- which is O(log n) probes and also MEASURES how stale the count is.
79505
+ set _sBound to ${countVar}
79506
+ if _sBound > 0 then
79507
+ set _sEndOk to false
79508
+ try
79509
+ get id of message _sBound of ${mbVar}
79510
+ set _sEndOk to true
79511
+ end try
79512
+ if not _sEndOk then
79513
+ set _sLoB to 0
79514
+ set _sHiB to _sBound
79515
+ repeat while (_sHiB - _sLoB) > 1
79516
+ set _sMid to (_sLoB + _sHiB) div 2
79517
+ set _sMidOk to false
79518
+ try
79519
+ get id of message _sMid of ${mbVar}
79520
+ set _sMidOk to true
79521
+ end try
79522
+ if _sMidOk then
79523
+ set _sLoB to _sMid
79524
+ else
79525
+ set _sHiB to _sMid
79526
+ end if
79527
+ end repeat
79528
+ set _sBound to _sLoB
79529
+ set _sTrue to _sLoB
79530
+ end if
79531
+ end if
79479
79532
  set _sLo to 1
79480
- repeat while _sLo <= ${countVar}
79533
+ repeat while _sLo <= _sBound
79481
79534
  set _sHi to _sLo + _sChunk - 1
79482
- if _sHi > ${countVar} then set _sHi to ${countVar}
79535
+ if _sHi > _sBound then set _sHi to _sBound
79483
79536
  set _sGot to false
79484
79537
  repeat with _sTry from 1 to ${SNAPSHOT_SLICE_ATTEMPTS}
79485
79538
  set _sIds to {}
@@ -79537,7 +79590,7 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
79537
79590
  -- PARTIAL under the existing rules and withholds the halves a
79538
79591
  -- truncation would poison.
79539
79592
  try
79540
- set _sOverId to ((id of message (${countVar} + 1) of ${mbVar}) as string)
79593
+ set _sOverId to ((id of message (_sBound + 1) of ${mbVar}) as string)
79541
79594
  -- A specifier that CLAMPS rather than raising hands back the LAST
79542
79595
  -- message instead of failing. That is not evidence of a truncation,
79543
79596
  -- so only an id this enumeration did not already record counts.
@@ -79547,7 +79600,7 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
79547
79600
  end repeat
79548
79601
  if not _sSeen then
79549
79602
  if _sMiss is not "" then set _sMiss to _sMiss & ","
79550
- set _sMiss to _sMiss & ((${countVar} + 1) as string) & "-end"
79603
+ set _sMiss to _sMiss & ((_sBound + 1) as string) & "-end"
79551
79604
  end if
79552
79605
  end try
79553
79606
  if _sMiss is not "" then
@@ -79562,7 +79615,7 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
79562
79615
  set _sPayload to _sPairs as string
79563
79616
  set AppleScript's text item delimiters to _sTid
79564
79617
  end if
79565
- set _out to _out & "${SNAP_TAG}${FIELD_SEP}" & ${acctExpr} & "${FIELD_SEP}" & ${mbExpr} & "${FIELD_SEP}${phase}${FIELD_SEP}" & _sStatus & "${FIELD_SEP}" & _sPayload & "${FIELD_SEP}" & _sMiss & "${RECORD_SEP}"`;
79618
+ set _out to _out & "${SNAP_TAG}${FIELD_SEP}" & ${acctExpr} & "${FIELD_SEP}" & ${mbExpr} & "${FIELD_SEP}${phase}${FIELD_SEP}" & _sStatus & "${FIELD_SEP}" & _sPayload & "${FIELD_SEP}" & _sMiss & "${FIELD_SEP}" & (_sTrue as string) & "${RECORD_SEP}"`;
79566
79619
  }
79567
79620
  /**
79568
79621
  * AppleScript capturing the message the op is ABOUT to touch into `_pre`,
@@ -79623,13 +79676,15 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
79623
79676
  continue;
79624
79677
  }
79625
79678
  if (f[0] === SNAP_TAG) {
79679
+ const measured = f[7] !== void 0 && f[7] !== "" ? Number(f[7]) : -1;
79626
79680
  snaps.push({
79627
79681
  account: f[1] ?? "",
79628
79682
  mailbox: f[2] ?? "",
79629
79683
  phase: f[3] === "after" ? "after" : "before",
79630
79684
  status: f[4] ?? "",
79631
79685
  payload: f[5] ?? "",
79632
- miss: f[6] ?? ""
79686
+ miss: f[6] ?? "",
79687
+ ...Number.isFinite(measured) && measured >= 0 ? { measuredLength: measured } : {}
79633
79688
  });
79634
79689
  continue;
79635
79690
  }
@@ -79724,25 +79779,7 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
79724
79779
  const readable = m.before >= 0 && m.after >= 0;
79725
79780
  const observed = readable ? m.before - m.after : null;
79726
79781
  const note = noteFor(m.account, m.mailbox);
79727
- let status;
79728
- let unknownReason;
79729
- if (!readable) {
79730
- status = "unknown";
79731
- unknownReason = "count-unreadable";
79732
- } else if (m.expected === null) {
79733
- status = "unknown";
79734
- unknownReason = "no-expectation";
79735
- } else if (observed === m.expected) {
79736
- status = "match";
79737
- } else if ((observed ?? 0) > m.expected) {
79738
- status = "over";
79739
- } else if (observed === 0) {
79740
- status = "unknown";
79741
- unknownReason = "count-did-not-move";
79742
- } else {
79743
- status = "unknown";
79744
- unknownReason = "count-partial";
79745
- }
79782
+ const { status, unknownReason } = classifyCountStatus(readable, m.expected, observed);
79746
79783
  return {
79747
79784
  account: m.account,
79748
79785
  mailbox: m.mailbox,
@@ -79813,6 +79850,7 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
79813
79850
  const unrequested = disappeared.filter(
79814
79851
  (e) => !requestedNumericIds.has(canonicalNumericId(e.id))
79815
79852
  );
79853
+ const countStale = [b, a].filter((s) => s.measuredLength !== void 0).map((s) => ({ phase: s.phase, measuredLength: s.measuredLength }));
79816
79854
  const holes = [b, a].filter((s) => s.miss !== "").map((s) => ({ phase: s.phase, ranges: s.miss }));
79817
79855
  if (holes.length === 0) {
79818
79856
  collateral.push({
@@ -79821,7 +79859,8 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
79821
79859
  snapshot: "ok",
79822
79860
  disappeared,
79823
79861
  unrequested,
79824
- appeared
79862
+ appeared,
79863
+ ...countStale.length ? { countStale } : {}
79825
79864
  });
79826
79865
  continue;
79827
79866
  }
@@ -79833,6 +79872,7 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
79833
79872
  account: g.account,
79834
79873
  mailbox: g.mailbox,
79835
79874
  snapshot: "partial",
79875
+ ...countStale.length ? { countStale } : {},
79836
79876
  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".`,
79837
79877
  unobserved: holes,
79838
79878
  ...a.miss === "" ? { disappeared, unrequested } : {},
@@ -84425,7 +84465,15 @@ async function imapFetchAttachment(id, attachmentName, deps = {}) {
84425
84465
  }
84426
84466
  });
84427
84467
  }
84428
- async function imapBatch(ids, deps, op) {
84468
+ async function mailboxCount(client, path) {
84469
+ try {
84470
+ const st = await client.status(path, { messages: true });
84471
+ return typeof st.messages === "number" ? st.messages : null;
84472
+ } catch {
84473
+ return null;
84474
+ }
84475
+ }
84476
+ async function imapBatch(ids, deps, op, opts = {}) {
84429
84477
  const groups = /* @__PURE__ */ new Map();
84430
84478
  const errors = [];
84431
84479
  let failed = 0;
@@ -84442,15 +84490,39 @@ async function imapBatch(ids, deps, op) {
84442
84490
  groups.set(key, g);
84443
84491
  }
84444
84492
  let success = 0;
84493
+ const countDelta = [];
84445
84494
  for (const g of groups.values()) {
84446
84495
  try {
84447
84496
  await useClient(depsForAccount(g.account, deps), async (client) => {
84497
+ const before = opts.reconcile ? await mailboxCount(client, g.path) : null;
84448
84498
  const lock = await client.getMailboxLock(g.path);
84449
84499
  try {
84450
84500
  await op(client, g.uids, g.path);
84451
84501
  } finally {
84452
84502
  lock.release();
84453
84503
  }
84504
+ if (!opts.reconcile) return;
84505
+ const after = await mailboxCount(client, g.path);
84506
+ const readable = before !== null && after !== null;
84507
+ const observed = readable ? before - after : null;
84508
+ const { status, unknownReason } = classifyCountStatus(readable, g.uids.length, observed);
84509
+ countDelta.push({
84510
+ account: g.account,
84511
+ mailbox: g.path,
84512
+ before,
84513
+ after,
84514
+ expected: g.uids.length,
84515
+ observed,
84516
+ status,
84517
+ ...unknownReason ? { unknownReason } : {},
84518
+ ...unknownReason === "count-unreadable" ? { note: "The server did not answer STATUS for this mailbox" } : {},
84519
+ ...unknownReason === "count-did-not-move" ? {
84520
+ 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.`
84521
+ } : {},
84522
+ ...unknownReason === "count-partial" ? {
84523
+ 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.`
84524
+ } : {}
84525
+ });
84454
84526
  });
84455
84527
  success += g.uids.length;
84456
84528
  } catch (e) {
@@ -84458,7 +84530,7 @@ async function imapBatch(ids, deps, op) {
84458
84530
  errors.push(`${g.path}: ${errText(e)}`);
84459
84531
  }
84460
84532
  }
84461
- return { success, failed, errors };
84533
+ return { success, failed, errors, ...countDelta.length ? { countDelta } : {} };
84462
84534
  }
84463
84535
  var imapBatchMarkRead = (ids, deps = {}) => imapBatch(ids, deps, async (c, uids) => {
84464
84536
  assertMutated(
@@ -84493,17 +84565,27 @@ var imapBatchUnflag = (ids, deps = {}) => imapBatch(ids, deps, async (c, uids) =
84493
84565
  `IMAP unflag of ${uids.length} message(s)`
84494
84566
  );
84495
84567
  });
84496
- var imapBatchDelete = (ids, deps = {}) => imapBatch(ids, deps, async (c, uids, path) => {
84497
- await trashUids(c, uids, path);
84498
- });
84568
+ var imapBatchDelete = (ids, deps = {}) => imapBatch(
84569
+ ids,
84570
+ deps,
84571
+ async (c, uids, path) => {
84572
+ await trashUids(c, uids, path);
84573
+ },
84574
+ { reconcile: true }
84575
+ );
84499
84576
  function imapBatchMove(ids, destMailbox, deps = {}) {
84500
- return imapBatch(ids, deps, async (c, uids) => {
84501
- const dest = await findMailboxPathOrThrow(c, destMailbox) ?? resolveMailboxPath(destMailbox, "list");
84502
- assertMutated(
84503
- await c.messageMove(uids, dest, { uid: true }),
84504
- `IMAP move of ${uids.length} message(s) to "${dest}"`
84505
- );
84506
- });
84577
+ return imapBatch(
84578
+ ids,
84579
+ deps,
84580
+ async (c, uids) => {
84581
+ const dest = await findMailboxPathOrThrow(c, destMailbox) ?? resolveMailboxPath(destMailbox, "list");
84582
+ assertMutated(
84583
+ await c.messageMove(uids, dest, { uid: true }),
84584
+ `IMAP move of ${uids.length} message(s) to "${dest}"`
84585
+ );
84586
+ },
84587
+ { reconcile: true }
84588
+ );
84507
84589
  }
84508
84590
  function senderName(from) {
84509
84591
  const a = from?.[0];
@@ -84702,13 +84784,15 @@ async function hybridBatchCounts(ids, appleFn, imapFn) {
84702
84784
  fail += res.length - s;
84703
84785
  errors.push(...res.filter((r) => !r.success && r.error).map((r) => r.error));
84704
84786
  }
84787
+ let countDelta;
84705
84788
  if (imapIds.length > 0) {
84706
84789
  const r = await imapFn(imapIds);
84707
84790
  success += r.success;
84708
84791
  fail += r.failed;
84709
84792
  errors.push(...r.errors);
84793
+ if (r.countDelta?.length) countDelta = r.countDelta;
84710
84794
  }
84711
- return { success, fail, errors };
84795
+ return { success, fail, errors, ...countDelta ? { countDelta } : {} };
84712
84796
  }
84713
84797
  function distinctErrors(errors) {
84714
84798
  return [...new Set(errors.filter(Boolean))];
@@ -84747,6 +84831,10 @@ ${warnings.join("\n")}` : "";
84747
84831
  function toManagerScope(args) {
84748
84832
  return { account: args.sourceAccount, mailbox: args.sourceMailbox };
84749
84833
  }
84834
+ function mergeCountDeltas(apple, imap) {
84835
+ const all = [...apple ?? [], ...imap ?? []];
84836
+ return all.length ? { countDelta: all } : {};
84837
+ }
84750
84838
  async function runBatchDelete(deps, args) {
84751
84839
  const { ids, sourceMailbox, sourceAccount } = args;
84752
84840
  let forensics = { warnings: [] };
@@ -84770,7 +84858,10 @@ async function runBatchDelete(deps, args) {
84770
84858
  allFailed: (n) => `Failed to delete all ${n} message(s)`,
84771
84859
  partial: (ok, failed) => `Deleted ${ok} message(s), ${failed} failed`
84772
84860
  },
84773
- forensics.countDelta ? { countDelta: forensics.countDelta } : {},
84861
+ // #181: merge both backends' reconciliation. An AppleScript-only batch is
84862
+ // unchanged; an IMAP-only one now reports a delta where it previously
84863
+ // reported nothing; a mixed batch reports both, per source mailbox.
84864
+ mergeCountDeltas(forensics.countDelta, counts.countDelta),
84774
84865
  forensics.warnings
84775
84866
  );
84776
84867
  }
@@ -84804,7 +84895,10 @@ async function runBatchMove(deps, args) {
84804
84895
  allFailed: (n) => `Failed to move all ${n} message(s)`,
84805
84896
  partial: (ok, failed) => `Moved ${ok} message(s) to "${mailbox}", ${failed} failed`
84806
84897
  },
84807
- { mailbox, ...forensics.countDelta ? { countDelta: forensics.countDelta } : {} },
84898
+ {
84899
+ mailbox,
84900
+ ...mergeCountDeltas(forensics.countDelta, counts.countDelta)
84901
+ },
84808
84902
  forensics.warnings
84809
84903
  );
84810
84904
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-mail-mcp",
3
- "version": "2.14.0",
3
+ "version": "2.15.0",
4
4
  "description": "MCP server for Apple Mail - read, search, send, and manage emails via Claude and other AI assistants",
5
5
  "type": "module",
6
6
  "main": "build/index.js",