apple-mail-mcp 2.10.32 → 2.10.33

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 +40 -6
  2. package/build/index.js +112 -38
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1399,6 +1399,7 @@ batch of only `imap:` ids returns no `countDelta` rather than a fabricated one.
1399
1399
  | `APPLE_MAIL_MCP_AUDIT_LOG` | *(off)* | Absolute path to an NDJSON file. Setting it enables the audit log **and** the collateral diff below |
1400
1400
  | `APPLE_MAIL_MCP_AUDIT_SUBJECTS` | `0` | Set `1` to also record message **subjects**. Separate, deliberate second opt-in — see Privacy |
1401
1401
  | `APPLE_MAIL_MCP_AUDIT_SNAPSHOT_MAX` | `2000` | Skip the collateral snapshot for mailboxes larger than this many messages. `0` disables the snapshot entirely |
1402
+ | `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
1403
 
1403
1404
  When set, each destructive operation appends **one JSON object per line**
1404
1405
  containing: timestamp, tool name, server version, the arguments it was called
@@ -1451,12 +1452,45 @@ exceed AppleScript's 2^29 integer range (where Mail hands them back as
1451
1452
  compared in the raw form, a message you explicitly asked to delete would be
1452
1453
  reported here as collateral.
1453
1454
 
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.
1455
+ This is O(mailbox size), so it is bounded by `APPLE_MAIL_MCP_AUDIT_SNAPSHOT_MAX`.
1456
+ When the bound bites, the record says so explicitly (`"snapshot": "skipped"` with
1457
+ a reason) rather than omitting the field — a silently skipped snapshot would read
1458
+ as "nothing collateral happened", which is worse than no snapshot at all.
1459
+ `countDelta` is unaffected by the skip and still reconciles the counts.
1460
+
1461
+ #### Partial snapshots on large mailboxes
1462
+
1463
+ The mailbox is read in `APPLE_MAIL_MCP_AUDIT_SNAPSHOT_CHUNK`-sized slices, each
1464
+ retried once on its own. It used to be a single whole-mailbox read, which meant
1465
+ Mail declining that one request lost the **entire** diff — and the bigger the
1466
+ mailbox, the more likely that was. The mechanism that attributes collateral
1467
+ damage was therefore least reliable exactly when the blast radius was largest.
1468
+
1469
+ When a slice still will not read, the snapshot is reported as `partial` and it
1470
+ **names its own gap**:
1471
+
1472
+ ```json
1473
+ {
1474
+ "snapshot": "partial",
1475
+ "unobserved": [{ "phase": "after", "ranges": "251-500" }],
1476
+ "appeared": [],
1477
+ "skipReason": "Mail would not read 251-500 (after) of this mailbox, so the snapshot has a hole in it. …"
1478
+ }
1479
+ ```
1480
+
1481
+ Each half of the diff is withheld when the snapshot that could **refute** it has
1482
+ a hole, because a wrong name here is worse than a missing one:
1483
+
1484
+ | Hole in | `disappeared` / `unrequested` | `appeared` |
1485
+ |---------|-------------------------------|------------|
1486
+ | neither (`"ok"`) | reported | reported |
1487
+ | `before` | reported (an undercount — a message never read before cannot be missed after) | withheld |
1488
+ | `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 |
1489
+ | both | withheld | withheld |
1490
+
1491
+ **An absent field means "not computable", never "empty".** Check
1492
+ `"snapshot": "ok"` before reading `disappeared` as a clean bill of health — the
1493
+ false-ok warning does exactly that, so a `partial` snapshot never triggers it.
1460
1494
 
1461
1495
  ### Privacy, and what the file costs you
1462
1496
 
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;
@@ -79402,10 +79412,24 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
79402
79412
  * into a SNAP record — the before/after pair the collateral diff subtracts.
79403
79413
  *
79404
79414
  * 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.
