apple-mail-mcp 2.10.33 → 2.11.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 +61 -12
  2. package/build/index.js +58 -27
  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
 
@@ -1489,8 +1531,15 @@ a hole, because a wrong name here is worse than a missing one:
1489
1531
  | both | withheld | withheld |
1490
1532
 
1491
1533
  **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.
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.
1494
1543
 
1495
1544
  ### Privacy, and what the file costs you
1496
1545
 
package/build/index.js CHANGED
@@ -78071,11 +78071,17 @@ function sleep(ms) {
78071
78071
  }
78072
78072
  }
78073
78073
  }
78074
+ var PERMISSION_DENIED_PATTERN = /not authorized|not permitted|access.*denied/i;
78075
+ var PERMISSION_DENIED_MESSAGE = "Permission denied. Grant automation access in System Settings > Privacy & Security > Automation.";
78076
+ function isPermissionDenied(error2) {
78077
+ if (!error2) return false;
78078
+ return PERMISSION_DENIED_PATTERN.test(error2) || error2.includes(PERMISSION_DENIED_MESSAGE);
78079
+ }
78074
78080
  var ERROR_MAPPINGS = [
78075
78081
  // Permission errors
78076
78082
  {
78077
- pattern: /not authorized|not permitted|access.*denied/i,
78078
- message: "Permission denied. Grant automation access in System Settings > Privacy & Security > Automation."
78083
+ pattern: PERMISSION_DENIED_PATTERN,
78084
+ message: PERMISSION_DENIED_MESSAGE
78079
78085
  },
78080
78086
  // Application not running
78081
78087
  {
@@ -78854,17 +78860,8 @@ function countDeltaWarning(d) {
78854
78860
  const where = d.account ? `"${d.mailbox}" in account "${d.account}"` : `"${d.mailbox}"`;
78855
78861
  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.`;
78856
78862
  }
78857
- function falseOkWarning(d, collateral) {
78858
- if (d.status !== "under" || d.expected === null) return null;
78859
- if (d.observed !== 0 || d.expected <= 0) return null;
78860
- if (!collateral || collateral.snapshot !== "ok") return null;
78861
- if ((collateral.disappeared?.length ?? 0) !== 0) return null;
78862
- const where = d.account ? `"${d.mailbox}" in account "${d.account}"` : `"${d.mailbox}"`;
78863
- 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.`;
78864
- }
78865
78863
  function reconciliationWarnings(report) {
78866
- const collateralFor = (d) => report.collateral.find((c) => c.account === d.account && c.mailbox === d.mailbox);
78867
- return report.countDeltas.flatMap((d) => [countDeltaWarning(d), falseOkWarning(d, collateralFor(d))]).filter((w) => w !== null);
78864
+ return report.countDeltas.map((d) => countDeltaWarning(d)).filter((w) => w !== null);
78868
78865
  }
78869
78866
 
78870
78867
  // src/services/appleMailManager.ts
@@ -79142,6 +79139,8 @@ function canonicalNumericId(raw) {
79142
79139
  return Number.isFinite(n) ? String(n) : trimmed;
79143
79140
  }
79144
79141
  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.";
79142
+ 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.`;
79143
+ 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.`;
79145
79144
  function snapshotKey(entry) {
79146
79145
  return `${entry.id}\0${entry.messageId}`;
79147
79146
  }
@@ -79679,10 +79678,24 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
79679
79678
  const observed = readable ? m.before - m.after : null;
79680
79679
  const note = noteFor(m.account, m.mailbox);
79681
79680
  let status;
79682
- if (!readable || m.expected === null) status = "unknown";
79683
- else if (observed === m.expected) status = "match";
79684
- else if ((observed ?? 0) > m.expected) status = "over";
79685
- else status = "under";
79681
+ let unknownReason;
79682
+ if (!readable) {
79683
+ status = "unknown";
79684
+ unknownReason = "count-unreadable";
79685
+ } else if (m.expected === null) {
79686
+ status = "unknown";
79687
+ unknownReason = "no-expectation";
79688
+ } else if (observed === m.expected) {
79689
+ status = "match";
79690
+ } else if ((observed ?? 0) > m.expected) {
79691
+ status = "over";
79692
+ } else if (observed === 0) {
79693
+ status = "unknown";
79694
+ unknownReason = "count-did-not-move";
79695
+ } else {
79696
+ status = "unknown";
79697
+ unknownReason = "count-partial";
79698
+ }
79686
79699
  return {
79687
79700
  account: m.account,
79688
79701
  mailbox: m.mailbox,
@@ -79691,11 +79704,11 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
79691
79704
  expected: m.expected,
79692
79705
  observed,
79693
79706
  status,
79707
+ ...unknownReason ? { unknownReason } : {},
79694
79708
  ...note ? { note } : {},
79695
- ...status === "unknown" && readable === false ? { note: note ?? "Mail did not report a message count for this mailbox" } : {},
79696
- ...status === "under" && !note ? {
79697
- 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."
79698
- } : {}
79709
+ ...unknownReason === "count-unreadable" ? { note: note ?? "Mail did not report a message count for this mailbox" } : {},
79710
+ ...unknownReason === "count-did-not-move" && !note ? { note: COUNT_UNMOVED_NOTE } : {},
79711
+ ...unknownReason === "count-partial" && !note ? { note: COUNT_PARTIAL_NOTE } : {}
79699
79712
  };
79700
79713
  });
79701
79714
  const preImages = [];
@@ -82834,7 +82847,7 @@ ${actionStmts.join("\n")}
82834
82847
  message: "Mail.app is accessible"
