Skip to content

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 optional account_id and thread the same resolveAccountIdForTool email→UUID upgrade #191 gave the action tools; the reader's listMailboxes filters by UUID directly when account_id is supplied, and an account_name that resolves to no account now throws account not found — use list_accounts (a new MailSQLiteError.accountNotResolvable) instead of silently returning all mailboxes. Name-mode behaviour for a valid display name is byte-identical (the AppleScript selector uses the existing resolveAccountRef). Reader-side scoping/throw is covered by an FDA-gated MailSQLiteTests case; schema + handler-wiring are pinned in the default suite.
  • Deterministic search ordering on same-second date_received ties (#209). search_emails ordered only by m.date_received (second granularity), so rows sharing the same second had unspecified relative order — making which row sits on the limit/limit+1 truncation boundary nondeterministic (surfaced by the #208 verify). The SQLite reader now adds a m.ROWID secondary sort key (and MIN(m.ROWID) for the dedup GROUP BY branch) to all three search ORDER BY clauses (searchPage, searchIds non-dedup + dedup), giving a total, deterministic order. The result schema is unchanged — only intra-tie ordering becomes stable.
  • export_emails_markdown now populates the in_reply_to frontmatter field (#198). It was hard-coded to "", so reply threading via that field didn't work. EmailContent now carries an inReplyTo field parsed from the .emlx In-Reply-To header (via the existing RFC 822 header parser), and the exporter threads it into the (already-present) in_reply_to frontmatter field. Absent header → "" (unchanged behavior).
  • reply_email / forward_email no longer wrap the new body in <blockquote type="cite"> (#218). Same wrapper bug as #175, but the fix is structurally different: a reply's quoted original legitimately belongs in a blockquote type="cite" — only the new reply/forward text shouldn't be wrapped, and mailto: (the #175 fix) can't thread a reply or carry the quoted original. The clean path drives Mail's native reply / reply all / forward verb (Mail builds the quoted original itself — correct cite-blockquote + In-Reply-To / References threading headers) and pastes only the new body at the cursor via a System Events clipboard paste — never set content / set html content for the new text. So the quoted original stays a proper quote and the user's new text is clean (mobile clients honoring cite no longer render it as quoted). The reply/forward window is identified by window-id delta (the localized Re: / Fwd: title prefix is never matched); its real title is read from Mail and bridged into the Accessibility context to raise the right window before the paste/dispatch keystrokes, and on-error cleanup closes only the window we created (by id) so a pre-existing user draft is never discarded.
  • search_emails AppleScript fallback now honors field / date_from / date_to (#194). The SQLite primary path filters by field + date bounds, but the AppleScript fallback (used without Full Disk Access / on EWS accounts / on SQLite init failure) silently dropped all three — every fallback query was a hardcoded subject contains … or sender contains … with no date bound, so the same call returned different results than the search_emails schema promises. The fallback now builds its whose predicate from a new pure SearchEmailsScriptBuilder: subject / sender / any use a fast whose substring match; recipient (which AppleScript's whose can't express over the recipient object-lists) is matched in-loop against to / cc recipient address + name; date bounds are added via locale-independent current date + component setters (never a region-parsed date "…" literal), inclusive both ends to mirror the SQLite >= from AND <= to. Documented residual: under the fallback, field=any covers subject+sender only — recipient-only matches need the SQLite index (a one-line stderr note is emitted when this path runs), which keeps the default search fast rather than enumerating the whole mailbox.

Security

  • export_emails_markdown output path: home-relative denylist + opt-in strict allowlist (#197). The write-safety validator accepted anything under $HOME (the dispatcher passed no roots), with only a system-level denylist — so an LLM-driven (prompt-injection-realistic) output_dir of ~/Library/LaunchAgents, ~/.ssh, ~/.zshrc, ~/bin/<wrapped-binary> etc. passed. AllowedRootsValidator now also enforces a home-relative denylist (~/Library, ~/bin, and ~/.ssh / ~/.config / ~/.gnupg / ~/.aws / ~/.docker / ~/.kube / ~/.local / shell rc dotfiles), applied after ..-canonicalization and regardless of mode (defense-in-depth, like the existing system denylist) — an explicit list, not a blanket "all dotfiles" rule, so a legitimate ~/.mailarchive isn't surprised. Default behaviour is unchanged for normal targets (~/Documents/… still works); a deployment can set CHE_MAIL_EXPORT_ALLOWED_ROOTS (:-separated absolute paths) to opt into a strict allowlist — those roots then replace home (deny-by-default). The denylist is inherently incomplete (the documented residual); the strict-allowlist config is the escape hatch.
  • export_emails_markdown writes are now race-free against a symlink swap (validate→write TOCTOU) (#200). The validator resolved symlinks at validation time, but the attachment dirs were createDirectory'd and the files written later — a concurrent local actor planting a symlink in that window could redirect the write outside the validated tree. The export now writes through a new RaceFreeFileWriter that descends from the validated output_dir file descriptor with openat/mkdirat + O_NOFOLLOW (a symlinked path component fails ELOOP/ENOTDIR → refused) and creates each file via a sibling temp (O_CREAT|O_EXCL|O_NOFOLLOW) + renameat — so no path component is re-resolved through a newly-planted symlink, a symlink at the target leaf is replaced (never followed), and the prior .atomic overwrite semantics (idempotent re-export) are preserved. Attachment byte extraction moved to a new EmlxParser.attachmentData(…); the save_attachment tool path is unchanged (it now extracts via the same method, then atomic-writes). LOW severity (needs a co-resident hostile local process), so not urgent, but closed properly.

Internal

  • Source-invariant guard against the whose content contains full-body scan (#221). A new NoContentContainsScanGuardTests scans Sources/CheAppleMailMCP/ and fails if any shipped read path uses the whose content contains AppleScript predicate — an O(corpus) query that deserializes every message body and OOM-crashes Mail.app on a large (~80k-email) mailbox (the incident an ad-hoc query caused during #218 spike verification). Shipped search_emails already uses subject/sender matching only; this test pins that invariant so a future read path can't silently reintroduce the footgun. (The companion discipline note lives in the gitignored local .claude/rules/r-must-direct-db.md; un-tracking is why it ships as a test, not a committed doc.)

Notes

  • The clean reply/forward path is used only when: format is plain, Accessibility is granted, and the env hatch is off (for forward_email, additionally only when a new body is provided — a forward with no body injects nothing, so it is already wrapper-free). markdown/html bodies, no Accessibility, the env hatch, or any GUI-step failure gracefully fall back to the legacy injection path (which works but wraps the new body) with a one-line stderr warning — never a silent failure or refused send. reply_all, extra cc (#34), attachments (#60), save_as_draft, threading, and the native quoted original are all preserved (set via the native verbs / object model — only new-body insertion changed). save_as_draft on the clean path briefly opens the reply window (it needs a window to paste into); the legacy fallback still covers the no-window draft.
  • Window-identity = front-window-id guard (6-AI verify — Logic + Devil's Advocate; live-verified). The id-delta uniquely identifies the reply/forward window in the Mail object model, but the Accessibility keystroke layer has no view of Mail's window id. The #175 mailto path bridges to Accessibility by window title — which does not work here: Mail's reply/forward compose windows expose an empty name (live-verified — a reply window's title is ""), so a title match would refuse the common empty-title case (falling back to a wrapped body) or match the wrong same-titled window. Instead, each keystroke phase is gated in the Mail context on id of front window ∈ the id-delta set — i.e. our window is the frontmost Mail window — then keystrokes go to that frontmost window. A stolen focus → id mismatch → legacy injection fallback (never a wrong-window send). Residual (inherent to GUI automation, documented not hidden): a sub-second TOCTOU between the Mail-side check and the Accessibility keystroke remains — same class as the #175 mailto residual; a detectable change degrades to the legacy fallback. The clean-path save_as_draft also closes its own compose window after ⌘S (the send ⇧⌘D closes itself), so quiet drafts don't accumulate windows.
  • Verification discipline (#221): the gated live test (CHE_MAIL_LIVE_TEST=1) reads the saved reply draft's .emlx off disk to assert the new body is free of the Apple-Mail-URLShareWrapperClass wrapper while the native cite-blockquote quote is retained above it. It finds the source message and cleans up by metadata / id-delta only — never a whose content contains full-body scan, which O(corpus) loads every message body and OOM-crashes Mail on a large mailbox (the regression that opened #221).