Releases: PsychQuant/che-apple-mail-mcp
Release list
v2.24.0
Fixed
- AppleScript execution is now bounded by a timeout + a fail-fast Automation pre-flight — a stuck call can no longer take the whole MCP server down (#297). Every AppleScript-backed tool funnels through
MailController.runScript/runScriptAsList, which called the synchronous, un-timeout'dNSAppleScript.executeAndReturnError. When this binary's Automation grant is.notDetermined(an authorization prompt is pending but cannot be answered in the headless MCP context) — or when Mail is simply unresponsive — that call does not return-1743, it blocks indefinitely: the request thread wedges, and the MCP client's ~120s idle timeout then drops the entire server connection, taking all 53 tools offline rather than failing the one call. Observed repeatedly in a live session:search_emailsstayed fast and reliable throughout (it reads the Mail Envelope Index viaMailSQLiteand never issues an Apple event), whileget_special_mailboxes/get_email/reply_emaileach hung ~120s and then disconnected the server — the "SQLite stable / AppleScript hangs" boundary that pinned the root cause. This is a different failure mode from its two predecessors, and neither of their remedies reaches it: #287 added the zero-TCCopen_mailtocompose path and #288 rendered actionable guidance for a returned-1743, but both address the denial-that-returns; the never-returns mode has no mailto equivalent on the read/archive side (get_email,batch_export_emails_markdown,save_attachment). Fix, applied at the single shared choke point so all AppleScript tools are covered at once with no per-tool changes: (a) a new privaterunGuarded<T>runs the execution on a detached thread and waits on aDispatchSemaphorewith a bounded deadline — 45s by default, comfortably under the ~120s client idle timeout and far above normal ~1–2s Mail IPC, overridable through a newscriptTimeouttest seam — throwing the newMailError.scriptTimedOut(seconds:)on expiry instead of hanging; becauseNSAppleScriptis a blocking, uncancellable C API the timed-out thread is deliberately abandoned (it resolves once TCC is answered or the process restarts), which bounds the block to the deadline rather than pretending the call was cancelled; (b)preflightAutomation()reuses the existing non-promptingAutomationStatus.probe()(#293) to fail fast before blocking —.deniedthrows reusingAutomationHelp.guidanceverbatim (#288 remains the single source of that text) and.targetNotRunningtells the caller to open Mail, while.notDetermined/.granted/ unexpected states proceed under the guard (never false-fast a legitimate first-run prompt — that is precisely the state the timeout exists to bound). The new error's message names the pending-prompt / unresponsive-Mail cases explicitly so it is not mistaken for the #288 recorded-Deny wall. Scoped deliberately:SetupWindow's interactive setup script is not an MCP tool path and stays unguarded. NewMailControllerTimeoutTestsdrive the guard through thescriptRunnerOverrideseam — a runner that sleeps 5s returns.scriptTimedOutin 0.505s against a 0.5s deadline (bounded, not the runner's 5s and not a wedge), a fast runner returns normally, and a throwing runner's own error propagates rather than being masked as a timeout. 1029 tests pass.
v2.23.1
Fixed
- From-popup menu enumeration now polls for a populated menu (#296, the menu-layer sibling of #295, found by its live re-smoke). Under in-process
NSAppleScriptthe popup's menu can evaluate EMPTY right afterclick(the identical matcher passes underosascript— same class as the documented in-processfront windowunreliability), so the for-in enumerated zero items and mis-reportedSENDERPOPUP: no From account exactly matches. The script now polls (bounded 12×0.3s) forcount of menu items > 0, re-clicks ONCE on a still-empty menu and polls again, and enumerates by guarded index (the #295 pattern); total failure keeps the fail-closed sentinel, whose message now discloses the menu count seen. Live-verified end-to-end (2026-07-22, direct binary drive):create_draftwithfrom_addressreturnedDraft created successfully (mailto path) [sender verified via From popup: d06227105@ntu.edu.tw]and the saved draft's sender matched — the #219 clean-body + verified-custom-sender combination delivered live for the first time (v2.23.0 raw -2700 → c82ec50 scan guard → this menu poll). 1026 tests.
Fixed
- Verified sender-popup identifies the From control by AXIdentifier + matches Mail's real value format — #219's clean path finally works safely (#219, reopened). A cross-model re-verify (Codex BLOCKING) of the format fix below caught a deeper hole: the From popup was discovered by "the pop-up-button whose value contains
@", which a signature popup named like an email could satisfy — the poll could then lock onto it and pass a self-consistent select + read-back on the WRONG control while the real From stayed on the default account (wrong-account send). A live AX dump showed every compose-window popup carries a stable, locale-independentAXIdentifier(popup_priority/popup_from/popup_signature), so the From popup is now identified byAXIdentifier = "popup_from"— deterministic, #174-safe, and structurally immune to a signature-popup mix-up (the_fromPopupCountvalue-@heuristic is gone). Combined with the en-dash format fix (below) and the #296 menu-population poll, the clean path selects + verifies the correct account or fails closed to legacy. A live smoke (once #295 removed the -2700 crash masking it) + an AX dump of a real compose window revealed the blind spot behind #219's entire cross-model verification: Mail's From popup rendersDisplay Name – addr@domain— a SPACE + EN DASH (U+2013) + SPACE separator, no angle brackets (e.g.鄭澈 Che Cheng – che830621@as.edu.tw). Every round of Codex R2→R5 + 2 Claude lenses had reasoned aboutsenderMatchesagainst an assumedName <addr>form, so branches_label is _addrand_label ends with "<addr>"never matched the real value → the clean path silently fell to legacy on every account (unit tests + static analysis structurally can't see this — only a live smoke against real Mail can). Fix:senderMatchesgains a separator-agnostic branch — the addr is the LAST space-delimited token of the label (isSimpleAddrSpecgates the addr to no-whitespace upstream, so a simple addr is always that final token), compared with exactis(anti-spoof preserved: a… – notche@xlabel's last token isnotche@x≠che@x; a…@as.edu.tw.evil.comlast token ≠…@as.edu.tw— verified against the real value + spoof/subdomain/hyphen/bare/angle cases in isolation). Also a bounded poll (12 × 0.3s) before the scan, because the From popup value is empty for a beat after the compose window opens (an early scan gotSENDERPOPUP: From popup not found). Unit-locked (script must carry the AXIdentifier discovery + last-token match + poll); the match logic verified live against the exact popup value (bare / angle / en-dash / hyphen + spoof / subdomain cases), the AXIdentifier discovery grounded in the live AX dump, and the AXIdentifier hardening re-verified by Codex (PASS — the value-based wrong-control path is structurally gone). The full clean-body + verified-custom-sender pipeline is confirmed live end-to-end (see #296 —create_draftreturned[sender verified via From popup: …]and the saved draft's sender matched). - From-popup scan no longer leaks a raw -2700 on a settling AX tree (#295, regression from the #219-verify-R4 exactly-one-popup change). A live smoke of v2.23.0's verified sender-popup (driving the notarized binary against a real Mail.app) caught it:
create_draftwith a customfrom_addressfell to legacy withAppleScript error (-2700): … Can't get item 2 of every pop up button … Invalid index.— the popup scan'srepeat … in (pop up buttons of _w)re-fetchesitem Neach pass, and a compose window whose AX tree is still settling (or a degraded/slow Mail) throws from that item-fetch OUTSIDE the innertry, crashing the whole clean path (the invariant still held — draft created under the correct account, wrapped body + disclosed — but #219's clean body wasn't delivered). The R4 change had removed the originalexit repeat(which stopped at item 1, never reaching item 2), exposing this. Fix: snapshot the popup count once (count of pop up buttons of _w) and fetch each by guarded index (pop up button _pbi of _winside thetry), so an unstable/vanished index is skipped → the scan fails closed to a cleanSENDERPOPUP→ legacy, never a leaked -2700. Static + unit locked (the generated script must use the indexed guarded form, not the bare collection loop). Live re-smoke required when Mail is healthy — the AX-instability path is GUI-timing, not unit-coverable.
v2.23.0
Added
- New
check_automationtool — the third TCC probe axis (#293, formalizing the #288 Strategy's deferred optional; blueprint = che-ical-mcp's EventKit probe/setup pattern).check_fdaandcheck_accessibilitycovered two axes; Automation had only the after-the-fact -1743 guidance.AutomationStatus.probe()wrapsAEDeterminePermissionToAutomateTarget(askUserIfNeeded: false)— a NON-prompting status read issued BY the binary itself (the only honest vantage per #288's attribution model: the signed binary holds its own grant, so a shell osascript probe would report the terminal's grant instead). Four states with per-state remediation: granted / denied (recorded -1743 — reusesAutomationHelp.guidanceverbatim, single text source) / not-determined (-1744 — run any Mail tool to trigger the prompt; the prompt names the binary/host, not the terminal) / Mail-not-running (open Mail first — the probe deliberately has no side effects, no auto-launch); unexpected OSStatus surfaces verbatim, judged conservatively. Pure state→report mapping unit-tested; the live probe is the thin residue layer. Tool count 52→53 (census guards updated across README/README_zh-TW/server.json/mcpb manifest). 1013→1019 tests (cluster). - Custom
from_addressnow rides the wrapper-free clean path — verified sender popup (#219, the #175 follow-up). mailto always composes from the default account, so a custom sender previously forced the legacy path (correct account, body wrapped inblockquote type="cite"). The GUI now drives the compose window's From popup — located as the pop up button whose current VALUE carries an@(the signature popup doesn't) — clicks the menu item containing the requested address, then reads the popup value back (the issue's hard requirement): any mismatch/absence raises a pre-dispatchSENDERPOPUPsentinel, the script's on-error cleanup closes OUR window (saving no, never a pre-existing same-titled draft), and the router falls back to legacy whoseset senderguarantees the right account — a send never leaves from the wrong account. Eligibility flip:hasCustomSenderno longer disqualifies; without Accessibility the reason names the popup mechanism it needs. Result string discloses the verified sender; tool descriptions /compose-wrapper-freerules updated (the manual account-switch footgun recipe is superseded, kept only for Accessibility-less machines). Popup phase is ordering-pinned before dispatch. Cross-model verify (2 Claude lenses + Codex) hardened three safety edges in-loop: the popup selection AND read-back match by exact addr-spec (thesenderMatcheshandler tests a menu label by exact<addr>suffix —is _addrorends with "<addr>"— never extraction or substringcontains— acontainscheck would have verifiednotme@x/me@x.twasme@xand sent from the wrong account (Codex BLOCKING)); a pre-existing same-subject window aborts the clean path (System Events can't read Mail's window id, so a title collision made identity ambiguous → legacy fallback); and disclosure/refusal strings +shouldUseMailtoComposedoc were de-staled (no longer say "pending #219"). A second cross-model round (Codex R3 BLOCKING) closed two more edges: a quoted-local-partfrom_address("prefix<foo"@evil) could suffix-match a crafted evil account label → a newisSimpleAddrSpeceligibility gate routes any non-simple custom sender (quote/angle-bracket/whitespace/multi-@) to legacy, sosenderMatchesis only ever driven with a simple addr-spec and the spoof is unreachable; and the new-window identity now asserts exactly one(new ∧ title==subject)candidate (_ourMatches), failing closed on a concurrent same-subject window rather than risking asaving noon the user's window — while still not over-rejecting when launching Mail opens its viewer too. Both Claude lenses PASS across rounds; the residuals (GUI-automation title TOCTOU, a cosmetic orphan compose window on a pre-tryguard, case-fold of two same-canonical-mailbox accounts) are inherent or non-exploitable. Live smoke (real multi-account Mail) is the attended residue. - Display-name recipients ride the clean path on drafts — GUI clipboard fill (#277, #219's complement; together the three-way "clean body + names + chosen account" now coexists on drafts). RFC 6068 can't carry a display name, so
Name <email>recipients forced the legacy path. On the draft path the mailto URL now omits any To/Cc list that carries a display name and the GUI fills it after the window opens: clipboard paste (never keystrokes — CJK names would hit IME nondeterminism, the #220 lesson; clipboard preserved via the #175 machinery), exploiting the fresh window's To-field default focus, tab to tokenize, Cc likewise. Draft-only by design: a failed fill on a send would fire with missing recipients, socompose_emailkeeps display-names → legacy; a display-name bcc also stays legacy (the Bcc field isn't reliably fillable) — the eligibility reason spells out the boundary (displayNameFillViable, threaded from both createDraft probe sites, wiring-scan pinned). Cross-model verify (Codex BLOCKING) scoped the fill to To-only: Mail's Cc field can be hidden via Header Fields, so a Tab-to-Cc paste could silently land in Subject and drop the Cc — display-name cc/bcc now keep the legacy path (displayNameFillViablerequires a display-name-free cc AND bcc), the To field is always visible + default-focused. A defense-in-depth guard incomposeViaMailtothrows if a send ever reaches the fill phase (structurally unreachable via eligibility, but fail-loud beats a wrong-recipient send). Result string tells the caller to verify To in the saved draft (fill read-back is limited — the honest verification is the attended live smoke, shared with #219).
Added
-1743(Automation TCC denied) now returns actionable guidance (#288). The rawAppleScript error (-1743): Not authorized to send Apple events to Mailpassed through with zero remediation — two independent sessions re-diagnosed the same wall from one bare line (recurrence documented on the issue). The mapping lives at the singleMailError.scriptFailedrendering sink (every AppleScript-backed tool funnels through it — theFullDiskAccessHelpprecedent, Automation axis), matches on the error CODE (never the locale-dependent message text), and appends — the raw error stays first. Guidance (attribution model empirically corrected pre-merge, #288 evidence 2026-07-21): the signed MCP binary holds its own Automation grant — TCC identity keyed to the binary's signing identity (the #211 FDA lesson, Automation axis), separate from the terminal's; on the incident machine shellosascriptcontrolled Mail while the binary still got -1743, so the guidance pre-empts the osascript-works misdiagnosis, names the System Settings → Privacy & Security → Automation entry location (Desktop extension: under Claude.app), gives the remembered-denial escape hatch (tccutil reset AppleEvents— macOS never re-prompts a recorded Deny), the per-install/update-invalidation warning (#211 family), and the immediate zero-TCC fallback pointer toopen_mailto(#287, cite-block-free). All other error codes stay byte-identical passthrough (pinned by test). 998→1001 tests.
Changed
open_mailtonow goes through LaunchServices — zero Automation TCC (#287). The old implementation drovetell application "Mail" / mailto— an Apple event, so the nominally "just open a URL" tool required Automation TCC and died with-1743on an unauthorized machine, exactly where a zero-TCC escape hatch is needed (session-verified: shellopen "mailto:..."worked while both MCP compose paths failed). NowNSWorkspace.shared.openposts the URL through LaunchServices: no Apple events, no TCC, and the mailto compose window is inherently cite-block-free (#175). Amailto:scheme guard replaces the old accept-anything behavior (anhttps:URL would have silently opened a browser);openreturning false (no mailto handler) fails loud with a remediation hint. Disclosed trade-offs (result string + tool description): the window opens in the system DEFAULT mail client (may not be Mail.app), and mailto cannot carry attachments (RFC 6068 — drag manually). The tool description,.claude/rules/compose-wrapper-free.md(new "TCC fallback ladder" section), and README (new -1743 troubleshooting section) now all spell out the cite-block-avoidance ladder — (a) clean path (Automation+Accessibility) → (b)open_mailto(zero TCC) → (c) legacy injection (wrapped; formal mail unacceptable) — with the iron rule: on -1743, (b) is the answer, never (c), and the responsible-process table (CLI → terminal app; Desktop extension → Claude.app; independent grants, update-invalidation per #211). 994→998 tests.
Fixed
atCountcounts unicode scalars, not grapheme clusters (#289, the atCount sibling of #280's angle-scan fix, surfaced by its verify R2).@fused with a trailing combining scalar (U+FE0F) into one grapheme cluster compared unequal to"@"under Character-level counting —a@\u{FE0F}b@ccountedatCount==1and passed with the masked@intact (Mail-level invalid, no mis-send; the masked-only-@case was already fail-safe atatCount==0). Scalar counting sees every U+0040 →atCount==2→ rejected. Cross-model verify (Codex) caught the boundary sibling in-loop: the prefix/suffix checks were still Character-level, so a LEADING fused@(@\u{FE0F}x— empty local part) would have flipped from accidental-reject...
v2.22.0
Added
update_draft— replace an existing draft (upsert) (#276). Apple Mail drafts cannot be edited in place, so "editing" a draft previously meant manually deleting and recreating it in Mail.app (and same-subject stale drafts piled up). The new tool mechanizes locate → create → delete: identify the old draft bydraft_id(fromlist_drafts) or an EXACTsubject_match(never substring — misfires would delete the wrong draft; optionalaccount_name/account_idscoping), create the replacement through the fullcreate_draftmechanism (inheriting the #175/#237/#239 clean-body eligibility and disclosure), and only then delete the old draft inside the same unified-drafts child scope that located it (#174 mechanism; AppleScript delete = Trash, recoverable). Ordering is deliberately create-then-delete — the reverse of the issue's original phrasing — because the failure direction is then always toward KEEPING drafts (worst case both may exist, visible and recoverable) instead of "neither" (data loss); a delete failure after a successful create reportsdeleted_old: falsewith a note matched to what is actually known — confirmed absent (clean-scan 9276) / state unknown (9277) / both drafts may exist (generic failure) — instead of throwing or over-claiming. Ambiguity always refuses: 0 matches (update requires an existing draft) or >1 (candidates{id, subject}listed). The replacement is a NEW draft with a NEW id, created undercreate_draft's account semantics. Cross-model verify (Codex) tightened four edges before merge: the all-accounts locate scan is fail-closed (any drafts-child read error aborts the locate and refuses — a swallowed error could have hidden a real cross-account ambiguity behind a "unique" match);draft_idvalidation is strict ASCII[0-9]at both the handler and controller (Unicode digits rejected);parseDraftRowsrefuses empty/non-numeric ids so nothing malformed can reach the delete script; and the delete script separates the not-found lookup (→ recognizable 9276) from thedeleteverb, so a real deletion failure surfaces its true cause asdeleted_old: falseinstead of masquerading as "not found". Selector validation is presence-based (verify R2): an explicitly emptysubject_matchordraft_idis a provided-but-invalid value with its own parameter error — never silently treated as absent (which would have letdraft_id+subject_match:""slip past the mutual-exclusion gate). A Devil's-Advocate round then hardened the two delete gates: a post-create receipt re-lists the drafts and requires a NEW id before the old draft is touched (the GUI mailto path can report success without the draft landing — a phantom success now KEEPS the old draft, disclosed as not-confirmed), and the delete selects candidates by numeric id then compares the subject underconsidering casefor exact Swift-==parity (AppleScript equality is case-insensitive by default — a barewhose subject iscould match another account's same-id draft differing only in case). The receipt snapshot uses the all-accounts scan (the replacement may land under a different account than the locate scope) and requires the new id to carry the replacement's exact subject (a different-subject concurrent draft is not a receipt; a same-subject concurrent draft remains indistinguishable without a create-path id — documented limit). A final round also fixed 9276 mis-classification (an id candidate whose subject no longer matches is ambiguity, never "confirmed absent") and gave the account scoping fields the same presence-first type validation (account_name of a non-string type errors instead of silently widening a mutation to all accounts). The fail-closed all-accounts scan error now names the remedy (retry with account scoping). A final cross-model round hardened three more edges: the delete lookup is a non-throwing count over the whose-filter with scan-clean gating — 9276 "not found" may only fire after the whole scope scanned cleanly (a condition-eval error downgrades to 9277 "scan incomplete — old-draft state unknown", and real query errors propagate), so "confirmed absent" is never claimed on a swallowed error; handler-boundary selector validation is key-presence-first via a pure static (draft_id: 123type confusion is a parameter error, never silently treated as absent — unit-tested without the transport); and digit validation is byte-level ASCII (isASCIIDigitsover UTF-8, shared by handler/controller/parser/builder — a combining-mark digit grapheme can slip a Character range check). Attended live-smoke checklist (deferred, must-test): GUI-path create receipt on a real drafts mailbox; cross-account id semantics; all-accounts scan behavior on an "On My Mac" local drafts container. Spectra changeadd-update-draft-tool(proposal / design /draft-updatespec delta / tasks). Live smoke (real-draft roundtrip) deferred to an attended session — unit/seam tests pin the four orchestration paths and the create-before-delete order.list_draftsnow returns each draft's numericid(#276, additive). The AppleScript reads each row's id and subject from the SAME message reference (one snapshot, per-message loop — verify R2, Codex: two bulk parallel-list queries could mis-pair when the mailbox mutates between them with unchanged length, the only shape a length guard cannot catch), joined with control-character delimiters (comma-safe) and zipped byparseDraftRows, which throws on any length mismatch or malformed id instead of silently truncating. Subject-only consumers are unchanged; the id feedsupdate_draft.draft_idanddelete_email.iddirectly, closing the "list_drafts can't drive a precise delete" gap noted in the official-travel workflow.
Fixed
- Recipient lite validator — unpaired stray angle brackets now rejected (#270, residual of #265). The #265 boundary guard required a matched angle pair (
contains("<") && contains(">")), so a single stray bracket (<a@x/a@x>) bypassed it —parseRecipientreturns such strings whole as bare addresses (brackets intact), the single-@count passed, and the malformed recipient landed in the AppleScriptaddressas a Mail-level-invalid (no mis-send, but silently accepted). The guard now uses a quote-awarecontainsUnquotedAnglescan: any</>outside RFC 5322 quoted strings is rejected when the parser could not split a display name — covering both the matched-pair (#265) and unpaired (#270) cases with one predicate. Deliberate behavior change: a quoted local-part carrying angles is now legal even with a matched pair ("a<b>"@x, previously documented-unsupported and rejected) — angles inside quoted strings are legal RFC 5322 specials, and the scan honors quoted-pair escapes (\") consistently withunescapeQuotedPairs(#266). Verify rounds (cross-model, Codex R1+R2) tightened the exemption to stay honest: an unterminated quote is not an RFC 5322 quoted-string, so angles inside one count as unquoted at EOF ("a<b@xrejected — and the"a<b>@xshape the #265 paired guard used to catch stays rejected, closing a would-be regression); a quoted-string cannot appear in the domain, so after the first unquoted@a quote is literal (a@"<x>"rejected); and an escaped angle inside a still-open quote is recorded too ("a\<b\>@xrejected — the escaped branch consuming\<silently was R2's catch; the properly closed"a\<b\>"@xstays legal). Deliberate lite-validator boundary (documented in the guard comment): properly closed quote segments before the first unquoted@exempt their angles without local-part grammar validation. Error message updated to "stray/unpaired angle brackets". get_emailno longer silently returns an empty body for a not-yet-downloaded message (#274). Mail stores a header-synced / body-not-downloaded message as<rowid>.partial.emlx;resolveEmlxPathtransparently fell back to it,readEmailparsed the header-only file into a "successful" empty-body result, and the handler returned it — never triggering the AppleScript fallback, so archive flows silently produced header-only archives (the issue's cache-invalidation hypothesis was refuted by code inspection: there is no content cache; the partial file just parses "successfully"). Now:resolveEmlxPathDetailedexposes anisPartialbit (the legacyString?API is a byte-compatible wrapper; the attachment extractor's own partial handling is untouched),EmailContentcarriesfromPartialEmlx, andget_emailroutes a partial-with-absent-body result to the logged AppleScript fallback — whosecontent/sourceread doubles as the download nudge the direct file read cannot perform. If the AppleScript read still carries no body, the result getsbody_downloaded: false— the check is format-aware (verify R1, Codex: a header-only source is non-empty yet body-less, so a bare emptiness test would silently skip the annotation for the exact case this issue exists to surface). The signal is negative-only:falseor absent, nevertrue— absence means "no partial-file evidence of a missing body", not a downloaded-body guarantee; converselyfalseis conservative — a legitimately empty-body (or whitespace-only) message reached via the partial route also gets flagged, since the heuristic cannot tell "empty by design" from "not fetched" (deliberate warn-over-silence trade-off).get_emails_batchannotates flagged items with the same key but stays on the fast path (a per-item AppleScript fallback would be one Mail IPC round-trip per message) — batch callers re-fetch flagged ids via singleget_email. **Sco...
v2.21.0
Added
get_special_mailboxesper-account mode now also returns a<type>_pathfield
for each present special mailbox — the FULL mailbox path (drafts_path
"[Gmail]/草稿") matching themailboxfieldsearch_emailsreturns, so a
consumer can compare full-path == full-path directly instead of the #109
leaf-suffix heuristic. Built by an AppleScript container-walk that joins parent
mailbox names with/up to the account boundary (reproducing
MailboxURL.mailboxPath); a top-level mailbox's path equals its leaf
(inbox_path "收件匣"). Purely additive — the leaf<type>field is unchanged,
and a walk failure omits only that_pathkey. The result tuple appends the 5
paths after the 5 leaf names, preserving the #179 fixed-tuple positions
([matchedId, matchedName, matchCount, n0…n4, p0…p4]). (#268)to/cc/bccaccept RFC 5322 mailbox form (Name <email>) — recipients can display a person's name (#251). Previously the recipient parameters were documented as bare addresses; aName <email>string happened to pass the boundary validation (single@) and flowed unparsed into AppleScript, while a legal name containing@was mis-rejected by the same check. Now a newparseRecipientboundary parser splits the mailbox form; validation applies to the addr-spec part; the legacy AppleScript path emits native{name, address}recipient properties (Mail displays the person's name — this also silently benefitsreply_email'scc_additionalandforward_email'sto, which share the fragment builder); and display-name recipients oncompose_email/create_draftbecome a named mailto-ineligibility (the mailto URL carries addr-spec only per RFC 6068) — the same disclosed legacy-path trade-off as a customfrom_address: names shown natively, body wrapped + disclosed; withrequire_wrapper_free: true, a clean refusal names the drop-the-names alternative. Bare-address calls are byte-identical. Contacts-based auto-naming stays a follow-up on the issue. Verify round (PR #262) hardened the parser: an unquoted name containing</>(e.g. the multi-angleAlice <a@x> <b@y>) is rejected at the boundary instead of silently reinterpreted as a send to the last address (RFC 5322 makes angles specials, forbidden in unquoted atoms; quoted names keep them), and the bare-angle form<a@b.c>normalizes to the bare address.redirect_emailrecipient parity with #251 (#263). The shared boundary validation relaxed by #251 letName <email>reach the redirect script builder, which passed the whole string as the AppleScriptaddress(Mail's display-name fallback — no mis-send, but malformed-downstream). The builder now delegates to the shared name-awarerecipientFragment: canonical (unpadded) bare addresses stay byte-identical (the #135 golden is untouched; a whitespace-padded bare address is now trimmed, matching validation and compose/reply/forward), mailbox-form recipients get the native{name, address}pair — compose/reply/forward/redirect now behave identically. Well-formed multi-angle inputs (two addr-specs, e.g.Alice <a@x> <b@y>) stay rejected at the shared boundary (#251 regression lock added for redirect); a malformed one-@multi-angle input still passes the lite validator and lands as a Mail-level-invalid address (no mis-send) — shared-boundary hardening tracked as #265.
Fixed
- Recipient parser hardening — malformed multi-angle rejection + quoted-pair decoding (#265, #266). The shared
validateEmailAddressesboundary now rejects a recipient thatparseRecipientcould not cleanly split (name resolves to nil) yet still carries a matched<...>pair — the single-@malformed multi-angle case (Alice <not-an-email> <bob@x>) that previously slipped theatCountcheck and landed whole in the AppleScriptaddress. A clean bare addr-spec and the normalized bare-angle form (<a@b.c>→a@b.c) are unaffected; the pathological quoted-local-part literally containing a matched angle pair ("a<b>"@x) is explicitly unsupported. Separately, quoted display names now decode RFC 5322 quoted-pairs (\"→",\\→\) so the native recipient name carries the intended value rather than the backslash-escaped source form (#266). The parser uses a simple outer-quote heuristic (a name that both starts and ends with"), so a terminal escaped quote ("x\") is still classified as quoted and decoded; the decoded value is re-escaped for AppleScript backslash-first, so the emitted recipient literal is always balanced (no injection). A residual note: single-unmatched-angle malformed recipients (<a@x,a@x>) are not caught by the matched-pair boundary reject and land as Mail-level-invalid addresses (no mis-send) — tracked as #270. - Test isolation — awaited seam teardown (#267). The three
RecipientDisplayNameTestsseam-reset sites migrated from a detacheddefer { Task { … } }(which could reset the process-wideMailController.sharedsingleton after the next test started) toaddTeardownBlock, which XCTest awaits.
v2.20.0
Added
download_if_missing— opt-in best-effort fetch for a server-side-only attachment (#272). #238 taughtsave_attachmentto detect a not-downloaded attachment (savable_reason:"not_downloaded") but the remedy stayed manual ("open the message in Mail"). This adds an opt-in boolean (default false, fully backward-compatible): when both tiers fail on thenot_downloaded/ generic-10000path AND the caller set the flag, the tool nudges Mail to materialize the message — readingsource of theMessage(the raw RFC 822; non-GUI, no viewer window), which on an IMAP account pulls the server-side content into the local store as a side effect — then poll-retries the save on a bounded budget (~30s, 2s interval). Best-effort and unverified by design (Phase-0 spike, direction of #238(a)): Mail's scripting dictionary exposes no per-attachmentdownloadverb (downloadedis read-only, the only command issave), so this relies on an undocumented, version-/account-dependent materialization side effect that may not fetch on every account (notably where the save simply errors). It fails honestly — a spent budget throws thenot_downloadedguidance, never a false "saved" — and the gate is a strict conjunction (notDownloaded && code==-10000 && downloadIfMissing) so every non-opt-in caller's behavior is byte-identical. Detection stayed on the SQLite path; only the state-changing fetch trigger + retry use AppleScript (perr-must-direct-db). NewAttachmentDownloadScriptBuilder(pure trigger-script builder + gate +DownloadRetryPolicy) andMailController.saveAttachmentRetryingForDownload; 13 unit tests (script text, gate, policy, deterministic retry loop via the injected script-runner seam). The live question — doessource-read actually pull a not-downloaded attachment down? — is a pre-merge gate (no not-downloaded specimen was available to live-test;sourceis confirmed a validmessageproperty via the sdef). Post-verify hardening (6-AI APPROVE, PR #273): the retry loop is bounded by a real wall-clock deadline (not just a sleep-count that ignored each save's ~1-2s IPC), aborts immediately with the honest cause on a definitive "not found" instead of polling the full budget, logs a fetch-trigger failure to stderr, and uses a precisehasPrefixsuccess check; the flag is documented as a silent no-op on Exchange/EWS (no.emlx) and when the local index is unavailable.
v2.19.0
Added
require_wrapper_freeparameter forcompose_email/create_draft— clean failure instead of a silently wrapped draft (#239). #237's disclosure tells the caller a wrapped-body draft was produced — after the fact. For callers where a clean body is a hard requirement, the new opt-in boolean (default false, fully backward-compatible) turns that into fail-fast: an ineligible call (customfrom_address/ non-plain format / empty subject / no Accessibility / env hatch) errors out with the named reason and every actionable alternative, creating nothing; an eligible call whose GUI step fails propagates the error with no legacy fallback — and a send-stage (POSTDISPATCH) failure maps to the same canonical unknown-send-state guidance as the default path (check Sent/Outbox, do NOT retry; verify-round REQUIRED fix — a raw sentinel token would invite an auto-retrying caller to re-send), via a sharedunknownSendStateErrorhelper now also used by the #242 router hook. Spectra changerequire-wrapper-free-paramadds the normative message-composition clause; pinned byRequireWrapperFreeTests(7 tests, including production-site strict-branch tests through the #254 seams: strict+ineligible runs zero scripts; strict clean-path failure runs exactly one). Complements #219 (which will let custom senders use the clean path at all).get_special_mailboxesper-account resolution now includesinbox(#249). Inbox was conservatively deferred in #179 (referenced asinbox, not<X> mailbox, with unverified per-account child semantics); the spec made lifting it conditional on a live multi-account check. That check ran 2026-07-14 against 7 configured accounts:every mailbox of inboxexposes a per-account child on all of them, and an Exchange account proves the real name localizes (收件匣) — so the resolution carries real value. One list entry appended (the count-driven builder/parser needed no other logic); Spectra changeper-account-inboxlifts the spec's deferral clauses.
Fixed
-
compose_email/create_draftwith a non-ASCII attachment path no longer hang in the GUI attach flow (#220). The wrapper-free mailto path attaches via the go-to-folder sheet (⇧⌘G + clipboard paste), which hung deterministically on CJK/fullwidth paths (live repro on v2.17.0 — the sheet never accepts the input, and the panel-closed proxy can't see it). Non-ASCII attachment paths are now a named mailto-ineligibility: the call routes to the legacy path, whose nativePOSIX fileattachment handles any path — so the worst case changes from a frozen compose window to a working draft with a wrapped body + the standard[legacy path — …]disclosure (and withrequire_wrapper_free: true(#239), a clean refusal naming #220 and the ASCII alternative). ASCII-only paths keep the clean path unchanged. Note the conservative breadth: ANY non-ASCII path — including accented-Latin (é, ü) — is swept to the legacy path, because narrowing the predicate would require live-reproducing the freeze to enumerate which ranges hang; the asymmetric cost (needless legacy = working draft + disclosure vs. a missed hang = frozen Mail.app) favors over-blocking. Residual (documented): making the GUI attach flow itself CJK-safe would need live GUI iteration on the sheet's behavior; this fix removes the hang, not the wrapped-body trade-off. -
reply_email/forward_emailno longer risk a duplicate outbound send when the clean paste path fails at the send-keystroke stage (#254). The #242 POSTDISPATCH protection now covers the reply/forward family verbatim: the shared paste builder wraps the ⇧⌘D dispatch in a sentinel, a_dispatchedflag marks errors from the success-path tail (where the mail is DEFINITELY sent), the on-error window cleanup is skipped for marked errors (the compose window is the user's only evidence), and both call sites refuse the legacy re-send with an unknown-send-state error directing the user to check Sent/Outbox and the original thread. The draft path (⌘SsaveAsDraft) keeps the plain fallback and its post-try window close unchanged. Additionally,MailControllergains actor-isolated test seams (script-runner + eligibility overrides; production never sets them) enabling production-site behavioral tests:MailControllerSeamTestsdrives the real compose/createDraft/reply/forward methods with a fake runner and pins the family-correct disclosure suffix per site (closing the #241 wiring guard's family-swap blind spot, per that verify round's DA adjudication) plus the bodyless-forward no-suffix/no-probe invariant;ReplyForwardSendStageTestsadds an end-to-end refuse-branch test (post-dispatch reply failure → exactly one script run, no legacy re-send). -
savable:falsefalse-negatives from Mail's degraded on-disk attachment filenames (#183). Live-proven on message 274368: Mail writes the external attachment cache under its OWN rendition of the filename —?-substituted CJK plus whitespace drift — while our parser reads the correct MIME-header name, so the byte-for-byte name probe missed bytes that were right there (AppleScriptdownloaded=true). The on-disk part directory equals the Envelope Indexattachment_id, so both the savability probe andsave_attachment's fast path now fall back to a name-free part-directory lookup (Attachments/<rowId>/<attachment_id>/, single-file), threaded from the SQLite read. Name-based lookup stays first (older messages whose part numbering may not align). -
Not-downloaded attachments now say so (#238, direction (b)). A
.partial.emlxpart whose body was never fetched carriesX-Apple-Content-Lengthplaceholder headers with an empty body — a detectable state that previously surfaced as the misleading generic "Attachment not found" / opaque-10000.list_attachmentsitems withsavable:falsenow carrysavable_reason("not_downloaded"vs"not_extractable"), andsave_attachment(after both tiers fail) returns the actionable guidance: open the message in Mail (or set Download Attachments: All), then retry. The Tier-2 AppleScript attempt still runs first — whether it can itself trigger a server fetch is #238 (a), still open pending an uncached specimen. -
compose_emailno longer risks a duplicate outbound send when the clean path fails at the send-keystroke stage (#242). The #175-era control flow fell back to the legacy AppleScript path on ANY clean-path error — including errors thrown at or after the ⇧⌘D send dispatch, when the first mail may already be on the wire (programmatic confirmation is impossible — the #175 documented residual). The mailto compose script now wraps the send dispatch in aPOSTDISPATCHsentinel: pre-dispatch errors (window lost, lingering attach sheet) keep the safe fallback exactly as before, while sentinel-marked errors refuse the fallback and return an unknown-send-state error telling the caller to check Sent/Outbox before re-sending. The on-error cleanup also skips itsclose saving nofor sentinel errors — the compose window (if still open) is the user's only evidence and is left untouched. Verify-round hardening (Codex HIGH + DA): a_dispatchedflag additionally marks errors from the post-dispatch settle tail — which runs only on the success path, where a throw would otherwise mean a GUARANTEED duplicate (not merely unknown) — and the Swift sentinel check is prefix-only (hasPrefix), symmetric with the AppleScriptstarts withcleanup guard.create_draft(⌘S) keeps the plain fallback: a duplicated draft is visible and harmless. Rejected alternative (recorded in the diagnosis): post-hoc Sent/Outbox scanning for a same-subject message — O(mailbox) and race-prone. TherouteWrapperFreeComposerouter (#241) gainsshouldFallback/mapNoFallbackErrorhooks (defaults preserve every other site's behavior), pinned bySendStageNoRefallbackTests(7 tests: builder sentinel contract for both send/draft, sentinel detection, router refuse-branch, production wiring source guard). -
Doc/manifest tool-count census — every documented count now equals
defineTools()(51), pinned by a guard test (#248). Counts had drifted across six locations (server.json 47, mcpb/manifest.json 48×2, README_zh-TW.md 47×3, README.md section sub-counts summing 49) because each tool addition updated only some of them;check_fda/check_accessibility(#213) had never been tabled at all, and README_zh-TW.md was missing the entire Batch section (#233-era). All six locations now read 51; both READMEs gain a Diagnostics section (and zh the Batch section); two internal diagnostic strings referencing the pre-#233 tool name now name the canonicalbatch_export_emails_markdown. A newToolCountCensusGuardTests(8 tests) asserts every count claim AND the exact tool-name set of both README tables againstdefineTools(), so a future tool addition fails the suite until the docs are synced. -
batch_export_emails_markdownfrontmatterdatenow preserves the originalDateheader's UTC offset (#244). The renderer parsed the RFC 2822 zone into an absolute instant, then re-formatted with a UTC-pinned formatter — so a Taiwan-sent16:49:57 +0800email carrieddate: …T08:49:57Zin frontmatter while the bodyDate:line (correctly) said 16:49. Every non-UTC-timezone user's archive had shifted frontmatter timestamps, and index files built from them inherited the shift. The shared helper (`rf...
v2.18.0
Added
batch_export_emails_markdown— the batch-export tool's canonical name;export_emails_markdownbecomes a deprecated alias (#233). The #232 incident showed LLM callers weight the tool NAME above its description when scanning the tool list — an agent following an archive SOP still called per-emailget_emailbecause nothing name-level said "batch". The rename ships as a zero-break dual registration: both names share one description constant, one input-schema constant, and one dispatch case label (so they can never diverge — pinned by schema-equality and source-guard tests); the old name's description leads withDEPRECATED — renamed to batch_export_emails_markdownand old-name calls emit a one-line stderr deprecation warning (result content unchanged). Removal gate: the alias will not be removed before the next major release (v3.0) — removal is its own breaking change with its own migration note (normative clause added to the batch-operations spec). Callers should migrate tobatch_export_emails_markdown; the archive-mail SOP skill (psychquant-claude-plugins) migrates at its next distribution sync. README tool tables gained the previously-missing Batch section and the tool counts were corrected to the actual 51.- Archive primitives: dedup-aware
export_emails_markdown+ asummarysearch projection + batchmessage_id(#177). Bulk archiving cost O(corpus) tokens becausesearch_emailsinlined full per-hit JSON andget_emails_batchpulled content into context before writing. Rather than a new monolithic archiver, the already-shippedsearch projection: ids → export_emails_markdown(binary-side, zero content in context) is enriched into a complete, dedup-aware O(1)-context archive primitive — the downstream index stays the caller's. Three additive changes: (1)export_emails_markdownaccepts an optionalskip_message_ids_path— a file of already-archived RFC 5322 Message-IDs (one per line; blank/#lines ignored), validated read-only under the same allowed-roots policy asoutput_dir; matching emails are skipped (manifeststatus: "skipped"+ askippedcount) rather than rewritten, so a re-run only writes new mail (missing/unreadable file → no skips). The manifest already carriesmessage_idper item, so a caller can reconcile a Message-ID-keyed index from the (small) manifest without re-fetching. (2)search_emailsprojectiongains asummaryvalue returning triage objects with onlyid/date/sender/subject/mailbox(betweenidsandfull; SQLite-only,dedup: logical-compatible since it does no recipient subquery). (3)get_emails_batchper-email results now includemessage_id, parity with singleget_email. export_emails_markdownadds abody_type: text|htmlfrontmatter field (#199). For an html-only email,textBodyis nil and the renderer emits the raw HTML verbatim (no data loss) — but previously nothing signalled that the body was HTML, a content-quality gap for a human/LLM-read archive. The exporter now emitsbody_type(textwhen a plain body exists,htmlfor html-only) via the renderer's sanctionedextraFrontmatterextension point, so the frozen 6-field frontmatter contract is unchanged (body_type is appended after the core six, never reordering them).CHE_MAIL_DISABLE_PASTE_REPLYenv escape hatch (#218) — set to1/true/yesto force the legacy AppleScript injection path for reply/forward (wrapped new body), independent of the compose hatch (CHE_MAIL_DISABLE_MAILTO_COMPOSE), so the two GUI paths can be toggled separately. The clean reply/forward path reuses theCHE_MAIL_MAILTO_*GUI-timing knobs.
Fixed
- Concurrent
export_emails_markdownruns to the sameoutput_dirno longer race (#236). Two overlappingrun()calls could both snapshot the directory before either wrote (defeating #232's cross-call-Ncollision guard), derive the same(date,slug)filename, and silently last-rename-wins overwrite each other — with the deterministic.idd200.tmptemp names additionally producing spurious cross-run errors, and the whole-run manifest write being last-wins. A newExportDirLock(advisoryflockonoutput_dir/.export.lock, covering the entire run: snapshot → writes → manifest) makes the concurrent case equal the sequential case #232 already handles. Fail-fast, never blocking: an overlapping call gets a clear "another export is writing to this output_dir — retry" error (LOCK_NB), so an MCP tool call can't hang behind a long export. Works across processes (Claude Code plugin + Claude Desktop extension installs) sinceflockis per open-file-description; advisory-only by design — both writers we control are this binary (same threat-model assumption as #197/#200).run()is nowthrows; the manifest schema is unchanged;RaceFreeFileWriter(shared withsave_attachment) and the idempotent re-export /-Nuniquify semantics are untouched. The lockfile persists between runs by design (unlinking would reintroduce a race) and, being a dotfile (not.md), never enters the collision seeding or the manifest. The lockfile open carriesO_NOFOLLOW(a planted symlink at the fixed.export.lockpath is refused — parity with everyRaceFreeFileWriteropen) andO_NONBLOCK(a planted reader-less FIFO fails withENXIOimmediately instead of hangingopen()before theLOCK_NBfail-fast is reached). Scope note:flockserializes same-host runs on a local filesystem — two machines exporting into one cloud-synced folder are not coordinated. compose_email/create_draftno longer degrade silently to the wrapped-body legacy path (#237). The 2026-07-09 "cite-block regression" report was not a code regression: every call that day carried a customfrom_address, which routes to the legacy AppleScript injection by design (mailto: composes from the default account only; the verified sender-popup is pending #219) — and nothing disclosed it. Three disclosure layers close the gap: (1) legacy-path result strings now append a[legacy path — …]suffix naming the reason and the mobile-quoted-rendering consequence (the historicalDraft created successfully/Email sent successfullyprefixes are unchanged for prefix-parsing callers); (2) a newwarnMailtoIneligiblestderr line fires when the mailto path was never attempted (sibling of the existing tried-and-failedwarnMailtoFallback); (3) thecompose_email/create_drafttool descriptions and thefrom_address/formatparameter descriptions state the wrapper-free eligibility conditions and thefrom_addressconsequence up front, pinned by a newComposeDisclosureGuardTestssource guard. NewmailtoIneligibilityReason()names the reason;shouldUseMailtoCompose()is now a thin wrapper over it so the routing decision and the disclosed reason can never disagree (matrix-tested). The clean-body recipes are documented split by audience: the sender recipe (omitfrom_address+ switch sender manually in the compose window) in the README Accessibility section; the two-step manual attach recipe for CJK paths (see #220) in a local.claude/rules/compose-wrapper-free.mddiscipline note (gitignored, same pattern as the #221 note — the enforceable part ships as the guard test + schema text).reply_email/forward_emailno longer degrade silently to the wrapped-new-body legacy path (#229). The #218 clean paste path routes to the legacy injection (which wraps the NEW body in<blockquote type="cite">; the quoted original's cite block is the legitimate structure) for markdown/html, without Accessibility, via theCHE_MAIL_DISABLE_PASTE_REPLYhatch, or on GUI failure — previously the ineligible branch was fully silent and the tried-and-failed branch was stderr-only, with no result-string disclosure in either. Mirrors compose's #237 disclosure layers for the reply/forward family: (1) legacy-path result strings append a[legacy path — …]suffix naming the reason (historicalReply sent successfully/Reply saved as draft/Email forwarded successfullyprefixes unchanged; a bodyless forward — already wrapper-free — never gets a suffix); (2) a newwarnPasteReplyIneligiblestderr line covers the never-attempted branch (sibling ofwarnReplyForwardPasteFallback); (3) thereply_email/forward_emailtool descriptions state the clean-path eligibility conditions, pinned by a newReplyForwardDisclosureGuardTestssource guard (condition-level asserts). NewpasteReplyForwardIneligibilityReason()names the reason;shouldUsePasteReplyForward()is now a thin wrapper over it (consistency matrix-tested). Echoed GUI error text is clamped to one bounded line. The #218 GUI paths themselves are unchanged.get_account_info/list_mailboxesaccept anaccount_idescape hatch + resolve email-formaccount_name(#202). Both read tools keyed account selection on the name with noaccount_idescape hatch — so a caller holding an email-formaccount_name(the formlist_accounts/search_emailsemit) failed:get_account_infofell to the AppleScript path's structural-1728, andlist_mailboxeswas worse — the SQLite reader's name→UUID lookup found nothing and silently dropped its WHERE clause, returning every mailbox across every account. Both tools now advertise an option...
v2.17.0
Fixed
compose_email/create_draftno longer wrap the body in<blockquote type="cite">(#175). Mail.app applies itsApple-Mail-URLShareWrapperClass/blockquote type="cite""inserted content" wrapper to any AppleScript-injected outgoing-message body (content:/set content/set html content) at MIME-serialization time. Desktop hides it with an inlineborder-left-style:none, so the sender saw nothing wrong — but many mobile clients honor thecitesemantics and rendered the user's own new text as quoted content. Runtime testing disproved every "strip the wrapper after injection" fix (reading the live outgoing message'shtml content→ AppleScript -1723; re-setting clean HTML → re-wraps; editing the saved.emlx→ overwritten on send). The wrapper-free fix routes compose/draft through Mail's native compose pipeline via amailto:hand-off (which does not wrap), then drives save/send and attachments with locale-independent keyboard shortcuts (⇧⌘A attach, ⇧⌘G go-to-folder, ⌘S save, ⇧⌘D send — no hardcoded localized menu names, avoiding the #174-class trap). A window-count delta guards dispatch so a keystroke never fires into the wrong window.
Added
check_accessibilityMCP tool (#175). Reports whether Accessibility (GUI-scripting) is granted viaAXIsProcessTrusted()— the permission the wrapper-free compose path needs (separate grant from Full Disk Access /check_fda). The--setupwindow gains an Accessibility row alongside Full Disk Access / Automation.CHE_MAIL_DISABLE_MAILTO_COMPOSEenv escape hatch — set to1/true/yesto force the legacy AppleScript injection path (e.g. heavy/unattended automation where a briefly-visible compose window is unacceptable).CHE_MAIL_MAILTO_WINDOW_DELAY/CHE_MAIL_MAILTO_STEP_DELAYtune the GUI-chain timing (0–10s, like the #64 attachment delays).
Notes
- The mailto path is used only when:
formatisplain, a subject is present, Accessibility is granted, no customfrom_address, and the env hatch is off. markdown/html bodies, custom sender, empty subject, no Accessibility, or any GUI-step failure gracefully fall back to the legacy path (which works but wraps the body) with a one-line stderr warning — never a silent failure or refused send. Clean-body compose forreply_email/forward_emailand for a custom sender are tracked as follow-ups (#218, #219). - GUI-robustness hardening (6-AI verify — Devil's-Advocate + Codex cross-model): dispatch locates the compose window by title (= subject) and raises it before ⌘S/⇧⌘D so the keystroke can't land on a window the user opened during the delay (and hard-errors → fallback if our window never opened —
activatecould otherwise inflate a bare window count); attachments add a drain delay + a target-window "no open sheet" check so ⇧⌘D can't fire before the File▸Attach panel closed; the GUI section is wrapped so a failure closes the abandoned compose window before the legacy fallback runs (no orphaned compose / duplicate); the user's clipboard is preserved at full fidelity viaNSPasteboard(not a text-only AppleScript round-trip); the URL builder uses an explicit ASCII unreserved set; an over-long body (> ~8000 char URL) falls back rather than risk truncation; and the gated live test now reads the saved.emlxto assert the body is wrapper-free. Residual limitations (inherent to GUI automation, documented not hidden): attachment binding is gated by a drain + panel-closed signal rather than per-file completion polling, and a successful ⇧⌘D send cannot be programmatically confirmed — both degrade to the legacy fallback on detectable failure.
v2.16.0
Developer ID signing + notarization so Full Disk Access survives version bumps — closes #211. The released binary was ad-hoc signed, so macOS TCC keyed the FDA grant to its cdhash and every release invalidated it; users had to re-add the binary to the Full Disk Access list after each upgrade, and SQLite-only features (#208 projection, export_emails_markdown) silently broke until they did. Adds scripts/sign-and-notarize.sh (adapted from che-ical-mcp; Full Disk Access is not a requestable entitlement, but controlling Mail.app via Apple events under a hardened runtime requires com.apple.security.automation.apple-events, so the entitlements file carries that key — without it a signed build would break all Mail AppleScript control, a regression the verify Codex cross-model lens caught), a Makefile (install-signed for a stable FDA grant on the maintainer's own machine without waiting for notarization; release-signed for distribution), an empty Sources/CheAppleMailMCP/Entitlements.plist, and a universal (arm64 + x86_64) build in scripts/release.sh behind a fork-friendly SKIP_CODESIGN / REQUIRE_CODESIGN gate. The FDA-denied failure is now loud and actionable: a shared FullDiskAccessHelp carries the Privacy_AllFiles deep-link plus the exact running-binary path into EnvelopeIndexReader, the init-failure log, and the projection / export_emails_markdown errors (no more "grant it to the terminal application"). Test suite 665 (50 env-skipped), 0 failures. The first FDA grant is still manual — signing makes it permanent, not automatic.
#214 — the FDA-denied error names the responsible process, not the binary. macOS TCC grants Full Disk Access to the responsible process (the app that launched the server — the terminal for Claude Code, or Claude Desktop), not to the binary itself; #211's message pointed at the binary, so users granted the wrong target and stayed denied. The message now leads with the responsible-process candidates (terminal / Claude Desktop) and keeps the binary as the direct-launch fallback. An auto-resolver via the libquarantine SPI responsibility_get_pid_responsible_for_pid was attempted and then removed — verification (the 6-AI ensemble's adversarial lens) proved it returns self for terminal- and CLI-launched processes, so there is no reliable in-process API to name the exact app; the message names candidates rather than guessing.
#213 — proactive Full Disk Access onboarding. CheAppleMailMCP --setup opens a SwiftUI window with live FDA status (re-checked on a timer; flips to "Ready ✅" the moment you grant), an on-demand Automation check, and "Open Full Disk Access settings" / "Copy binary path" buttons. CheAppleMailMCP --check-fda prints status headlessly and opens the pane when access is denied. A check_fda MCP tool reports the same status to Claude on demand. All three are gated behind their flag so the stdio MCP path is byte-for-byte untouched; AppKit/SwiftUI are linked but the runloop only starts under --setup. The functional FDA probe (fopen of the Envelope Index) discriminates granted / denied (EPERM/EACCES) / no-mail-data / undetermined, and the no-mail-data case honestly notes that a denial can hide ~/Library/Mail (so an FDA-denied user isn't told the opposite of the fix). None of this removes Apple's single manual FDA toggle — Apple puts FDA in the manual-only bucket — but it makes "what do I do?" obvious with live feedback.
Test suite 677, 0 failures. The --setup GUI is verified to launch (manual smoke-test; a window can't be CI-tested). Version is a suggestion; the maintainer picks the actual tag at release time.