apple-mail-mcp 2.10.32 → 2.11.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 +99 -16
  2. package/build/index.js +141 -57
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1319,14 +1319,26 @@ returns the comparison in `structuredContent`:
1319
1319
  }
1320
1320
  ```
1321
1321
 
1322
- `status` is deliberately four-valued rather than a pass/fail flag:
1322
+ `status` is deliberately not a pass/fail flag:
1323
1323
 
1324
1324
  | `status` | Meaning | Warns? |
1325
1325
  |----------|---------|--------|
1326
1326
  | `match` | Exactly as many messages left the mailbox as the operation acted on. | No |
1327
1327
  | `over` | **More** left than were operated on. Messages are unaccounted for. | **Yes** |
1328
- | `under` | **Fewer** left than expected. | No |
1329
- | `unknown` | No comparison was possible: either Mail would not report a count (`before`/`after` null) or there is no predictable expectation (`expected` null — see the self-move rule below). | No |
1328
+ | `unknown` | No comparison this server is willing to assert. `unknownReason` says which of four situations produced it. | No |
1329
+
1330
+ `unknownReason` distinguishes four cases that are *not* interchangeable:
1331
+
1332
+ | `unknownReason` | Meaning |
1333
+ |-----------------|---------|
1334
+ | `count-unreadable` | Mail would not report a count at all (`before`/`after` null). |
1335
+ | `no-expectation` | No expectation is predictable, so no comparison exists — a move whose destination **is** the source mailbox. |
1336
+ | `count-did-not-move` | The count did not move. On a store that flags deletions instead of removing them this is the **ordinary, correct** reading for an operation that fully succeeded. |
1337
+ | `count-partial` | The count moved, but by less than the operation accounted for. A flag-only store cannot produce this, which is why it is worth telling apart. |
1338
+
1339
+ > **`under` was removed in 2.11.0.** It used to mean "fewer left than expected"
1340
+ > and was documented as routine. Field evidence retired it — see
1341
+ > [Why `observed` is a lower bound](#why-observed-is-a-lower-bound-155).
1330
1342
 
1331
1343
  #### What an `over` warning does and does not tell you
1332
1344
 
@@ -1346,7 +1358,7 @@ trusted:
1346
1358
  **Concurrent departure is the benign cause to rule out first**, and the warning
1347
1359
  text says so. What the asymmetry argument actually buys is the other half:
1348
1360
  concurrent *arrivals* cannot produce `over`, because a message arriving
1349
- mid-operation *raises* the after-count and biases the reading toward `under`.
1361
+ mid-operation *raises* the after-count and biases the reading short.
1350
1362
  That is why `over` is the interesting direction — a strong signal, not a proof.
1351
1363
 
1352
1364
  Setting `APPLE_MAIL_MCP_AUDIT_LOG` is what settles which one you have: the
@@ -1354,12 +1366,42 @@ collateral diff below **names** the messages that disappeared, and "the
1354
1366
  newsletter my rule files every morning" is a very different report from a message
1355
1367
  nothing should have touched.
1356
1368
 
1357
- `under`, by contrast, is routine: an account that flags deletions rather than
1358
- removing them leaves the message in place and the count does not move, and a
1359
- Gmail label mailbox can behave the same way while the delete genuinely succeeded.
1360
- A warning that fires on every ordinary Gmail delete would be ignored exactly when
1361
- it matters, so `under` is **reported** in `countDelta` (with a `note` explaining
1362
- it) and never warned about.
1369
+ #### Why `observed` is a lower bound (#155)
1370
+
1371
+ **`observed` is the movement of Mail's count. It is a lower bound on how many
1372
+ messages left, not a count of how many left.**
1373
+
1374
+ On iCloud, @scottstern0325 ran the check that settled this: for two batches
1375
+ reporting `observed: 0`, the messages were located **in Trash**, matched by
1376
+ `date received` + sender against the audit log's pre-image. The deletes had
1377
+ happened. Mail's count had not caught up. Across four readings — 0 of 4, 0 of 1
1378
+ (a *single-id* delete), 15 of 16, and 14 of 15 — the shortfall bore no relation
1379
+ to batch size, which is what a lagging count looks like and not what a
1380
+ store-behaviour rule looks like.
1381
+
1382
+ So a short reading is not evidence about your operation. **The per-id outcomes
1383
+ are what report success; this number is not. Do not retry on the strength of
1384
+ it** — that is how a message gets deleted twice.
1385
+
1386
+ Two things follow:
1387
+
1388
+ - A count that does not move at all is still the ordinary reading on a store that
1389
+ flags deletions instead of removing them (Gmail label mailboxes, IMAP accounts
1390
+ with "move deleted messages to Trash" off). It reports
1391
+ `unknownReason: "count-did-not-move"` and says so, and it is never warned about
1392
+ — a warning that fires on every ordinary Gmail delete would be ignored exactly
1393
+ when it matters.
1394
+ - **To confirm where messages went, match them at the destination by `date
1395
+ received` plus sender — not by the numeric ids you passed.** Ids are renumbered
1396
+ by the move and do not survive it.
1397
+
1398
+ **Removed in 2.11.0: the "reported success with no observed effect" warning.**
1399
+ Shipped in 2.10.30, it fired when the count was flat, the snapshot read cleanly
1400
+ and nothing had disappeared. Its premise was that the snapshot corroborated the
1401
+ count — but both are read back-to-back in the same script, and the record that
1402
+ prompted it turns out to have had *both* instruments stale at once. It therefore
1403
+ fired on stores that had done exactly what they were asked. It is gone rather
1404
+ than narrowed; `over` is the only surviving assertion.
1363
1405
 
1364
1406
  Three more honesty rules:
1365
1407
 
@@ -1399,6 +1441,7 @@ batch of only `imap:` ids returns no `countDelta` rather than a fabricated one.
1399
1441
  | `APPLE_MAIL_MCP_AUDIT_LOG` | *(off)* | Absolute path to an NDJSON file. Setting it enables the audit log **and** the collateral diff below |
1400
1442
  | `APPLE_MAIL_MCP_AUDIT_SUBJECTS` | `0` | Set `1` to also record message **subjects**. Separate, deliberate second opt-in — see Privacy |
1401
1443
  | `APPLE_MAIL_MCP_AUDIT_SNAPSHOT_MAX` | `2000` | Skip the collateral snapshot for mailboxes larger than this many messages. `0` disables the snapshot entirely |
1444
+ | `APPLE_MAIL_MCP_AUDIT_SNAPSHOT_CHUNK` | `250` | How many messages the collateral snapshot reads from Mail per request. Lower it if Mail declines slices on a very large mailbox |
1402
1445
 
1403
1446
  When set, each destructive operation appends **one JSON object per line**
1404
1447
  containing: timestamp, tool name, server version, the arguments it was called
@@ -1451,12 +1494,52 @@ exceed AppleScript's 2^29 integer range (where Mail hands them back as
1451
1494
  compared in the raw form, a message you explicitly asked to delete would be
1452
1495
  reported here as collateral.
1453
1496
 
1454
- This costs one bulk property read per snapshot and is O(mailbox size), so it is
1455
- bounded by `APPLE_MAIL_MCP_AUDIT_SNAPSHOT_MAX`. When the bound bites, the record
1456
- says so explicitly (`"snapshot": "skipped"` with a reason) rather than omitting
1457
- the field a silently skipped snapshot would read as "nothing collateral
1458
- happened", which is worse than no snapshot at all. `countDelta` is unaffected by
1459
- the skip and still reconciles the counts.
1497
+ This is O(mailbox size), so it is bounded by `APPLE_MAIL_MCP_AUDIT_SNAPSHOT_MAX`.
1498
+ When the bound bites, the record says so explicitly (`"snapshot": "skipped"` with
1499
+ a reason) rather than omitting the field — a silently skipped snapshot would read
1500
+ as "nothing collateral happened", which is worse than no snapshot at all.
1501
+ `countDelta` is unaffected by the skip and still reconciles the counts.
1502
+
1503
+ #### Partial snapshots on large mailboxes
1504
+
1505
+ The mailbox is read in `APPLE_MAIL_MCP_AUDIT_SNAPSHOT_CHUNK`-sized slices, each
1506
+ retried once on its own. It used to be a single whole-mailbox read, which meant
1507
+ Mail declining that one request lost the **entire** diff — and the bigger the
1508
+ mailbox, the more likely that was. The mechanism that attributes collateral
1509
+ damage was therefore least reliable exactly when the blast radius was largest.
1510
+
1511
+ When a slice still will not read, the snapshot is reported as `partial` and it
1512
+ **names its own gap**:
1513
+
1514
+ ```json
1515
+ {
1516
+ "snapshot": "partial",
1517
+ "unobserved": [{ "phase": "after", "ranges": "251-500" }],
1518
+ "appeared": [],
1519
+ "skipReason": "Mail would not read 251-500 (after) of this mailbox, so the snapshot has a hole in it. …"
1520
+ }
1521
+ ```
1522
+
1523
+ Each half of the diff is withheld when the snapshot that could **refute** it has
1524
+ a hole, because a wrong name here is worse than a missing one:
1525
+
1526
+ | Hole in | `disappeared` / `unrequested` | `appeared` |
1527
+ |---------|-------------------------------|------------|
1528
+ | neither (`"ok"`) | reported | reported |
1529
+ | `before` | reported (an undercount — a message never read before cannot be missed after) | withheld |
1530
+ | `after` | **withheld** — a message absent from a partial `after` may merely be unread, and naming it would present an innocent message as evidence of data loss | reported |
1531
+ | both | withheld | withheld |
1532
+
1533
+ **An absent field means "not computable", never "empty".** Check
1534
+ `"snapshot": "ok"` before reading `disappeared` as a clean bill of health.
1535
+
1536
+ > ⚠️ **`"ok"` is necessary, not sufficient.** The enumeration's range is bounded
1537
+ > by Mail's own message count, and #155 established that count can lag the
1538
+ > mutation. A count reading **low** truncates the enumeration silently — the
1539
+ > unread tail is never requested, so it never registers as a failed slice and the
1540
+ > status still says `"ok"` — and messages past that bound would then look like
1541
+ > they disappeared. Until that is fixed, treat a `disappeared` entry as a lead to
1542
+ > check, not a proof.
1460
1543
 
1461
1544
  ### Privacy, and what the file costs you
1462
1545
 
package/build/index.js CHANGED
@@ -78791,7 +78791,10 @@ import { appendFileSync } from "node:fs";
78791
78791
  var AUDIT_LOG_ENV = "APPLE_MAIL_MCP_AUDIT_LOG";
78792
78792
  var AUDIT_SUBJECTS_ENV = "APPLE_MAIL_MCP_AUDIT_SUBJECTS";
78793
78793
  var AUDIT_SNAPSHOT_MAX_ENV = "APPLE_MAIL_MCP_AUDIT_SNAPSHOT_MAX";
78794
+ var AUDIT_SNAPSHOT_CHUNK_ENV = "APPLE_MAIL_MCP_AUDIT_SNAPSHOT_CHUNK";
78794
78795
  var DEFAULT_SNAPSHOT_MAX = 2e3;
78796
+ var DEFAULT_SNAPSHOT_CHUNK = 250;
78797
+ var SNAPSHOT_SLICE_ATTEMPTS = 2;
78795
78798
  function isOn(raw) {
78796
78799
  return /^(1|true|yes|on)$/i.test((raw ?? "").trim());
78797
78800
  }
@@ -78812,6 +78815,13 @@ function auditSnapshotMax() {
78812
78815
  if (!Number.isFinite(n) || n < 0) return DEFAULT_SNAPSHOT_MAX;
78813
78816
  return Math.floor(n);
78814
78817
  }
78818
+ function auditSnapshotChunk() {
78819
+ const raw = process.env[AUDIT_SNAPSHOT_CHUNK_ENV]?.trim();
78820
+ if (raw === void 0 || raw === "") return DEFAULT_SNAPSHOT_CHUNK;
78821
+ const n = Number(raw);
78822
+ if (!Number.isFinite(n) || n < 1) return DEFAULT_SNAPSHOT_CHUNK;
78823
+ return Math.floor(n);
78824
+ }
78815
78825
  function writeAuditRecord(record2) {
78816
78826
  const path = auditLogPath();
78817
78827
  if (!path) return;
@@ -78844,17 +78854,8 @@ function countDeltaWarning(d) {
78844
78854
  const where = d.account ? `"${d.mailbox}" in account "${d.account}"` : `"${d.mailbox}"`;
78845
78855
  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.`;
