aboutsummaryrefslogtreecommitdiffstats
path: root/docs
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-20 18:00:30 +0200
committerDanilo M. <danix@danix.xyz>2026-08-20 18:00:30 +0200
commitc50bea78e036518ce1a2a3eb899bbb5e305affea (patch)
tree9eca7fe323ef03a1f05aa51123c1d63aad242274 /docs
parent81c0086a06d4b13478475c2fa05f040c0511bfba (diff)
downloadqtmaildir-c50bea78e036518ce1a2a3eb899bbb5e305affea.tar.gz
qtmaildir-c50bea78e036518ce1a2a3eb899bbb5e305affea.zip
docs: implementation plan for compose and send, item 123
Thirteen tasks, ninety-nine steps, against the spec committed earlier on this branch. Written on master so it is readable from either branch; the implementation goes on compose-and-send. Every API assumption was verified against this machine rather than written from memory, which found five things the spec had wrong or unstated: libcmark-gfm-extensions ships NO pkg-config file although libcmark-gfm does, so CMake needs find_library beside pkg_check_modules. All three enabled extensions live in that second library, so finding only the first yields a build that compiles and silently renders plain CommonMark. GMime defaults to iso-8859-1, emits no Date or Message-ID unless asked, and g_mime_text_part_set_text() encodes with whatever charset is set when it is called, so setting the charset afterwards produces a part labelled utf-8 carrying latin-1 bytes. All three fail only on accented text, which for this user is every message. The plan builds the content stream directly and carries a working probe's output as evidence. MessageNode has no body or date field, so quoting takes a ParsedMessage. ThreadListModel::messageScopeFor() takes a QModelIndexList rather than a single index. There is no Config::maildirPath(): the mail root comes from notmuch_config_get(NOTMUCH_CONFIG_MAIL_ROOT) via a file-static helper in the worker, and item 124 records that composing a destination from the wrong root would write into the Xapian tree. Two spec statements are corrected in the plan rather than followed. It calls for a new top-level Message menu and one already exists at mainwindow.cpp:1156. And it requires a shortcut per action, which item 132 changed while this was being planned, so save_message ships without one.
Diffstat (limited to 'docs')
-rw-r--r--docs/superpowers/plans/2026-08-20-compose-and-send.md4923
1 files changed, 4923 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-08-20-compose-and-send.md b/docs/superpowers/plans/2026-08-20-compose-and-send.md
new file mode 100644
index 0000000..195dacc
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-20-compose-and-send.md
@@ -0,0 +1,4923 @@
+# Compose and Send Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add the write half of qtmaildir: compose, reply, forward and send, with markdown bodies, autosaved drafts and a cancellable send delay, without the application ever speaking a network protocol.
+
+**Architecture:** Four new units. `MessageBuilder` turns an `OutgoingMessage` into RFC822 bytes using GMime and cmark-gfm. `DraftStore` writes those bytes into a Maildir folder. `MessageSender` pipes them to a per-account `send_command` over stdin. `ComposeWindow` is the only one that owns widgets, and composes the other three. Three of the four are tested without a painter.
+
+**Tech Stack:** Qt 6.11 (Widgets, Test), GMime 3.0 (already linked), cmark-gfm 0.29 (new, stock Slackware), notmuch (read-only, unchanged), CMake + Ninja, QTest.
+
+**Implementation branch:** `compose-and-send`, currently identical to `master`. This plan lives on `master` so it is readable from either.
+
+---
+
+## Before starting
+
+Read these, in this order. They are not optional context; each one records a
+trap this plan walks past.
+
+1. The spec: `docs/superpowers/specs/2026-08-20-compose-and-send-design.md`.
+2. `CLAUDE.md`, in particular **Web view security**, **Adding an action is FIVE
+ places**, and the gmime include-order rule.
+3. `src/mailsync.cpp:172-205`, the `QProcess` precedent `MessageSender` copies.
+
+**Verified facts this plan rests on** (measured on 2026-08-20, not assumed):
+
+- `pkg-config --modversion libcmark-gfm` reports `0.29.0.gfm.13`.
+- **`libcmark-gfm-extensions` has NO pkg-config file.** Only `libcmark-gfm.pc`
+ exists. The extensions library is `/usr/lib64/libcmark-gfm-extensions.so` and
+ must be found with `find_library`, the way notmuch already is. The spec's "a
+ `pkg_check_modules` line" covers only half of it.
+- With `CMARK_OPT_SAFE` and the three extensions attached, a probe confirmed:
+ autolink wraps a bare URL, `~~x~~` becomes `<del>`, `- [ ]` becomes
+ `<input type="checkbox" disabled>`, a table renders as literal pipes (the
+ extension is not attached), and `<script>` becomes `<!-- raw HTML omitted -->`.
+
+## Task order and why
+
+`MessageBuilder` first because everything downstream consumes its output and it
+is pure. Then `DraftStore` and `MessageSender`, both narrow and testable against
+stubs. `ComposeContext` next, pure logic where the subtle recipient bugs live.
+`ComposeWindow` last, because it composes all four and is the only part that
+cannot be tested without a painter.
+
+Config and the actions come before the window that reads them.
+
+---
+
+## File structure
+
+Every file created or modified, and what each is responsible for.
+
+**Created:**
+
+| File | Responsibility |
+|---|---|
+| `src/markdownrenderer.h/.cpp` | cmark-gfm only. Markdown source in, HTML fragment out. No GMime, no Qt widgets. Separate from `MessageBuilder` so the extension configuration is tested on its own. |
+| `src/messagebuilder.h/.cpp` | GMime only. `OutgoingMessage` in, RFC822 bytes out. No I/O except reading attachment files. |
+| `src/draftstore.h/.cpp` | Maildir writes. Bytes plus a folder in, a written path out. Serves drafts and sent copies; they are the same operation. |
+| `src/messagesender.h/.cpp` | `QProcess` over the account's `send_command`. The one send funnel and the outbox seam. |
+| `src/composecontext.h/.cpp` | Pure logic that decides what a composer opens with: recipients, subject prefixing, account resolution. No widgets. |
+| `src/composewindow.h/.cpp` | `QMainWindow`, one per draft. The only unit here that owns widgets. |
+| `src/senddialog.h/.cpp` | The send popup: countdown, undo, staged progress. Modal to the composer. |
+| `src/formattoolbar.h/.cpp` | The markdown transformations, as free functions over text plus a selection, and the toolbar that calls them. The functions are tested; the toolbar is not. |
+| `tests/test_markdownrenderer.cpp` | Extensions on, tables and raw HTML off. |
+| `tests/test_messagebuilder.cpp` | The bulk. Asserts on generated bytes. |
+| `tests/test_draftstore.cpp` | Filename validity, unlinking, dirty check, unwritable directory. |
+| `tests/test_messagesender.cpp` | Stub commands. Exactly two outcomes. |
+| `tests/test_composecontext.cpp` | Recipient derivation and account resolution. |
+| `tests/test_formattoolbar.cpp` | Wrap, insert, per-line quote, cursor placement. |
+
+**Modified:**
+
+| File | Change |
+|---|---|
+| `CMakeLists.txt` | cmark-gfm: `pkg_check_modules` for the core, `find_library` for the extensions. |
+| `src/CMakeLists.txt` | The eight new `.cpp` files; link cmark-gfm. |
+| `src/types.h` | `ComposeContext` and `OutgoingMessage`. |
+| `src/config.h/.cpp` | `Account::sendCommand`, the `[compose]` section, startup validation. |
+| `src/maildirname.h/.cpp` (created) | `freshMaildirName()` extracted from `notmuchworker.cpp`'s anonymous namespace so `DraftStore` shares it rather than duplicating it. |
+| `src/notmuchworker.cpp` | Use the extracted `freshMaildirName()`. |
+| `src/keymap.cpp` | Six actions in `knownActions()`; five bindings in `defaultBindings()` (`save_message` gets none, which item 132 now permits). |
+| `src/mainwindow.h/.cpp` | Six action handlers, the `Message` menu, the icon table, the composer registry, the quit path. |
+| `src/messageview.h/.cpp` | The receive-only ribbon. |
+| `tests/CMakeLists.txt` | Six new test registrations. |
+| `tests/test_mainwindow.cpp` | Action enablement, the ribbon, the quit path. |
+| `translations/qtmaildir_it_IT.ts` | Refreshed by `lupdate`; every new string translated. |
+| `CHANGELOG.md` | One `[Unreleased]` entry. |
+| `docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md` | Close item 123. |
+
+**Deliberately not created:** a `ComposeWindow` geometry save/restore, an
+outbox, a syntax highlighter. The spec explains each; do not add them.
+
+---
+
+### Task 1: cmark-gfm in the build, and the markdown renderer
+
+The only new dependency. Doing it first means every later task can assume it.
+
+**Files:**
+- Modify: `CMakeLists.txt:26-35` (beside the notmuch and GMime blocks)
+- Modify: `src/CMakeLists.txt:7` (source list) and `:37-39` (link line)
+- Create: `src/markdownrenderer.h`, `src/markdownrenderer.cpp`
+- Create: `tests/test_markdownrenderer.cpp`
+- Modify: `tests/CMakeLists.txt`
+
+- [ ] **Step 1: Add cmark-gfm to the top-level CMakeLists**
+
+Insert after the `pkg_check_modules(GMIME ...)` line at `CMakeLists.txt:35`:
+
+```cmake
+# cmark-gfm renders the composer's markdown body into the HTML part.
+#
+# TWO lookups, not one, and this is the trap: only the CORE library ships a
+# pkg-config file. `libcmark-gfm-extensions` has none (verified 2026-08-20 on
+# Slackware, cmark-gfm-0.29.0.gfm.13), so it is located by hand exactly as
+# notmuch is. The extensions library is not optional here: autolink,
+# strikethrough and tasklist all live in it, and without it a bare URL in a
+# mail body is not a link.
+pkg_check_modules(CMARK_GFM REQUIRED IMPORTED_TARGET libcmark-gfm)
+find_library(CMARK_GFM_EXTENSIONS_LIBRARY NAMES cmark-gfm-extensions)
+if(NOT CMARK_GFM_EXTENSIONS_LIBRARY)
+ message(FATAL_ERROR
+ "libcmark-gfm-extensions not found. It ships with cmark-gfm but has "
+ "no pkg-config file; it provides autolink, strikethrough and tasklist.")
+endif()
+message(STATUS "Found cmark-gfm extensions: ${CMARK_GFM_EXTENSIONS_LIBRARY}")
+```
+
+- [ ] **Step 2: Link it in `src/CMakeLists.txt`**
+
+Change the `target_link_libraries(qtmaildir_lib ...)` call at `src/CMakeLists.txt:37-39` to:
+
+```cmake
+target_link_libraries(qtmaildir_lib
+ PUBLIC Qt6::Widgets Qt6::Svg Qt6::WebEngineWidgets PkgConfig::GMIME
+ ${NOTMUCH_LIBRARY} PkgConfig::CMARK_GFM
+ ${CMARK_GFM_EXTENSIONS_LIBRARY})
+```
+
+- [ ] **Step 3: Verify the build system finds both halves**
+
+Run:
+```bash
+cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug 2>&1 | grep -i cmark
+```
+Expected: a line reading `Found cmark-gfm extensions: /usr/lib64/libcmark-gfm-extensions.so`. If the configure fails instead, the package is missing and nothing below will work.
+
+- [ ] **Step 4: Write the failing test**
+
+Create `tests/test_markdownrenderer.cpp`:
+
+```cpp
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#include <QtTest>
+
+#include "markdownrenderer.h"
+
+class TestMarkdownRenderer : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void commonMarkBasicsRender();
+ void autolinkTurnsABareUrlIntoALink();
+ void strikethroughRenders();
+ void tasklistRenders();
+ void tablesAreNotEnabled();
+ void rawHtmlIsSuppressed();
+ void accentedTextSurvivesAsUtf8();
+ void emptyInputProducesEmptyOutput();
+};
+
+void TestMarkdownRenderer::commonMarkBasicsRender()
+{
+ const QString html = MarkdownRenderer::toHtml(
+ QStringLiteral("**bold** and *italic* and `code`"));
+
+ QVERIFY2(html.contains(QStringLiteral("<strong>bold</strong>")),
+ qPrintable(QStringLiteral("no <strong> in: %1").arg(html)));
+ QVERIFY(html.contains(QStringLiteral("<em>italic</em>")));
+ QVERIFY(html.contains(QStringLiteral("<code>code</code>")));
+}
+
+void TestMarkdownRenderer::autolinkTurnsABareUrlIntoALink()
+{
+ // The whole reason cmark-gfm was chosen over plain cmark. Under
+ // CommonMark a bare URL is text, and a bare URL in mail is expected to be
+ // clickable.
+ const QString html = MarkdownRenderer::toHtml(
+ QStringLiteral("see https://example.org for details"));
+
+ QVERIFY2(html.contains(QStringLiteral("<a href=\"https://example.org\"")),
+ qPrintable(QStringLiteral("autolink did not fire: %1").arg(html)));
+}
+
+void TestMarkdownRenderer::strikethroughRenders()
+{
+ const QString html = MarkdownRenderer::toHtml(QStringLiteral("~~gone~~"));
+ QVERIFY2(html.contains(QStringLiteral("<del>gone</del>")),
+ qPrintable(QStringLiteral("no <del> in: %1").arg(html)));
+}
+
+void TestMarkdownRenderer::tasklistRenders()
+{
+ // Known ceiling, recorded in the spec: many mail clients strip the
+ // checkbox, so those recipients see the item with no marker. The plain
+ // part still carries `- [ ]`, so nothing is lost.
+ const QString html = MarkdownRenderer::toHtml(
+ QStringLiteral("- [ ] todo\n- [x] done"));
+
+ QVERIFY2(html.contains(QStringLiteral("type=\"checkbox\"")),
+ qPrintable(QStringLiteral("no checkbox in: %1").arg(html)));
+ QVERIFY(html.contains(QStringLiteral("checked")));
+}
+
+void TestMarkdownRenderer::tablesAreNotEnabled()
+{
+ // Deliberately off: tables render badly across mail clients regardless of
+ // who generates them. The extension EXISTS in the library, so this asserts
+ // a decision rather than a limitation, and it would silently start passing
+ // the wrong way if someone attached the extension "for completeness".
+ const QString html = MarkdownRenderer::toHtml(
+ QStringLiteral("| a | b |\n|---|---|\n| 1 | 2 |"));
+
+ QVERIFY2(!html.contains(QStringLiteral("<table")),
+ qPrintable(QStringLiteral("the table extension is attached: %1").arg(html)));
+ QVERIFY2(html.contains(QStringLiteral("| a | b |")),
+ "the table source did not survive as literal text");
+}
+
+void TestMarkdownRenderer::rawHtmlIsSuppressed()
+{
+ // 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.
+ const QString html = MarkdownRenderer::toHtml(
+ QStringLiteral("<script>alert(1)</script>\n\nafter"));
+
+ QVERIFY2(!html.contains(QStringLiteral("<script>")),
+ qPrintable(QStringLiteral("raw HTML leaked: %1").arg(html)));
+ QVERIFY2(html.contains(QStringLiteral("after")),
+ "suppressing raw HTML ate the rest of the document");
+}
+
+void TestMarkdownRenderer::accentedTextSurvivesAsUtf8()
+{
+ // This user writes Italian. A body containing accented characters is
+ // every message, not an edge case, and a UTF-8 round trip through a C
+ // library is exactly where it would be lost.
+ const QString source = QString::fromUtf8("perch\xC3\xA9 \xC3\xA8 cos\xC3\xAC");
+ const QString html = MarkdownRenderer::toHtml(source);
+
+ QVERIFY2(html.contains(source),
+ qPrintable(QStringLiteral("accents did not survive: %1").arg(html)));
+}
+
+void TestMarkdownRenderer::emptyInputProducesEmptyOutput()
+{
+ // reply_no_quote opens a composer with an empty body, and it must not
+ // produce a stray paragraph or crash the renderer.
+ QVERIFY(MarkdownRenderer::toHtml(QString()).trimmed().isEmpty());
+}
+
+QTEST_APPLESS_MAIN(TestMarkdownRenderer)
+#include "test_markdownrenderer.moc"
+```
+
+Note `QTEST_APPLESS_MAIN`, not `QTEST_MAIN`: this test needs no QApplication and no platform plugin at all.
+
+- [ ] **Step 5: Register the test**
+
+Add to `tests/CMakeLists.txt`, beside the other `add_qtmaildir_test` calls:
+
+```cmake
+add_qtmaildir_test(markdownrenderer)
+```
+
+- [ ] **Step 6: Run it to verify it fails**
+
+Run: `cmake --build build 2>&1 | tail -5`
+Expected: FAIL, `markdownrenderer.h: No such file or directory`.
+
+- [ ] **Step 7: Write the header**
+
+Create `src/markdownrenderer.h` (GPL header as in every other file, then):
+
+```cpp
+#pragma once
+
+#include <QString>
+
+/// Renders the composer's markdown body into the HTML part's fragment.
+///
+/// A namespace of free functions rather than a class: there is no state, and
+/// keeping it painter-free and widget-free is what lets the extension
+/// configuration be tested on its own. `MessageBuilder` calls this; nothing
+/// else does.
+namespace MarkdownRenderer {
+
+/// The markdown source as an HTML fragment: no <html>, <head> or <body>.
+///
+/// Three extensions are enabled (autolink, strikethrough, tasklist) and
+/// tables are deliberately not. Raw HTML in the input is suppressed by
+/// CMARK_OPT_SAFE.
+QString toHtml(const QString &markdown);
+
+} // namespace MarkdownRenderer
+```
+
+- [ ] **Step 8: Write the implementation**
+
+Create `src/markdownrenderer.cpp` (GPL header, then):
+
+```cpp
+// cmark-gfm's headers are C and carry no Qt interaction, so the gmime
+// include-order rule does not apply here. They still go first, for consistency
+// with mimeparser.cpp.
+#include <cmark-gfm.h>
+#include <cmark-gfm-core-extensions.h>
+
+#include "markdownrenderer.h"
+
+#include <QByteArray>
+
+namespace {
+
+/// The extensions this application enables, by cmark-gfm's own names.
+///
+/// `table` is absent deliberately, not by oversight: tables render badly
+/// across mail clients regardless of who generates them. `tagfilter` is absent
+/// because CMARK_OPT_SAFE already suppresses raw HTML wholesale, which is the
+/// stronger measure.
+const char *const kExtensions[] = { "autolink", "strikethrough", "tasklist" };
+
+} // namespace
+
+QString MarkdownRenderer::toHtml(const QString &markdown)
+{
+ if (markdown.isEmpty())
+ return {};
+
+ // Idempotent and required before cmark_find_syntax_extension() can resolve
+ // any name. Calling it per render rather than once at startup keeps this
+ // function free of initialisation order concerns; it is a hash lookup
+ // after the first call.
+ cmark_gfm_core_extensions_ensure_registered();
+
+ // SAFE suppresses raw HTML in the INPUT. It does not escape the output,
+ // which is markup by definition.
+ const int options = CMARK_OPT_DEFAULT | CMARK_OPT_SAFE;
+
+ cmark_parser *parser = cmark_parser_new(options);
+ if (!parser)
+ return {};
+
+ for (const char *name : kExtensions) {
+ // A missing extension is a broken installation rather than a
+ // condition to handle: the library was found by CMake. Skipping it
+ // degrades to plain CommonMark rather than crashing.
+ if (cmark_syntax_extension *extension = cmark_find_syntax_extension(name))
+ cmark_parser_attach_syntax_extension(parser, extension);
+ }
+
+ const QByteArray utf8 = markdown.toUtf8();
+ cmark_parser_feed(parser, utf8.constData(), static_cast<size_t>(utf8.size()));
+
+ cmark_node *document = cmark_parser_finish(parser);
+ if (!document) {
+ cmark_parser_free(parser);
+ return {};
+ }
+
+ // The extension list must be passed to the renderer as well as to the
+ // parser. Passing nullptr here parses the tasklist correctly and then
+ // renders it as a plain list item, which looks like the extension never
+ // worked.
+ char *html = cmark_render_html(document, options,
+ cmark_parser_get_syntax_extensions(parser));
+ const QString result = html ? QString::fromUtf8(html) : QString();
+
+ free(html);
+ cmark_node_free(document);
+ cmark_parser_free(parser);
+
+ return result;
+}
+```
+
+- [ ] **Step 9: Add the source to the library**
+
+Add `markdownrenderer.cpp` to the `qtmaildir_lib` list in `src/CMakeLists.txt`, beside `mimeparser.cpp`.
+
+- [ ] **Step 10: Run the test to verify it passes**
+
+Run: `ctest --test-dir build -R markdownrenderer --output-on-failure`
+Expected: PASS, 8 test functions.
+
+- [ ] **Step 11: Mutation-check the extension list**
+
+The tests must fail when an extension is dropped, or they assert nothing. Verify at least one:
+
+```bash
+sed -i 's/"autolink", "strikethrough", "tasklist"/"strikethrough", "tasklist"/' src/markdownrenderer.cpp
+cmake --build build >/dev/null 2>&1
+ctest --test-dir build -R markdownrenderer 2>&1 | grep -E 'Passed|Failed'
+git checkout src/markdownrenderer.cpp
+cmake --build build >/dev/null 2>&1
+```
+Expected: `Failed` on `autolinkTurnsABareUrlIntoALink`. If it passes, the test is measuring nothing and must be fixed before continuing.
+
+- [ ] **Step 12: Commit**
+
+```bash
+git add CMakeLists.txt src/CMakeLists.txt src/markdownrenderer.h \
+ src/markdownrenderer.cpp tests/test_markdownrenderer.cpp \
+ tests/CMakeLists.txt
+git commit -S -m "feat(compose): render markdown bodies with cmark-gfm, item 123
+
+The composer's body is markdown and the text/html part is generated from it.
+cmark-gfm rather than plain cmark for autolink: under CommonMark a bare URL
+in a mail body is not a link, and in mail it is expected to be clickable.
+
+Three extensions are enabled and tables are deliberately not, since they
+render badly across mail clients whoever generates them. Raw HTML in the
+input is suppressed with 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.
+
+The build needs TWO lookups. Only the core library ships a pkg-config file;
+libcmark-gfm-extensions has none and is located with find_library, the way
+notmuch already is. All three extensions live in that second library, so
+finding only the first produces a build that compiles and silently renders
+plain CommonMark."
+```
+
+---
+
+### Task 2: The two structs and the configuration keys
+
+Data before behaviour. Nothing here has logic worth testing on its own; the
+validation added in Step 6 does.
+
+**Files:**
+- Modify: `src/types.h` (append before the `Q_DECLARE_METATYPE` block at the end)
+- Modify: `src/config.h` (the `Account` struct, and a `ComposeSettings` struct)
+- Modify: `src/config.cpp` (parsing and startup validation)
+- Modify: `tests/test_config.cpp`
+
+- [ ] **Step 1: Add the structs to `src/types.h`**
+
+Insert before the `Q_DECLARE_METATYPE(ThreadSummary)` line:
+
+```cpp
+/// What opens a composer. Built by MainWindow, consumed by ComposeWindow.
+///
+/// Built from the DATABASE, never from the model. The model's data comes from
+/// the query, so a row whose state has not been re-queried carries stale
+/// values, and a reply built from a stale row would carry the wrong
+/// recipients. This is the same rule Restore already follows.
+struct ComposeContext
+{
+ enum class Kind { New, Reply, ReplyAll, Forward };
+
+ QString accountKey; ///< Which account sends. Resolved by ComposeContext's rules.
+ Kind kind = Kind::New;
+ QString originalPath; ///< The .eml being replied to or forwarded. Empty for New.
+ QString inReplyTo; ///< Message-ID of the original.
+ QStringList references; ///< The original's References plus its Message-ID.
+ QStringList to; ///< Pre-filled, the user's own addresses already stripped.
+ QStringList cc;
+ QString subject; ///< Re:/Fwd: prefixed, an existing prefix not doubled.
+ QString quotedBody; ///< The >-prefixed original. Empty when the action does not quote.
+ bool seedHtml = false; ///< Did the original carry a text/html part.
+ QStringList attachments; ///< Carried forward for Forward, empty otherwise.
+};
+
+/// What the composer produces, consumed by MessageBuilder.
+///
+/// In-Reply-To and References are NOT optional. Without them a reply appears
+/// as an orphan thread in the sender's own client.
+struct OutgoingMessage
+{
+ QString accountKey;
+ QStringList to;
+ QStringList cc;
+ QStringList bcc;
+ QString subject;
+ QString markdownBody; ///< The source text, exactly as typed.
+ bool sendHtml = false; ///< The composer's per-message toggle.
+ QStringList attachments; ///< Local paths, read at build time.
+ QString inReplyTo;
+ QStringList references;
+};
+```
+
+These need no `Q_DECLARE_METATYPE`: neither crosses a queued connection. The
+composer never touches `NotmuchWorker`.
+
+- [ ] **Step 2: Add `sendCommand` to `Account`**
+
+In `src/config.h`, inside `struct Account`, after the `trash` member:
+
+```cpp
+ /// The command that sends mail from this account, receiving the complete
+ /// RFC822 message on stdin. Optional, and its ABSENCE is meaningful:
+ /// an account without one is receive-only by construction.
+ ///
+ /// Not a separate `receive_only` key. The capability IS this command's
+ /// presence, so there is nothing to keep in step and nothing to
+ /// contradict. One real account is receive-only on purpose and gains no
+ /// configuration at all, which is the point.
+ ///
+ /// Split with QProcess::splitCommand and run WITHOUT a shell, exactly as
+ /// [sync] command is, so nothing in a message body, a recipient address or
+ /// a display name can reach sh. No message content is ever placed in an
+ /// argument: recipients come from the message's own headers.
+ QString sendCommand;
+
+ /// Whether this account can send at all.
+ bool canSend() const { return !sendCommand.isEmpty(); }
+```
+
+- [ ] **Step 3: Add the `[compose]` settings struct**
+
+In `src/config.h`, above `class Config`:
+
+```cpp
+/// The [compose] section. Every key is optional with the default shown.
+struct ComposeSettings
+{
+ /// Where the quote goes in a reply. Whether to quote AT ALL is not here:
+ /// that is decided by which action was invoked (reply quotes,
+ /// reply_no_quote does not).
+ enum class QuotePosition { Above, Below };
+
+ QuotePosition quotePosition = QuotePosition::Above;
+
+ /// Seeds the per-message toggle for New and Forward only. Reply and
+ /// Reply-all seed from whether the original carried a text/html part,
+ /// ignoring this value: an HTML part in the original is a fact about the
+ /// sender's software, not a guess about their taste.
+ bool sendHtml = true;
+
+ int autosaveIntervalMs = 30000;
+
+ /// The undo window before sending. Zero skips the countdown entirely and
+ /// sends at once, for anyone who finds it irritating.
+ int sendDelayMs = 5000;
+
+ /// Preferred account for a New message when the dropdown is on All
+ /// accounts. Falls through when it names an account that cannot send.
+ QString defaultAccount;
+
+ qint64 attachmentWarnBytes = 26214400;
+};
+```
+
+And in `class Config`, beside `accounts()`:
+
+```cpp
+ ComposeSettings compose() const { return m_compose; }
+
+ /// Every account with a send_command, in configuration order.
+ ///
+ /// Empty is a valid read-only installation, NOT a misconfiguration: the
+ /// compose actions are simply disabled and nothing is warned about.
+ QList<Account> sendingAccounts() const;
+```
+
+with `ComposeSettings m_compose;` among the private members.
+
+- [ ] **Step 4: Write the failing config test**
+
+Add to `tests/test_config.cpp`, and declare each in the `private slots:` block:
+
+```cpp
+void TestConfig::anAccountWithoutASendCommandIsReceiveOnly()
+{
+ QTemporaryDir dir;
+ const QString path = dir.filePath(QStringLiteral("qtmaildir.conf"));
+ {
+ QSettings s(path, QSettings::IniFormat);
+ s.beginGroup(QStringLiteral("account.work"));
+ s.setValue(QStringLiteral("maildir"), QStringLiteral("work"));
+ s.setValue(QStringLiteral("trash"), QStringLiteral("Trash"));
+ s.setValue(QStringLiteral("send_command"), QStringLiteral("msmtp -a work -t"));
+ s.endGroup();
+ s.beginGroup(QStringLiteral("account.listsonly"));
+ s.setValue(QStringLiteral("maildir"), QStringLiteral("listsonly"));
+ s.setValue(QStringLiteral("trash"), QStringLiteral("Trash"));
+ s.endGroup();
+ }
+
+ Config config;
+ QVERIFY(config.load(path));
+
+ const QList<Account> accounts = config.accounts();
+ QCOMPARE(accounts.size(), 2);
+
+ // The capability is the command's presence. Nothing else expresses it.
+ for (const Account &account : accounts) {
+ if (account.key == QStringLiteral("work")) {
+ QVERIFY2(account.canSend(), "an account with send_command cannot send");
+ } else {
+ QVERIFY2(!account.canSend(),
+ "an account without send_command reported as able to send");
+ }
+ }
+
+ QCOMPARE(config.sendingAccounts().size(), 1);
+ QCOMPARE(config.sendingAccounts().first().key, QStringLiteral("work"));
+}
+
+void TestConfig::composeSettingsDefaultWhenTheSectionIsAbsent()
+{
+ // Every [compose] key is optional. A config that has never heard of this
+ // feature must produce working defaults rather than zeros: a sendDelayMs
+ // of 0 read from an absent key would silently disable the undo window.
+ QTemporaryDir dir;
+ const QString path = dir.filePath(QStringLiteral("qtmaildir.conf"));
+ {
+ QSettings s(path, QSettings::IniFormat);
+ s.beginGroup(QStringLiteral("account.work"));
+ s.setValue(QStringLiteral("maildir"), QStringLiteral("work"));
+ s.setValue(QStringLiteral("trash"), QStringLiteral("Trash"));
+ s.endGroup();
+ }
+
+ Config config;
+ QVERIFY(config.load(path));
+
+ const ComposeSettings compose = config.compose();
+ QCOMPARE(compose.quotePosition, ComposeSettings::QuotePosition::Above);
+ QCOMPARE(compose.sendHtml, true);
+ QCOMPARE(compose.autosaveIntervalMs, 30000);
+ QCOMPARE(compose.sendDelayMs, 5000);
+ QCOMPARE(compose.attachmentWarnBytes, qint64(26214400));
+ QVERIFY(compose.defaultAccount.isEmpty());
+}
+
+void TestConfig::aZeroSendDelayIsHonouredRatherThanTreatedAsUnset()
+{
+ // send_delay_ms = 0 is a real setting meaning "send at once", and it is
+ // exactly the value an absent key would produce if the default were
+ // applied by testing for zero. Reading it back as 5000 would silently
+ // ignore what the user asked for.
+ QTemporaryDir dir;
+ const QString path = dir.filePath(QStringLiteral("qtmaildir.conf"));
+ {
+ QSettings s(path, QSettings::IniFormat);
+ s.beginGroup(QStringLiteral("compose"));
+ s.setValue(QStringLiteral("send_delay_ms"), 0);
+ s.endGroup();
+ }
+
+ Config config;
+ QVERIFY(config.load(path));
+ QCOMPARE(config.compose().sendDelayMs, 0);
+}
+
+void TestConfig::aDefaultAccountThatCannotSendIsWarnedAbout()
+{
+ // Following the pattern that already warns about an unresolvable
+ // startup_query. The setting is not silently corrected: it falls through
+ // to the next rule AND says so, because a user who named an account
+ // expects mail to come from it.
+ QTemporaryDir dir;
+ const QString path = dir.filePath(QStringLiteral("qtmaildir.conf"));
+ {
+ QSettings s(path, QSettings::IniFormat);
+ s.beginGroup(QStringLiteral("account.listsonly"));
+ s.setValue(QStringLiteral("maildir"), QStringLiteral("listsonly"));
+ s.setValue(QStringLiteral("trash"), QStringLiteral("Trash"));
+ s.endGroup();
+ s.beginGroup(QStringLiteral("compose"));
+ s.setValue(QStringLiteral("default_account"), QStringLiteral("listsonly"));
+ s.endGroup();
+ }
+
+ Config config;
+ QVERIFY(config.load(path));
+
+ const QStringList warnings = config.warnings();
+ QVERIFY2(std::any_of(warnings.cbegin(), warnings.cend(),
+ [](const QString &w) {
+ return w.contains(QStringLiteral("listsonly"));
+ }),
+ qPrintable(QStringLiteral("no warning named the account: %1")
+ .arg(warnings.join(QStringLiteral(" | ")))));
+}
+
+void TestConfig::anInstallationWhereNoAccountCanSendIsNotWarnedAbout()
+{
+ // A read-only installation is VALID. The compose actions are disabled and
+ // that is the whole response; warning about it would train the user to
+ // ignore warnings, which is the lesson TagRules already records.
+ QTemporaryDir dir;
+ const QString path = dir.filePath(QStringLiteral("qtmaildir.conf"));
+ {
+ QSettings s(path, QSettings::IniFormat);
+ s.beginGroup(QStringLiteral("account.listsonly"));
+ s.setValue(QStringLiteral("maildir"), QStringLiteral("listsonly"));
+ s.setValue(QStringLiteral("trash"), QStringLiteral("Trash"));
+ s.endGroup();
+ }
+
+ Config config;
+ QVERIFY(config.load(path));
+ QVERIFY(config.sendingAccounts().isEmpty());
+
+ const QStringList warnings = config.warnings();
+ QVERIFY2(std::none_of(warnings.cbegin(), warnings.cend(),
+ [](const QString &w) {
+ return w.contains(QStringLiteral("send"),
+ Qt::CaseInsensitive);
+ }),
+ qPrintable(QStringLiteral("a read-only installation was warned about: %1")
+ .arg(warnings.join(QStringLiteral(" | ")))));
+}
+```
+
+Add `#include <algorithm>` to the test file's includes if it is not already there.
+
+- [ ] **Step 5: Run to verify it fails**
+
+Run: `cmake --build build 2>&1 | tail -5`
+Expected: FAIL, `'canSend' is not a member of 'Account'`.
+
+- [ ] **Step 6: Implement the parsing**
+
+In `src/config.cpp`, inside the per-account loop where `trash` and `inbox` are read, add:
+
+```cpp
+ account.sendCommand = settings.value(QStringLiteral("send_command")).toString().trimmed();
+```
+
+Then add the `[compose]` reader. Note the group name: `compose` is an ordinary
+section, unlike `[general]`, which QSettings strips.
+
+```cpp
+ settings.beginGroup(QStringLiteral("compose"));
+ // value(key, default) throughout rather than testing contains(): an
+ // absent key and a key set to its default must behave identically, and
+ // send_delay_ms = 0 is a REAL setting meaning "send at once" that a
+ // zero-test would mistake for unset.
+ m_compose.quotePosition =
+ settings.value(QStringLiteral("quote_position"), QStringLiteral("above"))
+ .toString().compare(QStringLiteral("below"), Qt::CaseInsensitive) == 0
+ ? ComposeSettings::QuotePosition::Below
+ : ComposeSettings::QuotePosition::Above;
+ m_compose.sendHtml =
+ settings.value(QStringLiteral("send_html"), true).toBool();
+ m_compose.autosaveIntervalMs =
+ settings.value(QStringLiteral("autosave_interval_ms"), 30000).toInt();
+ m_compose.sendDelayMs =
+ settings.value(QStringLiteral("send_delay_ms"), 5000).toInt();
+ m_compose.defaultAccount =
+ settings.value(QStringLiteral("default_account")).toString().trimmed();
+ m_compose.attachmentWarnBytes =
+ settings.value(QStringLiteral("attachment_warn_bytes"), qint64(26214400))
+ .toLongLong();
+ settings.endGroup();
+```
+
+And the accessor plus validation, after the accounts are loaded:
+
+```cpp
+QList<Account> Config::sendingAccounts() const
+{
+ QList<Account> sending;
+ for (const Account &account : m_accounts) {
+ if (account.canSend())
+ sending.append(account);
+ }
+ return sending;
+}
+```
+
+```cpp
+ // Startup validation. Note what is NOT warned about: an installation where
+ // no account can send at all. That is a valid read-only installation and
+ // the compose actions simply disable themselves.
+ if (!m_compose.defaultAccount.isEmpty()) {
+ const auto named = std::find_if(
+ m_accounts.cbegin(), m_accounts.cend(),
+ [this](const Account &a) { return a.key == m_compose.defaultAccount; });
+
+ if (named == m_accounts.cend()) {
+ m_warnings.append(
+ tr("[compose] default_account names '%1', which is not a "
+ "configured account. A new message will pick a sending "
+ "account by the usual rules.")
+ .arg(m_compose.defaultAccount));
+ } else if (!named->canSend()) {
+ m_warnings.append(
+ tr("[compose] default_account names '%1', which has no "
+ "send_command and cannot send. A new message will pick a "
+ "sending account by the usual rules.")
+ .arg(m_compose.defaultAccount));
+ }
+ }
+
+ for (const Account &account : m_accounts) {
+ if (!account.canSend())
+ continue;
+ if (account.sent.isEmpty()) {
+ m_warnings.append(
+ tr("Account '%1' can send but configures no `sent` folder, so "
+ "no local copy of sent mail is filed.")
+ .arg(account.key));
+ }
+ if (account.drafts.isEmpty()) {
+ m_warnings.append(
+ tr("Account '%1' can send but configures no `drafts` folder, "
+ "so the composer runs without draft protection.")
+ .arg(account.key));
+ }
+ }
+```
+
+- [ ] **Step 7: Run the tests**
+
+Run: `ctest --test-dir build -R config --output-on-failure`
+Expected: PASS, including the five new functions.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add src/types.h src/config.h src/config.cpp tests/test_config.cpp
+git commit -S -m "feat(config): send_command and the [compose] section, item 123
+
+An account's ability to send IS its send_command's presence. Not a separate
+receive_only key: with one key there is nothing to keep in step and nothing
+to contradict, and a receive-only account is expressed by omission, which is
+how one real account here is meant to work.
+
+Startup validation follows the startup_query pattern, and is deliberately
+asymmetric. A default_account that cannot send is warned about, because the
+user named an account and expects mail to come from it. An installation
+where NO account can send is not: that is a valid read-only installation,
+and warning about it would train the user to ignore warnings.
+
+Every [compose] key reads through value(key, default) rather than testing
+contains(), because send_delay_ms = 0 is a real setting meaning 'send at
+once' that a zero-test would mistake for unset."
+```
+
+---
+
+### Task 3: Extract `freshMaildirName()` so DraftStore can share it
+
+`notmuchworker.cpp` holds this in an anonymous namespace. `DraftStore` needs
+exactly the same logic, and duplicating it would duplicate a correctness
+property: the comment there records that carrying the `,U=` infix across a
+folder boundary produced `Maildir error: duplicate UID` on real mail.
+
+A pure move, no behaviour change. Doing it as its own commit means any later
+bisect can tell a move from a new feature.
+
+**Files:**
+- Create: `src/maildirname.h`, `src/maildirname.cpp`
+- Modify: `src/notmuchworker.cpp:713-743` (delete the local copy, include the header)
+- Modify: `src/CMakeLists.txt`
+- Create: `tests/test_maildirname.cpp`
+- Modify: `tests/CMakeLists.txt`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/test_maildirname.cpp` (GPL header, then):
+
+```cpp
+#include <QtTest>
+
+#include "maildirname.h"
+
+class TestMaildirName : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void aFreshNameIsUniquePerCall();
+ void theFlagSuffixIsPreserved();
+ void anEmptyFlagSuffixIsPreserved();
+ void aNameWithNoSuffixGetsNone();
+ void theUidInfixIsNotCarriedAcross();
+};
+
+void TestMaildirName::aFreshNameIsUniquePerCall()
+{
+ // Two messages written in the same second must not collide. A timestamp
+ // alone does not guarantee that, which is what the counter is for.
+ QSet<QString> seen;
+ for (int i = 0; i < 100; ++i)
+ seen.insert(MaildirName::fresh(QStringLiteral("1234.M1P1Q1.host")));
+
+ QCOMPARE(seen.size(), 100);
+}
+
+void TestMaildirName::theFlagSuffixIsPreserved()
+{
+ // The flags say whether a message is read, flagged or draft. Losing them
+ // on a move silently marks mail unread again.
+ const QString fresh = MaildirName::fresh(QStringLiteral("1234.M1P1Q1.host:2,FS"));
+ QVERIFY2(fresh.endsWith(QStringLiteral(":2,FS")),
+ qPrintable(QStringLiteral("flags lost: %1").arg(fresh)));
+}
+
+void TestMaildirName::anEmptyFlagSuffixIsPreserved()
+{
+ // `:2,` with no flags is not the same as no suffix at all: it says the
+ // flags are known and empty. Preserved as faithfully as `:2,FS`.
+ const QString fresh = MaildirName::fresh(QStringLiteral("1234.M1P1Q1.host:2,"));
+ QVERIFY2(fresh.endsWith(QStringLiteral(":2,")),
+ qPrintable(QStringLiteral("empty flag suffix lost: %1").arg(fresh)));
+}
+
+void TestMaildirName::aNameWithNoSuffixGetsNone()
+{
+ const QString fresh = MaildirName::fresh(QStringLiteral("1234.M1P1Q1.host"));
+ QVERIFY2(!fresh.contains(QStringLiteral(":2,")),
+ qPrintable(QStringLiteral("a suffix was invented: %1").arg(fresh)));
+}
+
+void TestMaildirName::theUidInfixIsNotCarriedAcross()
+{
+ // The reason this function exists rather than reusing the old name.
+ // mbsync writes a `,U=<n>` infix that is meaningful only within one
+ // folder; carrying it across a folder boundary produced
+ // "Maildir error: duplicate UID" on the user's real mail.
+ const QString fresh =
+ MaildirName::fresh(QStringLiteral("1234.M1P1Q1.host,U=42:2,S"));
+ QVERIFY2(!fresh.contains(QStringLiteral("U=42")),
+ qPrintable(QStringLiteral("the UID infix was carried across: %1").arg(fresh)));
+ QVERIFY2(fresh.endsWith(QStringLiteral(":2,S")),
+ qPrintable(QStringLiteral("flags lost while dropping the UID: %1").arg(fresh)));
+}
+
+QTEST_MAIN(TestMaildirName)
+#include "test_maildirname.moc"
+```
+
+`QTEST_MAIN` rather than `QTEST_APPLESS_MAIN`: the implementation calls
+`QCoreApplication::applicationPid()`.
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `cmake --build build 2>&1 | tail -3`
+Expected: FAIL, `maildirname.h: No such file or directory`.
+
+- [ ] **Step 3: Create the header**
+
+`src/maildirname.h` (GPL header, then):
+
+```cpp
+#pragma once
+
+#include <QString>
+
+/// Maildir filename generation, shared by every path that writes a message
+/// file: NotmuchWorker::moveMessages() and DraftStore.
+///
+/// A namespace rather than a class; there is no state beyond a counter.
+namespace MaildirName {
+
+/// A fresh, unique Maildir filename, preserving \p oldName's flag suffix.
+///
+/// A FRESH name, never a reuse. mbsync writes a `,U=<n>` infix that is
+/// meaningful only within one folder, and carrying it across a folder
+/// boundary produced "Maildir error: duplicate UID" on real mail. Only the
+/// `:2,` flag suffix is carried, because the flags describe the message
+/// rather than its position.
+///
+/// Pass an empty string for a message that has no previous name, which is
+/// what a newly composed draft is.
+QString fresh(const QString &oldName);
+
+} // namespace MaildirName
+```
+
+- [ ] **Step 4: Move the implementation**
+
+Create `src/maildirname.cpp` with the body currently at
+`src/notmuchworker.cpp:713-743`, unchanged except for the name and the
+includes. Copy it verbatim, including its comments, and add:
+
+```cpp
+#include "maildirname.h"
+
+#include <QCoreApplication>
+#include <QDateTime>
+#include <QHostInfo>
+```
+
+Rename `freshMaildirName` to `MaildirName::fresh`.
+
+- [ ] **Step 5: Delete the original and include the header**
+
+In `src/notmuchworker.cpp`, delete the whole `freshMaildirName` function from
+the anonymous namespace, add `#include "maildirname.h"` with the other project
+includes, and change the one call site (near `:829`) from
+`freshMaildirName(...)` to `MaildirName::fresh(...)`.
+
+- [ ] **Step 6: Register the source and the test**
+
+Add `maildirname.cpp` to `src/CMakeLists.txt` and
+`add_qtmaildir_test(maildirname)` to `tests/CMakeLists.txt`.
+
+- [ ] **Step 7: Run the full suite**
+
+Run: `ctest --test-dir build --output-on-failure 2>&1 | tail -5`
+Expected: every test passes. `test_notmuchworker` is the one that matters here: it exercises the moved function through `moveMessages`, so a botched move fails there rather than in the new test.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add src/maildirname.h src/maildirname.cpp src/notmuchworker.cpp \
+ src/CMakeLists.txt tests/test_maildirname.cpp tests/CMakeLists.txt
+git commit -S -m "refactor(maildir): extract freshMaildirName for reuse, item 123
+
+DraftStore needs the same filename generation moveMessages() already has,
+and duplicating it would duplicate a correctness property rather than a
+convenience: the comment records that carrying mbsync's ,U= infix across a
+folder boundary produced 'Maildir error: duplicate UID' on real mail.
+
+A pure move with no behaviour change, committed on its own so a bisect can
+tell it apart from the feature that needed it. The function gains its own
+tests, including the UID-infix case that previously had none."
+```
+
+---
+
+### Task 4: MessageBuilder
+
+The heart of the feature and the largest task. Pure: an `OutgoingMessage` in,
+RFC822 bytes out, no I/O except reading attachment files.
+
+**Read before starting.** The GMime calls below were verified empirically on
+2026-08-20 against GMime 3.2 on this machine, because the obvious ones are
+wrong in a way that only shows on accented text:
+
+- **GMime defaults to iso-8859-1, not UTF-8.** A subject set without an
+ explicit charset argument came out `=?iso-8859-1?B?...?=`. This user writes
+ Italian, so this is every message rather than an edge case.
+- **`g_mime_text_part_set_text()` encodes using the charset set at the moment
+ it is called.** Setting the charset afterwards relabels the part without
+ re-encoding it, producing a part labelled `charset=utf-8` whose bytes are
+ latin-1: mojibake that looks correct in the headers. The plan below builds
+ the content stream directly instead, so the bytes are exactly the UTF-8
+ supplied.
+- **`g_mime_format_options_set_allow_international()` is commented out** in
+ this build's headers and cannot be used.
+- No `Date` or `Message-ID` header is generated unless asked for.
+
+**Files:**
+- Create: `src/messagebuilder.h`, `src/messagebuilder.cpp`
+- Create: `tests/test_messagebuilder.cpp`
+- Modify: `src/CMakeLists.txt`, `tests/CMakeLists.txt`
+
+- [ ] **Step 1: Write the header**
+
+`src/messagebuilder.h` (GPL header, then):
+
+```cpp
+#pragma once
+
+#include <QByteArray>
+#include <QString>
+
+#include "types.h"
+
+class Account;
+
+/// Turns an OutgoingMessage into the RFC822 bytes that get sent.
+///
+/// ONE built message 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.
+///
+/// GMime rather than assembling RFC822 by string. The alternative means
+/// reimplementing RFC 2047 header encoding, quoted-printable for accented
+/// bodies, boundary uniqueness and line-length limits. This user writes
+/// Italian; a body containing an accented character is every message, and a
+/// bug there produces mail that looks correct locally and arrives as mojibake.
+namespace MessageBuilder {
+
+struct Result
+{
+ QByteArray bytes; ///< The complete message. Empty on failure.
+ QString error; ///< Empty on success.
+ QString messageId; ///< The generated Message-ID, for the caller's records.
+
+ bool ok() const { return error.isEmpty(); }
+};
+
+/// Builds \p message as sent from \p account.
+///
+/// Fails, rather than sending a partial message, when an attachment named in
+/// the message no longer exists. That is checked HERE, at build time, rather
+/// than when the file was attached: a file can vanish in between, and the
+/// failure must stop the send rather than produce a message missing the thing
+/// it was written to carry.
+Result build(const OutgoingMessage &message, const Account &account);
+
+} // namespace MessageBuilder
+```
+
+- [ ] **Step 2: Write the failing test**
+
+Create `tests/test_messagebuilder.cpp`. This asserts on the **generated bytes**
+rather than round-tripping through `MimeParser`, since a builder and a parser
+that agree can be wrong together.
+
+```cpp
+#include <QtTest>
+#include <QTemporaryDir>
+
+#include "config.h"
+#include "messagebuilder.h"
+#include "types.h"
+
+class TestMessageBuilder : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void initTestCase();
+
+ void plainOnlyWhenSendHtmlIsOff();
+ void multipartAlternativeWhenSendHtmlIsOn();
+ void thePlainPartCarriesTheMarkdownSourceUnmodified();
+ void theHtmlPartIsRenderedFromTheSameSource();
+ void anAccentedBodyIsUtf8QuotedPrintable();
+ void anAccentedSubjectIsRfc2047Utf8();
+ void inReplyToAndReferencesAreCarried();
+ void attachmentsProduceMultipartMixed();
+ void aMissingAttachmentFailsTheBuild();
+ void everyMessageCarriesADateAndMessageId();
+ void recipientsAppearInTheirOwnHeaders();
+
+private:
+ Account m_account;
+};
+
+void TestMessageBuilder::initTestCase()
+{
+ m_account.key = QStringLiteral("work");
+ m_account.name = QStringLiteral("Danilo M.");
+ m_account.address = QStringLiteral("user@example.org");
+ m_account.maildir = QStringLiteral("work");
+ m_account.sendCommand = QStringLiteral("/bin/true");
+}
+
+void TestMessageBuilder::plainOnlyWhenSendHtmlIsOff()
+{
+ OutgoingMessage message;
+ message.to = { QStringLiteral("to@example.org") };
+ message.subject = QStringLiteral("Subject");
+ message.markdownBody = QStringLiteral("**bold**");
+ message.sendHtml = false;
+
+ const MessageBuilder::Result result = MessageBuilder::build(message, m_account);
+ QVERIFY2(result.ok(), qPrintable(result.error));
+
+ const QString text = QString::fromUtf8(result.bytes);
+ QVERIFY2(text.contains(QStringLiteral("Content-Type: text/plain")),
+ qPrintable(QStringLiteral("no text/plain part:\n%1").arg(text)));
+ QVERIFY2(!text.contains(QStringLiteral("multipart/alternative")),
+ "sendHtml was off and an alternative part was built anyway");
+ QVERIFY2(!text.contains(QStringLiteral("text/html")),
+ "sendHtml was off and an HTML part was built anyway");
+}
+
+void TestMessageBuilder::multipartAlternativeWhenSendHtmlIsOn()
+{
+ OutgoingMessage message;
+ message.to = { QStringLiteral("to@example.org") };
+ message.subject = QStringLiteral("Subject");
+ message.markdownBody = QStringLiteral("**bold**");
+ message.sendHtml = true;
+
+ const MessageBuilder::Result result = MessageBuilder::build(message, m_account);
+ QVERIFY2(result.ok(), qPrintable(result.error));
+
+ const QString text = QString::fromUtf8(result.bytes);
+ QVERIFY(text.contains(QStringLiteral("multipart/alternative")));
+ QVERIFY(text.contains(QStringLiteral("text/plain")));
+ QVERIFY(text.contains(QStringLiteral("text/html")));
+
+ // Order matters in multipart/alternative: least-rich first, so a client
+ // that renders the LAST part it understands picks the HTML.
+ const int plainAt = text.indexOf(QStringLiteral("text/plain"));
+ const int htmlAt = text.indexOf(QStringLiteral("text/html"));
+ QVERIFY2(plainAt < htmlAt,
+ "text/html came before text/plain, so clients pick the plain part");
+}
+
+void TestMessageBuilder::thePlainPartCarriesTheMarkdownSourceUnmodified()
+{
+ // The markdown source IS the plain part. Not a stripped-of-syntax version
+ // of it: `**bold**` is readable as emphasis and rewriting it would mean
+ // inventing a second renderer.
+ OutgoingMessage message;
+ message.to = { QStringLiteral("to@example.org") };
+ message.markdownBody = QStringLiteral("**bold** and - [ ] a task");
+ message.sendHtml = true;
+
+ const MessageBuilder::Result result = MessageBuilder::build(message, m_account);
+ QVERIFY2(result.ok(), qPrintable(result.error));
+
+ QVERIFY2(QString::fromUtf8(result.bytes).contains(QStringLiteral("**bold**")),
+ "the markdown source did not survive into the plain part");
+}
+
+void TestMessageBuilder::theHtmlPartIsRenderedFromTheSameSource()
+{
+ OutgoingMessage message;
+ message.to = { QStringLiteral("to@example.org") };
+ message.markdownBody = QStringLiteral("**bold**");
+ message.sendHtml = true;
+
+ const MessageBuilder::Result result = MessageBuilder::build(message, m_account);
+ QVERIFY2(result.ok(), qPrintable(result.error));
+
+ QVERIFY2(QString::fromUtf8(result.bytes).contains(QStringLiteral("<strong>bold</strong>")),
+ "the HTML part was not rendered from the markdown");
+}
+
+void TestMessageBuilder::anAccentedBodyIsUtf8QuotedPrintable()
+{
+ // The trap this test exists for. GMime defaults to iso-8859-1, and
+ // set_text() encodes with whatever charset is set at the moment it runs,
+ // so a part can be LABELLED utf-8 while carrying latin-1 bytes. That
+ // arrives as mojibake and looks correct locally.
+ //
+ // Asserting on the bytes: `=C3=A9` is UTF-8 quoted-printable for e-acute.
+ // `=E9` is the latin-1 encoding of the same character and is the failure.
+ OutgoingMessage message;
+ message.to = { QStringLiteral("to@example.org") };
+ message.markdownBody = QString::fromUtf8("perch\xC3\xA9 \xC3\xA8 cos\xC3\xAC");
+ message.sendHtml = false;
+
+ const MessageBuilder::Result result = MessageBuilder::build(message, m_account);
+ QVERIFY2(result.ok(), qPrintable(result.error));
+
+ const QString text = QString::fromUtf8(result.bytes);
+ QVERIFY2(text.contains(QStringLiteral("charset=utf-8"), Qt::CaseInsensitive),
+ qPrintable(QStringLiteral("the part is not labelled utf-8:\n%1").arg(text)));
+ QVERIFY2(text.contains(QStringLiteral("=C3=A9")),
+ qPrintable(QStringLiteral("the body is not UTF-8 quoted-printable:\n%1").arg(text)));
+ QVERIFY2(!text.contains(QStringLiteral("=E9")),
+ "the body carries latin-1 bytes under a utf-8 label: mojibake");
+}
+
+void TestMessageBuilder::anAccentedSubjectIsRfc2047Utf8()
+{
+ OutgoingMessage message;
+ message.to = { QStringLiteral("to@example.org") };
+ message.subject = QString::fromUtf8("Perch\xC3\xA9 \xC3\xA8 importante");
+ message.markdownBody = QStringLiteral("body");
+
+ const MessageBuilder::Result result = MessageBuilder::build(message, m_account);
+ QVERIFY2(result.ok(), qPrintable(result.error));
+
+ const QString text = QString::fromUtf8(result.bytes);
+ QVERIFY2(text.contains(QStringLiteral("=?UTF-8?"), Qt::CaseInsensitive),
+ qPrintable(QStringLiteral("the subject is not RFC2047 UTF-8:\n%1").arg(text)));
+ QVERIFY2(!text.contains(QStringLiteral("=?iso-8859-1?"), Qt::CaseInsensitive),
+ "the subject fell back to iso-8859-1, GMime's default");
+}
+
+void TestMessageBuilder::inReplyToAndReferencesAreCarried()
+{
+ // Not optional. Without them a reply appears as an orphan thread in the
+ // sender's own client.
+ OutgoingMessage message;
+ message.to = { QStringLiteral("to@example.org") };
+ message.markdownBody = QStringLiteral("body");
+ message.inReplyTo = QStringLiteral("<orig@example.org>");
+ message.references = { QStringLiteral("<older@example.org>"),
+ QStringLiteral("<orig@example.org>") };
+
+ const MessageBuilder::Result result = MessageBuilder::build(message, m_account);
+ QVERIFY2(result.ok(), qPrintable(result.error));
+
+ const QString text = QString::fromUtf8(result.bytes);
+ QVERIFY2(text.contains(QStringLiteral("In-Reply-To: <orig@example.org>")),
+ qPrintable(QStringLiteral("no In-Reply-To:\n%1").arg(text)));
+ QVERIFY2(text.contains(QStringLiteral("References:")),
+ "no References header");
+ QVERIFY2(text.contains(QStringLiteral("<older@example.org>")),
+ "References dropped the older entry");
+}
+
+void TestMessageBuilder::attachmentsProduceMultipartMixed()
+{
+ QTemporaryDir dir;
+ const QString path = dir.filePath(QStringLiteral("note.txt"));
+ {
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::WriteOnly));
+ file.write("attached content\n");
+ }
+
+ OutgoingMessage message;
+ message.to = { QStringLiteral("to@example.org") };
+ message.markdownBody = QStringLiteral("see attached");
+ message.sendHtml = true;
+ message.attachments = { path };
+
+ const MessageBuilder::Result result = MessageBuilder::build(message, m_account);
+ QVERIFY2(result.ok(), qPrintable(result.error));
+
+ const QString text = QString::fromUtf8(result.bytes);
+ QVERIFY2(text.contains(QStringLiteral("multipart/mixed")),
+ qPrintable(QStringLiteral("no multipart/mixed:\n%1").arg(text)));
+ // The alternative nests INSIDE the mixed part, not beside it.
+ QVERIFY(text.contains(QStringLiteral("multipart/alternative")));
+ QVERIFY2(text.indexOf(QStringLiteral("multipart/mixed"))
+ < text.indexOf(QStringLiteral("multipart/alternative")),
+ "the alternative part is not nested inside the mixed part");
+ QVERIFY2(text.contains(QStringLiteral("note.txt")),
+ "the attachment filename is not in the message");
+ QVERIFY(text.contains(QStringLiteral("Content-Disposition: attachment")));
+}
+
+void TestMessageBuilder::aMissingAttachmentFailsTheBuild()
+{
+ // Checked at BUILD time, not at attach time: a file can vanish in
+ // between, and the failure must stop the send rather than produce a
+ // message missing the thing it was written to carry.
+ OutgoingMessage message;
+ message.to = { QStringLiteral("to@example.org") };
+ message.markdownBody = QStringLiteral("see attached");
+ message.attachments = { QStringLiteral("/nonexistent/vanished.pdf") };
+
+ const MessageBuilder::Result result = MessageBuilder::build(message, m_account);
+ QVERIFY2(!result.ok(), "a build with a missing attachment reported success");
+ QVERIFY2(result.bytes.isEmpty(),
+ "a failed build still produced bytes, which could be sent");
+ QVERIFY2(result.error.contains(QStringLiteral("vanished.pdf")),
+ qPrintable(QStringLiteral("the error does not name the file: %1").arg(result.error)));
+}
+
+void TestMessageBuilder::everyMessageCarriesADateAndMessageId()
+{
+ // GMime generates neither unless asked. A message without a Message-ID
+ // cannot be threaded by anything that receives it.
+ OutgoingMessage message;
+ message.to = { QStringLiteral("to@example.org") };
+ message.markdownBody = QStringLiteral("body");
+
+ const MessageBuilder::Result result = MessageBuilder::build(message, m_account);
+ QVERIFY2(result.ok(), qPrintable(result.error));
+
+ const QString text = QString::fromUtf8(result.bytes);
+ QVERIFY2(text.contains(QStringLiteral("Date:")), "no Date header");
+ QVERIFY2(text.contains(QStringLiteral("Message-Id:"), Qt::CaseInsensitive),
+ "no Message-ID header");
+ QVERIFY2(!result.messageId.isEmpty(),
+ "the built message-id was not reported back to the caller");
+}
+
+void TestMessageBuilder::recipientsAppearInTheirOwnHeaders()
+{
+ // Bcc must NOT appear in the built bytes: the whole point is that other
+ // recipients cannot see it. The send command gets recipients from the
+ // envelope, which is what `-t` reads, so a Bcc header here would leak.
+ OutgoingMessage message;
+ message.to = { QStringLiteral("to@example.org") };
+ message.cc = { QStringLiteral("cc@example.org") };
+ message.bcc = { QStringLiteral("bcc@example.org") };
+ message.markdownBody = QStringLiteral("body");
+
+ const MessageBuilder::Result result = MessageBuilder::build(message, m_account);
+ QVERIFY2(result.ok(), qPrintable(result.error));
+
+ const QString text = QString::fromUtf8(result.bytes);
+ QVERIFY(text.contains(QStringLiteral("To: to@example.org")));
+ QVERIFY(text.contains(QStringLiteral("Cc: cc@example.org")));
+ QVERIFY(text.contains(QStringLiteral("From: ")));
+ QVERIFY2(text.contains(QStringLiteral("bcc@example.org")),
+ "the Bcc recipient is absent entirely, so -t cannot deliver to them");
+}
+
+QTEST_MAIN(TestMessageBuilder)
+#include "test_messagebuilder.moc"
+```
+
+**A decision the last test encodes.** `Bcc` is kept in the built message
+because the example `send_command` is `msmtp -t`, which reads recipients from
+the headers and *strips* `Bcc` itself before transmission. Removing it here
+would mean blind recipients never receive the message at all. If a later change
+switches to passing recipients as arguments, this test must change with it, and
+the spec's rule that no message content reaches an argument makes that
+unlikely.
+
+- [ ] **Step 3: Run to verify it fails**
+
+Run: `cmake --build build 2>&1 | tail -3`
+Expected: FAIL, `messagebuilder.h: No such file or directory`.
+
+- [ ] **Step 4: Write the implementation**
+
+Create `src/messagebuilder.cpp`:
+
+```cpp
+// gmime BEFORE any Qt header. glib declares a struct field named `signals`,
+// which Qt defines as a macro. This is the rule CLAUDE.md records and it
+// applies to every translation unit that touches GMime.
+#include <gmime/gmime.h>
+
+#include "messagebuilder.h"
+
+#include "config.h"
+#include "markdownrenderer.h"
+
+#include <QDateTime>
+#include <QFileInfo>
+#include <QMimeDatabase>
+#include <QMimeType>
+
+namespace {
+
+/// Initialises GMime exactly once per process.
+///
+/// g_mime_init() is not reentrant and the library is also initialised by
+/// MimeParser. Calling it twice is harmless but this keeps the ordering
+/// obvious from either entry point.
+void ensureGMimeInitialised()
+{
+ static bool done = false;
+ if (!done) {
+ g_mime_init();
+ done = true;
+ }
+}
+
+/// A text part carrying exactly the UTF-8 bytes supplied.
+///
+/// Built from an explicit stream rather than with g_mime_text_part_set_text().
+/// That function encodes using the charset set at the moment it is CALLED, so
+/// setting the charset afterwards relabels the part without re-encoding it and
+/// produces a part marked `charset=utf-8` whose bytes are latin-1. Verified on
+/// 2026-08-20: it arrives as mojibake and looks correct in the headers.
+GMimePart *makeTextPart(const QString &subtype, const QString &text)
+{
+ const QByteArray utf8 = text.toUtf8();
+
+ GMimePart *part = g_mime_part_new_with_type(
+ "text", subtype.toUtf8().constData());
+ g_mime_object_set_content_type_parameter(GMIME_OBJECT(part), "charset", "utf-8");
+
+ GMimeStream *stream =
+ g_mime_stream_mem_new_with_buffer(utf8.constData(), utf8.size());
+ GMimeDataWrapper *wrapper =
+ g_mime_data_wrapper_new_with_stream(stream, GMIME_CONTENT_ENCODING_DEFAULT);
+ g_mime_part_set_content(part, wrapper);
+
+ // Quoted-printable rather than 8bit: some servers still refuse 8-bit
+ // bodies, and an accented Italian body is every message here.
+ g_mime_part_set_content_encoding(part, GMIME_CONTENT_ENCODING_QUOTEDPRINTABLE);
+
+ g_object_unref(wrapper);
+ g_object_unref(stream);
+ return part;
+}
+
+/// Sets an address header with explicit UTF-8 encoding of display names.
+void setAddressHeader(GMimeMessage *message, const char *header,
+ const QStringList &addresses, GMimeFormatOptions *format)
+{
+ if (addresses.isEmpty())
+ return;
+
+ InternetAddressList *list = internet_address_list_new();
+ for (const QString &address : addresses) {
+ const QByteArray utf8 = address.trimmed().toUtf8();
+ if (utf8.isEmpty())
+ continue;
+ // parse() rather than mailbox_new(): the field may hold
+ // "Name <addr>" as typed, and re-parsing is what splits it correctly.
+ InternetAddressList *parsed =
+ internet_address_list_parse(nullptr, utf8.constData());
+ if (parsed) {
+ internet_address_list_append(list, parsed);
+ g_object_unref(parsed);
+ }
+ }
+
+ char *rendered = internet_address_list_to_string(list, format, TRUE);
+ if (rendered) {
+ g_mime_object_set_header(GMIME_OBJECT(message), header, rendered, "utf-8");
+ g_free(rendered);
+ }
+ g_object_unref(list);
+}
+
+} // namespace
+
+MessageBuilder::Result MessageBuilder::build(const OutgoingMessage &message,
+ const Account &account)
+{
+ Result result;
+ ensureGMimeInitialised();
+
+ // Every attachment is checked BEFORE anything is built. A partial message
+ // that is missing the file it was written to carry must never reach the
+ // send command.
+ for (const QString &path : message.attachments) {
+ const QFileInfo info(path);
+ if (!info.exists() || !info.isReadable()) {
+ result.error =
+ QObject::tr("The attachment '%1' no longer exists or cannot be read.")
+ .arg(info.fileName().isEmpty() ? path : info.fileName());
+ return result;
+ }
+ }
+
+ GMimeFormatOptions *format = g_mime_format_options_get_default();
+ GMimeMessage *mime = g_mime_message_new(TRUE);
+
+ // From: the account's own identity.
+ g_mime_message_add_mailbox(mime, GMIME_ADDRESS_TYPE_FROM,
+ account.name.toUtf8().constData(),
+ account.address.toUtf8().constData());
+
+ setAddressHeader(mime, "To", message.to, format);
+ setAddressHeader(mime, "Cc", message.cc, format);
+ // Bcc is kept in the built bytes deliberately: `msmtp -t` reads its
+ // recipients from the headers and strips Bcc itself before transmission,
+ // so removing it here would mean blind recipients never receive the
+ // message at all.
+ setAddressHeader(mime, "Bcc", message.bcc, format);
+
+ // The explicit "utf-8" argument is required. Without it GMime encodes the
+ // subject as iso-8859-1, which is its default rather than an inference
+ // from the content.
+ g_mime_message_set_subject(mime, message.subject.toUtf8().constData(), "utf-8");
+
+ // Threading. Not optional: without these a reply appears as an orphan
+ // thread in the sender's own client.
+ if (!message.inReplyTo.isEmpty()) {
+ g_mime_object_set_header(GMIME_OBJECT(mime), "In-Reply-To",
+ message.inReplyTo.toUtf8().constData(), "utf-8");
+ }
+ if (!message.references.isEmpty()) {
+ const QString joined = message.references.join(QLatin1Char(' '));
+ g_mime_object_set_header(GMIME_OBJECT(mime), "References",
+ joined.toUtf8().constData(), "utf-8");
+ }
+
+ // GMime generates neither of these on its own.
+ GDateTime *now = g_date_time_new_now_local();
+ g_mime_message_set_date(mime, now);
+ g_date_time_unref(now);
+
+ const QString domain = account.address.section(QLatin1Char('@'), 1);
+ char *generatedId = g_mime_utils_generate_message_id(
+ domain.isEmpty() ? "localhost" : domain.toUtf8().constData());
+ if (generatedId) {
+ g_mime_message_set_message_id(mime, generatedId);
+ result.messageId = QStringLiteral("<%1>").arg(QString::fromUtf8(generatedId));
+ g_free(generatedId);
+ }
+
+ // The body. The markdown source IS the plain part, unmodified.
+ GMimeObject *body = nullptr;
+ GMimePart *plain = makeTextPart(QStringLiteral("plain"), message.markdownBody);
+
+ if (message.sendHtml) {
+ const QString html = MarkdownRenderer::toHtml(message.markdownBody);
+ GMimePart *htmlPart = makeTextPart(QStringLiteral("html"), html);
+
+ GMimeMultipart *alternative =
+ GMIME_MULTIPART(g_mime_multipart_new_with_subtype("alternative"));
+ // Least-rich FIRST. A client renders the last part it understands, so
+ // this order is what makes the HTML win where it is supported.
+ g_mime_multipart_add(alternative, GMIME_OBJECT(plain));
+ g_mime_multipart_add(alternative, GMIME_OBJECT(htmlPart));
+ g_object_unref(plain);
+ g_object_unref(htmlPart);
+ body = GMIME_OBJECT(alternative);
+ } else {
+ body = GMIME_OBJECT(plain);
+ }
+
+ if (!message.attachments.isEmpty()) {
+ // multipart/mixed WRAPPING the body, so the alternative nests inside
+ // rather than sitting beside the attachments.
+ GMimeMultipart *mixed =
+ GMIME_MULTIPART(g_mime_multipart_new_with_subtype("mixed"));
+ g_mime_multipart_add(mixed, body);
+ g_object_unref(body);
+
+ QMimeDatabase mimeDatabase;
+ for (const QString &path : message.attachments) {
+ const QFileInfo info(path);
+ const QMimeType type = mimeDatabase.mimeTypeForFile(info);
+ const QString typeName =
+ type.isValid() ? type.name() : QStringLiteral("application/octet-stream");
+
+ GMimePart *part = g_mime_part_new_with_type(
+ typeName.section(QLatin1Char('/'), 0, 0).toUtf8().constData(),
+ typeName.section(QLatin1Char('/'), 1).toUtf8().constData());
+
+ GMimeStream *stream = g_mime_stream_file_open(
+ path.toUtf8().constData(), "r", nullptr);
+ if (!stream) {
+ g_object_unref(part);
+ g_object_unref(mixed);
+ g_object_unref(mime);
+ result.error = QObject::tr("The attachment '%1' could not be opened.")
+ .arg(info.fileName());
+ return result;
+ }
+
+ GMimeDataWrapper *wrapper = g_mime_data_wrapper_new_with_stream(
+ stream, GMIME_CONTENT_ENCODING_DEFAULT);
+ g_mime_part_set_content(part, wrapper);
+ g_mime_part_set_content_encoding(part, GMIME_CONTENT_ENCODING_BASE64);
+ g_mime_part_set_filename(part, info.fileName().toUtf8().constData());
+ g_mime_object_set_disposition(GMIME_OBJECT(part), "attachment");
+
+ g_mime_multipart_add(mixed, GMIME_OBJECT(part));
+ g_object_unref(wrapper);
+ g_object_unref(stream);
+ g_object_unref(part);
+ }
+ body = GMIME_OBJECT(mixed);
+ }
+
+ g_mime_message_set_mime_part(mime, body);
+ g_object_unref(body);
+
+ char *rendered = g_mime_object_to_string(GMIME_OBJECT(mime), format);
+ if (rendered) {
+ result.bytes = QByteArray(rendered);
+ g_free(rendered);
+ } else {
+ result.error = QObject::tr("The message could not be assembled.");
+ }
+
+ g_object_unref(mime);
+ return result;
+}
+```
+
+Add `#include <QObject>` if `tr()` does not resolve.
+
+- [ ] **Step 5: Register the source and the test**
+
+`messagebuilder.cpp` in `src/CMakeLists.txt`, `add_qtmaildir_test(messagebuilder)` in `tests/CMakeLists.txt`.
+
+- [ ] **Step 6: Run the tests**
+
+Run: `ctest --test-dir build -R messagebuilder --output-on-failure`
+Expected: PASS, 11 functions.
+
+- [ ] **Step 7: Mutation-check the encoding tests**
+
+These are the ones that matter and the ones most likely to be vacuous. Verify the accented-body test can fail:
+
+```bash
+sed -i 's/"charset", "utf-8"/"charset", "iso-8859-1"/' src/messagebuilder.cpp
+cmake --build build >/dev/null 2>&1
+ctest --test-dir build -R messagebuilder 2>&1 | grep -E 'Passed|Failed'
+git checkout src/messagebuilder.cpp
+cmake --build build >/dev/null 2>&1
+```
+Expected: `Failed`. If it passes, the test is not looking at what it claims to.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add src/messagebuilder.h src/messagebuilder.cpp src/CMakeLists.txt \
+ tests/test_messagebuilder.cpp tests/CMakeLists.txt
+git commit -S -m "feat(compose): build outgoing messages with GMime, item 123
+
+One built message 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.
+
+Three GMime defaults are wrong for this application and each is corrected
+explicitly, because all three fail only on accented text and this user
+writes Italian:
+
+GMime encodes as iso-8859-1 unless told otherwise, so the subject carries an
+explicit utf-8 argument. g_mime_text_part_set_text() encodes with whatever
+charset is set when it is CALLED, so setting the charset afterwards produces
+a part labelled utf-8 carrying latin-1 bytes; the content stream is built
+directly instead. And neither Date nor Message-ID is generated unless asked
+for, and a message without a Message-ID cannot be threaded by anything that
+receives it.
+
+Attachments are checked at build time rather than at attach time: a file can
+vanish in between, and a message missing the thing it was written to carry
+must never reach the send command."
+```
+
+---
+
+### Task 5: DraftStore
+
+Maildir writes. Drafts and sent copies are the same operation into two folders,
+which is why this is one unit rather than two.
+
+**Files:**
+- Create: `src/draftstore.h`, `src/draftstore.cpp`
+- Create: `tests/test_draftstore.cpp`
+- Modify: `src/CMakeLists.txt`, `tests/CMakeLists.txt`
+
+- [ ] **Step 1: Write the header**
+
+`src/draftstore.h` (GPL header, then):
+
+```cpp
+#pragma once
+
+#include <QByteArray>
+#include <QString>
+
+/// Writes message bytes into a Maildir folder.
+///
+/// Drafts and sent copies are the same operation into different folders with
+/// different flags, so they are one unit. Nothing here calls notmuch: the
+/// files become visible on the next sync, which keeps the read-only-by-default
+/// rule intact and needs no write lock.
+class DraftStore
+{
+public:
+ struct Result
+ {
+ QString path; ///< The file written. Empty on failure.
+ QString error; ///< Empty on success.
+
+ bool ok() const { return error.isEmpty(); }
+ };
+
+ /// Writes \p bytes into \p folderPath, an absolute Maildir folder.
+ ///
+ /// \p flags is the Maildir flag string without the `:2,` prefix: "D" for a
+ /// draft, "S" for a sent copy.
+ ///
+ /// \p previousPath, when not empty, is unlinked AFTER the new file is
+ /// safely in place. Maildir has no in-place edit, so a draft rewritten
+ /// every thirty seconds would otherwise accumulate one file per pause.
+ /// The order matters: unlinking first would lose the draft entirely if the
+ /// write then failed.
+ static Result write(const QString &folderPath, const QByteArray &bytes,
+ const QString &flags, const QString &previousPath = {});
+};
+```
+
+- [ ] **Step 2: Write the failing test**
+
+Create `tests/test_draftstore.cpp`:
+
+```cpp
+#include <QtTest>
+#include <QTemporaryDir>
+
+#include "draftstore.h"
+
+class TestDraftStore : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void aWriteLandsInCurWithTheGivenFlags();
+ void twoWritesProduceDistinctFiles();
+ void thePreviousRevisionIsUnlinked();
+ void theNewFileExistsBeforeTheOldOneGoes();
+ void anUnwritableDirectoryReportsRatherThanThrows();
+ void theFolderIsCreatedWhenAbsent();
+ void theBytesAreWrittenVerbatim();
+};
+
+void TestDraftStore::aWriteLandsInCurWithTheGivenFlags()
+{
+ // cur/, never new/. A file dropped in new/ is re-announced as fresh mail
+ // by every reader of the Maildir, so a draft would arrive as a new
+ // message every time it autosaved.
+ QTemporaryDir dir;
+ const DraftStore::Result result = DraftStore::write(
+ dir.path(), QByteArray("From: a@example.org\r\n\r\nbody\r\n"),
+ QStringLiteral("D"));
+
+ QVERIFY2(result.ok(), qPrintable(result.error));
+ QVERIFY2(result.path.contains(QStringLiteral("/cur/")),
+ qPrintable(QStringLiteral("not written to cur/: %1").arg(result.path)));
+ QVERIFY2(result.path.endsWith(QStringLiteral(":2,D")),
+ qPrintable(QStringLiteral("flags missing: %1").arg(result.path)));
+ QVERIFY(QFile::exists(result.path));
+}
+
+void TestDraftStore::twoWritesProduceDistinctFiles()
+{
+ QTemporaryDir dir;
+ const DraftStore::Result first = DraftStore::write(
+ dir.path(), QByteArray("one"), QStringLiteral("D"));
+ const DraftStore::Result second = DraftStore::write(
+ dir.path(), QByteArray("two"), QStringLiteral("D"));
+
+ QVERIFY(first.ok() && second.ok());
+ QVERIFY2(first.path != second.path,
+ "two writes in the same second produced the same filename");
+}
+
+void TestDraftStore::thePreviousRevisionIsUnlinked()
+{
+ // Otherwise a draft autosaved every thirty seconds accumulates one file
+ // per pause, and every one of them syncs to the server.
+ QTemporaryDir dir;
+ const DraftStore::Result first = DraftStore::write(
+ dir.path(), QByteArray("revision one"), QStringLiteral("D"));
+ QVERIFY(first.ok());
+
+ const DraftStore::Result second = DraftStore::write(
+ dir.path(), QByteArray("revision two"), QStringLiteral("D"), first.path);
+ QVERIFY(second.ok());
+
+ QVERIFY2(!QFile::exists(first.path),
+ "the previous draft revision was left behind");
+ QVERIFY(QFile::exists(second.path));
+}
+
+void TestDraftStore::theNewFileExistsBeforeTheOldOneGoes()
+{
+ // The ordering that matters: unlinking first would lose the draft
+ // entirely if the write then failed. Asserted by pointing the write at an
+ // unwritable destination and checking the old revision SURVIVED.
+ QTemporaryDir good;
+ const DraftStore::Result first = DraftStore::write(
+ good.path(), QByteArray("precious"), QStringLiteral("D"));
+ QVERIFY(first.ok());
+
+ const DraftStore::Result failed = DraftStore::write(
+ QStringLiteral("/proc/nonexistent-and-unwritable"),
+ QByteArray("replacement"), QStringLiteral("D"), first.path);
+
+ QVERIFY2(!failed.ok(), "a write to an unwritable path reported success");
+ QVERIFY2(QFile::exists(first.path),
+ "the previous revision was unlinked even though the new write failed");
+}
+
+void TestDraftStore::anUnwritableDirectoryReportsRatherThanThrows()
+{
+ const DraftStore::Result result = DraftStore::write(
+ QStringLiteral("/proc/nonexistent-and-unwritable"),
+ QByteArray("body"), QStringLiteral("D"));
+
+ QVERIFY2(!result.ok(), "an unwritable directory reported success");
+ QVERIFY2(!result.error.isEmpty(), "a failure carried no message to show");
+ QVERIFY(result.path.isEmpty());
+}
+
+void TestDraftStore::theFolderIsCreatedWhenAbsent()
+{
+ // A configured drafts folder that does not exist yet is ordinary on a
+ // fresh account. Note the asymmetry with the trash folder: creating a
+ // folder here is safe because the NAME came from configuration and is
+ // validated at load, not composed from a tag.
+ QTemporaryDir dir;
+ const QString nested = dir.filePath(QStringLiteral("Drafts"));
+ const DraftStore::Result result = DraftStore::write(
+ nested, QByteArray("body"), QStringLiteral("D"));
+
+ QVERIFY2(result.ok(), qPrintable(result.error));
+ QVERIFY(QDir(nested + QStringLiteral("/cur")).exists());
+}
+
+void TestDraftStore::theBytesAreWrittenVerbatim()
+{
+ // A draft must be byte-identical to what would be sent, so nothing here
+ // may re-encode, add a trailing newline, or translate line endings.
+ QTemporaryDir dir;
+ const QByteArray bytes("From: a@example.org\r\nSubject: x\r\n\r\nbody\r\n");
+ const DraftStore::Result result =
+ DraftStore::write(dir.path(), bytes, QStringLiteral("D"));
+ QVERIFY(result.ok());
+
+ QFile file(result.path);
+ QVERIFY(file.open(QIODevice::ReadOnly));
+ QCOMPARE(file.readAll(), bytes);
+}
+
+QTEST_MAIN(TestDraftStore)
+#include "test_draftstore.moc"
+```
+
+- [ ] **Step 3: Run to verify it fails**
+
+Run: `cmake --build build 2>&1 | tail -3`
+Expected: FAIL, `draftstore.h: No such file or directory`.
+
+- [ ] **Step 4: Write the implementation**
+
+`src/draftstore.cpp` (GPL header, then):
+
+```cpp
+#include "draftstore.h"
+
+#include "maildirname.h"
+
+#include <QDir>
+#include <QFile>
+#include <QFileInfo>
+#include <QSaveFile>
+
+DraftStore::Result DraftStore::write(const QString &folderPath,
+ const QByteArray &bytes,
+ const QString &flags,
+ const QString &previousPath)
+{
+ Result result;
+
+ if (folderPath.isEmpty()) {
+ result.error = QObject::tr("No folder was configured to write to.");
+ return result;
+ }
+
+ // cur/, never new/. A file in new/ is re-announced as fresh mail by every
+ // reader of the Maildir, so an autosaved draft would arrive as a new
+ // message on every revision.
+ const QString curPath = folderPath + QStringLiteral("/cur");
+ if (!QDir().mkpath(curPath)) {
+ result.error = QObject::tr("Cannot create the folder %1.").arg(curPath);
+ return result;
+ }
+
+ const QString name = MaildirName::fresh(QString())
+ + QStringLiteral(":2,") + flags;
+ const QString target = curPath + QLatin1Char('/') + name;
+
+ // QSaveFile: writes to a temporary and renames into place, so a reader
+ // never sees a half-written message. mbsync and notmuch both watch this
+ // directory.
+ QSaveFile file(target);
+ if (!file.open(QIODevice::WriteOnly)) {
+ result.error = QObject::tr("Cannot write to %1: %2")
+ .arg(target, file.errorString());
+ return result;
+ }
+
+ if (file.write(bytes) != bytes.size() || !file.commit()) {
+ result.error = QObject::tr("Cannot write to %1: %2")
+ .arg(target, file.errorString());
+ return result;
+ }
+
+ result.path = target;
+
+ // AFTER the new file is safely in place, never before: unlinking first
+ // would lose the draft entirely if the write then failed. A failure to
+ // remove the old revision is not reported as a failure of the write,
+ // because the new revision IS on disk; the cost is one stale file.
+ if (!previousPath.isEmpty() && previousPath != target)
+ QFile::remove(previousPath);
+
+ return result;
+}
+```
+
+Add `#include <QObject>` for `tr()`.
+
+- [ ] **Step 5: Register and run**
+
+Add the source and `add_qtmaildir_test(draftstore)`, then:
+
+Run: `ctest --test-dir build -R draftstore --output-on-failure`
+Expected: PASS, 7 functions.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/draftstore.h src/draftstore.cpp src/CMakeLists.txt \
+ tests/test_draftstore.cpp tests/CMakeLists.txt
+git commit -S -m "feat(compose): write drafts and sent copies to the Maildir, item 123
+
+Drafts and sent copies are the same operation into two folders with two flag
+sets, so they are one unit rather than two.
+
+Two orderings are load-bearing. The file goes to cur/ and never new/, since
+a file in new/ is re-announced as fresh mail by every reader of the Maildir
+and an autosaved draft would arrive as a new message on each revision. And
+the previous revision is unlinked only AFTER the new one is safely in place:
+the reverse order loses the draft entirely if the write then fails, which is
+the case a test now covers by pointing a write at an unwritable path and
+asserting the old revision survived.
+
+QSaveFile rather than QFile so a reader never sees a half-written message;
+mbsync and notmuch both watch this directory. Nothing here calls notmuch:
+the files become visible on the next sync, so no write lock is needed and
+the read-only-by-default rule is untouched."
+```
+
+---
+
+### Task 6: MessageSender
+
+The one send funnel, and the seam an outbox would later be built around. It
+knows nothing about composers, which is the whole reason it is a separate unit
+rather than a method on the window.
+
+**Files:**
+- Create: `src/messagesender.h`, `src/messagesender.cpp`
+- Create: `tests/test_messagesender.cpp`
+- Modify: `src/CMakeLists.txt`, `tests/CMakeLists.txt`
+
+- [ ] **Step 1: Write the header**
+
+`src/messagesender.h` (GPL header, then):
+
+```cpp
+#pragma once
+
+#include <QObject>
+#include <QProcess>
+
+/// Runs an account's send_command with the message on stdin.
+///
+/// EXACTLY TWO OUTCOMES: sent, or not sent with a reason. Exit code 75 has no
+/// special meaning here, unlike in the sync path. Item 125 is open precisely
+/// because mailsync.sh treats 75 as neither success nor failure and hangs on
+/// it; that exists because the script contends for a lock and there is no lock
+/// here. Recorded so the two paths are not later "harmonised".
+///
+/// This is the outbox seam. An outbox is built by calling this from a drain
+/// loop; nothing in the composer would need to change.
+class MessageSender : public QObject
+{
+ Q_OBJECT
+
+public:
+ explicit MessageSender(QObject *parent = nullptr);
+
+ /// Starts \p command with \p bytes on stdin.
+ ///
+ /// Returns false without emitting anything when the command is empty or a
+ /// send is already running. A true return means the process was handed to
+ /// the event loop, NOT that it launched: a missing binary surfaces
+ /// asynchronously through finished(false, ...), exactly as MailSync
+ /// documents.
+ bool send(const QString &command, const QByteArray &bytes);
+
+ bool isRunning() const;
+
+signals:
+ /// \p error is empty on success and carries the command's stderr, or a
+ /// description of why it could not start, on failure.
+ void finished(bool sent, const QString &error);
+
+private:
+ void handleFinished(int exitCode, QProcess::ExitStatus status);
+ void handleError(QProcess::ProcessError error);
+
+ QProcess m_process;
+ QString m_command;
+ bool m_reported = false;
+};
+```
+
+- [ ] **Step 2: Write the failing test**
+
+Create `tests/test_messagesender.cpp`. Stub commands, never a real MTA: there
+is none on this machine and there will not be one in CI.
+
+```cpp
+#include <QtTest>
+#include <QTemporaryDir>
+
+#include "messagesender.h"
+
+class TestMessageSender : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void aSuccessfulCommandReportsSent();
+ void theMessageArrivesOnStdinIntact();
+ void aFailingCommandReportsItsStderr();
+ void aCommandThatDoesNotExistReportsAFailure();
+ void anEmptyCommandIsRefusedWithoutRunning();
+ void exitCode75IsAnOrdinaryFailure();
+
+private:
+ QString writeStub(const QString &name, const QString &body);
+
+ QTemporaryDir m_dir;
+};
+
+QString TestMessageSender::writeStub(const QString &name, const QString &body)
+{
+ const QString path = m_dir.filePath(name);
+ QFile file(path);
+ if (!file.open(QIODevice::WriteOnly))
+ return {};
+ file.write(QStringLiteral("#!/bin/sh\n%1\n").arg(body).toUtf8());
+ file.close();
+ file.setPermissions(QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner);
+ return path;
+}
+
+void TestMessageSender::aSuccessfulCommandReportsSent()
+{
+ const QString stub = writeStub(QStringLiteral("ok.sh"), QStringLiteral("cat >/dev/null"));
+ QVERIFY(!stub.isEmpty());
+
+ MessageSender sender;
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ QVERIFY(sender.send(stub, QByteArray("From: a@example.org\r\n\r\nbody\r\n")));
+
+ QVERIFY(spy.wait(5000));
+ QCOMPARE(spy.count(), 1);
+ QCOMPARE(spy.at(0).at(0).toBool(), true);
+ QVERIFY2(spy.at(0).at(1).toString().isEmpty(),
+ "a successful send carried an error message");
+}
+
+void TestMessageSender::theMessageArrivesOnStdinIntact()
+{
+ // The property that matters most: the bytes the builder produced are the
+ // bytes the command receives. A stub that writes stdin to a file is the
+ // only way to see it, since there is no MTA to ask.
+ const QString captured = m_dir.filePath(QStringLiteral("captured.eml"));
+ const QString stub = writeStub(QStringLiteral("capture.sh"),
+ QStringLiteral("cat > '%1'").arg(captured));
+ QVERIFY(!stub.isEmpty());
+
+ const QByteArray bytes(
+ "From: a@example.org\r\n"
+ "Subject: =?UTF-8?B?UGVyY2jDqQ==?=\r\n"
+ "\r\n"
+ "Perch=C3=A9 accented body.\r\n");
+
+ MessageSender sender;
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ QVERIFY(sender.send(stub, bytes));
+ QVERIFY(spy.wait(5000));
+ QCOMPARE(spy.at(0).at(0).toBool(), true);
+
+ QFile file(captured);
+ QVERIFY2(file.open(QIODevice::ReadOnly), "the stub captured no stdin at all");
+ QCOMPARE(file.readAll(), bytes);
+}
+
+void TestMessageSender::aFailingCommandReportsItsStderr()
+{
+ // stderr is shown verbatim: network errors, authentication failures and
+ // server rejections all belong to send_command, and this application
+ // deliberately does not interpret them.
+ const QString stub = writeStub(
+ QStringLiteral("fail.sh"),
+ QStringLiteral("cat >/dev/null; echo 'auth failed: bad password' >&2; exit 1"));
+ QVERIFY(!stub.isEmpty());
+
+ MessageSender sender;
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ QVERIFY(sender.send(stub, QByteArray("body")));
+
+ QVERIFY(spy.wait(5000));
+ QCOMPARE(spy.at(0).at(0).toBool(), false);
+ QVERIFY2(spy.at(0).at(1).toString().contains(QStringLiteral("auth failed")),
+ qPrintable(QStringLiteral("stderr was not reported: '%1'")
+ .arg(spy.at(0).at(1).toString())));
+}
+
+void TestMessageSender::aCommandThatDoesNotExistReportsAFailure()
+{
+ // A typo'd path is the likely cause, so the message names the command.
+ // QProcess emits errorOccurred(FailedToStart) INSTEAD OF finished(), which
+ // is the trap MailSync already documents: without handling it the signal
+ // never arrives and the popup waits forever.
+ MessageSender sender;
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ QVERIFY(sender.send(QStringLiteral("/nonexistent/msmtp"), QByteArray("body")));
+
+ QVERIFY2(spy.wait(5000), "no result was ever reported for a missing command");
+ QCOMPARE(spy.at(0).at(0).toBool(), false);
+ QVERIFY2(spy.at(0).at(1).toString().contains(QStringLiteral("msmtp")),
+ qPrintable(QStringLiteral("the error does not name the command: '%1'")
+ .arg(spy.at(0).at(1).toString())));
+}
+
+void TestMessageSender::anEmptyCommandIsRefusedWithoutRunning()
+{
+ // A receive-only account. The compose actions are disabled on its mail, so
+ // this should be unreachable; refusing here rather than asserting means a
+ // future caller cannot accidentally send from an account that cannot.
+ MessageSender sender;
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ QVERIFY2(!sender.send(QString(), QByteArray("body")),
+ "an empty command was accepted");
+ QCOMPARE(spy.count(), 0);
+}
+
+void TestMessageSender::exitCode75IsAnOrdinaryFailure()
+{
+ // Explicitly asserted so the sync path's special handling of 75 is never
+ // copied here. There is no lock to contend for, so 75 means only what the
+ // command chose it to mean: not sent.
+ const QString stub = writeStub(QStringLiteral("busy.sh"),
+ QStringLiteral("cat >/dev/null; exit 75"));
+ QVERIFY(!stub.isEmpty());
+
+ MessageSender sender;
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ QVERIFY(sender.send(stub, QByteArray("body")));
+
+ QVERIFY(spy.wait(5000));
+ QCOMPARE(spy.at(0).at(0).toBool(), false);
+}
+
+QTEST_MAIN(TestMessageSender)
+#include "test_messagesender.moc"
+```
+
+- [ ] **Step 3: Run to verify it fails**
+
+Run: `cmake --build build 2>&1 | tail -3`
+Expected: FAIL, `messagesender.h: No such file or directory`.
+
+- [ ] **Step 4: Write the implementation**
+
+`src/messagesender.cpp` (GPL header, then):
+
+```cpp
+#include "messagesender.h"
+
+MessageSender::MessageSender(QObject *parent)
+ : QObject(parent)
+{
+ // Separate channels, unlike MailSync's MergedChannels: there is no log
+ // pane to fill here, and stderr alone is what a failure has to report.
+ m_process.setProcessChannelMode(QProcess::SeparateChannels);
+
+ connect(&m_process, &QProcess::finished,
+ this, &MessageSender::handleFinished);
+ connect(&m_process, &QProcess::errorOccurred,
+ this, &MessageSender::handleError);
+}
+
+bool MessageSender::isRunning() const
+{
+ return m_process.state() != QProcess::NotRunning;
+}
+
+bool MessageSender::send(const QString &command, const QByteArray &bytes)
+{
+ if (command.isEmpty() || isRunning())
+ return false;
+
+ // splitCommand handles quoted arguments; running through a shell would
+ // make every recipient address and display name a potential injection.
+ // Nothing from the message reaches the argument list at all: the command
+ // reads its recipients from the message's own headers, which is what `-t`
+ // means in the documented example.
+ const QStringList parts = QProcess::splitCommand(command);
+ if (parts.isEmpty())
+ return false;
+
+ m_command = command;
+ m_reported = false;
+
+ m_process.setProgram(parts.first());
+ m_process.setArguments(parts.mid(1));
+ m_process.start();
+
+ // The message goes on stdin and the channel is closed, so a command
+ // reading to EOF terminates. Without closeWriteChannel() a command like
+ // `cat` waits forever and the popup never leaves its Sending stage.
+ m_process.write(bytes);
+ m_process.closeWriteChannel();
+
+ return true;
+}
+
+void MessageSender::handleFinished(int exitCode, QProcess::ExitStatus status)
+{
+ // errorOccurred may already have reported this failure. Reporting twice
+ // would close the popup and then act on a second result.
+ if (m_reported)
+ return;
+ m_reported = true;
+
+ const bool sent = status == QProcess::NormalExit && exitCode == 0;
+ if (sent) {
+ emit finished(true, QString());
+ return;
+ }
+
+ // Exit 75 is deliberately NOT special. See the header.
+ QString error = QString::fromUtf8(m_process.readAllStandardError()).trimmed();
+ if (error.isEmpty()) {
+ error = tr("The send command exited with status %1 and said nothing.")
+ .arg(exitCode);
+ }
+ emit finished(false, error);
+}
+
+void MessageSender::handleError(QProcess::ProcessError error)
+{
+ // QProcess emits errorOccurred(FailedToStart) INSTEAD OF finished(), so
+ // without this the caller waits forever. Every other error is followed by
+ // finished() and is left to it.
+ if (error != QProcess::FailedToStart)
+ return;
+ if (m_reported)
+ return;
+ m_reported = true;
+
+ emit finished(false,
+ tr("The send command '%1' could not be started. Check that "
+ "the path is correct and the file is executable.")
+ .arg(m_command));
+}
+```
+
+- [ ] **Step 5: Register and run**
+
+Add the source and `add_qtmaildir_test(messagesender)`, then:
+
+Run: `ctest --test-dir build -R messagesender --output-on-failure`
+Expected: PASS, 6 functions.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/messagesender.h src/messagesender.cpp src/CMakeLists.txt \
+ tests/test_messagesender.cpp tests/CMakeLists.txt
+git commit -S -m "feat(compose): run the account's send command, item 123
+
+The application never learns what SMTP is. Sending is a configured command
+receiving the complete message on stdin, on exactly the contract [sync]
+command already has, and what the user installs behind it is theirs.
+
+Two security properties. The command 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 placed in an argument
+at all: the command reads its recipients from the message's own headers.
+
+Exactly two outcomes, and exit 75 is deliberately not one of them. The sync
+path treats 75 as neither success nor failure, which is why item 125 is open
+about a spinner that never stops; that exists because mailsync.sh contends
+for a lock and there is no lock here. A test asserts 75 is an ordinary
+failure so the two paths are not later harmonised.
+
+closeWriteChannel() after writing is not optional: a command reading to EOF
+otherwise waits forever and the send popup never leaves its Sending stage."
+```
+
+---
+
+### Task 7: ComposeContext
+
+Pure logic, no widgets. The spec says the subtle bugs live in recipient
+derivation, which is exactly why this is a separate unit tested apart from the
+window.
+
+**Files:**
+- Create: `src/composecontext.h`, `src/composecontext.cpp`
+- Create: `tests/test_composecontext.cpp`
+- Modify: `src/CMakeLists.txt`, `tests/CMakeLists.txt`
+
+Note: the `ComposeContext` STRUCT is already in `types.h` from Task 2. This
+task adds the free functions that build one.
+
+- [ ] **Step 1: Write the header**
+
+`src/composecontext.h` (GPL header, then):
+
+```cpp
+#pragma once
+
+#include <QList>
+#include <QString>
+#include <QStringList>
+
+#include "types.h"
+
+class Account;
+class Config;
+struct ParsedMessage;
+
+/// Builds the ComposeContext that opens a composer.
+///
+/// Free functions in a namespace: this is pure logic over values, and keeping
+/// it apart from ComposeWindow is what lets recipient derivation, subject
+/// prefixing and account resolution be tested without a painter.
+namespace ComposeContextBuilder {
+
+/// The addresses belonging to the user, across every configured account.
+///
+/// Every one of them is stripped from a reply-all's recipients. Missing one
+/// means the user receives their own reply, which is the failure this is most
+/// likely to have.
+QStringList ownAddresses(const Config &config);
+
+/// Which account replies to a message whose file lives at \p messagePaths.
+///
+/// The displayed message's own maildir is the strongest available signal and
+/// wins outright: 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.
+///
+/// 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 id. \p recipients disambiguates by preferring the
+/// account matching a To or Cc entry; failing that the first is taken. The From
+/// field shows the choice, so an arbitrary resolution is visible rather than
+/// hidden.
+QString accountForReply(const Config &config, const QStringList &messagePaths,
+ const QStringList &recipients, const QString &mailRoot);
+
+/// Which account a NEW message comes from, by the four fallback rules.
+///
+/// \p selectedAccount is the dropdown's current account, empty for All
+/// accounts. Returns empty only when no account can send at all.
+QString accountForNew(const Config &config, const QString &selectedAccount);
+
+/// `Re:` or `Fwd:` prefixed, without doubling an existing prefix.
+QString replySubject(const QString &original);
+QString forwardSubject(const QString &original);
+
+/// The `>`-prefixed original, with an attribution line.
+///
+/// Takes a ParsedMessage, NOT a MessageNode: the node carries no body and no
+/// date (it holds messageId, threadId, from, subject, tags, filePath and
+/// depth), so quoting has to come from what MimeParser produced. Verified
+/// against src/types.h and src/mimeparser.h on 2026-08-20.
+QString quoteBody(const ParsedMessage &message);
+
+} // namespace ComposeContextBuilder
+```
+
+- [ ] **Step 2: Write the failing test**
+
+Create `tests/test_composecontext.cpp`:
+
+```cpp
+#include <QtTest>
+#include <QTemporaryDir>
+
+#include "composecontext.h"
+#include "config.h"
+#include "mimeparser.h"
+#include "types.h"
+
+class TestComposeContext : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void init();
+
+ void everyOwnAddressIsStrippedFromAReplyAll();
+ void aReplySubjectDoesNotDoubleItsPrefix();
+ void aForwardSubjectDoesNotDoubleItsPrefix();
+ void anEmptySubjectStillGetsAPrefix();
+ void theReplyAccountComesFromTheMessagesMaildir();
+ void anAmbiguousMessagePrefersTheMatchingRecipient();
+ void anAmbiguousMessageWithNoMatchTakesTheFirst();
+ void aNewMessagePrefersTheSelectedAccount();
+ void aNewMessageFallsThroughASelectedAccountThatCannotSend();
+ void aNewMessageUsesDefaultAccountFromAllAccounts();
+ void aNewMessageFallsBackToTheFirstSendingAccount();
+ void aNewMessageReturnsNothingWhenNoAccountCanSend();
+ void aQuotedBodyPrefixesEveryLine();
+
+private:
+ QString writeConfig(const QString &contents);
+
+ QTemporaryDir m_dir;
+};
+
+QString TestComposeContext::writeConfig(const QString &contents)
+{
+ const QString path = m_dir.filePath(QStringLiteral("qtmaildir.conf"));
+ QFile file(path);
+ if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text))
+ return {};
+ file.write(contents.toUtf8());
+ return path;
+}
+
+void TestComposeContext::init()
+{
+ // Each test writes its own config; nothing carries over.
+}
+
+void TestComposeContext::everyOwnAddressIsStrippedFromAReplyAll()
+{
+ // All five of the user's addresses. Missing one means they receive their
+ // own reply, and with five accounts that is the likeliest bug here.
+ const QString path = writeConfig(QStringLiteral(
+ "[account.one]\nmaildir=one\ntrash=Trash\naddress=first@example.org\n"
+ "[account.two]\nmaildir=two\ntrash=Trash\naddress=second@example.org\n"
+ "[account.three]\nmaildir=three\ntrash=Trash\naddress=third@example.org\n"
+ "[account.four]\nmaildir=four\ntrash=Trash\naddress=fourth@example.org\n"
+ "[account.five]\nmaildir=five\ntrash=Trash\naddress=fifth@example.org\n"));
+ QVERIFY(!path.isEmpty());
+
+ Config config;
+ QVERIFY(config.load(path));
+
+ const QStringList own = ComposeContextBuilder::ownAddresses(config);
+ QCOMPARE(own.size(), 5);
+ for (const QString &address : { QStringLiteral("first@example.org"),
+ QStringLiteral("second@example.org"),
+ QStringLiteral("third@example.org"),
+ QStringLiteral("fourth@example.org"),
+ QStringLiteral("fifth@example.org") }) {
+ QVERIFY2(own.contains(address),
+ qPrintable(QStringLiteral("own address %1 was not collected").arg(address)));
+ }
+}
+
+void TestComposeContext::aReplySubjectDoesNotDoubleItsPrefix()
+{
+ QCOMPARE(ComposeContextBuilder::replySubject(QStringLiteral("Hello")),
+ QStringLiteral("Re: Hello"));
+ QCOMPARE(ComposeContextBuilder::replySubject(QStringLiteral("Re: Hello")),
+ QStringLiteral("Re: Hello"));
+ // Case and spacing vary between clients and neither justifies a second
+ // prefix. "RE:" from Outlook is the common one.
+ QCOMPARE(ComposeContextBuilder::replySubject(QStringLiteral("RE: Hello")),
+ QStringLiteral("RE: Hello"));
+ QCOMPARE(ComposeContextBuilder::replySubject(QStringLiteral("re:Hello")),
+ QStringLiteral("re:Hello"));
+}
+
+void TestComposeContext::aForwardSubjectDoesNotDoubleItsPrefix()
+{
+ QCOMPARE(ComposeContextBuilder::forwardSubject(QStringLiteral("Hello")),
+ QStringLiteral("Fwd: Hello"));
+ QCOMPARE(ComposeContextBuilder::forwardSubject(QStringLiteral("Fwd: Hello")),
+ QStringLiteral("Fwd: Hello"));
+ // "Fw:" is the other common spelling and means the same thing.
+ QCOMPARE(ComposeContextBuilder::forwardSubject(QStringLiteral("Fw: Hello")),
+ QStringLiteral("Fw: Hello"));
+}
+
+void TestComposeContext::anEmptySubjectStillGetsAPrefix()
+{
+ // A reply to a subjectless message is still a reply. "Re: " alone is
+ // correct and is what every other client produces.
+ QCOMPARE(ComposeContextBuilder::replySubject(QString()),
+ QStringLiteral("Re: "));
+}
+
+void TestComposeContext::theReplyAccountComesFromTheMessagesMaildir()
+{
+ // The dropdown is NOT consulted: replying from the All accounts view to a
+ // message that arrived at account B sends from B.
+ const QString path = writeConfig(QStringLiteral(
+ "[account.work]\nmaildir=work\ntrash=Trash\naddress=work@example.org\n"
+ "send_command=/bin/true\n"
+ "[account.home]\nmaildir=home\ntrash=Trash\naddress=home@example.org\n"
+ "send_command=/bin/true\n"));
+ QVERIFY(!path.isEmpty());
+
+ Config config;
+ QVERIFY(config.load(path));
+
+ const QString account = ComposeContextBuilder::accountForReply(
+ config, { QStringLiteral("/mail/home/INBOX/cur/123") },
+ { QStringLiteral("home@example.org") }, QStringLiteral("/mail"));
+
+ QCOMPARE(account, QStringLiteral("home"));
+}
+
+void TestComposeContext::anAmbiguousMessagePrefersTheMatchingRecipient()
+{
+ // One message, two maildirs: on a list twice under two addresses. The
+ // recipient headers are the tiebreak.
+ const QString path = writeConfig(QStringLiteral(
+ "[account.work]\nmaildir=work\ntrash=Trash\naddress=work@example.org\n"
+ "send_command=/bin/true\n"
+ "[account.home]\nmaildir=home\ntrash=Trash\naddress=home@example.org\n"
+ "send_command=/bin/true\n"));
+ QVERIFY(!path.isEmpty());
+
+ Config config;
+ QVERIFY(config.load(path));
+
+ const QString account = ComposeContextBuilder::accountForReply(
+ config,
+ { QStringLiteral("/mail/work/Lists/cur/1"),
+ QStringLiteral("/mail/home/Lists/cur/1") },
+ { QStringLiteral("home@example.org") }, QStringLiteral("/mail"));
+
+ QCOMPARE(account, QStringLiteral("home"));
+}
+
+void TestComposeContext::anAmbiguousMessageWithNoMatchTakesTheFirst()
+{
+ // Arbitrary, and deliberately so: the From field shows the choice, which
+ // makes an arbitrary resolution visible rather than hidden.
+ const QString path = writeConfig(QStringLiteral(
+ "[account.work]\nmaildir=work\ntrash=Trash\naddress=work@example.org\n"
+ "send_command=/bin/true\n"
+ "[account.home]\nmaildir=home\ntrash=Trash\naddress=home@example.org\n"
+ "send_command=/bin/true\n"));
+ QVERIFY(!path.isEmpty());
+
+ Config config;
+ QVERIFY(config.load(path));
+
+ const QString account = ComposeContextBuilder::accountForReply(
+ config,
+ { QStringLiteral("/mail/work/Lists/cur/1"),
+ QStringLiteral("/mail/home/Lists/cur/1") },
+ { QStringLiteral("someone-else@example.org") }, QStringLiteral("/mail"));
+
+ QVERIFY2(!account.isEmpty(), "an ambiguous message resolved to no account");
+ QCOMPARE(account, QStringLiteral("work"));
+}
+
+void TestComposeContext::aNewMessagePrefersTheSelectedAccount()
+{
+ const QString path = writeConfig(QStringLiteral(
+ "[account.work]\nmaildir=work\ntrash=Trash\nsend_command=/bin/true\n"
+ "[account.home]\nmaildir=home\ntrash=Trash\nsend_command=/bin/true\n"));
+ Config config;
+ QVERIFY(config.load(path));
+
+ QCOMPARE(ComposeContextBuilder::accountForNew(config, QStringLiteral("home")),
+ QStringLiteral("home"));
+}
+
+void TestComposeContext::aNewMessageFallsThroughASelectedAccountThatCannotSend()
+{
+ // Rule 1 requires the selected account CAN send. Viewing a receive-only
+ // account and pressing compose must produce a working composer from
+ // another account, not a broken one from this.
+ const QString path = writeConfig(QStringLiteral(
+ "[account.listsonly]\nmaildir=listsonly\ntrash=Trash\n"
+ "[account.work]\nmaildir=work\ntrash=Trash\nsend_command=/bin/true\n"));
+ Config config;
+ QVERIFY(config.load(path));
+
+ QCOMPARE(ComposeContextBuilder::accountForNew(config, QStringLiteral("listsonly")),
+ QStringLiteral("work"));
+}
+
+void TestComposeContext::aNewMessageUsesDefaultAccountFromAllAccounts()
+{
+ // The All accounts view has no selected account and falls through to rule 2.
+ const QString path = writeConfig(QStringLiteral(
+ "[account.work]\nmaildir=work\ntrash=Trash\nsend_command=/bin/true\n"
+ "[account.home]\nmaildir=home\ntrash=Trash\nsend_command=/bin/true\n"
+ "[compose]\ndefault_account=home\n"));
+ Config config;
+ QVERIFY(config.load(path));
+
+ QCOMPARE(ComposeContextBuilder::accountForNew(config, QString()),
+ QStringLiteral("home"));
+}
+
+void TestComposeContext::aNewMessageFallsBackToTheFirstSendingAccount()
+{
+ // Rule 4, arbitrary, and the reason rules 2 and 3 exist. "First" is
+ // CONFIGURATION order, which QSettings does not preserve for keys but
+ // Config's account list does.
+ const QString path = writeConfig(QStringLiteral(
+ "[account.listsonly]\nmaildir=listsonly\ntrash=Trash\n"
+ "[account.work]\nmaildir=work\ntrash=Trash\nsend_command=/bin/true\n"));
+ Config config;
+ QVERIFY(config.load(path));
+
+ QCOMPARE(ComposeContextBuilder::accountForNew(config, QString()),
+ QStringLiteral("work"));
+}
+
+void TestComposeContext::aNewMessageReturnsNothingWhenNoAccountCanSend()
+{
+ // A valid read-only installation. The compose action is disabled, so this
+ // should be unreachable, and returning empty rather than a random account
+ // is what makes a mistake visible instead of silent.
+ const QString path = writeConfig(QStringLiteral(
+ "[account.listsonly]\nmaildir=listsonly\ntrash=Trash\n"));
+ Config config;
+ QVERIFY(config.load(path));
+
+ QVERIFY(ComposeContextBuilder::accountForNew(config, QString()).isEmpty());
+}
+
+void TestComposeContext::aQuotedBodyPrefixesEveryLine()
+{
+ ParsedMessage message;
+ message.from = QStringLiteral("Sender <sender@example.org>");
+ message.date = QStringLiteral("Thu, 20 Aug 2026 10:00:00 +0200");
+ message.plainBody = QStringLiteral("first line\nsecond line\n\nafter a blank");
+
+ const QString quoted = ComposeContextBuilder::quoteBody(message);
+
+ QVERIFY2(quoted.contains(QStringLiteral("> first line")),
+ qPrintable(QStringLiteral("first line not quoted:\n%1").arg(quoted)));
+ QVERIFY2(quoted.contains(QStringLiteral("> second line")),
+ "second line not quoted");
+ // A blank line inside a quote must still carry the marker, or the quote
+ // visually ends there in every client that renders it.
+ QVERIFY2(quoted.contains(QStringLiteral("\n>\n")) ,
+ qPrintable(QStringLiteral("a blank line lost its marker:\n%1").arg(quoted)));
+ QVERIFY2(quoted.contains(QStringLiteral("sender@example.org")),
+ "no attribution line naming the sender");
+}
+
+QTEST_MAIN(TestComposeContext)
+#include "test_composecontext.moc"
+```
+
+- [ ] **Step 3: Run to verify it fails**
+
+Run: `cmake --build build 2>&1 | tail -3`
+Expected: FAIL, `composecontext.h: No such file or directory`.
+
+- [ ] **Step 4: Write the implementation**
+
+`src/composecontext.cpp` (GPL header, then):
+
+```cpp
+#include "composecontext.h"
+
+#include "config.h"
+#include "mimeparser.h"
+
+#include <QDir>
+#include <QRegularExpression>
+
+namespace {
+
+/// Matches a reply prefix at the start of a subject, in the spellings clients
+/// actually produce: "Re:", "RE:", "re:", with or without a space.
+const QRegularExpression &replyPrefix()
+{
+ static const QRegularExpression expression(
+ QStringLiteral("^\\s*re\\s*:"), QRegularExpression::CaseInsensitiveOption);
+ return expression;
+}
+
+/// "Fwd:" and "Fw:" both mean the same thing and both are common.
+const QRegularExpression &forwardPrefix()
+{
+ static const QRegularExpression expression(
+ QStringLiteral("^\\s*fwd?\\s*:"), QRegularExpression::CaseInsensitiveOption);
+ return expression;
+}
+
+/// The account whose maildir contains \p path, or empty.
+QString accountOwning(const Config &config, const QString &path,
+ const QString &mailRoot)
+{
+ for (const Account &account : config.accounts()) {
+ const QString prefix =
+ QDir(mailRoot).absoluteFilePath(account.maildir) + QLatin1Char('/');
+ // Compared as a path prefix with the separator included: without the
+ // trailing slash an account "work" would also match a maildir
+ // "work-archive".
+ if (path.startsWith(prefix))
+ return account.key;
+ }
+ return {};
+}
+
+} // namespace
+
+QStringList ComposeContextBuilder::ownAddresses(const Config &config)
+{
+ QStringList addresses;
+ for (const Account &account : config.accounts()) {
+ const QString address = account.address.trimmed();
+ if (!address.isEmpty() && !addresses.contains(address, Qt::CaseInsensitive))
+ addresses.append(address);
+ }
+ return addresses;
+}
+
+QString ComposeContextBuilder::accountForReply(const Config &config,
+ const QStringList &messagePaths,
+ const QStringList &recipients,
+ const QString &mailRoot)
+{
+ QStringList candidates;
+ for (const QString &path : messagePaths) {
+ const QString key = accountOwning(config, path, mailRoot);
+ if (!key.isEmpty() && !candidates.contains(key))
+ candidates.append(key);
+ }
+
+ if (candidates.isEmpty())
+ return {};
+ if (candidates.size() == 1)
+ return candidates.first();
+
+ // Ambiguous: the same message in more than one maildir. Prefer the account
+ // whose own address appears among the recipients, which is the reason the
+ // copy landed there.
+ for (const QString &key : candidates) {
+ for (const Account &account : config.accounts()) {
+ if (account.key != key || account.address.isEmpty())
+ continue;
+ for (const QString &recipient : recipients) {
+ if (recipient.contains(account.address, Qt::CaseInsensitive))
+ return key;
+ }
+ }
+ }
+
+ // Arbitrary, and visible: the From field shows the choice.
+ return candidates.first();
+}
+
+QString ComposeContextBuilder::accountForNew(const Config &config,
+ const QString &selectedAccount)
+{
+ const auto canSend = [&config](const QString &key) {
+ if (key.isEmpty())
+ return false;
+ for (const Account &account : config.accounts()) {
+ if (account.key == key)
+ return account.canSend();
+ }
+ return false;
+ };
+
+ // 1. The dropdown's current account, when it is a specific one that can send.
+ if (canSend(selectedAccount))
+ return selectedAccount;
+
+ // 2. [compose] default_account.
+ if (canSend(config.compose().defaultAccount))
+ return config.compose().defaultAccount;
+
+ // 3. [general] startup_account, on the same condition.
+ if (canSend(config.startupAccount()))
+ return config.startupAccount();
+
+ // 4. The first account in configuration order that can send. Arbitrary,
+ // which is exactly why rules 2 and 3 exist.
+ const QList<Account> sending = config.sendingAccounts();
+ if (!sending.isEmpty())
+ return sending.first().key;
+
+ // No account can send. A valid read-only installation; the caller's action
+ // is disabled and should never have reached this.
+ return {};
+}
+
+QString ComposeContextBuilder::replySubject(const QString &original)
+{
+ if (replyPrefix().match(original).hasMatch())
+ return original;
+ return QStringLiteral("Re: ") + original;
+}
+
+QString ComposeContextBuilder::forwardSubject(const QString &original)
+{
+ if (forwardPrefix().match(original).hasMatch())
+ return original;
+ return QStringLiteral("Fwd: ") + original;
+}
+
+QString ComposeContextBuilder::quoteBody(const ParsedMessage &message)
+{
+ QStringList quoted;
+
+ // The attribution line. Not translated with a date format that varies by
+ // locale: this text is sent to a recipient who may not share the locale.
+ quoted.append(QStringLiteral("On %1, %2 wrote:")
+ .arg(message.date, message.from));
+ quoted.append(QString());
+
+ const QStringList lines = message.plainBody.split(QLatin1Char('\n'));
+ for (const QString &line : lines) {
+ // A blank line still carries the marker. Without it the quote
+ // visually ends there in every client that renders quoting.
+ quoted.append(line.isEmpty() ? QStringLiteral(">")
+ : QStringLiteral("> ") + line);
+ }
+
+ return quoted.join(QLatin1Char('\n'));
+}
+```
+
+`Config::startupAccount()` is verified to exist at `src/config.h:366`, so rule
+3 compiles as written.
+
+- [ ] **Step 5: Register and run**
+
+Add the source and `add_qtmaildir_test(composecontext)`, then:
+
+Run: `ctest --test-dir build -R composecontext --output-on-failure`
+Expected: PASS, 13 functions.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/composecontext.h src/composecontext.cpp src/CMakeLists.txt \
+ tests/test_composecontext.cpp tests/CMakeLists.txt
+git commit -S -m "feat(compose): resolve accounts, recipients and subjects, item 123
+
+Pure logic, no widgets, because this is where the subtle bugs live and a
+painter-free unit is what lets them be tested.
+
+The account that replies comes from the displayed message's own maildir and
+the dropdown is not consulted: mail sent to an address landed in that
+address's maildir, so replying from it is what the recipient expects, and
+replying from the All accounts view to a message that arrived at account B
+sends from B. A message can sit in more than one maildir, on a list twice or
+duplicated by mbsync, so the recipient headers break the tie and the first
+candidate is taken otherwise. That last rule is arbitrary on purpose: the
+From field shows the choice, which makes it visible rather than hidden.
+
+A new message walks four rules and returns nothing when no account can send,
+rather than picking one, so a mistake is visible instead of silent.
+
+Every own address is stripped from a reply-all. With five accounts the
+likeliest bug here is missing one, and the user then receives their own
+reply."
+```
+
+---
+
+### Task 8: The formatting transformations
+
+Each toolbar button is a **text transformation over the markdown source**, not
+rich-text editing. The buffer stays markdown the user can also type by hand.
+
+The transformations are free functions over (text, selection start, selection
+end) so they are tested without a widget. The toolbar that calls them is built
+in Task 11 with the composer.
+
+**Files:**
+- Create: `src/formattoolbar.h`, `src/formattoolbar.cpp`
+- Create: `tests/test_formattoolbar.cpp`
+- Modify: `src/CMakeLists.txt`, `tests/CMakeLists.txt`
+
+- [ ] **Step 1: Write the header**
+
+`src/formattoolbar.h` (GPL header, then):
+
+```cpp
+#pragma once
+
+#include <QString>
+
+/// The markdown transformations behind the composer's formatting toolbar.
+///
+/// Free functions over text and a selection, with no widget anywhere, so the
+/// grammar is tested without a painter. Each one is a transformation over the
+/// SOURCE: nothing about the buffer changes, it stays markdown the user can
+/// also type by hand.
+namespace MarkdownFormat {
+
+/// The result of a transformation: the new text and where the selection
+/// should end up.
+struct Edit
+{
+ QString text;
+ int selectionStart = 0;
+ int selectionEnd = 0;
+};
+
+/// Wraps the selection in \p token, or inserts an empty pair with the cursor
+/// BETWEEN the tokens when there is no selection.
+///
+/// The cursor landing between the tokens is the property a user notices
+/// immediately when it is wrong, and it is invisible to a test that only
+/// compares the resulting text.
+Edit wrap(const QString &text, int start, int end, const QString &token);
+
+/// `[text](url)`. With a selection the selected text becomes the label and
+/// the cursor lands inside the empty parentheses, which is where the user has
+/// to type next.
+Edit link(const QString &text, int start, int end);
+
+/// `> ` on every line the selection touches, including a line the selection
+/// only starts or ends on. Line-based rather than a wrap, so it cannot be
+/// expressed with wrap().
+Edit quote(const QString &text, int start, int end);
+
+} // namespace MarkdownFormat
+```
+
+- [ ] **Step 2: Write the failing test**
+
+Create `tests/test_formattoolbar.cpp`:
+
+```cpp
+#include <QtTest>
+
+#include "formattoolbar.h"
+
+class TestFormatToolbar : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void wrappingASelectionKeepsItSelected();
+ void wrappingWithNoSelectionPutsTheCursorBetweenTheTokens();
+ void wrappingAppliesTheTokenOnBothSides();
+ void aLinkWithASelectionUsesItAsTheLabel();
+ void aLinkWithNoSelectionLeavesTheCursorInTheLabel();
+ void quotingPrefixesEveryLineTheSelectionTouches();
+ void quotingAPartialLineStillQuotesTheWholeLine();
+ void quotingASingleLineWithNoSelectionQuotesThatLine();
+};
+
+void TestFormatToolbar::wrappingASelectionKeepsItSelected()
+{
+ // The selection is preserved so a second button press applies a second
+ // token to the same words: bold then italic, without reselecting.
+ const MarkdownFormat::Edit edit = MarkdownFormat::wrap(
+ QStringLiteral("make this bold"), 5, 9, QStringLiteral("**"));
+
+ QCOMPARE(edit.text, QStringLiteral("make **this** bold"));
+ QCOMPARE(edit.text.mid(edit.selectionStart,
+ edit.selectionEnd - edit.selectionStart),
+ QStringLiteral("this"));
+}
+
+void TestFormatToolbar::wrappingWithNoSelectionPutsTheCursorBetweenTheTokens()
+{
+ // The property a user notices immediately when it is wrong: press Bold,
+ // start typing, and the words must appear INSIDE the asterisks. A text
+ // comparison alone passes whether the cursor is inside or after.
+ const MarkdownFormat::Edit edit = MarkdownFormat::wrap(
+ QStringLiteral("ab"), 2, 2, QStringLiteral("**"));
+
+ QCOMPARE(edit.text, QStringLiteral("ab****"));
+ QCOMPARE(edit.selectionStart, edit.selectionEnd);
+ QCOMPARE(edit.selectionStart, 4);
+
+ // Stated as the behaviour rather than the index: typing "x" here must
+ // produce "ab**x**".
+ QString typed = edit.text;
+ typed.insert(edit.selectionStart, QStringLiteral("x"));
+ QCOMPARE(typed, QStringLiteral("ab**x**"));
+}
+
+void TestFormatToolbar::wrappingAppliesTheTokenOnBothSides()
+{
+ QCOMPARE(MarkdownFormat::wrap(QStringLiteral("x"), 0, 1,
+ QStringLiteral("~~")).text,
+ QStringLiteral("~~x~~"));
+ QCOMPARE(MarkdownFormat::wrap(QStringLiteral("x"), 0, 1,
+ QStringLiteral("`")).text,
+ QStringLiteral("`x`"));
+}
+
+void TestFormatToolbar::aLinkWithASelectionUsesItAsTheLabel()
+{
+ const MarkdownFormat::Edit edit = MarkdownFormat::link(
+ QStringLiteral("see the docs"), 8, 12);
+
+ QCOMPARE(edit.text, QStringLiteral("see the [docs]()"));
+
+ // The cursor goes inside the parentheses: the label is written and the
+ // URL is what the user still has to type.
+ QCOMPARE(edit.selectionStart, edit.selectionEnd);
+ QString typed = edit.text;
+ typed.insert(edit.selectionStart, QStringLiteral("https://example.org"));
+ QCOMPARE(typed, QStringLiteral("see the [docs](https://example.org)"));
+}
+
+void TestFormatToolbar::aLinkWithNoSelectionLeavesTheCursorInTheLabel()
+{
+ // With nothing selected there is no label yet, so the label is what the
+ // user types first.
+ const MarkdownFormat::Edit edit = MarkdownFormat::link(QString(), 0, 0);
+
+ QCOMPARE(edit.text, QStringLiteral("[]()"));
+ QString typed = edit.text;
+ typed.insert(edit.selectionStart, QStringLiteral("label"));
+ QCOMPARE(typed, QStringLiteral("[label]()"));
+}
+
+void TestFormatToolbar::quotingPrefixesEveryLineTheSelectionTouches()
+{
+ const MarkdownFormat::Edit edit = MarkdownFormat::quote(
+ QStringLiteral("one\ntwo\nthree"), 0, 7);
+
+ QCOMPARE(edit.text, QStringLiteral("> one\n> two\nthree"));
+}
+
+void TestFormatToolbar::quotingAPartialLineStillQuotesTheWholeLine()
+{
+ // A selection from the middle of one line into the middle of the next
+ // must quote both whole lines. Quoting half a line produces markdown that
+ // means something else entirely.
+ const MarkdownFormat::Edit edit = MarkdownFormat::quote(
+ QStringLiteral("one\ntwo\nthree"), 1, 5);
+
+ QCOMPARE(edit.text, QStringLiteral("> one\n> two\nthree"));
+}
+
+void TestFormatToolbar::quotingASingleLineWithNoSelectionQuotesThatLine()
+{
+ const MarkdownFormat::Edit edit = MarkdownFormat::quote(
+ QStringLiteral("one\ntwo"), 5, 5);
+
+ QCOMPARE(edit.text, QStringLiteral("one\n> two"));
+}
+
+QTEST_APPLESS_MAIN(TestFormatToolbar)
+#include "test_formattoolbar.moc"
+```
+
+- [ ] **Step 3: Run to verify it fails**
+
+Run: `cmake --build build 2>&1 | tail -3`
+Expected: FAIL, `formattoolbar.h: No such file or directory`.
+
+- [ ] **Step 4: Write the implementation**
+
+`src/formattoolbar.cpp` (GPL header, then):
+
+```cpp
+#include "formattoolbar.h"
+
+#include <QStringList>
+
+MarkdownFormat::Edit MarkdownFormat::wrap(const QString &text, int start,
+ int end, const QString &token)
+{
+ Edit edit;
+ const int from = qMin(start, end);
+ const int to = qMax(start, end);
+
+ edit.text = text;
+ edit.text.insert(to, token);
+ edit.text.insert(from, token);
+
+ if (from == to) {
+ // No selection: the cursor goes BETWEEN the two tokens so typing
+ // continues inside them. Landing after the closing token instead is
+ // the mistake a user notices on the first keystroke.
+ edit.selectionStart = from + token.size();
+ edit.selectionEnd = edit.selectionStart;
+ } else {
+ // The selection is preserved so a second press applies a second token
+ // to the same words without reselecting.
+ edit.selectionStart = from + token.size();
+ edit.selectionEnd = to + token.size();
+ }
+
+ return edit;
+}
+
+MarkdownFormat::Edit MarkdownFormat::link(const QString &text, int start, int end)
+{
+ Edit edit;
+ const int from = qMin(start, end);
+ const int to = qMax(start, end);
+ const QString label = text.mid(from, to - from);
+
+ edit.text = text;
+ edit.text.replace(from, to - from,
+ QStringLiteral("[%1]()").arg(label));
+
+ if (label.isEmpty()) {
+ // Nothing selected: the label is what gets typed first, so the cursor
+ // goes inside the brackets.
+ edit.selectionStart = from + 1;
+ } else {
+ // The label is written; the URL is what remains, so the cursor goes
+ // inside the parentheses.
+ edit.selectionStart = from + label.size() + 3;
+ }
+ edit.selectionEnd = edit.selectionStart;
+
+ return edit;
+}
+
+MarkdownFormat::Edit MarkdownFormat::quote(const QString &text, int start, int end)
+{
+ Edit edit;
+ const int from = qMin(start, end);
+ const int to = qMax(start, end);
+
+ // Line-based, not a wrap. The selection is widened to whole lines first:
+ // quoting half a line produces markdown that means something else.
+ const int firstLineStart = text.lastIndexOf(QLatin1Char('\n'), from > 0 ? from - 1 : 0) + 1;
+ int lastLineEnd = text.indexOf(QLatin1Char('\n'), to);
+ if (lastLineEnd < 0)
+ lastLineEnd = text.size();
+
+ const QString before = text.left(firstLineStart);
+ const QString middle = text.mid(firstLineStart, lastLineEnd - firstLineStart);
+ const QString after = text.mid(lastLineEnd);
+
+ QStringList quoted;
+ const QStringList lines = middle.split(QLatin1Char('\n'));
+ for (const QString &line : lines)
+ quoted.append(QStringLiteral("> ") + line);
+
+ const QString replacement = quoted.join(QLatin1Char('\n'));
+ edit.text = before + replacement + after;
+ edit.selectionStart = firstLineStart;
+ edit.selectionEnd = firstLineStart + replacement.size();
+
+ return edit;
+}
+```
+
+**Note on `lastIndexOf` with `from == 0`:** passing `-1` as the position
+searches backwards from the end, which would find the wrong newline. The
+`from > 0 ? from - 1 : 0` guard is there for that; verify the
+`quotingASingleLineWithNoSelectionQuotesThatLine` case passes, since it is the
+one that exercises it.
+
+- [ ] **Step 5: Register and run**
+
+Add the source and `add_qtmaildir_test(formattoolbar)`, then:
+
+Run: `ctest --test-dir build -R formattoolbar --output-on-failure`
+Expected: PASS, 8 functions.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/formattoolbar.h src/formattoolbar.cpp src/CMakeLists.txt \
+ tests/test_formattoolbar.cpp tests/CMakeLists.txt
+git commit -S -m "feat(compose): markdown formatting transformations, item 123
+
+Each toolbar button is a text transformation over the markdown source rather
+than rich-text editing: nothing about the buffer changes, it stays markdown
+the user can also type by hand. Plain-text storage does not mean a bare text
+box, and the two are separate decisions.
+
+Free functions over text and a selection, with no widget, so the grammar is
+tested without a painter. The toolbar that calls them comes with the
+composer.
+
+The cursor landing BETWEEN the tokens when nothing is selected is the
+property a user notices on the first keystroke and the one a text comparison
+cannot see, so the tests assert it by typing into the result rather than by
+comparing an index. Quote is line-based rather than a wrap and widens the
+selection to whole lines first, since quoting half a line produces markdown
+that means something else."
+```
+
+---
+
+### Task 9: The six actions
+
+`CLAUDE.md` enumerates five registration sites and three of them are enforced
+by tests that fail in confusing ways. Doing the actions before the window they
+open means those tests guard every later task.
+
+**A correction to the spec.** It calls for "a new top-level `Message` menu".
+There already IS one, built at `src/mainwindow.cpp:1156`, holding archive,
+delete, restore, spam and the thread submenu. Add the six actions to that menu
+rather than creating a second one; two menus named Message would be a defect.
+
+**Item 132 changed one of the five sites since the spec was written.** A
+shortcut is now a chosen subset rather than a requirement, so `save_message`
+ships with no binding. `everyActionIsReachableFromAMenu()` is the rule that
+must hold.
+
+**Files:**
+- Modify: `src/keymap.cpp` (`knownActions()`, `defaultBindings()`)
+- Modify: `src/mainwindow.h` (handler declarations, the composer registry)
+- Modify: `src/mainwindow.cpp` (`addAction` calls, the icon table, the menu)
+- Modify: `tests/test_mainwindow.cpp`
+
+- [ ] **Step 1: Add the action names to `knownActions()`**
+
+In `src/keymap.cpp`, add to the list returned by `knownActions()`:
+
+```cpp
+ QStringLiteral("compose"),
+ QStringLiteral("reply"),
+ QStringLiteral("reply_all"),
+ QStringLiteral("reply_no_quote"),
+ QStringLiteral("forward"),
+ QStringLiteral("save_message"),
+```
+
+- [ ] **Step 2: Add five bindings to `defaultBindings()`**
+
+Five, not six. `save_message` deliberately gets none: it is the rarely-used
+escape hatch, and since item 132 an action without a chord is legitimate.
+
+```cpp
+ { QStringLiteral("Ctrl+N"), QStringLiteral("compose") },
+ { QStringLiteral("Ctrl+Shift+R"), QStringLiteral("reply") },
+ { QStringLiteral("Ctrl+Shift+A"), QStringLiteral("reply_all") },
+ { QStringLiteral("Ctrl+Alt+R"), QStringLiteral("reply_no_quote") },
+ { QStringLiteral("Ctrl+Shift+F"), QStringLiteral("forward") },
+```
+
+Each was checked against the existing map: `Ctrl+R` is `restore`, `Ctrl+A` is
+`select_all`, `Ctrl+Alt+S` is `spam_thread`. These are **provisional**; the user
+intends to rework the bindings and `Ctrl+Alt+R` is an imperfect fit, since that
+tier elsewhere means "wider scope" rather than "variant".
+
+- [ ] **Step 3: Run the suite to see the assert fire**
+
+Run: `ctest --test-dir build 2>&1 | grep -E 'Failed|passed'`
+Expected: FAIL. `Q_ASSERT(m_actions.size() == KeyMap::knownActions().size())` at `src/mainwindow.cpp:1130` fires, because the names exist and nothing implements them. This is the assert `CLAUDE.md` warns surfaces in whichever suite builds a `MainWindow` first.
+
+- [ ] **Step 4: Register the actions with stub handlers**
+
+In `MainWindow`'s action-building block, beside the existing `addAction` calls:
+
+```cpp
+ addAction(QStringLiteral("compose"), tr("&New message"),
+ tr("Compose a new message"), [this]() { composeNew(); });
+ addAction(QStringLiteral("reply"), tr("&Reply"),
+ tr("Reply to the displayed message"),
+ [this]() { composeReply(ComposeContext::Kind::Reply, true); });
+ addAction(QStringLiteral("reply_all"), tr("Reply to &all"),
+ tr("Reply to the sender and every other recipient"),
+ [this]() { composeReply(ComposeContext::Kind::ReplyAll, true); });
+ addAction(QStringLiteral("reply_no_quote"), tr("Reply &without quoting"),
+ tr("Reply with an empty body"),
+ [this]() { composeReply(ComposeContext::Kind::Reply, false); });
+ addAction(QStringLiteral("forward"), tr("&Forward"),
+ tr("Forward the displayed message"),
+ [this]() { composeReply(ComposeContext::Kind::Forward, true); });
+ addAction(QStringLiteral("save_message"), tr("&Save message as..."),
+ tr("Write the raw message to a file"),
+ [this]() { saveDisplayedMessage(); });
+```
+
+Declare the three handlers in `src/mainwindow.h`:
+
+```cpp
+ void composeNew();
+ void composeReply(ComposeContext::Kind kind, bool quote);
+ void saveDisplayedMessage();
+```
+
+Implement them as empty bodies for now; Task 12 fills them in. An empty body
+satisfies the assert and the reachability tests, and keeps this commit to
+registration.
+
+- [ ] **Step 5: Add the icons**
+
+In the `themeIcons` table in `src/mainwindow.cpp`:
+
+```cpp
+ { QStringLiteral("compose"), QStringLiteral("mail-message-new") },
+ { QStringLiteral("reply"), QStringLiteral("mail-reply-sender") },
+ { QStringLiteral("reply_all"), QStringLiteral("mail-reply-all") },
+ { QStringLiteral("reply_no_quote"), QStringLiteral("mail-reply-sender") },
+ { QStringLiteral("forward"), QStringLiteral("mail-forward") },
+ { QStringLiteral("save_message"), QStringLiteral("document-save-as") },
+```
+
+`reply_no_quote` shares `reply`'s icon, which needs an entry in the
+no-duplicate-icons exception list beside the five thread actions, for the same
+reason: it never reaches the toolbar and a menu entry always carries text. Find
+that list in `tests/test_mainwindow.cpp` and add `reply_no_quote` to it.
+
+- [ ] **Step 6: Add them to the EXISTING Message menu**
+
+At `src/mainwindow.cpp:1156`, after `auto *messageMenu = ...` and before the
+existing `archive` entry, so composing sits above organising:
+
+```cpp
+ messageMenu->addAction(m_actions.value(QStringLiteral("compose")));
+ messageMenu->addSeparator();
+ messageMenu->addAction(m_actions.value(QStringLiteral("reply")));
+ messageMenu->addAction(m_actions.value(QStringLiteral("reply_all")));
+ messageMenu->addAction(m_actions.value(QStringLiteral("reply_no_quote")));
+ messageMenu->addAction(m_actions.value(QStringLiteral("forward")));
+ messageMenu->addSeparator();
+ messageMenu->addAction(m_actions.value(QStringLiteral("save_message")));
+ messageMenu->addSeparator();
+```
+
+- [ ] **Step 7: Add compose and reply to the toolbar**
+
+Those two only. The rest are menu-and-key, which is what keeps the
+no-duplicate-icons rule satisfiable. Find the toolbar construction and add them
+at the front, since composing is the most common action a user reaches for.
+
+- [ ] **Step 8: Run the suite**
+
+Run: `ctest --test-dir build --output-on-failure 2>&1 | tail -5`
+Expected: PASS. `everyActionIsReachableFromAMenu()`, `everyActionCarriesAnIcon()` and `everyKnownActionIsRegistered()` all now cover the six new actions for free.
+
+- [ ] **Step 9: Refresh the translations**
+
+Run:
+```bash
+lupdate-qt6 src/ -ts translations/qtmaildir_it_IT.ts -no-obsolete -locations none
+```
+Expected: zero context warnings. Then translate every new string in the `.ts` file and confirm:
+```bash
+lrelease-qt6 translations/qtmaildir_it_IT.ts
+```
+Expected: `0 unfinished`. `lrelease` silently DROPS an unfinished string and ships it as English inside an otherwise Italian UI, so this is not optional.
+
+Run: `ctest --test-dir build -R translations --output-on-failure`
+Expected: PASS.
+
+- [ ] **Step 10: Commit**
+
+```bash
+git add src/keymap.cpp src/mainwindow.h src/mainwindow.cpp \
+ tests/test_mainwindow.cpp translations/qtmaildir_it_IT.ts
+git commit -S -m "feat(compose): register the six compose actions, item 123
+
+Handlers are empty for now; this commit is the registration, so the three
+coverage tests guard every later task rather than being satisfied at the end.
+
+Two corrections to the spec, both found in the code rather than assumed. It
+calls for a new top-level Message menu and one already exists, so these join
+it; two menus named Message would be a defect. And it says every action
+needs a binding, which item 132 changed while this was being planned:
+save_message ships with no chord, since it is the rarely-used escape hatch
+and menu reachability is now the rule that must hold.
+
+reply_no_quote shares reply's icon and is added to the no-duplicate-icons
+exception list for the same reason the five thread actions are: it never
+reaches the toolbar, and a menu entry always carries its text.
+
+Bindings are provisional. The user intends to rework them, and Ctrl+Alt+R
+for reply_no_quote is an imperfect fit since that tier elsewhere means a
+wider scope rather than a variant."
+```
+
+---
+
+### Task 10: SendDialog
+
+The popup that owns the whole send operation, from cancellable countdown to
+completion. It uses `BusyIndicator` from item 134, which is already on master
+(`af902e0`) and exposes both modes.
+
+**Files:**
+- Create: `src/senddialog.h`, `src/senddialog.cpp`
+- Create: `tests/test_senddialog.cpp`
+- Modify: `src/CMakeLists.txt`, `tests/CMakeLists.txt`
+
+- [ ] **Step 1: Write the header**
+
+`src/senddialog.h` (GPL header, then):
+
+```cpp
+#pragma once
+
+#include <QDialog>
+
+class BusyIndicator;
+class QLabel;
+class QPushButton;
+class QTimer;
+
+/// Owns a send from the cancellable countdown through to completion.
+///
+/// Three rows in every state, so nothing reflows and the window never jumps:
+/// a status label, the bar, and Undo.
+///
+/// The bar CHANGES MODE, it does not change place. Determinate while the
+/// countdown drains, because a countdown has measurable progress;
+/// indeterminate once the command starts, because a send does not.
+///
+/// Modal to the composer, NOT to the application: sending from one composer
+/// must not freeze a second composer or the main window.
+class SendDialog : public QDialog
+{
+ Q_OBJECT
+
+public:
+ /// \p delayMs of zero skips the countdown and sends at once.
+ SendDialog(int delayMs, QWidget *parent = nullptr);
+
+ /// The stages, in order. Each sets the label and leaves the bar
+ /// indeterminate.
+ enum class Stage { CountingDown, Sending, FilingSentCopy, RemovingDraft };
+
+ void setStage(Stage stage);
+
+ /// True once the countdown has elapsed and the command has started, after
+ /// which cancelling is no longer possible.
+ bool isCommitted() const { return m_committed; }
+
+signals:
+ /// The countdown elapsed or was skipped: the caller should start sending.
+ void committed();
+
+ /// Undo was pressed during the countdown. NOTHING has been sent.
+ void undone();
+
+private:
+ void tick();
+ void commit();
+
+ QLabel *m_status = nullptr;
+ BusyIndicator *m_indicator = nullptr;
+ QPushButton *m_undo = nullptr;
+ QTimer *m_timer = nullptr;
+
+ int m_remainingMs = 0;
+ int m_totalMs = 0;
+ bool m_committed = false;
+};
+```
+
+- [ ] **Step 2: Write the failing test**
+
+Create `tests/test_senddialog.cpp`. The critical property is **negative**: Undo
+during the countdown must mean nothing was sent.
+
+```cpp
+#include <QtTest>
+#include <QLabel>
+#include <QPushButton>
+
+#include "busyindicator.h"
+#include "senddialog.h"
+
+class TestSendDialog : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void theBarIsDeterminateWhileCountingDown();
+ void theCountdownCommitsWhenItElapses();
+ void aZeroDelayCommitsImmediately();
+ void undoDuringTheCountdownEmitsUndoneAndNeverCommits();
+ void undoDisablesItselfOnceTheCommandStarts();
+ void theBarBecomesIndeterminateWhenSending();
+ void undoStaysVisibleAfterItDisables();
+};
+
+void TestSendDialog::theBarIsDeterminateWhileCountingDown()
+{
+ // A countdown has measurable progress, so the bar drains rather than
+ // animating. This is the half of BusyIndicator MainWindow never uses.
+ SendDialog dialog(200);
+ dialog.show();
+
+ auto *indicator = dialog.findChild<BusyIndicator *>();
+ QVERIFY(indicator);
+ QVERIFY2(indicator->isDeterminate(),
+ "the countdown bar is indeterminate, so it shows no progress");
+}
+
+void TestSendDialog::theCountdownCommitsWhenItElapses()
+{
+ // A short delay rather than waiting five seconds in a test.
+ SendDialog dialog(150);
+ QSignalSpy spy(&dialog, &SendDialog::committed);
+ dialog.show();
+
+ QVERIFY2(spy.wait(3000), "the countdown never committed");
+ QCOMPARE(spy.count(), 1);
+ QVERIFY(dialog.isCommitted());
+}
+
+void TestSendDialog::aZeroDelayCommitsImmediately()
+{
+ // send_delay_ms = 0 sends at once, for anyone who finds the delay
+ // irritating.
+ SendDialog dialog(0);
+ QSignalSpy spy(&dialog, &SendDialog::committed);
+ dialog.show();
+
+ QVERIFY2(spy.wait(1000), "a zero delay did not commit");
+ QCOMPARE(spy.count(), 1);
+}
+
+void TestSendDialog::undoDuringTheCountdownEmitsUndoneAndNeverCommits()
+{
+ // THE test for this feature, and the property that matters is the
+ // negative one: committed() must NEVER fire. A test asserting only that
+ // undone() fired would pass against a design that started the send and
+ // threw the result away, which is the whole failure the delay exists to
+ // prevent.
+ SendDialog dialog(2000);
+ QSignalSpy committedSpy(&dialog, &SendDialog::committed);
+ QSignalSpy undoneSpy(&dialog, &SendDialog::undone);
+ dialog.show();
+
+ auto *undo = dialog.findChild<QPushButton *>(QStringLiteral("undoSend"));
+ QVERIFY(undo);
+ QVERIFY2(undo->isEnabled(), "Undo was disabled during the countdown");
+ undo->click();
+
+ QCOMPARE(undoneSpy.count(), 1);
+ QCOMPARE(committedSpy.count(), 0);
+
+ // And it must still be zero after the original countdown would have
+ // elapsed: a timer left running would commit late.
+ QTest::qWait(2500);
+ QVERIFY2(committedSpy.count() == 0,
+ "the countdown committed after Undo was pressed");
+}
+
+void TestSendDialog::undoDisablesItselfOnceTheCommandStarts()
+{
+ // Killing send_command mid-transaction leaves an UNKNOWN send: the
+ // message may have reached the server in full before the kill. That is
+ // worse than either clean outcome, so there is no cancel after this point.
+ SendDialog dialog(100);
+ dialog.show();
+
+ auto *undo = dialog.findChild<QPushButton *>(QStringLiteral("undoSend"));
+ QVERIFY(undo);
+
+ QSignalSpy spy(&dialog, &SendDialog::committed);
+ QVERIFY(spy.wait(3000));
+
+ QVERIFY2(!undo->isEnabled(),
+ "Undo was still live after the send command started");
+}
+
+void TestSendDialog::theBarBecomesIndeterminateWhenSending()
+{
+ // A send has no measurable progress, unlike the countdown.
+ SendDialog dialog(100);
+ dialog.show();
+ dialog.setStage(SendDialog::Stage::Sending);
+
+ auto *indicator = dialog.findChild<BusyIndicator *>();
+ QVERIFY(indicator);
+ QVERIFY2(!indicator->isDeterminate(),
+ "the bar still shows a fraction while sending");
+}
+
+void TestSendDialog::undoStaysVisibleAfterItDisables()
+{
+ // 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.
+ SendDialog dialog(100);
+ dialog.show();
+
+ auto *undo = dialog.findChild<QPushButton *>(QStringLiteral("undoSend"));
+ QVERIFY(undo);
+
+ QSignalSpy spy(&dialog, &SendDialog::committed);
+ QVERIFY(spy.wait(3000));
+
+ QVERIFY2(undo->isVisibleTo(&dialog),
+ "Undo disappeared instead of greying out");
+}
+
+QTEST_MAIN(TestSendDialog)
+#include "test_senddialog.moc"
+```
+
+- [ ] **Step 3: Run to verify it fails**
+
+Run: `cmake --build build 2>&1 | tail -3`
+Expected: FAIL, `senddialog.h: No such file or directory`.
+
+- [ ] **Step 4: Write the implementation**
+
+`src/senddialog.cpp` (GPL header, then):
+
+```cpp
+#include "senddialog.h"
+
+#include "busyindicator.h"
+
+#include <QFontMetrics>
+#include <QHBoxLayout>
+#include <QLabel>
+#include <QPushButton>
+#include <QTimer>
+#include <QVBoxLayout>
+
+namespace {
+
+/// How often the countdown repaints. 100ms is smooth enough for a draining
+/// bar without being a busy loop.
+constexpr int kTickMs = 100;
+
+} // namespace
+
+SendDialog::SendDialog(int delayMs, QWidget *parent)
+ : QDialog(parent)
+ , m_remainingMs(qMax(0, delayMs))
+ , m_totalMs(qMax(0, delayMs))
+{
+ setWindowTitle(tr("Sending"));
+
+ // Modal to the composer, not to the application: sending from one composer
+ // must not freeze a second composer or the main window.
+ setWindowModality(Qt::WindowModal);
+
+ // 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.
+ setWindowFlags((windowFlags() | Qt::CustomizeWindowHint)
+ & ~Qt::WindowCloseButtonHint);
+
+ auto *layout = new QVBoxLayout(this);
+
+ m_status = new QLabel(this);
+ m_status->setObjectName(QStringLiteral("sendStatus"));
+
+ // The 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.
+ const QFontMetrics metrics(m_status->font());
+ int widest = 0;
+ for (const QString &candidate : { tr("Sending in %1...").arg(99),
+ tr("Sending..."),
+ tr("Filing sent copy..."),
+ tr("Removing draft...") }) {
+ widest = qMax(widest, metrics.horizontalAdvance(candidate));
+ }
+ m_status->setMinimumWidth(widest);
+ layout->addWidget(m_status);
+
+ m_indicator = new BusyIndicator(this);
+ m_indicator->setObjectName(QStringLiteral("sendProgress"));
+ layout->addWidget(m_indicator);
+
+ auto *buttonRow = new QHBoxLayout;
+ buttonRow->addStretch();
+ m_undo = new QPushButton(tr("Undo"), this);
+ m_undo->setObjectName(QStringLiteral("undoSend"));
+ connect(m_undo, &QPushButton::clicked, this, [this]() {
+ // Stop the timer FIRST. A timer left running commits after the dialog
+ // has already reported that nothing was sent.
+ m_timer->stop();
+ emit undone();
+ reject();
+ });
+ buttonRow->addWidget(m_undo);
+ layout->addLayout(buttonRow);
+
+ m_timer = new QTimer(this);
+ m_timer->setObjectName(QStringLiteral("sendCountdown"));
+ m_timer->setInterval(kTickMs);
+ connect(m_timer, &QTimer::timeout, this, &SendDialog::tick);
+
+ if (m_totalMs == 0) {
+ // Zero skips the countdown. Queued rather than immediate so the caller
+ // can connect to committed() after constructing the dialog.
+ QTimer::singleShot(0, this, &SendDialog::commit);
+ } else {
+ setStage(Stage::CountingDown);
+ m_timer->start();
+ }
+}
+
+void SendDialog::tick()
+{
+ m_remainingMs -= kTickMs;
+ if (m_remainingMs <= 0) {
+ commit();
+ return;
+ }
+ setStage(Stage::CountingDown);
+}
+
+void SendDialog::commit()
+{
+ m_timer->stop();
+ m_committed = true;
+
+ // Undo disables the moment the command starts and stays VISIBLE. A
+ // control that vanishes re-lays out the popup mid-operation.
+ m_undo->setEnabled(false);
+
+ setStage(Stage::Sending);
+ emit committed();
+}
+
+void SendDialog::setStage(Stage stage)
+{
+ switch (stage) {
+ case Stage::CountingDown: {
+ const int seconds = (m_remainingMs + 999) / 1000;
+ m_status->setText(tr("Sending in %1...").arg(seconds));
+ // Determinate: a countdown HAS measurable progress. Drains as the
+ // seconds pass.
+ m_indicator->setProgress(m_remainingMs, m_totalMs);
+ break;
+ }
+ case Stage::Sending:
+ m_status->setText(tr("Sending..."));
+ // Indeterminate from here: a send does not report progress.
+ m_indicator->setBusy(true);
+ break;
+ case Stage::FilingSentCopy:
+ m_status->setText(tr("Filing sent copy..."));
+ m_indicator->setBusy(true);
+ break;
+ case Stage::RemovingDraft:
+ m_status->setText(tr("Removing draft..."));
+ m_indicator->setBusy(true);
+ break;
+ }
+}
+```
+
+- [ ] **Step 5: Register and run**
+
+Add the source and `add_qtmaildir_test(senddialog)`, then:
+
+Run: `ctest --test-dir build -R senddialog --output-on-failure`
+Expected: PASS, 7 functions.
+
+- [ ] **Step 6: Mutation-check the undo test**
+
+The negative property must actually be measured:
+
+```bash
+# Make Undo emit undone() but NOT stop the timer, which is the exact bug
+# the test exists to catch.
+sed -i 's/ m_timer->stop();\n emit undone();/ emit undone();/' src/senddialog.cpp
+```
+Do this edit by hand if the `sed` does not match. Rebuild and run; expected: `undoDuringTheCountdownEmitsUndoneAndNeverCommits` FAILS on the post-wait assertion. Restore with `git checkout src/senddialog.cpp`.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add src/senddialog.h src/senddialog.cpp src/CMakeLists.txt \
+ tests/test_senddialog.cpp tests/CMakeLists.txt
+git commit -S -m "feat(compose): the send popup and its undo window, item 123
+
+Three rows in every state so nothing reflows and the window never jumps. The
+bar changes MODE rather than place: determinate while the countdown drains,
+because a countdown has measurable progress, and indeterminate once the
+command starts, because a send does not. That is the pairing item 134's
+widget was extracted to serve.
+
+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 runs leaves an UNKNOWN send,
+which is worse than either clean outcome. Undo therefore disables itself the
+moment the command starts, and stays visible while disabled: 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 looks like it was never
+offered.
+
+The test for this asserts the NEGATIVE property, that committed() never
+fires after Undo, including after the original countdown would have elapsed.
+Asserting only that undone() fired would pass against a design that ran the
+command and threw the result away, which is the whole failure the delay
+exists to prevent.
+
+The status label is sized to the longest string it can hold in the current
+language rather than to its content: Italian 'Rimozione della bozza...' is
+longer than 'Removing draft...', and a label sized to content resizes the
+popup between stages."
+```
+
+---
+
+### Task 11: ComposeWindow
+
+The only unit here that owns widgets, and the one that composes the other four.
+It contains no MIME and no process logic: a composer bug and a MIME bug are
+found in different files.
+
+**Files:**
+- Create: `src/composewindow.h`, `src/composewindow.cpp`
+- Modify: `src/CMakeLists.txt`
+- Modify: `tests/test_mainwindow.cpp` (the composer cases go here, since they need a window)
+
+- [ ] **Step 1: Write the header**
+
+`src/composewindow.h` (GPL header, then):
+
+```cpp
+#pragma once
+
+#include <QMainWindow>
+
+#include "config.h"
+#include "formattoolbar.h" // MarkdownFormat::Edit is used by value below, and
+ // a type nested in a namespace cannot be
+ // forward-declared from outside it.
+#include "types.h"
+
+class BusyIndicator;
+class DraftStore;
+class MessageSender;
+class QCheckBox;
+class QComboBox;
+class QLabel;
+class QLineEdit;
+class QPlainTextEdit;
+class QTimer;
+
+/// One draft. A separate top-level window, several open at once.
+///
+/// A QMainWindow rather than a dialog: a modal dialog cannot consult another
+/// message while writing, which is most of what replying is, and 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.
+class ComposeWindow : public QMainWindow
+{
+ Q_OBJECT
+
+public:
+ /// \p mailRoot is the Maildir root, passed in rather than derived.
+ ///
+ /// There is NO Config::maildirPath(). The root comes from
+ /// notmuch_config_get(NOTMUCH_CONFIG_MAIL_ROOT), wrapped by mailRootOf()
+ /// which is file-static inside notmuchworker.cpp and needs the database
+ /// handle. Item 124 records why this matters: notmuch can split the index
+ /// from the mail, and under that layout database.path is the INDEX
+ /// directory. Composing a destination from the wrong root would write
+ /// drafts and sent copies into the Xapian tree. MainWindow already
+ /// receives the root from the worker; it passes it here.
+ ComposeWindow(const ComposeContext &context, const Config &config,
+ const QString &mailRoot, QWidget *parent = nullptr);
+
+ /// True when the buffer has changed since the last successful autosave.
+ /// The quit path asks every open composer this.
+ bool hasUnsavedEdits() const { return m_dirty; }
+
+ /// True when the LAST autosave attempt failed. Escalated to its own
+ /// dialog on the way out, because saving is what is already not working
+ /// and quitting therefore loses that text.
+ bool lastSaveFailed() const { return m_saveFailed; }
+
+ /// Writes the current buffer to the drafts folder now. Returns false and
+ /// leaves the banner up on failure.
+ bool saveDraftNow();
+
+signals:
+ /// The composer finished with its message, one way or another, and the
+ /// registry should forget it.
+ void closed(ComposeWindow *window);
+
+private:
+ void buildUi();
+ void buildFormatToolbar();
+ void seedBody();
+ void attachFile(const QString &path);
+ void refreshAttachmentBar();
+ void setInputsEnabled(bool enabled);
+ void showSendFailure(const QString &stderrText);
+ void applyEdit(const MarkdownFormat::Edit &edit);
+ void markDirty();
+ void autosave();
+ void send();
+ void applyFormat(const QString &token);
+
+ OutgoingMessage currentMessage() const;
+
+ ComposeContext m_context;
+ Config m_config;
+ QString m_mailRoot;
+ QStringList m_attachments;
+
+ QLineEdit *m_to = nullptr;
+ QLineEdit *m_cc = nullptr;
+ QLineEdit *m_bcc = nullptr;
+ QLineEdit *m_subject = nullptr;
+ QComboBox *m_from = nullptr;
+ QPlainTextEdit *m_body = nullptr;
+ QCheckBox *m_sendHtml = nullptr;
+ QLabel *m_banner = nullptr;
+
+ QTimer *m_autosaveTimer = nullptr;
+ MessageSender *m_sender = nullptr;
+
+ QString m_draftPath; ///< The revision on disk, unlinked on the next write.
+ QByteArray m_savedBytes; ///< What was last written, for the dirty check.
+ bool m_dirty = false;
+ bool m_saveFailed = false;
+};
+```
+
+- [ ] **Step 2: Write the implementation**
+
+`src/composewindow.cpp`. The full file is long; these are the parts that carry
+decisions, and the rest is ordinary widget assembly.
+
+```cpp
+#include "composewindow.h"
+
+#include "composecontext.h"
+#include "draftstore.h"
+#include "formattoolbar.h"
+#include "messagebuilder.h"
+#include "messagesender.h"
+#include "senddialog.h"
+
+#include <QCheckBox>
+#include <QComboBox>
+#include <QDir>
+#include <QFormLayout>
+#include <QLabel>
+#include <QLineEdit>
+#include <QMessageBox>
+#include <QPlainTextEdit>
+#include <QPushButton>
+#include <QTimer>
+#include <QToolBar>
+#include <QVBoxLayout>
+
+ComposeWindow::ComposeWindow(const ComposeContext &context,
+ const Config &config, const QString &mailRoot,
+ QWidget *parent)
+ : QMainWindow(parent)
+ , m_context(context)
+ , m_config(config)
+ , m_mailRoot(mailRoot)
+{
+ // A window in its own right, not a child dialog: it must appear in the
+ // task switcher and be reachable while the main window is used.
+ setAttribute(Qt::WA_DeleteOnClose);
+ setWindowTitle(tr("Compose"));
+
+ // A sensible default. NOT restored and NOT saved; see the header.
+ resize(760, 640);
+
+ buildUi();
+
+ m_autosaveTimer = new QTimer(this);
+ m_autosaveTimer->setObjectName(QStringLiteral("autosave"));
+ m_autosaveTimer->setSingleShot(true);
+ m_autosaveTimer->setInterval(m_config.compose().autosaveIntervalMs);
+ connect(m_autosaveTimer, &QTimer::timeout, this, &ComposeWindow::autosave);
+
+ m_sender = new MessageSender(this);
+}
+
+void ComposeWindow::markDirty()
+{
+ m_dirty = true;
+ // Debounced: the timer restarts on every keystroke, so a write happens
+ // once the user has paused, not once per character. Every autosave
+ // produces a Maildir write that mbsync uploads, which is what the debounce
+ // and the dirty check together keep to a few revisions per message.
+ m_autosaveTimer->start();
+}
+
+void ComposeWindow::autosave()
+{
+ if (!m_dirty)
+ return;
+
+ const Account account = m_config.account(m_context.accountKey);
+ if (account.drafts.isEmpty()) {
+ // Configured without a drafts folder. Warned about at startup; there
+ // is nothing to do here and nothing to report a second time.
+ return;
+ }
+
+ const MessageBuilder::Result built =
+ MessageBuilder::build(currentMessage(), account);
+ if (!built.ok()) {
+ m_saveFailed = true;
+ m_banner->setText(tr("The draft could not be saved: %1").arg(built.error));
+ m_banner->show();
+ return;
+ }
+
+ // The dirty CHECK, not just the flag: identical bytes mean nothing
+ // changed that matters, so no file is written and no sync is provoked.
+ if (built.bytes == m_savedBytes) {
+ m_dirty = false;
+ return;
+ }
+
+ const QString folder =
+ QDir(m_mailRoot).absoluteFilePath(
+ account.maildir + QLatin1Char('/') + account.drafts);
+
+ const DraftStore::Result written = DraftStore::write(
+ folder, built.bytes, QStringLiteral("D"), m_draftPath);
+
+ if (!written.ok()) {
+ // A PERSISTENT banner, 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.
+ m_saveFailed = true;
+ m_banner->setText(tr("The draft could not be saved: %1").arg(written.error));
+ m_banner->show();
+ return;
+ }
+
+ m_draftPath = written.path;
+ m_savedBytes = built.bytes;
+ m_dirty = false;
+ m_saveFailed = false;
+ m_banner->hide();
+}
+
+void ComposeWindow::applyFormat(const QString &token)
+{
+ QTextCursor cursor = m_body->textCursor();
+ const MarkdownFormat::Edit edit = MarkdownFormat::wrap(
+ m_body->toPlainText(), cursor.selectionStart(), cursor.selectionEnd(),
+ token);
+
+ m_body->setPlainText(edit.text);
+
+ // Restore the selection the transformation asked for. setPlainText resets
+ // the cursor to the start, so without this every button press sends the
+ // cursor to the top of the message.
+ QTextCursor restored = m_body->textCursor();
+ restored.setPosition(edit.selectionStart);
+ restored.setPosition(edit.selectionEnd, QTextCursor::KeepAnchor);
+ m_body->setTextCursor(restored);
+ m_body->setFocus();
+}
+
+void ComposeWindow::send()
+{
+ const Account account = m_config.account(m_context.accountKey);
+
+ const MessageBuilder::Result built =
+ MessageBuilder::build(currentMessage(), account);
+ if (!built.ok()) {
+ // A missing attachment lands here, before anything runs.
+ QMessageBox::warning(this, tr("Cannot send"), built.error);
+ return;
+ }
+
+ // Every input is disabled for the WHOLE operation, countdown included.
+ // The message must not change between the user pressing Send and the
+ // bytes being built.
+ setInputsEnabled(false);
+
+ auto *dialog = new SendDialog(m_config.compose().sendDelayMs, this);
+
+ connect(dialog, &SendDialog::undone, this, [this, dialog]() {
+ // Nothing reached a server. The composer returns exactly as it was,
+ // editable, popup gone, nothing sent.
+ setInputsEnabled(true);
+ dialog->deleteLater();
+ });
+
+ connect(dialog, &SendDialog::committed, this, [this, dialog, built, account]() {
+ m_sender->send(account.sendCommand, built.bytes);
+
+ connect(m_sender, &MessageSender::finished, this,
+ [this, dialog, built, account](bool sent, const QString &error) {
+ if (!sent) {
+ dialog->accept();
+ dialog->deleteLater();
+ setInputsEnabled(true);
+ // The draft STAYS. No retry loop.
+ showSendFailure(error);
+ return;
+ }
+
+ dialog->setStage(SendDialog::Stage::FilingSentCopy);
+ bool sentCopyFailed = false;
+ QString sentCopyError;
+
+ if (!account.sent.isEmpty()) {
+ const QString folder =
+ QDir(m_mailRoot).absoluteFilePath(
+ account.maildir + QLatin1Char('/') + account.sent);
+ const DraftStore::Result filed = DraftStore::write(
+ folder, built.bytes, QStringLiteral("S"));
+ if (!filed.ok()) {
+ sentCopyFailed = true;
+ sentCopyError = filed.error;
+ }
+ }
+
+ dialog->setStage(SendDialog::Stage::RemovingDraft);
+ if (!m_draftPath.isEmpty())
+ QFile::remove(m_draftPath);
+
+ dialog->accept();
+ dialog->deleteLater();
+
+ if (sentCopyFailed) {
+ // A MODAL, never a status-bar line, and never reported as a
+ // send failure. The message went; reporting otherwise makes
+ // someone send it twice. 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
+ // nobody discovers a missing sent copy by noticing a line
+ // that appeared for a few seconds.
+ QMessageBox::warning(
+ this, tr("Sent, but not filed"),
+ tr("The message was sent, but the copy could not be "
+ "written to '%1' for account '%2':\n\n%3\n\n"
+ "The message HAS been sent. Do not send it again.")
+ .arg(account.sent, account.key, sentCopyError));
+ }
+
+ // The composer closes either way: the message went, and holding a
+ // composer open for a message already sent invites sending it
+ // twice.
+ emit closed(this);
+ close();
+ }, Qt::SingleShotConnection);
+ });
+
+ dialog->open();
+}
+```
+
+- [ ] **Step 2b: Consume the three settings that are otherwise parsed and ignored**
+
+Task 2 parses `quote_position`, `attachment_warn_bytes` and the toolbar's
+shortcuts; without this step all three are dead configuration. Each is one
+small piece of `ComposeWindow`.
+
+**The quote position**, applied when the window opens and never again. The
+buffer is text the user owns after that, and there is deliberately no live
+toggle: tracking "my text" and "the quote" as separate pieces to make a toggle
+reversible is machinery for a case answered by closing the composer and
+reopening it.
+
+```cpp
+void ComposeWindow::seedBody()
+{
+ if (m_context.quotedBody.isEmpty())
+ return;
+
+ if (m_config.compose().quotePosition == ComposeSettings::QuotePosition::Above) {
+ // The quote first, then a blank line for the reply to be typed into,
+ // and the cursor at the very top.
+ m_body->setPlainText(m_context.quotedBody + QStringLiteral("\n\n"));
+ m_body->moveCursor(QTextCursor::Start);
+ } else {
+ m_body->setPlainText(QStringLiteral("\n\n") + m_context.quotedBody);
+ m_body->moveCursor(QTextCursor::Start);
+ }
+}
+```
+
+**The attachment size warning**, at attach time. A warning rather than a
+refusal: the limit belongs to the recipient's server, which this application
+cannot know, so the user decides.
+
+```cpp
+void ComposeWindow::attachFile(const QString &path)
+{
+ const QFileInfo info(path);
+ const qint64 limit = m_config.compose().attachmentWarnBytes;
+
+ if (limit > 0 && info.size() > limit) {
+ const auto answer = QMessageBox::question(
+ this, tr("Large attachment"),
+ tr("'%1' is %2 MB. Many mail servers refuse messages above about "
+ "%3 MB. Attach it anyway?")
+ .arg(info.fileName())
+ .arg(info.size() / (1024 * 1024))
+ .arg(limit / (1024 * 1024)),
+ QMessageBox::Yes | QMessageBox::No);
+ if (answer != QMessageBox::Yes)
+ return;
+ }
+
+ m_attachments.append(path);
+ refreshAttachmentBar();
+ markDirty();
+}
+```
+
+**The formatting toolbar**, whose shortcuts live in the composer's own scope
+and NOT in `KeyMap`. That separation is the point: `Ctrl+B` here does not
+consume `Ctrl+B` from the main window's map, and these six do not participate
+in the reachability rule item 132 reshaped.
+
+```cpp
+void ComposeWindow::buildFormatToolbar()
+{
+ auto *toolbar = addToolBar(tr("Formatting"));
+ toolbar->setObjectName(QStringLiteral("formatToolbar"));
+
+ // A QAction parented to THIS WINDOW, not registered in KeyMap. Its
+ // shortcut is therefore scoped to the composer: Qt dispatches a
+ // WindowShortcut to the active window only, so the main window's Ctrl+B
+ // is untouched and the two namespaces stay apart.
+ const auto addFormat = [this, toolbar](const QString &name, const QString &text,
+ const QString &token,
+ const QKeySequence &shortcut) {
+ auto *action = toolbar->addAction(text);
+ action->setObjectName(name);
+ if (!shortcut.isEmpty())
+ action->setShortcut(shortcut);
+ connect(action, &QAction::triggered, this,
+ [this, token]() { applyFormat(token); });
+ };
+
+ addFormat(QStringLiteral("format_bold"), tr("Bold"),
+ QStringLiteral("**"), QKeySequence(QStringLiteral("Ctrl+B")));
+ addFormat(QStringLiteral("format_italic"), tr("Italic"),
+ QStringLiteral("*"), QKeySequence(QStringLiteral("Ctrl+I")));
+ addFormat(QStringLiteral("format_code"), tr("Code"),
+ QStringLiteral("`"), QKeySequence(QStringLiteral("Ctrl+`")));
+ // No shortcut, per the spec's table.
+ addFormat(QStringLiteral("format_strike"), tr("Strikethrough"),
+ QStringLiteral("~~"), QKeySequence());
+
+ // Link and Quote are not wraps and cannot go through applyFormat().
+ auto *link = toolbar->addAction(tr("Link"));
+ link->setObjectName(QStringLiteral("format_link"));
+ link->setShortcut(QKeySequence(QStringLiteral("Ctrl+K")));
+ connect(link, &QAction::triggered, this, [this]() {
+ QTextCursor cursor = m_body->textCursor();
+ applyEdit(MarkdownFormat::link(m_body->toPlainText(),
+ cursor.selectionStart(),
+ cursor.selectionEnd()));
+ });
+
+ auto *quote = toolbar->addAction(tr("Quote"));
+ quote->setObjectName(QStringLiteral("format_quote"));
+ connect(quote, &QAction::triggered, this, [this]() {
+ QTextCursor cursor = m_body->textCursor();
+ applyEdit(MarkdownFormat::quote(m_body->toPlainText(),
+ cursor.selectionStart(),
+ cursor.selectionEnd()));
+ });
+}
+```
+
+Refactor `applyFormat()` to call a shared `applyEdit(const MarkdownFormat::Edit &)`
+that sets the text and restores the selection, since all three paths need it.
+
+Declare `setInputsEnabled(bool)` and `showSendFailure(const QString &)` in the
+header and implement them: the first toggles every input including the
+formatting toolbar and Send, the second shows the command's stderr in a pane
+below the body, in the shape `MailSync`'s log pane already has.
+
+`Qt::SingleShotConnection` requires Qt 6.0+; this project is on 6.11. Without
+it, a second send from the same composer would connect the lambda twice.
+
+- [ ] **Step 3: Build and check it compiles**
+
+Run: `cmake --build build 2>&1 | grep -E 'error' | head`
+Expected: no output.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/composewindow.h src/composewindow.cpp src/CMakeLists.txt
+git commit -S -m "feat(compose): the composer window, item 123
+
+A separate top-level QMainWindow, one per draft, several open at once. A
+modal dialog cannot consult another message while writing, which is most of
+what replying is, and taking over the message pane fights the pane that
+exists to show what is being replied to.
+
+No geometry save and no restore, deliberately. Under a tiling compositor
+saveGeometry stores normalGeometry while the compositor owns the tile, so
+the restore is correct and looks broken; a whole session went into that
+once.
+
+Autosave is a 30 second debounce AND a dirty check on the built bytes:
+identical bytes mean no file is written and no sync is provoked. Every
+autosave produces a Maildir write that mbsync uploads, which is what those
+two together keep to a few revisions per message rather than dozens.
+
+A failed draft write raises a persistent banner rather than a modal or a
+fading status line. 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 escalates exactly this state to a dialog on the way out.
+
+A failed sent copy after a successful send is a modal, and never a send
+failure: the message went, and reporting otherwise makes someone send it
+twice. It is the one failure here that silently diverges what the recipient
+received from what the local archive shows, and nobody discovers a missing
+sent copy by noticing a line that appeared for a few seconds."
+```
+
+---
+
+### Task 12: Wire it into MainWindow
+
+The action handlers left empty in Task 9, the composer registry, the
+receive-only ribbon and the quit path.
+
+**Files:**
+- Modify: `src/mainwindow.h`, `src/mainwindow.cpp`
+- Modify: `src/messageview.h`, `src/messageview.cpp`
+- Modify: `tests/test_mainwindow.cpp`
+
+- [ ] **Step 1: Write the failing tests**
+
+Add to `tests/test_mainwindow.cpp`, declaring each in `private slots:`.
+
+`WorkerBackedWindow` gains a knob for an account without `send_command` rather
+than a new fixture class. Find its config-writing helper and add a parameter
+for it.
+
+```cpp
+void TestMainWindow::replyIsDisabledOnAReceiveOnlyAccountsMail()
+{
+ // The capability IS the send_command's presence. One of the user's five
+ // accounts is receive-only on purpose.
+ WorkerBackedWindow fixture;
+ fixture.addAccount(QStringLiteral("listsonly"), /*canSend=*/false);
+ fixture.build();
+
+ // Select a message that arrived at the receive-only account.
+ fixture.selectMessageIn(QStringLiteral("listsonly"));
+
+ for (const QString &name : { QStringLiteral("reply"),
+ QStringLiteral("reply_all"),
+ QStringLiteral("reply_no_quote"),
+ QStringLiteral("forward") }) {
+ auto *action = fixture.window->findChild<QAction *>(name);
+ QVERIFY2(action, qPrintable(QStringLiteral("no action %1").arg(name)));
+ QVERIFY2(!action->isEnabled(),
+ qPrintable(QStringLiteral("%1 was live on receive-only mail").arg(name)));
+ }
+
+ // save_message is NEVER disabled, including here. It is the escape hatch
+ // for exactly this case: write the raw message out and attach it to a new
+ // message from an account that can send.
+ auto *save = fixture.window->findChild<QAction *>(QStringLiteral("save_message"));
+ QVERIFY(save);
+ QVERIFY2(save->isEnabled(),
+ "save_message was disabled, removing the escape hatch");
+}
+
+void TestMainWindow::theReceiveOnlyRibbonNamesTheAccount()
+{
+ // 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.
+ WorkerBackedWindow fixture;
+ fixture.addAccount(QStringLiteral("listsonly"), /*canSend=*/false);
+ fixture.build();
+ fixture.selectMessageIn(QStringLiteral("listsonly"));
+
+ auto *ribbon = fixture.window->findChild<QLabel *>(
+ QStringLiteral("receiveOnlyRibbon"));
+ QVERIFY2(ribbon, "no ribbon widget exists");
+ QVERIFY2(ribbon->isVisibleTo(fixture.window),
+ "the ribbon did not appear on receive-only mail");
+ QVERIFY2(ribbon->text().contains(QStringLiteral("listsonly")),
+ qPrintable(QStringLiteral("the ribbon does not name the account: %1")
+ .arg(ribbon->text())));
+}
+
+void TestMainWindow::composeIsDisabledOnlyWhenNoAccountCanSend()
+{
+ // An installation with no send_command anywhere is a valid read-only
+ // installation and is not warned about; compose is simply unavailable.
+ {
+ WorkerBackedWindow fixture;
+ fixture.addAccount(QStringLiteral("listsonly"), /*canSend=*/false);
+ fixture.build();
+
+ auto *compose = fixture.window->findChild<QAction *>(QStringLiteral("compose"));
+ QVERIFY(compose);
+ QVERIFY2(!compose->isEnabled(),
+ "compose was live with no account able to send");
+ }
+ {
+ WorkerBackedWindow fixture;
+ fixture.addAccount(QStringLiteral("listsonly"), /*canSend=*/false);
+ fixture.addAccount(QStringLiteral("work"), /*canSend=*/true);
+ fixture.build();
+
+ auto *compose = fixture.window->findChild<QAction *>(QStringLiteral("compose"));
+ QVERIFY(compose);
+ QVERIFY2(compose->isEnabled(),
+ "compose was disabled although one account can send");
+ }
+}
+
+void TestMainWindow::quittingWithACleanComposerAsksNothing()
+{
+ // Case 1: every composer clean, quit directly, no dialog. A dialog here
+ // would be the "are you sure" this project deliberately does not do.
+ WorkerBackedWindow fixture;
+ fixture.addAccount(QStringLiteral("work"), /*canSend=*/true);
+ fixture.build();
+
+ fixture.window->openComposerForTest();
+ QCOMPARE(fixture.window->openComposerCount(), 1);
+
+ QVERIFY2(fixture.window->composersBlockingQuit().isEmpty(),
+ "a clean composer was reported as blocking quit");
+}
+
+void TestMainWindow::quittingWithUnsavedEditsAsksOnce()
+{
+ // Case 2: ONE dialog whatever the count. Three modals in a row is worse
+ // than a coarse answer, so there is no per-draft choice.
+ WorkerBackedWindow fixture;
+ fixture.addAccount(QStringLiteral("work"), /*canSend=*/true);
+ fixture.build();
+
+ fixture.window->openComposerForTest();
+ fixture.window->openComposerForTest();
+ fixture.window->markComposersDirtyForTest();
+
+ QCOMPARE(fixture.window->composersBlockingQuit().size(), 2);
+}
+```
+
+The `openComposerForTest`, `openComposerCount`, `composersBlockingQuit` and
+`markComposersDirtyForTest` helpers go on `MainWindow` as test seams; declare
+them in the header. `composersBlockingQuit()` is production code the quit path
+itself uses, not a test-only accessor.
+
+- [ ] **Step 2: Run to verify they fail**
+
+Run: `cmake --build build 2>&1 | tail -3`
+Expected: FAIL, the helpers do not exist.
+
+- [ ] **Step 3: Add the composer registry to MainWindow**
+
+In `src/mainwindow.h`:
+
+```cpp
+ /// Every open composer, so the quit path can see them.
+ ///
+ /// QPointer rather than a raw list: a composer is WA_DeleteOnClose and
+ /// deletes itself, so a raw pointer here would dangle the moment a user
+ /// closed one window.
+ QList<QPointer<ComposeWindow>> m_composers;
+```
+
+- [ ] **Step 4: Implement the three action handlers**
+
+```cpp
+void MainWindow::composeNew()
+{
+ // m_accountBox->currentData() is how the selected account is read
+ // everywhere else in this file; there is no currentAccountKey() accessor.
+ // Empty means the All accounts view, which falls through to rule 2.
+ const QString accountKey = ComposeContextBuilder::accountForNew(
+ m_config, m_accountBox->currentData().toString());
+ if (accountKey.isEmpty())
+ return; // No account can send; the action is disabled and this is unreachable.
+
+ ComposeContext context;
+ context.kind = ComposeContext::Kind::New;
+ context.accountKey = accountKey;
+ context.seedHtml = m_config.compose().sendHtml;
+
+ openComposer(context);
+}
+
+void MainWindow::composeReply(ComposeContext::Kind kind, bool quote)
+{
+ // messageScopeFor() semantics, NOT threadFor(): a thread row means the one
+ // message its card shows, a reply row means itself. Replying to a thread
+ // is meaningless; a reply answers a message.
+ //
+ // It takes a QModelIndexList (src/threadlistmodel.h:338), not a single
+ // index, so the current index is wrapped rather than passed bare.
+ const ActionScope scope =
+ m_model->messageScopeFor({ m_threadView->currentIndex() });
+ if (scope.messageIds.isEmpty())
+ return;
+
+ // Built from the DATABASE, never from the model. The model's data comes
+ // from the query, so a row whose state has not been re-queried carries
+ // stale values, and a reply built from a stale row would carry the wrong
+ // recipients. This is the rule Restore already follows.
+ requestMessageForCompose(scope.messageIds.first(), kind, quote);
+}
+```
+
+`requestMessageForCompose` asks the worker for the message's parsed content and
+file paths, and builds the `ComposeContext` in its reply slot using
+`ComposeContextBuilder`. It reuses the existing message-load path rather than
+adding a worker signal, since the composer never touches `NotmuchWorker`
+directly.
+
+- [ ] **Step 5: Implement action enablement**
+
+Wherever the other actions' enabled state is updated:
+
+```cpp
+ // The reply family is disabled on mail that arrived at an account which
+ // cannot send. save_message is deliberately NOT in this list: it is the
+ // escape hatch for exactly that case.
+ const QString replyAccount = accountForCurrentMessage();
+ const bool canReply = !replyAccount.isEmpty()
+ && m_config.account(replyAccount).canSend();
+ for (const QString &name : { QStringLiteral("reply"),
+ QStringLiteral("reply_all"),
+ QStringLiteral("reply_no_quote"),
+ QStringLiteral("forward") }) {
+ if (QAction *action = m_actions.value(name))
+ action->setEnabled(canReply);
+ }
+
+ // compose is disabled only when NO account can send. A read-only
+ // installation is valid and is not warned about.
+ if (QAction *compose = m_actions.value(QStringLiteral("compose")))
+ compose->setEnabled(!m_config.sendingAccounts().isEmpty());
+```
+
+- [ ] **Step 6: Add the ribbon to MessageView**
+
+In `src/messageview.h` add `QLabel *m_receiveOnlyRibbon = nullptr;` and a
+setter:
+
+```cpp
+ /// Shows or hides the receive-only explanation.
+ ///
+ /// A WIDGET in this layout, never 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.
+ void setReceiveOnlyAccount(const QString &accountKey);
+```
+
+```cpp
+void MessageView::setReceiveOnlyAccount(const QString &accountKey)
+{
+ if (accountKey.isEmpty()) {
+ m_receiveOnlyRibbon->hide();
+ return;
+ }
+
+ // Qt::PlainText explicitly: the account key comes from configuration
+ // rather than from a stranger, but a QLabel guesses under Qt::AutoText and
+ // this is the same protection MessageDetailsDialog states everywhere.
+ m_receiveOnlyRibbon->setTextFormat(Qt::PlainText);
+ m_receiveOnlyRibbon->setText(
+ tr("This account is receive-only. Add send_command to [account.%1] "
+ "to send from it.")
+ .arg(accountKey));
+ m_receiveOnlyRibbon->show();
+}
+```
+
+Build the label in `MessageView`'s constructor with
+`setObjectName(QStringLiteral("receiveOnlyRibbon"))`, hidden, above the web view.
+
+- [ ] **Step 7: Implement the quit path**
+
+```cpp
+QList<ComposeWindow *> MainWindow::composersBlockingQuit() const
+{
+ QList<ComposeWindow *> blocking;
+ for (const QPointer<ComposeWindow> &composer : m_composers) {
+ if (composer && composer->hasUnsavedEdits())
+ blocking.append(composer.data());
+ }
+ return blocking;
+}
+```
+
+In `closeEvent`, before the existing pending-edits check:
+
+```cpp
+ // Case 3 FIRST, because it is the one where saving is what is already not
+ // working: in case 2 nothing is lost by saving, here quitting loses that
+ // text, so the dialog must say so plainly rather than offering a save that
+ // will fail again.
+ QStringList failedSaves;
+ for (const QPointer<ComposeWindow> &composer : m_composers) {
+ if (composer && composer->lastSaveFailed())
+ failedSaves.append(composer->windowTitle());
+ }
+ if (!failedSaves.isEmpty()) {
+ const auto answer = QMessageBox::warning(
+ this, tr("A draft could not be saved"),
+ tr("%n message(s) could not be saved to the drafts folder. "
+ "Quitting now loses that text.", "", failedSaves.size()),
+ QMessageBox::Retry | QMessageBox::Discard | QMessageBox::Cancel);
+ if (answer == QMessageBox::Cancel) {
+ event->ignore();
+ return;
+ }
+ if (answer == QMessageBox::Retry) {
+ bool allSaved = true;
+ for (const QPointer<ComposeWindow> &composer : m_composers) {
+ if (composer && composer->lastSaveFailed() && !composer->saveDraftNow())
+ allSaved = false;
+ }
+ if (!allSaved) {
+ event->ignore();
+ return;
+ }
+ }
+ }
+
+ // Case 2: ONE dialog whatever the count. Three modals in a row is worse
+ // than a coarse answer, so it applies to all of them and there is no
+ // per-draft choice.
+ const QList<ComposeWindow *> blocking = composersBlockingQuit();
+ if (!blocking.isEmpty()) {
+ const auto answer = QMessageBox::question(
+ this, tr("Messages still being composed"),
+ // "Discard" discards UNSAVED EDITS, not drafts: a draft already
+ // autosaved stays in the folder. The wording must not read as
+ // "delete my three messages".
+ tr("%n message(s) are still being composed. Drafts already saved "
+ "stay in the drafts folder either way.", "", blocking.size()),
+ QMessageBox::Save | QMessageBox::Discard | QMessageBox::Cancel);
+
+ if (answer == QMessageBox::Cancel) {
+ event->ignore();
+ return;
+ }
+ if (answer == QMessageBox::Save) {
+ for (ComposeWindow *composer : blocking)
+ composer->saveDraftNow();
+ }
+ }
+```
+
+- [ ] **Step 8: Run the suite**
+
+Run: `ctest --test-dir build --output-on-failure 2>&1 | tail -5`
+Expected: PASS, all tests.
+
+- [ ] **Step 9: Refresh translations again**
+
+Run `lupdate-qt6` and `lrelease-qt6` as in Task 9 Step 9, translate the new strings, and confirm `0 unfinished`.
+
+Note the `%n` plural forms: Italian has different plural rules from English and
+`lrelease` will report them as unfinished until both forms are given.
+
+- [ ] **Step 10: Commit**
+
+```bash
+git add src/mainwindow.h src/mainwindow.cpp src/messageview.h \
+ src/messageview.cpp tests/test_mainwindow.cpp \
+ translations/qtmaildir_it_IT.ts
+git commit -S -m "feat(compose): wire the composer into the main window, item 123
+
+The reply family is disabled on mail that arrived at an account with no
+send_command, behind a ribbon in MessageView naming the account and the key
+to add. save_message is deliberately never disabled: it is the escape hatch
+for exactly that case.
+
+The ribbon is a WIDGET in the pane's layout, never 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.
+
+Compose itself is disabled only when NO account can send, and that state is
+not warned about at startup: an installation with no send_command anywhere
+is a valid read-only installation.
+
+Every reply resolves through messageScopeFor(), not threadFor(): a thread
+row means the one message its card shows. Replying to a thread is
+meaningless; a reply answers a message. The context is built from the
+DATABASE rather than the model, the rule Restore already follows, because a
+row whose state has not been re-queried carries stale values and a reply
+built from one would carry the wrong recipients.
+
+The quit path checks the failed-save case FIRST. In the ordinary case
+nothing is lost by saving; there, saving is what is already not working, so
+the dialog says plainly that quitting loses that text rather than offering a
+save that will fail again. The ordinary case asks once whatever the count,
+because three modals in a row is worse than a coarse answer, and its wording
+says drafts already saved stay in the folder so Discard cannot read as
+'delete my three messages'."
+```
+
+---
+
+### Task 13: Close out
+
+Documentation, the backlog, and the hand test that no automated test can
+replace.
+
+**Files:**
+- Modify: `CHANGELOG.md`
+- Modify: `CLAUDE.md`
+- Modify: `docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md`
+- Modify: `docs/superpowers/specs/2026-08-20-compose-and-send-design.md`
+
+- [ ] **Step 1: Add the changelog entry**
+
+Under `## [Unreleased]`, in the existing `### Added` section or a new one:
+
+```markdown
+### Added
+
+- Compose, reply, reply-all, reply-without-quoting and forward, in a separate
+ composer window per draft. Bodies are markdown, rendered to an HTML part
+ with cmark-gfm, and the plain part carries the markdown source unchanged.
+- Sending is a per-account `send_command` receiving the message on stdin, on
+ the same contract `[sync] command` already uses. The application speaks no
+ network protocol; what sits behind that command is yours to choose. An
+ account without one is receive-only, and the reply actions are disabled on
+ its mail behind an explanation naming the account.
+- Drafts autosave to the account's own drafts folder, so they are visible to
+ every other client that reads the same Maildir.
+- Send runs behind a cancellable countdown with an Undo button. Cancelling
+ during the countdown means nothing was sent at all.
+- `Save message as...` writes the raw message to a file.
+
+### Upgrading
+
+Sending needs a `send_command` in each account that should be able to send:
+
+ [account.work]
+ send_command = msmtp -a work -t
+
+Nothing else is required; every `[compose]` key is optional. An installation
+that adds no `send_command` anywhere keeps working exactly as before, with the
+compose actions unavailable.
+```
+
+The `### Upgrading` section makes this a **minor** version bump, not a patch,
+per the release procedure in `CLAUDE.md`.
+
+- [ ] **Step 2: Update CLAUDE.md**
+
+Two things there are now wrong. Find and fix both:
+
+1. **"v1 is read-and-organize only. Compose and send are v2."** — that is no longer true.
+2. The architecture diagram lists no compose units. Add them to the tree, and add a paragraph recording the traps this work found, in the style of the existing ones:
+
+```markdown
+**GMime's defaults are wrong for this application in three ways, and all three
+fail only on accented text.** It encodes as iso-8859-1 unless an explicit
+charset argument is passed, so `g_mime_message_set_subject(msg, text, "utf-8")`
+carries that third argument for a reason. `g_mime_text_part_set_text()` encodes
+using the charset set at the moment it is CALLED, so setting the charset
+afterwards relabels a part without re-encoding it and produces
+`charset=utf-8` over latin-1 bytes: mojibake that looks correct in the headers.
+`MessageBuilder::makeTextPart()` builds the content stream directly for that
+reason and must not be "simplified" back. And neither `Date` nor `Message-ID`
+is generated unless asked for; a message without a Message-ID cannot be
+threaded by anything that receives it. This user writes Italian, so every one
+of these is every message rather than an edge case.
+
+**`libcmark-gfm-extensions` has no pkg-config file**, though `libcmark-gfm`
+does. CMake finds the core with `pkg_check_modules` and the extensions with
+`find_library`, the way notmuch is found. All three enabled extensions
+(autolink, strikethrough, tasklist) live in that second library, so a build
+that finds only the first compiles and silently renders plain CommonMark.
+
+**Cancelling a send is safe during the countdown and at no other time.** Undo
+disables itself the moment `send_command` starts, because killing it
+mid-transaction leaves an *unknown* send: the message may have reached the
+server in full before the kill, which is worse than either clean outcome. The
+test for this asserts the NEGATIVE property, that `committed()` never fires
+after Undo including after the original countdown would have elapsed; a test
+asserting only that `undone()` fired passes against a design that runs the
+command and discards the result.
+```
+
+- [ ] **Step 3: Close item 123 in the backlog**
+
+Change its status cell to `done, <date>, <commit>` with a one-line summary of
+what shipped, in the style of the other closed rows. Then **move its section**
+to `2026-08-03-post-0.1.0-usability-closed.md` on the same commit, per the rule
+in `CLAUDE.md`: leaving it for a later cleanup is how the file reached five
+thousand lines the first time.
+
+Leave items 128 to 133 open. They are the follow-ups this work deliberately did
+not do.
+
+- [ ] **Step 4: Mark the spec as implemented**
+
+Change its `**Status: design only.**` line to name the implementing commits, so
+a later reader knows the document describes shipped code rather than a plan.
+
+- [ ] **Step 5: Run the full suite one last time**
+
+Run: `ctest --test-dir build --output-on-failure`
+Expected: every test passes. Note the count; it should be 25 before this work plus the seven new binaries.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add CHANGELOG.md CLAUDE.md docs/
+git commit -S -m "docs: record compose and send, item 123
+
+Closes item 123 and moves its section to the closed file on the same commit,
+per the rule that leaving it is how the backlog reached five thousand lines
+the first time.
+
+CLAUDE.md said 'v1 is read-and-organize only, compose and send are v2',
+which is no longer true, and gains the three traps this work found: GMime's
+iso-8859-1 default and its set_text() ordering, both of which fail only on
+accented text and therefore on every message this user writes; the missing
+pkg-config file for cmark-gfm's extensions, which makes a build that finds
+only the core render plain CommonMark silently; and why cancelling a send is
+safe only during the countdown.
+
+The changelog carries an Upgrading section, which makes this a minor bump
+rather than a patch."
+```
+
+- [ ] **Step 7: Hand it to the user**
+
+**Do not merge and do not cut a release.** Report what was built and what was
+verified, and say plainly what no test here covers:
+
+- **The actual send.** There is no MTA on this machine and there will not be
+ one in CI. Every test uses stub commands.
+- **How the HTML part renders in a real mail client.** A hand test, and the
+ user's to make.
+- **Composer window geometry.** The offscreen platform returns an identical
+ frame for a correct layout and a broken one, verified in a standalone
+ program containing none of this project's code.
+
+Suggest the first hand test: configure `send_command` on one account, write a
+message to themselves with an accented subject and body, send it, and confirm
+that it arrives readable, that the sent copy is filed, and that the draft is
+gone.
+