diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-20 12:16:36 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-20 12:16:36 +0200 |
| commit | 0a67757fa488b204da908803d99784c41c87dc29 (patch) | |
| tree | d1d18b674869b2ce49464d1c718feedef021598f /docs/superpowers/specs/2026-08-20-compose-and-send-design.md | |
| parent | e81e4f09a6a40162d0041253a35777e547d4dd81 (diff) | |
| download | qtmaildir-0a67757fa488b204da908803d99784c41c87dc29.tar.gz qtmaildir-0a67757fa488b204da908803d99784c41c87dc29.zip | |
docs: specify compose and send, item 123
Brainstormed with the user. Design only, no code, which is what the
item's #plan-only tag asked for.
The decision that shaped everything: there is no MTA on the machine, so
"an external script on the same model as mailsync.sh" had no model to
copy. Send becomes a per-account send_command taking the message on
stdin, exactly as [sync] command already works, which keeps the
no-network-protocol rule intact without naming an MTA.
An account with no send_command is receive-only by construction, which
is how one of the five accounts is meant to work. Reply, reply-all and
forward are disabled on its mail behind a ribbon that says why.
The body is markdown parsed by cmark-gfm rather than a hand-written
parser for a limited set: the two share no code, so the small one is
deleted wholesale the moment the set widens.
Four new units, three of them widget-free and testable without a
painter. MessageSender is deliberately a separate unit rather than a
method on the composer, so a future outbox wraps the funnel instead of
reworking it.
Item 123's section is replaced by a pointer to the spec, per this
document's own rule for a fully specified item. The brainstorm opened
items 128 to 132, including a review of the every-action-has-a-shortcut
rule, which the user raised: six more actions takes it past the point
where a chord for everything is useful.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDq53rMd3AQp7QmcZzpuBM
Diffstat (limited to 'docs/superpowers/specs/2026-08-20-compose-and-send-design.md')
| -rw-r--r-- | docs/superpowers/specs/2026-08-20-compose-and-send-design.md | 537 |
1 files changed, 537 insertions, 0 deletions
diff --git a/docs/superpowers/specs/2026-08-20-compose-and-send-design.md b/docs/superpowers/specs/2026-08-20-compose-and-send-design.md new file mode 100644 index 0000000..ee3bc8c --- /dev/null +++ b/docs/superpowers/specs/2026-08-20-compose-and-send-design.md @@ -0,0 +1,537 @@ +# 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 is plain text. What is typed is markdown, and it is what the +`text/plain` part carries, unmodified. The `text/html` part is generated from +it. + +**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 `<input type="checkbox" disabled>`, 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. + +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. + +### 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 blocks, visibly, and does not queue + +Send disables the composer and shows progress. Exit 0 closes it, files the sent +copy and deletes the draft. Non-zero re-enables it with everything intact and +shows 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 `<maildir>/<sent>/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 | +| `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 | + +`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 | +| `reply_all` | Reply to all, quoted | sender + To + Cc, own addresses removed | +| `reply_no_quote` | Reply with an empty body | sender only | +| `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. + +**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 +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 + +**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 warning in the composer's status area, not a modal. It +must not interrupt typing and must not silently succeed. + +**Sent copy write failed after a successful send.** A warning saying exactly +that. Never a send failure, never an offer to resend. + +**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. + +**`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. + +**`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. + +**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. +- **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. |