78846
78856
  }
78847
- function falseOkWarning(d, collateral) {
78848
- if (d.status !== "under" || d.expected === null) return null;
78849
- if (d.observed !== 0 || d.expected <= 0) return null;
78850
- if (!collateral || collateral.snapshot !== "ok") return null;
78851
- if ((collateral.disappeared?.length ?? 0) !== 0) return null;
78852
- const where = d.account ? `"${d.mailbox}" in account "${d.account}"` : `"${d.mailbox}"`;
78853
- return `\u26A0\uFE0F Reported success with no observed effect in ${where}: ${d.expected} message(s) were operated on and reported ok, but the mailbox count did not move (${d.before} \u2192 ${d.after}) and the collateral snapshot \u2014 which read this mailbox successfully \u2014 shows no message left it. Either the operation silently did nothing, or Mail's count and listing are both stale and the messages did move. Check the destination (Trash for a delete) before assuming either: if they are there, this is a measurement-timing bug; if they are still in the source mailbox minutes later, the operation failed while reporting success. Please report at https://github.com/sweetrb/apple-mail-mcp/issues/155 with ${AUDIT_LOG_ENV}=/path/to/audit.ndjson set.`;
78854
- }
78855
78857
  function reconciliationWarnings(report) {
78856
- const collateralFor = (d) => report.collateral.find((c) => c.account === d.account && c.mailbox === d.mailbox);
78857
- return report.countDeltas.flatMap((d) => [countDeltaWarning(d), falseOkWarning(d, collateralFor(d))]).filter((w) => w !== null);
78858
+ return report.countDeltas.map((d) => countDeltaWarning(d)).filter((w) => w !== null);
78858
78859
  }
