Skip to content

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. Scope honesty (verify DA): this protects the get_email / get_emails_batch paths only — batch_export_emails_markdown (archive-mail's primary Step 5.0 bulk path) does not yet consume the new partial visibility and still writes header-only markdown silently; tracked as #283. Two further honest caveats: a partial file whose parse throws falls back via the generic error path without partial provenance (no annotation); and the fetch nudge may be asynchronous on the IMAP side — the annotated call can stay empty while a later re-read succeeds. Live acceptance (does the nudge fetch a real not-downloaded message — synchronously, asynchronously, or not at all; "open in Mail.app then readable" criterion) remains a documented residue pending an attended session with a real specimen.