Skip to content

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 (rfc822ToISO8601, formerly rfc822ToISO8601UTC) 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 historical Z, 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 an unknown-date filename instead of a raw-header fragment. Because frontmatter, manifest, and the filename YYYY-MM-DD all 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-ID skip_message_ids_path dedup is unaffected (it never keyed on filenames). The batch-operations spec's frozen frontmatter contract is updated accordingly (Spectra change preserve-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 the MailController singleton 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 pure routeWrapperFreeCompose(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 inline var legacyReason pattern is gone). A bodyless forward_email still bypasses the router entirely (it injects nothing and never gets a suffix — the #229 invariant).