78859
78860
 
78860
78861
  // src/services/appleMailManager.ts
@@ -79132,6 +79133,8 @@ function canonicalNumericId(raw) {
79132
79133
  return Number.isFinite(n) ? String(n) : trimmed;
79133
79134
  }
79134
79135
  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.";
79136
+ var COUNT_UNMOVED_NOTE = `Mail's count did not move. This is the ordinary reading on a store that flags deletions instead of removing them (Gmail label mailboxes and IMAP accounts with "move deleted messages to Trash" off), where the message stays put and the operation still fully succeeded. It can also mean Mail's count simply had not caught up yet. The per-id outcomes are what report success; this number is not, so do not retry on the strength of it.`;
79137
+ var COUNT_PARTIAL_NOTE = `Mail's count moved by less than this operation accounted for. That is a LOWER BOUND on what left, not a count of what left: Mail has been observed reporting a stale count for a delete it had already performed (issue #155), and new mail arriving mid-operation reads the same way. No claim is made either way. To confirm where the messages went, match them at the destination by "date received" plus sender \u2014 NOT by the numeric ids you passed, which are renumbered by the move and do not survive it.`;
79135
79138
  function snapshotKey(entry) {
79136
79139
  return `${entry.id}\0${entry.messageId}`;
79137
79140
  }
