Skip to content

Releases: PsychQuant/che-apple-mail-mcp

v2.24.0

Choose a tag to compare

@kiki830621 kiki830621 released this 25 Jul 16:10

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'd NSAppleScript.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_emails stayed fast and reliable throughout (it reads the Mail Envelope Index via MailSQLite and never issues an Apple event), while get_special_mailboxes / get_email / reply_email each 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-TCC open_mailto compose 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 private runGuarded<T> runs the execution on a detached thread and waits on a DispatchSemaphore with a bounded deadline — 45s by default, comfortably under the ~120s client idle timeout and far above normal ~1–2s Mail IPC, overridable through a new scriptTimeout test seam — throwing the new MailError.scriptTimedOut(seconds:) on expiry instead of hanging; because NSAppleScript is 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-prompting AutomationStatus.probe() (#293) to fail fast before blocking — .denied throws reusing AutomationHelp.guidance verbatim (#288 remains the single source of that text) and .targetNotRunning tells 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. New MailControllerTimeoutTests drive the guard through the scriptRunnerOverride seam — a runner that sleeps 5s returns .scriptTimedOut in 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

Choose a tag to compare

@kiki830621 kiki830621 released this 22 Jul 01:03

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 NSAppleScript the popup's menu can evaluate EMPTY right after click (the identical matcher passes under osascript — same class as the documented in-process front window unreliability), so the for-in enumerated zero items and mis-reported SENDERPOPUP: no From account exactly matches. The script now polls (bounded 12×0.3s) for count 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_draft with from_address returned Draft 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-independent AXIdentifier (popup_priority / popup_from / popup_signature), so the From popup is now identified by AXIdentifier = "popup_from" — deterministic, #174-safe, and structurally immune to a signature-popup mix-up (the _fromPopupCount value-@ 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 renders Display 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 about senderMatches against an assumed Name <addr> form, so branches _label is _addr and _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: senderMatches gains a separator-agnostic branch — the addr is the LAST space-delimited token of the label (isSimpleAddrSpec gates the addr to no-whitespace upstream, so a simple addr is always that final token), compared with exact is (anti-spoof preserved: a … – notche@x label's last token is notche@xche@x; a …@as.edu.tw.evil.com last 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 got SENDERPOPUP: 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 #296create_draft returned [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_draft with a custom from_address fell to legacy with AppleScript error (-2700): … Can't get item 2 of every pop up button … Invalid index. — the popup scan's repeat … in (pop up buttons of _w) re-fetches item N each pass, and a compose window whose AX tree is still settling (or a degraded/slow Mail) throws from that item-fetch OUTSIDE the inner try, 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 original exit 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 _w inside the try), so an unstable/vanished index is skipped → the scan fails closed to a clean SENDERPOPUP → 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

Choose a tag to compare

@kiki830621 kiki830621 released this 21 Jul 16:22

