# Compose and send
**Resolves backlog item 123.** Opens follow-up items for an outbox, inline
images, save-message-as-attachment, configurable markdown dialect, and a review
of the every-action-has-a-shortcut rule; those are listed at the end.
**Status: design only.** No code has been written. This document is the output
of the brainstorm the item asked for, on the branch its `#new-branch` tag asked
for.
v1 was read-and-organize. This is the other half.
## What decides the shape
Two facts about the machine, measured on 2026-08-20 and recorded in item 123,
constrain everything below.
**There is no MTA.** `msmtp` and `sendmail` are both absent. neomutt, the
application this one mirrors, sends over its own built-in SMTP configured per
account in `~/.config/neomutt/accounts/*.rc`. So the sentence "sending should be
an external script on the same model as `mailsync.sh`" describes a model that
does not exist on this machine and would have to be created.
**`CLAUDE.md` records that this application does no network protocol work at
all.** Fetching is `mbsync` through `assets/mailsync.sh`. That rule is
load-bearing: a socket in the process that also runs a browser engine is a
different project with a different security surface.
The decision taken is to keep the rule and make the send path a **configured
command**, exactly as `[sync] command` already is. The application never learns
what SMTP is. What the user installs behind that command is theirs to choose.
## Decisions
Each of these was settled in the brainstorm. The reasoning is kept because the
alternatives are all plausible and will be proposed again otherwise.
### Sending is a per-account command
```ini
[account.work]
send_command = msmtp -a work -t
```
The command receives the complete RFC822 message on **stdin**. Its exit status
is the result: 0 is success, anything else is failure and its stderr is shown.
This is `[sync] command`'s contract, deliberately. `mailsync.sh` already
documents why an honest exit status matters: a `0` from a failed sync makes the
application report success for work that never happened.
The application names no particular MTA. msmtp is the obvious thing to point it
at and is not required.
**Two properties are security-relevant.** The command string is split into an
argument list and run without a shell, so nothing in a message body, a
recipient address or a display name can reach `sh`. And **no message content is
ever placed in an argument**: recipients come from the message's own headers,
which is what `-t` means in the example, not from the command line.
### An account that has no `send_command` is receive-only
Not a separate key. The capability *is* the command's presence, so a
receive-only account is defined by omitting the same key that a sending account
sets. There is nothing to keep in step and nothing to contradict.
One of this user's five accounts is receive-only on purpose. It gains no
configuration, which is the whole point: the shape is expressed by omission.
(`listsonly` stands in for it below.)
On a message that arrived at such an account, `reply`, `reply_all`,
`reply_no_quote` and `forward` are **disabled**, and `MessageView` shows a
ribbon saying why and how to change it, naming the account:
> This account is receive-only. Add `send_command` to
> `[account.listsonly]` to send from it.
The ribbon is a widget in `MessageView`'s layout, **not** markup inside the web
view. Composing HTML from configuration into the one document that renders
input from strangers is the wrong direction, and the header row is already a
widget for the same reason.
Forward is disabled along with reply, rather than staying enabled with a
substituted From. One rule ("this account cannot compose") is easier to explain
than two, and the escape hatch is `save_message` (below), which writes the raw
message to a file that can then be attached to a new message from an account
that can send.
`compose` is disabled only when **no** account can send. An installation with
no `send_command` anywhere is a valid read-only installation and is not warned
about.
### The composer is a separate top-level window
`ComposeWindow`, a `QMainWindow`, one per draft, several open at once.
A modal dialog cannot consult another message while writing, which is most of
what replying is. Taking over the message pane fights the pane that exists to
show what is being replied to.
**No geometry restore.** `CLAUDE.md` records what `saveGeometry` does under a
tiling compositor: it stores `normalGeometry`, the compositor owns the tile, and
the restore is correct while looking broken. A whole session went into that
once. The composer opens at a sensible default size and the compositor places
it.
### Drafts autosave to the account's drafts folder
Every account already configures `drafts`, and notmuch indexes it, so the
destination was settled before this design started.
**30 second debounce, write only when the content changed since the last
write.** The previous revision is unlinked on each rewrite, because Maildir has
no in-place edit and drafts would otherwise accumulate one file per pause.
The cost, stated plainly: every autosave produces a Maildir write that mbsync
uploads. Thirty seconds and a dirty check is what keeps that to a few revisions
per message rather than dozens. The benefit is that a draft is visible to
neomutt, to the server and to a phone, which is the point of putting it there
rather than in a local scratch directory.
A composer whose autosave **failed** shows it. It must not interrupt typing and
it must not silently succeed, because the quit path's honesty depends on
knowing what is actually on disk.
### The body is markdown, rendered by cmark-gfm
The editor holds plain text and what is typed is markdown. That source is what
the `text/plain` part carries, unmodified; the `text/html` part is generated
from it.
**Plain-text storage does not mean a bare text box.** The two are separate
decisions and it is worth stating the second explicitly, because "the editor is
plain text" reads as "you are on your own with the syntax". The composer carries
a formatting toolbar with `Ctrl+B`-style shortcuts, described below.
**cmark-gfm, not a hand-written parser for a limited set.** A three-rule parser
and a real markdown parser share no code, so the first is deleted entirely when
the second arrives. The parser accepts CommonMark from the first commit; what is
"limited" initially is the set of affordances and help text the composer
advertises, not what it can parse.
**cmark-gfm rather than cmark**, for autolink. Under plain CommonMark a bare
`https://example.org` in a mail body is not a link, and a bare URL in mail is
expected to be clickable.
Extensions enabled: **autolink, strikethrough, tasklist**. Tables are off: they
render badly across mail clients regardless of who generates them. Tagfilter is
off because raw HTML is already suppressed wholesale.
Raw HTML in the input is refused (`CMARK_OPT_SAFE`). The body is the user's own
text, but a body that can inject markup into its own generated HTML part is a
sharp edge with no upside.
Known ceiling: a tasklist renders as ``, which
many mail clients strip, so those recipients see the list item with no marker.
The plain part still shows `- [ ]` correctly, so nothing is lost.
**It is the only new dependency, and it is cheap.** `cmark-gfm` ships in stock
Slackware (`cmark-gfm-0.29.0.gfm.13-x86_64-3`, verified 2026-08-20) with a
pkg-config file, so it is a `pkg_check_modules` line beside the one GMime
already has, and the SlackBuild needs no `REQUIRES` entry, which lists only
non-stock dependencies. Note the version: cmark-gfm tracks an older CommonMark
base than plain cmark (0.31.2 here), which is a real if small staleness cost
accepted in exchange for autolink.
Neither Qt's `QTextDocument::setMarkdown` nor plain cmark was chosen. Qt's
markdown is a display facility whose `toHtml()` emits markup styled for
`QTextEdit`, which would need unpicking before it is fit to send: the same
throwaway problem one level up.
### The composer has a formatting toolbar
Each button is a **text transformation over the markdown source**, not
rich-text editing. Nothing about the buffer changes: it stays markdown that the
user can also type by hand.
| Button | Wraps in | Shortcut |
|---|---|---|
| Bold | `**` | `Ctrl+B` |
| Italic | `*` | `Ctrl+I` |
| Code | `` ` `` | `Ctrl+`` ` `` |
| Strikethrough | `~~` | none |
| Link | `[text](url)` | `Ctrl+K` |
| Quote | `> ` per line | none |
**Selection-aware.** With a selection, the tokens wrap it and the selection is
preserved. With none, the pair is inserted with the cursor between them, so
typing continues inside. Quote is line-based rather than a wrap, applying to
every line the selection touches.
**These shortcuts do not touch `KeyMap`.** They belong to the composer window,
which is a separate shortcut scope, so `Ctrl+B` here does not consume `Ctrl+B`
from the main window's map and does not participate in the
every-action-has-a-shortcut rule. Keeping the two namespaces apart matters for
item 132.
**No live syntax highlighting** in this design. A `QSyntaxHighlighter` colouring
`**bold**` in the editor is standard Qt and needs no dependency, but it has to
agree with the markdown grammar about nesting and about code spans suppressing
what is inside them, which is the sort of thing that looks nearly right and
stays annoying for years. It is a follow-up, better judged after living with the
toolbar.
### Whether the HTML part is sent is per-message
`sendHtml` decides whether the message is `multipart/alternative` (plain +
HTML) or `text/plain` alone. Both are built from the same source, so this is one
output branch rather than two editors.
The composer always shows the toggle and it is **never remembered**. What varies
is the seed:
- **New, Forward** seed from `[compose] send_html`.
- **Reply, Reply-all** seed from whether the original carried a `text/html`
part, ignoring the config value.
The reply seed is evidence rather than inference: an HTML part in the original
is a fact about the sender's software, not a guess about their taste. Since it
seeds a visible toggle, a wrong seed costs one keystroke.
No per-recipient memory. That is an address-book feature and item 72 (khard) is
already queued behind this one.
The toggle is labelled for what it does, a formatted copy riding along with the
plain text, rather than "HTML", which reads as an either/or that it is not.
### Quote position is configuration, quoting on or off is a gesture
`[compose] quote_position = above|below`, default `above`.
Whether to quote at all is per-message, and it is decided by **which action was
invoked**: `reply` quotes, `reply_no_quote` does not. The quote is inserted or
not when the window opens, and after that the buffer is text the user owns.
There is deliberately no live toggle that inserts and removes the quote while
editing. Tracking "my text" and "the quote" as separate pieces to make a toggle
reversible is machinery for a case that is answered by closing the composer and
reopening it, or by deleting the quote by hand.
### MIME is built by GMime
Already a dependency, already linked, already how this application parses mail.
The alternative is assembling RFC822 by string, which means reimplementing RFC
2047 header encoding, quoted-printable for accented bodies, boundary
uniqueness and line-length limits. This user writes Italian; a body containing
`è` is every message, not an edge case. A bug there produces mail that looks
correct locally and arrives as mojibake.
**One built message object serves three consumers**: the autosaved draft, the
bytes on the send command's stdin, and the sent copy. A draft is therefore
byte-identical to what would be sent.
The include-order rule applies to every new file that touches GMime: gmime
headers before any Qt header in the same translation unit, because glib
declares a struct field named `signals`.
### Sending runs behind a cancellable delay, and does not queue
**Send opens a popup that owns the whole operation**, from a cancellable
countdown through to completion. It is modelled on Gmail's undo-send, and it is
what settles what "cancel" can mean here.
Three rows, the same three in every state, so nothing reflows and the window
never jumps:
```
+-----------------------------------------+
| Sending in 5... | status label
| ##################................. | bar, DETERMINATE, draining
| [ Undo ] | enabled
+-----------------------------------------+
+-----------------------------------------+
| Sending... | then: Filing sent copy...
| #######################################| bar, INDETERMINATE
| [ Undo ] | visible, disabled
+-----------------------------------------+
```
**The bar changes mode, it does not change place.** During the countdown it is
determinate and drains as the seconds pass, because a countdown has measurable
progress. It becomes indeterminate (`setRange(0, 0)`) when `send_command`
starts, because a send does not. This is why item 134's widget class must expose
both modes rather than hardcoding the indeterminate one.
**Undo stays visible after it disables**, rather than disappearing. A control
that vanishes re-lays out the popup mid-operation, and a greyed Undo says why
cancelling is no longer possible where an absent one only looks like it was
never offered.
The stages, in order:
```
Sending in N... the countdown, Undo live
Sending... send_command running, Undo disabled
Filing sent copy... DraftStore writing to the account's sent folder
Removing draft... the draft revision is unlinked
popup and composer both close
```
**The status label takes its width from the longest string it can hold, in the
current language, not from its content.** Italian "Rimozione della bozza..." is
longer than "Removing draft...", so a label sized to content resizes the popup
between stages, which is the jumping the fixed layout exists to avoid.
**The delay is where cancelling is safe, and it is the only place it is.**
Nothing has reached a server during the countdown, so Undo means genuinely
nothing happened. Killing `send_command` once it is running does not: the
message may have been handed to the server in full before the kill, so the
result is an *unknown* send, which is worse than either clean outcome. Undo
therefore disables itself the moment the command starts, and there is no cancel
after that.
This is what the delay buys in practice, and it is the case the user named:
pressing Send and immediately noticing the missing attachment. Undo returns the
composer exactly as it was, editable, popup gone, nothing sent.
**In a popup rather than the composer's status bar.** A countdown nobody notices
is a countdown that does not work, and the objection to status bars is precisely
that they do not catch the eye. The same reasoning that puts a failed sent copy
in a modal puts the undo window in one.
**The popup owns the progress too**, rather than handing over to the status bar
once the countdown elapses: one widget changing state in one place, instead of a
popup vanishing and something else appearing somewhere else.
Two properties it must have:
- **Modal to the composer, not to the application.** Sending from one composer
must not freeze a second composer or the main window.
- **No close button and no Escape dismiss.** During the countdown a dismissal is
ambiguous, since it could mean cancel or send now. During the send there is
nothing to dismiss. Undo is the only control and it disables itself.
The composer's inputs are disabled for the whole operation, countdown included:
body, recipient fields, subject, attachment controls, formatting toolbar and
Send. The message must not change between the user pressing Send and the bytes
being built. Undo re-enables all of it.
`[compose] send_delay_ms` sets the countdown, default 5000. **Zero skips it**
and sends at once, for anyone who finds it irritating.
The progress element is an **indeterminate `QProgressBar`** (`setRange(0, 0)`)
beside a label, which is what `MainWindow` already does for the sync indicator
(`m_syncProgress`) and for the same reason: neither operation has measurable
progress.
**This is the second instance of that pairing, so it becomes a widget class**
rather than a second inline build. Item 134 covers extracting it and converting
`MainWindow` to use it. Building the same thing twice is where a class earns
itself, and "this codebase builds small UI inline" describes what the code does
rather than justifying repeating it. Whether 134 lands before or after this
work, the composer uses the shared widget: if it has not happened yet, this work
creates the class and converts `MainWindow` as part of the same change.
Exit 0 closes the popup and the composer. Non-zero closes the popup and
re-enables the composer with everything intact, showing the command's stderr.
**There is no outbox in this design**, and the reason it is not simply
"deferred" is that it needs its own indicator story. This project has four
closed items (18, 19, 28, 54) about an indicator lying, and one open one (125)
about a spinner that never stops. An outbox adds a queue whose failures surface
long after the user stopped thinking about the message.
**The seam is designed in.** `MessageSender` takes a built message and an
account and returns a result. It knows nothing about composers. An outbox is
built around that funnel by calling it from a drain loop; nothing in the
composer needs to change. That is the whole reason it is a separate unit rather
than a method on the window.
### The sent copy is written locally
After a successful send, the same bytes are written to `//cur/`
with the Maildir `S` flag. The next `notmuch new` indexes it.
An account with `send_command` but no `sent` key sends correctly and files
nothing, with a startup warning. That is the case for a provider whose own SMTP
files sent mail server-side, and it needs no second key to express.
**A failed sent-copy write is never reported as a send failure.** The message
went. Reporting otherwise makes someone send it twice.
### Nothing here calls `notmuch new`
Drafts and sent copies become visible on the next sync, cron's or the user's.
No write path needs the notmuch write lock, and the read-only-by-default rule
in `CLAUDE.md` is untouched.
## Architecture
Four new units. Three of them have no widgets and are tested without a painter,
in the manner `SearchTerm`, `CardLayout` and `MimeParser` already are.
```
ComposeWindow (QMainWindow, one per draft)
├ recipient fields, subject, body editor, attachment bar
├ the send-html toggle
├ the 30s dirty-debounce autosave timer
└ composes the three below; contains no MIME and no process logic
MessageBuilder (GMime + cmark-gfm) pure construction, no I/O beyond attachments
MessageSender (QProcess) the one send funnel; the outbox seam
DraftStore (Maildir writes) drafts and sent copies; same operation, two folders
```
Existing units touched: `Config` (new keys and their validation), `KeyMap` (six
actions), `MessageView` (the receive-only ribbon), `MainWindow` (the actions,
the account-resolution rules, the composer registry, the quit path).
**The composer never touches `NotmuchWorker`.** It reads its context from the
database once at open time through the existing worker, then works entirely in
files. No new worker signals, no new generation counters.
The boundary that matters: `ComposeWindow` is the only unit that knows about
widgets. A composer bug and a MIME bug are found in different files.
## Data flow
Two structs cross boundaries, in `types.h` beside the existing ones.
**`ComposeContext`**, what opens a composer. Built by `MainWindow`, consumed by
`ComposeWindow`.
| field | meaning |
|---|---|
| `accountKey` | which account sends, resolved by the rules below |
| `kind` | New, Reply, ReplyAll, Forward |
| `originalPath` | the `.eml` being replied to or forwarded; empty for New |
| `inReplyTo` | Message-ID of the original |
| `references` | the original's References plus its Message-ID |
| `to`, `cc` | pre-filled recipients, the user's own addresses already stripped; a reply to the user's OWN message is addressed to that message's recipients instead of back to the user, mirroring its To/Cc split (see Replying to oneself) |
| `subject` | `Re:` / `Fwd:` prefixed, an existing prefix not doubled |
| `quotedBody` | the `>`-prefixed original; empty when the action does not quote |
| `seedHtml` | did the original carry a `text/html` part |
| `attachments` | carried forward for Forward, empty otherwise |
**`OutgoingMessage`**, what the composer produces, consumed by
`MessageBuilder`.
| field | meaning |
|---|---|
| `accountKey`, `to`, `cc`, `bcc`, `subject` | as edited |
| `markdownBody` | the source text, exactly as typed |
| `sendHtml` | the composer's per-message toggle |
| `attachments` | local paths |
| `inReplyTo`, `references` | carried through unchanged |
Message-ids are carried BARE, without angle brackets, matching what GMime hands
back when `MimeParser` reads a `Message-ID`. `MessageBuilder` adds the brackets
when it writes the header, in one place rather than in each caller: they are wire
syntax, and GMime writes an EMPTY header for a bare addr-spec rather than
complaining, so a caller that forgets them ships a reply that threads nowhere
while nothing looks wrong locally.
`In-Reply-To` and `References` are not optional. Without them a reply appears as
an orphan thread in the sender's own client.
**Opening.** Action fires. `MainWindow` resolves the displayed message, builds a
`ComposeContext` **from the database rather than the model**, constructs a
`ComposeWindow`, and registers it so the quit path can see it.
Reading the database rather than the model is the rule Restore already follows,
and `CLAUDE.md` records why: the model's data comes from the query, so a row
whose state has not been re-queried carries stale values. A reply built from a
stale row would carry the wrong recipients.
**Autosave.** 30s idle, content changed → build `OutgoingMessage` →
`MessageBuilder` → `DraftStore` writes to the drafts folder and unlinks the
previous revision.
**Sending.** Send → build → `MessageSender` runs the command with the bytes on
stdin → 0: file the sent copy, delete the draft, close. Non-zero: re-enable,
show stderr, leave the draft.
## Which account sends
The displayed message's own maildir is the strongest available signal and wins
outright for **Reply, Reply-all and Forward**. Mail sent to an address landed in
that address's maildir, so replying from it is what the recipient expects. The
account dropdown is **not** consulted: replying from the All accounts view to a
message that arrived at account B sends from B.
A message can be in more than one maildir, on a list twice under two
addresses, or duplicated across accounts by mbsync, and notmuch returns
several filenames for one message id. Prefer the account matching a recipient in
`To` or `Cc`; failing that take the first. The From field shows the choice, so an
ambiguity resolved arbitrarily is visible rather than hidden.
The reply actions never need a fallback for "the resolved account cannot send",
because on such a message they are disabled and there is no composer.
For a **New message** there is nothing to resolve from:
1. The dropdown's current account, when it is a specific one and it can send.
2. `[compose] default_account`, when set and it can send.
3. `[general] startup_account`, on the same condition.
4. The first account in configuration order with a `send_command`.
The All accounts view falls through to 2. Rule 4 is arbitrary and is the reason
rules 2 and 3 exist.
**The From field is always editable** and lists every sending account, which is
what makes every rule above a default rather than a decision.
## Actions
Six, each needing the five places `CLAUDE.md` enumerates: `knownActions()`,
`defaultBindings()`, the icon table, a menu, and a handler.
| Action | Meaning | Scope |
|---|---|---|
| `compose` | New message | none needed |
| `reply` | Reply to the displayed message, quoted | sender only, except when the sender is the user (see below) |
| `reply_all` | Reply to all, quoted | sender + To + Cc, own addresses removed |
| `reply_no_quote` | Reply with an empty body | sender only, same exception |
| `forward` | Forward, body quoted inline, attachments carried | none |
| `save_message` | Write the raw `.eml` to a chosen path | any message |
`reply_all_no_quote` is deliberately absent. Six actions is already a large
menu and the combination is reached by deleting the quote.
### Replying to oneself
A reply whose sender is entirely the user's own addresses is addressed to that
message's **original recipients** rather than to the sender. A plain reply takes
its To and Cc together, having no Cc field of its own to mirror into. A
reply-all MIRRORS THE SPLIT: the original's To becomes To and its Cc becomes Cc,
because To means "addressed to you" and Cc "for information", and promoting a
Cc'd party to To is a change every recipient can see.
This is an ordinary gesture rather than an edge case: it is reached from the
Sent view, from a follow-up on mail that went unanswered, and from any thread
whose selected row is the user's own message. Addressing the sender there
addresses the user, so the reply reaches nobody it was meant for.
"Own" means EVERY parsed sender address is the user's. A message the user sent
together with somebody else is still a reply to that co-sender, and takes the
ordinary sender-only path.
Mail the user sent to THEMSELVES alone leaves nothing after own addresses are
removed, and there the sender is restored: the user is the correct recipient of
their own note. The rejected alternative was to strip the sender and leave To
empty, which silently drops every recipient while the message still looks
sendable.
**Every action acts on the displayed message**, resolved with
`messageScopeFor()` semantics: a thread row means the one message its card
shows, a reply row means itself. Not `threadFor()`. Replying to a thread is
meaningless; a reply answers a message.
`save_message` is never disabled, including on a receive-only account. It is the
escape hatch for that case.
**Provisional key bindings.** All chords; the map has no bare letters, for the
reason `defaultBindings()` records at length.
| Action | Key | Why |
|---|---|---|
| `compose` | `Ctrl+N` | conventional; free |
| `reply` | `Ctrl+Shift+R` | `Ctrl+R` is `restore` |
| `reply_all` | `Ctrl+Shift+A` | `Ctrl+A` is `select_all` |
| `reply_no_quote` | `Ctrl+Alt+R` | the Ctrl+Alt tier, as the thread actions use it |
| `forward` | `Ctrl+Shift+F` | keeps the family on one modifier pattern |
| `save_message` | `Ctrl+Shift+E` | export; `Ctrl+Alt+S` is `spam_thread` |
These are provisional: the user intends to rework the bindings, and
`reply_no_quote` on `Ctrl+Alt+R` is an imperfect fit, since that tier elsewhere
means "wider scope" rather than "variant".
**A new top-level `Message` menu.** Six actions do not belong bolted onto an
existing one, and `everyActionIsReachableFromAMenu()` fails loudly if one is
missed.
**Toolbar: `compose` and `reply` only.** The rest are menu-and-key, which keeps
the no-duplicate-icons rule satisfiable.
## Configuration
```ini
[account.work]
send_command = msmtp -a work -t
[compose]
quote_position = above ; above | below
send_html = true ; seeds New and Forward; Reply seeds from the original
autosave_interval_ms = 30000
send_delay_ms = 5000 ; the undo window before sending; 0 sends at once
default_account = work
attachment_warn_bytes = 26214400
```
Every `[compose]` key is optional with the default shown.
**Startup validation**, following the pattern that already warns about an
unresolvable `startup_query`:
- `send_command` present, `sent` absent → sends work, no local copy is filed.
- `send_command` present, `drafts` absent → the composer runs without draft
protection.
- `default_account` names an account that cannot send → warn, fall through.
- No account can send → **no warning**; a read-only installation is valid and
the compose actions are simply disabled.
**Deliberately not configurable:** where drafts and sent copies go, and the
markdown dialect.
## Error handling
**The surface is chosen by consequence, not by convenience.** A warning the user
does not see is the same defect as an indicator that lies, and the status bar
does not catch the eye. The rule for this design:
| Consequence | Surface |
|---|---|
| Silent divergence the user would not otherwise discover | modal dialog |
| Something needing attention while they are mid-task | persistent banner, does not fade |
| Routine, self-correcting, or already visible | status bar, or nothing |
The status bar is for what is already obvious. Nothing whose failure the user
would learn about months later belongs there.
**Send failed** (non-zero exit). Composer re-enabled intact, stderr shown in a
pane below the body, in the shape `MailSync`'s log pane already has. The draft
stays. No retry loop.
**Exit 75 has no special meaning here.** `MessageSender` has exactly two
outcomes. Item 125 is open precisely because the *sync* path treats 75 as
neither success nor failure and hangs on it; that exists because `mailsync.sh`
contends for a lock, and there is no lock here. This is recorded so the two
paths are not later "harmonised".
**Command missing or unrunnable** (`QProcess::FailedToStart`). Reported as a
failure naming the command, since a typo'd path is the likely cause.
**Draft write failed.** A **persistent banner** in the composer, not a modal and
not a status-bar line that fades. A modal mid-sentence is hostile while the user
is typing, but the warning must survive until it is dealt with, because the quit
path's honesty depends on it: case 3 below escalates exactly this state to a
dialog on the way out.
**Sent copy write failed after a successful send.** A warning saying exactly
that. Never a send failure, never an offer to resend.
The staged progress display makes this visible rather than confusing: the
failure arrives while the status bar reads "Filing sent copy...", so the user
can see the send stage already passed. The composer still **closes**, because
the message went and holding a composer open for a message already sent invites
sending it twice.
**It is reported with a modal dialog, not a status-bar line.** This is the one
failure in the whole design that produces a silent divergence between what the
recipient received and what the local archive shows, and a status bar does not
catch the eye. Nobody discovers a missing sent copy by noticing a line that
appeared for a few seconds; they discover it months later by looking for a
message that is not there. The dialog names the account and the folder it could
not write to.
**An attachment vanished between attaching and sending.** Send is refused before
the command runs, naming the file. Checked at build time, not at attach time.
**Quitting with composers open.**
1. Every composer clean → quit directly, no dialog.
2. Any composer with unsaved edits → **one** dialog, whatever the count:
*"N messages are still being composed."* with `[Save drafts and quit]`,
`[Discard and quit]`, `[Cancel]`. It applies to all of them; there is no
per-draft choice, because three modals in a row is worse than a coarse
answer.
3. Any composer whose last autosave **failed** → a dialog that says so, naming
it, offering a retry. The risk here is different: in case 2 nothing is lost
by saving, in case 3 saving is what is already not working, so the dialog
states plainly that quitting loses that text.
"Discard and quit" discards **unsaved edits**, not drafts. A draft already
autosaved stays in the folder. The wording must not read as "delete my three
messages".
**Not handled, deliberately:** network errors, authentication failures, server
rejections. Those belong to `send_command` and its stderr is shown verbatim.
## Testing
**`test_messagebuilder`** carries the bulk, being pure. Fixture-driven like
`test_mimeparser`, asserting on the **generated bytes** rather than on a
round-trip through `MimeParser`, since a builder and a parser that agree can be
wrong together.
Cases: `multipart/alternative` when `sendHtml` is on and `text/plain` alone when
off; `multipart/mixed` nesting with attachments; each enabled extension
rendering, and tables and raw HTML **not** rendering; RFC 2047 encoding of a
non-ASCII subject and display name; quoted-printable for an accented body;
`In-Reply-To` and `References` carried; `Re:` and `Fwd:` not doubling, in the
non-English spellings and counted forms as well as the English ones.
**`test_messagesender`** uses stub commands, not msmtp: one exiting 0, one
exiting non-zero with stderr, one that does not exist. The stub writes stdin to
a file the test reads back, proving the message arrived intact. Asserts exactly
two outcomes.
**The send delay needs its own test, and the property that matters is a
negative one**: that Undo during the countdown leaves the stub command **never
run at all**. A test asserting only that the composer reopened would pass
against a design that ran the command and threw the result away, which is the
whole failure the delay exists to prevent. Assert the stub wrote no file.
Also: `send_delay_ms = 0` skips the countdown, and Undo is disabled the instant
the command starts. Drive the countdown with a short delay rather than waiting
five seconds in a test.
**`test_draftstore`**: Maildir filename validity and uniqueness, the previous
revision unlinked, the dirty check skipping a write, and an unwritable directory
reporting rather than throwing.
**`test_composecontext`**: recipient derivation is where the subtle bugs live
and it is pure logic, so it is tested apart from the window. All five of the
user's addresses stripped from a reply-all; every account-resolution rule
including the multi-maildir ambiguity; subject prefixing.
**The formatting toolbar** is tested through its transformations, not through
the widget: wrap with a selection, insert with none, quote applying per line,
and the cursor landing between the tokens in the empty-selection case. That last
one is the property a user notices immediately when it is wrong, and it is
invisible to a test that only compares the resulting text.
**In `test_mainwindow`**: action enablement against a receive-only account, the
ribbon appearing, and `everyActionIsReachableFromAMenu()` covering the six new
actions for free. `WorkerBackedWindow` gains a knob for writing an account
without `send_command` rather than a new fixture class.
**Not tested, and stated so nobody tries.** Composer window geometry: the
offscreen platform returns an identical frame for a correct restore and a broken
one, verified in a standalone program. The actual send: there is no MTA and
there will not be one in CI. How the HTML part renders in any real mail client:
that is a hand test and belongs to the user.
## Follow-up items
Deliberately out of scope here, each worth its own backlog entry.
- **An outbox.** Queue and drain rather than blocking. The seam is
`MessageSender`. Needs its own indicator story before it is built.
- **Inline images.** `cid:` references from the HTML part, `multipart/related`
nested inside the alternative. Wanted by the user; the most nesting-heavy part
of MIME assembly, and markdown offers no natural syntax for it.
- **Save a message as an attachment.** Attach a `message/rfc822` part directly,
rather than saving to a file and re-attaching it. The manual route through
`save_message` exists from the first commit.
- **Configurable markdown dialect and extensions**, in the shape Hugo's
configuration uses.
- **Live markdown syntax highlighting in the composer.** A
`QSyntaxHighlighter` over the editor, so `**bold**` reads as bold while the
buffer stays plain markdown. Deliberately after the toolbar: the grammar
agreement it needs is the expensive part, and the toolbar is what makes the
feature usable.
- **Review "every action has a shortcut".** `everyActionHasAShortcut` was
written when the action list was short. Six more actions takes the count past
the point where a chord for everything is useful, and each new action consumes
one whether or not anyone would press it. The replacement is the shape
`everyActionIsReachableFromAMenu()` already has: every action reachable from a
menu, with shortcuts a chosen subset. Not done here, because changing it while
adding six actions confuses two changes.