@@ -79402,10 +79405,24 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
79402
79405
  * into a SNAP record — the before/after pair the collateral diff subtracts.
79403
79406
  *
79404
79407
  * Empty string when the audit log is off or the snapshot is disabled, so the
79405
- * whole layer costs literally nothing by default. The two property reads are
79406
- * BULK (`id of messages of mb`, `message id of messages of mb`) — two Apple
79407
- * Events for the whole mailbox rather than two per message — and the joining
79408
- * is pure in-memory AppleScript.
79408
+ * whole layer costs literally nothing by default. The property reads are BULK
79409
+ * (`id of messages i thru j of mb`) — two Apple Events per SLICE rather than
79410
+ * two per message — and the joining is pure in-memory AppleScript.
79411
+ *
79412
+ * ## Why it is sliced rather than one whole-mailbox read (#176)
79413
+ *
79414
+ * This used to be a single `id of messages of mb` pair. When Mail declined
79415
+ * that request the entire snapshot came back `unavailable`, and the cost of
79416
+ * the request grows with the mailbox — so the one mechanism that can attribute
79417
+ * an unrequested departure was least reliable exactly when the batch and the
79418
+ * mailbox, and therefore the blast radius, were largest. That correlation was
79419
+ * the defect, not any individual failure.
79420
+ *
79421
+ * Now the mailbox is read in `APPLE_MAIL_MCP_AUDIT_SNAPSHOT_CHUNK`-sized
79422
+ * slices; each slice is retried once on its own; and a slice that still will
79423
+ * not read costs only its own range. The unreadable ranges are emitted in
79424
+ * their own field, so the diff can report a PARTIAL snapshot that names its
79425
+ * own gap instead of an all-or-nothing `unavailable`.
79409
79426
  *
