diff options
| -rw-r--r-- | docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md | 91 | ||||
| -rw-r--r-- | docs/superpowers/specs/2026-08-20-compose-and-send-design.md | 537 |
2 files changed, 572 insertions, 56 deletions
diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md index 49f4000..ebcbea9 100644 --- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md +++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md @@ -189,7 +189,7 @@ taking that too literally. | 121 | The thread list shows nothing while a query is running | feedback | S | open, 2026-08-20, from the notes. Follows item 74, which fixed the status-bar half and left the list itself blank | | 122 | The README documents a version of the app that no longer exists | documentation | M | open, 2026-08-20, from the notes. Delete-to-trash is entirely undocumented, including a config key a user must now set | -| 123 | Sending mail is not designed | v2 | ? | open, 2026-08-20, from the notes. Brainstorm only, explicitly `#plan-only`; the user places most open UX behind it | +| 123 | Sending mail is not designed | v2 | L | **specified** 2026-08-20, on branch `compose-and-send`. Design in `docs/superpowers/specs/2026-08-20-compose-and-send-design.md`; read that, not this row. Send is a per-account `send_command` on stdin, so the no-network-protocol rule stands. Composer is a separate window, body is markdown via cmark-gfm, drafts autosave to the account's drafts folder. No code written | | 124 | The worker reads the index directory as the mail root | defect | S | **done** 2026-08-20, unreleased. `mailRootOf()` over `NOTMUCH_CONFIG_MAIL_ROOT`, correct under both layouts. Verified by migrating the developer's own index to NVMe the same day: cold start 38.6 s to 0.67 s | @@ -197,6 +197,11 @@ taking that too literally. | 126 | A link with `target="_blank"` does nothing when clicked | defect | S | **done** 2026-08-20, unreleased. `createWindow()` returns a relay page that receives the navigation, hands the URL to the browser and refuses. The URL cannot be read in `createWindow()` itself, which is why a relay rather than a lookup | | 127 | A link's context menu offers four browser actions that cannot work | defect | XS | **done** 2026-08-20, unreleased. Three Open-in actions removed, `CopyLinkToClipboard` kept. Item 126 made them more dangerous rather than less: with a real `createWindow()` they would have started working | +| 128 | No outbox: a send with no network fails instead of queueing | v2 | M | open, 2026-08-20, from the item 123 brainstorm. **Blocked on 123.** The seam is designed in (`MessageSender` is the one funnel), so this wraps it rather than reworking it. Needs its own indicator story first: items 18, 19, 28 and 54 are all an indicator lying, and 125 is one still open | +| 129 | No inline images in a composed message | v2 | M | open, 2026-08-20, from the item 123 brainstorm. **Blocked on 123.** Wanted by the user. `cid:` from the HTML part with `multipart/related` nested inside the alternative, the most nesting-heavy part of MIME assembly, and markdown offers no syntax for it | +| 130 | A message cannot be attached to another message directly | v2 | S | open, 2026-08-20, from the item 123 brainstorm. **Blocked on 123.** A `message/rfc822` part, which GMime builds natively. The manual route exists from 123's first commit: `save_message` writes the `.eml` and it is attached as a file | +| 131 | The markdown dialect and extensions are fixed | v2 | S | open, 2026-08-20, from the item 123 brainstorm. **Blocked on 123.** Configurable in the shape Hugo's config uses. Deliberately fixed initially: CommonMark plus autolink, strikethrough and tasklist | +| 132 | Every action must have a shortcut, and that no longer serves | policy | S | open, 2026-08-20, raised by the user during the item 123 brainstorm. `everyActionHasAShortcut` was written when the action list was short; item 123 adds six more, and each new action consumes a chord whether or not anyone would press it. Replacement is the shape `everyActionIsReachableFromAMenu()` already has: menu reachability required, shortcuts a chosen subset. **Not** done inside 123, which would confuse two changes | Sizes are rough: XS under an hour, S a sitting, M a session. @@ -998,61 +1003,35 @@ likely to have drifted, since both enumerate things that have been added to. ## 123. Sending mail is not designed -**Observed (user, from the notes):** "we should start brainstorming sending -emails. Most of the open issues are UX. #v2 #plan-only #new-branch". - -**Not a defect and not implementation work.** The note tags it `#plan-only`, and -this repo's own scope statement agrees: `CLAUDE.md` records that v1 is -read-and-organize only and that compose and send are v2. Recorded here so the -backlog stops being silent about the largest thing the user has written down. - -**What it blocks.** Item 72 (khard/khal) is explicitly placed after send by the -user's own note, and cannot be specified before it. The completion machinery -that would serve recipient completion already exists as `QueryCompleter`, and -the `QLineEdit::setCompleter` trap in `CLAUDE.md` applies directly to any -multi-recipient field, so there is prior art to reuse rather than a blank page. - -**What it does NOT change.** The architecture note that this application does -**no network protocol work at all** is load-bearing: fetching is `mbsync` via -`assets/mailsync.sh`, and sending should be an external script on the same -model, not an SMTP client written here. A design that puts a socket in this -process is out of scope regardless of how the UI turns out. - -**What exists on this machine, measured 2026-08-20, because the sentence above -assumes something that is not there.** "Sending should be an external script on -the same model" is easy to write and there is no such model to copy: the fetch -side has `mbsync`, and the send side has NOTHING. - -- No MTA is installed at all. `msmtp` and `sendmail` are both absent. -- neomutt sends over its OWN built-in SMTP, configured per account - (`smtp_url` and `smtp_pass` in `~/.config/neomutt/accounts/*.rc`), so the - working setup this application mirrors has no external send path either. -- All five accounts already configure a `drafts` folder, so the draft half has - somewhere to live before anything is decided. - -That is the first question for the user, and it is not a UX one: sending needs -either an MTA they choose to install and configure (msmtp being the obvious -one, and the only shape that keeps the no-network-protocol rule intact), or a -decision to relax that rule. Do not assume the first just because it is -tidier; installing and configuring an MTA is work they have not asked for, and -the credentials already exist in neomutt's files. - -**Approach.** Brainstorm first, on its own branch, producing a spec under -`docs/superpowers/specs/` before any code. The open questions are all UX, as the -note says: where a composer lives, how a draft is stored so `notmuch` can see -it, what reply and forward do to the thread the user is looking at, and how a -queued message reaches the sending script. - -**Constraints.** - -- **Ask the user before designing.** They have not said what they pictured, and - this is the item where guessing costs the most. -- Drafts are already visible to the app: item 67 counts them in the placeholder - pane, so a draft folder is configured and indexed. -- `#new-branch` is the user's own tag on it. - -**Size: `?`** until the brainstorm has happened. It is the largest open item by -some distance. +**Specified 2026-08-20.** Read +`docs/superpowers/specs/2026-08-20-compose-and-send-design.md` instead of this +section. Brainstormed with the user on branch `compose-and-send`; no code +written, which is what the note's `#plan-only` asked for. + +**The three constraints a reader needs before opening the spec.** + +- **There is no MTA on this machine**, measured 2026-08-20. `msmtp` and + `sendmail` are both absent, and neomutt sends over its own built-in SMTP. So + "an external script on the same model as `mailsync.sh`" had no model to copy. + The design keeps the no-network-protocol rule by making send a **per-account + `send_command`** taking the message on stdin, exactly as `[sync] command` + works. What the user installs behind it is their choice. +- **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, with a ribbon in the message pane saying why. +- **The body is markdown**, parsed by cmark-gfm (autolink, strikethrough, + tasklist; tables off), sent as `multipart/alternative` or plain text per a + per-message toggle. A hand-written parser for a limited set was rejected + because it would be deleted wholesale the moment the set widened. + +**What it blocks and what it opened.** Item 72 (khard/khal) is placed after send +by the user's own note. The brainstorm opened items 128 to 132: an outbox, +inline images, attaching a message to a message, a configurable markdown +dialect, and a review of the every-action-has-a-shortcut rule. + +**Size: L.** Four new units, six new actions, and a new dependency +(`cmark-gfm`, already installed here, and a `REQUIRES` entry for the +SlackBuild). ## 125. A skipped sync leaves the spinner running for ever 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. |
