v2.10.0: 11-issue marathon (#104 sweep wrap-up + RFC 2047/2231 hardening + multi-account compose)
11-issue marathon — closes #126, #128, #129, #130, #131, #133, #134, #135, #137, #138, #139. Highlights: #131 multi-account from_address for compose/draft (new feature), #133 redirect_email boundary recipient validation (#41 consistency), #134 + #139 regression-lock for reply/forward + mailbox-CRUD actor wiring (closes #104 PR-A/B/C/D verify DA gaps), #135 RedirectEmailScriptBuilder escape coverage + byte-identity golden fixture, #137 resolveMailboxRef / mailboxRefByAccountId compose resolveAccountRef (#110 sibling DRY), #126 decodeRFC2047IfApplicable RFC 2047 §2 gate tightening + fail-fast regex, #125 / #122 / #115 / #124 RFC 2047 / 2231 attachment-filename hardening sweep across CJK + Yahoo / Outlook 16 / path-traversal vectors, #129 move_email/copy_email cross-account README limitation, #130 + #128 positional UUID / verb-anchor assertion hardening, #138 deferred (Codex layer migration — not recurrent). Test suite 475 → 482+; full 6-AI ensemble verify every PR.
Changed
MailControllermutation-tool method signatures reordered foraccountId-before-accountNameconsistency (#117 — from #114's Devil's Advocate review). The 5 single-message mutation methods (markRead,flagEmail,setFlagColor,setBackgroundColor,markAsJunk) declared parameters(id, mailbox, accountName, accountId, …)while thebuildXxxScriptbuilders andresolveMsgRefuse(id, mailbox, accountId, accountName, …)— Swift label-based calls compiled fine, but the order mismatch was a footgun for anyone grepping signatures. Pure parameter reorder of the 5 signatures + 5 matchingServer.swiftcall sites; zero behavior change.- Real PII removed from test fixtures and documentation (#119, #147). Hardcoded real email addresses (the maintainer's
kiki830621@gmail.com, a third-partyd06227105@ntu.edu.tw) and a real personal name embedded in an RFC 2047 Q-encoded header fixture were replaced with RFC 2606 synthetics (alice@example.com/bob@example.com/測試) across 7 test files, theAccountMapper.swiftdoc-comment, aREADME.mdsave_attachmentexample, and theemlx-parserspec's Quoted-Printable decode scenario (#147's verify caught the spec/test drift the test-only #119 fix would otherwise have introduced). Test-only behavior; no production behavior change. Author-attribution credits and archived Spectra authorship metadata intentionally retain the maintainer's name/handle. resolveMailboxRef/mailboxRefByAccountIdnow composeresolveAccountRefinstead of duplicating its selector syntax inline (#137 — from #104 PR-D sister sweep). Pre-#137 three places knew how to build the account-only AppleScript selector:resolveAccountRef(the canonical),mailboxRefByAccountId(inlined UUID path), andresolveMailboxRef's fallback branch (inlined display_name path). If(account id "...")syntax ever needs hardening (e.g. escaping subtlety, Apple semantic change), pre-#137 all three would have to change in lock-step — classic drift risk. Refactor: both functions now callresolveAccountRef(accountId:accountName:)and concatenate the(first mailbox of …shell around it.mailboxRefByAccountIdpassesaccountName: ""(sentinel — accountId is non-empty by contract, soresolveAccountRefalways takes the UUID path; an empty-string accountName would surface in scripts as a visibly wrong fallback should a future caller bypass the contract). Output-preserving — all 17 existingAppleScriptRefBuilderTests(exact-string assertions for both UUID and display_name paths) stay green unchanged, and the full 475-test suite passes. Sibling to #110 (escape-function dedup) — both consolidate AppleScript-construction discipline at single chokepoints.- AppleScript escaping deduplicated —
MailController.escapeForAppleScriptremoved in favor of the sharedappleScriptEscapefree function (#110).MailControllercarried aprivate func escapeForAppleScript(_:)whose body was character-for-character identical to the package-levelappleScriptEscapefree function used by every AppleScript builder (ComposeScriptBuilder,CreateRuleScriptBuilder, etc.) — two copies of the same escape order (backslash → double-quote →\r\n→\n→\r→\t) drifting independently was a latent correctness hazard. The private method was deleted and all ~29 in-method call sites swapped toappleScriptEscape;appleScriptEscapeis now the single AppleScript-escape chokepoint across the package. Pure refactor —appleScriptEscapeis a synchronous file-level function (not actor-isolated), so noawaitwas needed at any swapped call site; output is byte-identical for every input.grep -rn "escapeForAppleScript" Sources Testsnow returns 0 hits. - README documents the
move_email/copy_emailcross-account limitation (#129 — from #127 verify DA-8).move_emailandcopy_emailaccept a singleaccount_idthat is threaded through both sourcemsgRefand destinationmailboxRef; cross-account transfer (source on account A, destination on account B) is not supported because Mail.app's AppleScriptmove msg to <mailboxRef>requires the destination to be expressed relative to a single account context. The Account Disambiguation section now explicitly states this limitation and notes that users wishing to recreate a message on a different account can usesave_attachment+compose_emailto manually rebuild the body and attachments (this is not a true move/copy of the original — message metadata, flags, and identity are not preserved). Docs-only; no behaviour change.
Added
compose_email/create_draftgain optionalfrom_addressparameter for multi-account sender selection (#131 — feature carved out of #104 PR-C scoping). Pre-#131 these two tools accepted a deadaccountNameparameter that was never read by the Server.swift handlers and never plumbed to the AppleScript builders — multi-account users couldn't choose which account to send/save FROM, and Mail.app fell back to its default account. Unlike the #104 sweep (which addressesaccount "<display_name>"selector collision when referencing existing mail),compose_email/create_draftmake new outgoing message, so they have no selector defect — they had a missing-feature instead. Mail.app'ssenderproperty on an outgoing message is a STRING matching one of the user's configured email addresses (RFC 5322 addr-spec, optionally"Name <email@example.com>"), NOT anaccount idselector.buildComposeEmailScriptandbuildCreateDraftScriptnow acceptfromAddress: String? = nil; when non-nil/non-empty they emitset sender to "<escaped>"inside thetell newMessageblock (escaped viaappleScriptEscape— header-injection-safe).MailController.composeEmail/createDraftvalidatefromAddressat the boundary viavalidateEmailAddresses(_, field: "from_address")(same #41 hardening applied to recipients). Server.swift handlers readarguments["from_address"]?.stringValueand plumb through. Backward compat preserved: omittingfrom_addressproduces byte-identical AppleScript to pre-#131 (noset sender toline) — Mail.app continues to use the default account. 8 new tests cover builder emit + escape + nil/empty fallback + boundary validation (control-char rejection / missing-@rejection) for both compose and draft. Uselist_accountsto discover available addresses.create_draftnow accepts optionalcc/bccrecipient arrays (#107).create_draft's schema previously exposed onlyto— an agent that reasoned about who to CC (a referrer, a PI) could not carry that decision into the draft, forcing the user to re-add Cc/Bcc by hand in Mail.app.create_draftnow mirrorscompose_email: optionalccandbccstring arrays flow throughMailController.createDraft→buildCreateDraftScript, emittingmake new cc/bcc recipientAppleScript fragments (via the sharedrecipientFragmenthelper, same emission order asbuildComposeEmailScript). Recipients are validated at the boundary viavalidateEmailAddresses(#41 consistency —to/cc/bccall checked). Backward compat preserved: both fields are optional and absent fromrequired; omitting them produces byte-identical AppleScript to the pre-#107 builder. Themessage-compositionspec gains a normativecc/bccrequirement (covering bothcompose_emailandcreate_draft). 3 new tests (buildCreateDraftScriptcc/bcc inside thetell newMessageblock + omitted-when-nil;create_draftschema advertises cc/bcc as non-required).list_attachments/list_attachments_batchnow stamp each attachment with asavableboolean (#105 — sister concern of #103). ThecrossValidateAttachmentsfilter (#24) dropped SQLite rows whosenamewas absent from the.emlxbody, but never checked whether the binary was actually retrievable — so a.partial.emlxattachment whose name is in the envelope yet whose binary is in neither the inline body nor Apple Mail's externalAttachments/<rowId>/cache was reported as available, and a subsequentsave_attachmentthen failed opaquely with-10000(#103). Each cross-validated entry now carries"savable": true|false—truewhen the binary is retrievable from the local store,falsefor the #103 precursor state — so callers can predict (and route around) the failure before attempting the save. On the.emlx-parse-failure fallback path the field is omitted (savability unknown); an absentsavablemust not be read asfalse. Implementation preserves the #24 no-decode perf property: a newMIMEParser.enumerateAttachmentInlinePresenceextends the existing names-only tree walker to also report per-part inline-body presence (bodyhas any non-whitespace byte — base64/QP of whitespace-only input decodes to empty, and.containsearly-exits on the first content byte, so a 50-MB attachment is never decoded just to answer this);EnvelopeIndexReader-adjacentEmlxParser.attachmentSavability(rowId:mailboxURL:)combines that with the external-cache lookup.enumerateAttachmentNamesis now derived from the new map's keys (single tree walk; behavior unchanged). When the same filename appears on multiple parts the first occurrence in document order wins (mirrorssaveAttachment'sparts.firstselection — OR-combining would overstate savability). Thesqlite-query-engineandbatch-operationsspecs gain normative coverage of thesavablefield. 8 new tests (MIMEParserstripped-vs-present +enumerateAttachmentNamesregression + duplicate-name first-wins;attachmentSavabilityexternal-present →true/ external-missing →false/ path-traversal name rejected;crossValidateAttachmentssavablestamping + omit-on-unknown).
Fixed
MailController.createMailbox/deleteMailboxwiring now has automated regression lock (#139 — from #136 verify DA-5, recurrence of #134's pattern). The 11 PR-D builder tests directly coverbuildCreateMailboxScript/buildDeleteMailboxScript/resolveAccountRef, but the actor methods' call sites ("doescreateMailboxactually call the builder with the right params?") had no integration-free coverage. A future edit that inlines the AppleScript directly into the actor (let script = "tell application \"Mail\" …") would leaveswift testgreen and silently kill #104 account_id disambiguation. Solution: 2 structural source-code introspection tests inMailboxCrudScriptBuilderTestsreadMailController.swift(via#filePath-derived path), extract thecreateMailbox/deleteMailboxfunction bodies (brace-balanced), and assert (a)buildCreateMailboxScript(name:/buildDeleteMailboxScript(name:call is present and (b) inlinetell application "Mail"is absent. Brittle to deliberate refactors — when the wiring shape intentionally changes, the tests should be updated to reflect the new contract; that's the trade-off for directly catching the "actor stopped calling the builder" regression that semantic builder tests cannot detect without integration infrastructure. Mirrors #134's discipline (testable builder seam at the right layer) but adds a one-step-further structural pin for actor methods whose body is effectively a 2-line wrapper.RedirectEmailScriptBuildertest coverage hardened — escape-discipline tests + byte-identity golden fixture (#135 — from #132 verify LOW bundle: SEC-6 + DA-4 residual). Pre-#135RedirectEmailScriptBuilderTests.swiftcovered recipient-quote escape but not the symmetric escape positions (account_id, mailbox name, backslash inputs); PR #132 verified byte-identity of the nil-accountId path by 3 reviewers manually compiling-and-diffing, but no test pinned it. New tests close both gaps: 4 escape tests (account_id quote / mailbox quote / backslash-in-recipient / backslash-in-accountName) plus a byte-identity golden-string fixture for theaccountId: nilfallback path that would catch any silent drift betweenappleScriptEscapeandMailController.escapeForAppleScript-class helpers (already deduplicated by #110, but a golden fixture remains the cheapest insurance). Test-only; no production code change. The third LOW gap (forward_email display_name-fallback test) was already addressed by #134's newbuildForwardEmailScript(id:mailbox:accountId:accountName:...)overload tests.reply_email/forward_emailresolveMsgRefwiring now has automated regression lock (#134 — from #132 verify DA-3). Pre-#134MailController.replyEmailandforwardEmailcomputedref = resolveMsgRef(...)inline and threadedrefinto the(messageRef:)builders — the literalresolveMsgRefcall that #104 PR-C added for account_id disambiguation had zero automated test (onlyMailAppIntegrationTests, which areXCTSkip-gated and don't passaccountId). RevertingresolveMsgRef → msgRefin either method would have leftswift testgreen and silently reintroduced the #104 display_name-collision bug. NewbuildReplyEmailScript(id:mailbox:accountId:accountName:...)andbuildForwardEmailScript(id:mailbox:accountId:accountName:...)overloads internalize theresolveMsgRefcall so the wiring IS unit-testable; the existing(messageRef:)overloads stay intact for the 25 legacy tests that pre-build the ref.MailController.replyEmail/forwardEmailswitched to the new overloads. 5 new tests (MailControllerComposeTests) cover UUID path + display_name fallback + empty-string-accountId fallback semantics for both reply and forward. Mirrors PR-B's #130 (positional UUID assertion) and PR-C's redirect builder pattern — the actor-method wiring sweep is now consistent across PR-A/B/C. Pre-fetch path (buildFetchOriginalContentScript) still computesrefinline; locking that wiring is a separate concern out of #134's scope.redirect_emailnow validates recipient addresses at the boundary, matchingcompose_email/reply_email/forward_email(#133 — sister of #41, surfaced during #104 PR-C sister sweep).MailController.redirectEmailwas the only message-relay tool that skippedvalidateEmailAddresses, so malformed addresses (control characters attempting header injection, missing@, multiple@) bypassed the cleanMailError.invalidParameterboundary error and either reached Mail.app to surface as an opaque AppleScript error or silently produced an undeliverable redirect. Not a security defect (recipients are stillappleScriptEscape'd so injection was never possible), but a consistency gap: callers relying on the #41 contract got divergent behavior depending on which relay tool they used. Fix is a one-linetry validateEmailAddresses(to, field: "to")at the top ofredirectEmail, mirroringforwardEmail(line 885). 2 new tests cover control-char and missing-@rejection paths at theredirectEmailboundary.RFC822Parser.decodeRFC2047now strips CR/LF (in addition to space/tab) between consecutive encoded-words (#125 — sister of #99). Pre-fix the inter-encoded-word whitespace check atRFC822Parser.swift:129only strippedand\t, so a CR/LF separator between two adjacent=?…?=encoded-words leaked through as a literal control character into the decoded result (e.g.=?utf-8?B?YWJj?=\n=?utf-8?B?ZGVm?=→"abc\ndef"). RFC 2047 §6.2 defines the inter-encoded-word whitespace as RFC 822 linear-white-space (LWS) — space + tab + CR + LF — so all four must be skipped. Subtle: Swift treats\r\nas a single extended grapheme cluster that does not equal any individualCharacterliteral in aCharacter-levelallSatisfycheck; the fix iteratesprefix.unicodeScalarsso each LWS component matches scalar-wise. Header-level callers viaparseHeadersare mostly unaffected because the upstream unfolding pass atRFC822Parser.swift:84-87already normalises folded\r\n[ \t]sequences to a single space, but the per-parameterdecodeRFC2047IfApplicablepath (used byMIMEParser.resolveFilenameafter #115) does not unfold its input, so CR/LF between EWs could survive into the decoded filename pre-fix. 1 new test pins all five LWS separators (space, tab, LF, CR, CRLF) round-trip cleanly toabcdef. The originally-suggested fix in #125's body — tightening the gate's\s*to[ \t]— is moot: #115 round-2 already removed the multi-EW(\s*=?…?=)*repetition group from the gate (it is now a single-EW substring search), so the asymmetry lives only at the decoder layer, where this fix lands.resolveFilenamenow assembles RFC 2231 continuation on the Content-Typename*Nfamily symmetric tofilename*N(#122 — sister of #99).MIMEParser.resolveFilenamepreviously assembled RFC 2231 continuation segments only on the Content-Dispositionfilename*Nfamily (step 2). A sender that emits onlyname*0*/name*1*on Content-Type (and no Content-Dispositionfilenameat all — a rare but real Outlook 16 / non-conformant shape) hit the same #99 silent-drop class:params["name"]wasnil, no continuation aggregator ran overcontentTypeParams, the assembled (let alone decoded) name was never produced,enumerateAttachmentNamesemitted no key,crossValidateAttachmentsdropped every SQLite row,list_attachmentsreturned[]. Fix: extracted the step-2 continuation-assembly block into a sharedassembleRFC2231Continuation(params:paramName:)helper parameterised over the param name (DRY — two parallel aggregators was exactly what let this sister bug slip past #99), generalized thecontinuationIndex(_:paramName:)helper accordingly, then added two new resolution steps for thenamefamily: 4acontentTypeParams["name*"]RFC 5987 single-segment defensive handler (mirrors step 1 forfilename*), and 4bname*Ncontinuation aggregator (mirrors step 2 forfilename*N). Step 2 still produces byte-identical output (pinned by the existing #99 + #115 tests + round-2 regression tests, all green); resolution priority is unchanged — Content-Dispositionfilenamestill wins over Content-Typenamefamily at every level. 2 new tests:testResolveFilename_contentTypeName_rfc2231WithNestedRfc2047(mirrors the #99 Outlook 16 fixture but withname*0*/name*1*incontentTypeParamsonly, decodes to中央研究院新進聘僱人員體格檢查表-1110331製表.pdf) andtestResolveFilename_contentTypeName_rfc5987(covers step 4a, decodes to中文.pdf).list_attachments/list_attachments_batchno longer silently return[]for emails whose attachment filename is an RFC 2047 encoded-word split across RFC 2231 continuation segments (#115). An older Yahoo Mail client (WebService/… YMailNorrin) encodes a CJK attachment filename as RFC 2047 encoded-words spread over RFC 2231 plain continuation parameters (filename*0/filename*1, no trailing*), and the segment boundary cuts through the second encoded-word's=?opener (=endsfilename*0,?charset…startsfilename*1). Root cause:RFC822Parser.parseHeadersapplieddecodeRFC2047to every header value — including the structuredContent-Dispositionheader — beforeMIMEParsersplit it into parameters. The header-level scan decoded the encoded-word fully contained insidefilename*0but left the straddling one untouched, producing a half-decoded value (測試 =?UTF-8?Q?…?=);resolveFilenamethen assembled a string no longer starting with=?, so its strict full-string-gated #99 second pass refused to decode it.crossValidateAttachmentscompared that garbage name against Mail.app's SQLite-cached human-readable name, found no intersection, and dropped every row. (Unlike #99, the defect is upstream ofresolveFilename— the value is corrupted before parameter-splitting, so #99's per-return-point second pass could not catch it.) Fix:parseHeadersno longer RFC 2047-decodesContent-*headers at the raw-header level — per RFC 2047 §5 an encoded-word may not appear in a structured field body, and the only legitimate filename encoded-word lives inside afilename/nameparameter, whichMIMEParser.resolveFilenamealready decodes per-parameter. To keep that per-parameter layer compatible with everything the old raw-header scan used to handle, thedecodeRFC2047IfApplicablegate was relaxed from a strict whole-string anchor to a substring search for one well-formed encoded-word, so a legitimate=?…?=.<ext>value (encoded basename + literal extension — a real non-conformant client pattern) is now decoded in place; the malformedreport=?bogus.pdfcase still survives untouched becausedecodeRFC2047itself only rewrites well-formed encoded-words. The legacyContent-Type: name=add point incollectAttachmentNameswas made symmetric with the same per-parameter decode so the emitted attachment-name set no longer carries a spurious raw=?…?=entry when raw-header decoding is gone. Emails using RFC 5987filename*=(no encoded-word in the disposition header) were never affected. Also adds observability the diagnosis flagged as missing:list_attachments/list_attachments_batchnow write aWARN:line to stderr when cross-validation drops all non-empty SQLite attachment rows (previously a completely silent[]). 5 new tests: end-to-endenumerateAttachmentNamesover a synthetic split-encoded-word RFC 2231 continuation fixture, end-to-end coverage ofContent-Type: name="=?…?="at the name-add path,parseHeaderspinningContent-Dispositionas left-raw, a guard thatSubjectis still RFC 2047-decoded, andresolveFilenameaccepting=?…?=.pdf(encoded basename + literal extension).save_attachmentnow returns an actionable error instead of the bare-10000 AppleEvent handler failed(#103). When the Tier-2 AppleScript fallback fails with-10000(errAEEventFailed— Mail.app's ownsave attachmenthandler raised an internal exception, distinct from #101/#102's-1728/-1719account-selector errors), the caller previously got only the generic"AppleScript error (-10000): Mail got an error: AppleEvent handler failed."with no recovery path. Forsave_attachmentthe dominant cause is an attachment whose binary is not in the local Mail store and which Mail.app could not re-fetch from the IMAP server. A newsaveAttachmentAppleEventHint(code:accountName:rawMessage:localCopyConfirmedMissing:)pure helper re-words only the-10000case (any other code rethrows unchanged, so the #101/#102 disambiguation diagnostics are never masked) into an actionable message with concrete recovery steps: re-fetch via thesynchronize_accountMCP tool (or Mail.app's Take All Accounts Online → Synchronize) → Rebuild the mailbox → manual Save-Attachment from the Mail.app UI, plus a save-path-writable check for the non-missing-binary case. The message is definitive, not hedged, when the MCP can prove the cause: thesave_attachmenthandler threadslocalCopyConfirmedMissing— set when the Tier-1 SQLite +.emlxfast path threwattachmentNotFound, i.e. the MCP itself confirmed from local.emlxstate that the binary is absent (no inline copy, no externalised copy) — into the helper, so a confirmed case states the cause outright while an unconfirmed-10000stays hedged ("usually means"). A newMailError.operationFailed(String)case surfaces the hint verbatim (no misleading"AppleScript error (N):"prefix). Scope: this addresses the diagnosed Phase 3 (actionable error); the underlying-10000save failure itself is a Mail.app-internal handler error and is not made to succeed — when the binary is genuinely absent locally and un-fetchable, no save is possible. Tier 1's.partial.emlx-no-external detection (attachmentNotFound) is already pinned by the existingtestSaveAttachment_partialEmlxNoExternal_throwsAttachmentNotFound. 4 new tests cover the helper (definitive vs hedged-10000message; other codes →nil;MailError.operationFailedverbatim description).get_email/get_emails_batchno longer fall through to AppleScript silently when the SQLite Envelope Index lookup returnsnil(#100). Both tools have run the SQLite +.emlxfast path since v2.0.0 (ff2f33a) — #100's "migrate to fast path" premise was stale. The real defect:EnvelopeIndexReader.mailboxURL(forMessageId:)returnsnil(it does notthrow) when a rowId is absent from the Envelope Index, and the fast-path block only wrapped ado/catch— so thenil-return case slipped out of theif let mailboxUrlstraight to the AppleScript fallback with no stderr line. #69 closed thethrow-path observability hole but left thenil-path one open, violating.claude/rules/r-must-direct-db.md's "logged fallback" rule; a bulk archive that missed the fast path for every message looked "mysteriously slow" with no diagnostic. Fix: a newfastPathFallthroughLog(tool:rowId:reason:perItem:)pure message builder +logFastPathFallthrough(...)writer route both fall-through modes through one chokepoint — the.errorcase is byte-identical to the pre-#100 inlinecatch-branch strings (pure refactor of the throw path), and the new.rowIdNotIndexedcase is deliberately worded as a neutral "fast path miss" (NOT "failed"), since it fires legitimately on every call for EWS/Exchange accounts that have no local.emlxstore (#9). No behavior change — the success path and the fallback decision are untouched; only the previously-missing stderr diagnostic is added. 5 new tests pin the message contract (neutral-miss wording, error detail, per-item suffix, byte-equivalence to the legacy inline strings, and the actual stderr write via thecaptureStderrfd-2 helper).- Non-string
account_idarguments now warn to stderr instead of silently degrading — across all 14account_id-accepting tools (#111 — verify P3 from #101). Every handler that readsaccount_iddidarguments["account_id"]?.stringValue— for a JSON number / bool / array / object the?.stringValueaccessor returnsnil, so a mistyped argument (e.g.account_id: 12345instead of"12345") was indistinguishable from "noaccount_idsupplied". The tool then silently fell back to theaccount_name(display_name) path — re-exposing exactly the #101 multi-account-same-display_name collision theaccount_idparameter exists to avoid, with no diagnostic. Post-fix: a newdecodeAccountId(_:tool:)helper distinguishes the three cases — a JSON string returns the value verbatim (including empty string, preserving the downstream non-nil/non-empty resolver contract), a genuine JSONnullor an absent key returnsnilsilently (legitimate "not supplied"), and any other type (.int/.double/.bool/.array/.object/.data) returnsnilafter writing aWARN:line to stderr naming the offending tool and the received type. All 14 call sites acrossServer.swiftwere swapped from the raw?.stringValueread todecodeAccountId(arguments, tool: invokedTool); aninvokedToolbinding was captured before theswitchbecause thecreate_mailbox/delete_mailbox/ rule handler cases rebindnameas a local. Behavior for correctly-typed callers is unchanged; the only observable difference is the new stderr diagnostic for the (always-erroneous) non-string case. 5 newXCTestmethods pin the helper contract (string→verbatim, absent→nil,.null→nil, non-string types→nil, empty-string→verbatim). - 2 mailbox CRUD tools (
create_mailbox,delete_mailbox) now acceptaccount_idfor multi-account-same-display_name disambiguation (#104 PR-D — the final sweep increment). Same-1728 / -1719failure mode as PR-A/B/C — theaccount "<display_name>"AppleScript selector non-deterministically picks the wrong account when two accounts share a display_name. Pre-fix: creating a mailbox could attach it to the wrong account; deleting could remove the wrong account's mailbox. Post-fix: each tool accepts an optionalaccount_id; when provided, AppleScript uses the(account id "<UUID>")selector. The two tools needed different resolvers:delete_mailboxreferences an existing mailbox, so it routes through the establishedresolveMailboxRefchokepoint;create_mailboxaddresses an account directly (make new mailbox ... at <accountRef>), a shape no prior resolver covered — PR-D adds a newresolveAccountRef(accountId:accountName:)toAppleScriptRefBuilder.swift(purely additive —resolveMsgRef/resolveMailboxRefunchanged) that returns just the account selector. Both inline scripts were extracted into a new testableMailboxCrudScriptBuilder.swift(mirrors PR-B'sMoveCopyDeleteScriptBuilder). Backward compat preserved: omittingaccount_idfalls back to the legacy display_name path, byte-identical to the pre-PR-D inline scripts. 11 new tests (7MailboxCrudScriptBuilder: UUID path + nil/empty-string fallback + quote escaping per builder; 4resolveAccountRef: UUID path + nil/empty fallback + escaping). With PR-D the #104 sweep covers all 13 account-referencing AppleScript tools; PR-E (R-tool fallbacks) stays deferred — SQLite Tier 1 dominates, the fallback rarely fires. - 3 message-relay tools (
reply_email,forward_email,redirect_email) now acceptaccount_idfor multi-account-same-display_name disambiguation (#104 PR-C). Same-1728 / -1719failure mode as PR-A / PR-B —msgRef'saccount "<display_name>"AppleScript selector non-deterministically picks the wrong account when two accounts share a display_name. Pre-fix: replying to / forwarding / redirecting a message could resolve the wrong account's copy. Post-fix: each tool accepts an optionalaccount_id; when provided, AppleScript uses the(account id "<UUID>")selector via Phase 0'sAppleScriptRefBuilder.resolveMsgRefchokepoint. Implementation note:redirect_email's script was built inline inMailController— PR-C extracts it into a new testableRedirectEmailScriptBuilder.swiftfree builder (mirrors PR-B'sMoveCopyDeleteScriptBuilder).reply_email/forward_emailalready delegate tobuildReplyEmailScript/buildForwardEmailScript, which take an opaque pre-resolvedmessageRef: String; their signatures are unchanged (25 existing tests untouched) — theaccount_idis threaded throughresolveMsgRefat theMailControllercall site (amsgRef → resolveMsgRefswap). Scope note:compose_email/create_draftare NOT part of this sweep — theymake new outgoing messageand never emit anaccount "<display_name>"selector, so they have no-1728/-1719defect; their separate sender-account-selection gap (deadaccountNameparameter) is tracked at #131. Backward compat preserved: omittingaccount_idfalls back to the legacy display_name path. 8 new tests (5RedirectEmailScriptBuilder: UUID path + nil/empty-string fallback + multi-recipient + quote escaping; 3 reply/forward propagation tests verifying the builders embed the resolved UUID ref verbatim). - 3 movement/destruction tools (
move_email,copy_email,delete_email) now acceptaccount_idfor multi-account-same-display_name disambiguation (#104 PR-B). Same-1728 / -1719failure mode as PR-A's 5 mutation tools —msgRef'saccount "<display_name>"AppleScript selector non-deterministically picks the wrong account when two accounts share a display_name. Pre-fix: any of these 3 tools could fail or operate on the wrong account's message. Post-fix: each tool accepts an optionalaccount_id; when provided, AppleScript uses(account id "<UUID>")selector.move_emailandcopy_emailare particularly subtle because they emit TWO refs in one script (sourcemsgRef+ destinationmailboxRef) — both refs flow through the sameaccountIdsince move/copy operations stay within a single account. Implementation: newMoveCopyDeleteScriptBuilder.swiftwith 3 free builder functions that delegate to Phase 0'sAppleScriptRefBuilder.resolveMsgRef/resolveMailboxRefchokepoint. Backward compat preserved: omittingaccount_idfalls back to the legacy display_name path. 6 new tests (2 per builder: UUID path verifies UUID in BOTH ref points for move/copy; display_name fallback verifies legacy form).
Security
externalAttachmentURLnow rejects path-traversal attachment names, closing a pre-existing CWE-22 (Path Traversal) window surfaced + amplified by #105 (caught by the #105 6-AI verify Security reviewer). The attachment binary lookup joins the email-sender-controlled MIME filename onto a filesystem path viaappendingPathComponentwith no sanitisation — a craftedfilename="../../../../etc/passwd"escapedAttachments/<rowId>/. Pre-#105 the probe fired once per deliberatesave_attachmentcall; #105's newattachmentSavabilityrunsexternalAttachmentURLfor every attachment name on everylist_attachments/list_attachments_batchcall, turning a passive listing into a filesystem existence-oracle (thesavableboolean leaks "does file X exist on the victim's machine" to a remote sender). Fix:externalAttachmentURLnowguards the name against path separators and./.., returningnilfor any path-bearing name — a name with a/is never a valid single-segment attachment filename. This also closes the strictly-worse arbitrary-read variant on thesave_attachmentpath (which calls the same helper). 1 exploit-style regression test plants a decoy file exactly where the traversal would resolve and asserts the crafted name reportssavable: false.set_background_colornow whitelists the color enum before AppleScript interpolation, closing a pre-existing CWE-94 (Code Injection) window (#116 — surfaced during #104 PR-A verify by Codex P2 / DA-F / Logic LOW#1). Thecolorargument flowed from the MCP client throughServer.swift'sset_background_colorhandler intoMutationToolScriptBuilder.buildSetBackgroundColorScript'sset background color of <ref> to \(color)line without any validation — a malicious or buggy caller passing newline-bearing ("red\n do shell script \"…\""), quote-bearing, or semicolon-chained payloads could inject arbitrary AppleScript that ships intoosascript(downstream CWE-78). Defense-in-depth fix: (1) newbackgroundColorWhitelist: Set<String>constant co-located with the builder — single source of truth, lowercase-strict, exact-match against Apple Mail's 8-value enum; (2)Server.swifthandlerguards membership before delegating, throwsMailError.invalidParameterwith the valid set echoed back to the caller; (3)buildSetBackgroundColorScriptadds aprecondition(release-safe —Package.swiftdoes not enable-Ounchecked) for programmer-error catch if any internal caller bypasses the handler gate. 4 newXCTestmethods pin the constant against drift, exercise injection-payload rejection (newline / quote / semicolon), case-strictness ("Blue"/"BLUE"rejected), and whitespace-strictness ("blue "/""rejected). Existing 2 happy-path tests (UUID path + display_name fallback for"blue"/"green") stay green. Backward compat preserved: callers using the 8 documented enum values (blue, gray, green, none, orange, purple, red, yellow) see identical behavior. Scope note: the diagnose-stage sister sweep covered the 6 AppleScript builder files and confirmedset_background_color.coloris the only externally-supplied enum-shapedStringslot raw-interpolated across those builder files; subsequent/idd-verifyindependently confirmed a separate raw-enum interpolation slot atMailController.swift:1111(createRule'squalifier:\(qualifier)) that uses the same anti-pattern outside the builder boundary — fixed by #140 below.create_rulenow whitelists thequalifierenum before AppleScript interpolation, closing a pre-existing CWE-94 (Code Injection) window (#140 — sister fix to #116; independently surfaced by Security + Devil's Advocate + Codex during #116/idd-verify). Thequalifierargument flowed from MCP client throughServer.swift'screate_rulehandler intoMailController.createRule's inlinemake new rule condition with properties {... qualifier:\(qualifier), ...}line without any validation — a caller passing a newline-bearing payload ("does contain value, expression:\"\"}\n do shell script \"…\"\n tell newRule\n make new rule condition with properties { header:\"X\"") could inject arbitrary AppleScript that ships intoosascript(downstream CWE-78). Sibling slots (header,expression, rulename,move_messagemailbox) were alreadyescapeForAppleScript-protected; onlyqualifierwas raw. Defense-in-depth fix mirrors #116 + extends the #104 PR-B/C/D extraction pattern: (1) inline AppleScript extracted into newSources/CheAppleMailMCP/AppleScript/CreateRuleScriptBuilder.swiftfree function (buildCreateRuleScript(name:, conditions:, actions:)) — output preserves the pre-extraction AppleScript shape for valid inputs (pinned bytestBuildCreateRuleScript_byteEquivalenceWithInlineImplementationvia ordered-substring containment — smoke regression, not full byte-equality); (2) newruleQualifierWhitelist: Set<String>constant with the 8 empirically-verified Apple MailRuleQualifierenum values from/System/Applications/Mail.app/Contents/Resources/Mail.sdef(begins with value,does contain value,does not contain value,ends with value,equal to value,less than value,greater than value,none); (3)Server.swiftcreate_rulehandler loops conditions andguard ruleQualifierWhitelist.contains(qualifier)before delegating, throwingMailError.invalidParameterwith the 8 valid values echoed back; (4) builder-sideprecondition(release-safe —Package.swiftdoes not enable-Ounchecked) for programmer-error catch if any internal caller bypasses the handler gate. 10 new XCTest methods cover the whitelist contract (drift-pin + injection-payload rejection + case-strictness + whitespace-strictness), happy-path emission (all 8 qualifiers + multi-condition + escape preservation + move/bool actions), and a regression snapshot. Backward compat preserved: callers using any of the 8 documented qualifier enum values see identical AppleScript output and identical behavior; non-whitelisted callers (previously silently producing invalid AppleScript that osascript would error on) now get a clean MCP-levelinvalidParametererror with the valid set echoed back. With #116 (above), the two AppleScript-enum injection slots surfaced by the #116 verify ensemble are both closed.resolveMsgRef/msgRefByAccountIdnow reject non-numeric messageidin release builds (#118 — from #114's Security advisory #3 / Logic check #3). The twoAppleScriptRefBuildermessage-reference builders interpolatedidunquoted intowhose id is <id>, guarded only by a debug-onlyassertthat compiles out under-O— a release-build caller bypassingServer.requireMessageIdcould inject an AppleScript predicate (#50 / CWE-89-adjacent class). Bothasserts replaced with a release-safe numeric check (Int(id) != nil ? id : "-1"): a non-numericidcollapses to an impossible id, so the malicious string is never interpolated and the script fails cleanly with-1728. Byte-identical output for valid numeric ids. 4 new injection / byte-equivalence tests.MailController.msgRefnow rejects non-numeric messageidin release builds (#145 — sister of #118, surfaced by #118's verify ensemble). The legacy private message-reference builder (6 live read-path callers:get_email/get_email_headers/get_email_source/get_email_metadata/list_attachments/save_attachmentAppleScript fallback) carried the identical release-unsafe debug-onlyassert. The same release-safeInt(id)guard was applied;privatewas widened tointernalpurely as the test seam. With #118, all threewhose id is <id>interpolation sites across the codebase are now release-guarded — the #50-class predicate-injection surface is fully closed. 3 new tests exercised viaMailController.shared.