diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-20 18:00:30 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-20 18:00:30 +0200 |
| commit | c50bea78e036518ce1a2a3eb899bbb5e305affea (patch) | |
| tree | 9eca7fe323ef03a1f05aa51123c1d63aad242274 /docs/superpowers/plans | |
| parent | 81c0086a06d4b13478475c2fa05f040c0511bfba (diff) | |
| download | qtmaildir-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/superpowers/plans')
| -rw-r--r-- | docs/superpowers/plans/2026-08-20-compose-and-send.md | 4923 |
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. + |
