Added
- Self-hosted plugin marketplace — the plugin shell now lives beside its binary (#335). The commands / skills / rules / hooks / wrapper previously lived in the
psychquant-claude-pluginsaggregator while the Swift source lived here, splitting one product across two repos: archive-mail SOP issues accumulated in the aggregator (#104/#107/#109/#110/#112) while binary issues stayed here (#232/#261), each "split from" the other — and the two manifests drifted (marketplace.jsonsaidbinary_version: 2.24.0,plugin.jsonsaid2.25.0, while the newest release was v2.26.0, so the v2.44.0 shell shipped an SOP that documents "binary v2.26.0+" against a pin that fetched 2.25.0). Layout follows theche-ical-mcppilot: a root.claude-plugin/marketplace.jsonwithsource: "./plugin", plus aplugin/subtree carrying the 20 files verbatim (md5-identical, 20/20). The new manifest deliberately omitsbinary_versionsoplugin/.claude-plugin/plugin.jsonis the single source for the binary pin and that axis of the two-manifest drift is structurally impossible rather than merely corrected once. (The shellversionis still declared in both manifests — the schema wants one per entry — soManifestVersionTestspins the pair equal, and the marketplace entry's description deliberately carries no version narrative at all; both guards added at #335 verify.) Cross-references updated inCLAUDE.mdandscripts/release.sh; the plugin's own hook suite passes unchanged in its new path (22/22).
Removed — BREAKING
-
The legacy compose path is gone: no code in this project assigns a message
body through AppleScript any more
(#304).
Mail wraps any AppleScript-assigned body in<blockquote type="cite">at MIME
serialization. The sender cannot see it — the wrapper's inline style has no
border, so Apple Mail renders the letter normally — while Gmail's web UI and
Outlook show the whole thing as quoted text. On 2026-07-29 a formal meeting
notice went out that way to 10 recipients and could not be recalled; the
trigger was a cc carrying a Chinese display name, a choice with no visible
relationship to body rendering.require_wrapper_freealready existed and
already defaulted to false — a default can be flipped back, forgotten, or
bypassed by the next ineligibility dimension nobody thought of. What changed
is structural: the eight injection points across four builders were deleted,
andNoBodyInjectionGuardTestsfails the suite if any of the three forms
(set content,set html content,content:in an outgoing-message
construction) reappears anywhere underSources/.Removed from the public tool schemas:
format: "markdown"andformat: "html"on all four composing tools.
The enum is now["plain"]. No path this project ships delivers rich text
without assigninghtml content, which is the injection this change
eliminates. That is a statement about what exists, not a proof of
impossibility
(#310): the
clipboard paste path (#218) is a second wrapper-free route already in
production andNSPasteboardcan carrypublic.rtf/public.html, but
nobody has checked the MIME such a paste produces —
#306 is
settling it. Both values are still parsed, so a caller passing them gets
an error naming the removal and pointing at #306 and at
#308 /
#309 rather
than a generic "unknown value".require_wrapper_free(compose_email,create_draft,update_draft).
Itstruebehavior — fail with a named reason instead of producing a
wrapped body — is now unconditional, so a flag that is permanently true
would only imply a control the caller no longer has.sanitize_links. It governed link rendering during markdown-to-HTML
conversion, which only the composing tools' markdown mode performed. The
export path is unaffected:batch_export_emails_markdownuses
EmailMarkdownRenderer, a different module.CHE_MAIL_DISABLE_MAILTO_COMPOSEandCHE_MAIL_DISABLE_PASTE_REPLY.
Both forced the legacy path; there is nothing left to force.
Sources/CheAppleMailMCP/MarkdownRendering.swiftwas deleted with them
(BodyFormatmoved to its own file soformatcan still be parsed and
refused by name).
Changed — BREAKING
- A composing call that cannot use the clean path now FAILS instead of
silently producing a wrapped body. This is the intended behavior change, not
a regression: calls that previously "succeeded" while quietly degrading will
now return an error. Failures carry a named reason and an executable
alternative, and have no side effects — no draft created, nothing sent,
and (forupdate_draft) the existing draft left untouched. The set of reasons
is closed at six and must not be extended by analogy, because every one of
them is decidable before any side effect: a non-plainformat; an empty
subject; Accessibility not granted (the error namesopen_mailto, which needs
no TCC grant but cannot carry attachments); afrom_addressthat is not a
bare addr-spec; an attachment path containing non-ASCII characters (create the
draft withoutattachmentsand drag the file in — do not rename it to
ASCII, since the recipient sees that name); and a recipient carrying a display
name this path cannot fill. Mid-operation failures are a separate contract:
they propagate, keep their post-dispatch classification
(#242), and
never claim that nothing happened.
Capability losses (recorded, not worked around)
Removing the legacy path costs two things that have no replacement:
- Composing without a visible window. The legacy path was the only way to
build a message without a compose window appearing; that was the stated reason
CHE_MAIL_DISABLE_MAILTO_COMPOSEexisted ("heavy/unattended automation where
a briefly-visible compose window is unacceptable", v2.17.0). Themailto:
hand-off necessarily opens one. If this becomes a real blocker, the route is
#308 (IMAP
APPEND) — not reviving the injection. compose_emailsending toName <addr>. Display-name recipients used to
work by routing to the legacy builder, which set the name natively at the cost
of the wrapper. The clean path's GUI fill is draft-only
(#277) — a fill
that failed on a send would dispatch with missing recipients — so a
display-name send is now refused. Usecreate_draft, check the To field,
and send the draft yourself.
Fixed
-
Attachments added via
create_draft/compose_emaillanded at the start
of the body instead of the end
(#341,
#321 — the same
defect reported twice from live use). Mail's File ▸ Attach inserts at the
current insertion point, and after the mailto hand-off fills the body the
caret sits at its start, so the attachment icon appeared to the LEFT of the
first line, ahead of the salutation. For a formal letter that is the first
thing the recipient sees. The script now moves the caret to the end of the
document (⌘↓, locale-independent like the other shortcuts on this path) once,
before the first attach cycle.
Live-verified against real Mail: the pre-fix script produces
<body …><object …x-apple-msg-attachment…— the attachment first, ahead of
every line of text — while the fixed script produces
<body …>FIRST-LINE-MARKER<br><br>--<br>signature line<object …. (MIME part
order is not the signal: both produce the identical order, because Mail
serialises attachment parts at the end regardless of visual placement. The
text/htmlpart is where placement lives.) -
MailboxURL.mailboxNameno longer splits a mailbox name on its own slash (#358). It took the substring after the last/ofmailboxPath, which is the lossy whole-path decode — so a mailbox literally namedR&D/Sent(rawR%26D%2FSent) reported its leaf asSent. Exactly the defect #344 removed from the mailbox filter, still standing in the accessor next door. It now derives frompathComponents. It had zero consumers, which is why it was fixed rather than deleted: it reads naturally enough that removing it would invite the next caller to writemailboxPath.split("/").lastthemselves and reintroduce the bug in their own code.