Compose
Core model
Section titled “Core model”mxr writes drafts in your editor. The daemon handles parsing, validation, send, save-draft, and provider delivery.
This applies to:
- New compose
- Reply
- Reply-all
- Forward
- Draft editing
mxr composemxr compose --to alice@example.com --subject "hello"mxr reply MESSAGE_IDmxr reply-all MESSAGE_IDmxr forward MESSAGE_ID --to team@example.commxr draftsmxr send DRAFT_IDLocal and linked provider drafts
Section titled “Local and linked provider drafts”mxr’s local draft store is canonical. The CLI, TUI, web app, and MCP all read the same rows. List or edit them with:
mxr drafts --format jsonmxr drafts edit DRAFT_IDDelete uses the same draft selection for preview and commit. For a linked
draft, commit deletes the provider draft first and then the local row;
provider failures preserve the local row. discard remains an alias for older
scripts:
mxr drafts delete DRAFT_ID --dry-run --format jsonmxr drafts delete DRAFT_IDGmail accounts can link a local draft to Gmail Drafts:
mxr drafts push DRAFT_ID --dry-run --format jsonmxr drafts push DRAFT_IDThe first push creates one Gmail draft and records its stable draft id.
Repeating push, or editing the linked draft from any mxr client, updates that
same Gmail draft in place. Normal mxr sync uses Gmail’s changing message id
as a revision: Gmail-side edits replace the same local draft, and Gmail-side
deletion removes it locally. No MIME diff is required. Unsupported providers
are refused without changing the local draft.
In mxr web, Drafts in the sidebar opens this same local list. Open a row
to edit it, use the confirmed trash action to delete both linked copies, or
choose Save to server draft to create/update the provider link.
In the TUI, open the command palette and choose Drafts, or press gE.
The draft browser uses e to edit, d to preview and confirm linked deletion,
and p to preview and confirm provider sync. Unsupported accounts are
refused without changing the local draft.
Follow the linked Gmail draft workflow for CLI and MCP examples, sync behavior, and failure handling.
Save as a draft (don’t send)
Section titled “Save as a draft (don’t send)”compose, reply, reply-all, and forward each save a draft with
--draft and send with --yes. The two flags are mutually exclusive, so
no added flag can turn a save into a send — pass --draft and the command
cannot transmit.
# Save a reply-all as a draft. Threading is preserved; nothing is sent.mxr reply-all MESSAGE_ID --body "Thanks all." --draft# → Draft saved: draft_abc123# Send with: mxr send draft_abc123
# Same for a new message, a reply, or a forward.mxr compose --to alice@example.com --subject "Friday" --body "Notes below." --draftmxr reply MESSAGE_ID --body "On it." --draftmxr forward MESSAGE_ID --to team@example.com --body "FYI" --draft
# Preview first — the dry-run reports "save draft", matching what runs.mxr reply-all MESSAGE_ID --body "Thanks all." --draft --dry-run
# --draft and --yes cannot be combined:mxr reply-all MESSAGE_ID --draft --yes# → error: the argument '--draft' cannot be used with '--yes'Without --draft and without --yes, an interactive mxr reply
(no --body) opens $EDITOR and then saves a draft; adding --yes is
what sends. Reach for --draft when you want the save to be explicit and
unsendable — especially from scripts or agents, where an accidental
--yes would otherwise transmit. Send the saved draft later with
mxr send DRAFT_ID (which runs the pre-send safety pipeline).
c: composer: replya: reply allf: forward
If you start from a thread view, reply actions target the focused message, not the latest message in the thread.
Draft format
Section titled “Draft format”Drafts use YAML frontmatter plus body text:
---to: - alice@example.comcc: []bcc: []subject: Examplefrom: hello@planetaryescape.xyz---
Hello from mxr.The from: field is the address the message is sent from. Leave it as the
account’s primary address, or set it to any registered alias
to send as that address. Editing from: in $EDITOR works too.
HTML email
Section titled “HTML email”Some mail is designed, not written: a branded template with tables, inline CSS,
media queries, Outlook conditional comments and a logo. Markdown cannot express
that, so --html-file takes the document as-is.
mxr compose \ --account notto \ --to person@example.com \ --subject "Product Digest" \ --html-file message.html \ --text-file message.txt \ --inline notto-logo=assets/notto-logo.png \ --draft--html-stdin reads the same thing from a pipe. HTML mode is explicit — mxr
never guesses that a --body string is HTML — and it is mutually exclusive with
--body and --body-stdin.
Your HTML is not rewritten
Section titled “Your HTML is not rewritten”The document mxr builds contains the bytes you supplied. It is not reformatted,
re-wrapped, minified, prettified, or sanitised. Tables, inline style
attributes, <style> blocks, @media queries, Outlook conditional comments,
and Unicode like ® all survive unchanged.
The text/html part is base64-encoded rather than quoted-printable
specifically to guarantee this: quoted-printable would rewrite your line endings
to CRLF, so an LF-only file would not decode back to the file you wrote.
Dangerous content is refused, not stripped
Section titled “Dangerous content is refused, not stripped”mxr parses the document to check it, then throws the parse away. If it finds active content it reports the problem and refuses to create the draft:
<script>,<object>,<embed>,<applet>,<iframe>,<form>and other executing or submitting elements- inline event handlers (
onclick=and friends) - URLs using a scheme outside
http,https,mailto,cid,telanddata:image/*—javascript:andvbscript:are rejected <style>blocks containingexpression()orjavascript:- any of the above hidden inside a conditional comment
It will not quietly delete the offending tag and send the rest. You get the tag, the line number, and an unmodified file to fix.
This check runs in the daemon, not just the CLI, so a client speaking IPC directly gets the same treatment.
The plain-text alternative
Section titled “The plain-text alternative”Every HTML message goes out as multipart/alternative with a text/plain half.
Supply it with --text-file, or let mxr generate one from the HTML — the same
renderer the reader uses. Generating it does not alter the HTML.
Inline images
Section titled “Inline images”--inline NAME=PATH attaches an image as a CID-referenced part so the HTML can
reference it:
<img src="cid:notto-logo" alt="Notto">The parts nest as multipart/related wrapping the alternative, which is the
shape Gmail’s own composer produces and the one clients reliably render. Using
multipart/mixed instead is the common mistake that makes inline images show up
as attachments. If the HTML references a cid: no --inline provides, mxr
warns but does not block — the author may know something mxr does not.
Signatures
Section titled “Signatures”A signature is never injected into supplied HTML. Splicing markdown into a
designed document is exactly the kind of silent edit this feature exists to
avoid. Opt in explicitly with --signature-html <path>, which appends before
the closing </body>. Markdown composition is unchanged: it still appends the
account signature as before.
Editing an HTML draft
Section titled “Editing an HTML draft”mxr drafts edit refuses an HTML draft. The compose file is markdown, and
round-tripping a designed document through it would destroy the markup. Edit the
source file and create a new draft.
Upgrading from an earlier version
Section titled “Upgrading from an earlier version”Schema migration 049 adds four columns to drafts (body_html, body_text,
inline_assets, content_kind). It is additive and forward-only: existing
drafts keep their body_markdown, default to content_kind = 'markdown', and
are not rewritten. Nothing needs to be exported or re-created.
The JSON a draft serialises to is unchanged for markdown drafts — still
{"body_markdown": "..."} — so existing scripts keep working. HTML drafts
carry body_html and body_text instead, which older clients simply will not
recognise as a body they can render.
A caveat about Gmail
Section titled “A caveat about Gmail”mxr guarantees the MIME it produces. What Gmail’s API then does on send is
outside its control, and Gmail is known to re-serialise outgoing messages: it
rewrites MIME boundaries and replaces a supplied text/plain part with one it
generates from the HTML. Stored drafts (--draft) are not affected, and the
SMTP path transmits mxr’s bytes unchanged. If byte-level fidelity at the
recipient matters, prefer an SMTP account.
Reply context
Section titled “Reply context”Reply and forward drafts include message context. If the original message only had HTML, mxr uses the rendered reader output, not raw HTML tags.
Reply recipient
Section titled “Reply recipient”A reply targets the original message’s Reply-To: header when the sender set one, falling back to From: otherwise. This is what mailing lists and no-reply@ senders rely on — a reply to a list digest goes to the list, not the unmonitored sender address. reply-all adds the other original recipients as Cc on top of that target.
The reply’s own From defaults to the owned address the original was delivered to — see Sending from an alias.
Send confirmation
Section titled “Send confirmation”After the editor closes, mxr shows a confirmation modal:
- Changed draft: send, save draft, edit again, discard
- Unchanged draft: edit again or discard
The modal also shows the pre-send safety verdict
(SAFE / WARN / BLOCKED) and any issues found. Blocker issues require a
fix or override token before the s (send) key is enabled.
Pre-send safety
Section titled “Pre-send safety”Every send runs through a six-check safety pipeline: wrong recipient, missing attachment, reply-all sanity, PII/secrets, tone mismatch, and answer-coverage. The pipeline runs in three places:
- The TUI send-confirm modal after
Ctrl-x(changed draft). - The CLI on
mxr send DRAFT_ID(gates the send) andmxr send DRAFT_ID --check(dry-run only). - The scheduled-send flusher when a scheduled send fires.
# Dry-run a stored draft (no provider call). Exit 2 on Blocker.mxr send DRAFT_ID --check --format json
# Same idea, but for a transient draft built from CLI args — no daemon# row created.mxr compose --to alice@example.com --body 'see attached' --check
# If --check turned up a Blocker you accept (e.g. you really do mean# to email competitor.com), it minted a single-use override token.mxr send DRAFT_ID --override-safety OVERRIDE_TOKEN_FROM_CHECKAccount selection
Section titled “Account selection”mxr compose --from work --to alice@example.com --subject "Follow-up"mxr reply MESSAGE_ID --account work --body "Thanks." --dry-runmxr forward MESSAGE_ID --account personal --to bob@example.com --dry-runWhat you get: a new draft from the selected sender, plus reply/forward previews that first confirm the original message belongs to that account.
New compose uses --from <account-or-address> to choose the sender.
Replies and forwards can also take --account <selector> to assert which
account owns the original message before drafting.
Those are two related choices, not one identity. An account key such as
--from work selects that account and uses its configured account email. An
owned address such as --from accounts@example.com selects the account that
owns the address and uses that exact address as the message From. If more than
one account owns the address, add --account to disambiguate it.
The sender address comes from the selected/default runtime account, not from a static status snapshot. This matters for multi-account setups.
mxr drafts --account work --format jsonSending from an alias (per-message From)
Section titled “Sending from an alias (per-message From)”An account can own several addresses: its primary plus any aliases you
register with mxr accounts addresses add. Any owned address can be the From on a per-message basis —
handy for a shared mailbox with hello@, accounts@, and support@ on one
account.
Pick the sender three ways, all landing on the same daemon-side path:
# --from accepts a registered alias on compose, reply, reply-all, and forward.mxr compose --from accounts@planetaryescape.xyz --to client@example.com \ --subject "Invoice 42" --dry-run
mxr reply MESSAGE_ID --from support@planetaryescape.xyz --body "On it."# ...or edit the `from:` frontmatter field in $EDITOR:---to: client@example.comsubject: Invoice 42from: accounts@planetaryescape.xyz---Rules:
- Owned addresses only. The chosen From must be the account’s primary or a
registered alias (matched case-insensitively). An unowned address is rejected
before anything is sent, with the list of registered addresses in the error —
the same check on a real send, a
--dry-run, and the send-confirm modal. - Replies default to the delivered-to address. A reply, reply-all, or
forward starts from whichever owned address the original was delivered to
(its
To/Cc/Delivered-To), falling back to the primary. So a message that arrived atsupport@is answered fromsupport@unless you override it. --from/from:win. An explicit value overrides the reply default.- The From flows into both the message
From:header and the SMTP envelope sender;--dry-run --format jsonreports the effectivefrom. - Provider permission is separate. Registering an owned address in mxr does not create the alias or grant send-as permission at Zoho, Gmail, or an SMTP provider. Configure the alias there first. A dry-run proves mxr’s local account and From resolution; the provider still makes the final acceptance decision when you send.
Attachments
Section titled “Attachments”CLI compose supports:
mxr compose --attach ./invoice.pdf --attach ./notes.txtEvery compose flow stores attachment paths in the draft’s attach: frontmatter.
That gives replies, reply-all drafts, and forwards the same editor escape hatch
even when a client does not expose a dedicated attachment control:
---to: - team@example.comsubject: Re: Investor letterattach: - /Users/alice/Documents/investor-letter.docx---
Please find my rewrite attached.Add or remove those paths while the draft is open in $EDITOR, or reopen a
saved draft first:
mxr drafts edit DRAFT_IDTUI message viewing supports attachment open/download. Compose-side attachment management is through the editor and CLI.
Snippets
Section titled “Snippets”mxr can store short stock replies for reuse in compose flows. Use
mxr snippets set thanks "Thanks for reaching out, will follow up shortly."
once, then browse or copy it when drafting. Built-in variables:
{first_name}, {date}, {thread_subject}.
mxr snippets listmxr snippets set decline "Thanks for the offer; can't take this on right now." --vars ""mxr snippets remove declineThe TUI exposes a read-only snippets browser via Ctrl-p → Snippets.
Recipes
Section titled “Recipes”# Send a quick reply from the command line, body via heredocmxr reply MESSAGE_ID --body-stdin <<'EOF'Confirmed for Friday at 14:00 GMT.EOF
# Compose with multiple attachments and dry-run before sendingmxr compose --to team@example.com --subject "Q1 numbers" \ --attach ~/work/q1-summary.pdf \ --attach ~/work/q1-charts.png \ --dry-run
# Reply to every flagged "reply later" item in a row, interactivelymxr replies --format ids | while read id; do mxr cat "$id" --view reader read -p "Reply? [y/N/q] " a < /dev/tty case "$a" in y) mxr reply "$id" ;; q) break ;; esacdoneCrash safety
Section titled “Crash safety”A draft you’re editing lives in SQLite. If the daemon dies mid-send,
the row sits in 'sending' state — and the next daemon startup auto-
resets anything older than an hour back to 'draft'. To act sooner:
mxr drafts recover # show orphansmxr drafts resume DRAFT_ID # back to 'draft'; retry with mxr sendmxr drafts discard DRAFT_ID # permanently deleteSee the crash-safe drafts guide for the full state machine.
In real life
Section titled “In real life”- You write better at night, send better in the morning: compose
the draft, save it (
mxr drafts), then send tomorrow withmxr send DRAFT_ID --at 'tomorrow 9am'. - Repetitive client onboarding emails: keep a
;welcomesnippet with{first_name}placeholders. - Composing from a script:
--body-stdinlets you pipe rendered Markdown / templated output straight into a reply without ever opening an editor.
Agent prompts that work
Section titled “Agent prompts that work”"Draft a polite decline to the latest message from acme.com using myprior tone. Don't send — write to a draft so I can review with `mxrdrafts`. Use `mxr draft-assist` for the body.""Schedule a Friday-afternoon nudge to anyone I haven't replied to in 7days. Use `mxr stale --mine --older-than-days 7 --format ids | xargs-I{} mxr remind {} --when 'friday 16:00'`. Show me what would fire."See also
Section titled “See also”- Pre-send safety — the six checks every draft passes through, plus the override-token flow
- Crash-safe drafts
- LLM features — draft assist
- Recipes — compose loops
- CLI —
mxr composeandmxr drafts