79415
+ * whole layer costs literally nothing by default. The property reads are BULK
79416
+ * (`id of messages i thru j of mb`) — two Apple Events per SLICE rather than
79417
+ * two per message — and the joining is pure in-memory AppleScript.
79418
+ *
79419
+ * ## Why it is sliced rather than one whole-mailbox read (#176)
79420
+ *
79421
+ * This used to be a single `id of messages of mb` pair. When Mail declined
79422
+ * that request the entire snapshot came back `unavailable`, and the cost of
79423
+ * the request grows with the mailbox — so the one mechanism that can attribute
79424
+ * an unrequested departure was least reliable exactly when the batch and the
79425
+ * mailbox, and therefore the blast radius, were largest. That correlation was
79426
+ * the defect, not any individual failure.
79427
+ *
79428
+ * Now the mailbox is read in `APPLE_MAIL_MCP_AUDIT_SNAPSHOT_CHUNK`-sized
79429
+ * slices; each slice is retried once on its own; and a slice that still will
79430
+ * not read costs only its own range. The unreadable ranges are emitted in
79431
+ * their own field, so the diff can report a PARTIAL snapshot that names its
79432
+ * own gap instead of an all-or-nothing `unavailable`.
79409
79433
  *
79410
79434
  * Above `APPLE_MAIL_MCP_AUDIT_SNAPSHOT_MAX` messages the snapshot is skipped,
79411
79435
  * and the skip is EMITTED as a record with its reason. A silently skipped
@@ -79422,46 +79446,77 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
79422
79446
  snapshotFragment(phase, acctExpr, mbExpr, countVar, mbVar = "_tmb") {
79423
79447
  const max = auditSnapshotMax();
79424
79448
  if (!isAuditEnabled() || max <= 0) return "";
79449
+ const chunk = auditSnapshotChunk();
79425
79450
  return `
79426
79451
  set _sStatus to "ok"
79427
79452
  set _sPayload to ""
79428
- set _sIds to {}
79429
- set _sMids to {}
79453
+ set _sMiss to ""
79454
+ set _sPairs to {}
79455
+ set _sChunk to ${chunk}
79430
79456
  if ${countVar} < 0 then
79431
79457
  set _sStatus to "unavailable"
79432
79458
  else if ${countVar} > ${max} then
79433
79459
  set _sStatus to "skipped"
79434
79460
  set _sPayload to "mailbox holds " & (${countVar} as string) & " messages, above ${AUDIT_SNAPSHOT_MAX_ENV}=${max}"
79435
79461
  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
79462
+ set _sLo to 1
79463
+ repeat while _sLo <= ${countVar}
79464
+ set _sHi to _sLo + _sChunk - 1
79465
+ if _sHi > ${countVar} then set _sHi to ${countVar}
79466
+ set _sGot to false
79467
+ repeat with _sTry from 1 to ${SNAPSHOT_SLICE_ATTEMPTS}
79468
+ set _sIds to {}
79469
+ set _sMids to {}
79470
+ -- A slice is staged into _sBuf and merged only once it has been
79471
+ -- read IN FULL. Appending as we go would leave a slice that threw
79472
+ -- halfway both partially recorded AND marked unread, and the
79473
+ -- retry would then record its messages a second time.
79474
+ set _sBuf to {}
79475
+ try
79476
+ set _sIds to (id of messages _sLo thru _sHi of ${mbVar})
79477
+ set _sMids to (message id of messages _sLo thru _sHi of ${mbVar})
79478
+ if (class of _sIds) is not list then set _sIds to {_sIds}
79479
+ if (class of _sMids) is not list then set _sMids to {_sMids}
79480
+ if (count of _sIds) is (count of _sMids) then
79481
+ repeat with _q from 1 to (count of _sIds)
79482
+ set _sOne to ""
79483
+ try
79484
+ set _zSnapMid to ((item _q of _sMids) as string)${this.sanitizeFragment("_zSnapMid", " ")}
79485
+ set _sOne to ((item _q of _sIds) as string) & "${SNAP_PAIR}" & _zSnapMid
79486
+ on error
79487
+ set _sOne to ((item _q of _sIds) as string) & "${SNAP_PAIR}"
79488
+ end try
79489
+ set end of _sBuf to _sOne
79490
+ end repeat
79491
+ set _sGot to true
79492
+ end if
79493
+ end try
79494
+ if _sGot then
79495
+ repeat with _sB in _sBuf
79496
+ set end of _sPairs to (contents of _sB)
79497
+ end repeat
79498
+ exit repeat
79499
+ end if
79500
+ end repeat
79501
+ if not _sGot then
79502
+ if _sMiss is not "" then set _sMiss to _sMiss & ","
79503
+ set _sMiss to _sMiss & (_sLo as string) & "-" & (_sHi as string)
79504
+ end if
79505
+ set _sLo to _sHi + 1
79506
+ end repeat
79507
+ if _sMiss is not "" then
79508
+ if (count of _sPairs) is 0 then
79444
79509
  set _sStatus to "unavailable"
79445
79510
  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
79511
+ set _sStatus to "partial"
79461
79512
  end if
79462
79513
  end if
79514
+ set _sTid to AppleScript's text item delimiters
79515
+ set AppleScript's text item delimiters to "${SNAP_ITEM}"
79516
+ set _sPayload to _sPairs as string
79517
+ set AppleScript's text item delimiters to _sTid
79463
79518
  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}"`;
79519
+ 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
79520
  }