79410
79427
  * Above `APPLE_MAIL_MCP_AUDIT_SNAPSHOT_MAX` messages the snapshot is skipped,
79411
79428
  * and the skip is EMITTED as a record with its reason. A silently skipped
@@ -79422,46 +79439,77 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
79422
79439
  snapshotFragment(phase, acctExpr, mbExpr, countVar, mbVar = "_tmb") {
79423
79440
  const max = auditSnapshotMax();
79424
79441
  if (!isAuditEnabled() || max <= 0) return "";
79442
+ const chunk = auditSnapshotChunk();
79425
79443
  return `
79426
79444
  set _sStatus to "ok"
79427
79445
  set _sPayload to ""
79428
- set _sIds to {}
79429
- set _sMids to {}
79446
+ set _sMiss to ""
79447
+ set _sPairs to {}
79448
+ set _sChunk to ${chunk}
79430
79449
  if ${countVar} < 0 then
79431
79450
  set _sStatus to "unavailable"
79432
79451
  else if ${countVar} > ${max} then
79433
79452
  set _sStatus to "skipped"
79434
79453
  set _sPayload to "mailbox holds " & (${countVar} as string) & " messages, above ${AUDIT_SNAPSHOT_MAX_ENV}=${max}"
79435
79454
  else
79436
- try
79437
- set _sIds to (id of messages of ${mbVar})
79438
- set _sMids to (message id of messages of ${mbVar})
79439
- on error
79440
- set _sStatus to "unavailable"
79441
- end try
79442
- if _sStatus is "ok" then
79443
- if (count of _sIds) is not (count of _sMids) then
79455
+ set _sLo to 1
79456
+ repeat while _sLo <= ${countVar}
79457
+ set _sHi to _sLo + _sChunk - 1
79458
+ if _sHi > ${countVar} then set _sHi to ${countVar}
79459
+ set _sGot to false
79460
+ repeat with _sTry from 1 to ${SNAPSHOT_SLICE_ATTEMPTS}
79461
+ set _sIds to {}
79462
+ set _sMids to {}
79463
+ -- A slice is staged into _sBuf and merged only once it has been
79464
+ -- read IN FULL. Appending as we go would leave a slice that threw
79465
+ -- halfway both partially recorded AND marked unread, and the
79466
+ -- retry would then record its messages a second time.
79467
+ set _sBuf to {}
79468
+ try
79469
+ set _sIds to (id of messages _sLo thru _sHi of ${mbVar})
79470
+ set _sMids to (message id of messages _sLo thru _sHi of ${mbVar})
79471
+ if (class of _sIds) is not list then set _sIds to {_sIds}
79472
+ if (class of _sMids) is not list then set _sMids to {_sMids}
79473
+ if (count of _sIds) is (count of _sMids) then
79474
+ repeat with _q from 1 to (count of _sIds)
79475
+ set _sOne to ""
79476
+ try
79477
+ set _zSnapMid to ((item _q of _sMids) as string)${this.sanitizeFragment("_zSnapMid", " ")}
79478
+ set _sOne to ((item _q of _sIds) as string) & "${SNAP_PAIR}" & _zSnapMid
79479
+ on error
79480
+ set _sOne to ((item _q of _sIds) as string) & "${SNAP_PAIR}"
79481
+ end try
79482
+ set end of _sBuf to _sOne
79483
+ end repeat
79484
+ set _sGot to true
79485
+ end if
79486
+ end try
79487
+ if _sGot then
79488
+ repeat with _sB in _sBuf
79489
+ set end of _sPairs to (contents of _sB)
79490
+ end repeat
79491
+ exit repeat
79492
+ end if
79493
+ end repeat
79494
+ if not _sGot then
79495
+ if _sMiss is not "" then set _sMiss to _sMiss & ","
79496
+ set _sMiss to _sMiss & (_sLo as string) & "-" & (_sHi as string)
79497
+ end if
79498
+ set _sLo to _sHi + 1
79499
+ end repeat
79500
+ if _sMiss is not "" then
79501
+ if (count of _sPairs) is 0 then
79444
79502
  set _sStatus to "unavailable"
79445
79503
  else
79446
- set _sPairs to {}
79447
- repeat with _q from 1 to (count of _sIds)
79448
- set _sOne to ""
79449
- try
79450
- set _zSnapMid to ((item _q of _sMids) as string)${this.sanitizeFragment("_zSnapMid", " ")}
79451
- set _sOne to ((item _q of _sIds) as string) & "${SNAP_PAIR}" & _zSnapMid
79452
- on error
79453
- set _sOne to ((item _q of _sIds) as string) & "${SNAP_PAIR}"
79454
- end try
79455
- set end of _sPairs to _sOne
79456
- end repeat
79457
- set _sTid to AppleScript's text item delimiters
79458
- set AppleScript's text item delimiters to "${SNAP_ITEM}"
79459
- set _sPayload to _sPairs as string
79460
- set AppleScript's text item delimiters to _sTid
79504
+ set _sStatus to "partial"
79461
79505
  end if
79462
79506
  end if
79507
+ set _sTid to AppleScript's text item delimiters
79508
+ set AppleScript's text item delimiters to "${SNAP_ITEM}"
79509
+ set _sPayload to _sPairs as string
79510
+ set AppleScript's text item delimiters to _sTid
79463
79511
  end if
79464
- set _out to _out & "${SNAP_TAG}${FIELD_SEP}" & ${acctExpr} & "${FIELD_SEP}" & ${mbExpr} & "${FIELD_SEP}${phase}${FIELD_SEP}" & _sStatus & "${FIELD_SEP}" & _sPayload & "${RECORD_SEP}"`;
79512
+ 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}"`;
79465
79513
  }
