You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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_emailsprojection 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 throwsaccount 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 .emlxIn-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-independentcurrent 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_dirfile 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 emptyname (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).