Added

  • New check_automation tool — the third TCC probe axis (#293, formalizing the #288 Strategy's deferred optional; blueprint = che-ical-mcp's EventKit probe/setup pattern). check_fda and check_accessibility covered two axes; Automation had only the after-the-fact -1743 guidance. AutomationStatus.probe() wraps AEDeterminePermissionToAutomateTarget(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 — reuses AutomationHelp.guidance verbatim, 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_address now 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 in blockquote 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-dispatch SENDERPOPUP sentinel, 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 whose set sender guarantees the right account — a send never leaves from the wrong account. Eligibility flip: hasCustomSender no longer disqualifies; without Accessibility the reason names the popup mechanism it needs. Result string discloses the verified sender; tool descriptions / compose-wrapper-free rules 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 (the senderMatches handler tests a menu label by exact <addr> suffix — is _addr or ends with "<addr>" — never extraction or substring contains — a contains check would have verified notme@x / me@x.tw as me@x and 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 + shouldUseMailtoCompose doc were de-staled (no longer say "pending #219"). A second cross-model round (Codex R3 BLOCKING) closed two more edges: a quoted-local-part from_address ("prefix<foo"@evil) could suffix-match a crafted evil account label → a new isSimpleAddrSpec eligibility gate routes any non-simple custom sender (quote/angle-bracket/whitespace/multi-@) to legacy, so senderMatches is 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 a saving no on 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-try guard, 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, so compose_email keeps 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 (displayNameFillViable requires a display-name-free cc AND bcc), the To field is always visible + default-focused. A defense-in-depth guard in composeViaMailto throws 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 raw AppleScript error (-1743): Not authorized to send Apple events to Mail passed 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 single MailError.scriptFailed rendering sink (every AppleScript-backed tool funnels through it — the FullDiskAccessHelp precedent, 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 shell osascript controlled 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 to open_mailto (#287, cite-block-free). All other error codes stay byte-identical passthrough (pinned by test). 998→1001 tests.

Changed

  • open_mailto now goes through LaunchServices — zero Automation TCC (#287). The old implementation drove tell application "Mail" / mailto — an Apple event, so the nominally "just open a URL" tool required Automation TCC and died with -1743 on an unauthorized machine, exactly where a zero-TCC escape hatch is needed (session-verified: shell open "mailto:..." worked while both MCP compose paths failed). Now NSWorkspace.shared.open posts the URL through LaunchServices: no Apple events, no TCC, and the mailto compose window is inherently cite-block-free (#175). A mailto: scheme guard replaces the old accept-anything behavior (an https: URL would have silently opened a browser); open returning 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

  • atCount counts 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@c counted atCount==1 and passed with the masked @ intact (Mail-level invalid, no mis-send; the masked-only-@ case was already fail-safe at atCount==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...
Read more

v2.22.0

Choose a tag to compare

@kiki830621 kiki830621 released this 20 Jul 08:13

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 by draft_id (from list_drafts) or an EXACT subject_match (never substring — misfires would delete the wrong draft; optional account_name/account_id scoping), create the replacement through the full create_draft mechanism (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 reports deleted_old: false with 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 under create_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_id validation is strict ASCII [0-9] at both the handler and controller (Unicode digits rejected); parseDraftRows refuses empty/non-numeric ids so nothing malformed can reach the delete script; and the delete script separates the not-found lookup (→ recognizable 9276) from the delete verb, so a real deletion failure surfaces its true cause as deleted_old: false instead of masquerading as "not found". Selector validation is presence-based (verify R2): an explicitly empty subject_match or draft_id is a provided-but-invalid value with its own parameter error — never silently treated as absent (which would have let draft_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 under considering case for exact Swift-== parity (AppleScript equality is case-insensitive by default — a bare whose subject is could 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: 123 type confusion is a parameter error, never silently treated as absent — unit-tested without the transport); and digit validation is byte-level ASCII (isASCIIDigits over 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 change add-update-draft-tool (proposal / design / draft-update spec 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_drafts now returns each draft's numeric id (#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 by parseDraftRows, which throws on any length mismatch or malformed id instead of silently truncating. Subject-only consumers are unchanged; the id feeds update_draft.draft_id and delete_email.id directly, 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 — parseRecipient returns such strings whole as bare addresses (brackets intact), the single-@ count passed, and the malformed recipient landed in the AppleScript address as a Mail-level-invalid (no mis-send, but silently accepted). The guard now uses a quote-aware containsUnquotedAngle scan: 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 with unescapeQuotedPairs (#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@x rejected — and the "a<b>@x shape 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\>@x rejected — the escaped branch consuming \< silently was R2's catch; the properly closed "a\<b\>"@x stays 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_email no 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; resolveEmlxPath transparently fell back to it, readEmail parsed 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: resolveEmlxPathDetailed exposes an isPartial bit (the legacy String? API is a byte-compatible wrapper; the attachment extractor's own partial handling is untouched), EmailContent carries fromPartialEmlx, and get_email routes a partial-with-absent-body result to the logged AppleScript fallback — whose content/source read doubles as the download nudge the direct file read cannot perform. If the AppleScript read still carries no body, the result gets body_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: false or absent, never true — absence means "no partial-file evidence of a missing body", not a downloaded-body guarantee; conversely false is 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_batch annotates 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 single get_email. **Sco...
Read more

v2.21.0

Choose a tag to compare

@kiki830621 kiki830621 released this 16 Jul 02:03

Added

  • get_special_mailboxes per-account mode now also returns a <type>_path field
    for each present special mailbox — the FULL mailbox path (drafts_path
    "[Gmail]/草稿") matching the mailbox field search_emails returns, 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 _path key. 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 / bcc accept RFC 5322 mailbox form (Name <email>) — recipients can display a person's name (#251). Previously the recipient parameters were documented as bare addresses; a Name <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 new parseRecipient boundary 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 benefits reply_email's cc_additional and forward_email's to, which share the fragment builder); and display-name recipients on compose_email / create_draft become a named mailto-ineligibility (the mailto URL carries addr-spec only per RFC 6068) — the same disclosed legacy-path trade-off as a custom from_address: names shown natively, body wrapped + disclosed; with require_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-angle Alice <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_email recipient parity with #251 (#263). The shared boundary validation relaxed by #251 let Name <email> reach the redirect script builder, which passed the whole string as the AppleScript address (Mail's display-name fallback — no mis-send, but malformed-downstream). The builder now delegates to the shared name-aware recipientFragment: 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 validateEmailAddresses boundary now rejects a recipient that parseRecipient could 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 the atCount check and landed whole in the AppleScript address. 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 RecipientDisplayNameTests seam-reset sites migrated from a detached defer { Task { … } } (which could reset the process-wide MailController.shared singleton after the next test started) to addTeardownBlock, which XCTest awaits.

v2.20.0

Choose a tag to compare

@kiki830621 kiki830621 released this 15 Jul 22:45

Added

  • download_if_missing — opt-in best-effort fetch for a server-side-only attachment (#272). #238 taught save_attachment to 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 the not_downloaded / generic -10000 path AND the caller set the flag, the tool nudges Mail to materialize the message — reading source 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-attachment download verb (downloaded is read-only, the only command is save), 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 the not_downloaded guidance, 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 (per r-must-direct-db). New AttachmentDownloadScriptBuilder (pure trigger-script builder + gate + DownloadRetryPolicy) and MailController.saveAttachmentRetryingForDownload; 13 unit tests (script text, gate, policy, deterministic retry loop via the injected script-runner seam). The live question — does source-read actually pull a not-downloaded attachment down? — is a pre-merge gate (no not-downloaded specimen was available to live-test; source is confirmed a valid message property 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 precise hasPrefix success 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

Choose a tag to compare

@kiki830621 kiki830621 released this 15 Jul 03:26

Added

  • require_wrapper_free parameter for compose_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 (custom from_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 shared unknownSendStateError helper now also used by the #242 router hook. Spectra change require-wrapper-free-param adds the normative message-composition clause; pinned by RequireWrapperFreeTests (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_mailboxes per-account resolution now includes inbox (#249). Inbox was conservatively deferred in #179 (referenced as inbox, 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 inbox exposes 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 change per-account-inbox lifts the spec's deferral clauses.

Fixed

  • compose_email / create_draft with 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 native POSIX file attachment 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 with require_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_email no 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 _dispatched flag 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 (⌘S saveAsDraft) keeps the plain fallback and its post-try window close unchanged. Additionally, MailController gains actor-isolated test seams (script-runner + eligibility overrides; production never sets them) enabling production-site behavioral tests: MailControllerSeamTests drives 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; ReplyForwardSendStageTests adds an end-to-end refuse-branch test (post-dispatch reply failure → exactly one script run, no legacy re-send).

  • savable:false false-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 (AppleScript downloaded=true). The on-disk part directory equals the Envelope Index attachment_id, so both the savability probe and save_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.emlx part whose body was never fetched carries X-Apple-Content-Length placeholder headers with an empty body — a detectable state that previously surfaced as the misleading generic "Attachment not found" / opaque -10000. list_attachments items with savable:false now carry savable_reason ("not_downloaded" vs "not_extractable"), and save_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_email no 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 a POSTDISPATCH sentinel: 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 its close saving no for 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 _dispatched flag 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 AppleScript starts with cleanup 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. The routeWrapperFreeCompose router (#241) gains shouldFallback/mapNoFallbackError hooks (defaults preserve every other site's behavior), pinned by SendStageNoRefallbackTests (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 canonical batch_export_emails_markdown. A new ToolCountCensusGuardTests (8 tests) asserts every count claim AND the exact tool-name set of both README tables against defineTools(), so a future tool addition fails the suite until the docs are synced.

  • batch_export_emails_markdown frontmatter date now preserves the original Date header'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-sent 16:49:57 +0800 email carried date: …T08:49:57Z in frontmatter while the body Date: 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...

Read more

v2.18.0

Choose a tag to compare

@kiki830621 kiki830621 released this 13 Jul 16:02

Added

  • batch_export_emails_markdown — the batch-export tool's canonical name; export_emails_markdown becomes 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-email get_email because 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 with DEPRECATED — renamed to batch_export_emails_markdown and 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 to batch_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 + a summary search projection + batch message_id (#177). Bulk archiving cost O(corpus) tokens because search_emails inlined full per-hit JSON and get_emails_batch pulled content into context before writing. Rather than a new monolithic archiver, the already-shipped search 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_markdown accepts an optional skip_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 as output_dir; matching emails are skipped (manifest status: "skipped" + a skipped count) rather than rewritten, so a re-run only writes new mail (missing/unreadable file → no skips). The manifest already carries message_id per item, so a caller can reconcile a Message-ID-keyed index from the (small) manifest without re-fetching. (2) search_emails projection gains a summary value returning triage objects with only id/date/sender/subject/mailbox (between ids and full; SQLite-only, dedup: logical-compatible since it does no recipient subquery). (3) get_emails_batch per-email results now include message_id, parity with single get_email.
  • export_emails_markdown adds a body_type: text|html frontmatter field (#199). For an html-only email, textBody is 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 emits body_type (text when a plain body exists, html for html-only) via the renderer's sanctioned extraFrontmatter extension 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_REPLY env escape hatch (#218) — set to 1/true/yes to 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 the CHE_MAIL_MAILTO_* GUI-timing knobs.

Fixed

  • Concurrent export_emails_markdown runs to the same output_dir no longer race (#236). Two overlapping run() calls could both snapshot the directory before either wrote (defeating #232's cross-call -N collision guard), derive the same (date,slug) filename, and silently last-rename-wins overwrite each other — with the deterministic .idd200.tmp temp names additionally producing spurious cross-run errors, and the whole-run manifest write being last-wins. A new ExportDirLock (advisory flock on output_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) since flock is per open-file-description; advisory-only by design — both writers we control are this binary (same threat-model assumption as #197/#200). run() is now throws; the manifest schema is unchanged; RaceFreeFileWriter (shared with save_attachment) and the idempotent re-export / -N uniquify 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 carries O_NOFOLLOW (a planted symlink at the fixed .export.lock path is refused — parity with every RaceFreeFileWriter open) and O_NONBLOCK (a planted reader-less FIFO fails with ENXIO immediately instead of hanging open() before the LOCK_NB fail-fast is reached). Scope note: flock serializes same-host runs on a local filesystem — two machines exporting into one cloud-synced folder are not coordinated.
  • compose_email / create_draft no 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 custom from_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 historical Draft created successfully / Email sent successfully prefixes are unchanged for prefix-parsing callers); (2) a new warnMailtoIneligible stderr line fires when the mailto path was never attempted (sibling of the existing tried-and-failed warnMailtoFallback); (3) the compose_email / create_draft tool descriptions and the from_address / format parameter descriptions state the wrapper-free eligibility conditions and the from_address consequence up front, pinned by a new ComposeDisclosureGuardTests source guard. New mailtoIneligibilityReason() 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 (omit from_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.md discipline note (gitignored, same pattern as the #221 note — the enforceable part ships as the guard test + schema text).
  • reply_email / forward_email no 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 the CHE_MAIL_DISABLE_PASTE_REPLY hatch, 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 (historical Reply sent successfully / Reply saved as draft / Email forwarded successfully prefixes unchanged; a bodyless forward — already wrapper-free — never gets a suffix); (2) a new warnPasteReplyIneligible stderr line covers the never-attempted branch (sibling of warnReplyForwardPasteFallback); (3) the reply_email / forward_email tool descriptions state the clean-path eligibility conditions, pinned by a new ReplyForwardDisclosureGuardTests source guard (condition-level asserts). New pasteReplyForwardIneligibilityReason() 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_mailboxes accept an account_id escape hatch + resolve email-form account_name (#202). Both read tools keyed account selection on the name with no account_id escape hatch — so a caller holding an email-form account_name (the form list_accounts / search_emails emit) failed: get_account_info fell to the AppleScript path's structural -1728, and list_mailboxes was 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...
Read more

v2.17.0

Choose a tag to compare

@kiki830621 kiki830621 released this 24 Jun 07:00

Fixed

  • compose_email / create_draft no longer wrap the body in <blockquote type="cite"> (#175). Mail.app applies its Apple-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 inline border-left-style:none, so the sender saw nothing wrong — but many mobile clients honor the cite semantics 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's html 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 a mailto: 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_accessibility MCP tool (#175). Reports whether Accessibility (GUI-scripting) is granted via AXIsProcessTrusted() — the permission the wrapper-free compose path needs (separate grant from Full Disk Access / check_fda). The --setup window gains an Accessibility row alongside Full Disk Access / Automation.
  • CHE_MAIL_DISABLE_MAILTO_COMPOSE env escape hatch — set to 1/true/yes to 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_DELAY tune the GUI-chain timing (0–10s, like the #64 attachment delays).

Notes

  • The mailto path is used only when: format is plain, a subject is present, Accessibility is granted, no custom from_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 for reply_email / forward_email and 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 — activate could 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 via NSPasteboard (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 .emlx to 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

Choose a tag to compare

@kiki830621 kiki830621 released this 19 Jun 12:40

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.