79466
79514
  /**
79467
79515
  * AppleScript capturing the message the op is ABOUT to touch into `_pre`,
@@ -79527,7 +79575,8 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
79527
79575
  mailbox: f[2] ?? "",
79528
79576
  phase: f[3] === "after" ? "after" : "before",
79529
79577
  status: f[4] ?? "",
79530
- payload: f[5] ?? ""
79578
+ payload: f[5] ?? "",
79579
+ miss: f[6] ?? ""
79531
79580
  });
79532
79581
  continue;
79533
79582
  }
@@ -79623,10 +79672,24 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
79623
79672
  const observed = readable ? m.before - m.after : null;
79624
79673
  const note = noteFor(m.account, m.mailbox);
79625
79674
  let status;
79626
- if (!readable || m.expected === null) status = "unknown";
79627
- else if (observed === m.expected) status = "match";
79628
- else if ((observed ?? 0) > m.expected) status = "over";
79629
- else status = "under";
79675
+ let unknownReason;
79676
+ if (!readable) {
79677
+ status = "unknown";
79678
+ unknownReason = "count-unreadable";
79679
+ } else if (m.expected === null) {
79680
+ status = "unknown";
79681
+ unknownReason = "no-expectation";
79682
+ } else if (observed === m.expected) {
79683
+ status = "match";
79684
+ } else if ((observed ?? 0) > m.expected) {
79685
+ status = "over";
79686
+ } else if (observed === 0) {
79687
+ status = "unknown";
79688
+ unknownReason = "count-did-not-move";
79689
+ } else {
79690
+ status = "unknown";
79691
+ unknownReason = "count-partial";
79692
+ }
79630
79693
  return {
79631
79694
  account: m.account,
79632
79695
  mailbox: m.mailbox,
@@ -79635,11 +79698,11 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
79635
79698
  expected: m.expected,
79636
79699
  observed,
79637
79700
  status,
79701
+ ...unknownReason ? { unknownReason } : {},
79638
79702
  ...note ? { note } : {},
79639
- ...status === "unknown" && readable === false ? { note: note ?? "Mail did not report a message count for this mailbox" } : {},
79640
- ...status === "under" && !note ? {
79641
- 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."
79642
- } : {}
79703
+ ...unknownReason === "count-unreadable" ? { note: note ?? "Mail did not report a message count for this mailbox" } : {},
79704
+ ...unknownReason === "count-did-not-move" && !note ? { note: COUNT_UNMOVED_NOTE } : {},
79705
+ ...unknownReason === "count-partial" && !note ? { note: COUNT_PARTIAL_NOTE } : {}
79643
79706
  };
79644
79707
  });
79645
79708
  const preImages = [];
@@ -79677,8 +79740,9 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
79677
79740
  });
79678
79741
  continue;
79679
79742
  }
79680
- if (b.status !== "ok" || a.status !== "ok") {
79681
- const bad = b.status !== "ok" ? b : a;
79743
+ const dead = (s) => s.status !== "ok" && s.status !== "partial";
79744
+ if (dead(b) || dead(a)) {
79745
+ const bad = dead(b) ? b : a;
79682
79746
  collateral.push({
79683
79747
  account: g.account,
79684
79748
  mailbox: g.mailbox,
@@ -79696,13 +79760,30 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
79696
79760
  const unrequested = disappeared.filter(
79697
79761
  (e) => !requestedNumericIds.has(canonicalNumericId(e.id))
79698
79762
  );
79763
+ const holes = [b, a].filter((s) => s.miss !== "").map((s) => ({ phase: s.phase, ranges: s.miss }));
79764
+ if (holes.length === 0) {
79765
+ collateral.push({
79766
+ account: g.account,
79767
+ mailbox: g.mailbox,
79768
+ snapshot: "ok",
79769
+ disappeared,
79770
+ unrequested,
79771
+ appeared
79772
+ });
79773
+ continue;
79774
+ }
79775
+ const derivable = [
79776
+ a.miss === "" ? `what left the ${beforeEntries.length} message(s) read before it` : null,
79777
+ b.miss === "" ? "what arrived during it" : null
79778
+ ].filter((s) => s !== null);
79699
79779
  collateral.push({
79700
79780
  account: g.account,
79701
79781
  mailbox: g.mailbox,
79702
- snapshot: "ok",
79703
- disappeared,
79704
- unrequested,
79705
- appeared
79782
+ snapshot: "partial",
79783
+ 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".`,
79784
+ unobserved: holes,
79785
+ ...a.miss === "" ? { disappeared, unrequested } : {},
79786
+ ...b.miss === "" ? { appeared } : {}
79706
79787
  });
79707
79788
  }
79708
79789
  return { countDeltas, preImages, outcomes: parsed.outcomes, collateral };
@@ -85229,7 +85310,10 @@ var COUNT_DELTA_OUTPUT_SCHEMA = external_exports.array(
85229
85310
  // `status: "unknown"`, and never with a warning.
85230
85311
  expected: external_exports.number().nullable().optional(),
85231
85312
  observed: external_exports.number().nullable().optional(),
85232
- status: external_exports.enum(["match", "over", "under", "unknown"]).optional(),
85313
+ status: external_exports.enum(["match", "over", "unknown"]).optional(),
85314
+ // Declared explicitly: the SDK stamps additionalProperties:false on a bare
85315
+ // zod shape, so an undeclared key makes the CLIENT reject the result.
85316
+ unknownReason: external_exports.enum(["count-unreadable", "no-expectation", "count-did-not-move", "count-partial"]).optional(),
85233
85317
  note: external_exports.string().optional()
85234
85318
  })
85235
85319
  ).optional();
@@ -86387,7 +86471,7 @@ registerTool(
86387
86471
  registerTool(
86388
86472
  "fetch-attachment",
86389
86473
  {
86390
- description: "Use when: retrieving an attachment's raw bytes inline as base64 (by message id and attachmentName), e.g. to process its contents without touching disk.\nReturns: the attachment's bytes base64-encoded, with its size and (for IMAP) MIME type.\nDo not use when: you don't know the attachment name (use list-attachments first) or you just want it saved to disk (use save-attachment).",
86474
+ description: "Use when: retrieving an attachment's raw bytes inline as base64 (by message id and attachmentName), e.g. to process its contents without keeping a file.\nReturns: the attachment's bytes base64-encoded, with its size and (for IMAP) MIME type.\nDo not use when: you don't know the attachment name (use list-attachments first) or you just want it saved to disk (use save-attachment).\nSafety: leaves no file behind, but the AppleScript path is not disk-free \u2014 Mail writes the attachment into a private temp directory, which is read back and then deleted. Needs no Full Disk Access either way: Mail performs that write under the Automation grant, and this server never reads the mail store itself.",
86391
86475
  inputSchema: {
86392
86476
  id: MESSAGE_ID_SCHEMA,
86393
86477
  attachmentName: external_exports.string().min(1, "Attachment name is required")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-mail-mcp",
3
- "version": "2.10.32",
3
+ "version": "2.11.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",