79466
79521
  /**
79467
79522
  * AppleScript capturing the message the op is ABOUT to touch into `_pre`,
@@ -79527,7 +79582,8 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
79527
79582
  mailbox: f[2] ?? "",
79528
79583
  phase: f[3] === "after" ? "after" : "before",
79529
79584
  status: f[4] ?? "",
79530
- payload: f[5] ?? ""
79585
+ payload: f[5] ?? "",
79586
+ miss: f[6] ?? ""
79531
79587
  });
79532
79588
  continue;
79533
79589
  }
@@ -79677,8 +79733,9 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
79677
79733
  });
79678
79734
  continue;
79679
79735
  }
79680
- if (b.status !== "ok" || a.status !== "ok") {
79681
- const bad = b.status !== "ok" ? b : a;
79736
+ const dead = (s) => s.status !== "ok" && s.status !== "partial";
79737
+ if (dead(b) || dead(a)) {
79738
+ const bad = dead(b) ? b : a;
79682
79739
  collateral.push({
79683
79740
  account: g.account,
79684
79741
  mailbox: g.mailbox,
@@ -79696,13 +79753,30 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
79696
79753
  const unrequested = disappeared.filter(
79697
79754
  (e) => !requestedNumericIds.has(canonicalNumericId(e.id))
79698
79755
  );
79756
+ const holes = [b, a].filter((s) => s.miss !== "").map((s) => ({ phase: s.phase, ranges: s.miss }));
79757
+ if (holes.length === 0) {
79758
+ collateral.push({
79759
+ account: g.account,
79760
+ mailbox: g.mailbox,
79761
+ snapshot: "ok",
79762
+ disappeared,
79763
+ unrequested,
79764
+ appeared
79765
+ });
79766
+ continue;
79767
+ }
79768
+ const derivable = [
79769
+ a.miss === "" ? `what left the ${beforeEntries.length} message(s) read before it` : null,
79770
+ b.miss === "" ? "what arrived during it" : null
79771
+ ].filter((s) => s !== null);
79699
79772
  collateral.push({
79700
79773
  account: g.account,
79701
79774
  mailbox: g.mailbox,
79702
- snapshot: "ok",
79703
- disappeared,
79704
- unrequested,
79705
- appeared
79775
+ snapshot: "partial",
79776
+ 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".`,
79777
+ unobserved: holes,
79778
+ ...a.miss === "" ? { disappeared, unrequested } : {},
79779
+ ...b.miss === "" ? { appeared } : {}
79706
79780
  });
79707
79781
  }
79708
79782
  return { countDeltas, preImages, outcomes: parsed.outcomes, collateral };
@@ -86387,7 +86461,7 @@ registerTool(
86387
86461
  registerTool(
86388
86462
  "fetch-attachment",
86389
86463
  {
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).",
86464
+ 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
86465
  inputSchema: {
86392
86466
  id: MESSAGE_ID_SCHEMA,
86393
86467
  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.10.33",
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",