v2.19.0
Added
require_wrapper_freeparameter forcompose_email/create_draft— clean failure instead of a silently wrapped draft (#239). #237's disclosure tells the caller a wrapped-body draft was produced — after the fact. For callers where a clean body is a hard requirement, the new opt-in boolean (default false, fully backward-compatible) turns that into fail-fast: an ineligible call (customfrom_address/ non-plain format / empty subject / no Accessibility / env hatch) errors out with the named reason and every actionable alternative, creating nothing; an eligible call whose GUI step fails propagates the error with no legacy fallback — and a send-stage (POSTDISPATCH) failure maps to the same canonical unknown-send-state guidance as the default path (check Sent/Outbox, do NOT retry; verify-round REQUIRED fix — a raw sentinel token would invite an auto-retrying caller to re-send), via a sharedunknownSendStateErrorhelper now also used by the #242 router hook. Spectra changerequire-wrapper-free-paramadds the normative message-composition clause; pinned byRequireWrapperFreeTests(7 tests, including production-site strict-branch tests through the #254 seams: strict+ineligible runs zero scripts; strict clean-path failure runs exactly one). Complements #219 (which will let custom senders use the clean path at all).get_special_mailboxesper-account resolution now includesinbox(#249). Inbox was conservatively deferred in #179 (referenced asinbox, not<X> mailbox, with unverified per-account child semantics); the spec made lifting it conditional on a live multi-account check. That check ran 2026-07-14 against 7 configured accounts:every mailbox of inboxexposes a per-account child on all of them, and an Exchange account proves the real name localizes (收件匣) — so the resolution carries real value. One list entry appended (the count-driven builder/parser needed no other logic); Spectra changeper-account-inboxlifts the spec's deferral clauses.
Fixed
-
compose_email/create_draftwith a non-ASCII attachment path no longer hang in the GUI attach flow (#220). The wrapper-free mailto path attaches via the go-to-folder sheet (⇧⌘G + clipboard paste), which hung deterministically on CJK/fullwidth paths (live repro on v2.17.0 — the sheet never accepts the input, and the panel-closed proxy can't see it). Non-ASCII attachment paths are now a named mailto-ineligibility: the call routes to the legacy path, whose nativePOSIX fileattachment handles any path — so the worst case changes from a frozen compose window to a working draft with a wrapped body + the standard[legacy path — …]disclosure (and withrequire_wrapper_free: true(#239), a clean refusal naming #220 and the ASCII alternative). ASCII-only paths keep the clean path unchanged. Note the conservative breadth: ANY non-ASCII path — including accented-Latin (é, ü) — is swept to the legacy path, because narrowing the predicate would require live-reproducing the freeze to enumerate which ranges hang; the asymmetric cost (needless legacy = working draft + disclosure vs. a missed hang = frozen Mail.app) favors over-blocking. Residual (documented): making the GUI attach flow itself CJK-safe would need live GUI iteration on the sheet's behavior; this fix removes the hang, not the wrapped-body trade-off. -
reply_email/forward_emailno longer risk a duplicate outbound send when the clean paste path fails at the send-keystroke stage (#254). The #242 POSTDISPATCH protection now covers the reply/forward family verbatim: the shared paste builder wraps the ⇧⌘D dispatch in a sentinel, a_dispatchedflag marks errors from the success-path tail (where the mail is DEFINITELY sent), the on-error window cleanup is skipped for marked errors (the compose window is the user's only evidence), and both call sites refuse the legacy re-send with an unknown-send-state error directing the user to check Sent/Outbox and the original thread. The draft path (⌘SsaveAsDraft) keeps the plain fallback and its post-try window close unchanged. Additionally,MailControllergains actor-isolated test seams (script-runner + eligibility overrides; production never sets them) enabling production-site behavioral tests:MailControllerSeamTestsdrives the real compose/createDraft/reply/forward methods with a fake runner and pins the family-correct disclosure suffix per site (closing the #241 wiring guard's family-swap blind spot, per that verify round's DA adjudication) plus the bodyless-forward no-suffix/no-probe invariant;ReplyForwardSendStageTestsadds an end-to-end refuse-branch test (post-dispatch reply failure → exactly one script run, no legacy re-send). -
savable:falsefalse-negatives from Mail's degraded on-disk attachment filenames (#183). Live-proven on message 274368: Mail writes the external attachment cache under its OWN rendition of the filename —?-substituted CJK plus whitespace drift — while our parser reads the correct MIME-header name, so the byte-for-byte name probe missed bytes that were right there (AppleScriptdownloaded=true). The on-disk part directory equals the Envelope Indexattachment_id, so both the savability probe andsave_attachment's fast path now fall back to a name-free part-directory lookup (Attachments/<rowId>/<attachment_id>/, single-file), threaded from the SQLite read. Name-based lookup stays first (older messages whose part numbering may not align). -
Not-downloaded attachments now say so (#238, direction (b)). A
.partial.emlxpart whose body was never fetched carriesX-Apple-Content-Lengthplaceholder headers with an empty body — a detectable state that previously surfaced as the misleading generic "Attachment not found" / opaque-10000.list_attachmentsitems withsavable:falsenow carrysavable_reason("not_downloaded"vs"not_extractable"), andsave_attachment(after both tiers fail) returns the actionable guidance: open the message in Mail (or set Download Attachments: All), then retry. The Tier-2 AppleScript attempt still runs first — whether it can itself trigger a server fetch is #238 (a), still open pending an uncached specimen. -
compose_emailno longer risks a duplicate outbound send when the clean path fails at the send-keystroke stage (#242). The #175-era control flow fell back to the legacy AppleScript path on ANY clean-path error — including errors thrown at or after the ⇧⌘D send dispatch, when the first mail may already be on the wire (programmatic confirmation is impossible — the #175 documented residual). The mailto compose script now wraps the send dispatch in aPOSTDISPATCHsentinel: pre-dispatch errors (window lost, lingering attach sheet) keep the safe fallback exactly as before, while sentinel-marked errors refuse the fallback and return an unknown-send-state error telling the caller to check Sent/Outbox before re-sending. The on-error cleanup also skips itsclose saving nofor sentinel errors — the compose window (if still open) is the user's only evidence and is left untouched. Verify-round hardening (Codex HIGH + DA): a_dispatchedflag additionally marks errors from the post-dispatch settle tail — which runs only on the success path, where a throw would otherwise mean a GUARANTEED duplicate (not merely unknown) — and the Swift sentinel check is prefix-only (hasPrefix), symmetric with the AppleScriptstarts withcleanup guard.create_draft(⌘S) keeps the plain fallback: a duplicated draft is visible and harmless. Rejected alternative (recorded in the diagnosis): post-hoc Sent/Outbox scanning for a same-subject message — O(mailbox) and race-prone. TherouteWrapperFreeComposerouter (#241) gainsshouldFallback/mapNoFallbackErrorhooks (defaults preserve every other site's behavior), pinned bySendStageNoRefallbackTests(7 tests: builder sentinel contract for both send/draft, sentinel detection, router refuse-branch, production wiring source guard). -
Doc/manifest tool-count census — every documented count now equals
defineTools()(51), pinned by a guard test (#248). Counts had drifted across six locations (server.json 47, mcpb/manifest.json 48×2, README_zh-TW.md 47×3, README.md section sub-counts summing 49) because each tool addition updated only some of them;check_fda/check_accessibility(#213) had never been tabled at all, and README_zh-TW.md was missing the entire Batch section (#233-era). All six locations now read 51; both READMEs gain a Diagnostics section (and zh the Batch section); two internal diagnostic strings referencing the pre-#233 tool name now name the canonicalbatch_export_emails_markdown. A newToolCountCensusGuardTests(8 tests) asserts every count claim AND the exact tool-name set of both README tables againstdefineTools(), so a future tool addition fails the suite until the docs are synced. -
batch_export_emails_markdownfrontmatterdatenow preserves the originalDateheader's UTC offset (#244). The renderer parsed the RFC 2822 zone into an absolute instant, then re-formatted with a UTC-pinned formatter — so a Taiwan-sent16:49:57 +0800email carrieddate: …T08:49:57Zin frontmatter while the bodyDate:line (correctly) said 16:49. Every non-UTC-timezone user's archive had shifted frontmatter timestamps, and index files built from them inherited the shift. The shared helper (rfc822ToISO8601, formerlyrfc822ToISO8601UTC) now extracts the numeric zone token and emits ISO 8601 with that offset (2026-07-13T16:49:57+08:00); a zero offset keeps the historicalZ, a named/missing zone falls back to UTC, an RFC 2822 obsolete trailing comment (+0800 (CST)/+0000 (UTC)— 3.2% of a 4k-email live corpus, Outlook/Exchange/Gmail styles) is stripped before parsing so those headers preserve their offset too (verify round REQUIRED fix), and an unparseable date still passes through flattened — now yielding anunknown-datefilename instead of a raw-header fragment. Because frontmatter, manifest, and the filenameYYYY-MM-DDall derive from this one helper, the filename date becomes the sender-local calendar date (consistent with the header). Two documented trade-offs: (1) plain string sorting across a MIXED-offset corpus compares wall-clock, not instants — same-offset archives (the normal case) are unaffected; (2) an email whose UTC and local dates differ (cross-midnight) gets a different filename than a pre-fix export, so a bare re-export of such an email writes a new file instead of overwriting — Message-IDskip_message_ids_pathdedup is unaffected (it never keyed on filenames). The batch-operations spec's frozen frontmatter contract is updated accordingly (Spectra changepreserve-export-date-offset).
Internal
- The #237/#229 disclosure wiring now has a runnable regression lock (#241). The clean-path-or-disclosed-legacy control flow (try wrapper-free GUI path → on ineligibility warn + on failure warn → legacy result carries the
[legacy path — …]suffix) lived inline in theMailControllersingleton actor with zero non-live coverage — deleting the wiring kept the whole suite green, the #237 silent-regression pattern one level up. The flow is extracted into a purerouteWrapperFreeCompose(ineligibilityReason:cleanPath:legacyPath:disclosure:warnIneligible:warnTriedAndFailed:fallbackReason:)router (MailtoCompose.swift) and all FOUR compose-family sites (compose_email,create_draft,reply_email,forward_email-with-body) now route through it — behavior byte-identical, pinned by a consistency test suite (WrapperFreeRouteTests: clean-success carries no suffix, tried-and-failed warns once + suffixes the clamped echo, ineligible warns once + never attempts the clean path, legacy errors propagate, clamp bounds the suffix) plus a source-scan wiring guard (exactly 4 router call sites; the inlinevar legacyReasonpattern is gone). A bodylessforward_emailstill bypasses the router entirely (it injects nothing and never gets a suffix — the #229 invariant).