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
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.