82835
82848
  });
82836
82849
  } else {
82837
- const errorHint = mailCheck.error?.includes("not authorized") ? " (check System Settings > Privacy & Security > Automation)" : "";
82850
+ const errorHint = isPermissionDenied(mailCheck.error) ? " (check System Settings > Privacy & Security > Automation)" : "";
82838
82851
  checks.push({
82839
82852
  name: "mail_app",
82840
82853
  passed: false,
@@ -82850,7 +82863,7 @@ ${actionStmts.join("\n")}
82850
82863
  message: "AppleScript automation permissions granted"
82851
82864
  });
82852
82865
  } else {
82853
- const isPermError = permCheck.error?.includes("not authorized") || permCheck.error?.includes("not permitted");
82866
+ const isPermError = isPermissionDenied(permCheck.error);
82854
82867
  checks.push({
82855
82868
  name: "permissions",
82856
82869
  passed: !isPermError,
@@ -83674,7 +83687,12 @@ function structuredRow(m, account, path) {
83674
83687
  flagColorIndex: mailFlagColorIndex(m.flags),
83675
83688
  mailbox: path,
83676
83689
  account,
83677
- hasAttachments: false,
83690
+ // Derived from BODYSTRUCTURE, which the list/search fetch now requests.
83691
+ // This was hardcoded `false` from 2.2.0 until 2.11.1 — indistinguishable to
83692
+ // a caller from "no attachments", so every IMAP-sourced message claimed to
83693
+ // have none. Falls back to false only when the fetch carried no
83694
+ // BODYSTRUCTURE at all.
83695
+ hasAttachments: bodyStructureHasAttachments(m.bodyStructure),
83678
83696
  // Message-ID (when the envelope carries it) is the strongest cross-/intra-
83679
83697
  // backend dedup key for the multi-account merge (imapMultiAccount.ts). The
83680
83698
  // AppleScript path does not expose it, so cross-backend dedup falls back to
@@ -83705,7 +83723,10 @@ async function run(args, listMode, deps) {
83705
83723
  const byUid = /* @__PURE__ */ new Map();
83706
83724
  for await (const msg of client.fetch(
83707
83725
  newest.join(","),
83708
- { envelope: true, flags: true },
83726
+ // BODYSTRUCTURE rides along so `hasAttachments` is computed rather
83727
+ // than assumed. Measured on 50 real messages: ~390ms -> ~465ms for
83728
+ // the fetch (~17%), same single round trip, no extra request.
83729
+ { envelope: true, flags: true, bodyStructure: true },
83709
83730
  { uid: true }
83710
83731
  )) {
83711
83732
  byUid.set(msg.uid, msg);
@@ -84199,7 +84220,8 @@ function collectAttachments(node, out = []) {
84199
84220
  if (!node) return out;
84200
84221
  const filename = node.dispositionParameters?.filename || node.parameters?.name;
84201
84222
  const disposition = node.disposition?.toLowerCase();
84202
- const isAttachment = !!node.part && (disposition === "attachment" || !!filename && disposition !== "inline");
84223
+ const isEmbeddedByReference = disposition === "inline" && !!node.id;
84224
+ const isAttachment = !!node.part && (disposition === "attachment" || !!filename && !isEmbeddedByReference);
84203
84225
  if (isAttachment) {
84204
84226
  out.push({
84205
84227
  part: node.part,
@@ -84211,6 +84233,9 @@ function collectAttachments(node, out = []) {
84211
84233
  for (const child of node.childNodes ?? []) collectAttachments(child, out);
84212
84234
  return out;
84213
84235
  }
84236
+ function bodyStructureHasAttachments(node) {
84237
+ return !!node && collectAttachments(node).length > 0;
84238
+ }
84214
84239
  async function streamToBuffer(content, maxBytes) {
84215
84240
  const chunks = [];
84216
84241
  let total = 0;
@@ -84382,7 +84407,10 @@ async function imapThread(id, deps = {}, limit = 50) {
84382
84407
  const msgs = [];
84383
84408
  for await (const msg of client.fetch(
84384
84409
  uids.join(","),
84385
- { envelope: true, flags: true },
84410
+ // Same reason as the list/search fetch: get-thread emits structured
84411
+ // rows too, so it needs BODYSTRUCTURE or its hasAttachments would
84412
+ // silently disagree with the same message seen via search.
84413
+ { envelope: true, flags: true, bodyStructure: true },
84386
84414
  { uid: true }
84387
84415
  )) {
84388
84416
  msgs.push(msg);
@@ -85303,7 +85331,10 @@ var COUNT_DELTA_OUTPUT_SCHEMA = external_exports.array(
85303
85331
  // `status: "unknown"`, and never with a warning.
85304
85332
  expected: external_exports.number().nullable().optional(),
85305
85333
  observed: external_exports.number().nullable().optional(),
85306
- status: external_exports.enum(["match", "over", "under", "unknown"]).optional(),
85334
+ status: external_exports.enum(["match", "over", "unknown"]).optional(),
85335
+ // Declared explicitly: the SDK stamps additionalProperties:false on a bare
85336
+ // zod shape, so an undeclared key makes the CLIENT reject the result.
85337
+ unknownReason: external_exports.enum(["count-unreadable", "no-expectation", "count-did-not-move", "count-partial"]).optional(),
85307
85338
  note: external_exports.string().optional()
85308
85339
  })
85309
85340
  ).optional();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-mail-mcp",
3
- "version": "2.10.33",
3
+ "version": "2.11.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",