aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-23 21:15:13 +0200
committerDanilo M. <danix@danix.xyz>2026-08-23 21:15:13 +0200
commitfabcf080652c6e5d57bf234be5e100769a9b965b (patch)
tree0de4222c1e2aab58c38d34c9e0e3c37c68298cc8
parentc50bea78e036518ce1a2a3eb899bbb5e305affea (diff)
parentddcae8d02ef46db522b3cf6c228196c7a66a6432 (diff)
downloadqtmaildir-fabcf080652c6e5d57bf234be5e100769a9b965b.tar.gz
qtmaildir-fabcf080652c6e5d57bf234be5e100769a9b965b.zip
Merge branch 'compose-and-send': composing and sending mail
Item 123, built over 2026-08-20 to 2026-08-23 in thirteen tasks against docs/superpowers/specs/2026-08-20-compose-and-send-design.md. The application writes mail now. A composer window per message, markdown as the body, drafts autosaving into the account's Maildir, and sending through a per-account command on stdin rather than any network protocol of this program's own. A countdown with an Undo stands between pressing Send and the command running. Two things came in alongside it. The notmuch auto-tagging hooks moved here from the retiring `mailctl` project and learned that mail this application files itself never arrived, so sent mail and drafts stop appearing in the inbox. And the v1/v2 language is retired: semver on the user-visible surface is the rule, and those labels described a split that composing made obsolete. Hand tested against a fake send command rather than a real one, deliberately: New, Reply and Forward all produce correct messages, a forwarded attachment survives intact, and the sent copy is filed. That testing found the two defects fixed on this branch, and both were invisible to the suite: a composer orphaned by quitting the main window, and every sent message tagged `inbox`. Twenty-two defects were found in the plan document's own draft code while building it, which is why CLAUDE.md says to treat every code block in a plan as a draft.
-rw-r--r--.gitignore3
-rw-r--r--CHANGELOG.md78
-rw-r--r--CLAUDE.md35
-rw-r--r--CMakeLists.txt17
-rw-r--r--README.md88
-rwxr-xr-xassets/hooks/mailrules.py261
-rwxr-xr-xassets/hooks/post-new188
-rwxr-xr-xassets/hooks/qtmaildirconf.py134
-rwxr-xr-xassets/hooks/test_mailrules.py278
-rwxr-xr-xassets/hooks/test_post_new.py340
-rwxr-xr-xassets/hooks/test_qtmaildirconf.py179
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md389
-rw-r--r--docs/superpowers/plans/2026-08-20-compose-and-send.md53
-rw-r--r--docs/superpowers/specs/2026-08-20-compose-and-send-design.md39
-rw-r--r--src/CMakeLists.txt12
-rw-r--r--src/composecontext.cpp517
-rw-r--r--src/composecontext.h177
-rw-r--r--src/composewindow.cpp919
-rw-r--r--src/composewindow.h267
-rw-r--r--src/config.cpp160
-rw-r--r--src/config.h56
-rw-r--r--src/draftstore.cpp82
-rw-r--r--src/draftstore.h60
-rw-r--r--src/formattoolbar.cpp182
-rw-r--r--src/formattoolbar.h76
-rw-r--r--src/keymap.cpp33
-rw-r--r--src/maildirname.cpp80
-rw-r--r--src/maildirname.h41
-rw-r--r--src/mainwindow.cpp719
-rw-r--r--src/mainwindow.h193
-rw-r--r--src/markdownrenderer.cpp110
-rw-r--r--src/markdownrenderer.h40
-rw-r--r--src/messagebuilder.cpp407
-rw-r--r--src/messagebuilder.h59
-rw-r--r--src/messagesender.cpp197
-rw-r--r--src/messagesender.h164
-rw-r--r--src/messageview.cpp29
-rw-r--r--src/messageview.h10
-rw-r--r--src/mimeparser.cpp2
-rw-r--r--src/mimeparser.h16
-rw-r--r--src/notmuchworker.cpp96
-rw-r--r--src/notmuchworker.h20
-rw-r--r--src/senddialog.cpp318
-rw-r--r--src/senddialog.h145
-rw-r--r--src/types.h41
-rw-r--r--tests/CMakeLists.txt28
-rw-r--r--tests/test_composecontext.cpp1051
-rw-r--r--tests/test_config.cpp196
-rw-r--r--tests/test_draftstore.cpp255
-rw-r--r--tests/test_formattoolbar.cpp346
-rw-r--r--tests/test_maildirname.cpp93
-rw-r--r--tests/test_mainwindow.cpp2342
-rw-r--r--tests/test_markdownrenderer.cpp151
-rw-r--r--tests/test_messagebuilder.cpp466
-rw-r--r--tests/test_messagesender.cpp532
-rw-r--r--tests/test_senddialog.cpp468
-rw-r--r--translations/qtmaildir_it_IT.ts367
57 files changed, 13517 insertions, 88 deletions
diff --git a/.gitignore b/.gitignore
index 3437dad..6054f6c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,6 @@ HANDOFF.md
# The .ts is tracked; the .qm is generated from it by lrelease.
*.qm
+
+# Python bytecode from the notmuch hooks in assets/hooks/.
+__pycache__/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3f0748a..9e19ff2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,8 +11,86 @@ point at which they are stable.
## [Unreleased]
+### Added
+
+- **Composing and sending.** Ctrl+N opens a composer; Reply, Reply all, Reply
+ without quoting and Forward start one from the selected message. Each is a
+ window in its own right, so several can be open at once and the main window
+ stays usable behind them.
+- The body is markdown, sent as plain text exactly as typed. "Also send a
+ formatted copy" renders an HTML part from the same source and sends both in
+ a `multipart/alternative`; `[compose] send_html` sets the default, and a
+ reply follows what the message being answered used.
+- Drafts autosave to the account's `drafts` folder as ordinary Maildir files,
+ so mbsync carries them to the server and another client can pick one up.
+ Closing a composer with unsaved edits asks first, and so does quitting with
+ one open.
+- Sending goes to a per-account `send_command` on stdin, so any sendmail
+ compatible program works (msmtp, ssmtp, sendmail) and the credentials stay
+ in that program's own store. The application still speaks no network
+ protocol of its own. An account with no `send_command` is receive-only, and
+ the composer says so rather than failing at the end.
+- A send counts down before it runs, and Undo during that window stops it and
+ returns you to the composer with everything intact. Nothing reaches the
+ network until the countdown ends. `[compose] send_delay_ms` sets the length;
+ 0 removes it.
+- A copy of every sent message is filed in the account's `sent` folder.
+- The notmuch hooks that auto-tag incoming mail now live in this repository,
+ under `assets/hooks/`. They moved from the companion `mailctl` project,
+ which is being retired.
+
### Fixed
+- Sent mail and drafts no longer appear in the inbox. notmuch tags every newly
+ indexed file with `inbox`, including the copy this application files after a
+ send and the drafts it autosaves, so both turned up in the Inbox view and in
+ any `tag:inbox` search. The `post-new` hook now removes it from mail inside a
+ configured `sent` or `drafts` folder, which is mail that never arrived. Only
+ `inbox` is touched, and trash is deliberately left alone so Restore can still
+ find where a message came from.
+- Quitting with a composer open no longer leaves it behind. A composer is a
+ top-level window with no parent, so closing the main window did not take it
+ down and the process stayed alive for it: the main window vanished, the
+ composer stayed on screen, and closing it then asked about unsaved edits for
+ a session that had already ended.
+
+### Upgrading
+
+**To send, an account needs a `send_command`.** Without one it is
+receive-only: it still reads, tags and syncs exactly as before, and the
+compose actions are simply disabled for it. Nothing breaks by doing nothing.
+
+```ini
+[account.work]
+send_command = /usr/bin/msmtp -a work -t
+```
+
+The command receives the finished message on stdin and is run **without a
+shell**, so pipes and redirections do not work; give an absolute path and
+plain arguments. Credentials belong to that program, not to this one.
+
+An account that sends should also name `drafts` and `sent`, both relative to
+its `maildir`. Without `drafts` a composer cannot autosave and says so; without
+`sent` no copy of what you sent is kept locally.
+
+**If you run the auto-tagging hook, redeploy it.** It moved here from the
+`mailctl` project and gained the sent-and-drafts carve-out described above.
+Copy `assets/hooks/post-new`, `mailrules.py` and `qtmaildirconf.py` into
+`<database.path>/.notmuch/hooks/`, all three together: `post-new` imports the
+other two, and the new one reads your `qtmaildir.conf` to learn which folders
+are yours rather than arrivals. The rules file itself is unchanged, and
+`mailctl` can still read it.
+
+**Existing sent mail and drafts keep their `inbox` tag**, since the hook only
+sees newly indexed mail. To clear the backlog in one pass:
+
+```sh
+notmuch tag -inbox -- 'tag:inbox and (path:"work/Sent/**" or path:"work/Drafts/**")'
+```
+
+naming your own folders. This is a tag change only: no file moves, nothing
+reaches the server, and re-adding `inbox` to the same query undoes it.
+
- Clicking a link in a message opens it in the system browser. Links carrying
`target="_blank"`, which is most links in HTML mail, did nothing at all: no
error, nothing on screen. Chromium routes those to a new-window request
diff --git a/CLAUDE.md b/CLAUDE.md
index b065741..031b3d2 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -64,10 +64,19 @@ MainWindow NotmuchWorker
│ ONE column of cards; CardDelegate paints each whole, from CardLayout
└ MessageView (header QLabel, QWebEngineView, attachment bar, TagStrip)
+ComposeWindow (its own top-level window, one per message being written)
+ ├ MarkdownFormat (namespace: what the formatting buttons do to a selection)
+ ├ MarkdownRenderer (namespace, cmark-gfm) MessageBuilder (namespace, GMime)
+ ├ MessageSender (QProcess, the per-account send_command on stdin)
+ └ SendDialog (the undo countdown) DraftStore (autosave to the drafts folder)
+
+ComposeContext (a struct: what a Reply or Forward inherits)
+ComposeContextBuilder (namespace: fills one, and picks the account)
CardLayout (pure geometry, no painting)
SearchTerm (pure query strings, no widget)
Config (INI) KeyMap MailSync (QProcess) MimeParser (GMime)
SyncMonitor (/proc/locks) TagColors QueryCompleter ThreadCidMap
+MaildirName (fresh Maildir filenames)
```
The query row and the message-pane header are **built inline in `MainWindow` and
@@ -75,11 +84,20 @@ The query row and the message-pane header are **built inline in `MainWindow` and
listed `QueryBar`, `SavedQueryBar`, `HeaderWidget` and `AttachmentBar`; none of
those types have ever existed, and looking for them wastes a search. The widget
classes that do exist are `MessageView`, `ThreadListView`, `TagStrip`,
-`TagDialog`, `MessageDetailsDialog`, `RowStyleDelegate` and `CardDelegate`;
-`TagChip` is a namespace of painting helpers, not a widget, `SearchTerm` is a
-namespace of query builders, and `ThreadCidMap`, `CardLayout`, `SearchOffer`
-and `HeaderRow` are structs. `SubjectDelegate` existed until item 53 and is
-gone.
+`TagDialog`, `MessageDetailsDialog`, `RowStyleDelegate`, `CardDelegate`,
+`ComposeWindow`, `SendDialog` and `BusyIndicator`; `TagChip` is a namespace of
+painting helpers, not a widget, `SearchTerm` is a namespace of query builders,
+and `ThreadCidMap`, `CardLayout`, `SearchOffer` and `HeaderRow` are structs.
+`SubjectDelegate` existed until item 53 and is gone.
+
+**The compose units are mostly NAMESPACES, and the same warning applies to
+them.** `MarkdownRenderer`, `MarkdownFormat`, `MessageBuilder`,
+`ComposeContextBuilder`, `DraftStore` and `MaildirName` are namespaces of free
+functions over values, deliberately, so the markdown, the MIME assembly and
+the account-picking are all testable without a widget. `MessageSender` IS a
+QObject, because it owns a `QProcess`. There is no `FormatToolbar` class: the
+composer's formatting row is built inline in `ComposeWindow` and asks
+`MarkdownFormat` what each button does to the selection.
**`MessageDetailsDialog` was a `QPlainTextEdit` inside `MessageView` until item
85.** It is rows now so each value can carry its own context menu, and its
@@ -925,7 +943,12 @@ test" position — it is the only code that writes to a notmuch index.
Work goes directly on `master`, no PR flow. Commits must be GPG-signed (`git commit -S`).
`HANDOFF.md` is local-only and gitignored; never stage or commit it.
-v1 is read-and-organize only. Compose and send are v2.
+**There is no "v1" and no "v2".** The project follows semver on its
+user-visible surface, and those labels described a scope split that stopped
+being true when compose and send shipped. Reading, organizing and sending are
+all part of the application now. The phrase survives in the older spec and
+plan documents, which are historical records and are not being rewritten; read
+it there as "before compose" and "after compose".
## Cutting a release
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 97a1d90..47b0df6 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -34,6 +34,23 @@ message(STATUS "Found notmuch: ${NOTMUCH_LIBRARY}")
find_package(PkgConfig REQUIRED)
pkg_check_modules(GMIME REQUIRED IMPORTED_TARGET gmime-3.0)
+# 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}")
+
# The version lives only in the project() call above; version.h is generated
# from it so no source file repeats the literal.
configure_file(
diff --git a/README.md b/README.md
index 1801bbd..75f9cbe 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
# qtmaildir
-A Qt6 desktop mail client for reading and organizing a local,
-notmuch-indexed Maildir. A GUI counterpart to neomutt for the parts of mail
-handling that are easier with a mouse and a real HTML renderer.
+A Qt6 desktop mail client for reading, organizing and writing mail in a
+local, notmuch-indexed Maildir. A GUI counterpart to neomutt for the parts of
+mail handling that are easier with a mouse and a real HTML renderer.
## What it does not do
@@ -18,8 +18,10 @@ script means joining the `flock` that already serializes it against cron; a
built-in implementation would sit outside that lock and could run two
`mbsync` processes over one Maildir, which corrupts UID state.
-Sending is not implemented. Compose, reply, forward and send are planned for
-v2 and need a companion send script that does not exist yet.
+Sending follows the same rule. The application builds the message and hands
+it to a command you configure, on stdin; it speaks no SMTP itself. Any sendmail
+compatible program does (msmtp, ssmtp, the real sendmail), which keeps the
+credentials in that program's own store rather than in this one's config.
There is also no dry-run and no "are you sure?" on destructive actions. The
answer for a human at a GUI is undo, which is implemented, and which is
@@ -225,9 +227,21 @@ identity.
[account.work]
name = Your Name
address = you@example.org
-maildir = work-mail ; relative to notmuch's database.path
-drafts = Drafts ; recorded for v2; unused today
-sent = Sent ; optional; enables the Sent button for this account
+maildir = work-mail ; relative to notmuch's mail root
+trash = Trash ; Delete moves the file here. Not optional in
+ ; practice: without it the application reports a
+ ; config problem and Delete does not work.
+drafts = Drafts ; optional; where the composer autosaves
+sent = Sent ; optional; enables the Sent button, and where a
+ ; sent copy is filed
+inbox = Inbox ; optional; where Restore puts a message whose
+ ; origin is unknown. Defaults to "Inbox"
+
+; Optional, and its absence is what makes an account receive-only: with no
+; send_command the compose actions are disabled for it. The message is written
+; to the command's stdin. Run WITHOUT a shell, so no pipes or redirections;
+; give an absolute path and plain arguments.
+send_command = /usr/bin/msmtp -a work -t
label = W ; optional chip text; defaults to the key
color = #2f6fa8 ; optional chip colour; generated when unset
channel = work ; optional mbsync channel; defaults to the key
@@ -236,8 +250,38 @@ channel = work ; optional mbsync channel; defaults to the key
name = Your Name
address = you@example.net
maildir = personal
+trash = Trash
drafts = Drafts
+; Optional, and global rather than per-account. Every key below shows its
+; default, so an omitted [compose] section behaves exactly like this one.
+[compose]
+; Send an HTML part alongside the plain text one. The composer's own checkbox
+; overrides this per message. It seeds New and Forward only: a Reply follows
+; whether the message being answered carried an HTML part, which is a fact
+; about the sender's software rather than a guess about their taste.
+send_html = true
+
+; Where the quoted original goes in a reply: above or below.
+quote_position = above
+
+; How long the send popup counts down before the command runs, in
+; milliseconds. This is the window in which Undo can still stop it; 0 skips
+; the countdown and sends at once.
+send_delay_ms = 5000
+
+; How often an open composer autosaves its draft, in milliseconds. Values
+; below 1000 are raised to it.
+autosave_interval_ms = 30000
+
+; Preferred account for a new message while the dropdown is on All accounts.
+; Ignored when it names an account that cannot send.
+; default_account = work
+
+; Warn before attaching a file larger than this, in bytes. 25 MiB by default,
+; which is the limit most providers enforce.
+attachment_warn_bytes = 26214400
+
[tagcolors]
; Optional. Colours resolve by exact tag first, then by top-level prefix, so
; one entry covers a whole hierarchy.
@@ -441,6 +485,34 @@ an attachment, so it is visible without opening the thread. It comes from the
`attachment` tag notmuch applies while indexing, not from parsing the message,
and costs no extra query.
+## Composing
+
+**Ctrl+N** opens a composer; Reply, Reply all, Reply without quoting and
+Forward start one from the selected message. Each is a window in its own
+right, so several can be open at once and the main window stays usable behind
+them.
+
+The body is **markdown**. It is sent as plain text, and the markdown is what
+you typed rather than a rendering of it, so a recipient reading plain text
+sees exactly the source. Tick "Also send a formatted copy" and an HTML part is
+rendered from that same source and sent alongside it, in a
+`multipart/alternative`; `[compose] send_html` sets the default.
+
+Drafts autosave to the account's `drafts` folder while you type, as ordinary
+Maildir files, so mbsync carries them to the server like any other message and
+another client can pick one up. Closing a composer with unsaved edits asks
+first, and so does quitting with one open.
+
+Sending goes through the account's `send_command`, which receives the finished
+message on stdin. An account without one is receive-only, and the composer
+says so rather than failing at the end. A copy of what was sent is filed in
+the account's `sent` folder.
+
+**Send waits.** A popup counts down before the command runs, and Undo during
+that window stops it and returns you to the composer with everything intact.
+Nothing has reached the network until the countdown ends;
+`[compose] send_delay_ms` sets how long it lasts, and 0 removes it.
+
## Tagging
Archive, delete, spam, mark-important and toggle-unread write fixed tags. For anything
diff --git a/assets/hooks/mailrules.py b/assets/hooks/mailrules.py
new file mode 100755
index 0000000..dbb80a5
--- /dev/null
+++ b/assets/hooks/mailrules.py
@@ -0,0 +1,261 @@
+#!/usr/bin/env python3
+#
+# 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.
+"""Shared notmuch tagging-rule store.
+
+The rules live in ~/.config/mailrules/rules.json and are read by both this
+tool and qtmaildir, so the format belongs to neither: a field one tool does
+not understand is preserved verbatim across a save by the other.
+
+A rule carries NO scope. The post-new hook supplies `tag:new`, a dry run
+supplies nothing and counts against the whole corpus. This is what lets one
+rule serve arrivals, a dry run, and (later) a backfill over history.
+
+Stdlib only, deliberately: this module is imported by a notmuch hook that
+runs on every sync, and mailctl has no dependencies to inherit.
+"""
+
+import json
+import os
+import re
+import tempfile
+from dataclasses import dataclass, field
+from pathlib import Path
+
+FORMAT_VERSION = 1
+DEFAULT_STAGE = 50
+
+# Fields this version understands. Anything else in a rule object is kept in
+# `unknown` and written back untouched, which is what makes the file neutral
+# rather than this tool's file that another program may read.
+KNOWN_KEYS = {"id", "stage", "enabled", "add", "remove", "query", "note"}
+
+# An id is a handle, not a display name: a UI selects on it and a diff tracks
+# it. Tags may contain '/' and may be renamed; ids may not.
+ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
+
+
+@dataclass
+class Rule:
+ id: str
+ query: str
+ add: list = field(default_factory=list)
+ remove: list = field(default_factory=list)
+ stage: int = DEFAULT_STAGE
+ enabled: bool = True
+ note: str = ""
+ unknown: dict = field(default_factory=dict)
+
+
+@dataclass
+class Store:
+ rules: list = field(default_factory=list)
+ warnings: list = field(default_factory=list)
+ unknown: dict = field(default_factory=dict)
+ # Distinguishes "no file yet" from "a file that would not load". The hook
+ # treats them differently: the first is a fresh install, the second must
+ # not consume tag:new.
+ missing: bool = False
+ failed: bool = False
+
+
+def default_path():
+ """$XDG_CONFIG_HOME/mailrules/rules.json, or ~/.config/... as fallback.
+
+ No hardcoded home directory: both tools must resolve the same path, and
+ a user with XDG_CONFIG_HOME set expects it honoured.
+ """
+ base = os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config"
+ return Path(base) / "mailrules" / "rules.json"
+
+
+def load(path=None):
+ """Read the store. Never raises for a bad file: problems land in
+ Store.warnings and the offending rule is dropped, so one malformed rule
+ cannot stop the other nineteen from running."""
+ path = Path(path) if path else default_path()
+ store = Store()
+
+ if not path.exists():
+ store.missing = True
+ return store
+
+ try:
+ raw = json.loads(path.read_text())
+ except (json.JSONDecodeError, OSError) as exc:
+ store.warnings.append(f"{path}: cannot read: {exc}")
+ store.failed = True
+ return store
+
+ if not isinstance(raw, dict):
+ store.warnings.append(f"{path}: top level is not an object")
+ store.failed = True
+ return store
+
+ version = raw.get("version", FORMAT_VERSION)
+ if version != FORMAT_VERSION:
+ store.warnings.append(
+ f"{path}: format version {version} is newer than this tool "
+ f"understands ({FORMAT_VERSION}); refusing to guess")
+ store.failed = True
+ return store
+
+ store.unknown = {k: v for k, v in raw.items()
+ if k not in ("version", "rules")}
+
+ seen = set()
+ for index, obj in enumerate(raw.get("rules", [])):
+ rule = _parse_rule(obj, index, seen, store.warnings)
+ if rule is not None:
+ seen.add(rule.id)
+ store.rules.append(rule)
+
+ return store
+
+
+def scoped_query(rule, scope):
+ """The rule's query narrowed by `scope`, or the bare query when scope is
+ empty.
+
+ The parentheses are load-bearing. notmuch's `and` binds tighter than
+ `or`, so `tag:new and a or b` means `(tag:new and a) or b`: a rule that
+ is a disjunction of senders would escape its scope and match the whole
+ corpus. Do not remove them, and do not build this string anywhere else.
+ """
+ if not scope:
+ return rule.query
+ return f"{scope} and ({rule.query})"
+
+
+def tag_arguments(rule):
+ """The +tag/-tag arguments for `notmuch tag`, adds before removes."""
+ return [f"+{t}" for t in rule.add] + [f"-{t}" for t in rule.remove]
+
+
+def save(store, path=None):
+ """Write the store atomically: a temp file in the same directory, then
+ rename. Rename within a filesystem is atomic, so a concurrent reader sees
+ either the old file or the new one and never a partial write.
+
+ There is no locking. Last writer wins on a true collision, which is
+ accepted for a single-user setup; the failure that would actually hurt is
+ a truncated read by the hook, and rename eliminates it.
+ """
+ path = Path(path) if path else default_path()
+ path.parent.mkdir(parents=True, exist_ok=True)
+
+ payload = dict(store.unknown)
+ payload["version"] = FORMAT_VERSION
+ payload["rules"] = [_rule_to_dict(r) for r in store.rules]
+
+ # delete=False plus an explicit replace: NamedTemporaryFile would unlink
+ # the file on close, and the rename is the whole point.
+ handle = tempfile.NamedTemporaryFile(
+ mode="w", dir=path.parent, prefix=".rules-", suffix=".tmp",
+ delete=False)
+ try:
+ with handle:
+ json.dump(payload, handle, indent=2, ensure_ascii=False)
+ handle.write("\n")
+ handle.flush()
+ os.fsync(handle.fileno())
+ os.replace(handle.name, path)
+ except BaseException:
+ # A failed write must not leave the temp file beside the real one.
+ try:
+ os.unlink(handle.name)
+ except OSError:
+ pass
+ raise
+
+
+def _rule_to_dict(rule):
+ """Known fields first in a stable order, then anything this version did
+ not understand. Stable ordering keeps a diff of this file readable."""
+ out = {
+ "id": rule.id,
+ "stage": rule.stage,
+ "enabled": rule.enabled,
+ "add": list(rule.add),
+ "remove": list(rule.remove),
+ "query": rule.query,
+ "note": rule.note,
+ }
+ out.update(rule.unknown)
+ return out
+
+
+def ordered(rules):
+ """Enabled rules in execution order: by stage ascending, ties by position.
+
+ `sorted` is stable, so sorting on stage alone preserves file order within
+ a stage. That is the tie-break the format promises, and it is why this
+ does not sort on (stage, id): an id-sorted tie would reorder rules a user
+ deliberately sequenced.
+ """
+ return sorted([r for r in rules if r.enabled], key=lambda r: r.stage)
+
+
+def _parse_rule(obj, index, seen, warnings):
+ """One rule, or None with a warning appended. `index` names the rule when
+ it has no usable id of its own."""
+ where = f"rule #{index + 1}"
+
+ if not isinstance(obj, dict):
+ warnings.append(f"{where}: not an object; dropped")
+ return None
+
+ rule_id = obj.get("id", "")
+ if not isinstance(rule_id, str) or not ID_RE.match(rule_id):
+ warnings.append(
+ f"{where}: id '{rule_id}' is missing or not lowercase "
+ f"letters, digits and dashes; dropped")
+ return None
+
+ if rule_id in seen:
+ warnings.append(f"rule '{rule_id}': duplicate id; keeping the first")
+ return None
+
+ query = obj.get("query", "")
+ if not isinstance(query, str) or not query.strip():
+ warnings.append(f"rule '{rule_id}': no query; dropped")
+ return None
+
+ add = [t for t in obj.get("add", []) if isinstance(t, str) and t.strip()]
+ remove = [t for t in obj.get("remove", []) if isinstance(t, str) and t.strip()]
+ if not add and not remove:
+ warnings.append(
+ f"rule '{rule_id}': adds and removes nothing; dropped")
+ return None
+
+ try:
+ stage = int(obj.get("stage", DEFAULT_STAGE))
+ except (TypeError, ValueError):
+ warnings.append(
+ f"rule '{rule_id}': stage '{obj.get('stage')}' is not a "
+ f"number; using {DEFAULT_STAGE}")
+ stage = DEFAULT_STAGE
+
+ return Rule(
+ id=rule_id,
+ query=query,
+ add=add,
+ remove=remove,
+ stage=stage,
+ enabled=bool(obj.get("enabled", True)),
+ note=obj.get("note", "") if isinstance(obj.get("note", ""), str) else "",
+ unknown={k: v for k, v in obj.items() if k not in KNOWN_KEYS},
+ )
diff --git a/assets/hooks/post-new b/assets/hooks/post-new
new file mode 100755
index 0000000..5102103
--- /dev/null
+++ b/assets/hooks/post-new
@@ -0,0 +1,188 @@
+#!/usr/bin/env python3
+#
+# 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.
+"""notmuch post-new hook: auto-tag incoming mail from the shared rule store.
+
+Runs after every `notmuch new`. Reads ~/.config/mailrules/rules.json and
+applies each enabled rule scoped to `tag:new`, in stage order, then consumes
+the `tag:new` marker.
+
+Rules may add any tag and remove most, but this hook REFUSES to remove `unread`
+or `inbox` unattended and skips any rule that asks: see PROTECTED_REMOVALS
+below for why, and for the conditions under which that restriction should be
+lifted. It is expected to be relaxed once there is a story for confirming such
+a rule before it runs.
+
+Requires `new` in [new] tags= in ~/.notmuch-config. Without it every scoped
+query matches nothing and this silently no-ops.
+
+Install: copy to <database.path>/.notmuch/hooks/post-new, with mailrules.py
+importable (same directory, or on PYTHONPATH).
+"""
+
+import subprocess
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+
+import mailrules
+import qtmaildirconf
+
+SCOPE = "tag:new"
+
+# Tags this hook refuses to REMOVE, whatever a rule says.
+#
+# maildir.synchronize_flags is true, so `unread` is not just an index entry:
+# removing it rewrites Maildir filenames and propagates to the server on the
+# next mbsync. `inbox` is what keeps mail visible at all. Unattended, on every
+# sync, either one silently reorganizes a mailbox in a way that is tedious to
+# undo and reaches other clients before anyone notices.
+#
+# Adding these tags is untouched, and so is removing anything else: a rule may
+# still strip `promo` or any tag of its own making.
+#
+# DELIBERATELY CONSERVATIVE, AND EXPECTED TO BE RELAXED. The rules in use today
+# only add tags, so this forbids nothing anyone is doing. It exists because the
+# hook runs unattended and a mistake here is expensive, not because removing
+# `unread` is wrong in principle: an "archive anything in notify/* older than
+# 90 days" rule is a reasonable thing to want and would need this list revised.
+# When that day comes, the question to answer first is what confirms the rule
+# before it runs, not whether the guard is annoying.
+# NOT the same list as mailctl.py's PROTECTED_REMOVALS, and the two must not be
+# merged. That one is `{inbox}` and is a GATE: a human can override it with
+# --confirm-destructive. This one is `{unread, inbox}` and is a REFUSAL, because
+# there is no human present to confirm anything when cron runs a sync.
+PROTECTED_REMOVALS = frozenset({"unread", "inbox"})
+
+
+def log(message):
+ print(f"post-new: {message}", file=sys.stderr)
+
+
+def strip_inbox_from_sent(run):
+ """Take `inbox` off mail the user SENT, and nothing else.
+
+ `notmuch new` applies new.tags to every file it indexes, and it cannot
+ tell an arrival from the copy qtmaildir files into a sent folder after a
+ send. The result is sent mail carrying `inbox`, which puts it in an inbox
+ view it never arrived in and in any hand-typed `tag:inbox` search.
+
+ This is NOT a relaxation of PROTECTED_REMOVALS below, and the difference
+ is the whole reason it can run unattended. That guard is about a RULE
+ removing `inbox` from mail whose provenance the hook cannot judge. Here
+ the provenance is the file's own path: a message inside a configured sent
+ folder is one this system sent, and `inbox` was never true of it. Nothing
+ the user could act on is being hidden.
+
+ Only `inbox`. `unread` is untouched, because maildir.synchronize_flags is
+ true and removing it rewrites Maildir filenames, which reaches the server
+ on the next mbsync.
+
+ Scoped to tag:new like every rule, so a sync never rewrites tags across
+ the whole corpus. Mail already indexed keeps whatever it has.
+ """
+ folders = qtmaildirconf.sent_folders()
+ if not folders:
+ # No config, or no account keeping sent mail locally. Nothing to
+ # protect, and this must NOT fall through to an empty query: notmuch
+ # reads that as "match everything", which would strip `inbox` from
+ # every newly indexed message on the system.
+ return True
+
+ query = f"{SCOPE} and ({qtmaildirconf.sent_query(folders)})"
+ if not run(["-inbox"], query):
+ return False
+
+ log(f"sent-folder carve-out applied over {len(folders)} folder(s)")
+ return True
+
+
+def protected_removals(rule):
+ """The protected tags this rule would remove, if any."""
+ return sorted(PROTECTED_REMOVALS.intersection(rule.remove))
+
+
+def run_tag(arguments, query):
+ result = subprocess.run(["notmuch", "tag"] + arguments + ["--", query],
+ capture_output=True, text=True)
+ if result.returncode != 0:
+ log(f"notmuch tag failed: {result.stderr.strip()}")
+ return False
+ return True
+
+
+def main():
+ store = mailrules.load()
+
+ # A file that will not load must NOT reach the consumer below. If the
+ # marker were cleared while the rules did not run, that mail could never
+ # be tagged by these rules again: the failure is silent, permanent, and
+ # invisible until someone notices a gap months later. Leaving tag:new in
+ # place makes the next successful run catch up instead.
+ if store.failed:
+ for warning in store.warnings:
+ log(warning)
+ log("rules did not load; leaving tag:new in place")
+ return 1
+
+ if store.missing:
+ log("no rules file; nothing to do")
+ return 0
+
+ # A dropped rule is not fatal, but it must be visible: this goes to the
+ # sync log, which is where someone looks when a tag stops appearing.
+ for warning in store.warnings:
+ log(warning)
+
+ applied = 0
+ for rule in mailrules.ordered(store.rules):
+ # Skip the rule, do not abort the run. A single over-reaching rule
+ # must not cost the tagging every other rule would have done, and
+ # aborting here would also leave tag:new set forever: the rule would
+ # be refused again on every subsequent sync and the marker would never
+ # be consumed.
+ refused = protected_removals(rule)
+ if refused:
+ log(f"rule '{rule.id}' would remove {', '.join(refused)}; "
+ f"skipped, this hook does not remove those unattended")
+ continue
+
+ query = mailrules.scoped_query(rule, SCOPE)
+ if not run_tag(mailrules.tag_arguments(rule), query):
+ log(f"rule '{rule.id}' failed; leaving tag:new in place")
+ return 1
+ applied += 1
+
+ # AFTER the rules and BEFORE the marker is consumed. After, so a rule can
+ # still see its own sent mail with `inbox` on it and match the way it
+ # always did; before, because the marker is what scopes this to newly
+ # indexed mail and consuming it first would leave nothing to match.
+ if not strip_inbox_from_sent(run_tag):
+ log("sent-folder carve-out failed; leaving tag:new in place")
+ return 1
+
+ # Only after every rule succeeded. A failure part way through leaves the
+ # marker set, so re-running the hook is safe and finishes the work.
+ if not run_tag(["-new"], SCOPE):
+ return 1
+
+ log(f"applied {applied} rule(s)")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/assets/hooks/qtmaildirconf.py b/assets/hooks/qtmaildirconf.py
new file mode 100755
index 0000000..e709a28
--- /dev/null
+++ b/assets/hooks/qtmaildirconf.py
@@ -0,0 +1,134 @@
+#!/usr/bin/env python3
+#
+# 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.
+"""Reads the account layout out of qtmaildir.conf, for the post-new hook.
+
+Only the sent folders are read, and only so the hook can tell mail the user
+SENT from mail that arrived. Everything else in that file belongs to the
+application.
+
+Stdlib only: this is imported by a notmuch hook that runs on every sync.
+
+The file is written by QSettings rather than by configparser, and the two
+disagree in one place that matters here. QSettings treats `/` in a section
+name as a group separator, so accounts are `[account.<key>]` with a DOT, and
+that key may itself contain dots (`[account.provider.name]`). The account key
+is therefore everything after the FIRST dot, never a split on the last one.
+"""
+
+import configparser
+from pathlib import Path
+
+ACCOUNT_PREFIX = "account."
+
+
+def default_path():
+ """~/.config/qtmaildir/qtmaildir.conf, honouring XDG_CONFIG_HOME.
+
+ Read through the environment rather than hardcoded so a test can point
+ at a throwaway config, which is how the hook's own tests reach it.
+ """
+ import os
+ base = os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config")
+ return Path(base) / "qtmaildir" / "qtmaildir.conf"
+
+
+def _accounts(path):
+ """Every `[account.*]` section as a dict, or nothing at all.
+
+ A file that will not parse yields NO accounts rather than raising. The
+ caller is a hook running after `notmuch new` has already indexed the
+ mail: failing the sync over a malformed application config is worse than
+ not protecting sent mail for one cycle, and the hook logs the miss.
+ """
+ parser = configparser.ConfigParser(
+ # QSettings writes `;` comments, and `#` appears inside values (a
+ # colour is `#2f6fa8`), so `#` must NOT introduce a comment.
+ comment_prefixes=(";",),
+ # A value may contain `%` and `$`; neither is an interpolation here.
+ interpolation=None,
+ # `[Gmail]/Posta inviata` is a legal value. Nothing in this file
+ # relies on duplicate keys, but tolerating them beats raising.
+ strict=False)
+ try:
+ # Explicit UTF-8: QSettings writes it, and the C locale would
+ # otherwise decide.
+ with open(path, encoding="utf-8") as handle:
+ parser.read_file(handle)
+ except (OSError, UnicodeDecodeError, configparser.Error):
+ return []
+
+ return [(name[len(ACCOUNT_PREFIX):], parser[name])
+ for name in parser.sections()
+ if name.startswith(ACCOUNT_PREFIX)]
+
+
+# Folders mail does not ARRIVE in: this system put the message there itself.
+#
+# Trash is deliberately absent. qtmaildir's own Delete leaves `inbox` on a
+# trashed message so Restore can put it back where it came from, and stripping
+# it here would fight that.
+NOT_ARRIVALS = ("sent", "drafts")
+
+
+def sent_folders(path=None):
+ """Every folder mail does not arrive in, relative to the mail root.
+
+ An account contributes nothing unless it names a maildir: a bare `Sent`
+ would match every account's folder of that name at once. Each of the keys
+ in NOT_ARRIVALS is optional on its own, since an account may keep no sent
+ mail or no drafts locally.
+ """
+ if path is None:
+ path = default_path()
+
+ folders = []
+ for _key, section in _accounts(path):
+ maildir = section.get("maildir", "").strip()
+ if not maildir:
+ continue
+ for key in NOT_ARRIVALS:
+ folder = section.get(key, "").strip()
+ if folder:
+ folders.append(f"{maildir}/{folder}")
+ return folders
+
+
+def sent_query(folders):
+ """A notmuch query matching everything inside the given folders.
+
+ Empty for an empty list, and the caller MUST check: an empty query means
+ "match everything" to notmuch, so handing this straight to a tag command
+ would treat the whole corpus as sent mail.
+
+ `path:` is hierarchical, so `<folder>/**` covers `cur/` and `new/` and
+ any nesting a provider invents underneath.
+ """
+ if not folders:
+ return ""
+
+ terms = [f'path:"{_quote(folder)}/**"' for folder in folders]
+ return " or ".join(terms)
+
+
+def _quote(value):
+ """Escape a folder name for a double-quoted notmuch term.
+
+ Backslashes BEFORE quotes: the other order escapes the backslashes just
+ added. Same rule as SearchTerm::quote() in the application, and the same
+ reason.
+ """
+ return value.replace("\\", "\\\\").replace('"', '\\"')
diff --git a/assets/hooks/test_mailrules.py b/assets/hooks/test_mailrules.py
new file mode 100755
index 0000000..b1f31c9
--- /dev/null
+++ b/assets/hooks/test_mailrules.py
@@ -0,0 +1,278 @@
+#!/usr/bin/env python3
+#
+# 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.
+"""Self-checks for mailrules.py, the shared tagging-rule store.
+
+The risk in this file is the format, not the notmuch calls: a rule that
+silently loses a field on save, or one that sorts into the wrong stage,
+mis-tags real mail on the next sync and does it quietly.
+
+Run: ./test_mailrules.py
+"""
+
+import json
+import tempfile
+from pathlib import Path
+
+import mailrules
+
+
+def write_rules(tmp, payload):
+ path = Path(tmp) / "rules.json"
+ path.write_text(json.dumps(payload))
+ return path
+
+
+def test_loads_a_rule():
+ with tempfile.TemporaryDirectory() as tmp:
+ path = write_rules(tmp, {
+ "version": 1,
+ "rules": [
+ {
+ "id": "notify-forge",
+ "stage": 50,
+ "enabled": True,
+ "add": ["notify/forge"],
+ "remove": [],
+ "query": "from:notifications@example.com",
+ "note": "All repositories, not one project.",
+ }
+ ],
+ })
+ store = mailrules.load(path)
+ assert store.warnings == [], store.warnings
+ assert len(store.rules) == 1
+ rule = store.rules[0]
+ assert rule.id == "notify-forge"
+ assert rule.stage == 50
+ assert rule.enabled is True
+ assert rule.add == ["notify/forge"]
+ assert rule.remove == []
+ assert rule.query == "from:notifications@example.com"
+ assert rule.note == "All repositories, not one project."
+
+
+def test_defaults_are_applied():
+ """stage, enabled, remove and note are all optional in the file."""
+ with tempfile.TemporaryDirectory() as tmp:
+ path = write_rules(tmp, {
+ "version": 1,
+ "rules": [{"id": "minimal", "add": ["x"],
+ "query": "from:someone@example.com"}],
+ })
+ store = mailrules.load(path)
+ assert store.warnings == [], store.warnings
+ rule = store.rules[0]
+ assert rule.stage == 50
+ assert rule.enabled is True
+ assert rule.remove == []
+ assert rule.note == ""
+
+
+def test_a_bad_rule_is_dropped_and_the_rest_survive():
+ """One malformed rule must not stop the others. The hook runs every ten
+ minutes on real mail; losing all tagging because of one typo is worse
+ than losing one rule."""
+ with tempfile.TemporaryDirectory() as tmp:
+ path = write_rules(tmp, {
+ "version": 1,
+ "rules": [
+ {"id": "good", "add": ["x"], "query": "from:a@example.com"},
+ {"id": "no-query", "add": ["y"]},
+ {"id": "no-tags", "query": "from:b@example.com"},
+ {"id": "bad id!", "add": ["z"], "query": "from:c@example.com"},
+ {"add": ["w"], "query": "from:d@example.com"},
+ ],
+ })
+ store = mailrules.load(path)
+ assert [r.id for r in store.rules] == ["good"]
+ assert len(store.warnings) == 4, store.warnings
+ joined = " ".join(store.warnings)
+ assert "no-query" in joined
+ assert "no-tags" in joined
+ assert "bad id!" in joined
+
+
+def test_duplicate_ids_keep_the_first():
+ with tempfile.TemporaryDirectory() as tmp:
+ path = write_rules(tmp, {
+ "version": 1,
+ "rules": [
+ {"id": "dup", "add": ["first"], "query": "from:a@example.com"},
+ {"id": "dup", "add": ["second"], "query": "from:b@example.com"},
+ ],
+ })
+ store = mailrules.load(path)
+ assert len(store.rules) == 1
+ assert store.rules[0].add == ["first"]
+ assert any("dup" in w for w in store.warnings)
+
+
+def test_a_missing_file_is_empty_not_an_error():
+ """qtmaildir must open on a machine that has never written this file."""
+ with tempfile.TemporaryDirectory() as tmp:
+ store = mailrules.load(Path(tmp) / "absent.json")
+ assert store.rules == []
+ assert store.warnings == []
+ assert store.missing is True
+
+
+def test_unparseable_json_warns_and_yields_no_rules():
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "rules.json"
+ path.write_text("{not json")
+ store = mailrules.load(path)
+ assert store.rules == []
+ assert len(store.warnings) == 1
+ assert store.failed is True
+
+
+def test_a_newer_format_version_is_refused():
+ """Guessing at semantics a later version defined is how a rule silently
+ changes meaning. Refuse instead."""
+ with tempfile.TemporaryDirectory() as tmp:
+ path = write_rules(tmp, {
+ "version": 2,
+ "rules": [{"id": "x", "add": ["a"], "query": "from:a@example.com"}],
+ })
+ store = mailrules.load(path)
+ assert store.rules == []
+ assert store.failed is True
+ assert any("version" in w for w in store.warnings)
+
+
+def test_ordered_sorts_by_stage_then_file_position():
+ """Account tags must run before topic rules. Ties keep file order, so
+ the file still reads as a sequence."""
+ with tempfile.TemporaryDirectory() as tmp:
+ path = write_rules(tmp, {
+ "version": 1,
+ "rules": [
+ {"id": "topic-b", "stage": 50, "add": ["b"],
+ "query": "from:b@example.com"},
+ {"id": "account", "stage": 10, "add": ["acct"],
+ "query": "path:\"work/**\""},
+ {"id": "topic-a", "stage": 50, "add": ["a"],
+ "query": "from:a@example.com"},
+ ],
+ })
+ store = mailrules.load(path)
+ assert [r.id for r in mailrules.ordered(store.rules)] == [
+ "account", "topic-b", "topic-a"]
+
+
+def test_ordered_skips_disabled_rules():
+ with tempfile.TemporaryDirectory() as tmp:
+ path = write_rules(tmp, {
+ "version": 1,
+ "rules": [
+ {"id": "on", "add": ["a"], "query": "from:a@example.com"},
+ {"id": "off", "add": ["b"], "query": "from:b@example.com",
+ "enabled": False},
+ ],
+ })
+ store = mailrules.load(path)
+ assert [r.id for r in mailrules.ordered(store.rules)] == ["on"]
+ # The disabled rule is still LOADED, so a UI can show and re-enable it.
+ assert [r.id for r in store.rules] == ["on", "off"]
+
+
+def test_save_round_trips_unknown_fields():
+ """The neutrality guarantee. If this tool strips a field qtmaildir
+ added, the file is this tool's file that qtmaildir may read."""
+ with tempfile.TemporaryDirectory() as tmp:
+ path = write_rules(tmp, {
+ "version": 1,
+ "future_top_level": {"set_by": "another tool"},
+ "rules": [{
+ "id": "keeper",
+ "add": ["x"],
+ "query": "from:a@example.com",
+ "future_field": [1, 2, 3],
+ }],
+ })
+ store = mailrules.load(path)
+ assert store.rules[0].unknown == {"future_field": [1, 2, 3]}
+
+ mailrules.save(store, path)
+
+ raw = json.loads(path.read_text())
+ assert raw["future_top_level"] == {"set_by": "another tool"}
+ assert raw["rules"][0]["future_field"] == [1, 2, 3]
+ assert raw["rules"][0]["id"] == "keeper"
+ assert raw["version"] == 1
+
+
+def test_save_is_atomic():
+ """A reader must never see a half-written file: the hook runs every ten
+ minutes and a truncated read would be a failed sync."""
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "rules.json"
+ store = mailrules.Store(rules=[
+ mailrules.Rule(id="a", query="from:a@example.com", add=["x"])])
+ mailrules.save(store, path)
+ # The temp file the write went through must not be left behind.
+ assert [p.name for p in Path(tmp).iterdir()] == ["rules.json"]
+ assert json.loads(path.read_text())["rules"][0]["id"] == "a"
+
+
+def test_save_creates_the_directory():
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "nested" / "rules.json"
+ mailrules.save(mailrules.Store(), path)
+ assert path.exists()
+ assert json.loads(path.read_text()) == {"version": 1, "rules": []}
+
+
+def test_scoped_query_parenthesises_the_rule():
+ """Without the parentheses `tag:new and a or b` binds as
+ `(tag:new and a) or b`, and the rule matches every message in the corpus
+ satisfying b rather than only new arrivals. Several real rules are a
+ disjunction of senders, so this is the difference between tagging four
+ messages and tagging four thousand."""
+ rule = mailrules.Rule(
+ id="disjunction",
+ query="from:a@example.com or from:b@example.com",
+ add=["promo"])
+ assert mailrules.scoped_query(rule, "tag:new") == (
+ "tag:new and (from:a@example.com or from:b@example.com)")
+
+
+def test_scoped_query_with_no_scope_is_the_bare_query():
+ """A dry run counts against the whole corpus, which is what makes the
+ same rule answer 'what would this tag on arrival' and 'what does this
+ match in all my mail'."""
+ rule = mailrules.Rule(id="x", query="from:a@example.com", add=["y"])
+ assert mailrules.scoped_query(rule, None) == "from:a@example.com"
+ assert mailrules.scoped_query(rule, "") == "from:a@example.com"
+
+
+def test_tag_arguments():
+ rule = mailrules.Rule(id="x", query="from:a@example.com",
+ add=["one", "two"], remove=["three"])
+ assert mailrules.tag_arguments(rule) == ["+one", "+two", "-three"]
+
+
+def run_all():
+ for name, fn in sorted(globals().items()):
+ if name.startswith("test_") and callable(fn):
+ fn()
+ print(f"ok {name}")
+
+
+if __name__ == "__main__":
+ run_all()
+ print("\nall passed")
diff --git a/assets/hooks/test_post_new.py b/assets/hooks/test_post_new.py
new file mode 100755
index 0000000..a0228aa
--- /dev/null
+++ b/assets/hooks/test_post_new.py
@@ -0,0 +1,340 @@
+#!/usr/bin/env python3
+#
+# 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.
+"""End-to-end checks for the post-new hook against a throwaway notmuch
+database. Nothing here touches the user's real mail: NOTMUCH_CONFIG points at
+a generated maildir under a temp directory.
+
+The properties worth proving are the ones that cannot be unit-tested from
+mailrules.py alone:
+
+ - a rule actually tags the mail its query matches, and only that mail
+ - the tag:new marker is consumed on success
+ - the marker SURVIVES when a rule fails, so a re-run catches up
+ - a rule removing a protected tag is skipped whole, and the run continues
+
+Run: ./test_post_new.py (requires notmuch on PATH)
+"""
+
+import json
+import os
+import subprocess
+import tempfile
+from pathlib import Path
+
+HOOK = Path(__file__).resolve().parent / "post-new"
+
+
+def make_message(maildir, name, sender, subject):
+ path = maildir / "new" / name
+ path.write_text(
+ f"From: {sender}\n"
+ f"To: you@example.org\n"
+ f"Subject: {subject}\n"
+ f"Message-Id: <{name}@example.org>\n"
+ f"Date: Mon, 11 Aug 2026 10:00:00 +0000\n"
+ f"\nbody\n")
+
+
+def setup_database(tmp):
+ """A maildir with three messages, indexed, every message carrying the
+ `new` marker the rules key off."""
+ maildir = Path(tmp) / "Mail"
+ for sub in ("new", "cur", "tmp"):
+ (maildir / sub).mkdir(parents=True)
+
+ make_message(maildir, "one", "notifications@example.com", "a notification")
+ make_message(maildir, "two", "friend@example.org", "a real message")
+ make_message(maildir, "three", "promo@example.net", "an advertisement")
+
+ config = Path(tmp) / "notmuch-config"
+ config.write_text(
+ f"[database]\npath={maildir}\n\n"
+ f"[new]\ntags=new;unread;inbox\n\n"
+ f"[user]\nname=Test\nprimary_email=you@example.org\n")
+
+ env = dict(os.environ)
+ env["NOTMUCH_CONFIG"] = str(config)
+ env["XDG_CONFIG_HOME"] = str(Path(tmp) / "config")
+ subprocess.run(["notmuch", "new"], env=env, capture_output=True, check=True)
+ return env
+
+
+def write_rules(env, rules):
+ path = Path(env["XDG_CONFIG_HOME"]) / "mailrules" / "rules.json"
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(json.dumps({"version": 1, "rules": rules}))
+
+
+def count(env, query):
+ out = subprocess.run(["notmuch", "count", query], env=env,
+ capture_output=True, text=True, check=True)
+ return int(out.stdout.strip())
+
+
+def test_a_rule_tags_only_what_it_matches():
+ with tempfile.TemporaryDirectory() as tmp:
+ env = setup_database(tmp)
+ write_rules(env, [{
+ "id": "notify",
+ "add": ["notify/forge"],
+ "query": "from:notifications@example.com",
+ }])
+ assert count(env, "tag:new") == 3
+
+ result = subprocess.run([str(HOOK)], env=env, capture_output=True,
+ text=True)
+ assert result.returncode == 0, result.stderr
+
+ assert count(env, "tag:notify/forge") == 1
+ assert count(env, "tag:notify/forge and from:friend@example.org") == 0
+ # The marker is consumed, so the next sync's rules see only new mail.
+ assert count(env, "tag:new") == 0
+
+
+def test_stage_order_is_honoured():
+ with tempfile.TemporaryDirectory() as tmp:
+ env = setup_database(tmp)
+ write_rules(env, [
+ {"id": "late", "stage": 50, "add": ["second"],
+ "query": "tag:first"},
+ {"id": "early", "stage": 10, "add": ["first"],
+ "query": "from:notifications@example.com"},
+ ])
+ subprocess.run([str(HOOK)], env=env, capture_output=True, check=True)
+ # `late` matches only what `early` tagged, so a wrong order gives 0.
+ assert count(env, "tag:second") == 1
+
+
+def test_a_failing_rule_leaves_the_marker_in_place():
+ """The property that makes a re-run safe. An invalid query fails the
+ notmuch call, and tag:new must survive so the next run catches up.
+
+ The query has to be one notmuch genuinely rejects, which is a narrower
+ set than it looks: notmuch 0.39's parser accepts unbalanced parentheses
+ and bare punctuation without complaint, tags nothing, and exits 0. A
+ malformed date range is rejected by the date parser and does exit
+ non-zero, which is why the fixture uses one.
+ """
+ with tempfile.TemporaryDirectory() as tmp:
+ env = setup_database(tmp)
+ write_rules(env, [{
+ "id": "broken",
+ "add": ["x"],
+ "query": "date:zzz..zzz",
+ }])
+ result = subprocess.run([str(HOOK)], env=env, capture_output=True,
+ text=True)
+ assert result.returncode == 1
+ assert count(env, "tag:new") == 3
+
+
+def test_a_disjunction_stays_inside_its_scope():
+ """The parenthesisation guard, end to end. Both senders are already
+ indexed and out of tag:new after a first run; a rule that escaped its
+ scope would tag them anyway."""
+ with tempfile.TemporaryDirectory() as tmp:
+ env = setup_database(tmp)
+ write_rules(env, [{"id": "noop", "add": ["pass-one"],
+ "query": "from:nobody@example.invalid"}])
+ subprocess.run([str(HOOK)], env=env, capture_output=True, check=True)
+ assert count(env, "tag:new") == 0
+
+ write_rules(env, [{
+ "id": "disjunction",
+ "add": ["promo"],
+ "query": "from:friend@example.org or from:promo@example.net",
+ }])
+ subprocess.run([str(HOOK)], env=env, capture_output=True, check=True)
+ # Nothing carries tag:new any more, so a correctly scoped rule tags
+ # nothing. Unparenthesised, the `or` branch would tag one message.
+ assert count(env, "tag:promo") == 0
+
+
+def test_a_protected_removal_is_skipped_whole_and_the_run_continues():
+ """The PROTECTED_REMOVALS guard, end to end.
+
+ Four assertions in one run, because three of them pass against a guard
+ that is broken in a different way. A guard that skipped only the removal
+ would still apply the rule's adds; a guard that aborted the run would
+ starve every later rule; and a guard that aborted before the consumer
+ would strand tag:new, so every future sync would refuse the same rule
+ again and nothing would ever be tagged after it.
+ """
+ with tempfile.TemporaryDirectory() as tmp:
+ env = setup_database(tmp)
+ write_rules(env, [
+ {"id": "over-reaching", "stage": 10,
+ "add": ["archived"], "remove": ["unread", "inbox"],
+ "query": "from:notifications@example.com"},
+ {"id": "well-behaved", "stage": 20, "add": ["promo"],
+ "query": "from:promo@example.net"},
+ ])
+ assert count(env, "tag:unread") == 3
+ assert count(env, "tag:inbox") == 3
+
+ result = subprocess.run([str(HOOK)], env=env, capture_output=True,
+ text=True)
+ assert result.returncode == 0, result.stderr
+ assert "over-reaching" in result.stderr, result.stderr
+
+ # 1. the protected tags survive on the message the rule matched
+ assert count(env, "tag:unread and from:notifications@example.com") == 1
+ assert count(env, "tag:inbox and from:notifications@example.com") == 1
+ # 2. the rule is skipped ENTIRELY, so its adds never land either
+ assert count(env, "tag:archived") == 0
+ # 3. a later, well-behaved rule still runs
+ assert count(env, "tag:promo") == 1
+ # 4. the marker is still consumed, so the next sync is not stuck
+ assert count(env, "tag:new") == 0
+
+
+def setup_accounts(tmp, sent_config=True):
+ """A maildir laid out as qtmaildir configures it: two accounts, each with
+ an Inbox and a Sent folder, one message in each.
+
+ Separate from setup_database() because the sent carve-out is the only
+ thing that cares where a file sits. The folder names are the awkward
+ ones deliberately: a bracketed, spaced provider folder is what the real
+ config carries, and a flat `Sent` is what the other half carries.
+ """
+ root = Path(tmp) / "Mail"
+ folders = {
+ "one": ("acct-one/Inbox", "acct-one/Sent"),
+ "two": ("acct-two/[Provider]/Posta inviata",
+ "acct-two/[Provider]/Posta inviata"),
+ }
+ for sub in ("acct-one/Inbox", "acct-one/Sent",
+ "acct-two/Inbox", "acct-two/[Provider]/Posta inviata"):
+ for part in ("new", "cur", "tmp"):
+ (root / sub / part).mkdir(parents=True)
+
+ make_message(root / "acct-one/Inbox", "arrived-one",
+ "friend@example.org", "an arrival")
+ make_message(root / "acct-one/Sent", "sent-one",
+ "you@example.org", "something sent")
+ make_message(root / "acct-two/Inbox", "arrived-two",
+ "friend@example.org", "another arrival")
+ make_message(root / "acct-two/[Provider]/Posta inviata", "sent-two",
+ "you@example.org", "something else sent")
+
+ config = Path(tmp) / "notmuch-config"
+ config.write_text(
+ f"[database]\npath={root}\n\n"
+ f"[new]\ntags=new;unread;inbox\n\n"
+ f"[user]\nname=Test\nprimary_email=you@example.org\n")
+
+ env = dict(os.environ)
+ env["NOTMUCH_CONFIG"] = str(config)
+ env["XDG_CONFIG_HOME"] = str(Path(tmp) / "config")
+
+ if sent_config:
+ conf = Path(env["XDG_CONFIG_HOME"]) / "qtmaildir" / "qtmaildir.conf"
+ conf.parent.mkdir(parents=True, exist_ok=True)
+ conf.write_text(
+ "[account.one]\nmaildir = acct-one\nsent = Sent\n"
+ "[account.two]\nmaildir = acct-two\n"
+ "sent = [Provider]/Posta inviata\n")
+
+ subprocess.run(["notmuch", "new"], env=env, capture_output=True,
+ check=True)
+ return env
+
+
+def test_sent_mail_does_not_keep_the_inbox_tag():
+ """The carve-out. notmuch's new.tags applies `inbox` to every file it
+ indexes, including the copy the composer files into a sent folder, so
+ mail the user SENT shows up in an inbox view it never arrived in.
+
+ Both accounts are asserted, because the folder shapes differ and a
+ reader that mishandles the bracketed, spaced one would still pass on the
+ flat `Sent`.
+ """
+ with tempfile.TemporaryDirectory() as tmp:
+ env = setup_accounts(tmp)
+ write_rules(env, [])
+ assert count(env, "tag:inbox") == 4
+
+ result = subprocess.run([str(HOOK)], env=env, capture_output=True,
+ text=True)
+ assert result.returncode == 0, result.stderr
+
+ # The two sent copies lose it...
+ assert count(env, 'tag:inbox and path:"acct-one/Sent/**"') == 0
+ assert count(
+ env,
+ 'tag:inbox and path:"acct-two/[Provider]/Posta inviata/**"') == 0
+ # ...and the two arrivals keep it. This is the half that fails if the
+ # query is unscoped, which is the expensive mistake here.
+ assert count(env, "tag:inbox") == 2
+ assert count(env, 'tag:inbox and path:"acct-one/Inbox/**"') == 1
+ assert count(env, 'tag:inbox and path:"acct-two/Inbox/**"') == 1
+
+
+def test_sent_mail_keeps_every_other_tag():
+ """Only `inbox` is stripped. `unread` in particular must survive:
+ maildir.synchronize_flags is true, so removing it rewrites Maildir
+ filenames and reaches the server on the next mbsync.
+ """
+ with tempfile.TemporaryDirectory() as tmp:
+ env = setup_accounts(tmp)
+ write_rules(env, [])
+ subprocess.run([str(HOOK)], env=env, capture_output=True, check=True)
+ assert count(env, 'tag:unread and path:"acct-one/Sent/**"') == 1
+
+
+def test_the_carve_out_only_touches_newly_indexed_mail():
+ """Scoped to tag:new like every rule, so the hook never rewrites tags
+ across the whole corpus on a sync. A sent message whose `inbox` tag was
+ put back by hand stays that way until it is reindexed.
+ """
+ with tempfile.TemporaryDirectory() as tmp:
+ env = setup_accounts(tmp)
+ write_rules(env, [])
+ subprocess.run([str(HOOK)], env=env, capture_output=True, check=True)
+ assert count(env, 'tag:inbox and path:"acct-one/Sent/**"') == 0
+
+ subprocess.run(["notmuch", "tag", "+inbox", "--",
+ 'path:"acct-one/Sent/**"'], env=env, check=True)
+ subprocess.run([str(HOOK)], env=env, capture_output=True, check=True)
+ assert count(env, 'tag:inbox and path:"acct-one/Sent/**"') == 1
+
+
+def test_no_qtmaildir_config_leaves_every_tag_alone():
+ """The hook must run on a system with no qtmaildir config: it then
+ protects nothing rather than failing the sync, and above all does not
+ treat an empty folder list as "every path", which is what an empty
+ notmuch query means.
+ """
+ with tempfile.TemporaryDirectory() as tmp:
+ env = setup_accounts(tmp, sent_config=False)
+ write_rules(env, [])
+ result = subprocess.run([str(HOOK)], env=env, capture_output=True,
+ text=True)
+ assert result.returncode == 0, result.stderr
+ assert count(env, "tag:inbox") == 4
+
+
+def run_all():
+ for name, fn in sorted(globals().items()):
+ if name.startswith("test_") and callable(fn):
+ fn()
+ print(f"ok {name}")
+
+
+if __name__ == "__main__":
+ run_all()
+ print("\nall passed")
diff --git a/assets/hooks/test_qtmaildirconf.py b/assets/hooks/test_qtmaildirconf.py
new file mode 100755
index 0000000..c8aa78d
--- /dev/null
+++ b/assets/hooks/test_qtmaildirconf.py
@@ -0,0 +1,179 @@
+#!/usr/bin/env python3
+#
+# 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.
+"""Unit checks for the qtmaildir.conf reader the post-new hook uses to find
+the sent folders.
+
+The file is written by QSettings, not by configparser, so the cases that
+matter are the ones where the two disagree: a section name carrying a dot, a
+comment introduced by `;`, and a key present but empty.
+
+Run: ./test_qtmaildirconf.py
+"""
+
+import tempfile
+from pathlib import Path
+
+import qtmaildirconf
+
+
+def write_config(tmp, text):
+ path = Path(tmp) / "qtmaildir" / "qtmaildir.conf"
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(text)
+ return path
+
+
+def test_sent_folders_are_read_per_account():
+ with tempfile.TemporaryDirectory() as tmp:
+ path = write_config(tmp, "[account.work]\n"
+ "maildir = work\n"
+ "sent = Sent\n"
+ "trash = Trash\n")
+ assert qtmaildirconf.sent_folders(path) == ["work/Sent"]
+
+
+def test_drafts_are_excluded_alongside_sent():
+ """A draft never arrived either, so it must not carry `inbox`. Both keys
+ feed one list: the hook asks a single question, "is this a folder mail
+ arrives in", and sent and drafts answer it the same way.
+
+ Trash is deliberately NOT here. qtmaildir's own Delete leaves `inbox` on
+ a trashed message so Restore can put it back where it came from, and
+ stripping it here would fight that.
+ """
+ with tempfile.TemporaryDirectory() as tmp:
+ path = write_config(tmp, "[account.work]\n"
+ "maildir = work\n"
+ "sent = Sent\n"
+ "drafts = Drafts\n"
+ "trash = Trash\n")
+ assert qtmaildirconf.sent_folders(path) == ["work/Sent", "work/Drafts"]
+
+
+def test_an_account_with_only_drafts_still_contributes():
+ with tempfile.TemporaryDirectory() as tmp:
+ path = write_config(tmp, "[account.a]\nmaildir = a\ndrafts = Drafts\n")
+ assert qtmaildirconf.sent_folders(path) == ["a/Drafts"]
+
+
+def test_an_account_section_may_carry_a_dot():
+ """QSettings writes `[account.a.b]` for the key `a.b`, and the account
+ key is everything after the first dot. Splitting on the LAST dot names
+ an account that does not exist and finds no folder."""
+ with tempfile.TemporaryDirectory() as tmp:
+ path = write_config(tmp, "[account.provider.name]\n"
+ "maildir = provider-name\n"
+ "sent = Sent\n")
+ assert qtmaildirconf.sent_folders(path) == ["provider-name/Sent"]
+
+
+def test_a_folder_may_contain_spaces_and_brackets():
+ """`[Gmail]/Posta inviata` is a real folder name here. The brackets are
+ the provider's, not INI syntax, because they are in a VALUE."""
+ with tempfile.TemporaryDirectory() as tmp:
+ path = write_config(tmp, "[account.g]\n"
+ "maildir = gmail\n"
+ "sent = [Gmail]/Posta inviata\n")
+ assert qtmaildirconf.sent_folders(path) == [
+ "gmail/[Gmail]/Posta inviata"]
+
+
+def test_an_account_without_a_sent_key_contributes_nothing():
+ """`sent` is optional: an account may keep no sent mail locally. It must
+ not contribute an entry, since a bare `maildir/` prefix would match the
+ whole account."""
+ with tempfile.TemporaryDirectory() as tmp:
+ path = write_config(tmp, "[account.a]\nmaildir = a\ntrash = Trash\n"
+ "[account.b]\nmaildir = b\nsent = Sent\n")
+ assert qtmaildirconf.sent_folders(path) == ["b/Sent"]
+
+
+def test_an_empty_sent_value_contributes_nothing():
+ with tempfile.TemporaryDirectory() as tmp:
+ path = write_config(tmp, "[account.a]\nmaildir = a\nsent =\n")
+ assert qtmaildirconf.sent_folders(path) == []
+
+
+def test_an_account_without_a_maildir_contributes_nothing():
+ """Without the account's own subdirectory the folder cannot be located,
+ and a bare `Sent` would match every account's sent folder at once."""
+ with tempfile.TemporaryDirectory() as tmp:
+ path = write_config(tmp, "[account.a]\nsent = Sent\n")
+ assert qtmaildirconf.sent_folders(path) == []
+
+
+def test_comments_and_other_sections_are_ignored():
+ with tempfile.TemporaryDirectory() as tmp:
+ path = write_config(tmp, "; a comment\n"
+ "[general]\n"
+ "language = it\n"
+ "[sync]\n"
+ "command = /bin/true\n"
+ "[account.a]\n"
+ "; another comment\n"
+ "maildir = a\n"
+ "sent = Sent\n")
+ assert qtmaildirconf.sent_folders(path) == ["a/Sent"]
+
+
+def test_a_missing_file_yields_no_folders():
+ """The hook must run on a system with no qtmaildir config at all: it
+ then protects nothing, rather than failing the sync."""
+ with tempfile.TemporaryDirectory() as tmp:
+ assert qtmaildirconf.sent_folders(Path(tmp) / "absent.conf") == []
+
+
+def test_an_unreadable_file_yields_no_folders():
+ """A malformed config must not fail the sync. notmuch new has already
+ run at this point; refusing to tag is worse than not protecting sent
+ mail for one cycle."""
+ with tempfile.TemporaryDirectory() as tmp:
+ path = write_config(tmp, "this is not an ini file\n[[[\n")
+ assert qtmaildirconf.sent_folders(path) == []
+
+
+def test_the_query_scopes_every_folder():
+ folders = ["a/Sent", "g/[Gmail]/Posta inviata"]
+ query = qtmaildirconf.sent_query(folders)
+ assert query == ('path:"a/Sent/**" or path:"g/[Gmail]/Posta inviata/**"')
+
+
+def test_the_query_is_empty_when_no_folder_is_configured():
+ """An empty query means "match everything" to notmuch, so the caller
+ must be able to tell "nothing to protect" from "protect the world"."""
+ assert qtmaildirconf.sent_query([]) == ""
+
+
+def test_a_folder_containing_a_quote_cannot_break_out_of_the_query():
+ """The folder name reaches a notmuch query as a quoted string. A stray
+ double quote would end the term and let the rest be read as syntax."""
+ query = qtmaildirconf.sent_query(['a/He said "hi"'])
+ assert query.count('"') % 2 == 0
+ assert "\\\"" in query or '""' in query
+
+
+def main():
+ tests = [value for name, value in sorted(globals().items())
+ if name.startswith("test_") and callable(value)]
+ for test in tests:
+ test()
+ print(f"ok {test.__name__}")
+ print(f"\n{len(tests)} passed")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
index 01be5d0..e008641 100644
--- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
+++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
@@ -87,7 +87,7 @@ taking that too literally.
| 18 | No visual cue that there are unsynced edits | feedback | S | **done** |
| 19 | No prompt to sync on exit when edits are pending | behavior | S | **done** |
| 20 | Thread view does not match the user's mental model | presentation | L | **done** 2026-08-10, as the card list; see 53 |
-| 21 | Default shortcuts are not sensible enough | discoverability | S | open |
+| 21 | Default shortcuts are not sensible enough | discoverability | S | open; **the user is drafting the table** in their own notes (`qtmaildir shortcuts and menu structure.md`), 2026-08-23. Read it first rather than proposing one. Settles `Ctrl+Return` for Send; leaves two collisions and an unfinished menu half, see the entry |
| 22 | Translatability audit and i18n wiring | correctness | M | **done** 2026-08-15, unreleased; see `specs/2026-08-15-i18n-design.md`. Found eight rule-builder labels that could never be translated in any language, and twenty untranslatable warnings. Ships an Italian translation of all 355 strings |
| 23 | No way to save a search query from the UI | workflow | M | **done** 2026-08-13, shipped in 0.18.0; see `specs/2026-08-13-saved-queries-design.md` |
| 24 | No right-click actions on the thread list | discoverability | S | **done** |
@@ -188,8 +188,8 @@ taking that too literally.
| 119 | The unsynced-changes count cannot be opened to see what it counts | information | S | open, 2026-08-19, from the notes. One of the four things it sums carries no message ids at all, so a list cannot be complete without a change to how the count is kept |
| 121 | The thread list shows nothing while a query is running | feedback | S | open, 2026-08-20, from the notes. Follows item 74, which fixed the status-bar half and left the list itself blank |
-| 122 | The README documents a version of the app that no longer exists | documentation | M | open, 2026-08-20, from the notes. Delete-to-trash is entirely undocumented, including a config key a user must now set |
-| 123 | Sending mail is not designed | v2 | L | **specified** 2026-08-20, on branch `compose-and-send`. Design in `docs/superpowers/specs/2026-08-20-compose-and-send-design.md`; read that, not this row. Send is a per-account `send_command` on stdin, so the no-network-protocol rule stands. Composer is a separate window, body is markdown via cmark-gfm, drafts autosave to the account's drafts folder. No code written |
+| 122 | The README documents a version of the app that no longer exists | documentation | M | **done** 2026-08-23, unreleased, inside item 123 task 13. `trash`, `send_command` and the whole `[compose]` section were undocumented; a Composing section is added and "sending is not implemented" removed. Every default was read from `config.h` rather than from the prose, which caught `send_html` documented as false when it defaults to true |
+| 123 | Sending mail is not designed | v2 | L | **specified** 2026-08-20, on branch `compose-and-send`. Design in `docs/superpowers/specs/2026-08-20-compose-and-send-design.md`; read that, not this row. Send is a per-account `send_command` on stdin, so the no-network-protocol rule stands. Composer is a separate window, body is markdown via cmark-gfm, drafts autosave to the account's drafts folder. Tasks 1 to 13 of 13 built 2026-08-20 to 2026-08-23; task 13 closed the documentation out and retired the v1/v2 split, which semver had made meaningless. **Hand tested 2026-08-22 and 2026-08-23** against a fake send command: New, Reply and Forward all send, a forwarded attachment survives intact, and the sent copy is filed. Found two defects, both fixed (the orphaned composer, and sent mail carrying `inbox`). Twenty-two defects were found in the plan's own draft code across tasks 4 to 12, so treat every code block in it as a draft |
| 124 | The worker reads the index directory as the mail root | defect | S | **done** 2026-08-20, unreleased. `mailRootOf()` over `NOTMUCH_CONFIG_MAIL_ROOT`, correct under both layouts. Verified by migrating the developer's own index to NVMe the same day: cold start 38.6 s to 0.67 s |
@@ -204,6 +204,20 @@ taking that too literally.
| 132 | Every action must have a shortcut, and that no longer serves | policy | S | done, 2026-08-20. `everyActionHasAShortcut` is deleted and nothing replaces it: `everyActionIsReachableFromAMenu()` is the required rule and a shortcut is now a chosen subset. Nothing else needed changing, since `showShortcutReference()` already printed `(unbound)` for an empty sequence. Verified by unbinding `tag_rules` and running the suite green, which would have failed before |
| 133 | The composer shows no markdown syntax highlighting | v2 | S | open, 2026-08-20, from the item 123 brainstorm. **Blocked on 123.** A `QSyntaxHighlighter` over the composer's editor, so `**bold**` reads as bold while the buffer stays plain markdown. Standard Qt, no dependency. Deliberately after 123's formatting toolbar: agreeing with the grammar about nesting and about code spans suppressing what is inside them is the expensive part, and the toolbar is what makes the feature usable |
| 134 | The busy indicator is built inline and is about to be built twice | maintenance | S | done, 2026-08-20, af902e0. `BusyIndicator` (`src/busyindicator.h`) carries both modes: `MainWindow` uses the indeterminate one, and item 123's send popup takes the determinate half for its undo countdown, switching the same widget over when the command starts. Only the BAR was extracted, not the status label this row paired with it. `m_statusLabel` has 34 uses across `MainWindow` for transient messages, selection counts and sync phases, so it belongs to the window rather than to the indicator, and the send popup owns its own phase text |
+| 135 | The formatting toolbar's buttons stack rather than toggle | v2 | S | open, 2026-08-21, asked for by the user during item 123 task 8 and reverted the same session. **A spec change, not a defect**: it conflicts with spec:236 ("deliberately no live toggle") and spec:187-190. Both sites need amending FIRST, and the amendment must resolve what replaces bold-then-italic, which is the gesture spec:187's preserved selection exists to serve and which a toggle makes unreachable. That question is the work; the state machine is understood and written up in the section |
+| 136 | `undoMovesTheMessageBack` fails about one run in six | defect | ? | open, 2026-08-21, found while running the suite during item 123 task 10. A pre-existing race in the test or in Delete's file move, NOT caused by 123: reproduced on a clean tree with the branch's work stashed out, 1 failure in 6 runs, and the failing run took 70s against a normal 25s. Unrelated to `SendDialog`. Size unknown until the race is located |
+| 137 | A reply to a message that arrived at two accounts can come from the wrong one | defect | S | open, 2026-08-22, found while building item 123 task 12. `ComposeContextBuilder::accountForReply()` takes `messagePaths` PLURAL to disambiguate, and nothing upstream ever gives it more than one path, so the disambiguation is inert |
+| 138 | No Drafts filter beside Sent and Trash | workflow | S | open, 2026-08-23, from the notes. Verified: `kQueryGenerators` has no `drafts` entry, though every account already configures a `drafts` folder. Follows the `sent` generator exactly, which composes per-account folders rather than matching a tag |
+| 139 | Forward is reachable only from the Message menu | discoverability | XS | open, 2026-08-23, from the notes. Verified: `forward` is added to `messageMenu` and to no toolbar. Compose and Reply are on the toolbar, so the third member of the set is the only one hidden |
+| 140 | Compose, Reply and Forward belong over the message pane, not on the main toolbar | presentation | M | open, 2026-08-23, from the notes. The user's design: a bar of its own above the message pane carrying the three message actions, leaving the main toolbar for list-wide operations. Absorbs 139, which is the same three buttons in a worse place. See also 141 |
+| 141 | The message pane has no button bar of its own | presentation | M | open, 2026-08-23, from the notes. The container 140 needs, and the home the user names for a `toggle_html` control. Sized as one item with 140 if built together |
+| 142 | The composer's formatting buttons share a toolbar with Send and Attach | presentation | S | open, 2026-08-23, from the notes. Verified: one `addToolBar` carries Bold through Quote, then Attach, Remove attachment and Send. The user reads the row as a menu bar that is not one. Move the formatting half down to sit directly above the editor, beside the HTML checkbox |
+| 143 | The formatting buttons are text, where every editor uses icons | presentation | XS | open, 2026-08-23, from the notes. Follows 142, and cheap once the row moves. `QIcon::fromTheme` per CLAUDE.md's chrome rule, with the text kept as the tooltip |
+| 144 | "Also send a formatted copy" is prominent and does not say what it does | presentation | XS | open, 2026-08-23, from the notes. It means "send an HTML part as well as plain text", which the label never says. Secondary to writing the message, so it should read as such |
+| 145 | Cc and Bcc are permanent rows on every composer | presentation | S | open, 2026-08-23, from the notes. Verified: both are unconditional `form->addRow` calls. Most messages use neither. Collapse behind a disclosure next to To:, expanded automatically when a draft or a reply already carries a value |
+| 146 | The unsynced-changes count cannot be opened to see what it counts | information | S | **duplicate of 119**, recorded 2026-08-23 from the notes. Same request, and 119 already carries the blocker: one of the four things the count sums holds no message ids, so a list cannot be complete without changing how the count is kept |
+| 147 | Toggle unread reads the same whichever way it will go | presentation | S | **duplicate of 99**, recorded 2026-08-23 from the notes. The notes ask for exactly what 99 describes: "Mark as read" on an unread message and the reverse. 99 already records that the label is harder than it looks, since a multi-row selection has no single direction |
+| 148 | Ctrl+W does not close the composer | discoverability | XS | open, 2026-08-23, from the notes. Verified: nothing binds `Ctrl+W` anywhere, and the composer has no close action of its own. Belongs with item 21's table rather than bound in isolation |
Sizes are rough: XS under an hour, S a sitting, M a session.
@@ -238,6 +252,45 @@ that appeared to be bound. See `KeyMap::defaultBindings()` and
the query bar claims it back while focused, so a proposal that moves it must
not resurrect that bug.
+**The user is drafting the table, 2026-08-23.** It lives in their own notes as
+`qtmaildir shortcuts and menu structure.md`, linked from the note this item
+came from, and it is the specification this item was waiting for: a row per
+action with the current binding, the proposed one, and an explicit "no
+shortcut" column for the actions that should have none. **Read it before
+starting, and do not propose a table of your own.** It is unfinished in two
+known places, so it is a starting point rather than a finished spec:
+
+- **The menu-structure half is one line long** ("File should hold Save
+ message") and is where the second half of this item's work is specified.
+- **The compose actions are absent from it**, because it predates them being
+ usable by hand. The user's position as of 2026-08-23, stated but not yet
+ written into their table: `Ctrl+Return` for Send is **kept**, which closes
+ that open question from item 123 task 11; and major actions should not go
+ three modifiers deep, so Reply becomes `Ctrl+R`, Reply all `Ctrl+Shift+R`,
+ and Forward `Ctrl+F`.
+
+**Two collisions that proposal creates, both to settle before building.**
+`Ctrl+R` is `restore` today, and the draft's own row for it says "ok if not
+needed for something else" — it now is, so Restore needs a new binding or
+none. And `Ctrl+F` is Find in most applications; the draft frees it by moving
+Find to `/`, so the two are coupled, and if `/` does not survive review then
+Forward loses its binding with it.
+
+**`/` for Find needs an event filter, not a shortcut.** Qt withholds only
+plain LETTERS from editable widgets, so a `/` registered as a `QAction`
+shortcut is dispatched before the query bar, the tag dialog and the composer's
+editor ever see it, and a user could not type a path or a URL in any of them.
+This is the same trap `CLAUDE.md` records for arrow keys, and `Return` is the
+worked example of the fix: claim it in `MainWindow::eventFilter` by accepting
+the `ShortcutOverride`, narrowly, for the one widget that needs it.
+
+**Dropping a shortcut is not dropping the action.** The draft marks the five
+`*_thread` actions (item 108) for removal, and the user confirmed on
+2026-08-23 that this means their SHORTCUTS only. The menu entries must stay:
+`everyActionIsReachableFromAMenu()` is a required rule, while item 132 made
+the shortcut itself optional, so an action with no binding is now ordinary and
+prints as `(unbound)` in the shortcut reference.
+
## 40. No live filter over the current view
**Observed (user, 2026-08-05):** "search in current view", spelled out as two
@@ -1098,6 +1151,78 @@ Then Delete a message. Verified by hand on 2026-08-20; this is how it was found.
**Size: S.**
+## 135. The formatting toolbar's buttons stack rather than toggle
+
+**Observed (user, 2026-08-21):** pressing Bold a second time on already-bold
+text adds another pair of asterisks rather than removing the first, so
+`**this**` becomes `****this****`. Quote nests the same way: a second press on
+`> one` gives `> > one`. The user asked for both to toggle.
+
+**A toggle was built and reverted the same session**, and the reason matters
+more than the code: it was not unwanted, it **conflicts with the spec**, which
+was not checked before the work started.
+
+- `2026-08-20-compose-and-send-design.md:236` states there is "deliberately no
+ live toggle that inserts and removes the quote while editing".
+- `:187-190` is the complete statement of the wrap behaviour and describes only
+ wrapping, with no toggle anywhere.
+
+**Cause.** This is a **spec change, not a defect**, and both sites need
+amending before any code is written again.
+
+Underneath sits a real design question the spec answers one way and a toggle
+answers the other, which is why the two cannot simply coexist. `:187` preserves
+the selection after a wrap **so that a second press applies a SECOND token** to
+the same words: bold, then italic, without touching the mouse. A toggle makes
+that gesture unreachable, because the second press now removes the first token
+instead. **What replaces bold-then-italic is unanswered**, and answering it is
+the substance of this item, not the state machine below. Possible directions,
+none chosen: a modifier on the second press, a separate un-format action, or
+accepting that the chord is lost and reaching nested emphasis by typing.
+
+**Approach.** When it is picked up, the transformation half is already
+understood, so the notes below exist to stop it being rediscovered. A toggling
+`wrap()` must distinguish three states, and a single "it unwraps" test passes
+against most of them being broken:
+
+- **INSIDE** the tokens: `**this**` with `this` selected (2..6). The tokens sit
+ just outside the selection; the same characters stay selected afterwards.
+- **AROUND** them: `**this**` selected whole (0..8). The selection shrinks to
+ the text that was between them.
+- **PARTIALLY overlapping** one: `*this**` (6..13). Neither of the above. It
+ does not describe a wrapped span, and stripping would have to guess which
+ half of a token to keep, so wrapping is the predictable answer.
+
+**INSIDE must be checked before AROUND.** On `***this***` both tests match, and
+only INSIDE removes the level the user actually asked for.
+
+**A naive adjacency test is wrong, and looks right.** Checking only whether the
+characters either side of the selection equal the token means pressing *Italic*
+on `**this**` finds a `*` on each side, strips one asterisk per side, and
+**un-bolds text the user asked to italicise**. A strip must require the adjacent
+RUN of token characters to be the token exactly, or the token plus one other
+complete emphasis token: `***` is bold+italic and divisible either way, while a
+run of two is one indivisible token whose half is not a token at all. This was
+found by writing the italic-on-bold test, not by reading the code.
+
+The quote side is simpler but has one trap: a bare `>` is what the quote path
+writes for a blank line, so an unquote that only recognises `"> "` leaves a
+stray marker on every blank line in a round trip. Whether a mixed block (some
+lines quoted, some not) quotes or unquotes is a decision; quoting it, so one
+press makes the block uniform and the next unquotes it, avoids the button doing
+two opposite things to two halves of one selection.
+
+**Constraints.** The spec amendment comes first and must resolve the
+bold-then-italic question, or the same conflict recurs. `MarkdownFormat` is
+painter-free and widget-free, so the whole state machine is unit-testable
+without the composer; keep it that way. The toolbar shortcuts belong to the
+composer window and do not touch `KeyMap`, so nothing here interacts with item
+132. Note that toggling changes what the preserved selection is FOR, so
+`wrappingTwiceNestsTheTokensAroundTheSameWords` and
+`quotingAnAlreadyQuotedLineNestsIt` in `tests/test_formattoolbar.cpp` both
+assert the current spec behaviour and would be replaced rather than extended.
+
+
## Deferred, unsized, or split out
Items noted while triaging but not part of the original list. Same numbering
@@ -1140,3 +1265,261 @@ whether to open the spec at all, and leave the rest there.
Name the spec `<date>-<name>-design.md`, and state in its header which backlog
items it resolves, so the numbering stays traceable in both directions.
+
+## 137. A reply to a message that arrived at two accounts can come from the wrong one
+
+**Observed.** A message that exists in more than one maildir, because it was
+sent to two of the user's addresses or duplicated across accounts by mbsync,
+can open its reply from either account. Which one is picked is arbitrary. The
+consequence is visible in the composer's From field, so it is not silent, but
+it is only visible to somebody who thinks to look: the reply is otherwise
+correct and sendable, and the recipient sees a From the user did not intend.
+
+**Cause, verified in the code.** The disambiguation exists and is unreachable.
+`ComposeContextBuilder::accountForReply()` (`src/composecontext.cpp:405`) takes
+`messagePaths` as a `QStringList` precisely so it can resolve this case: with
+more than one candidate account it prefers the one whose own address appears
+among the recipients, which is the reason the copy landed there. Nothing
+upstream ever gives it more than one path. `NotmuchWorker::loadMessage()`
+(`src/notmuchworker.cpp:573`) builds its `MessageRef` from
+`notmuch_message_get_filename()`, the SINGULAR accessor, so `MessageRef` holds
+one `filePath` and `MainWindow::openComposerFor()` can only pass a
+one-element list. The plural parameter is therefore inert, and the branch that
+consumes it is dead code today.
+
+`notmuch_message_get_filenames()`, the plural accessor that would supply the
+rest, exists in libnotmuch and is used nowhere in this repository.
+
+**Approach.** Add `QStringList filePaths` to `MessageRef` (`src/types.h:123`)
+ALONGSIDE the existing `filePath` rather than replacing it, and populate it in
+`loadMessage()` from `notmuch_message_get_filenames()`. `filePath` stays as the
+render path, so `MainWindow::renderMessages()` and everything else that opens
+one file are untouched; only `openComposerFor()` reads the new field. That
+keeps the change to two files plus the one call site.
+
+**Constraints.** The test has to put the same message id in two accounts'
+maildirs, which `NotmuchFixture` can do by writing the same `Message-ID` into
+two folders before indexing. Assert on the account CHOSEN rather than on a
+count of paths: a test that only checks `filePaths.size() == 2` passes against
+`accountForReply()` still ignoring them. The recipient-preference branch is
+what needs covering, so the two accounts must have different addresses and the
+message must be addressed to one of them, or either answer is correct and the
+test proves nothing.
+
+
+## 136. `undoMovesTheMessageBack` fails about one run in six
+
+**Observed.** `test_mainwindow` failed during a full-suite run while item 123
+task 10 was in the working tree. The failing function is
+`TestMainWindow::undoMovesTheMessageBack`. The run that failed took 70 seconds
+against a normal 25, so whatever goes wrong also blocks for a while before
+giving up.
+
+**Not caused by item 123.** This was checked rather than assumed, because a
+failure appearing during unrelated work is exactly the kind of thing that gets
+blamed on the change in front of it. With the branch's work `git stash`ed out,
+on a clean tree, it still failed **1 run in 6**. Nothing in `SendDialog`
+touches the model, the Maildir, or the undo stack.
+
+**Cause, unverified.** A race around the Maildir file move that Delete
+performs and Undo reverses. Whether the race is in the test's wait or in the
+production move is exactly what the item has to establish, and that is why the
+size is `?` rather than a guess. The two have very different consequences: a
+test that waits wrongly is noise, while a move that races is mail landing in
+the wrong folder, and CLAUDE.md already records that a wrong folder name from
+this code path reaches the mail server.
+
+**Approach.** Reproduce in isolation first, with the suite's own
+`QT_QPA_PLATFORM=offscreen` and a loop over `ctest -R mainwindow`, and capture
+a failing run's output before theorising. The 70-second duration is the useful
+clue: something is waiting on a condition that never arrives rather than
+asserting immediately, so find which `QTRY_*` or `qWait` is timing out.
+
+**Constraints.** A fix must not restore the real `/proc/locks` (item 61), and a
+flaky test must not be "fixed" by widening its timeout until it passes, which
+converts a real race into a slower green. If the race turns out to be in the
+production move rather than the test, this stops being a test-hygiene item and
+becomes a mail-safety one.
+
+---
+
+## 138. No Drafts filter beside Sent and Trash
+
+**Observed.** The query row carries Unread, Inbox, Important, Sent and Trash.
+There is no Drafts button, though the composer has been writing drafts to each
+account's drafts folder since item 123.
+
+**Cause, verified 2026-08-23.** `kQueryGenerators` in `config.cpp:62-66` is a
+closed set of five, and `drafts` is not among them. Every account already
+carries a `drafts` key (`config.cpp:453`), read for the composer's autosave, so
+the data the filter needs is configured and unused by the query row.
+
+**Approach.** Follow `sent`, not `inbox`. A tag query would be wrong for the
+same reason it is wrong for Sent: `draft` is a Maildir flag notmuch surfaces as
+a tag, but the folder is what the user means, and a message a provider marks
+differently would disagree. `Config::allSentQuery()` composes the union over
+every account's folder and `Account::sentQuery()` the per-account half; both
+need a drafts twin. The generator string is wire format and must stay `drafts`
+in queries.json whatever the button is called in a given locale.
+
+**Constraints.** An account with no `drafts` key contributes NOTHING rather
+than an empty term, or the button shows the whole Maildir: this is
+`Config::matchNothingQuery()`'s reason for existing. The hook's carve-out
+(`assets/hooks/qtmaildirconf.py`) reads the same key, so the two now agree on
+what a drafts folder is; they are separate readers and neither should start
+importing the other.
+
+---
+
+## 139. Forward is reachable only from the Message menu
+
+**Observed.** There is no Forward button anywhere in the interface. The action
+exists and works; it is in the Message menu and nowhere else.
+
+**Cause, verified 2026-08-23.** `mainwindow.cpp:1714` registers the action and
+`1763` adds it to `messageMenu`. The toolbar block at `1975-1994` adds Compose
+and Reply but never Forward, so two thirds of the message-action set are
+visible and the third is not.
+
+**Approach.** One line, if the toolbar is where it belongs. It probably is not:
+item 140 records the user's own view that all three belong over the message
+pane instead, which makes this item the cheap half of that one. Build 140 and
+this closes with it; build this alone only if 140 is deferred.
+
+**Constraints.** The no-duplicate-icons rule covers any action that can reach
+the toolbar, so Forward needs an icon distinct from Reply's rather than a
+variant of it.
+
+---
+
+## 140. Compose, Reply and Forward belong over the message pane
+
+**Observed.** The user's note: "'Write new message' and 'reply' live next to
+the other icon only buttons, but they belong in a new bar on top of the message
+pane, together with 'Forward'."
+
+**Cause.** Not a defect. The toolbar grew by accretion and now mixes two
+different scopes: Sync, Archive, Delete, Mark all read and Undo act on the LIST
+or on the selection, while Compose, Reply and Forward are about a message. The
+main toolbar reads as the place for everything, so the distinction is invisible.
+
+**Approach.** A bar above the message pane carrying the three message actions,
+and the main toolbar keeping the list-wide ones. Compose is arguably neither,
+since it needs no message at all; the user grouped it with the other two, and
+that grouping is theirs to make. It shares the container item 141 introduces.
+
+**Constraints.** The actions themselves do not move: they stay in
+`m_actions`, keep their shortcuts, and keep their menu entries, which is what
+`everyActionIsReachableFromAMenu()` asserts on. This is a second presentation
+of the same `QAction`s. Absorbs item 139.
+
+---
+
+## 141. The message pane has no button bar of its own
+
+**Observed.** The user asks for "a button bar in the message pane area", and
+names `toggle_html` as a control that would fit it.
+
+**Cause.** Nothing exists to hang such a control on. The pane is a header
+label, the web view, the attachment bar and the tag strip; a per-message
+control has no home, which is why `toggle_html` lives in a menu.
+
+**Approach.** The container item 140 needs. Whether it holds only the three
+message actions, only view controls like `toggle_html`, or both is the design
+question, and it should be settled with the user before building: a bar that
+mixes "act on this message" with "change how I am looking at it" is the same
+confusion item 140 exists to remove, one level down.
+
+**Constraints.** `MessageView` is built inline in its own class rather than
+from named widget classes, per CLAUDE.md, and this should not become the
+exception. Size assumes 140 and 141 are built together; separately they are
+each S and the seam between them is wasted work.
+
+---
+
+## 142. The composer's formatting buttons share a toolbar with Send and Attach
+
+**Observed.** The user reads the composer's top row as a menu bar that is not
+one, and asks for the formatting controls to move down beside the HTML
+checkbox, directly above the editor.
+
+**Cause, verified 2026-08-23.** `composewindow.cpp:326-397` builds ONE
+`addToolBar`, which carries Bold, Italic, Code, the heading and list actions,
+Link and Quote, then a separator, then Attach, Remove attachment and Send.
+Three different scopes in one row: text formatting, message composition, and
+the terminal action.
+
+**Approach.** Split it. The formatting half moves to a row directly above the
+editor, where the text it formats is; Attach, Remove attachment and Send stay
+in the window's own toolbar. The HTML checkbox already sits under the editor
+and is the anchor the user names.
+
+**Constraints.** `setInputsEnabled()` disables `m_formatToolbar` wholesale
+during a send (`composewindow.cpp:692`), so a split needs both halves disabled,
+and a test for the send path that asserts on only one of them would pass
+against a live Attach button during a send.
+
+---
+
+## 143. The formatting buttons are text where every editor uses icons
+
+**Observed.** The user asks for icon-only formatting buttons, "like any other
+text editor".
+
+**Cause.** They were built as text actions, and the composer's toolbar has no
+icons at all.
+
+**Approach.** `QIcon::fromTheme` per CLAUDE.md's rule that chrome is the
+system's, keeping the current text as the tooltip so nothing becomes
+unnameable. Cheap once item 142 has moved the row, and awkward before, since
+the same row would then mix icon-only formatting with text Send and Attach.
+
+**Constraints.** `format-text-bold` and its siblings are standard freedesktop
+names, but a theme may not carry all of them; an action with no icon must fall
+back to its text rather than rendering as an empty button. Icon-only is also
+the state where the tooltip stops being decoration, so every one needs to be
+right.
+
+---
+
+## 144. "Also send a formatted copy" is prominent and says nothing
+
+**Observed.** The user's note: "I suppose it means 'format/send as html', but
+that flag is secondary, doesn't need to be so prominent."
+
+**Cause, verified 2026-08-23.** `composewindow.cpp:265`. The label describes a
+mechanism ("a formatted copy") without naming it, so the reader has to infer
+that "formatted" means HTML and that "copy" means an additional MIME part
+rather than a second message.
+
+**Approach.** Two independent halves, and they can ship separately. Say what it
+does: something closer to "Send an HTML version as well". And demote it: it is
+a per-message override of a config default (`[compose] send_html`), which is
+secondary to writing the message.
+
+**Constraints.** The string is translated, so changing it makes the Italian
+entry stale; `ctest -R translations` fails on an untranslated string, which is
+the intended safety net rather than an obstacle. Nothing matches on this text,
+so CLAUDE.md's "translating a string something matches on" trap does not apply.
+
+---
+
+## 145. Cc and Bcc are permanent rows on every composer
+
+**Observed.** The user asks for them hidden behind a disclosure next to the To:
+field.
+
+**Cause, verified 2026-08-23.** `composewindow.cpp:251` and `255` add both as
+unconditional form rows. Most messages address neither, so two of the four
+header rows are usually empty.
+
+**Approach.** A disclosure beside To: that reveals both together. They expand
+automatically, and stay expanded, whenever either already carries a value: a
+reply that carries Cc, or a reopened draft, must not hide a recipient the
+message is actually addressed to.
+
+**Constraints.** That auto-expansion is the load-bearing half. A hidden field
+holding an address is a message going somewhere the sender cannot see, which is
+worse than the clutter this removes. The seeding runs before `buildUi()`'s
+`markDirty()` connections per the constructor's ordering comment, so whatever
+decides the initial state has to read the seeded values rather than the widgets.
diff --git a/docs/superpowers/plans/2026-08-20-compose-and-send.md b/docs/superpowers/plans/2026-08-20-compose-and-send.md
index 195dacc..7d5f6f1 100644
--- a/docs/superpowers/plans/2026-08-20-compose-and-send.md
+++ b/docs/superpowers/plans/2026-08-20-compose-and-send.md
@@ -3871,6 +3871,16 @@ popup between stages."
### Task 11: ComposeWindow
+**Found during Task 4's code review, and it lands here.** `MessageBuilder::build()`
+is SYNCHRONOUS and can block: a large attachment is read and base64-encoded on
+the calling thread. Autosave calls it on a timer, on the GUI thread, so a
+30-second debounce that hits a 25MB attachment stalls typing. The directory
+hang that review found is fixed in `MessageBuilder`, but the blocking read
+remains by design. Do not move it to a thread as part of this task, since
+nothing here crosses the worker boundary and adding a second threading model
+for one call is worse than the stall. Note it in a comment at the autosave call
+site so the next person measuring a freeze knows where to look.
+
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.
@@ -3998,6 +4008,20 @@ private:
`src/composewindow.cpp`. The full file is long; these are the parts that carry
decisions, and the rest is ordinary widget assembly.
+**One thing in this block is load-bearing and easy to drop while retyping it:
+the `Qt::SingleShotConnection` on the `MessageSender::finished` connect inside
+the `committed` handler.** `m_sender` is a long-lived member, so a plain
+`connect()` beside a `send()` call leaks a receiver per send and the second
+result runs every earlier lambda, each still holding an earlier message's bytes
+by value: a sent copy of the wrong message, and `accept()` on a destroyed
+dialog. `MessageSender`'s own once-only guard cannot help, because that guards
+the emit and this is one emit reaching many receivers. The header for
+`MessageSender::finished` states the rule and
+`test_messagesender.cpp::aPerSendConnectionMustBeSingleShot` measures it (3
+deliveries for 2 sends without the flag, 2 with it). Noted here because the
+plan's code blocks are drafts and this is the line whose absence still
+compiles, still runs, and is wrong only on the second send.
+
```cpp
#include "composewindow.h"
@@ -4159,6 +4183,20 @@ void ComposeWindow::send()
connect(dialog, &SendDialog::committed, this, [this, dialog, built, account]() {
m_sender->send(account.sendCommand, built.bytes);
+ // Qt::SingleShotConnection IS REQUIRED HERE, and this line is the
+ // correction of a defect that was in this plan's draft (found while
+ // building Task 6, 2026-08-21). m_sender is a long-lived member, so a
+ // bare connect() beside each send() accumulates a permanent receiver
+ // per send. Send, fail, correct the recipient, send again, and the
+ // second result runs BOTH lambdas: the first still holds the FIRST
+ // message's `built` and `account` by value, so it files a sent copy of
+ // the wrong message and calls accept() on a dialog it already
+ // deleteLater()'d. MessageSender's m_reported guard cannot prevent
+ // this: it collapses two QProcess signals into one emit, and this is
+ // one emit reaching many receivers. Measured in
+ // test_messagesender.cpp::aPerSendConnectionMustBeSingleShot, where
+ // the bare shape delivers 3 results for 2 sends and the single-shot
+ // shape delivers 2.
connect(m_sender, &MessageSender::finished, this,
[this, dialog, built, account](bool sent, const QString &error) {
if (!sent) {
@@ -4787,6 +4825,21 @@ replace.
- 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 0: Document the new keys in the README**
+
+Found during Task 2's code review and assigned here rather than there. The
+README's sample config at `README.md:150-215` documents EVERY other
+configuration key, including recently added ones, and has nothing for
+`send_command` or the `[compose]` section. Without this the keys ship
+undiscoverable: a user has no way to learn that sending exists.
+
+Take the block from the spec at
+`docs/superpowers/specs/2026-08-20-compose-and-send-design.md:552-560` and
+adapt it to the README's existing commented style, showing `send_command` in
+an account section and every `[compose]` key with its default. Say plainly
+that an account without `send_command` is receive-only, since that is the
+part no reader would guess.
+
- [ ] **Step 1: Add the changelog entry**
Under `## [Unreleased]`, in the existing `### Added` section or a new one:
diff --git a/docs/superpowers/specs/2026-08-20-compose-and-send-design.md b/docs/superpowers/specs/2026-08-20-compose-and-send-design.md
index aade2d1..9533602 100644
--- a/docs/superpowers/specs/2026-08-20-compose-and-send-design.md
+++ b/docs/superpowers/specs/2026-08-20-compose-and-send-design.md
@@ -430,7 +430,7 @@ Two structs cross boundaries, in `types.h` beside the existing ones.
| `originalPath` | the `.eml` being replied to or forwarded; empty for New |
| `inReplyTo` | Message-ID of the original |
| `references` | the original's References plus its Message-ID |
-| `to`, `cc` | pre-filled recipients, the user's own addresses already stripped |
+| `to`, `cc` | pre-filled recipients, the user's own addresses already stripped; a reply to the user's OWN message is addressed to that message's recipients instead of back to the user, mirroring its To/Cc split (see Replying to oneself) |
| `subject` | `Re:` / `Fwd:` prefixed, an existing prefix not doubled |
| `quotedBody` | the `>`-prefixed original; empty when the action does not quote |
| `seedHtml` | did the original carry a `text/html` part |
@@ -447,6 +447,13 @@ Two structs cross boundaries, in `types.h` beside the existing ones.
| `attachments` | local paths |
| `inReplyTo`, `references` | carried through unchanged |
+Message-ids are carried BARE, without angle brackets, matching what GMime hands
+back when `MimeParser` reads a `Message-ID`. `MessageBuilder` adds the brackets
+when it writes the header, in one place rather than in each caller: they are wire
+syntax, and GMime writes an EMPTY header for a bare addr-spec rather than
+complaining, so a caller that forgets them ships a reply that threads nowhere
+while nothing looks wrong locally.
+
`In-Reply-To` and `References` are not optional. Without them a reply appears as
an orphan thread in the sender's own client.
@@ -505,15 +512,38 @@ Six, each needing the five places `CLAUDE.md` enumerates: `knownActions()`,
| Action | Meaning | Scope |
|---|---|---|
| `compose` | New message | none needed |
-| `reply` | Reply to the displayed message, quoted | sender only |
+| `reply` | Reply to the displayed message, quoted | sender only, except when the sender is the user (see below) |
| `reply_all` | Reply to all, quoted | sender + To + Cc, own addresses removed |
-| `reply_no_quote` | Reply with an empty body | sender only |
+| `reply_no_quote` | Reply with an empty body | sender only, same exception |
| `forward` | Forward, body quoted inline, attachments carried | none |
| `save_message` | Write the raw `.eml` to a chosen path | any message |
`reply_all_no_quote` is deliberately absent. Six actions is already a large
menu and the combination is reached by deleting the quote.
+### Replying to oneself
+
+A reply whose sender is entirely the user's own addresses is addressed to that
+message's **original recipients** rather than to the sender. A plain reply takes
+its To and Cc together, having no Cc field of its own to mirror into. A
+reply-all MIRRORS THE SPLIT: the original's To becomes To and its Cc becomes Cc,
+because To means "addressed to you" and Cc "for information", and promoting a
+Cc'd party to To is a change every recipient can see.
+This is an ordinary gesture rather than an edge case: it is reached from the
+Sent view, from a follow-up on mail that went unanswered, and from any thread
+whose selected row is the user's own message. Addressing the sender there
+addresses the user, so the reply reaches nobody it was meant for.
+
+"Own" means EVERY parsed sender address is the user's. A message the user sent
+together with somebody else is still a reply to that co-sender, and takes the
+ordinary sender-only path.
+
+Mail the user sent to THEMSELVES alone leaves nothing after own addresses are
+removed, and there the sender is restored: the user is the correct recipient of
+their own note. The rejected alternative was to strip the sender and leave To
+empty, which silently drops every recipient while the message still looks
+sendable.
+
**Every action acts on the displayed message**, resolved with
`messageScopeFor()` semantics: a thread row means the one message its card
shows, a reply row means itself. Not `threadFor()`. Replying to a thread is
@@ -660,7 +690,8 @@ Cases: `multipart/alternative` when `sendHtml` is on and `text/plain` alone when
off; `multipart/mixed` nesting with attachments; each enabled extension
rendering, and tables and raw HTML **not** rendering; RFC 2047 encoding of a
non-ASCII subject and display name; quoted-printable for an accented body;
-`In-Reply-To` and `References` carried; `Re:` and `Fwd:` not doubling.
+`In-Reply-To` and `References` carried; `Re:` and `Fwd:` not doubling, in the
+non-English spellings and counted forms as well as the English ones.
**`test_messagesender`** uses stub commands, not msmtp: one exiting 0, one
exiting non-zero with stderr, one that does not exist. The stub writes stdin to
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index b63ff3e..2cebfef 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -2,6 +2,8 @@ add_library(qtmaildir_lib STATIC
keymap.cpp
config.cpp
mimeparser.cpp
+ markdownrenderer.cpp
+ messagebuilder.cpp
requestinterceptor.cpp
htmlbuilder.cpp
cidschemehandler.cpp
@@ -10,8 +12,15 @@ add_library(qtmaildir_lib STATIC
marks.cpp
carddelegate.cpp
notmuchworker.cpp
+ maildirname.cpp
+ draftstore.cpp
+ messagesender.cpp
+ composecontext.cpp
+ formattoolbar.cpp
tagchip.cpp
tagcolors.cpp
+ senddialog.cpp
+ composewindow.cpp
savequerydialog.cpp
tagdialog.cpp
tagrules.cpp
@@ -36,7 +45,8 @@ target_include_directories(qtmaildir_lib
target_link_libraries(qtmaildir_lib
PUBLIC Qt6::Widgets Qt6::Svg Qt6::WebEngineWidgets PkgConfig::GMIME
- ${NOTMUCH_LIBRARY})
+ ${NOTMUCH_LIBRARY} PkgConfig::CMARK_GFM
+ ${CMARK_GFM_EXTENSIONS_LIBRARY})
# resources.qrc belongs to the executable, not to the static library. A qrc
# compiled into a .a registers itself from a global initialiser, and the linker
diff --git a/src/composecontext.cpp b/src/composecontext.cpp
new file mode 100644
index 0000000..251a028
--- /dev/null
+++ b/src/composecontext.cpp
@@ -0,0 +1,517 @@
+/*
+ * 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.
+ */
+
+// gmime BEFORE any Qt header. glib declares a struct field named "signals",
+// which Qt #defines to Q_SIGNALS, and the collision is a compile error whose
+// message names neither library.
+#include <gmime/gmime.h>
+
+#include "composecontext.h"
+
+#include "config.h"
+#include "mimeparser.h"
+
+#include <QDir>
+#include <QSet>
+#include <QRegularExpression>
+
+namespace {
+
+/// GMime must be initialised exactly once per process.
+///
+/// MimeParser and MessageBuilder each carry their own copy of this guard, and
+/// this is a third rather than a shared one for the reason MessageBuilder
+/// already records: a test may link only one of them, so neither can assume
+/// another ran. Without it the first internet_address_list_parse() call
+/// dereferences an uninitialised type registry and SEGVs, which is exactly what
+/// this file did before the guard was added.
+///
+/// A function-local static rather than the `static bool` flag the other two
+/// use: C++11 guarantees the initialiser runs exactly once even under
+/// concurrent entry, which a bare flag does not.
+void ensureGMimeInitialised()
+{
+ static const bool initialised = [] {
+ g_mime_init();
+ return true;
+ }();
+ Q_UNUSED(initialised);
+}
+
+/// Matches a reply prefix at the START of a subject, in the spellings clients
+/// actually produce.
+///
+/// Anchored, and that is load-bearing rather than tidy: an unanchored search
+/// finds "re:" inside an ordinary subject ("Notes re: budget") and refuses to
+/// prefix a genuine first reply, which breaks threading in the recipient's
+/// client with nothing to see locally.
+///
+/// **The non-English spellings are not politeness, they are the doubling bug in
+/// a mixed-locale mailbox**, which this one is: the user writes Italian and
+/// corresponds beyond it. German and Dutch clients send `AW:`, Scandinavian
+/// ones `SV:`, Spanish and Portuguese `RES:`. An English-only pattern turns
+/// every one of those into `Re: AW: subject`, and the next round into
+/// `Re: Re: AW:`.
+///
+/// `Re[2]:` and `Re(2):` are the counted forms Outlook and some list managers
+/// emit. They mean the same thing and must not be doubled either.
+///
+/// **Single-letter spellings are deliberately NOT here**, though Italian
+/// clients do send `R:`. Measured 2026-08-21: with `r` in the alternation,
+/// `R: report on Q3` reads as a reply prefix, so a genuine first reply to that
+/// subject gets no `Re:` and threads nowhere in the recipient's client. A
+/// one-letter token before a colon is an ordinary subject far more often than
+/// it is a prefix, and this is the same failure the anchoring note above
+/// describes. The cost of omitting it is one doubled `Re: R:`, which is
+/// cosmetic; the cost of including it is broken threading, which is not.
+///
+/// Ordering inside the alternation matters: `res` before `re` so the longer
+/// spelling is not consumed by the shorter one, leaving a stray `S:` unmatched.
+const QRegularExpression &replyPrefix()
+{
+ static const QRegularExpression expression(
+ QStringLiteral("^\\s*(res|re|aw|antw|sv|vs)\\s*(\\[\\d+\\]|\\(\\d+\\))?\\s*:"),
+ QRegularExpression::CaseInsensitiveOption);
+ return expression;
+}
+
+/// "Fwd:" and "Fw:" mean the same thing and both are common, as do the
+/// non-English spellings a mixed-locale mailbox receives: German `WG:`, Spanish
+/// and Portuguese `RV:` and `ENC:`, French `TR:`. Same reasoning as
+/// replyPrefix(): an English-only pattern produces `Fwd: WG: subject`.
+///
+/// Italian `I:` is omitted for the reason replyPrefix() omits `R:`, and it is
+/// the worse of the two: `I: notes` is an entirely ordinary subject. The
+/// previous pattern was `fwd?`, which likewise matched a bare `F:`; that is not
+/// a forward marker in any client and `F: results` must still get a prefix.
+const QRegularExpression &forwardPrefix()
+{
+ static const QRegularExpression expression(
+ QStringLiteral("^\\s*(fwd|fw|wg|enc|rv|tr)\\s*(\\[\\d+\\]|\\(\\d+\\))?\\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()) {
+ if (account.maildir.isEmpty())
+ continue;
+ 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", and the reply would be sent from the wrong account.
+ if (path.startsWith(prefix))
+ return account.key;
+ }
+ return {};
+}
+
+/// True when \p address is one of \p ownAddresses, compared case-insensitively.
+///
+/// Compared on the addr-spec, never on a rendered "Name <addr>": a display
+/// name may legitimately contain an address-looking substring, and a substring
+/// test against the whole form strips a real recipient whose name happens to
+/// quote one of the user's addresses.
+bool isOwn(const QString &address, const QStringList &ownAddresses)
+{
+ for (const QString &own : ownAddresses) {
+ if (own.isEmpty())
+ continue;
+ if (address.compare(own, Qt::CaseInsensitive) == 0)
+ return true;
+ }
+ return false;
+}
+
+/// Appends \p recipient to \p out unless its address is already in \p seen or
+/// belongs to the user. \p seen is updated.
+///
+/// Deduplication is keyed on the lowercased ADDRESS, so the same mailbox under
+/// two different display names counts once, which is what the original's To
+/// and Cc routinely contain.
+void appendUnlessSuppressed(const ComposeContextBuilder::Recipient &recipient,
+ const QStringList &ownAddresses,
+ QSet<QString> *seen, QStringList *out)
+{
+ if (recipient.address.isEmpty())
+ return;
+ const QString key = recipient.address.toLower();
+ if (seen->contains(key))
+ return;
+ if (isOwn(recipient.address, ownAddresses))
+ return;
+ seen->insert(key);
+ out->append(recipient.rendered);
+}
+
+} // namespace
+
+QList<ComposeContextBuilder::Recipient>
+ComposeContextBuilder::parseAddressHeader(const QString &rawHeader)
+{
+ ensureGMimeInitialised();
+
+ const QByteArray utf8 = rawHeader.trimmed().toUtf8();
+ if (utf8.isEmpty())
+ return {};
+
+ // Returns NULL rather than an empty list for input it can make nothing of,
+ // including the empty string. Guarded above and again here: the header is
+ // untrusted and this is the crash if it is not.
+ InternetAddressList *list = internet_address_list_parse(nullptr, utf8.constData());
+ if (!list)
+ return {};
+
+ QList<Recipient> recipients;
+ const int count = internet_address_list_length(list);
+ for (int i = 0; i < count; ++i) {
+ InternetAddress *address = internet_address_list_get_address(list, i);
+ if (!address)
+ continue;
+
+ // Only MAILBOXES. A group carries a name and no address, so keeping it
+ // would put "undisclosed-recipients" in a To field as though it were a
+ // person. It is also the injection defence: measured 2026-08-21, a raw
+ // newline smuggled into a header makes GMime parse the following
+ // "Bcc: evil@example.net" as a GROUP, and dropping non-mailboxes drops
+ // it rather than pre-filling a recipient the user never saw.
+ if (!INTERNET_ADDRESS_IS_MAILBOX(address))
+ continue;
+
+ const char *addr =
+ internet_address_mailbox_get_addr(INTERNET_ADDRESS_MAILBOX(address));
+ if (!addr || !*addr)
+ continue;
+
+ Recipient recipient;
+ recipient.address = QString::fromUtf8(addr).trimmed();
+ if (recipient.address.isEmpty())
+ continue;
+
+ // Rendered BY GMIME rather than assembled by string. Quoting a display
+ // name is not a matter of wrapping it in quotes: a name containing a
+ // comma must come back out quoted or it re-parses as two recipients.
+ //
+ // The final argument is ENCODE, and it is a security parameter rather
+ // than a formatting preference. With FALSE a display name carrying a
+ // raw newline renders with that newline intact, which is a
+ // header-injection primitive: `"foo\nBcc: evil@example.net" <a@...>`
+ // comes back out verbatim and anything writing it into a To: line
+ // emits a second header the user never saw. With TRUE the same input
+ // renders RFC 2047 encoded as `=?iso-8859-1?q?foo=0ABcc=3A?= ...` and
+ // the newline can no longer terminate a header. Measured 2026-08-21;
+ // this shipped as FALSE and the test caught it.
+ char *rendered = internet_address_to_string(
+ address, g_mime_format_options_get_default(), TRUE);
+ recipient.rendered = rendered ? QString::fromUtf8(rendered).trimmed()
+ : QString();
+ g_free(rendered);
+ if (recipient.rendered.isEmpty())
+ recipient.rendered = recipient.address;
+
+ recipients.append(recipient);
+ }
+ g_object_unref(list);
+
+ return recipients;
+}
+
+QStringList ComposeContextBuilder::ownAddresses(const Config &config)
+{
+ QStringList addresses;
+ for (const Account &account : config.accounts()) {
+ const QString address = account.address.trimmed();
+ // An empty address is dropped rather than collected. It would match
+ // nothing usefully and, in any substring comparison, everything.
+ if (!address.isEmpty() && !addresses.contains(address, Qt::CaseInsensitive))
+ addresses.append(address);
+ }
+ return addresses;
+}
+
+void ComposeContextBuilder::recipientsForReply(const ParsedMessage &message,
+ bool replyAll,
+ const QStringList &ownAddresses,
+ QStringList *toOut,
+ QStringList *ccOut)
+{
+ if (toOut)
+ toOut->clear();
+ if (ccOut)
+ ccOut->clear();
+ if (!toOut)
+ return;
+
+ // Reply-To wins over From when present (RFC 5322 3.6.2: it names where the
+ // author wants replies sent). Applied to reply-all as well as to a plain
+ // reply: a list's reply-all belongs on the list too.
+ QList<Recipient> sender = parseAddressHeader(message.replyTo);
+ if (sender.isEmpty())
+ sender = parseAddressHeader(message.from);
+
+ // A reply to the user's OWN message goes to the people that message was
+ // addressed to, not back to the user. Reached from the Sent view, from a
+ // follow-up on unanswered mail, and from any thread whose selected row is
+ // the user's own message, so it is an ordinary gesture rather than an edge
+ // case. The alternative considered and rejected was stripping the sender
+ // and leaving To empty, which silently drops every recipient and looks
+ // sendable.
+ //
+ // "Own" means EVERY parsed sender address is the user's. A message with a
+ // co-sender is still a reply to that co-sender.
+ bool senderIsSelf = !sender.isEmpty();
+ for (const Recipient &recipient : sender) {
+ if (!isOwn(recipient.address, ownAddresses)) {
+ senderIsSelf = false;
+ break;
+ }
+ }
+
+ QSet<QString> seen;
+ if (senderIsSelf) {
+ // The original's To, with own addresses removed. Its Cc is deliberately
+ // NOT taken here for a reply-all: the split is the message's meaning,
+ // To being "addressed to you" and Cc "for information", and promoting a
+ // Cc'd party to To is visible to every recipient. The Cc pass below
+ // carries them across unchanged, so the reply mirrors the original.
+ //
+ // A PLAIN reply has no Cc field to mirror into, so it takes To and Cc
+ // together: everyone who was on the message is still addressed, which
+ // is what a reply to a conversation the user started means.
+ //
+ // Mail the user sent to themselves alone leaves nothing after the own
+ // filter, which is the one case where addressing the user IS correct,
+ // so the sender is restored below rather than producing an empty To.
+ QStringList headers = { message.to };
+ if (!replyAll)
+ headers.append(message.cc);
+ for (const QString &header : headers) {
+ const QList<Recipient> parsed = parseAddressHeader(header);
+ for (const Recipient &recipient : parsed)
+ appendUnlessSuppressed(recipient, ownAddresses, &seen, toOut);
+ }
+ }
+
+ if (toOut->isEmpty()) {
+ for (const Recipient &recipient : sender) {
+ if (recipient.address.isEmpty())
+ continue;
+ const QString key = recipient.address.toLower();
+ if (seen.contains(key))
+ continue;
+ // The sender is NOT filtered against the user's own addresses
+ // here. This branch is reached either for an ordinary reply, where
+ // the sender is somebody else, or for a note the user sent only to
+ // themselves, where they are the correct recipient. Stripping in
+ // either case produces a message with no recipient that still
+ // looks sendable.
+ seen.insert(key);
+ toOut->append(recipient.rendered);
+ }
+ }
+
+ // A From that parses to no mailbox at all leaves To empty, and that is
+ // reachable from real mail rather than only from a hostile fixture: a bare
+ // display name with no angle brackets ("From: Mailer Daemon") is what
+ // bounces and some automated senders emit, and MimeParser hands it over as
+ // a header with zero mailboxes. An empty To is the worst outcome available,
+ // since MessageBuilder treats it as success: the message is handed to the
+ // send command with nobody to deliver to and a copy is filed in Sent that
+ // looks sent and reached no one.
+ //
+ // The original's recipients are the only remaining candidates. Own
+ // addresses are stripped, so a message the user sent AND that has an
+ // unparseable From still yields nothing here, which is correct: there is
+ // genuinely nobody to address, and the composer shows an empty To the user
+ // can see and fill rather than a wrong one they will not check.
+ if (toOut->isEmpty()) {
+ for (const QString &header : { message.to, message.cc }) {
+ const QList<Recipient> parsed = parseAddressHeader(header);
+ for (const Recipient &recipient : parsed)
+ appendUnlessSuppressed(recipient, ownAddresses, &seen, toOut);
+ }
+ }
+
+ if (!replyAll || !ccOut)
+ return;
+
+ // Everyone else goes to Cc, with the user's own addresses removed and
+ // duplicates suppressed ACROSS the two fields rather than within each: the
+ // sender is very often also in the original's To, and per-field
+ // deduplication lists them twice.
+ for (const QString &header : { message.to, message.cc }) {
+ const QList<Recipient> parsed = parseAddressHeader(header);
+ for (const Recipient &recipient : parsed)
+ appendUnlessSuppressed(recipient, ownAddresses, &seen, ccOut);
+ }
+}
+
+QStringList ComposeContextBuilder::referencesForReply(const ParsedMessage &message)
+{
+ QStringList references;
+ QSet<QString> seen;
+
+ const auto append = [&references, &seen](const QString &raw) {
+ QString id = raw.trimmed();
+ if (id.startsWith(QLatin1Char('<')) && id.endsWith(QLatin1Char('>')))
+ id = id.mid(1, id.size() - 2).trimmed();
+ if (id.isEmpty() || seen.contains(id))
+ return;
+ seen.insert(id);
+ references.append(id);
+ };
+
+ // The header is a whitespace-separated run of <message-ids>, and real mail
+ // wraps it across lines, so whitespace is the conformant separator.
+ //
+ // Commas are accepted BESIDES whitespace because some clients emit
+ // `<a@x>,<b@y>`, which RFC 5322 does not allow here. Splitting on
+ // whitespace alone turns that whole header into ONE token, and the bracket
+ // strip below then yields the garbage id `a@x>,<b@y`: not a threading
+ // degradation but a fabricated Message-ID sent to the recipient's client.
+ // A comma cannot appear inside a msg-id, so accepting it costs nothing.
+ const QStringList existing = message.references.split(
+ QRegularExpression(QStringLiteral("[\\s,]+")), Qt::SkipEmptyParts);
+ for (const QString &id : existing)
+ append(id);
+
+ // The original's own id goes LAST, which is what makes the chain an order
+ // rather than a set. Appended through the same deduplication, so a message
+ // whose References already names it does not repeat it.
+ append(message.messageId);
+
+ return references;
+}
+
+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) {
+ const Account account = config.account(key);
+ if (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. Deliberately NOT translated and NOT reformatted
+ // through a locale-dependent date format: this text is sent to a recipient
+ // who may not share the user's locale, and the raw Date header is what
+ // every other client quotes.
+ quoted.append(QStringLiteral("On %1, %2 wrote:")
+ .arg(message.date, message.from));
+ quoted.append(QString());
+
+ // Normalised to LF first. A CRLF body split on '\n' alone leaves a
+ // carriage return at the end of every line, which survives into the sent
+ // message as a stray CR in the middle of a quoted line.
+ QString body = message.plainBody;
+ body.replace(QStringLiteral("\r\n"), QStringLiteral("\n"));
+ body.replace(QLatin1Char('\r'), QLatin1Char('\n'));
+
+ const QStringList lines = body.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'));
+}
diff --git a/src/composecontext.h b/src/composecontext.h
new file mode 100644
index 0000000..4027af0
--- /dev/null
+++ b/src/composecontext.h
@@ -0,0 +1,177 @@
+/*
+ * 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.
+ */
+
+#pragma once
+
+#include <QList>
+#include <QString>
+#include <QStringList>
+
+#include "types.h"
+
+struct 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 {
+
+/// One recipient split out of a header, as both parts and as a rendered whole.
+///
+/// Kept as a struct rather than a bare string because the two halves answer
+/// two different questions and conflating them is how the user's own address
+/// escapes a filter. `address` is what a comparison must use: a display name
+/// may legitimately CONTAIN an address-looking substring, and a substring test
+/// against the whole rendered form matches "not-me@example.org" for "me@example.org".
+/// `rendered` is what goes in the field the user sees.
+struct Recipient
+{
+ QString address; ///< The bare addr-spec, no display name, no angle brackets.
+ QString rendered; ///< "Name <addr>" or the bare address when it has no name.
+};
+
+/// 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);
+
+/// Splits a raw address header into individual recipients, using GMime.
+///
+/// NEVER split on commas. A display name may contain one, so
+/// `"Rossi, Mario" <m@example.org>, info@example.net` is TWO addresses and a
+/// naive split reports three, one of which ("Rossi") is not an address at all
+/// and would be handed to the send command as a recipient. This is the same
+/// reason `recipientSummary()` in mimeparser.cpp parses rather than splits.
+///
+/// Groups (`undisclosed-recipients:;`) contribute NOTHING. A group carries a
+/// name and no mailbox, so naming it would put "undisclosed-recipients" in a
+/// To field as though it were a person. This also closes a header-injection
+/// shape: a raw newline in a header value makes GMime parse the smuggled
+/// `Bcc: evil@example.net` as a GROUP, measured 2026-08-21, so dropping
+/// non-mailboxes drops the injected recipient rather than carrying it forward.
+///
+/// An unparseable header yields an empty list rather than a partial guess.
+QList<Recipient> parseAddressHeader(const QString &rawHeader);
+
+/// Who a reply goes to, as \p toOut and \p ccOut.
+///
+/// This is the function the spec calls out as where the subtle bugs live, and
+/// the rules are not interchangeable:
+///
+/// - **Reply** goes to the ORIGINAL SENDER only, and Cc is empty. Reply-To
+/// takes precedence over From when the original carries one (RFC 5322
+/// §3.6.2: it names where the author wants replies sent), which is what
+/// makes a mailing list's reply land on the list rather than on a person who
+/// never asked to be written to directly.
+/// - **Reply-all** puts the sender in To, and the original's To and Cc in Cc.
+/// The user's own addresses are stripped from BOTH, or they receive their
+/// own reply. Comparison is case-insensitive: an address's domain is
+/// case-insensitive by RFC and real mail varies the local part's case too,
+/// so a case-sensitive filter lets `User@Example.org` through against a
+/// configured `user@example.org`.
+/// - A duplicate is suppressed ACROSS To and Cc, not within each: the sender
+/// is very often also in the original's To, and listing them twice is what
+/// naive per-field deduplication produces.
+///
+/// \p replyAll false yields sender-only. \p ownAddresses is what
+/// ownAddresses(config) returned.
+///
+/// **A reply to the user's OWN message goes where that message went**, not
+/// back to the user: To comes from the original's recipients instead of from
+/// its sender. A plain reply takes its To and Cc together, having no Cc field
+/// of its own to mirror into; a reply-all MIRRORS THE SPLIT, the original's To
+/// becoming To and its Cc becoming Cc, because To means "addressed to you" and
+/// Cc "for information" and promoting a Cc'd party to To is visible to every
+/// recipient. This is reached from the Sent view, from a follow-up on
+/// unanswered mail, and from any thread whose selected row is the user's own
+/// message, so it is an ordinary gesture. "Own" means EVERY parsed sender
+/// address is the user's; a co-sender is still someone to reply to.
+///
+/// Mail the user sent to THEMSELVES alone leaves nothing after that filter, and
+/// there the sender is restored: the user is the correct recipient of their own
+/// note. Emptying To instead would produce a message with no recipient that
+/// still looks sendable, which is why stripping the sender was rejected as the
+/// fix. Nothing else strips an own address from a plain Reply's To.
+void recipientsForReply(const ParsedMessage &message, bool replyAll,
+ const QStringList &ownAddresses,
+ QStringList *toOut, QStringList *ccOut);
+
+/// The References header for a reply: the original's References plus its
+/// Message-ID.
+///
+/// Not optional. Without it a reply appears as an orphan thread in the
+/// sender's own client. A duplicate Message-ID is not appended twice.
+///
+/// Ids come back BARE, without angle brackets, matching what GMime hands back
+/// when MimeParser reads a `Message-ID`. The brackets are wire syntax and
+/// `MessageBuilder` adds them when it writes the header, in one place rather
+/// than in each caller: GMime writes an EMPTY header for a bare addr-spec
+/// rather than complaining, so a caller that forgets them ships a reply that
+/// threads nowhere while nothing looks wrong locally.
+QStringList referencesForReply(const ParsedMessage &message);
+
+/// 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.
+///
+/// An existing prefix is recognised in the non-English spellings a mixed-locale
+/// mailbox receives (`AW:`, `SV:`, `RES:`, `WG:`, `TR:`, `RV:`, `ENC:`) and in
+/// the counted forms Outlook emits (`Re[2]:`, `Re(3):`), or every one of those
+/// doubles into `Re: AW: subject`.
+///
+/// Single-letter spellings are deliberately NOT recognised, though Italian
+/// clients send `R:` and `I:`: `R: report on Q3` is an ordinary subject, and
+/// treating it as a prefix means a genuine first reply gets no `Re:` and
+/// threads nowhere. See the patterns in composecontext.cpp for the measurement.
+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.
+QString quoteBody(const ParsedMessage &message);
+
+} // namespace ComposeContextBuilder
diff --git a/src/composewindow.cpp b/src/composewindow.cpp
new file mode 100644
index 0000000..0445c5d
--- /dev/null
+++ b/src/composewindow.cpp
@@ -0,0 +1,919 @@
+/*
+ * 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 "composewindow.h"
+
+#include <QTemporaryDir>
+
+#include "draftstore.h"
+#include "messagebuilder.h"
+#include "mimeparser.h"
+#include "messagesender.h"
+#include "senddialog.h"
+
+#include <QAction>
+#include <QCheckBox>
+#include <QCloseEvent>
+#include <QComboBox>
+#include <QDir>
+#include <QFile>
+#include <QFileDialog>
+#include <QFileInfo>
+#include <QFormLayout>
+#include <QHBoxLayout>
+#include <QKeySequence>
+#include <QLabel>
+#include <QLineEdit>
+#include <QListWidget>
+#include <QMessageBox>
+#include <QPlainTextEdit>
+#include <QPushButton>
+#include <QTextCursor>
+#include <QTimer>
+#include <QToolBar>
+#include <QVBoxLayout>
+#include <QWidget>
+
+namespace {
+
+/// Splits a comma-separated recipient field into addresses.
+///
+/// Splitting on commas is WRONG for a raw header, which is why
+/// ComposeContextBuilder::parseAddressHeader parses instead. It is right here
+/// and only here: this is a field the user typed, and the composer's own
+/// rendering of it joins with ", ". A display name containing a comma has to
+/// be quoted by the user, exactly as it has to be in the wire format, and
+/// MessageBuilder is what turns each entry into a mailbox.
+QStringList splitRecipients(const QString &text)
+{
+ QStringList out;
+ const QStringList parts = text.split(QLatin1Char(','), Qt::SkipEmptyParts);
+ for (const QString &part : parts) {
+ const QString trimmed = part.trimmed();
+ if (!trimmed.isEmpty())
+ out.append(trimmed);
+ }
+ return out;
+}
+
+/// Everything about a message the user can change, as one comparable string.
+///
+/// Joined with a character no field can contain, because concatenating them
+/// bare lets a change move a boundary without changing the whole: a subject
+/// "ab" with body "c" and a subject "a" with body "bc" would produce the same
+/// string and the second edit would never be saved. A unit separator (U+001F)
+/// cannot be typed into a QLineEdit or a QPlainTextEdit and cannot appear in a
+/// file path.
+QString fingerprintOf(const OutgoingMessage &message)
+{
+ const QChar sep(QChar(0x1F));
+ return message.accountKey + sep + message.to.join(sep) + sep
+ + message.cc.join(sep) + sep + message.bcc.join(sep) + sep
+ + message.subject + sep + message.markdownBody + sep
+ + (message.sendHtml ? QStringLiteral("1") : QStringLiteral("0")) + sep
+ + message.attachments.join(sep);
+}
+
+} // namespace
+
+ComposeWindow::ComposeWindow(const ComposeContext &context,
+ const Config &config, const QString &mailRoot,
+ QWidget *parent)
+ : QMainWindow(parent)
+ , m_context(context)
+ , m_config(config)
+ , m_mailRoot(mailRoot)
+ , m_attachments(context.attachments)
+{
+ // 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. Passing a
+ // parent still makes Qt treat it as a window because of Qt::Window, which
+ // QMainWindow carries.
+ setAttribute(Qt::WA_DeleteOnClose);
+ setWindowTitle(tr("Compose"));
+
+ // A sensible default. NOT restored and NOT saved; see the header.
+ resize(760, 640);
+
+ // BEFORE buildUi(), and this ordering is load-bearing rather than
+ // stylistic. buildUi() connects every field to markDirty(), and seeding
+ // then fills those fields, so markDirty() runs during construction and
+ // calls m_autosaveTimer->start(). Created afterwards, that is a null
+ // dereference on the first seeded field, which is every composer.
+ 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);
+
+ buildUi();
+ buildFormatToolbar();
+ seedFields();
+ seedBody();
+
+ // AFTER buildUi(), which creates m_banner, and BEFORE
+ // refreshAttachmentList(), which renders m_attachments: extraction appends
+ // to that list, so listing first would show a Forward with no attachments
+ // on it, which is precisely the defect this fixes.
+ extractForwardedAttachments();
+
+ refreshAttachmentList();
+
+ // Seeding is not an edit. Every field was just filled from the context, so
+ // the widgets have emitted their change signals and left the window dirty
+ // before the user has typed anything; a composer opened and closed at once
+ // would then write a draft nobody asked for. The timer is stopped as well
+ // as the flag cleared, since markDirty() started it.
+ m_dirty = false;
+ m_autosaveTimer->stop();
+}
+
+
+ComposeWindow::~ComposeWindow() = default;
+
+void ComposeWindow::extractForwardedAttachments()
+{
+ if (m_context.kind != ComposeContext::Kind::Forward
+ || m_context.originalPath.isEmpty()) {
+ return;
+ }
+
+ MimeParser parser;
+ const ParsedMessage original = parser.parse(m_context.originalPath);
+ if (!original.ok || original.attachments.isEmpty())
+ return;
+
+ m_forwardedParts = std::make_unique<QTemporaryDir>();
+ if (!m_forwardedParts->isValid()) {
+ m_forwardedParts.reset();
+ m_banner->setText(
+ tr("The forwarded attachments could not be extracted."));
+ m_banner->show();
+ return;
+ }
+
+ // Not auto-removed on destruction by accident: QTemporaryDir does this by
+ // default, and it is the whole reason the directory rather than the files
+ // is what this window owns.
+ m_forwardedParts->setAutoRemove(true);
+
+ QStringList failed;
+ for (const Attachment &attachment : original.attachments) {
+ QString error;
+ // saveWithoutOverwriting, never saveTo. One message really can carry
+ // two parts with the same filename, and saveTo overwrites: CLAUDE.md
+ // records six of sixteen files lost that way, every write reporting
+ // success. Here it would silently forward fewer files than the
+ // original had.
+ const QString written =
+ attachment.saveWithoutOverwriting(m_forwardedParts->path(), &error);
+ if (written.isEmpty()) {
+ failed.append(attachment.safeFilename());
+ continue;
+ }
+ m_attachments.append(written);
+ }
+
+ if (!failed.isEmpty()) {
+ // Said out loud rather than swallowed. The composer looks entirely
+ // correct with an attachment missing, and the recipient gets a body
+ // quoting a document that is not there.
+ m_banner->setText(
+ tr("%n forwarded attachment(s) could not be extracted: %1", "",
+ failed.size())
+ .arg(failed.join(QStringLiteral(", "))));
+ m_banner->show();
+ }
+}
+
+Account ComposeWindow::currentAccount() const
+{
+ // The dropdown is the authority once the window is open: the context
+ // chooses the initial account and the user may then change it, and every
+ // build after that must use what the From field shows. Reading
+ // m_context.accountKey here instead would send from the seeded account
+ // however the dropdown was set, with the interface saying otherwise.
+ if (m_from && m_from->currentIndex() >= 0) {
+ const QString key = m_from->currentData().toString();
+ if (!key.isEmpty())
+ return m_config.account(key);
+ }
+ return m_config.account(m_context.accountKey);
+}
+
+void ComposeWindow::buildUi()
+{
+ auto *central = new QWidget(this);
+ central->setObjectName(QStringLiteral("composeCentral"));
+ auto *layout = new QVBoxLayout(central);
+
+ // The failed-save banner, above everything: a warning that must survive
+ // until it is dealt with does not belong below the fold. Hidden until
+ // there is something to say.
+ m_banner = new QLabel(central);
+ m_banner->setObjectName(QStringLiteral("draftBanner"));
+ m_banner->setWordWrap(true);
+ // PlainText explicitly. The text carries a filesystem error string and a
+ // path, neither of which is ours, and a QLabel guesses under AutoText.
+ m_banner->setTextFormat(Qt::PlainText);
+ m_banner->hide();
+ layout->addWidget(m_banner);
+
+ auto *form = new QFormLayout;
+
+ m_from = new QComboBox(central);
+ m_from->setObjectName(QStringLiteral("from"));
+ form->addRow(tr("From:"), m_from);
+
+ m_to = new QLineEdit(central);
+ m_to->setObjectName(QStringLiteral("to"));
+ form->addRow(tr("To:"), m_to);
+
+ m_cc = new QLineEdit(central);
+ m_cc->setObjectName(QStringLiteral("cc"));
+ form->addRow(tr("Cc:"), m_cc);
+
+ m_bcc = new QLineEdit(central);
+ m_bcc->setObjectName(QStringLiteral("bcc"));
+ form->addRow(tr("Bcc:"), m_bcc);
+
+ m_subject = new QLineEdit(central);
+ m_subject->setObjectName(QStringLiteral("subject"));
+ form->addRow(tr("Subject:"), m_subject);
+
+ layout->addLayout(form);
+
+ // Labelled for what it does, a formatted copy riding along with the plain
+ // text, rather than "HTML", which reads as an either/or that it is not.
+ m_sendHtml = new QCheckBox(tr("Also send a formatted copy"), central);
+ m_sendHtml->setObjectName(QStringLiteral("sendHtml"));
+ m_sendHtml->setToolTip(
+ tr("Sends the message as plain text with a formatted version "
+ "alongside it. The plain text is what you typed."));
+ layout->addWidget(m_sendHtml);
+
+ m_body = new QPlainTextEdit(central);
+ m_body->setObjectName(QStringLiteral("body"));
+ layout->addWidget(m_body, 1);
+
+ m_attachmentList = new QListWidget(central);
+ m_attachmentList->setObjectName(QStringLiteral("attachments"));
+ m_attachmentList->setMaximumHeight(90);
+ m_attachmentList->hide();
+ layout->addWidget(m_attachmentList);
+
+ // The send-failure pane, in the shape MainWindow's sync log already has:
+ // a header with a Close button and a read-only QPlainTextEdit under it. A
+ // QPlainTextEdit has no close affordance of its own, so the two travel
+ // together as one widget.
+ m_sendLogPane = new QWidget(central);
+ m_sendLogPane->setObjectName(QStringLiteral("sendLogPane"));
+ auto *logLayout = new QVBoxLayout(m_sendLogPane);
+ logLayout->setContentsMargins(0, 0, 0, 0);
+ logLayout->setSpacing(2);
+
+ auto *logHeader = new QHBoxLayout;
+ logHeader->addWidget(new QLabel(tr("Send output"), m_sendLogPane));
+ logHeader->addStretch();
+ auto *closeLog = new QPushButton(tr("Close"), m_sendLogPane);
+ closeLog->setObjectName(QStringLiteral("closeSendLog"));
+ connect(closeLog, &QPushButton::clicked, m_sendLogPane, &QWidget::hide);
+ logHeader->addWidget(closeLog);
+ logLayout->addLayout(logHeader);
+
+ m_sendLog = new QPlainTextEdit(m_sendLogPane);
+ m_sendLog->setObjectName(QStringLiteral("sendLog"));
+ m_sendLog->setReadOnly(true);
+ m_sendLog->setMaximumHeight(140);
+ logLayout->addWidget(m_sendLog);
+
+ m_sendLogPane->hide();
+ layout->addWidget(m_sendLogPane);
+
+ setCentralWidget(central);
+
+ // Every field marks the buffer dirty. The subject and the recipients are
+ // part of the message as much as the body is, and a draft that saved the
+ // body but not the address it was going to would be worse than none.
+ connect(m_body, &QPlainTextEdit::textChanged, this,
+ &ComposeWindow::markDirty);
+ for (QLineEdit *field : { m_to, m_cc, m_bcc, m_subject })
+ connect(field, &QLineEdit::textChanged, this, &ComposeWindow::markDirty);
+ connect(m_sendHtml, &QCheckBox::toggled, this, &ComposeWindow::markDirty);
+ connect(m_from, &QComboBox::currentIndexChanged, this,
+ &ComposeWindow::markDirty);
+}
+
+void ComposeWindow::buildFormatToolbar()
+{
+ m_formatToolbar = addToolBar(tr("Formatting"));
+ m_formatToolbar->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. These six do not
+ // participate in item 132's reachability rule for the same reason.
+ const auto addFormat = [this](const QString &name, const QString &text,
+ const QString &token,
+ const QKeySequence &shortcut) {
+ QAction *action = m_formatToolbar->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().
+ QAction *link = m_formatToolbar->addAction(tr("Link"));
+ link->setObjectName(QStringLiteral("format_link"));
+ link->setShortcut(QKeySequence(QStringLiteral("Ctrl+K")));
+ connect(link, &QAction::triggered, this, [this]() {
+ const QTextCursor cursor = m_body->textCursor();
+ applyEdit(MarkdownFormat::link(m_body->toPlainText(),
+ cursor.selectionStart(),
+ cursor.selectionEnd()));
+ });
+
+ QAction *quote = m_formatToolbar->addAction(tr("Quote"));
+ quote->setObjectName(QStringLiteral("format_quote"));
+ connect(quote, &QAction::triggered, this, [this]() {
+ const QTextCursor cursor = m_body->textCursor();
+ applyEdit(MarkdownFormat::quote(m_body->toPlainText(),
+ cursor.selectionStart(),
+ cursor.selectionEnd()));
+ });
+
+ m_formatToolbar->addSeparator();
+
+ m_attachAction = m_formatToolbar->addAction(tr("Attach..."));
+ m_attachAction->setObjectName(QStringLiteral("compose_attach"));
+ connect(m_attachAction, &QAction::triggered, this, [this]() {
+ const QStringList chosen = QFileDialog::getOpenFileNames(
+ this, tr("Attach files"));
+ for (const QString &path : chosen)
+ attachFile(path);
+ });
+
+ m_detachAction = m_formatToolbar->addAction(tr("Remove attachment"));
+ m_detachAction->setObjectName(QStringLiteral("compose_detach"));
+ connect(m_detachAction, &QAction::triggered, this, [this]() {
+ const int row = m_attachmentList->currentRow();
+ if (row < 0 || row >= m_attachments.size())
+ return;
+ m_attachments.removeAt(row);
+ refreshAttachmentList();
+ markDirty();
+ });
+
+ m_sendAction = m_formatToolbar->addAction(tr("Send"));
+ m_sendAction->setObjectName(QStringLiteral("compose_send"));
+ m_sendAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+Return")));
+ connect(m_sendAction, &QAction::triggered, this, &ComposeWindow::send);
+}
+
+void ComposeWindow::seedFields()
+{
+ // Only accounts that can send. An account without a send_command is
+ // receive-only by construction, and offering it in a From field would
+ // produce a message that cannot be sent from the account it says it is
+ // from.
+ const QList<Account> senders = m_config.sendingAccounts();
+ for (const Account &account : senders) {
+ const QString label = account.name.isEmpty()
+ ? account.address
+ : account.name + QStringLiteral(" <")
+ + account.address + QLatin1Char('>');
+ m_from->addItem(label, account.key);
+ }
+ const int index = m_from->findData(m_context.accountKey);
+ if (index >= 0)
+ m_from->setCurrentIndex(index);
+
+ m_to->setText(m_context.to.join(QStringLiteral(", ")));
+ m_cc->setText(m_context.cc.join(QStringLiteral(", ")));
+ m_subject->setText(m_context.subject);
+
+ // New and Forward seed from [compose] send_html; Reply and Reply-all seed
+ // from whether the original carried a text/html part, ignoring the config
+ // value. An HTML part in the original is a fact about the sender's
+ // software, not a guess about their taste.
+ const bool isReply = m_context.kind == ComposeContext::Kind::Reply
+ || m_context.kind == ComposeContext::Kind::ReplyAll;
+ m_sendHtml->setChecked(isReply ? m_context.seedHtml
+ : m_config.compose().sendHtml);
+}
+
+void ComposeWindow::seedBody()
+{
+ if (m_context.quotedBody.isEmpty())
+ return;
+
+ // 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.
+ if (m_config.compose().quotePosition
+ == ComposeSettings::QuotePosition::Above) {
+ // The quote first, then a blank line for the reply to be typed into.
+ m_body->setPlainText(m_context.quotedBody + QStringLiteral("\n\n"));
+ } else {
+ m_body->setPlainText(QStringLiteral("\n\n") + m_context.quotedBody);
+ }
+
+ // The cursor at the very top in both cases: with the quote below, the
+ // blank lines the reply goes into are at the top; with it above, the user
+ // scrolls past what they are answering, which is what quoting above means.
+ m_body->moveCursor(QTextCursor::Start);
+
+ // The seeded quote is not an edit the user made, so it must not survive as
+ // an undo step: one Ctrl+Z on a fresh composer would otherwise wipe the
+ // quote and read as the buffer losing its content.
+ m_body->document()->clearUndoRedoStacks();
+}
+
+void ComposeWindow::refreshAttachmentList()
+{
+ m_attachmentList->clear();
+ for (const QString &path : m_attachments)
+ m_attachmentList->addItem(QFileInfo(path).fileName());
+ m_attachmentList->setVisible(!m_attachments.isEmpty());
+}
+
+bool ComposeWindow::attachmentNeedsWarning(qint64 size) const
+{
+ const qint64 limit = m_config.compose().attachmentWarnBytes;
+ // A limit of zero or less disables the warning outright. Treating it as a
+ // threshold would warn about every attachment including an empty one,
+ // which is the opposite of what turning a warning off means.
+ return limit > 0 && size > limit;
+}
+
+/// A byte count as a figure a person reads, with one decimal below 10 units.
+///
+/// Integer MB division is what this replaces and it produced "'x' is 0 MB.
+/// Many mail servers refuse messages above about 0 MB.", which is what any
+/// attachment_warn_bytes under a megabyte reads as. The unit steps down as
+/// well, so a small configured limit is stated in KB rather than as zero of a
+/// larger unit.
+QString ComposeWindow::humanSize(qint64 bytes)
+{
+ constexpr qint64 kKb = 1024;
+ constexpr qint64 kMb = 1024 * 1024;
+
+ if (bytes >= kMb) {
+ const double mb = double(bytes) / double(kMb);
+ // One decimal only while the figure is small enough for it to say
+ // something; 26.2 MB is informative, 1234.6 MB is noise.
+ return mb < 10.0 ? QObject::tr("%1 MB").arg(mb, 0, 'f', 1)
+ : QObject::tr("%1 MB").arg(qRound(mb));
+ }
+ if (bytes >= kKb) {
+ const double kb = double(bytes) / double(kKb);
+ return kb < 10.0 ? QObject::tr("%1 KB").arg(kb, 0, 'f', 1)
+ : QObject::tr("%1 KB").arg(qRound(kb));
+ }
+ return QObject::tr("%1 bytes").arg(bytes);
+}
+
+void ComposeWindow::attachFile(const QString &path)
+{
+ const QFileInfo info(path);
+
+ if (attachmentNeedsWarning(info.size())) {
+ const qint64 limit = m_config.compose().attachmentWarnBytes;
+ const auto answer = QMessageBox::question(
+ this, tr("Large attachment"),
+ tr("'%1' is %2. Many mail servers refuse messages above about "
+ "%3. Attach it anyway?")
+ .arg(info.fileName(), humanSize(info.size()),
+ humanSize(limit)),
+ QMessageBox::Yes | QMessageBox::No);
+ if (answer != QMessageBox::Yes)
+ return;
+ }
+
+ m_attachments.append(path);
+ refreshAttachmentList();
+ markDirty();
+}
+
+OutgoingMessage ComposeWindow::currentMessage() const
+{
+ OutgoingMessage message;
+ message.accountKey = currentAccount().key;
+ message.to = splitRecipients(m_to->text());
+ message.cc = splitRecipients(m_cc->text());
+ message.bcc = splitRecipients(m_bcc->text());
+ message.subject = m_subject->text();
+ message.markdownBody = m_body->toPlainText();
+ message.sendHtml = m_sendHtml->isChecked();
+ message.attachments = m_attachments;
+ message.inReplyTo = m_context.inReplyTo;
+ message.references = m_context.references;
+ return message;
+}
+
+void ComposeWindow::applyEdit(const MarkdownFormat::Edit &edit)
+{
+ // A QTextCursor replacement rather than setPlainText(), and this is a
+ // correction of the plan's draft. Measured under the offscreen platform:
+ // setPlainText() DESTROYS the document's undo stack (isUndoAvailable goes
+ // from true to false) and resets the cursor to position 0, so every
+ // toolbar press would throw away everything the user could undo. A
+ // document-wide select and insertText inside one edit block leaves undo
+ // available, collapses to a SINGLE undo step, and emits textChanged once.
+ QTextCursor cursor = m_body->textCursor();
+ cursor.beginEditBlock();
+ cursor.select(QTextCursor::Document);
+ cursor.insertText(edit.text);
+ cursor.endEditBlock();
+
+ // Restore the selection the transformation asked for. The cursor is left
+ // at the end of the inserted text, so without this every button press
+ // sends it to the bottom of the message; the empty-selection case relies
+ // on it to land BETWEEN the tokens, which is the property a user notices
+ // immediately when it is wrong.
+ //
+ // Clamped rather than trusted: QTextCursor::setPosition() past the end
+ // warns on stderr and silently clamps, so a stale or arithmetic position
+ // would produce noise rather than an error. MarkdownFormat clamps its own
+ // output too, so this is a second line rather than the only one.
+ const int length = m_body->toPlainText().length();
+ const int start = qBound(0, edit.selectionStart, length);
+ const int end = qBound(start, edit.selectionEnd, length);
+
+ QTextCursor restored = m_body->textCursor();
+ restored.setPosition(start);
+ restored.setPosition(end, QTextCursor::KeepAnchor);
+ m_body->setTextCursor(restored);
+ m_body->setFocus();
+}
+
+void ComposeWindow::applyFormat(const QString &token)
+{
+ const QTextCursor cursor = m_body->textCursor();
+ applyEdit(MarkdownFormat::wrap(m_body->toPlainText(),
+ cursor.selectionStart(),
+ cursor.selectionEnd(), token));
+}
+
+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;
+ saveDraftNow();
+}
+
+bool ComposeWindow::saveDraftNow()
+{
+ const Account account = currentAccount();
+ 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. Reported
+ // as success because nothing failed: a false here would make the quit
+ // path offer a retry that cannot change anything.
+ return true;
+ }
+
+ const OutgoingMessage message = currentMessage();
+
+ // The dirty CHECK, not just the flag: an unchanged message means no file
+ // is written and no sync is provoked. Every autosave produces a Maildir
+ // write that mbsync uploads, so this and the debounce together are what
+ // keep a message to a few revisions rather than dozens.
+ //
+ // Checked BEFORE the build, and on the message rather than on the bytes.
+ // The plan's draft compared built.bytes, which can never match: GMime is
+ // given a fresh Date and Message-ID on every build, so two builds of an
+ // unchanged message differ. That check would have read as working while
+ // writing a file on every debounce. Doing it first also skips the
+ // blocking build entirely for the no-change case, which is the common one.
+ const QString fingerprint = fingerprintOf(message);
+ if (!m_savedFingerprint.isEmpty() && fingerprint == m_savedFingerprint) {
+ m_dirty = false;
+ return true;
+ }
+
+ // MessageBuilder::build() is SYNCHRONOUS and can block: a large attachment
+ // is read and base64-encoded on this thread, which is the GUI thread. A
+ // debounce firing with a 25MB attachment therefore stalls typing for as
+ // long as the read takes. Deliberately not moved to a thread: nothing here
+ // crosses the worker boundary, and a second threading model for one call
+ // is worse than the stall. If someone is measuring a composer freeze, this
+ // line is where to look.
+ const MessageBuilder::Result built = MessageBuilder::build(message, 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 false;
+ }
+
+ 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 false;
+ }
+
+ m_draftPath = written.path;
+ m_savedFingerprint = fingerprint;
+ m_dirty = false;
+ m_saveFailed = false;
+ m_banner->hide();
+ return true;
+}
+
+void ComposeWindow::setInputsEnabled(bool enabled)
+{
+ // Every input for the WHOLE operation, countdown included. The message
+ // must not change between the user pressing Send and the bytes being
+ // built. The send-failure pane is deliberately left alone: it is read-only
+ // and disabling it would make the stderr it carries unreadable.
+ m_to->setEnabled(enabled);
+ m_cc->setEnabled(enabled);
+ m_bcc->setEnabled(enabled);
+ m_subject->setEnabled(enabled);
+ m_from->setEnabled(enabled);
+ m_body->setReadOnly(!enabled);
+ m_sendHtml->setEnabled(enabled);
+ m_attachmentList->setEnabled(enabled);
+ m_formatToolbar->setEnabled(enabled);
+}
+
+void ComposeWindow::showSendFailure(const QString &stderrText)
+{
+ m_sendLog->setPlainText(stderrText.isEmpty()
+ ? tr("The send command reported no output.")
+ : stderrText);
+ m_sendLogPane->show();
+}
+
+void ComposeWindow::send()
+{
+ // Refused outright while a send operation is up, countdown included.
+ // setInputsEnabled(false) disables the toolbar the Send action lives on
+ // and SendDialog is window-modal, so a user cannot reach this twice; the
+ // guard covers the programmatic route, where a second call would put a
+ // second dialog over the first and start a send MessageSender then
+ // refuses, leaving a popup with no result coming for it.
+ if (m_sendInFlight)
+ return;
+ m_sendInFlight = true;
+
+ const Account account = currentAccount();
+
+ if (!account.canSend()) {
+ QMessageBox::warning(
+ this, tr("Cannot send"),
+ tr("The account '%1' has no send command configured.")
+ .arg(account.key));
+ m_sendInFlight = false;
+ return;
+ }
+
+ 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);
+ m_sendInFlight = false;
+ return;
+ }
+
+ // Every input is disabled for the WHOLE operation, countdown included.
+ 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.
+ //
+ // deleteLater(), never delete: this runs SYNCHRONOUSLY inside
+ // SendDialog::undo(), which emits undone() and then calls reject() on
+ // itself (senddialog.cpp), so the dialog is still on the stack here.
+ // This is CLAUDE.md's "a modal dialog must close BEFORE the action it
+ // asked for runs" arriving from the other side, and deleteLater is
+ // what makes it safe: it posts a deletion event rather than freeing
+ // the object the caller is about to keep using. A plain delete here
+ // would return into a destroyed SendDialog's reject().
+ m_sendInFlight = false;
+ setInputsEnabled(true);
+ dialog->deleteLater();
+ });
+
+ connect(dialog, &SendDialog::committed, this,
+ [this, dialog, built, account]() {
+ // No setStage(Sending) here: SendDialog::commit() sets it before
+ // emitting committed(), so doing it again would be a second owner of
+ // the same state.
+
+ // Qt::SingleShotConnection IS REQUIRED HERE. m_sender is a long-lived
+ // member, so a bare connect() beside each send() accumulates a
+ // permanent receiver per send. Send, fail, correct the recipient, send
+ // again, and the second result runs BOTH lambdas: the first still
+ // holds the FIRST message's `built` and `account` by value, so it
+ // files a sent copy of the wrong message and calls accept() on a
+ // dialog it already deleteLater()'d. MessageSender's m_reported guard
+ // cannot prevent this: it collapses two QProcess signals into one
+ // emit, and this is one emit reaching many receivers. Measured in
+ // test_messagesender.cpp::aPerSendConnectionMustBeSingleShot, where
+ // the bare shape delivers 3 results for 2 sends and the single-shot
+ // shape delivers 2.
+ const QMetaObject::Connection resultConnection = connect(
+ m_sender, &MessageSender::finished, this,
+ [this, dialog, built, account](bool sent, const QString &error) {
+ m_sendInFlight = false;
+
+ if (!sent) {
+ dialog->accept();
+ dialog->deleteLater();
+ setInputsEnabled(true);
+
+ // The draft stays, and it must be the draft of what was just
+ // attempted. send() builds from the widgets without saving, so
+ // the revision on disk is whatever the last debounce wrote:
+ // edit, send, fail, close, and the user gets the OLDER text
+ // back, having watched their correction be sent. No retry
+ // loop, but the text that failed to go is kept.
+ saveDraftNow();
+
+ 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);
+ m_draftPath.clear();
+ }
+
+ 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. m_finished stops closeEvent() saving a draft for a
+ // message that is gone, and stops it refusing the close.
+ m_finished = true;
+ m_dirty = false;
+ close();
+ }, Qt::SingleShotConnection);
+
+ if (!m_sender->send(account.sendCommand, built.bytes)) {
+ // Refused before any process started, so no finished() will ever
+ // arrive and the single-shot connection above would sit there for
+ // good. Disconnected here rather than left, since the next send
+ // would then have two receivers, which is exactly the defect the
+ // flag exists to prevent.
+ //
+ // THE HANDLE, not disconnect(m_sender, &finished, this, nullptr).
+ // That form drops every finished receiver on this object, so one
+ // connection added anywhere else would be killed here silently,
+ // and the failure it produces is not a wrong value but silence: a
+ // send whose result nobody processes, leaving the popup on
+ // "Sending...", the composer disabled, and no error anywhere.
+ //
+ // UNTESTED, and deliberately so rather than by omission. This
+ // branch is currently UNREACHABLE: MessageSender::send() returns
+ // false only for an empty command or a command already running,
+ // and canSend() rejects the first while m_sendInFlight rejects the
+ // second before either can arrive here. QSettings also unquotes
+ // every INI value, so no configured string survives trimming yet
+ // splits to nothing. A test would have to reach past the public
+ // surface to provoke it, and a test that cannot fail is worse than
+ // none. Kept because it costs nothing and stops being dead the
+ // moment send() grows a third refusal, which is the shape an
+ // outbox drain loop would add.
+ m_sendInFlight = false;
+ disconnect(resultConnection);
+ dialog->accept();
+ dialog->deleteLater();
+ setInputsEnabled(true);
+ showSendFailure(tr("The send command could not be started."));
+ }
+ });
+
+ dialog->open();
+}
+
+void ComposeWindow::closeEvent(QCloseEvent *event)
+{
+ // Refused for the WHOLE send, countdown included, and the countdown half
+ // is the one easily lost. A guard that starts at commit leaves the five
+ // seconds before it unprotected: closing then destroys this window, takes
+ // the parented SendDialog down with it, and committed() never fires, so
+ // the user pressed Send, watched a countdown, and believes the mail went.
+ // After commit the reason is the one MessageSender's destructor
+ // documents: a live SMTP conversation abandoned is an outcome nobody can
+ // report truthfully.
+ //
+ // Both windows close themselves when the operation ends, so refusing here
+ // strands nothing.
+ if (m_sendInFlight && !m_finished) {
+ event->ignore();
+ return;
+ }
+
+ // The last-moment autosave, and the reason it is here rather than in the
+ // quit path: the debounce means a composer closed inside its interval has
+ // unwritten text, and WA_DeleteOnClose destroys the window immediately
+ // after this. Without this call, typing a paragraph and pressing the
+ // window manager's X inside thirty seconds loses it silently, with no
+ // prompt and no write, which is exactly the loss the autosave design
+ // exists to prevent.
+ //
+ // Its failure is deliberately NOT allowed to refuse the close. A window
+ // that will not close because it cannot save is worse than one that closes
+ // having said so: the banner is already up from saveDraftNow(), and the
+ // quit path reads lastSaveFailed() to escalate. Task 12 owns that dialog;
+ // this call is what makes there be something to escalate ABOUT.
+ if (m_dirty && !m_finished)
+ saveDraftNow();
+
+ emit closed(this);
+ QMainWindow::closeEvent(event);
+}
diff --git a/src/composewindow.h b/src/composewindow.h
new file mode 100644
index 0000000..af50be6
--- /dev/null
+++ b/src/composewindow.h
@@ -0,0 +1,267 @@
+/*
+ * 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.
+ */
+
+#pragma once
+
+#include <QMainWindow>
+#include <QStringList>
+
+#include <memory>
+
+#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 QAction;
+class QCheckBox;
+class QComboBox;
+class QLabel;
+class QLineEdit;
+class QListWidget;
+class QPlainTextEdit;
+class QTimer;
+class QToolBar;
+class QTemporaryDir;
+class QWidget;
+
+class MessageSender;
+
+/// 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 and no geometry save. 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.
+///
+/// It contains no MIME and no process logic: a composer bug and a MIME bug are
+/// found in different files. Everything it does with a message goes through
+/// MessageBuilder, DraftStore, MessageSender, MarkdownFormat and SendDialog.
+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);
+
+ /// Defined in the .cpp, not defaulted here. m_forwardedParts is a
+ /// unique_ptr to a forward-declared QTemporaryDir, whose deleter needs the
+ /// complete type; an implicit destructor would be generated here, where it
+ /// is still incomplete.
+ ~ComposeWindow() override;
+
+ /// 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.
+ ///
+ /// Returns TRUE when the account configures no drafts folder: nothing was
+ /// written and nothing failed, and reporting a failure would make the quit
+ /// path offer a retry for a state no retry can change. The composer
+ /// running without draft protection is warned about at startup instead.
+ bool saveDraftNow();
+
+ /// What the composer would send or save right now.
+ ///
+ /// Public so a test can assert on the message the widgets produce without
+ /// building MIME, and so the quit path can be reasoned about from values.
+ OutgoingMessage currentMessage() const;
+
+ /// The paths currently attached, in the order they were attached.
+ QStringList attachments() const { return m_attachments; }
+
+ /// Attaches \p path, asking first when it is larger than
+ /// [compose] attachment_warn_bytes.
+ ///
+ /// A warning rather than a refusal: the limit belongs to the recipient's
+ /// server, which this application cannot know, so the user decides.
+ void attachFile(const QString &path);
+
+ /// A byte count as a figure a person reads.
+ ///
+ /// Static and public so the formatting is testable without a modal. The
+ /// integer MB division this replaces produced "0 MB" for any
+ /// attachment_warn_bytes under a megabyte, in both halves of the same
+ /// sentence.
+ static QString humanSize(qint64 bytes);
+
+ /// Whether \p size would raise the large-attachment question.
+ ///
+ /// Split out so the threshold is testable without a modal. A limit of zero
+ /// or less disables the warning outright rather than warning about
+ /// everything.
+ bool attachmentNeedsWarning(qint64 size) const;
+
+signals:
+ /// The composer finished with its message, one way or another, and the
+ /// registry should forget it.
+ ///
+ /// Emitted from the close path, so a registry connected to it can drop its
+ /// pointer before WA_DeleteOnClose destroys the window.
+ void closed(ComposeWindow *window);
+
+protected:
+ /// The one place the registry is told, whichever route closes the window.
+ void closeEvent(QCloseEvent *event) override;
+
+private:
+ void buildUi();
+ void buildFormatToolbar();
+ void seedFields();
+
+ /// Extracts a forwarded message's parts into m_forwardedParts and appends
+ /// their paths to m_attachments.
+ ///
+ /// The spec requires Forward to carry attachments, and they have to become
+ /// FILES because MessageBuilder reads every attachment by path. Extraction
+ /// happens here rather than in MainWindow so the files and the directory
+ /// that owns them are created together and die together.
+ ///
+ /// A part that cannot be written is SKIPPED with a banner rather than
+ /// failing the forward: some of the attachments is better than none, and
+ /// MessageBuilder refuses a build naming any path that later vanishes, so
+ /// a silently wrong send is not among the outcomes.
+ void extractForwardedAttachments();
+ void seedBody();
+ void refreshAttachmentList();
+ 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);
+ Account currentAccount() const;
+
+ ComposeContext m_context;
+ Config m_config;
+ QString m_mailRoot;
+ QStringList m_attachments;
+
+ /// Holds the parts a Forward extracted, for exactly as long as this window.
+ ///
+ /// Owned HERE rather than by MainWindow, because the lifetime that makes
+ /// sense is the composer's: MessageBuilder reads every attachment by PATH
+ /// at build time (messagebuilder.cpp:212), on each autosave and again at
+ /// send, so the files must outlive every build this window performs and
+ /// nothing after it. QTemporaryDir's destructor removes the tree, so
+ /// closing without sending cleans up rather than leaking.
+ ///
+ /// A draft does not depend on it. Autosave writes a COMPLETE MIME message
+ /// with the bytes embedded, so a saved draft stays valid after these files
+ /// are gone; and DraftStore is write-only, with no reopen path anywhere in
+ /// this codebase, so the "reopened next session pointing at a dead temp
+ /// path" hazard cannot arise. Should a reopen path ever be added, it must
+ /// read attachments back out of the draft's own MIME rather than trusting
+ /// a stored path.
+ ///
+ /// Null unless a Forward actually extracted something. unique_ptr because
+ /// QTemporaryDir is neither copyable nor movable.
+ std::unique_ptr<QTemporaryDir> m_forwardedParts;
+
+ 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;
+ QListWidget *m_attachmentList = nullptr;
+ QWidget *m_sendLogPane = nullptr;
+ QPlainTextEdit *m_sendLog = nullptr;
+ QToolBar *m_formatToolbar = nullptr;
+ QAction *m_sendAction = nullptr;
+ QAction *m_attachAction = nullptr;
+ QAction *m_detachAction = nullptr;
+
+ QTimer *m_autosaveTimer = nullptr;
+ MessageSender *m_sender = nullptr;
+
+ QString m_draftPath; ///< The revision on disk, unlinked on the next write.
+
+ /// A fingerprint of the message the last successful save wrote, for the
+ /// dirty CHECK.
+ ///
+ /// NOT the built bytes, and that is a correction of the plan's draft.
+ /// MessageBuilder generates a fresh Date and Message-ID on every build
+ /// (measured, messagebuilder.cpp around the g_mime_message_set_date call),
+ /// so two builds of an unchanged message never compare equal and a check
+ /// on the bytes can never fire. It would read as working while writing a
+ /// file, and an mbsync upload, on every debounce.
+ QString m_savedFingerprint;
+ bool m_dirty = false;
+ bool m_saveFailed = false;
+
+ /// True from the moment Send is pressed until the operation ends, however
+ /// it ends: the countdown, the command, the sent copy.
+ ///
+ /// ONE flag, covering the whole operation, and an earlier revision had two
+ /// because a narrower "committed and running" flag reads as the honest
+ /// thing to guard a live SMTP conversation with. It is not: every question
+ /// this window has to answer while sending has the same answer through the
+ /// countdown as after it. A close during the countdown destroys the
+ /// parented SendDialog and committed() never fires, so the user watches a
+ /// countdown for a message that is never sent, and a second Send during
+ /// the countdown opens a second popup. Splitting the two left the narrower
+ /// flag written in three places and read in none.
+ bool m_sendInFlight = false;
+
+ /// Set once the message has gone, so the close that follows a successful
+ /// send is neither refused nor made to write a draft.
+ ///
+ /// The close-REFUSAL half is load-bearing: m_sendInFlight is cleared in
+ /// the same handler, and without m_finished the composer's own close would
+ /// depend on that clear having already happened, which is a race rather
+ /// than a guarantee.
+ ///
+ /// The last-moment-SAVE half is deliberately redundant, and it is worth
+ /// saying so rather than letting the next reader mistake it for load
+ /// bearing: the send handler already clears m_dirty, so either condition
+ /// alone stops the save. Measured, each survives the other's removal and
+ /// only dropping both puts the draft of an already-sent message back on
+ /// disk. Kept because the two say different things, "nothing to write" and
+ /// "this window is done", and a future path that finishes without clearing
+ /// m_dirty would otherwise resurrect a sent message's draft silently.
+ bool m_finished = false;
+};
diff --git a/src/config.cpp b/src/config.cpp
index a2d1cec..c6bedd2 100644
--- a/src/config.cpp
+++ b/src/config.cpp
@@ -23,6 +23,8 @@
// so this reports the same numbers rather than keeping a second copy.
#include "messageview.h"
+#include <algorithm>
+
#include <QDateTime>
#include <QDir>
#include <QFile>
@@ -469,6 +471,12 @@ void Config::load(const QString &path)
account.inbox =
settings.value(QStringLiteral("inbox")).toString().trimmed();
+ // Optional, and its absence IS the receive-only state: see the field
+ // comment in config.h. Run without a shell, so trimming here is only
+ // whitespace hygiene, never a quoting concern.
+ account.sendCommand =
+ settings.value(QStringLiteral("send_command")).toString().trimmed();
+
// Both optional, and both describe this account's chip in the thread
// list. An account tag is a different taxonomy from a functional one,
// saying which mailbox a thread arrived in rather than what state it
@@ -516,6 +524,93 @@ void Config::load(const QString &path)
m_accounts.append(account);
}
+ settings.beginGroup(QStringLiteral("compose"));
+ // Absent keys stay silent (the struct's own default holds), but a
+ // PRESENT and malformed value is reported: value(key, default) alone
+ // would happily accept "quote_position = abov" as Above, matching every
+ // other enum-ish key in this file (sync_on_exit, language, date_format)
+ // rather than being the one silent exception.
+ const QString quotePosition =
+ settings.value(QStringLiteral("quote_position"), QStringLiteral("above"))
+ .toString().trimmed();
+ if (quotePosition.compare(QStringLiteral("above"), Qt::CaseInsensitive) == 0) {
+ m_compose.quotePosition = ComposeSettings::QuotePosition::Above;
+ } else if (quotePosition.compare(QStringLiteral("below"), Qt::CaseInsensitive) == 0) {
+ m_compose.quotePosition = ComposeSettings::QuotePosition::Below;
+ } else {
+ addProblem(tr("[compose] quote_position '%1' is not recognised; "
+ "expected above or below. Using above.")
+ .arg(quotePosition));
+ }
+
+ m_compose.sendHtml =
+ settings.value(QStringLiteral("send_html"), true).toBool();
+
+ // Three numerics, all following the shape already established at
+ // message_zoom, toolbar_icon_size, mark_read_delay_ms and
+ // auto_sync_delay_ms elsewhere in this function: a QVariant, a checked
+ // toInt()/toLongLong(), and a reported fallback to the struct's own
+ // default on failure. The bare toInt()/toLongLong() this replaced return
+ // 0 on a PARSE FAILURE, not the default, which is silently indistinguishable
+ // from the user writing 0 on purpose. For autosave_interval_ms that 0
+ // reaches a QTimer restarted on every keystroke, so it would fire on the
+ // very next event-loop pass and turn the debounce into a write per
+ // keystroke, each one uploaded by mbsync.
+ const QVariant autosave = settings.value(QStringLiteral("autosave_interval_ms"));
+ if (autosave.isValid()) {
+ bool ok = false;
+ const int value = autosave.toString().trimmed().toInt(&ok);
+ if (ok) {
+ // Clamped, not merely parsed: nothing in the spec assigns a
+ // meaning to a zero or negative autosave interval, unlike
+ // mark_read_delay_ms where negative-means-off is documented
+ // behaviour. A zero interval here is the same runaway-write
+ // hazard as the parse failure above, just spelled correctly.
+ m_compose.autosaveIntervalMs = qMax(1000, value);
+ } else {
+ addProblem(tr("[compose] autosave_interval_ms '%1' is not a "
+ "number; using %2.")
+ .arg(autosave.toString())
+ .arg(m_compose.autosaveIntervalMs));
+ }
+ }
+
+ // Zero is a REAL setting here, meaning "send at once", and must be
+ // honoured rather than mistaken for unset: that is exactly why this is
+ // isValid()-then-checked-parse rather than a zero-test.
+ const QVariant sendDelay = settings.value(QStringLiteral("send_delay_ms"));
+ if (sendDelay.isValid()) {
+ bool ok = false;
+ const int value = sendDelay.toString().trimmed().toInt(&ok);
+ if (ok) {
+ m_compose.sendDelayMs = value;
+ } else {
+ addProblem(tr("[compose] send_delay_ms '%1' is not a number; "
+ "using %2.")
+ .arg(sendDelay.toString())
+ .arg(m_compose.sendDelayMs));
+ }
+ }
+
+ m_compose.defaultAccount =
+ settings.value(QStringLiteral("default_account")).toString().trimmed();
+
+ const QVariant attachmentWarn =
+ settings.value(QStringLiteral("attachment_warn_bytes"));
+ if (attachmentWarn.isValid()) {
+ bool ok = false;
+ const qint64 value = attachmentWarn.toString().trimmed().toLongLong(&ok);
+ if (ok) {
+ m_compose.attachmentWarnBytes = value;
+ } else {
+ addProblem(tr("[compose] attachment_warn_bytes '%1' is not a "
+ "number; using %2.")
+ .arg(attachmentWarn.toString())
+ .arg(m_compose.attachmentWarnBytes));
+ }
+ }
+ settings.endGroup();
+
loadSavedQueries(path, settings);
// Checked here rather than where startup_query is read: the saved queries
@@ -534,6 +629,61 @@ void Config::load(const QString &path)
m_startupAccount.clear();
}
+ // default_account is validated here, once the accounts are parsed. A
+ // named account that cannot send is reported: the user named an account
+ // and expects mail to come from it, unlike an installation where no
+ // account can send at all, which is a valid read-only setup and not
+ // warned about below.
+ //
+ // Unlike startup_account just above, the bad value is NOT cleared after
+ // the warning: the composer resolves this through canSend() at the point
+ // of use, so a value naming an unusable account is simply skipped there
+ // rather than needing to be blanked here.
+ 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()) {
+ addProblem(
+ 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()) {
+ addProblem(
+ 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;
+ // A notice, not a problem: a provider whose SMTP server files sent
+ // mail on its own is a legitimate, permanently correct configuration.
+ // addProblem() here would raise a startup modal on every launch for a
+ // setup that will never change, which is exactly how a user learns to
+ // dismiss dialogs unread.
+ if (account.sent.isEmpty()) {
+ addNotice(
+ tr("Account '%1' can send but configures no `sent` folder, so "
+ "no local copy of sent mail is filed.")
+ .arg(account.key));
+ }
+ // Still a problem: unlike a missing sent folder, this is a real loss
+ // of protection (no draft is saved while composing) rather than a
+ // deliberate provider-side choice.
+ if (account.drafts.isEmpty()) {
+ addProblem(
+ tr("Account '%1' can send but configures no `drafts` folder, "
+ "so the composer runs without draft protection.")
+ .arg(account.key));
+ }
+ }
+
// Asks whether the resolved query matched on EITHER a name or a generator,
// rather than comparing the name alone. Comparing names warned about a
// config that was working: `startup_query = Inbox` resolves through the
@@ -948,6 +1098,16 @@ SavedQuery Config::startupSavedQuery() const
return builtinFilter(QStringLiteral("unread"));
}
+QList<Account> Config::sendingAccounts() const
+{
+ QList<Account> sending;
+ for (const Account &account : m_accounts) {
+ if (account.canSend())
+ sending.append(account);
+ }
+ return sending;
+}
+
Account Config::account(const QString &key) const
{
for (const Account &a : m_accounts) {
diff --git a/src/config.h b/src/config.h
index ede5dea..51fc1ee 100644
--- a/src/config.h
+++ b/src/config.h
@@ -67,6 +67,24 @@ struct Account
/// reports a missing key through the warnings path.
QString trash;
+ /// 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(); }
+
/// The account's inbox folder, relative to maildir. Optional.
///
/// Only Restore reads it, as the destination for a message that carries no
@@ -186,6 +204,35 @@ struct SavedQuery
QJsonObject unknown;
};
+/// 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;
+};
+
/// Reads ~/.config/qtmaildir/qtmaildir.conf.
///
/// The Maildir path is deliberately NOT configurable here: notmuch already
@@ -207,6 +254,14 @@ public:
QList<Account> accounts() const { return m_accounts; }
Account account(const QString &key) const;
+ 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;
+
/// In document order, which IS the display order. Never sort this.
QList<SavedQuery> savedQueries() const { return m_savedQueries; }
void setSavedQueries(const QList<SavedQuery> &queries)
@@ -443,6 +498,7 @@ private:
QList<Account> m_accounts;
QList<SavedQuery> m_savedQueries;
+ ComposeSettings m_compose;
/// Where saveSavedQueries() writes, remembered from load().
QString m_queriesPath;
diff --git a/src/draftstore.cpp b/src/draftstore.cpp
new file mode 100644
index 0000000..d458bff
--- /dev/null
+++ b/src/draftstore.cpp
@@ -0,0 +1,82 @@
+/*
+ * 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 "draftstore.h"
+
+#include "maildirname.h"
+
+#include <QDir>
+#include <QFile>
+#include <QObject>
+#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;
+ }
+
+ // A FRESH name, with no previous one to preserve flags from: a draft is
+ // newly composed, and MessageBuilder's bytes carry no filename. The flags
+ // are appended here instead.
+ 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;
+}
diff --git a/src/draftstore.h b/src/draftstore.h
new file mode 100644
index 0000000..13147c7
--- /dev/null
+++ b/src/draftstore.h
@@ -0,0 +1,60 @@
+/*
+ * 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.
+ */
+
+#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.
+ ///
+ /// The folder is the CALLER's to resolve, and item 124 is why it is not
+ /// resolved here: the mail root comes from
+ /// `notmuch_config_get(NOTMUCH_CONFIG_MAIL_ROOT)`, never from
+ /// `notmuch_database_get_path()`, which under a split index returns the
+ /// Xapian directory. A store that composed its own path from the wrong
+ /// accessor would write drafts into the index tree.
+ ///
+ /// \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 = {});
+};
diff --git a/src/formattoolbar.cpp b/src/formattoolbar.cpp
new file mode 100644
index 0000000..565d4af
--- /dev/null
+++ b/src/formattoolbar.cpp
@@ -0,0 +1,182 @@
+/*
+ * 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 "formattoolbar.h"
+
+#include <QStringList>
+
+
+namespace {
+
+/// Normalises the selection a widget reports into an ordered, in-range pair
+/// that does not split a character.
+///
+/// Three hazards, handled once rather than per function. A backwards drag
+/// reports the anchor AFTER the cursor; a selection can outlive the edit that
+/// shortened the buffer under it; and a boundary can land in the middle of a
+/// surrogate pair, where inserting a token splits one character into two
+/// halves and the result is not valid UTF-16 at all.
+///
+/// The surrogate case is not reachable with an arrow key or the mouse, which
+/// move in whole clusters, but QTextCursor::setPosition accepts such a
+/// position, so any caller computing one arithmetically can produce it: a
+/// draft restore, a find/replace, a template insertion. A boundary sitting on
+/// a LOW surrogate is inside a pair, and moving it back by one puts it before
+/// the whole character.
+///
+/// A COLLAPSED cursor moves back, not outward: nudging the two ends in
+/// opposite directions would turn an empty selection into a two-unit one and
+/// wrap a character the user never selected. A real selection widens, so that
+/// touching any part of a character covers the whole of it.
+void normalise(const QString &text, int &from, int &to)
+{
+ from = qBound(0, from, int(text.size()));
+ to = qBound(0, to, int(text.size()));
+ if (from > to)
+ qSwap(from, to);
+
+ const auto insidePair = [&text](int at) {
+ return at < text.size() && text.at(at).isLowSurrogate();
+ };
+
+ if (from == to) {
+ if (insidePair(from)) {
+ --from;
+ to = from;
+ }
+ return;
+ }
+
+ if (insidePair(from))
+ --from;
+ if (insidePair(to))
+ ++to;
+}
+
+} // namespace
+
+MarkdownFormat::Edit MarkdownFormat::wrap(const QString &text, int start,
+ int end, const QString &token)
+{
+ Edit edit;
+ int from = start;
+ int to = end;
+ normalise(text, from, to);
+
+ edit.text = text;
+ // The closing token first: inserting at `from` would shift `to`.
+ 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: bold then italic.
+ 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;
+ int from = start;
+ int to = end;
+ normalise(text, from, to);
+
+ 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, one past the '['.
+ edit.selectionStart = from + 1;
+ } else {
+ // The label is written; the URL is what remains, so the cursor goes
+ // inside the parentheses: past '[', the label, ']' and '('.
+ 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;
+ int from = start;
+ int to = end;
+ normalise(text, from, to);
+
+ // Line-based, not a wrap. The selection is widened to whole lines first:
+ // quoting half a line produces markdown that means something else.
+ //
+ // The backwards search starts at `from - 1`, not at `from`. QString's
+ // lastIndexOf INCLUDES the position it is given, so a cursor sitting at
+ // the end of a line, immediately before its newline, would find that
+ // newline and quote the FOLLOWING line instead of the one the cursor is
+ // on. The guard against a negative position matters too, since -1 means
+ // "search from the end" and would find the last newline in the buffer.
+ const int firstLineStart =
+ from > 0 ? text.lastIndexOf(QLatin1Char('\n'), from - 1) + 1 : 0;
+
+ // No newline after the last line, so the end of the text is the end of
+ // the block. Without this the whole tail would be dropped.
+ 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);
+
+ const QStringList lines = middle.split(QLatin1Char('\n'));
+
+ // Nesting rather than toggling, per the spec: a second press deepens the
+ // quote. There is deliberately no live toggle here, because tracking "my
+ // text" and "the quote" as separate pieces to make one reversible is
+ // machinery for a case the user answers by closing the composer.
+ QStringList result;
+ result.reserve(lines.size());
+ for (const QString &line : lines) {
+ // A blank line keeps the marker, since that is what continues a quote
+ // block in markdown, but WITHOUT the trailing space: several editors
+ // and mail clients strip trailing whitespace, and stripping it from
+ // "> " leaves ">" anyway, so writing it bare is the same result
+ // reached deliberately.
+ result.append(line.isEmpty() ? QStringLiteral(">")
+ : QStringLiteral("> ") + line);
+ }
+
+ const QString replacement = result.join(QLatin1Char('\n'));
+ edit.text = before + replacement + after;
+ // The quoted block stays selected, so a second press nests it.
+ edit.selectionStart = firstLineStart;
+ edit.selectionEnd = firstLineStart + replacement.size();
+
+ return edit;
+}
diff --git a/src/formattoolbar.h b/src/formattoolbar.h
new file mode 100644
index 0000000..d820a88
--- /dev/null
+++ b/src/formattoolbar.h
@@ -0,0 +1,76 @@
+/*
+ * 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.
+ */
+
+#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.
+///
+/// Every function takes the selection as the widget reports it, which means
+/// the anchor may sit AFTER the cursor. Each one normalises with qMin/qMax
+/// rather than requiring the caller to, since a backwards drag is an ordinary
+/// gesture and a caller that forgets would corrupt the buffer silently.
+/// Out-of-range positions are clamped to the text, so a stale selection
+/// cannot index past the end, and a boundary landing INSIDE a surrogate pair
+/// is nudged off it, so a position computed arithmetically cannot split a
+/// character in half.
+///
+/// Neither wrap() nor quote() TOGGLES. A second press stacks another level:
+/// `**this**` becomes `***this***` and `> one` becomes `> > one`. That is the
+/// design, not an omission. The selection is preserved precisely so a second
+/// press can apply a SECOND token to the same words, bold then italic without
+/// reselecting, and a toggle would make that gesture unreachable. A toggle is
+/// wanted eventually and is a spec change rather than a fix; see item 135 in
+/// the backlog for the states it has to distinguish.
+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. With none the cursor lands inside the brackets, since the
+/// label is then what gets typed first.
+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
diff --git a/src/keymap.cpp b/src/keymap.cpp
index 76c6b60..0df8450 100644
--- a/src/keymap.cpp
+++ b/src/keymap.cpp
@@ -54,6 +54,16 @@ QStringList KeyMap::knownActions()
QStringLiteral("spam_thread"),
QStringLiteral("toggle_unread_thread"),
QStringLiteral("flag_thread"),
+ // Compose and send (item 123). save_message deliberately carries no
+ // default chord: since item 132 a shortcut is a chosen subset rather
+ // than a requirement, and writing the raw message to a file is the
+ // rarely-used escape hatch. Menu reachability is the rule that holds.
+ QStringLiteral("compose"),
+ QStringLiteral("reply"),
+ QStringLiteral("reply_all"),
+ QStringLiteral("reply_no_quote"),
+ QStringLiteral("forward"),
+ QStringLiteral("save_message"),
QStringLiteral("focus_query"),
QStringLiteral("complete_query"),
QStringLiteral("save_query"),
@@ -98,6 +108,29 @@ QList<QPair<QString, QString>> KeyMap::defaultBindings()
{ QStringLiteral("Alt+Down"), QStringLiteral("next_thread") },
{ QStringLiteral("Alt+Up"), QStringLiteral("prev_thread") },
{ QStringLiteral("Return"), QStringLiteral("open_thread") },
+ // Compose and send (item 123), listed where the Message menu presents
+ // them: composing sits above organising.
+ //
+ // PROVISIONAL. The user intends to rework the bindings, and
+ // Ctrl+Alt+R for reply_no_quote is an imperfect fit: the Ctrl+Alt tier
+ // elsewhere means a WIDER SCOPE (the five whole-thread actions), not a
+ // variant of the same scope.
+ //
+ // Each was checked against every sequence in this table, not merely
+ // against the lines above it: these sit near the top, so most of the
+ // table is BELOW them, Ctrl+Shift+U and Ctrl+Shift+S among it.
+ // Checking only upwards would miss exactly those. The near misses:
+ // Ctrl+R is restore, Ctrl+A is select_all and Ctrl+Alt+S is
+ // spam_thread, so none of these five is a reuse.
+ //
+ // save_message gets none. Item 132 made a chord a chosen subset rather
+ // than a requirement, and this is the escape hatch nobody presses a
+ // key for.
+ { 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") },
{ QStringLiteral("Ctrl+E"), QStringLiteral("archive") },
// Del FIRST, and the order matters twice over. defaultSequenceFor()
// returns the first match, and sequenceFor() prefers any binding that
diff --git a/src/maildirname.cpp b/src/maildirname.cpp
new file mode 100644
index 0000000..6263aec
--- /dev/null
+++ b/src/maildirname.cpp
@@ -0,0 +1,80 @@
+/*
+ * 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 "maildirname.h"
+
+#include <QCoreApplication>
+#include <QDateTime>
+#include <QHostInfo>
+
+namespace MaildirName {
+
+/// A fresh Maildir filename for a message being moved between folders,
+/// preserving only its `:2,<flags>` suffix.
+///
+/// mbsync's manual is explicit about why this exists, under "the more
+/// efficient default UID mapping scheme": "it is important that the MUA
+/// renames files when moving them between Maildir folders", and "the general
+/// expectation is that a completely new filename is generated as if the
+/// message was new".
+///
+/// The `,U=<n>` infix mbsync writes is its per-folder IMAP UID. Carrying it
+/// into another folder makes it a claim about a folder the file is no longer
+/// in; moving a message out and back then reinserts a UID the server has
+/// since reassigned, and mbsync refuses the folder with `Maildir error:
+/// duplicate UID`. Measured on real mail, four collisions in one folder from
+/// a single move-and-restore.
+///
+/// The FLAGS are kept, deliberately, and that is not a contradiction of
+/// "as if the message was new". They record seen, flagged and replied, and
+/// `maildir.synchronize_flags` is true, so notmuch reads them back as tags:
+/// dropping them would mark every deleted message unread and lose Important
+/// on the way to the trash. Only the unique part is regenerated.
+QString fresh(const QString &oldName)
+{
+ // The `:2,` suffix, when there is one. `info` is everything from the
+ // separator on, so an empty-flag `:2,` is preserved as faithfully as
+ // `:2,FS`.
+ QString info;
+ const int sep = oldName.indexOf(QStringLiteral(":2,"));
+ if (sep >= 0)
+ info = oldName.mid(sep);
+
+ // The conventional left-to-right unique part: time, a per-process counter,
+ // the pid, the host. The counter is what makes two messages moved in the
+ // same second distinct, which a timestamp alone does not guarantee.
+ static quint64 counter = 0;
+ const qint64 now = QDateTime::currentSecsSinceEpoch();
+ const QString host = QHostInfo::localHostName().isEmpty()
+ ? QStringLiteral("localhost")
+ : QHostInfo::localHostName();
+
+ return QStringLiteral("%1.M%2P%3Q%4.%5%6")
+ .arg(now)
+ .arg(QDateTime::currentMSecsSinceEpoch() % 1000)
+ .arg(QCoreApplication::applicationPid())
+ .arg(++counter)
+ // A `/` or a `:` in a hostname would break the path or the flag
+ // separator. Neither is legal in a hostname, so this is belt and
+ // braces rather than a known case.
+ .arg(QString(host).replace(QLatin1Char('/'), QLatin1Char('_'))
+ .replace(QLatin1Char(':'), QLatin1Char('_')))
+ .arg(info);
+}
+
+} // namespace MaildirName
diff --git a/src/maildirname.h b/src/maildirname.h
new file mode 100644
index 0000000..f24bc71
--- /dev/null
+++ b/src/maildirname.h
@@ -0,0 +1,41 @@
+/*
+ * 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.
+ */
+
+#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
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index dc416ca..9a94a2e 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -26,6 +26,7 @@
#include <QDialog>
#include <QDialogButtonBox>
#include <QDir>
+#include <QFileDialog>
#include <QFileInfo>
#include <QHBoxLayout>
#include <QHeaderView>
@@ -47,6 +48,8 @@
#include <QToolButton>
#include <QVBoxLayout>
+#include "composecontext.h"
+#include "composewindow.h"
#include "mailsync.h"
#include "messageview.h"
#include "mimeparser.h"
@@ -93,8 +96,42 @@ QString MainWindow::uiStatePath()
namespace {
/// Overridden only by setLocksPathForTesting(); "/proc/locks" in every real run.
QString g_locksPath = QStringLiteral("/proc/locks");
+
} // namespace
+/// Doc comment on the declaration. Separators and control characters are
+/// replaced rather than stripped so a subject carrying one yields a readable
+/// name, instead of being truncated to its last segment by the basename
+/// reduction Attachment::safeFilename() performs afterwards.
+QString MainWindow::defaultMessageFilename(const QString &subject)
+{
+ QString name = subject.simplified();
+ for (QChar &c : name) {
+ if (c == QLatin1Char('/') || c == QLatin1Char('\\')
+ || c == QLatin1Char(':') || c.category() == QChar::Other_Control) {
+ c = QLatin1Char('-');
+ }
+ }
+ // Long subjects exist and many filesystems stop at 255 bytes. Truncated
+ // before the extension is added, so the cut cannot eat it.
+ name.truncate(120);
+ name = name.trimmed();
+
+ // A leading dot makes the file HIDDEN on every Unix desktop, and a subject
+ // beginning with one is ordinary ("...and another thing", or a traversal
+ // whose separators were just replaced above, leaving "..-..-etc-passwd").
+ // The write succeeds and the user cannot see the file they just saved.
+ // Measured: QDir::entryList omits it without QDir::Hidden, which is how
+ // this was found.
+ while (name.startsWith(QLatin1Char('.')))
+ name.remove(0, 1);
+ name = name.trimmed();
+
+ if (name.isEmpty())
+ name = QStringLiteral("message");
+ return name + QStringLiteral(".eml");
+}
+
void MainWindow::setLocksPathForTesting(const QString &path)
{
g_locksPath = path;
@@ -202,6 +239,105 @@ void MainWindow::closeEvent(QCloseEvent *event)
return;
}
+ // 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()) {
+ // The titles, not merely the count. The spec requires the dialog to
+ // NAME what could not be saved: "2 messages could not be saved" tells
+ // a user with four composers open nothing about which two to rescue.
+ //
+ // The list is a separate paragraph rather than interpolated into the
+ // sentence. The count and the list combine differently across
+ // languages, and a translator given "%n message(s) ...: %1" has to
+ // keep an English clause order Italian does not share.
+ QMessageBox box(this);
+ box.setIcon(QMessageBox::Warning);
+ box.setWindowTitle(tr("A draft could not be saved"));
+ box.setText(tr("%n message(s) could not be saved to the drafts "
+ "folder. Quitting now loses that text.", "",
+ failedSaves.size()));
+ box.setInformativeText(failedSaves.join(QLatin1Char('\n')));
+ box.setStandardButtons(QMessageBox::Retry | QMessageBox::Discard
+ | QMessageBox::Cancel);
+ box.setDefaultButton(QMessageBox::Cancel);
+ const int answer = box.exec();
+
+ 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) {
+ // Still failing: stay open rather than quitting on a retry
+ // that did not work, which would lose exactly the text the
+ // user pressed Retry to keep.
+ 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<QPointer<ComposeWindow>> blocking = composersBlockingQuit();
+ if (!blocking.isEmpty()) {
+ QStringList titles;
+ titles.reserve(blocking.size());
+ for (const QPointer<ComposeWindow> &composer : blocking)
+ titles.append(composer->windowTitle());
+
+ QMessageBox box(this);
+ box.setIcon(QMessageBox::Question);
+ box.setWindowTitle(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".
+ box.setText(tr("%n message(s) are still being composed. Drafts "
+ "already saved stay in the drafts folder either way.",
+ "", blocking.size()));
+ box.setInformativeText(titles.join(QLatin1Char('\n')));
+ box.setStandardButtons(QMessageBox::Save | QMessageBox::Discard
+ | QMessageBox::Cancel);
+ box.setDefaultButton(QMessageBox::Save);
+ const int answer = box.exec();
+
+ if (answer == QMessageBox::Cancel) {
+ event->ignore();
+ return;
+ }
+ if (answer == QMessageBox::Save) {
+ // Null-checked per iteration, because `blocking` was computed
+ // BEFORE exec() and a nested event loop processes deleteLater().
+ // The dialog is window-modal to this window only, so a user can
+ // close a composer while it is up; measured in a standalone Qt
+ // program, that composer is destroyed before exec() returns.
+ // Without this check the save runs on freed memory at the exact
+ // moment the application promised to preserve the text, and the
+ // remaining composers' drafts are never written because the crash
+ // happens mid-loop. Case 3's Retry loop above has always had the
+ // equivalent guard; this one had dropped it.
+ for (const QPointer<ComposeWindow> &composer : blocking) {
+ if (composer)
+ composer->saveDraftNow();
+ }
+ }
+ }
+
if (!m_closeApproved && pendingEditCount() > 0
&& m_config.syncOnExit() != Config::SyncOnExit::Never) {
@@ -286,6 +422,33 @@ void MainWindow::closeEvent(QCloseEvent *event)
}
}
+ // Every composer goes with the window, and this is the LAST thing before
+ // the close is accepted: every route that turns back (Cancel, a failed
+ // sync, a refused save) has already returned above, so reaching here means
+ // the application really is quitting.
+ //
+ // A composer is deliberately parentless, so that it appears in the task
+ // switcher and stays usable while the main window is. Qt therefore does not
+ // take it down with this window, and it kept the process alive: the main
+ // window vanished, the composer stayed on screen with nothing behind it,
+ // and closing it then raised the unsaved-edits dialog for a session that
+ // had already ended.
+ //
+ // Closing rather than deleting. WA_DeleteOnClose is set on every composer,
+ // so close() is what frees them, and it lets ComposeWindow::closeEvent()
+ // run its own draft handling on the way out. The drafts have already been
+ // saved by the dialogs above, so that pass has nothing left to do; going
+ // through it anyway keeps ONE exit path rather than a second one that has
+ // to be kept in step.
+ //
+ // Iterating a COPY: closing a composer runs the `closed` handler, which
+ // mutates m_composers, and mutating a container mid-iteration is undefined.
+ const QList<QPointer<ComposeWindow>> composers = m_composers;
+ for (const QPointer<ComposeWindow> &composer : composers) {
+ if (composer)
+ composer->close();
+ }
+
saveUiState();
QMainWindow::closeEvent(event);
}
@@ -758,6 +921,409 @@ void MainWindow::buildUi()
setWindowTitle(QStringLiteral("qtmaildir %1").arg(QTMAILDIR_VERSION));
}
+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()) {
+ // Unreachable while the action is disabled, which is the only state
+ // this can be true in. Reported rather than returning silently: an
+ // action that runs and does nothing is the failure mode item 105
+ // records as "the key does nothing".
+ showTransientStatus(tr("No account is configured to send mail"));
+ return;
+ }
+
+ 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, 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()) {
+ showTransientStatus(tr("No message is selected"));
+ 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);
+}
+
+void MainWindow::requestMessageForCompose(const QString &messageId,
+ ComposeContext::Kind kind,
+ bool quote)
+{
+ if (messageId.isEmpty())
+ return;
+
+ m_pendingCompose = { messageId, kind, quote, true };
+
+ // The same generation every other worker request carries, so a reply that
+ // arrives after the query moved on is discarded rather than opening a
+ // composer on a message the user is no longer looking at.
+ QMetaObject::invokeMethod(m_worker, "loadMessage", Qt::QueuedConnection,
+ Q_ARG(QString, messageId),
+ Q_ARG(quint64, m_generation));
+}
+
+void MainWindow::openComposerFor(const MessageRef &ref,
+ ComposeContext::Kind kind, bool quote)
+{
+ MimeParser parser;
+ const ParsedMessage original = parser.parse(ref.filePath);
+ if (!original.ok) {
+ showTransientStatus(tr("That message could not be read"));
+ return;
+ }
+
+ ComposeContext context;
+ context.kind = kind;
+ context.originalPath = ref.filePath;
+
+ const bool replyAll = kind == ComposeContext::Kind::ReplyAll;
+ const bool forwarding = kind == ComposeContext::Kind::Forward;
+
+ if (!forwarding) {
+ ComposeContextBuilder::recipientsForReply(
+ original, replyAll, ComposeContextBuilder::ownAddresses(m_config),
+ &context.to, &context.cc);
+
+ // Threading headers on a reply only. A forward starts a new
+ // conversation: carrying In-Reply-To would file it under the thread it
+ // was forwarded out of, in the RECIPIENT's client.
+ context.inReplyTo = original.messageId;
+ context.references = ComposeContextBuilder::referencesForReply(original);
+ }
+
+ context.subject = forwarding
+ ? ComposeContextBuilder::forwardSubject(original.subject)
+ : ComposeContextBuilder::replySubject(original.subject);
+
+ if (quote)
+ context.quotedBody = ComposeContextBuilder::quoteBody(original);
+
+ // Forward seeds from the CONFIG, Reply from the original. The split is
+ // the spec's and Config::ComposeSettings::sendHtml states it too: an HTML
+ // part in the original is a fact about the SENDER's software, so it is the
+ // right seed when answering them and says nothing about a forward, which
+ // is a new message to somebody else. composeNew() already reads the config
+ // for the same reason.
+ context.seedHtml = forwarding ? m_config.compose().sendHtml
+ : original.hasHtml();
+
+ // accountForReply() takes messagePaths PLURAL because notmuch can return
+ // several filenames for one id, and it disambiguates between them by
+ // recipient. That disambiguation is INERT here, and the reason is upstream
+ // rather than a decision made at this call site: NotmuchWorker::loadMessage
+ // builds its MessageRef from notmuch_message_get_filename(), the SINGULAR
+ // accessor, so nothing in the pipeline ever carries more than one path and
+ // the list below can never hold more than one element. Backlog item 137
+ // carries the fix (MessageRef gains a filePaths list populated from
+ // notmuch_message_get_filenames()); until then a message that arrived at
+ // two accounts can open its reply from the wrong one.
+ const QStringList recipients = context.to + context.cc;
+ context.accountKey = ComposeContextBuilder::accountForReply(
+ m_config, { ref.filePath }, recipients, m_mailRoot);
+
+ if (context.accountKey.isEmpty()
+ || !m_config.account(context.accountKey).canSend()) {
+ // The enablement pass should already have stopped this, but it answers
+ // from the model's path while this answers from the database's, and
+ // the two can disagree on a row that has not been re-queried.
+ showTransientStatus(
+ tr("That message arrived at an account that cannot send"));
+ return;
+ }
+
+ openComposer(context);
+}
+
+void MainWindow::openComposer(const ComposeContext &context)
+{
+ if (m_mailRoot.isEmpty()) {
+ // Without the root a draft cannot be written anywhere, and a composer
+ // that silently cannot autosave is the state the quit path's honesty
+ // depends on not being in.
+ showTransientStatus(tr("The Maildir root is not known yet"));
+ return;
+ }
+
+ auto *composer = new ComposeWindow(context, m_config, m_mailRoot);
+ composer->setAttribute(Qt::WA_DeleteOnClose);
+ m_composers.append(QPointer<ComposeWindow>(composer));
+
+ // Compaction, and ONLY compaction. The QPointer above is what keeps
+ // composersBlockingQuit() safe against a destroyed window, since it nulls
+ // on destruction; this drops the entry so the list does not accumulate
+ // nulls for the session's lifetime. Neither replaces the other: without
+ // the signal the list leaks entries, without the QPointer it dangles.
+ connect(composer, &ComposeWindow::closed, this,
+ [this](ComposeWindow *which) {
+ m_composers.removeIf([which](const QPointer<ComposeWindow> &p) {
+ return p.isNull() || p.data() == which;
+ });
+ });
+
+ composer->show();
+}
+
+QList<QPointer<ComposeWindow>> MainWindow::composersBlockingQuit() const
+{
+ QList<QPointer<ComposeWindow>> blocking;
+ for (const QPointer<ComposeWindow> &composer : m_composers) {
+ if (composer && composer->hasUnsavedEdits())
+ blocking.append(composer);
+ }
+ return blocking;
+}
+
+ComposeWindow *MainWindow::openComposerForTest()
+{
+ const QString accountKey =
+ ComposeContextBuilder::accountForNew(m_config, QString());
+ if (accountKey.isEmpty())
+ return nullptr;
+
+ ComposeContext context;
+ context.kind = ComposeContext::Kind::New;
+ context.accountKey = accountKey;
+
+ const int before = m_composers.size();
+ openComposer(context);
+ if (m_composers.size() == before)
+ return nullptr;
+ return m_composers.constLast().data();
+}
+
+QList<ComposeWindow *> MainWindow::openComposersForTest() const
+{
+ QList<ComposeWindow *> live;
+ for (const QPointer<ComposeWindow> &composer : m_composers) {
+ if (composer)
+ live.append(composer.data());
+ }
+ return live;
+}
+
+int MainWindow::openComposerCount() const
+{
+ int live = 0;
+ for (const QPointer<ComposeWindow> &composer : m_composers) {
+ if (composer)
+ ++live;
+ }
+ return live;
+}
+
+void MainWindow::markComposersDirtyForTest()
+{
+ // Through the real edit path: the body editor's own textChanged is what
+ // ComposeWindow::markDirty() is connected to, so inserting text here
+ // exercises the same route typing does. Setting a dirty flag directly
+ // would pass against a composer that never notices an edit at all.
+ //
+ // QTextCursor rather than QTest::keyClicks, so production code does not
+ // have to link QtTest.
+ for (const QPointer<ComposeWindow> &composer : m_composers) {
+ if (!composer)
+ continue;
+ if (auto *body = composer->findChild<QPlainTextEdit *>(
+ QStringLiteral("body"))) {
+ body->textCursor().insertText(QStringLiteral("x"));
+ }
+ }
+}
+
+QString MainWindow::accountForCurrentMessage() const
+{
+ if (m_mailRoot.isEmpty())
+ return {};
+
+ const QModelIndex current = m_threadView->currentIndex();
+ if (!current.isValid())
+ return {};
+
+ // The model's path, deliberately. This decides whether a CONTROL is live,
+ // which a stale path answers well enough; the context that actually opens
+ // a composer resolves the account again from the database. Asking the
+ // worker here would make every selection change a round trip.
+ //
+ // The two sources are in DIFFERENT FORMS and normalising them is not
+ // tidying. ThreadSummary::firstMessagePath is RELATIVE to the mail root,
+ // because runQuery() reduces it with relativeFilePath() so the UI can
+ // compare it against an account's maildir; MessageNode::filePath is
+ // ABSOLUTE, because MimeParser opens it. accountOwning() builds an
+ // absolute prefix, so handing it the relative one matches no account at
+ // all and every thread row reports no account, which disables the reply
+ // family on mail from an account that can perfectly well send. Measured:
+ // it did exactly that until the guard test caught it.
+ QString path;
+ if (m_model->isMessageRow(current)) {
+ path = m_model->messageAt(current).filePath;
+ } else {
+ path = m_model->threadFor(current).firstMessagePath;
+ }
+ if (path.isEmpty())
+ return {};
+
+ const QString absolute = QDir::isAbsolutePath(path)
+ ? path
+ : QDir(m_mailRoot).absoluteFilePath(path);
+
+ return ComposeContextBuilder::accountForReply(m_config, { absolute },
+ QStringList(), m_mailRoot);
+}
+
+void MainWindow::updateComposeActions()
+{
+ // 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, writing the raw message to a file
+ // that can be attached to a new message from an account that can send.
+ const QString replyAccount = accountForCurrentMessage();
+ const bool canReply = !replyAccount.isEmpty()
+ && m_config.account(replyAccount).canSend();
+
+ static const QStringList kReplyFamily = {
+ QStringLiteral("reply"), QStringLiteral("reply_all"),
+ QStringLiteral("reply_no_quote"), QStringLiteral("forward")
+ };
+ for (const QString &name : kReplyFamily) {
+ if (QAction *action = m_actions.value(name))
+ action->setEnabled(canReply);
+ }
+
+ // The ribbon appears only when an account was identified AND it cannot
+ // send. An unidentified account is not a receive-only one: it is a message
+ // whose file no account owns, and naming no account in a ribbon that
+ // exists to name one would be worse than staying quiet.
+ const bool receiveOnly =
+ !replyAccount.isEmpty() && !m_config.account(replyAccount).canSend();
+ m_messageView->setReceiveOnlyAccount(receiveOnly ? replyAccount
+ : QString());
+
+ // 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());
+}
+
+void MainWindow::saveDisplayedMessage(const QString &chosenDirectory)
+{
+ const QModelIndex current = m_threadView->currentIndex();
+ const ActionScope scope = m_model->messageScopeFor({ current });
+ if (scope.messageIds.isEmpty()) {
+ showTransientStatus(tr("No message is selected"));
+ return;
+ }
+
+ // The path from the model, which is what the pane is rendering. Unlike a
+ // reply, a copy of the wrong file is visible to the user the moment they
+ // open it, so this does not need the database round trip a reply does.
+ QString sourcePath;
+ QString subject;
+ if (m_model->isMessageRow(current)) {
+ const MessageNode node = m_model->messageAt(current);
+ sourcePath = node.filePath;
+ subject = node.subject;
+ } else {
+ const ThreadSummary thread = m_model->threadFor(current);
+ sourcePath = thread.firstMessagePath;
+ subject = thread.subject;
+ }
+ if (sourcePath.isEmpty()) {
+ showTransientStatus(tr("That message's file could not be found"));
+ return;
+ }
+
+ // Relative for a thread row, absolute for a message row. The same
+ // asymmetry accountForCurrentMessage() documents at length.
+ if (!QDir::isAbsolutePath(sourcePath) && !m_mailRoot.isEmpty())
+ sourcePath = QDir(m_mailRoot).absoluteFilePath(sourcePath);
+
+ if (!QFileInfo::exists(sourcePath)) {
+ showTransientStatus(tr("That message's file could not be found"));
+ return;
+ }
+
+ // The dialog only when no directory was supplied. A test supplies one,
+ // because the modal cannot be driven under the offscreen platform and the
+ // containment check below is the only line guarding the write.
+ const QString directory =
+ chosenDirectory.isEmpty()
+ ? QFileDialog::getExistingDirectory(
+ this, tr("Save message to"),
+ QStandardPaths::writableLocation(
+ QStandardPaths::DownloadLocation))
+ : chosenDirectory;
+ if (directory.isEmpty())
+ return; // cancelled
+
+ // The default name is derived from the SUBJECT, which is input from a
+ // stranger: it may carry path separators, "..", or nothing usable. The
+ // same rules the attachment path follows, and the same helpers, rather
+ // than a second implementation that has to be kept correct separately.
+ Attachment naming;
+ naming.filename = defaultMessageFilename(subject);
+ const QString safeName = naming.safeFilename();
+
+ // Disambiguated rather than overwritten, matching what the attachment bar
+ // does. Attachment::saveWithoutOverwriting() is the same rule and cannot
+ // be reused here because it writes an Attachment's own bytes, while this
+ // COPIES a file; the naming is duplicated, the behaviour is not.
+ //
+ // The earlier version deleted an existing same-named file, on the
+ // reasoning that a save the user just confirmed a location for should not
+ // silently do nothing. That is right about the failure and wrong about the
+ // remedy: two messages very often share a subject, so the second save
+ // would destroy the first, and QFile::copy's refusal is a reason to pick
+ // another name rather than to delete somebody's file.
+ const QFileInfo naming_info(safeName);
+ const QString base = naming_info.completeBaseName();
+ const QString suffix = naming_info.suffix().isEmpty()
+ ? QString()
+ : QLatin1Char('.') + naming_info.suffix();
+ const QDir dir(directory);
+ QString candidate = safeName;
+ for (int n = 2; dir.exists(candidate); ++n)
+ candidate = QStringLiteral("%1 (%2)%3").arg(base).arg(n).arg(suffix);
+
+ const QString target = dir.absoluteFilePath(candidate);
+
+ // Compared as PATHS, never with startsWith(): "/tmp/safe-evil" passes a
+ // startsWith("/tmp/safe") check while being a sibling directory.
+ if (!Attachment::isPathInsideDirectory(directory, target)) {
+ showTransientStatus(tr("Refusing to write outside %1")
+ .arg(QDir::cleanPath(
+ QDir(directory).absolutePath())));
+ return;
+ }
+
+ if (!QFile::copy(sourcePath, target)) {
+ showTransientStatus(tr("Could not write %1").arg(target));
+ return;
+ }
+ showTransientStatus(tr("Saved %1").arg(target));
+}
+
QAction *MainWindow::addAction(const QString &name, const QString &text,
const QString &description,
const std::function<void()> &handler)
@@ -1124,6 +1690,34 @@ void MainWindow::registerActions()
addAction(QStringLiteral("quit"), tr("&Quit"),
tr("Quit qtmaildir"), [this]() { close(); });
+ // Compose and send (item 123). The handlers are empty: this is the
+ // registration, so the three coverage tests
+ // (everyKnownActionIsRegistered, everyActionCarriesAnIcon and
+ // everyActionIsReachableFromAMenu) cover the composer from the first
+ // commit rather than being satisfied once it is finished.
+ //
+ // Reply and reply-without-quoting are the same Kind with and without a
+ // seeded body, which is why the quoting is a parameter rather than a
+ // fourth Kind: the recipients, the subject prefix and the threading
+ // headers are identical, and only the body differs.
+ addAction(QStringLiteral("compose"), tr("&New message"),
+ tr("Compose a new message"), [this]() { composeNew(); });
+ addAction(QStringLiteral("reply"), tr("Re&ply"),
+ tr("Reply to the displayed message"),
+ [this]() { composeReply(ComposeContext::Kind::Reply, true); });
+ addAction(QStringLiteral("reply_all"), tr("Reply to a&ll"),
+ 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("Sa&ve message as..."),
+ tr("Write the raw message to a file"),
+ [this]() { saveDisplayedMessage(); });
+
// A binding the user wrote for an action that does not exist would be
// silently dead. KeyMap warns about unknown names, but only a check here
// catches the reverse: a known action nothing implements.
@@ -1135,6 +1729,10 @@ void MainWindow::registerActions()
// and offering "Mark all read" against nothing is a live control that does
// nothing.
updateViewWideActions();
+
+ // Compose and the reply family, for the same reason: QAction starts
+ // enabled, so a window with nothing selected would offer a live Reply.
+ updateComposeActions();
}
void MainWindow::buildMenus()
@@ -1154,6 +1752,18 @@ void MainWindow::buildMenus()
editMenu->addAction(m_actions.value(QStringLiteral("select_all")));
auto *messageMenu = menuBar()->addMenu(tr("&Message"));
+ // Composing sits above organising (item 123). The spec called for a new
+ // top-level Message menu and this one already existed, so the six join it:
+ // two menus named Message would be a defect.
+ 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();
messageMenu->addAction(m_actions.value(QStringLiteral("archive")));
messageMenu->addAction(m_actions.value(QStringLiteral("delete")));
// Beside Delete, whose inverse it is. Greyed outside the trash view
@@ -1282,6 +1892,22 @@ void MainWindow::buildMenus()
{ QStringLiteral("spam_thread"), QStringLiteral("mail-mark-junk") },
{ QStringLiteral("toggle_unread_thread"), QStringLiteral("mail-mark-unread") },
{ QStringLiteral("flag_thread"), QStringLiteral("mail-mark-important") },
+
+ // Compose and send (item 123). reply_no_quote SHARES reply's icon for
+ // the same reason the five above share theirs: it never reaches the
+ // toolbar, it is a menu entry that always carries its text, and
+ // "Reply without quoting" beside the reply icon is the honest pairing.
+ // It is named in the exception list in noTwoActionsShareAnIcon(), so
+ // putting it on the toolbar fails that test rather than passing
+ // silently.
+ { 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") },
+ // NOT bookmark-new, which save_query uses: this really does write a
+ // file the user names, which is exactly what the disk shape means.
+ { QStringLiteral("save_message"), QStringLiteral("document-save-as") },
};
for (auto it = themeIcons.cbegin(); it != themeIcons.cend(); ++it) {
QAction *action = m_actions.value(it.key());
@@ -1340,6 +1966,16 @@ void MainWindow::buildMenus()
// anything this code can see.
const int iconSize = m_config.toolbarIconSize();
toolBar->setIconSize(QSize(iconSize, iconSize));
+
+ // First, because composing and replying are what a user reaches for most
+ // (item 123). These TWO only: the other four are menu-and-key, which is
+ // what keeps the no-duplicate-icons rule satisfiable, since reply_no_quote
+ // shares reply's icon and an icon-only toolbar would make the two buttons
+ // indistinguishable.
+ toolBar->addAction(m_actions.value(QStringLiteral("compose")));
+ toolBar->addAction(m_actions.value(QStringLiteral("reply")));
+ toolBar->addSeparator();
+
QAction *syncAction = m_actions.value(QStringLiteral("sync"));
// Carried over from the QPushButton this replaced: with no command
// configured the control is disabled, and the tooltip is the only thing
@@ -1638,6 +2274,8 @@ void MainWindow::wireWorker()
this, &MainWindow::onWorkerError);
connect(m_worker, &NotmuchWorker::allTagsReady,
this, &MainWindow::onAllTagsReady);
+ connect(m_worker, &NotmuchWorker::mailRootReady,
+ this, &MainWindow::onMailRootReady);
connect(m_worker, &NotmuchWorker::countsReady,
this, &MainWindow::onCountsReady);
connect(m_worker, &NotmuchWorker::databaseStatsReady,
@@ -1674,6 +2312,11 @@ void MainWindow::wireWorker()
// as the database can be read. Nothing waits on the answer: requestAllTags
// stays silent when the database cannot be opened.
requestAllTags();
+
+ // The Maildir root, which this window cannot derive (item 124). Asked once:
+ // it does not change while the application runs. Nothing waits on it
+ // either; the reply family is gated on send_command, not on this.
+ QMetaObject::invokeMethod(m_worker, "requestMailRoot", Qt::QueuedConnection);
}
void MainWindow::requestAllTags()
@@ -1694,6 +2337,17 @@ void MainWindow::onAllTagsReady(const QStringList &tags)
m_queryCompleter->setTags(tags);
}
+void MainWindow::onMailRootReady(const QString &mailRoot)
+{
+ m_mailRoot = mailRoot;
+
+ // The enablement pass reads m_mailRoot to resolve which account owns the
+ // displayed message, so it answers "no account" until this arrives. A
+ // window that had already selected a row would otherwise keep the reply
+ // family greyed out until the next selection change.
+ updateComposeActions();
+}
+
QList<MainWindow::PlaceholderLine> MainWindow::placeholderLines() const
{
// One list of (query, label-maker) pairs rather than two arrays indexed in
@@ -2647,6 +3301,11 @@ void MainWindow::onSelectionChanged()
if (changed)
onThreadSelected(current, QModelIndex());
}
+
+ // Which account owns the displayed message decides whether the reply
+ // family is live and whether the ribbon shows, so it is re-answered
+ // whenever the displayed message can have changed.
+ updateComposeActions();
return;
}
@@ -2658,6 +3317,7 @@ void MainWindow::onSelectionChanged()
if (m_statusLabel->text() == m_selectionMessage)
m_statusLabel->clear();
m_selectionMessage.clear();
+ updateComposeActions();
return;
}
@@ -2699,6 +3359,10 @@ void MainWindow::onSelectionChanged()
m_currentMessageThreadId.clear();
m_messageView->clear();
showPlaceholderPane();
+
+ // A multi-row selection displays no message, so there is no account to
+ // reply from and no ribbon to show.
+ updateComposeActions();
}
void MainWindow::onThreadSelected(const QModelIndex &current,
@@ -2836,6 +3500,61 @@ void MainWindow::onThreadSelected(const QModelIndex &current,
void MainWindow::onMessageLoaded(const QVector<MessageRef> &messages,
quint64 generation)
{
+ // A compose request comes through this same signal rather than through a
+ // worker signal of its own, so it is answered before the render guards
+ // below: those exist to protect the PANE, and none of them applies to
+ // opening a composer.
+ //
+ // Matched by MESSAGE ID, not merely by a pending flag. The compose request
+ // and the pane share one loadMessage slot and one messageLoaded signal, so
+ // a pane load already in flight when the user presses Reply arrives FIRST
+ // and carries a different message: consuming it on the flag alone would
+ // open a composer on whichever message the pane happened to be loading.
+ // A non-matching reply falls through to the pane, which is what it is.
+ if (m_pendingCompose.active) {
+ const auto it = std::find_if(
+ messages.cbegin(), messages.cend(),
+ [this](const MessageRef &ref) {
+ return ref.messageId == m_pendingCompose.messageId;
+ });
+ if (it != messages.cend()) {
+ const PendingCompose request = m_pendingCompose;
+ m_pendingCompose = {};
+
+ // The generation guard still applies: a query that moved on means
+ // the row the user asked from is gone.
+ if (generation == m_generation)
+ openComposerFor(*it, request.kind, request.quote);
+
+ // A compose load carries no pane update: m_currentMessageId is
+ // untouched by requestMessageForCompose(), so falling through
+ // would repaint the pane with a message it did not select.
+ return;
+ }
+
+ // No match, and the request is DISARMED rather than left waiting.
+ //
+ // Leaving it armed was a two-stage defect. The immediate half is that
+ // Reply silently does nothing when the message is not in the index,
+ // which is item 105's "the key does nothing". The delayed half is
+ // worse: the request stays armed with a specific message id, and the
+ // pane's own loads are the traffic being matched against, so merely
+ // SELECTING that message later would match, open a composer nobody
+ // asked for, and return before renderMessages() leaving the pane blank
+ // on the row just clicked.
+ //
+ // Only an EMPTY reply disarms it, and that asymmetry is the point.
+ // loadMessage() emits an empty list precisely when the id resolved to
+ // nothing, so that reply belongs to this request and says it failed.
+ // A NON-empty reply naming other messages is the pane's own load
+ // crossing ours, which is the race the id match exists to survive;
+ // disarming on it would reintroduce that race from the other side.
+ if (messages.isEmpty()) {
+ m_pendingCompose = {};
+ showTransientStatus(tr("That message is no longer indexed"));
+ }
+ }
+
// A stale generation means the query moved on. A reply landing after the
// selection grew past one row would paint a message back over a pane that
// was deliberately blanked: loadMessage crosses to the worker on a queued
diff --git a/src/mainwindow.h b/src/mainwindow.h
index 8e483d2..ea3ba61 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -63,6 +63,7 @@ class MailSync;
class NotmuchWorker;
class QueryCompleter;
class TagRulesDialog;
+class ComposeWindow;
class MainWindow : public QMainWindow
{
@@ -310,6 +311,102 @@ public:
onRulePreviewRequested(query);
}
+ /// The open composers with unsaved edits, which the quit path asks about.
+ ///
+ /// PRODUCTION code, not a test accessor: closeEvent() reads it. Skips a
+ /// null QPointer, which is a composer the user already closed and whose
+ /// closed() signal has not compacted the list yet.
+ ///
+ /// Returns QPointers rather than raw pointers, and that is a SAFETY
+ /// property rather than a style. The quit path holds this list across
+ /// QMessageBox::exec(), and a nested event loop PROCESSES deleteLater():
+ /// measured in a standalone Qt program, a parentless WA_DeleteOnClose
+ /// window closed while a modal is up is destroyed BEFORE exec() returns.
+ /// The dialog is window-modal to this window only, so the composers stay
+ /// interactive and the user really can close one from under it. A raw list
+ /// dangles there, and it dangles at the exact moment the application
+ /// promised to preserve their text.
+ QList<QPointer<ComposeWindow>> composersBlockingQuit() const;
+
+ /// Opens a composer on a blank message from the first account that can
+ /// send, for a test that needs one open without a modal file dialog or a
+ /// selected row. Returns nullptr when no account can send.
+ ComposeWindow *openComposerForTest();
+
+ /// How many composers the registry currently holds, counting only entries
+ /// that are still alive.
+ ///
+ /// A nulled QPointer is NOT counted, so this cannot by itself distinguish
+ /// "the entry was removed" from "the entry is still there but nulled".
+ /// That distinction is what closingAComposerCompactsTheRegistry() exists
+ /// to make, and it makes it by asserting this reaches zero after a close:
+ /// only compaction can empty the list, since a nulled entry would leave
+ /// m_composers non-empty while this still reported zero.
+ int openComposerCount() const;
+
+ /// Types a character into every open composer, which is what makes it
+ /// dirty. A test seam over the real edit path rather than a flag setter:
+ /// setting m_dirty directly would pass against a composer that never
+ /// notices an edit at all.
+ void markComposersDirtyForTest();
+
+ /// The Maildir root as the worker reported it, for the split-index test.
+ QString mailRootForTesting() const { return m_mailRoot; }
+
+ /// Runs save_message into \p directory instead of asking for one.
+ ///
+ /// The file dialog is a modal the offscreen platform cannot click, and the
+ /// containment check is the only line guarding the write, so without this
+ /// seam no test can reach the guard it is named after.
+ void saveDisplayedMessageForTest(const QString &directory)
+ {
+ saveDisplayedMessage(directory);
+ }
+
+ /// Builds a compose context from \p ref and opens the composer, which is
+ /// the production line openComposerFor() runs. A test that builds a
+ /// ComposeContext by hand instead proves only that ComposeWindow honours
+ /// what it is given, and cannot see which SOURCE a field came from.
+ void openComposerForTest(const MessageRef &ref, ComposeContext::Kind kind,
+ bool quote)
+ {
+ openComposerFor(ref, kind, quote);
+ }
+
+ /// Arms a compose request without a selected row, so a test can request
+ /// one for an id the database does not hold.
+ void requestMessageForComposeForTest(const QString &messageId,
+ ComposeContext::Kind kind, bool quote)
+ {
+ requestMessageForCompose(messageId, kind, quote);
+ }
+
+ /// Whether a compose request is still waiting for its message.
+ ///
+ /// A request that never disarms is the defect this exposes: it stays armed
+ /// with a message id and hijacks the next pane load for that message.
+ bool composeRequestPendingForTest() const { return m_pendingCompose.active; }
+
+ /// The live composers, for a test that needs to close them.
+ ///
+ /// Defined in the .cpp: dereferencing a QPointer needs the complete type,
+ /// and ComposeWindow is only forward-declared here.
+ QList<ComposeWindow *> openComposersForTest() const;
+
+ /// A default filename for a saved message, derived from its subject.
+ ///
+ /// Public and static so a test can assert on it with a hostile subject.
+ /// It was a file-local helper unreachable from any test, and the test
+ /// named after its defences asserted on Attachment's helpers directly
+ /// instead: three separate mutations left that test green. CLAUDE.md's
+ /// "a probe can be correct and still measure nothing, by being pointed at
+ /// the wrong object".
+ ///
+ /// The subject is UNTRUSTED, so this produces a CANDIDATE rather than a
+ /// safe name: the caller passes it through Attachment::safeFilename(),
+ /// which reduces it to a plain basename.
+ static QString defaultMessageFilename(const QString &subject);
+
protected:
void closeEvent(QCloseEvent *event) override;
@@ -481,6 +578,11 @@ private slots:
void onTagsApplied(const TagChange &change);
void onAllTagsReady(const QStringList &tags);
+ /// The Maildir root, answered once at startup. Enables nothing on its own:
+ /// the composer needs it, and the reply family is gated on the account's
+ /// send_command rather than on this having arrived.
+ void onMailRootReady(const QString &mailRoot);
+
/// Thread counts for the placeholder's helper lines, in the order
/// requestPlaceholderCounts() asked for them.
void onCountsReady(const QVector<int> &counts, quint64 generation);
@@ -594,6 +696,63 @@ private:
/// that populates.
void showMaildirOverview();
+ /// Opens a composer on a blank message (item 123).
+ void composeNew();
+
+ /// Opens a composer seeded from the displayed message (item 123).
+ ///
+ /// `kind` chooses reply, reply-all or forward; `quote` is what separates
+ /// reply from reply-without-quoting, which are the same kind with and
+ /// without a seeded body.
+ ///
+ /// Resolves through ThreadListModel::messageScopeFor(), NOT threadFor(): a
+ /// thread row means the one message its card shows. Replying to a thread
+ /// is meaningless, a reply answers a message.
+ void composeReply(ComposeContext::Kind kind, bool quote);
+
+ /// Asks the worker for \p messageId's current file, then opens a composer.
+ ///
+ /// The round trip is the point. The context is built from the DATABASE and
+ /// never from the model, which is the rule Restore already follows: the
+ /// model's paths and tags come from the query, so a row that has not been
+ /// re-queried carries stale values and a reply built from one would go to
+ /// the wrong recipients.
+ void requestMessageForCompose(const QString &messageId,
+ ComposeContext::Kind kind, bool quote);
+
+ /// Builds the context from a parsed message and shows the composer.
+ /// Called from onMessageLoaded() when a compose request is outstanding.
+ void openComposerFor(const MessageRef &ref, ComposeContext::Kind kind,
+ bool quote);
+
+ /// Constructs a ComposeWindow, registers it and shows it.
+ void openComposer(const ComposeContext &context);
+
+ /// Writes the displayed message's raw file somewhere the user chooses.
+ ///
+ /// Never disabled, including on a receive-only account: it is the escape
+ /// hatch for exactly that case, writing the raw message to a file that can
+ /// be attached to a new message from an account that can send.
+ ///
+ /// \p directory defaults to empty, which raises the file dialog. A test
+ /// passes one instead, via saveDisplayedMessageForTest(): the modal cannot
+ /// be driven under the offscreen platform, and the containment check below
+ /// it is the only line actually guarding the write, so with the dialog
+ /// inline no test could reach that line at all.
+ void saveDisplayedMessage(const QString &directory = QString());
+
+ /// The account a reply to the displayed message would send from, or empty
+ /// when there is no displayed message or no account owns its file.
+ ///
+ /// Read by the enablement pass, which is why it must not need a worker
+ /// round trip: it answers from the model's path, which is good enough to
+ /// decide whether a control is live. The context that actually opens a
+ /// composer resolves the account again from the database.
+ QString accountForCurrentMessage() const;
+
+ /// Puts the reply family and compose into their real enabled state.
+ void updateComposeActions();
+
/// Creates a QAction, binds it to the sequence KeyMap holds for `name`,
/// and registers it. `name` is the action name used in [keys].
QAction *addAction(const QString &name, const QString &text,
@@ -1226,6 +1385,40 @@ private:
/// back without clobbering a message some other action put there.
QString m_selectionMessage;
+ /// The Maildir root, from the worker (item 124, and this window has no
+ /// other way to know it).
+ ///
+ /// There is no Config::maildirPath() by design: notmuch owns the path and
+ /// duplicating it into config would create a second source of truth. It
+ /// arrives on mailRootReady() shortly after startup, so anything composing
+ /// a path under it has to cope with it being empty for the first moments.
+ QString m_mailRoot;
+
+ /// A compose request waiting for its message to come back from the worker.
+ ///
+ /// The reply family cannot open a composer synchronously: the context is
+ /// built from the database rather than from the model, so the file path
+ /// has to be fetched first. This records what to do with the answer.
+ struct PendingCompose
+ {
+ QString messageId;
+ ComposeContext::Kind kind = ComposeContext::Kind::Reply;
+ bool quote = true;
+ bool active = false;
+ };
+ PendingCompose m_pendingCompose;
+
+ /// Every open composer, so the quit path can see them.
+ ///
+ /// The QPointer and the closed() signal do DIFFERENT jobs and neither is
+ /// removable. A composer is WA_DeleteOnClose and deletes itself, so the
+ /// QPointer is what keeps composersBlockingQuit() from dereferencing a
+ /// destroyed window: it nulls on destruction. The signal is what lets this
+ /// list be COMPACTED, since a QPointer that nulled is still an entry and
+ /// the list would otherwise grow for the session's lifetime. Removing the
+ /// signal leaks entries; removing the QPointer crashes.
+ QList<QPointer<ComposeWindow>> m_composers;
+
/// Confirmed tag mutations not yet known to have reached the mail store.
///
/// A count of its own rather than QUndoStack::isClean(), which cannot serve
diff --git a/src/markdownrenderer.cpp b/src/markdownrenderer.cpp
new file mode 100644
index 0000000..7158981
--- /dev/null
+++ b/src/markdownrenderer.cpp
@@ -0,0 +1,110 @@
+/*
+ * 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.
+ */
+
+// 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>
+
+#include <cstdlib>
+
+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 safe mode (see below) 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 a hash lookup after the first call. The function-local
+ // static makes the FIRST call thread-safe: cmark-gfm's registry carries no
+ // once-guard of its own, so two threads racing the first call would tear
+ // it. Today's only caller is on the UI thread; this costs nothing and
+ // removes the trap before a worker-thread caller finds it.
+ static const bool registered = [] {
+ cmark_gfm_core_extensions_ensure_registered();
+ return true;
+ }();
+ Q_UNUSED(registered)
+
+ // CMARK_OPT_DEFAULT is 0, and CMARK_OPT_SAFE is a NO-OP in cmark-gfm 0.29:
+ // safe mode has been the default since that release, and the flag is kept
+ // only for API compatibility with code written against older versions.
+ // The real requirement is that CMARK_OPT_UNSAFE must never be set. Under
+ // safe mode a raw <script> block is replaced with an HTML comment
+ // placeholder, and a link whose scheme is not in the allowed set
+ // (javascript:, vbscript:, file:, and data: except a few safe image
+ // types) is replaced with an empty href. Measured against
+ // cmark-gfm-0.29.0.gfm.13 on 2026-08-20: rendering the same script tag and
+ // a javascript: link under OPT_DEFAULT alone, under OPT_DEFAULT|OPT_SAFE,
+ // and under OPT_UNSAFE shows the first two behave identically and
+ // suppress both, while OPT_UNSAFE leaks both verbatim into the output.
+ // OPT_SAFE is kept anyway, both as a statement of intent and in case a
+ // future cmark-gfm release makes it meaningful again; do not read its
+ // presence as the mechanism actually doing the suppressing.
+ 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;
+}
diff --git a/src/markdownrenderer.h b/src/markdownrenderer.h
new file mode 100644
index 0000000..6373fd9
--- /dev/null
+++ b/src/markdownrenderer.h
@@ -0,0 +1,40 @@
+/*
+ * 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.
+ */
+
+#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-gfm's safe mode, which is the DEFAULT in 0.29 and is not the
+/// CMARK_OPT_SAFE flag (a no-op); see markdownrenderer.cpp for the
+/// measurement. The requirement is that CMARK_OPT_UNSAFE is never set.
+QString toHtml(const QString &markdown);
+
+} // namespace MarkdownRenderer
diff --git a/src/messagebuilder.cpp b/src/messagebuilder.cpp
new file mode 100644
index 0000000..42a0e31
--- /dev/null
+++ b/src/messagebuilder.cpp
@@ -0,0 +1,407 @@
+/*
+ * 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.
+ */
+
+// gmime.h pulls in glib's gio headers, which declare a struct field named
+// "signals". Qt's <QtCore/qnamespace.h> #defines "signals" to "Q_SIGNALS"
+// (unless QT_NO_KEYWORDS is set), so gmime.h must be included before any Qt
+// header in this translation unit to avoid a macro collision.
+#include <gmime/gmime.h>
+
+#include "messagebuilder.h"
+
+#include <QCoreApplication>
+#include <QFileInfo>
+#include <QMimeDatabase>
+#include <QMimeType>
+#include <QObject>
+
+#include "config.h"
+#include "markdownrenderer.h"
+
+namespace {
+
+/// GMime must be initialised exactly once per process. MimeParser has its own
+/// copy of this guard; both are cheap and neither can assume the other ran,
+/// since a test may link only one of them.
+void ensureGMimeInitialised()
+{
+ static bool initialised = false;
+ if (!initialised) {
+ g_mime_init();
+ initialised = true;
+ }
+}
+
+/// A text part carrying \p text as utf-8, quoted-printable.
+///
+/// Deliberately NOT g_mime_text_part_set_text(). Measured 2026-08-20: that
+/// function encodes using the charset set at the moment it is CALLED, so the
+/// obvious "set the text, then set the charset" order relabels the part without
+/// re-encoding it. The result is a part headed charset=utf-8 whose bytes are
+/// latin-1 (`Perch=E9`), which looks correct in every header and arrives as
+/// mojibake. Building the content stream from the utf-8 bytes directly was
+/// measured to produce `Perch=C3=A9` correctly. This user writes Italian, so an
+/// accented character is in every message, not an edge case.
+GMimePart *makeTextPart(const char *subtype, const QString &text)
+{
+ GMimePart *part = g_mime_part_new_with_type("text", subtype);
+ g_mime_object_set_content_type_parameter(GMIME_OBJECT(part), "charset", "utf-8");
+
+ const QByteArray utf8 = text.toUtf8();
+ GMimeStream *stream = g_mime_stream_mem_new_with_buffer(utf8.constData(),
+ static_cast<size_t>(utf8.size()));
+ 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_QUOTEDPRINTABLE);
+
+ g_object_unref(wrapper);
+ g_object_unref(stream);
+ return part;
+}
+
+/// Sets \p header on \p message to \p addresses, RFC 2047 encoded as utf-8.
+/// Returns false and names the offending entry in \p badEntry if any of them
+/// could not be parsed as an address.
+///
+/// Each entry is passed through internet_address_list_parse() rather than
+/// treated as a bare address, because the composer's fields hold whatever the
+/// user typed and "Name <addr@example.org>" is the ordinary form. Parsing per
+/// entry rather than joining first keeps a comma inside a quoted display name
+/// from splitting one recipient into two.
+///
+/// An entry that does not parse is a FAILURE, never a skip. The previous
+/// version returned void, `continue`d past anything unparseable, and then only
+/// wrote the header if the assembled list came out non-empty, so
+/// `to = {"not an address at all ((("}` built a message with NO To: header at
+/// all and reported success. With `msmtp -t` the recipients come FROM the
+/// headers, so that is a message handed to the send command with nobody to
+/// deliver to, and a copy filed in Sent that looks sent and reached no one.
+/// Dropping one bad entry of several is the same defect wearing a smaller hat:
+/// the others are delivered and nothing says which was not.
+///
+/// Both the NULL and the zero-length results are treated as failure. Measured
+/// 2026-08-20 on GMime 3.2 with a standalone probe, every garbage input tried
+/// (`not an address at all (((`, `((((`, `a b c`, `,`, `;`, `()`, `<>`, `` )
+/// returned NULL, and no input was found that produced a non-null empty list.
+/// The length check is therefore defensive rather than a path with a fixture
+/// behind it: it is kept because the failure it would cover is a silently
+/// unaddressed message, and it costs one comparison. Do not read it as
+/// documenting observed behaviour, and do not expect a mutation on it to be
+/// killed by the suite.
+///
+/// Worth knowing for anything built on top of this: GMime is LENIENT, not
+/// strict. `garbage` and `""` both parse to a one-entry list. This function
+/// rejects what GMime cannot parse at all; it is not an address validator, and
+/// a typo that happens to be parseable still goes out.
+bool setAddressHeader(GMimeMessage *message, const char *header, const QStringList &addresses,
+ QString *badEntry);
+
+/// A message-id in the angle brackets the wire format requires, added if the
+/// caller did not supply them.
+///
+/// **The brackets are syntax, not decoration, and GMime enforces it by writing
+/// an EMPTY HEADER for a bare addr-spec rather than by complaining.** Measured
+/// 2026-08-21: `In-Reply-To: current@example.org` emits `In-Reply-To:` with no
+/// value, so the reply arrives as an orphan thread in the recipient's client
+/// while nothing looks wrong locally.
+///
+/// Bracketing lives HERE, in the one function that composes these headers,
+/// rather than in each caller. Every source of a message-id in this application
+/// hands over a bare one: GMime strips the brackets when MimeParser reads
+/// `Message-ID`, and `ComposeContextBuilder::referencesForReply` strips them
+/// again from the References chain so the two agree. A convention spread across
+/// callers is one a later caller gets wrong, and the failure is invisible
+/// without inspecting a sent message.
+QString bracketed(const QString &messageId)
+{
+ const QString id = messageId.trimmed();
+ if (id.isEmpty())
+ return {};
+ if (id.startsWith(QLatin1Char('<')) && id.endsWith(QLatin1Char('>')))
+ return id;
+ return QLatin1Char('<') + id + QLatin1Char('>');
+}
+
+bool setAddressHeader(GMimeMessage *message, const char *header, const QStringList &addresses,
+ QString *badEntry)
+{
+ if (addresses.isEmpty())
+ return true;
+
+ InternetAddressList *list = internet_address_list_new();
+ for (const QString &entry : addresses) {
+ const QString trimmed = entry.trimmed();
+ if (trimmed.isEmpty())
+ continue;
+ const QByteArray utf8 = trimmed.toUtf8();
+ InternetAddressList *parsed = internet_address_list_parse(nullptr, utf8.constData());
+ const bool parsedNothing = !parsed || internet_address_list_length(parsed) == 0;
+ if (parsedNothing) {
+ if (parsed)
+ g_object_unref(parsed);
+ g_object_unref(list);
+ *badEntry = trimmed;
+ return false;
+ }
+ internet_address_list_append(list, parsed);
+ g_object_unref(parsed);
+ }
+
+ if (internet_address_list_length(list) > 0) {
+ GMimeFormatOptions *format = g_mime_format_options_get_default();
+ 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);
+ return true;
+}
+
+} // namespace
+
+namespace MessageBuilder {
+
+Result build(const OutgoingMessage &message, const Account &account)
+{
+ Result result;
+
+ // Config::account() returns a DEFAULT-CONSTRUCTED Account for an unknown
+ // key rather than reporting an error, so an account reached by a stale or
+ // mistyped key arrives here looking like a valid one with empty fields.
+ // Building from it would produce a message with an empty From: silently
+ // malformed mail handed to the send command as though it were fine.
+ if (account.address.trimmed().isEmpty()) {
+ result.error = QObject::tr("The account %1 has no address configured, so no message "
+ "can be sent from it.")
+ .arg(account.key);
+ return result;
+ }
+
+ // Attachments are checked HERE rather than when the file was attached: a
+ // file can vanish in between, and a message missing the thing it was
+ // written to carry must never reach the send command. Checked before
+ // anything is allocated, so the failure path frees nothing.
+ //
+ // isFile() is load-bearing and not tidiness. A DIRECTORY reports
+ // exists=1 and isReadable=1, opening one read-only is legal, and GMime's
+ // base64 encoder then loops on a read() returning EISDIR without ever
+ // advancing or erroring: measured 2026-08-20 with strace at 2,169,821
+ // failed reads in twenty seconds and still going, so build() never
+ // returns. It runs synchronously from autosave on the GUI thread, so
+ // dragging a folder into a composer froze the whole application with the
+ // draft unrecoverable. Device nodes and FIFOs block or read forever the
+ // same way, and isFile() excludes those too.
+ for (const QString &path : message.attachments) {
+ const QFileInfo info(path);
+ if (!info.exists() || !info.isFile() || !info.isReadable()) {
+ result.error = QObject::tr("The attachment %1 is missing or unreadable.")
+ .arg(info.fileName().isEmpty() ? path : info.fileName());
+ return result;
+ }
+ }
+
+ ensureGMimeInitialised();
+
+ GMimeMessage *mime = g_mime_message_new(TRUE);
+
+ const QByteArray fromName = account.name.toUtf8();
+ const QByteArray fromAddress = account.address.toUtf8();
+ g_mime_message_add_mailbox(mime, GMIME_ADDRESS_TYPE_FROM,
+ account.name.isEmpty() ? nullptr : fromName.constData(),
+ fromAddress.constData());
+
+ // A recipient the user typed and this cannot understand STOPS the send,
+ // exactly as a missing attachment does, rather than quietly not being
+ // written. See setAddressHeader for what the silent version cost.
+ const struct { const char *header; const QStringList &values; } fields[] = {
+ {"To", message.to},
+ {"Cc", message.cc},
+ // Bcc is written into the bytes deliberately, and this is two separate
+ // decisions rather than one.
+ //
+ // On transmission: the documented send command is `msmtp -t`, which
+ // reads its recipients FROM the headers and strips Bcc itself before
+ // sending, so recipients never see the list. Omitting it here would
+ // mean blind recipients never receive the message at all, silently. If
+ // sending ever passes recipients as arguments instead, this entry must
+ // go with it.
+ //
+ // At rest: one built message serves three consumers, so the SENT COPY
+ // and any autosaved DRAFT are stored in the Maildir with the Bcc list
+ // in plaintext, and mbsync syncs those to the IMAP server where they
+ // are visible to anyone with account access. That is a separate
+ // exposure from transmission and it is accepted knowingly, not
+ // overlooked. Do not "fix" it by stripping Bcc here: that breaks blind
+ // delivery silently, which is worse.
+ {"Bcc", message.bcc},
+ };
+ for (const auto &field : fields) {
+ QString badEntry;
+ if (!setAddressHeader(mime, field.header, field.values, &badEntry)) {
+ g_object_unref(mime);
+ result.error = QObject::tr("%1 is not an address this can send to.").arg(badEntry);
+ return result;
+ }
+ }
+
+ // The explicit "utf-8". Measured 2026-08-20: with NULL here GMime encodes
+ // the subject as iso-8859-1 (=?iso-8859-1?B?...?=).
+ const QByteArray subject = message.subject.toUtf8();
+ g_mime_message_set_subject(mime, subject.constData(), "utf-8");
+
+ // Both headers are bracketed HERE rather than by the caller. See bracketed()
+ // for why, and for what a bare id costs.
+ const QString inReplyTo = bracketed(message.inReplyTo);
+ if (!inReplyTo.isEmpty()) {
+ const QByteArray value = inReplyTo.toUtf8();
+ g_mime_object_set_header(GMIME_OBJECT(mime), "In-Reply-To", value.constData(), "utf-8");
+ }
+ QStringList references;
+ for (const QString &id : message.references) {
+ const QString bracketedId = bracketed(id);
+ // An empty entry contributes nothing rather than a stray "<>": the
+ // References header is a run of ids, and one malformed entry is enough
+ // for a strict parser to discard the whole chain.
+ //
+ // Defensive rather than a path with a fixture behind it, like the
+ // length check in setAddressHeader above: referencesForReply() already
+ // drops empty ids, so a mutation on this line SURVIVES the suite.
+ // Measured 2026-08-21. Kept because it costs one comparison and the
+ // failure it covers is a silently broken thread.
+ if (!bracketedId.isEmpty())
+ references.append(bracketedId);
+ }
+ if (!references.isEmpty()) {
+ const QByteArray value = references.join(QLatin1Char(' ')).toUtf8();
+ g_mime_object_set_header(GMIME_OBJECT(mime), "References", value.constData(), "utf-8");
+ }
+
+ // Measured 2026-08-20: GMime generates neither Date nor Message-ID unless
+ // asked. A message without a Message-ID cannot be threaded by anything that
+ // receives it, this application's own index of the sent copy included.
+ 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);
+ const QByteArray domainUtf8 = (domain.isEmpty() ? QStringLiteral("localhost") : domain).toUtf8();
+ // Held locally rather than written into `result` here. Every failure below
+ // would otherwise have to remember to clear it, which is a two-place
+ // invariant the next early return forgets; it is assigned once, beside the
+ // bytes, on the one path that succeeds.
+ QString messageId;
+ char *generatedId = g_mime_utils_generate_message_id(domainUtf8.constData());
+ if (generatedId) {
+ g_mime_message_set_message_id(mime, generatedId);
+ messageId = QString::fromUtf8(generatedId);
+ g_free(generatedId);
+ }
+
+ // The markdown SOURCE is the plain part, never a stripped-of-syntax
+ // rewrite: `**bold**` reads as emphasis, and rewriting it would mean a
+ // second renderer whose output could disagree with the HTML one.
+ GMimeObject *body = GMIME_OBJECT(makeTextPart("plain", message.markdownBody));
+
+ if (message.sendHtml) {
+ GMimePart *html = makeTextPart("html", MarkdownRenderer::toHtml(message.markdownBody));
+ GMimeMultipart *alternative = g_mime_multipart_new_with_subtype("alternative");
+ // Least-rich FIRST. A client renders the LAST alternative it
+ // understands, so a reversed order shows the markdown source everywhere
+ // and the rendered part is never seen.
+ g_mime_multipart_add(alternative, body);
+ g_mime_multipart_add(alternative, GMIME_OBJECT(html));
+ g_object_unref(body);
+ g_object_unref(html);
+ body = GMIME_OBJECT(alternative);
+ }
+
+ if (!message.attachments.isEmpty()) {
+ GMimeMultipart *mixed = g_mime_multipart_new_with_subtype("mixed");
+ // The body goes in FIRST, so the wrapper NESTS it rather than standing
+ // beside it. Beside it, a client shows the alternatives as attachments
+ // and the message reads as empty.
+ g_mime_multipart_add(mixed, body);
+ g_object_unref(body);
+
+ QMimeDatabase mimeDb;
+ for (const QString &path : message.attachments) {
+ const QFileInfo info(path);
+ const QMimeType type = mimeDb.mimeTypeForFile(info);
+ const QByteArray typeName = type.name().toUtf8();
+
+ GMimeContentType *contentType =
+ g_mime_content_type_parse(nullptr, typeName.isEmpty()
+ ? "application/octet-stream"
+ : typeName.constData());
+ GMimePart *part = g_mime_part_new();
+ if (contentType) {
+ g_mime_object_set_content_type(GMIME_OBJECT(part), contentType);
+ g_object_unref(contentType);
+ }
+
+ GMimeStream *stream = g_mime_stream_file_open(path.toLocal8Bit().constData(),
+ "r", nullptr);
+ if (!stream) {
+ // Existence was checked above, so reaching here means the file
+ // went away between the check and the read. Fail rather than
+ // send a message with a hole in it.
+ g_object_unref(part);
+ g_object_unref(mixed);
+ g_object_unref(mime);
+ result.error = QObject::tr("The attachment %1 could not be read.")
+ .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_object_unref(wrapper);
+ g_object_unref(stream);
+
+ const QByteArray filename = info.fileName().toUtf8();
+ g_mime_part_set_filename(part, filename.constData());
+ g_mime_object_set_disposition(GMIME_OBJECT(part), "attachment");
+
+ g_mime_multipart_add(mixed, GMIME_OBJECT(part));
+ g_object_unref(part);
+ }
+ body = GMIME_OBJECT(mixed);
+ }
+
+ g_mime_message_set_mime_part(mime, body);
+ g_object_unref(body);
+
+ GMimeFormatOptions *format = g_mime_format_options_get_default();
+ char *rendered = g_mime_object_to_string(GMIME_OBJECT(mime), format);
+ if (rendered) {
+ result.bytes = QByteArray(rendered);
+ result.messageId = messageId;
+ g_free(rendered);
+ } else {
+ result.error = QObject::tr("The message could not be assembled.");
+ }
+
+ g_object_unref(mime);
+ return result;
+}
+
+} // namespace MessageBuilder
diff --git a/src/messagebuilder.h b/src/messagebuilder.h
new file mode 100644
index 0000000..f9de277
--- /dev/null
+++ b/src/messagebuilder.h
@@ -0,0 +1,59 @@
+/*
+ * 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.
+ */
+
+#pragma once
+
+#include <QByteArray>
+#include <QString>
+
+#include "types.h"
+
+struct 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
diff --git a/src/messagesender.cpp b/src/messagesender.cpp
new file mode 100644
index 0000000..f336028
--- /dev/null
+++ b/src/messagesender.cpp
@@ -0,0 +1,197 @@
+/*
+ * 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 "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.
+ // Merging them would put the command's ordinary chatter into the error
+ // message shown for a rejected send.
+ m_process.setProcessChannelMode(QProcess::SeparateChannels);
+
+ connect(&m_process, &QProcess::finished,
+ this, &MessageSender::handleFinished);
+ connect(&m_process, &QProcess::errorOccurred,
+ this, &MessageSender::handleError);
+}
+
+MessageSender::~MessageSender()
+{
+ if (m_process.state() == QProcess::NotRunning)
+ return;
+
+ // A send is a live SMTP conversation and abandoning one has a genuinely
+ // unknown outcome, so give the command a bounded chance to finish rather
+ // than killing it outright. Measured: without this, a one-second command
+ // destroyed 100ms in is killed and its work does not complete, announced
+ // only by a Qt warning on stderr. With it, the same command completes and
+ // the destructor costs the ~1s the command actually needed.
+ //
+ // The write channel is closed first because the command may still be
+ // reading: a command blocked on stdin would otherwise never reach EOF and
+ // would burn the whole timeout for no reason.
+ m_process.closeWriteChannel();
+ if (m_process.waitForFinished(kShutdownWaitMs))
+ return;
+
+ // Still running. A destructor cannot block a quitting application forever,
+ // so the process is killed deliberately here rather than by ~QProcess.
+ //
+ // NOTHING IS EMITTED. The outcome after a kill is unknown: the message may
+ // have been fully delivered, partially delivered, or not sent at all, and
+ // this class reports two outcomes only. Emitting finished(false, ...) would
+ // report "not sent" for a message that may well have been, which is the
+ // mailsync.sh mistake pointing the other way. Emitting finished(true, ...)
+ // would be worse. A caller that must know has to keep this object alive
+ // until finished() arrives.
+ //
+ // Claiming the report BEFORE the kill is what makes that true, and it is
+ // not optional: kill() makes QProcess deliver finished(CrashExit), which
+ // reaches handleFinished and would emit exactly the untruthful "not sent"
+ // this comment forbids. Measured, by a test that failed against the
+ // version without these two lines. This is also the one place m_reported
+ // does live work, rather than the defence-in-depth it is on the signal
+ // paths.
+ m_reported = true;
+ m_process.kill();
+ m_process.waitForFinished(kShutdownWaitMs);
+}
+
+bool MessageSender::isRunning() const
+{
+ return m_process.state() != QProcess::NotRunning;
+}
+
+bool MessageSender::send(const QString &command, const QByteArray &bytes)
+{
+ if (command.trimmed().isEmpty() || isRunning())
+ return false;
+
+ // splitCommand gives an argument list; running through a shell would make
+ // every recipient address, display name and config value a potential
+ // injection point. QProcess hands the list to execve, so a `;` or a
+ // `$(...)` in the configured command is a literal argument with nothing to
+ // interpret it. Note that splitCommand strips DOUBLE quotes only.
+ //
+ // 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));
+
+ // Deliberately no waitForStarted(): this runs on the GUI thread and the
+ // interface must stay responsive while a send is in flight. A failed
+ // launch arrives via errorOccurred(FailedToStart) instead, which QProcess
+ // emits INSTEAD OF finished() rather than before it (measured).
+ m_process.start();
+
+ // Written after start() and before the process has necessarily launched,
+ // which is safe: QProcess buffers and drains as the reader consumes.
+ // Measured with a 320KB payload against a `cat` stub, which arrived
+ // byte-identical, so a message with an attachment does not deadlock on the
+ // 64KB pipe buffer.
+ m_process.write(bytes);
+
+ // 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.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.
+ //
+ // This guard IS load-bearing, on exactly one path: the destructor sets
+ // m_reported before kill(), because kill() makes QProcess deliver
+ // finished(CrashExit) and without the flag this handler would emit a
+ // "not sent" for a message whose fate is genuinely unknown. A test fails
+ // against its removal.
+ //
+ // On the two signal paths it is defence in depth and currently cannot
+ // fire: handleError is filtered to FailedToStart, and FailedToStart is
+ // never followed by finished() (measured). An instrumented run of the
+ // whole suite recorded zero hits there, including on the crash and
+ // write-error paths that DO emit both signals. It stays because the day
+ // someone widens handleError to report another error, the double report is
+ // silent and costs a duplicate sent copy.
+ if (m_reported)
+ return;
+ m_reported = true;
+
+ // The exit status is the only authority. Nothing is inferred from what the
+ // command printed: mailsync.sh records what a wrong answer here costs, and
+ // a send reported as succeeding files a sent copy for a message that never
+ // left the machine.
+ 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()) {
+ // A failure with a blank explanation gives the user nothing to act on,
+ // so the status stands in for the reason the command did not give.
+ error = status == QProcess::CrashExit
+ ? tr("The send command crashed.")
+ : 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. Measured on Qt 6.11 for both a
+ // missing binary and a non-executable file: one errorOccurred, no
+ // finished().
+ //
+ // Every other error IS followed by finished() and is left to it, which is
+ // not merely tidiness. A command that exits without draining a large stdin
+ // emits errorOccurred(WriteError) and then finished() with the command's
+ // real exit code and its real stderr; reporting the write error here would
+ // replace the server's own rejection message with a plumbing detail, and
+ // reporting it as well as finished() would deliver two results for one
+ // message.
+ 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));
+}
diff --git a/src/messagesender.h b/src/messagesender.h
new file mode 100644
index 0000000..86dde68
--- /dev/null
+++ b/src/messagesender.h
@@ -0,0 +1,164 @@
+/*
+ * 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.
+ */
+
+#pragma once
+
+#include <QObject>
+#include <QProcess>
+#include <QString>
+
+/// 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".
+///
+/// **The exit status is the only authority on whether a message was sent.**
+/// This is the same rule assets/mailsync.sh exists to honour, and the same
+/// class of bug is available here: a sender that reported success on anything
+/// other than exit 0 would file a sent copy and close the composer for a
+/// message that never left the machine. Nothing is derived from the command's
+/// output, which belongs to whatever the user installed behind send_command.
+///
+/// **No shell, ever.** The command is a config value and is split into an
+/// argument list with QProcess::splitCommand, then handed to QProcess, which
+/// calls execve directly. A `;`, `&&`, `$(...)` or a backtick in the
+/// configured string therefore arrives as a literal argument with nothing to
+/// interpret it. Note that splitCommand understands DOUBLE quotes only:
+/// `-a 'my acct'` splits into three arguments, so a path or an argument
+/// containing a space must be written with double quotes. Measured, not
+/// assumed.
+///
+/// **No message content ever reaches the argument list.** The bytes go on
+/// stdin and only on stdin; the command reads its recipients from the
+/// message's own headers, which is what `-t` means in the documented example.
+/// A recipient address or a display name therefore cannot become an argument
+/// however it is spelled.
+///
+/// This is the outbox seam. An outbox is built by calling this from a drain
+/// loop; nothing in the composer would need to change.
+///
+/// Nothing here blocks the GUI thread DURING a send. send() hands the process
+/// to the event loop and returns; there is no waitForStarted() and no
+/// waitForFinished() on that path, so a command that hangs leaves the
+/// interface responsive and the caller waiting on finished(). Timing a hung
+/// command out is deliberately NOT this class's job: a timeout here would kill
+/// a slow but working send. The one place this class does block is its
+/// destructor, and that is the subject of the next paragraph.
+///
+/// **Destruction mid-send waits, briefly, and then kills.** A send is a live
+/// SMTP conversation, so the outcome of abandoning one is genuinely unknown:
+/// the message may be fully delivered, partially delivered, or not sent at
+/// all. Measured with a one-second command destroyed 100ms in: plain
+/// destruction returns in 100ms, kills the child, and the work does NOT
+/// complete, announced by nothing but a `QProcess: Destroyed while process is
+/// still running` warning on stderr. That is the mailsync.sh failure in a new
+/// place, an unknown real outcome reported as a definite one, and it is
+/// reachable by closing the composer with the window manager's X button while
+/// a send is in flight.
+///
+/// So the destructor waits up to kShutdownWaitMs for the command to finish on
+/// its own, which is the outcome that makes the report truthful: the same
+/// measurement with a bounded wait completes the child and costs only the
+/// ~1s the command actually needed. A command still running after that is
+/// killed, because a destructor cannot block a quitting application forever.
+///
+/// **No finished() is emitted from the destructor, in either branch, and that
+/// is deliberate rather than an omission.** After a kill the outcome is
+/// unknown, and this class reports two outcomes only; inventing a third by
+/// guessing would be the exact lie the rest of this header is built to avoid.
+/// After a successful late finish the emit would reach handlers on a
+/// half-destroyed caller. A caller that must know the result has to keep the
+/// sender alive until finished() arrives, which is what refusing to close a
+/// composer mid-send would express.
+///
+/// **There is no cancel(), and the caller does not have one either.** An
+/// earlier revision of this comment deferred cancellation to "the caller's
+/// popup", which overstated what exists: SendDialog offers an undo BEFORE the
+/// send is committed and none after, by an explicit design decision that a
+/// post-commit cancel is worse than either clean outcome. If a real cancel is
+/// ever wanted it belongs HERE, killing the process and emitting one
+/// finished(false, ...) through m_reported, which is the shape that flag
+/// already has. It is not built now, and this header does not promise it.
+class MessageSender : public QObject
+{
+ Q_OBJECT
+
+public:
+ explicit MessageSender(QObject *parent = nullptr);
+
+ /// Waits briefly for an in-flight send, then kills it. See the class
+ /// comment: this is the one blocking call in the class, and it emits
+ /// nothing.
+ ~MessageSender() override;
+
+ /// How long the destructor gives an in-flight command to finish on its
+ /// own before killing it. Long enough for a local MTA handing off to a
+ /// queue, short enough not to hang a quitting application.
+ static constexpr int kShutdownWaitMs = 5000;
+
+ /// Starts \p command with \p bytes on stdin.
+ ///
+ /// Returns false without emitting anything when the command is empty or
+ /// only whitespace, when it splits to nothing, or when a send is already
+ /// running. A true return means the process was handed to the event loop,
+ /// NOT that it launched: a missing or non-executable 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.
+ ///
+ /// EMITTED exactly once per accepted send, and the distinction between
+ /// emitted and RECEIVED is the whole of this paragraph. QProcess can report
+ /// both an error and a finish for one run (measured: a command that exits
+ /// without draining a large stdin emits errorOccurred(WriteError) and then
+ /// finished()), and m_reported collapses that to one emit.
+ ///
+ /// **m_reported guards the emit, not the receivers, and a caller can still
+ /// see one result twice.** A MessageSender is normally a long-lived member
+ /// reused for every send, so a caller that connects INSIDE its send path
+ /// adds a permanent connection each time: send, fail, correct the
+ /// recipient, send again, and the second result runs BOTH lambdas. The
+ /// first still holds the first message's bytes, so it files a sent copy of
+ /// the wrong message and acts on a dialog it already destroyed. That is
+ /// precisely the harm this signal's contract exists to prevent, arriving
+ /// by the one route no guard inside this class can cover.
+ ///
+ /// A caller connecting per-send must therefore pass
+ /// `Qt::SingleShotConnection` (Qt 6.0+; this project is on 6.11), which
+ /// disconnects the moment the lambda runs. Connecting ONCE in the caller's
+ /// constructor and keeping the per-send state in members is the other
+ /// correct shape. What is not correct, and what reads as permitted if this
+ /// paragraph is skipped, is a bare connect() next to a send() call.
+ 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;
+};
diff --git a/src/messageview.cpp b/src/messageview.cpp
index 469d148..5682858 100644
--- a/src/messageview.cpp
+++ b/src/messageview.cpp
@@ -416,6 +416,17 @@ MessageView::MessageView(QWidget *parent)
staleRow->addStretch();
m_staleBar->hide();
+ // Receive-only ribbon (item 123). Hidden until a message from an account
+ // with no send_command is displayed.
+ m_receiveOnlyRibbon = new QLabel(this);
+ m_receiveOnlyRibbon->setObjectName(QStringLiteral("receiveOnlyRibbon"));
+ // 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 on every value.
+ m_receiveOnlyRibbon->setTextFormat(Qt::PlainText);
+ m_receiveOnlyRibbon->setWordWrap(true);
+ m_receiveOnlyRibbon->hide();
+
m_attachmentBar = new QWidget(this);
m_attachmentBar->setObjectName(QStringLiteral("attachmentBar"));
new QHBoxLayout(m_attachmentBar);
@@ -442,6 +453,7 @@ MessageView::MessageView(QWidget *parent)
auto *layout = new QVBoxLayout(this);
layout->addLayout(headerRow);
layout->addLayout(blockedRow);
+ layout->addWidget(m_receiveOnlyRibbon);
layout->addWidget(m_staleBar);
layout->addWidget(m_view, 1);
layout->addWidget(m_attachmentBar);
@@ -1234,6 +1246,23 @@ void MessageView::saveAttachment(const Attachment &attachment)
emit statusMessage(tr("Saved %1").arg(written));
}
+void MessageView::setReceiveOnlyAccount(const QString &accountKey)
+{
+ if (accountKey.isEmpty()) {
+ m_receiveOnlyRibbon->hide();
+ return;
+ }
+
+ // Names the account AND the key to add. A ribbon saying only "you cannot
+ // reply" leaves the user with nothing to do about it, and the shape is
+ // expressed by omission, so there is no setting to go and look for.
+ m_receiveOnlyRibbon->setText(
+ tr("This account is receive-only. Add send_command to [account.%1] "
+ "to send from it.")
+ .arg(accountKey));
+ m_receiveOnlyRibbon->show();
+}
+
void MessageView::setStaleThread(const QString &threadId,
const QString &messageId)
{
diff --git a/src/messageview.h b/src/messageview.h
index 3cc1604..044bded 100644
--- a/src/messageview.h
+++ b/src/messageview.h
@@ -128,6 +128,15 @@ public:
/// Tags of the thread on display, shown as chips along the bottom.
void setTags(const QStringList &tags);
+ /// Shows or hides the receive-only explanation, naming \p accountKey.
+ /// An empty key hides it.
+ ///
+ /// 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);
+
/// The full headers of every message in the thread, read-only. Also
/// reachable from the button beside the header; public so the window's
/// message_details action can call it.
@@ -391,6 +400,7 @@ private:
QLabel *m_headerLabel = nullptr;
QLabel *m_blockedLabel = nullptr;
+ QLabel *m_receiveOnlyRibbon = nullptr;
QPushButton *m_loadRemoteButton = nullptr;
/// The stale-thread notice and the thread it offers to restore.
diff --git a/src/mimeparser.cpp b/src/mimeparser.cpp
index 2782a4a..c1198b8 100644
--- a/src/mimeparser.cpp
+++ b/src/mimeparser.cpp
@@ -412,9 +412,11 @@ ParsedMessage MimeParser::parse(const QString &filePath) const
out.subject = QString::fromUtf8(
g_mime_message_get_subject(message) ?: "");
out.from = headerText(message, "From");
+ out.replyTo = headerText(message, "Reply-To");
out.to = headerText(message, "To");
out.cc = headerText(message, "Cc");
out.date = headerText(message, "Date");
+ out.references = headerText(message, "References");
out.messageId = QString::fromUtf8(
g_mime_message_get_message_id(message) ?: "");
diff --git a/src/mimeparser.h b/src/mimeparser.h
index da54434..64c4585 100644
--- a/src/mimeparser.h
+++ b/src/mimeparser.h
@@ -119,11 +119,27 @@ struct ParsedMessage
QString subject;
QString from;
+
+ /// Where the author asked for replies to go, raw and undecoded-into-parts.
+ ///
+ /// Takes precedence over `from` when building a reply (RFC 5322 3.6.2).
+ /// Empty on the great majority of mail; a mailing list is the common case
+ /// that sets it, and honouring it is what keeps a list reply on the list
+ /// rather than on a person who never asked to be written to directly.
+ QString replyTo;
+
QString to;
QString cc;
QString date;
QString messageId;
+ /// The raw References header, a whitespace-separated run of <message-ids>.
+ ///
+ /// Carried so a reply can extend the chain. Without it the reply appears
+ /// as an orphan thread in the recipient's client, which is the whole
+ /// reason the header exists.
+ QString references;
+
QString plainBody;
QString htmlBody;
diff --git a/src/notmuchworker.cpp b/src/notmuchworker.cpp
index d0274cd..fca0a5a 100644
--- a/src/notmuchworker.cpp
+++ b/src/notmuchworker.cpp
@@ -20,16 +20,15 @@
#include <notmuch.h>
-#include <QCoreApplication>
#include <QDateTime>
#include <QDir>
#include <QDirIterator>
#include <QFileInfo>
-#include <QHostInfo>
#include <QSet>
#include <cstdlib>
+#include "maildirname.h"
#include "mimeparser.h"
#include "nmraii.h"
@@ -541,8 +540,17 @@ void NotmuchWorker::loadThreadTree(const QString &threadId,
void NotmuchWorker::loadMessage(const QString &messageId, quint64 generation)
{
- if (!openReadOnly())
+ // Every failure below emits an EMPTY result as well as its error, and that
+ // is a contract rather than tidiness. The bottom of this function already
+ // said so ("emitted even when empty, so the UI's handler runs"), but the
+ // three failure paths returned silently and broke it. A caller that arms
+ // state on this request and disarms it on the reply then waits for ever:
+ // MainWindow's compose path did exactly that, and a request left armed
+ // hijacks a later pane load for the same message.
+ if (!openReadOnly()) {
+ emit messageLoaded({}, generation);
return;
+ }
// id: is an exact-match prefix, and the id is quoted because a message id
// can legitimately contain characters notmuch's parser would otherwise read
@@ -552,6 +560,7 @@ void NotmuchWorker::loadMessage(const QString &messageId, quint64 generation)
if (!nmQuery) {
emit errorOccurred(
QStringLiteral("Cannot load message %1").arg(messageId));
+ emit messageLoaded({}, generation);
return;
}
@@ -560,6 +569,7 @@ void NotmuchWorker::loadMessage(const QString &messageId, quint64 generation)
!= NOTMUCH_STATUS_SUCCESS) {
emit errorOccurred(
QStringLiteral("Cannot search message %1").arg(messageId));
+ emit messageLoaded({}, generation);
return;
}
NmMessages messages(rawMessages);
@@ -687,63 +697,6 @@ void NotmuchWorker::applyTags(const TagChange &change)
emit tagsApplied(change);
}
-namespace {
-
-/// A fresh Maildir filename for a message being moved between folders,
-/// preserving only its `:2,<flags>` suffix.
-///
-/// mbsync's manual is explicit about why this exists, under "the more
-/// efficient default UID mapping scheme": "it is important that the MUA
-/// renames files when moving them between Maildir folders", and "the general
-/// expectation is that a completely new filename is generated as if the
-/// message was new".
-///
-/// The `,U=<n>` infix mbsync writes is its per-folder IMAP UID. Carrying it
-/// into another folder makes it a claim about a folder the file is no longer
-/// in; moving a message out and back then reinserts a UID the server has
-/// since reassigned, and mbsync refuses the folder with `Maildir error:
-/// duplicate UID`. Measured on real mail, four collisions in one folder from
-/// a single move-and-restore.
-///
-/// The FLAGS are kept, deliberately, and that is not a contradiction of
-/// "as if the message was new". They record seen, flagged and replied, and
-/// `maildir.synchronize_flags` is true, so notmuch reads them back as tags:
-/// dropping them would mark every deleted message unread and lose Important
-/// on the way to the trash. Only the unique part is regenerated.
-QString freshMaildirName(const QString &oldName)
-{
- // The `:2,` suffix, when there is one. `info` is everything from the
- // separator on, so an empty-flag `:2,` is preserved as faithfully as
- // `:2,FS`.
- QString info;
- const int sep = oldName.indexOf(QStringLiteral(":2,"));
- if (sep >= 0)
- info = oldName.mid(sep);
-
- // The conventional left-to-right unique part: time, a per-process counter,
- // the pid, the host. The counter is what makes two messages moved in the
- // same second distinct, which a timestamp alone does not guarantee.
- static quint64 counter = 0;
- const qint64 now = QDateTime::currentSecsSinceEpoch();
- const QString host = QHostInfo::localHostName().isEmpty()
- ? QStringLiteral("localhost")
- : QHostInfo::localHostName();
-
- return QStringLiteral("%1.M%2P%3Q%4.%5%6")
- .arg(now)
- .arg(QDateTime::currentMSecsSinceEpoch() % 1000)
- .arg(QCoreApplication::applicationPid())
- .arg(++counter)
- // A `/` or a `:` in a hostname would break the path or the flag
- // separator. Neither is legal in a hostname, so this is belt and
- // braces rather than a known case.
- .arg(QString(host).replace(QLatin1Char('/'), QLatin1Char('_'))
- .replace(QLatin1Char(':'), QLatin1Char('_')))
- .arg(info);
-}
-
-} // namespace
-
void NotmuchWorker::moveMessages(const QStringList &messageIds,
const QString &destFolder)
{
@@ -822,11 +775,11 @@ void NotmuchWorker::moveMessages(const QStringList &messageIds,
continue;
}
- // A FRESH name, never the old one. See freshMaildirName(): carrying
+ // A FRESH name, never the old one. See MaildirName::fresh(): carrying
// the `,U=` infix across a folder boundary is what produced
// `Maildir error: duplicate UID` on real mail.
const QString to = destDir + QLatin1Char('/')
- + freshMaildirName(QFileInfo(from).fileName());
+ + MaildirName::fresh(QFileInfo(from).fileName());
if (!QFile::rename(from, to)) {
emit errorOccurred(QStringLiteral("Cannot move %1 to %2")
@@ -1076,6 +1029,25 @@ void NotmuchWorker::requestMessageCounts(const QStringList &queries,
emit messageCountsReady(counts, generation);
}
+void NotmuchWorker::requestMailRoot()
+{
+ if (!openReadOnly()) {
+ // Answered anyway, with an empty root. A consumer waiting for this
+ // signal to enable something would otherwise wait for ever on a
+ // database that cannot be opened, which is the same silent stall
+ // loadMessage() emits an empty result to avoid.
+ emit mailRootReady(QString());
+ return;
+ }
+
+ // mailRootOf(), never notmuch_database_get_path(). Item 124: under a split
+ // config the latter names the INDEX directory, and a draft or a sent copy
+ // composed from it is written into the Xapian tree.
+ const QString root = mailRootOf(m_db);
+ emit mailRootReady(root.isEmpty() ? QString()
+ : QDir(root).absolutePath());
+}
+
void NotmuchWorker::requestFolders()
{
if (!openReadOnly())
diff --git a/src/notmuchworker.h b/src/notmuchworker.h
index 9932e59..8ed878f 100644
--- a/src/notmuchworker.h
+++ b/src/notmuchworker.h
@@ -223,6 +223,20 @@ public slots:
/// source of truth the design refuses.
void requestFolders();
+ /// The Maildir root, for whatever has to compose a path under it.
+ ///
+ /// This class owns the only database handle, and the root is a property of
+ /// the DATABASE rather than of config: notmuch can split the index from
+ /// the mail with `mail_root` and `path` as separate keys, so there is no
+ /// config key the UI could read instead. Item 124 records what the wrong
+ /// accessor costs. `notmuch_database_get_path()` returns the INDEX
+ /// directory under that layout, and a destination composed from it writes
+ /// into the Xapian tree.
+ ///
+ /// Requested at startup beside requestAllTags(), and answered once. The
+ /// root does not change while the application runs.
+ void requestMailRoot();
+
signals:
void threadsReady(const QVector<ThreadSummary> &threads, quint64 generation);
void queryFinished(int totalThreads, quint64 generation);
@@ -288,6 +302,12 @@ signals:
/// asks once when its dialog opens.
void foldersReady(const QStringList &folders);
+ /// The Maildir root, absolute. No generation: it is a property of the
+ /// database rather than of any query, so a late answer is still the right
+ /// one. Empty when the database could not be opened, which a consumer must
+ /// treat as "cannot compose a path yet" rather than as the root being "".
+ void mailRootReady(const QString &mailRoot);
+
void errorOccurred(const QString &message);
private:
diff --git a/src/senddialog.cpp b/src/senddialog.cpp
new file mode 100644
index 0000000..4a36b7b
--- /dev/null
+++ b/src/senddialog.cpp
@@ -0,0 +1,318 @@
+/*
+ * 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 "senddialog.h"
+
+#include <QDateTime>
+#include <QFontMetrics>
+#include <QHBoxLayout>
+#include <QCloseEvent>
+#include <QKeyEvent>
+#include <QLabel>
+#include <QPushButton>
+#include <QTimer>
+#include <QVBoxLayout>
+
+#include "busyindicator.h"
+
+namespace {
+
+// How often the countdown repaints: smooth enough for a draining bar without
+// being a busy loop. It is also the resolution of the countdown itself, since
+// tick() subtracts exactly this much rather than consulting a clock. Two
+// consequences, both deliberate: a delay that is not a multiple of 100 rounds
+// UP to one (250 runs for 300ms), and timer slack accumulates rather than
+// being corrected against a clock. Drift is irrelevant at this scale, where
+// the number is a courtesy pause and nothing downstream measures it.
+constexpr int kTickMs = 100;
+
+// How long the refused-dismissal hint holds the status label. Longer than a
+// tick, or the countdown would overwrite it before it could be read and the
+// refusal would be silent in practice; short enough that the countdown the
+// user is waiting on is not hidden for any meaningful part of its life.
+constexpr qint64 kHintMs = 1500;
+
+} // 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: during the countdown a bare dismissal is ambiguous,
+ // since it could equally mean "cancel" or "send now", so Undo is the only
+ // control that states which. This removes the AFFORDANCE only. Escape,
+ // close() and the window manager all still reach done(), and that override
+ // is what actually makes a dismissal safe; keyPressEvent() below merely
+ // spares the user an Escape that would silently undo. Reasoning about this
+ // flag alone is what left close() committing a send with no window up.
+ setWindowFlags((windowFlags() | Qt::CustomizeWindowHint)
+ & ~Qt::WindowCloseButtonHint);
+
+ auto *layout = new QVBoxLayout(this);
+
+ m_status = new QLabel(this);
+ m_status->setObjectName(QStringLiteral("sendStatus"));
+
+ // Sized to the LONGEST string it can hold in the current language, not to
+ // its content. Italian "Rimozione della bozza..." is longer than "Removing
+ // draft...", so a label sized to whatever it happens to be showing resizes
+ // the popup between stages. Computed from tr() results at construction, so
+ // it is correct in whatever language is loaded AT THAT MOMENT. That is
+ // sufficient here and not in general: main() installs the QTranslator on
+ // its own stack before any window exists, so no dialog can outlive a
+ // language change. A runtime language switch would need this recomputed.
+ const QFontMetrics metrics(m_status->font());
+ // The refusal hint is in this list too. It replaces the countdown text in
+ // the same label, so leaving it out would resize the popup at exactly the
+ // moment the user is being told the window will not close, which is the
+ // worst possible time for it to jump.
+ const QStringList candidates{
+ tr("Sending in %1...").arg(99),
+ tr("Sending..."),
+ tr("Filing sent copy..."),
+ tr("Removing draft..."),
+ tr("Press Undo to stop sending."),
+ };
+ int widest = 0;
+ for (const QString &candidate : candidates)
+ 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);
+
+ // Three rows in every state, so nothing reflows: Undo keeps its place and
+ // its size after it disables rather than vanishing.
+ auto *buttons = new QHBoxLayout;
+ buttons->addStretch();
+ m_undo = new QPushButton(tr("Undo"), this);
+ m_undo->setObjectName(QStringLiteral("undoSend"));
+ buttons->addWidget(m_undo);
+ layout->addLayout(buttons);
+
+ // Built BEFORE the Undo connection below, which stops it. The lambda would
+ // read a null m_timer otherwise, and only because nothing can click a
+ // button mid-constructor does the reverse order happen to survive.
+ m_timer = new QTimer(this);
+ m_timer->setObjectName(QStringLiteral("sendCountdown"));
+ m_timer->setInterval(kTickMs);
+ connect(m_timer, &QTimer::timeout, this, &SendDialog::tick);
+
+ // Both the button and done() funnel into one place, so the two dismissal
+ // routes cannot drift into disagreeing about what a cancel does.
+ connect(m_undo, &QPushButton::clicked, this, [this] { undo(); });
+
+ if (m_totalMs == 0) {
+ // Queued rather than immediate, so a caller that connects to
+ // committed() AFTER constructing the dialog still hears it. Emitting
+ // from the constructor would send to nobody.
+ QTimer::singleShot(0, this, &SendDialog::commit);
+ } else {
+ setStage(Stage::CountingDown);
+ m_timer->start();
+ }
+}
+
+bool SendDialog::undo()
+{
+ // Undo is disabled at commit, but a disabled button is a UI property and
+ // not an invariant. This is the ONE place that can report "nothing was
+ // sent", so it refuses outright once the command is running rather than
+ // trusting the button's state.
+ //
+ // m_undone is the second half and is NOT redundant: it makes undone()
+ // fire exactly once however many times this is reached.
+ if (m_committed || m_undone)
+ return false;
+ m_undone = true;
+
+ // The timer stops FIRST. A timer left running commits after the dialog has
+ // already reported that nothing was sent, which is the one outcome the
+ // whole delay exists to make impossible.
+ m_timer->stop();
+ m_undo->setEnabled(false);
+ emit undone();
+
+ // Undo is the ONE route out before commit, so it is the one caller allowed
+ // through done()'s refusal. The flag is what distinguishes it from every
+ // other reject(); it is never cleared, because the dialog is finished.
+ m_undoing = true;
+ reject();
+ return true;
+}
+
+void SendDialog::refuseDismissal()
+{
+ // A window that ignores a close reads as a hang, so the refusal says where
+ // the exit is rather than doing nothing at all. One function because both
+ // done() and closeEvent() refuse, and two copies of this meant neutering
+ // either one left the other still setting the text, hiding the regression.
+ //
+ // Held for kHintMs, because the countdown's next tick is only kTickMs away
+ // and would otherwise overwrite the hint before it could be read, leaving
+ // the refusal effectively silent after all. setStage() honours the hold
+ // rather than this scheduling a restore, so the countdown keeps running
+ // underneath and there is no second timer to get out of step.
+ m_hintUntil = QDateTime::currentMSecsSinceEpoch() + kHintMs;
+ m_status->setText(tr("Press Undo to stop sending."));
+ m_undo->setFocus();
+}
+
+void SendDialog::keyPressEvent(QKeyEvent *event)
+{
+ // QDialog maps Escape to reject(). Swallowed WITH ANY MODIFIER: Shift and
+ // Ctrl variants are the same keystroke as far as intent goes, and letting
+ // one through would be an undocumented back door to the same dismissal.
+ // done() would treat it safely as an Undo either way; this just spares the
+ // user a cancel they did not ask for by reflex.
+ if (event->key() == Qt::Key_Escape) {
+ event->accept();
+ return;
+ }
+ QDialog::keyPressEvent(event);
+}
+
+void SendDialog::done(int result)
+{
+ // Every dismissal route arrives here, which is the point: close(), the
+ // window manager, Escape and QDialog's own reject() all converge on
+ // done(), and guarding any one of them individually leaves the others
+ // open. Which routes are permitted, and when:
+ //
+ // BEFORE COMMIT, nothing closes the dialog except Undo. A close is
+ // REFUSED, not silently reinterpreted as a cancel: "close means undo" is
+ // confusing, because the user cannot tell whether dismissing the window
+ // stopped the send or merely hid it, and the two answers differ by whether
+ // their mail goes out. The popup carries exactly one control and it says
+ // what it does. Undo reaches QDialog::done() through m_undoing below.
+ //
+ // AFTER COMMIT, the send is in flight and there is nothing left to cancel,
+ // so any close is honoured. It is forced to Accepted so a caller reading
+ // result() cannot mistake a running send for a cancelled one.
+ //
+ // TASK 12 closes this dialog when the send finishes, and it does so after
+ // commit by definition, so the ordinary accept()/close() works and needs
+ // no special entry point. A stray reject() cannot reach the pre-commit
+ // state at all, which is the property this refusal buys.
+ if (m_committed) {
+ QDialog::done(QDialog::Accepted);
+ return;
+ }
+
+ if (m_undoing) {
+ QDialog::done(QDialog::Rejected);
+ return;
+ }
+
+ refuseDismissal();
+}
+
+void SendDialog::closeEvent(QCloseEvent *event)
+{
+ // Measured against a standalone Qt program, not assumed: close() on a
+ // dialog that was NEVER SHOWN reaches closeEvent() but returns BEFORE
+ // done(), so done()'s refusal alone would let that one route through. A
+ // shown dialog reaches both, and ignoring the event here stops it before
+ // done() is consulted.
+ if (!m_committed && !m_undoing) {
+ event->ignore();
+ refuseDismissal();
+ return;
+ }
+ QDialog::closeEvent(event);
+}
+
+void SendDialog::tick()
+{
+ m_remainingMs -= kTickMs;
+ if (m_remainingMs <= 0) {
+ commit();
+ return;
+ }
+ setStage(Stage::CountingDown);
+}
+
+void SendDialog::commit()
+{
+ // Idempotent: a stray tick racing the singleShot must not emit twice.
+ if (m_committed)
+ return;
+
+ m_timer->stop();
+ m_committed = true;
+
+ // Disabled, never hidden. A greyed Undo says why cancelling is no longer
+ // possible; an absent one only looks like it was never offered.
+ m_undo->setEnabled(false);
+
+ setStage(Stage::Sending);
+ emit committed();
+}
+
+void SendDialog::setStage(Stage stage)
+{
+ // The enum is documented "in order", so the class enforces that rather
+ // than trusting its caller: Task 12 passes values from this public enum,
+ // and winding back would relabel a running send "Sending in 0..." and
+ // redraw a full countdown bar under it, offering a cancel that no longer
+ // exists. Only the backwards step is refused; the forward stages are the
+ // caller's to drive.
+ if (m_committed && stage == Stage::CountingDown)
+ return;
+
+ // The refusal hint outranks the countdown text for as long as it is held.
+ // Only the countdown is suppressed: a stage change is a real event and
+ // must always be shown, and commit() clears the hold anyway.
+ if (stage == Stage::CountingDown
+ && QDateTime::currentMSecsSinceEpoch() < m_hintUntil) {
+ m_indicator->setProgress(m_remainingMs, m_totalMs);
+ return;
+ }
+
+ switch (stage) {
+ case Stage::CountingDown:
+ // Rounded up, so a countdown with 1ms left still reads "1" rather than
+ // sitting on "0" for a tick.
+ m_status->setText(tr("Sending in %1...")
+ .arg((m_remainingMs + 999) / 1000));
+ m_indicator->setProgress(m_remainingMs, m_totalMs);
+ return;
+ case Stage::Sending:
+ m_status->setText(tr("Sending..."));
+ break;
+ case Stage::FilingSentCopy:
+ m_status->setText(tr("Filing sent copy..."));
+ break;
+ case Stage::RemovingDraft:
+ m_status->setText(tr("Removing draft..."));
+ break;
+ }
+
+ // Everything past the countdown: the duration stops being knowable, so the
+ // same widget switches from a fraction to an animation.
+ m_indicator->setBusy(true);
+}
diff --git a/src/senddialog.h b/src/senddialog.h
new file mode 100644
index 0000000..a930c3d
--- /dev/null
+++ b/src/senddialog.h
@@ -0,0 +1,145 @@
+/*
+ * 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.
+ */
+
+#pragma once
+
+#include <QDialog>
+#include <QtGlobal>
+
+class BusyIndicator;
+class QLabel;
+class QCloseEvent;
+class QKeyEvent;
+class QPushButton;
+class QTimer;
+
+/// Owns a send from the cancellable countdown through to completion.
+///
+/// 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:
+/// the message may have reached the server in full before the kill, which is
+/// worse than either clean outcome. So there is no cancel after commit, and
+/// isCommitted() is the line between the two.
+///
+/// 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.
+///
+/// DISMISSAL IS A THIRD ROUTE TO THE SAME FAILURE, and removing the close
+/// button only removes the affordance. Escape, the window manager, close() and
+/// QDialog's own machinery all still reach done(); see done() and closeEvent()
+/// below, which are the two places that cover them. An earlier revision
+/// reasoned about Escape and the titlebar button alone and left close()
+/// committing a send with no window on screen.
+///
+/// Before commit, Undo is the ONLY way out and every other route is refused.
+class SendDialog : public QDialog
+{
+ Q_OBJECT
+
+public:
+ /// \p delayMs of zero skips the countdown and sends at once.
+ explicit SendDialog(int delayMs, QWidget *parent = nullptr);
+
+ /// The stages, in order. Each sets the label; every stage after the
+ /// countdown leaves the bar indeterminate.
+ enum class Stage { CountingDown, Sending, FilingSentCopy, RemovingDraft };
+ Q_ENUM(Stage)
+
+ 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();
+
+protected:
+ /// Swallows Escape, with any modifiers. QDialog maps it to reject(), and
+ /// during the countdown a bare dismissal is ambiguous in exactly the way
+ /// the constructor describes; Undo is the control that says which it means.
+ void keyPressEvent(QKeyEvent *event) override;
+
+ /// The single choke point for every dismissal route, which is why the
+ /// close button's removal was not enough on its own: QDialog reaches
+ /// reject() from the window manager, from close(), and from its own
+ /// machinery, and all of them arrive here.
+ ///
+ /// During the countdown a close is REFUSED. "Close means undo" is
+ /// confusing: the user cannot tell whether dismissing the window stopped
+ /// the send or merely hid it, and the two answers differ by whether their
+ /// mail goes out. Undo is the only way out, which is what the popup's
+ /// single control already says. After commit any close is honoured, since
+ /// there is nothing left to cancel, and it is forced to Accepted so a
+ /// caller reading result() cannot mistake a running send for a cancelled
+ /// one. Task 12 closes the dialog after the send finishes, which is
+ /// post-commit by definition and so needs no special entry point.
+ void done(int result) override;
+
+ /// CLAUDE.md's companion trap: close() on a widget that was never shown
+ /// returns early WITHOUT reaching done(), so done()'s refusal alone would
+ /// let exactly that one route through. Refuses on the same terms.
+ void closeEvent(QCloseEvent *event) override;
+
+private:
+ /// The one place that can report "nothing was sent". Returns false, and
+ /// does nothing at all, once the send has committed. Both the Undo button
+ /// and every dismissal route funnel through it.
+ bool undo();
+
+ /// Shows the hint that Undo is the only way out, and holds it long enough
+ /// to be read. One function because both refusal sites call it.
+ void refuseDismissal();
+
+ 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;
+
+ /// Set by the first undo(), so undone() is emitted exactly once however
+ /// many dismissal routes fire. A shown dialog's close() reaches BOTH
+ /// closeEvent() and done().
+ bool m_undone = false;
+
+ /// Deadline until which the refusal hint holds the status label against
+ /// the countdown's own text. Zero when no hint is showing.
+ qint64 m_hintUntil = 0;
+
+ /// Set only by undo(), and what lets that one route through done()'s
+ /// pre-commit refusal. Every other reject() is turned away.
+ bool m_undoing = false;
+};
diff --git a/src/types.h b/src/types.h
index f4d387a..99c271d 100644
--- a/src/types.h
+++ b/src/types.h
@@ -239,6 +239,47 @@ struct DatabaseStats
int tags = -1; ///< Distinct tag names in the database.
};
+/// 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. Plain data here; the resolution rules live with whatever builds this context.
+ 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;
+};
+
Q_DECLARE_METATYPE(ThreadSummary)
Q_DECLARE_METATYPE(MessageRef)
Q_DECLARE_METATYPE(MessageNode)
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index d1d8a29..1af49bb 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -68,9 +68,37 @@ add_qtmaildir_test(searchterm)
add_qtmaildir_test(busyindicator)
add_qtmaildir_test(tagstrip)
add_qtmaildir_test(messagedetailsdialog)
+add_qtmaildir_test(markdownrenderer)
+add_qtmaildir_test(messagebuilder)
+add_qtmaildir_test(maildirname)
+add_qtmaildir_test(draftstore)
+add_qtmaildir_test(messagesender)
+add_qtmaildir_test(composecontext)
+add_qtmaildir_test(formattoolbar)
+add_qtmaildir_test(senddialog)
add_qtmaildir_test(translations)
# Asserts on the tracked .ts rather than the generated .qm: an untranslated
# string is dropped by lrelease, so it is invisible in the .qm and shows up
# only as English in a running Italian UI.
target_compile_definitions(test_translations PRIVATE
TRANSLATIONS_DIR="${CMAKE_SOURCE_DIR}/translations")
+
+# The notmuch hooks (assets/hooks/), which are Python rather than C++ and are
+# therefore registered directly rather than through add_qtmaildir_test().
+#
+# They run against the user's REAL mail on every sync, so they belong in the
+# suite rather than beside it as scripts someone remembers to run. Two of the
+# three need `notmuch` on PATH and build a throwaway database in a temp
+# directory; none of them touches the real one.
+#
+# No QT_QPA_PLATFORM here: nothing Qt is involved.
+find_package(Python3 COMPONENTS Interpreter)
+if(Python3_Interpreter_FOUND)
+ foreach(hook_test mailrules post_new qtmaildirconf)
+ add_test(NAME hooks_${hook_test}
+ COMMAND ${Python3_EXECUTABLE}
+ ${CMAKE_SOURCE_DIR}/assets/hooks/test_${hook_test}.py)
+ endforeach()
+else()
+ message(STATUS "Python3 not found: the notmuch hook tests will not run")
+endif()
diff --git a/tests/test_composecontext.cpp b/tests/test_composecontext.cpp
new file mode 100644
index 0000000..fccec87
--- /dev/null
+++ b/tests/test_composecontext.cpp
@@ -0,0 +1,1051 @@
+/*
+ * 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.
+ */
+
+// gmime BEFORE any Qt header. glib declares a struct field named "signals",
+// which Qt #defines to Q_SIGNALS. Needed here for g_log_set_handler(), which is
+// how aGroupIsDroppedByTheGuardAndNotByAFailedCast() sees the difference
+// between a group dropped by the guard and one dropped by a failed cast.
+#include <gmime/gmime.h>
+
+#include <QtTest>
+#include <QTemporaryDir>
+
+#include "composecontext.h"
+#include "config.h"
+#include "mimeparser.h"
+#include "types.h"
+
+using ComposeContextBuilder::Recipient;
+
+class TestComposeContext : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ // Own addresses.
+ void everyOwnAddressIsCollected();
+ void aBlankOwnAddressIsNotCollected();
+
+ // Header parsing, the foundation the recipient rules stand on.
+ void aDisplayNameContainingACommaIsOneRecipient();
+ void aQuotedDisplayNameSurvivesRoundTripping();
+ void aMalformedHeaderYieldsNoRecipients();
+ void anEmptyHeaderYieldsNoRecipients();
+ void aGroupContributesNoRecipient();
+ void aGroupIsDroppedByTheGuardAndNotByAFailedCast();
+ void anInjectedHeaderLineIsNotCarriedForward();
+
+ // Reply and reply-all recipient derivation.
+ void aPlainReplyGoesToTheSenderOnly();
+ void aReplyPrefersReplyToOverFrom();
+ void aReplyAllPutsTheSenderInToAndTheRestInCc();
+ void aReplyAllStripsEveryOwnAddress();
+ void aReplyAllStripsAnOwnAddressRegardlessOfCase();
+ void aReplyAllDoesNotListTheSenderTwice();
+ void aReplyAllSuppressesDuplicatesAcrossToAndCc();
+ void aReplyToOneselfStillAddressesSomeone();
+ void aReplyToOwnMessageGoesToItsOriginalRecipients();
+ void aReplyAllToOwnMessageDoesNotRepeatToInCc();
+ void aCoSenderIsStillRepliedTo();
+ void anUnparseableSenderStillProducesARecipient();
+ void aReplyAllPrefersReplyToForTheToField();
+ void aDisplayNameContainingAnOwnAddressIsNotMistakenForIt();
+
+ // References.
+ void referencesCarryTheOriginalChainPlusItsId();
+ void referencesDoNotRepeatTheMessageId();
+ void aCommaSeparatedReferencesHeaderIsSplitIntoIds();
+
+ // Subjects.
+ void aReplySubjectDoesNotDoubleItsPrefix();
+ void aForwardSubjectDoesNotDoubleItsPrefix();
+ void anEmptySubjectStillGetsAPrefix();
+ void aSubjectMentioningReLaterStillGetsAPrefix();
+ void aNonEnglishPrefixIsNotDoubled();
+ void aCountedPrefixIsNotDoubled();
+ void aSingleLetterBeforeAColonIsNotAPrefix();
+
+ // Account resolution.
+ void theReplyAccountComesFromTheMessagesMaildir();
+ void anAccountIsNotMatchedByAPrefixOfItsMaildir();
+ void anAmbiguousMessagePrefersTheMatchingRecipient();
+ void anAmbiguousMessageWithNoMatchTakesTheFirst();
+ void aNewMessagePrefersTheSelectedAccount();
+ void aNewMessageFallsThroughASelectedAccountThatCannotSend();
+ void aNewMessageUsesDefaultAccountFromAllAccounts();
+ void aNewMessageUsesStartupAccountWhenNoDefaultIsSet();
+ void aNewMessageFallsBackToTheFirstSendingAccount();
+ void aNewMessageReturnsNothingWhenNoAccountCanSend();
+
+ // Quoting.
+ void aQuotedBodyPrefixesEveryLine();
+
+private:
+ QString writeConfig(const QString &contents);
+
+ QTemporaryDir m_dir;
+};
+
+QString TestComposeContext::writeConfig(const QString &contents)
+{
+ // A unique name per call: Config caches nothing, but reusing one path
+ // across tests in one binary invites a stale read to look like a pass.
+ static int counter = 0;
+ const QString path =
+ m_dir.filePath(QStringLiteral("qtmaildir%1.conf").arg(++counter));
+ QFile file(path);
+ if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text))
+ return {};
+ file.write(contents.toUtf8());
+ file.close();
+ return path;
+}
+
+// ---------------------------------------------------------------------------
+// Own addresses
+// ---------------------------------------------------------------------------
+
+void TestComposeContext::everyOwnAddressIsCollected()
+{
+ // 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;
+ config.load(path);
+ QCOMPARE(config.accounts().size(), 5);
+
+ 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::aBlankOwnAddressIsNotCollected()
+{
+ // An account with no address key is legal. An empty string in this list
+ // would match nothing usefully and, in a substring filter, everything.
+ const QString path = writeConfig(QStringLiteral(
+ "[account.one]\nmaildir=one\ntrash=Trash\naddress=first@example.org\n"
+ "[account.noaddress]\nmaildir=two\ntrash=Trash\n"));
+ Config config;
+ config.load(path);
+ QCOMPARE(config.accounts().size(), 2);
+
+ const QStringList own = ComposeContextBuilder::ownAddresses(config);
+ QCOMPARE(own, QStringList{ QStringLiteral("first@example.org") });
+}
+
+// ---------------------------------------------------------------------------
+// Header parsing
+// ---------------------------------------------------------------------------
+
+void TestComposeContext::aDisplayNameContainingACommaIsOneRecipient()
+{
+ // The single most likely parsing bug: splitting on commas turns one
+ // recipient into two, one of which ("Rossi") is not an address at all and
+ // would be handed to the send command.
+ const QList<Recipient> parsed = ComposeContextBuilder::parseAddressHeader(
+ QStringLiteral("\"Rossi, Mario\" <m@example.org>, info@example.net"));
+
+ QCOMPARE(parsed.size(), 2);
+ QCOMPARE(parsed.at(0).address, QStringLiteral("m@example.org"));
+ QCOMPARE(parsed.at(1).address, QStringLiteral("info@example.net"));
+}
+
+void TestComposeContext::aQuotedDisplayNameSurvivesRoundTripping()
+{
+ // A comma in a display name must come back out QUOTED. Unquoted, the
+ // rendered form is not a legal single address: it happens to survive
+ // GMime's own lenient re-parse, but it goes into a To: header that other
+ // clients and MTAs read, and a bare comma there is a recipient separator.
+ //
+ // Asserted on the RENDERED TEXT rather than on a re-parse, and that is the
+ // point of the test: a round-trip through parseAddressHeader() passes
+ // against string-assembled "Rossi, Mario <m@example.org>" because GMime
+ // reads it back as one address anyway. Measured 2026-08-21, a mutation
+ // replacing the GMime rendering with `name + " <" + addr + ">"` left the
+ // whole suite green until this assertion was written this way.
+ const QList<Recipient> parsed = ComposeContextBuilder::parseAddressHeader(
+ QStringLiteral("\"Rossi, Mario\" <m@example.org>"));
+ QCOMPARE(parsed.size(), 1);
+ QCOMPARE(parsed.at(0).rendered,
+ QStringLiteral("\"Rossi, Mario\" <m@example.org>"));
+
+ // And it still re-parses to the same one address.
+ const QList<Recipient> again =
+ ComposeContextBuilder::parseAddressHeader(parsed.at(0).rendered);
+ QCOMPARE(again.size(), 1);
+ QCOMPARE(again.at(0).address, QStringLiteral("m@example.org"));
+}
+
+void TestComposeContext::aMalformedHeaderYieldsNoRecipients()
+{
+ // GMime returns NULL rather than an empty list for input it can make
+ // nothing of. Measured 2026-08-21: "not an address at all" and "<<<>>>"
+ // both return NULL.
+ QVERIFY(ComposeContextBuilder::parseAddressHeader(
+ QStringLiteral("not an address at all")).isEmpty());
+ QVERIFY(ComposeContextBuilder::parseAddressHeader(
+ QStringLiteral("<<<>>>")).isEmpty());
+}
+
+void TestComposeContext::anEmptyHeaderYieldsNoRecipients()
+{
+ QVERIFY(ComposeContextBuilder::parseAddressHeader(QString()).isEmpty());
+ QVERIFY(ComposeContextBuilder::parseAddressHeader(
+ QStringLiteral(" ")).isEmpty());
+}
+
+void TestComposeContext::aGroupContributesNoRecipient()
+{
+ // A group has a name and no mailbox. Carrying its name forward would put
+ // "undisclosed-recipients" in a To field as though it were a person.
+ const QList<Recipient> parsed = ComposeContextBuilder::parseAddressHeader(
+ QStringLiteral("undisclosed-recipients:;"));
+ QVERIFY2(parsed.isEmpty(),
+ qPrintable(QStringLiteral("a group produced %1 recipient(s)")
+ .arg(parsed.size())));
+}
+
+void TestComposeContext::aGroupIsDroppedByTheGuardAndNotByAFailedCast()
+{
+ // The count alone cannot see this, which is why the guard survived a
+ // mutation until 2026-08-21. Removing the INTERNET_ADDRESS_IS_MAILBOX check
+ // still yields no recipients, because the invalid cast makes GMime's own
+ // assertion return NULL and the address is skipped one line later. The
+ // count is therefore right for the wrong reason, and the reason matters: an
+ // invalid GObject cast is undefined behaviour papered over by an assertion
+ // that G_DISABLE_CHECKS compiles out and that G_DEBUG=fatal-criticals turns
+ // into an abort. A security property must not rest on assertions staying
+ // enabled.
+ //
+ // So this asserts on the CRITICAL rather than on the count. glib routes it
+ // through the log handler installed here, and a clean parse emits none.
+ struct Captured
+ {
+ static void handler(const gchar *domain, GLogLevelFlags level,
+ const gchar *messageText, gpointer userData)
+ {
+ Q_UNUSED(domain);
+ Q_UNUSED(level);
+ auto *messages = static_cast<QStringList *>(userData);
+ messages->append(QString::fromUtf8(messageText));
+ }
+ };
+
+ // Registered per DOMAIN, and the domain is the trap: the two criticals this
+ // watches for carry "GLib-GObject" and "gmime", while a NULL domain
+ // registers only for the default one. A handler on nullptr alone catches
+ // NOTHING here and the test passes against the mutation, measured
+ // 2026-08-21.
+ QStringList criticals;
+ const auto levels = GLogLevelFlags(G_LOG_LEVEL_CRITICAL | G_LOG_LEVEL_WARNING
+ | G_LOG_FLAG_FATAL | G_LOG_FLAG_RECURSION);
+ QList<guint> handlerIds;
+ for (const char *domain : { "GLib-GObject", "gmime" })
+ handlerIds.append(g_log_set_handler(domain, levels, &Captured::handler, &criticals));
+
+ // A group carrying MEMBERS, not the empty "undisclosed-recipients:;". The
+ // empty form has nothing to cast, so it cannot tell the two paths apart.
+ const QList<Recipient> parsed = ComposeContextBuilder::parseAddressHeader(
+ QStringLiteral("friends: a@example.org, b@example.net;"));
+
+ int i = 0;
+ for (const char *domain : { "GLib-GObject", "gmime" })
+ g_log_remove_handler(domain, handlerIds.at(i++));
+
+ QVERIFY2(parsed.isEmpty(),
+ qPrintable(QStringLiteral("a group with members produced %1 recipient(s)")
+ .arg(parsed.size())));
+ QVERIFY2(criticals.isEmpty(),
+ qPrintable(QStringLiteral("GMime emitted %1 during the parse: %2")
+ .arg(criticals.size())
+ .arg(criticals.join(QLatin1Char('|')))));
+}
+
+void TestComposeContext::anInjectedHeaderLineIsNotCarriedForward()
+{
+ // Header injection, from a stranger's message into the user's reply.
+ // Measured 2026-08-21: GMime parses the smuggled line as a GROUP named
+ // "Bcc", so dropping non-mailboxes drops it. If groups were kept, a reply
+ // would silently pre-fill a recipient the user never saw.
+ const QList<Recipient> parsed = ComposeContextBuilder::parseAddressHeader(
+ QStringLiteral("a@example.org\nBcc: evil@example.net"));
+
+ QCOMPARE(parsed.size(), 1);
+ QCOMPARE(parsed.at(0).address, QStringLiteral("a@example.org"));
+ for (const Recipient &recipient : parsed) {
+ QVERIFY2(!recipient.rendered.contains(QStringLiteral("evil@example.net")),
+ qPrintable(QStringLiteral("injected address survived in: %1")
+ .arg(recipient.rendered)));
+ }
+
+ // The other injection shape: the newline hidden INSIDE a quoted display
+ // name, where it does not split the header and so is not dropped as a
+ // group. It has to come back RFC 2047 encoded, never as a raw newline: a
+ // bare CR or LF in a rendered recipient is a header-injection primitive
+ // the moment anything writes it into a To: line. Rendering by hand rather
+ // than through GMime is what loses the encoding.
+ const QList<Recipient> inName = ComposeContextBuilder::parseAddressHeader(
+ QStringLiteral("\"foo\nBcc: evil@example.net\" <a@example.org>"));
+ QCOMPARE(inName.size(), 1);
+ QCOMPARE(inName.at(0).address, QStringLiteral("a@example.org"));
+ QVERIFY2(!inName.at(0).rendered.contains(QLatin1Char('\n'))
+ && !inName.at(0).rendered.contains(QLatin1Char('\r')),
+ qPrintable(QStringLiteral("a raw newline survived into a rendered "
+ "recipient: %1")
+ .arg(inName.at(0).rendered)));
+}
+
+// ---------------------------------------------------------------------------
+// Reply and reply-all
+// ---------------------------------------------------------------------------
+
+void TestComposeContext::aPlainReplyGoesToTheSenderOnly()
+{
+ ParsedMessage message;
+ message.from = QStringLiteral("Sender <sender@example.org>");
+ message.to = QStringLiteral("me@example.org, other@example.net");
+ message.cc = QStringLiteral("third@example.com");
+
+ QStringList to;
+ QStringList cc;
+ ComposeContextBuilder::recipientsForReply(
+ message, /*replyAll=*/false, { QStringLiteral("me@example.org") }, &to, &cc);
+
+ QCOMPARE(to.size(), 1);
+ QVERIFY2(to.at(0).contains(QStringLiteral("sender@example.org")),
+ qPrintable(QStringLiteral("To was %1").arg(to.join(QLatin1Char('|')))));
+ QVERIFY2(cc.isEmpty(),
+ qPrintable(QStringLiteral("a plain reply put %1 in Cc")
+ .arg(cc.join(QLatin1Char('|')))));
+}
+
+void TestComposeContext::aReplyPrefersReplyToOverFrom()
+{
+ // RFC 5322 3.6.2: Reply-To names where the author wants replies sent. This
+ // is what makes a list reply land on the list rather than on a person who
+ // never asked to be written to directly.
+ ParsedMessage message;
+ message.from = QStringLiteral("Sender <sender@example.org>");
+ message.replyTo = QStringLiteral("List <list@example.net>");
+
+ QStringList to;
+ QStringList cc;
+ ComposeContextBuilder::recipientsForReply(message, /*replyAll=*/false, {}, &to, &cc);
+
+ QCOMPARE(to.size(), 1);
+ QVERIFY2(to.at(0).contains(QStringLiteral("list@example.net")),
+ qPrintable(QStringLiteral("To was %1, expected the Reply-To")
+ .arg(to.join(QLatin1Char('|')))));
+ QVERIFY2(!to.at(0).contains(QStringLiteral("sender@example.org")),
+ "From was used despite a Reply-To being present");
+}
+
+void TestComposeContext::aReplyAllPutsTheSenderInToAndTheRestInCc()
+{
+ ParsedMessage message;
+ message.from = QStringLiteral("Sender <sender@example.org>");
+ message.to = QStringLiteral("first@example.net");
+ message.cc = QStringLiteral("second@example.com");
+
+ QStringList to;
+ QStringList cc;
+ ComposeContextBuilder::recipientsForReply(message, /*replyAll=*/true, {}, &to, &cc);
+
+ QCOMPARE(to.size(), 1);
+ QVERIFY(to.at(0).contains(QStringLiteral("sender@example.org")));
+
+ QCOMPARE(cc.size(), 2);
+ QVERIFY2(cc.join(QLatin1Char('|')).contains(QStringLiteral("first@example.net")),
+ qPrintable(QStringLiteral("Cc was %1").arg(cc.join(QLatin1Char('|')))));
+ QVERIFY2(cc.join(QLatin1Char('|')).contains(QStringLiteral("second@example.com")),
+ qPrintable(QStringLiteral("Cc was %1").arg(cc.join(QLatin1Char('|')))));
+}
+
+void TestComposeContext::aReplyAllStripsEveryOwnAddress()
+{
+ // Five accounts, and the user's address appears in the original's To under
+ // THREE of them. Stripping only the first is the exact failure this guards:
+ // the reply-all would then be addressed to the user twice over.
+ ParsedMessage message;
+ message.from = QStringLiteral("Sender <sender@example.org>");
+ message.to = QStringLiteral(
+ "first@example.org, stranger@example.net, third@example.org");
+ message.cc = QStringLiteral("fifth@example.org, another@example.com");
+
+ const QStringList own = { QStringLiteral("first@example.org"),
+ QStringLiteral("second@example.org"),
+ QStringLiteral("third@example.org"),
+ QStringLiteral("fourth@example.org"),
+ QStringLiteral("fifth@example.org") };
+
+ QStringList to;
+ QStringList cc;
+ ComposeContextBuilder::recipientsForReply(message, /*replyAll=*/true, own, &to, &cc);
+
+ const QString all = (to + cc).join(QLatin1Char('|'));
+ for (const QString &address : own) {
+ QVERIFY2(!all.contains(address, Qt::CaseInsensitive),
+ qPrintable(QStringLiteral("own address %1 survived in: %2")
+ .arg(address, all)));
+ }
+ // And the strangers must all still be there: a filter that removed
+ // everything would pass the check above while producing an unsendable reply.
+ QVERIFY2(all.contains(QStringLiteral("stranger@example.net")),
+ qPrintable(QStringLiteral("a stranger was stripped too: %1").arg(all)));
+ QVERIFY2(all.contains(QStringLiteral("another@example.com")),
+ qPrintable(QStringLiteral("a stranger was stripped too: %1").arg(all)));
+ QVERIFY2(all.contains(QStringLiteral("sender@example.org")),
+ qPrintable(QStringLiteral("the sender was stripped: %1").arg(all)));
+}
+
+void TestComposeContext::aReplyAllStripsAnOwnAddressRegardlessOfCase()
+{
+ // A domain is case-insensitive by RFC and real mail varies the local part's
+ // case too. A case-sensitive filter lets the user's own address through and
+ // they receive their own reply.
+ ParsedMessage message;
+ message.from = QStringLiteral("Sender <sender@example.org>");
+ message.to = QStringLiteral("Me@Example.ORG, stranger@example.net");
+
+ QStringList to;
+ QStringList cc;
+ ComposeContextBuilder::recipientsForReply(
+ message, /*replyAll=*/true, { QStringLiteral("me@example.org") }, &to, &cc);
+
+ const QString all = (to + cc).join(QLatin1Char('|'));
+ QVERIFY2(!all.contains(QStringLiteral("Me@Example.ORG"), Qt::CaseInsensitive),
+ qPrintable(QStringLiteral("a differently-cased own address survived: %1")
+ .arg(all)));
+ QVERIFY(all.contains(QStringLiteral("stranger@example.net")));
+}
+
+void TestComposeContext::aReplyAllDoesNotListTheSenderTwice()
+{
+ // The sender is very often also in their own message's To (a list posting
+ // reflected back). Without cross-field suppression they appear in To AND Cc.
+ ParsedMessage message;
+ message.from = QStringLiteral("Sender <sender@example.org>");
+ message.to = QStringLiteral("sender@example.org, stranger@example.net");
+
+ QStringList to;
+ QStringList cc;
+ ComposeContextBuilder::recipientsForReply(message, /*replyAll=*/true, {}, &to, &cc);
+
+ const QString all = (to + cc).join(QLatin1Char('|'));
+ QCOMPARE(all.count(QStringLiteral("sender@example.org")), 1);
+ QVERIFY(all.contains(QStringLiteral("stranger@example.net")));
+}
+
+void TestComposeContext::aReplyAllSuppressesDuplicatesAcrossToAndCc()
+{
+ // The same address in the original's To and Cc, with different display
+ // names so a whole-string comparison would treat them as distinct.
+ ParsedMessage message;
+ message.from = QStringLiteral("Sender <sender@example.org>");
+ message.to = QStringLiteral("Person One <dup@example.net>");
+ message.cc = QStringLiteral("P. One <dup@example.net>, other@example.com");
+
+ QStringList to;
+ QStringList cc;
+ ComposeContextBuilder::recipientsForReply(message, /*replyAll=*/true, {}, &to, &cc);
+
+ const QString all = (to + cc).join(QLatin1Char('|'));
+ QCOMPARE(all.count(QStringLiteral("dup@example.net")), 1);
+ QVERIFY(all.contains(QStringLiteral("other@example.com")));
+}
+
+void TestComposeContext::aReplyToOneselfStillAddressesSomeone()
+{
+ // Replying to a message the user sent themselves. Stripping own addresses
+ // from a plain Reply's To would leave a message with no recipient that
+ // still looks sendable.
+ ParsedMessage message;
+ message.from = QStringLiteral("Me <me@example.org>");
+ message.to = QStringLiteral("me@example.org");
+
+ QStringList to;
+ QStringList cc;
+ ComposeContextBuilder::recipientsForReply(
+ message, /*replyAll=*/false, { QStringLiteral("me@example.org") }, &to, &cc);
+
+ QVERIFY2(!to.isEmpty(), "a reply to oneself produced no recipient at all");
+ QVERIFY(to.join(QLatin1Char('|')).contains(QStringLiteral("me@example.org")));
+}
+
+void TestComposeContext::aReplyToOwnMessageGoesToItsOriginalRecipients()
+{
+ // The Sent view, and a follow-up on unanswered mail: the user replies to a
+ // message they sent. Addressing the sender there addresses the user, so To
+ // comes from the original's own recipients instead. The Cc entry is
+ // included because a reply to a conversation the user started belongs to
+ // everyone who was on it.
+ ParsedMessage message;
+ message.from = QStringLiteral("Me <me@example.org>");
+ message.to = QStringLiteral("Correspondent <them@example.net>");
+ message.cc = QStringLiteral("watcher@example.com");
+
+ QStringList to;
+ QStringList cc;
+ ComposeContextBuilder::recipientsForReply(
+ message, /*replyAll=*/false, { QStringLiteral("me@example.org") }, &to, &cc);
+
+ const QString joined = to.join(QLatin1Char('|'));
+ QVERIFY2(joined.contains(QStringLiteral("them@example.net")),
+ qPrintable(QStringLiteral("To was %1").arg(joined)));
+ QVERIFY2(joined.contains(QStringLiteral("watcher@example.com")),
+ qPrintable(QStringLiteral("To was %1").arg(joined)));
+ // The whole point: the user is not written back to themselves.
+ QVERIFY2(!joined.contains(QStringLiteral("me@example.org")),
+ qPrintable(QStringLiteral("the reply addressed the user: %1").arg(joined)));
+ QVERIFY2(cc.isEmpty(), "a plain reply produced a Cc");
+}
+
+void TestComposeContext::aReplyAllToOwnMessageDoesNotRepeatToInCc()
+{
+ // Reply-all to your own message MIRRORS the original's split: its To
+ // becomes To, its Cc becomes Cc. The split is the message's meaning, To
+ // being "addressed to you" and Cc "for information", and promoting a Cc'd
+ // party to To is visible to every recipient.
+ //
+ // Asserted per FIELD, not on the union. A test counting each address once
+ // across to + cc passes whether the split is preserved or collapsed, which
+ // is how the collapse shipped and survived its first mutation check.
+ ParsedMessage message;
+ message.from = QStringLiteral("Me <me@example.org>");
+ message.to = QStringLiteral("them@example.net");
+ message.cc = QStringLiteral("watcher@example.com");
+
+ QStringList to;
+ QStringList cc;
+ ComposeContextBuilder::recipientsForReply(
+ message, /*replyAll=*/true, { QStringLiteral("me@example.org") }, &to, &cc);
+
+ const QString toJoined = to.join(QLatin1Char('|'));
+ const QString ccJoined = cc.join(QLatin1Char('|'));
+ QVERIFY2(toJoined.contains(QStringLiteral("them@example.net")),
+ qPrintable(QStringLiteral("To was %1").arg(toJoined)));
+ QVERIFY2(!toJoined.contains(QStringLiteral("watcher@example.com")),
+ qPrintable(QStringLiteral("a Cc recipient was promoted to To: %1").arg(toJoined)));
+ QVERIFY2(ccJoined.contains(QStringLiteral("watcher@example.com")),
+ qPrintable(QStringLiteral("Cc was %1").arg(ccJoined)));
+ QVERIFY2(!ccJoined.contains(QStringLiteral("them@example.net")),
+ qPrintable(QStringLiteral("the To address repeated in Cc: %1").arg(ccJoined)));
+ // The whole point of the self-reply rule.
+ QVERIFY2(!(toJoined + ccJoined).contains(QStringLiteral("me@example.org")),
+ "the reply addressed the user");
+}
+
+void TestComposeContext::aCoSenderIsStillRepliedTo()
+{
+ // A message the user sent WITH somebody else is not a message to oneself.
+ // Only an all-own sender diverts To to the original recipients; here the
+ // co-sender is a real person expecting the reply.
+ ParsedMessage message;
+ message.from = QStringLiteral("Me <me@example.org>, Other <other@example.net>");
+ message.to = QStringLiteral("them@example.com");
+
+ QStringList to;
+ QStringList cc;
+ ComposeContextBuilder::recipientsForReply(
+ message, /*replyAll=*/false, { QStringLiteral("me@example.org") }, &to, &cc);
+
+ const QString joined = to.join(QLatin1Char('|'));
+ QVERIFY2(joined.contains(QStringLiteral("other@example.net")),
+ qPrintable(QStringLiteral("To was %1").arg(joined)));
+ QVERIFY2(!joined.contains(QStringLiteral("them@example.com")),
+ qPrintable(QStringLiteral("a plain reply reached the original's To: %1")
+ .arg(joined)));
+}
+
+void TestComposeContext::anUnparseableSenderStillProducesARecipient()
+{
+ // "From: Mailer Daemon" is a bare display name with no angle brackets, which
+ // is what bounces and some automated senders emit. It parses to ZERO
+ // mailboxes, so the sender contributes nothing and To would otherwise come
+ // out empty.
+ //
+ // An empty To is the worst outcome available here, because MessageBuilder
+ // treats an empty recipient list as success: the message reaches the send
+ // command with nobody to deliver to and a copy is filed in Sent that looks
+ // sent and reached no one. The original's own recipients are the remaining
+ // candidates.
+ ParsedMessage message;
+ message.from = QStringLiteral("Mailer Daemon");
+ message.to = QStringLiteral("them@example.net");
+ message.cc = QStringLiteral("watcher@example.com");
+
+ QStringList to;
+ QStringList cc;
+ ComposeContextBuilder::recipientsForReply(
+ message, /*replyAll=*/false, { QStringLiteral("me@example.org") }, &to, &cc);
+
+ QVERIFY2(!to.isEmpty(), "an unparseable sender produced a reply with no recipient");
+ QVERIFY2(to.join(QLatin1Char('|')).contains(QStringLiteral("them@example.net")),
+ qPrintable(QStringLiteral("To was %1").arg(to.join(QLatin1Char('|')))));
+
+ // Reply-all is the worse half: without the fallback it puts every recipient
+ // in Cc and leaves To empty, which is a message addressed to nobody.
+ QStringList allTo;
+ QStringList allCc;
+ ComposeContextBuilder::recipientsForReply(
+ message, /*replyAll=*/true, { QStringLiteral("me@example.org") }, &allTo, &allCc);
+ QVERIFY2(!allTo.isEmpty(), "a reply-all to an unparseable sender left To empty");
+}
+
+void TestComposeContext::aReplyAllPrefersReplyToForTheToField()
+{
+ // Reply-To precedence is not a plain-Reply-only rule: a list's reply-all
+ // must also go to the list rather than to the individual poster.
+ ParsedMessage message;
+ message.from = QStringLiteral("Poster <poster@example.org>");
+ message.replyTo = QStringLiteral("List <list@example.net>");
+ message.to = QStringLiteral("list@example.net");
+ message.cc = QStringLiteral("watcher@example.com");
+
+ QStringList to;
+ QStringList cc;
+ ComposeContextBuilder::recipientsForReply(message, /*replyAll=*/true, {}, &to, &cc);
+
+ QCOMPARE(to.size(), 1);
+ QVERIFY2(to.at(0).contains(QStringLiteral("list@example.net")),
+ qPrintable(QStringLiteral("To was %1").arg(to.join(QLatin1Char('|')))));
+ // The list is in To, so it must not repeat in Cc even though the original's
+ // To named it.
+ QVERIFY2(!cc.join(QLatin1Char('|')).contains(QStringLiteral("list@example.net")),
+ qPrintable(QStringLiteral("the To address repeated in Cc: %1")
+ .arg(cc.join(QLatin1Char('|')))));
+ QVERIFY(cc.join(QLatin1Char('|')).contains(QStringLiteral("watcher@example.com")));
+}
+
+void TestComposeContext::aDisplayNameContainingAnOwnAddressIsNotMistakenForIt()
+{
+ // A stranger whose DISPLAY NAME quotes the user's address. Comparing the
+ // rendered whole rather than the addr-spec would strip a real recipient,
+ // and the reply would silently not reach them.
+ ParsedMessage message;
+ message.from = QStringLiteral("Sender <sender@example.org>");
+ message.to = QStringLiteral("\"about me@example.org\" <stranger@example.net>");
+
+ QStringList to;
+ QStringList cc;
+ ComposeContextBuilder::recipientsForReply(
+ message, /*replyAll=*/true, { QStringLiteral("me@example.org") }, &to, &cc);
+
+ QVERIFY2((to + cc).join(QLatin1Char('|')).contains(QStringLiteral("stranger@example.net")),
+ "a stranger was stripped because their display name quoted an own address");
+}
+
+// ---------------------------------------------------------------------------
+// References
+// ---------------------------------------------------------------------------
+
+void TestComposeContext::referencesCarryTheOriginalChainPlusItsId()
+{
+ ParsedMessage message;
+ message.messageId = QStringLiteral("current@example.org");
+ message.references =
+ QStringLiteral("<first@example.org> <second@example.org>");
+
+ const QStringList refs = ComposeContextBuilder::referencesForReply(message);
+
+ QCOMPARE(refs.size(), 3);
+ QCOMPARE(refs.at(0), QStringLiteral("first@example.org"));
+ QCOMPARE(refs.at(1), QStringLiteral("second@example.org"));
+ QCOMPARE(refs.at(2), QStringLiteral("current@example.org"));
+}
+
+void TestComposeContext::referencesDoNotRepeatTheMessageId()
+{
+ ParsedMessage message;
+ message.messageId = QStringLiteral("current@example.org");
+ message.references = QStringLiteral("<first@example.org> <current@example.org>");
+
+ const QStringList refs = ComposeContextBuilder::referencesForReply(message);
+
+ QCOMPARE(refs.count(QStringLiteral("current@example.org")), 1);
+ QCOMPARE(refs.last(), QStringLiteral("current@example.org"));
+}
+
+// ---------------------------------------------------------------------------
+// Subjects
+// ---------------------------------------------------------------------------
+
+void TestComposeContext::aCommaSeparatedReferencesHeaderIsSplitIntoIds()
+{
+ // `<a@x>,<b@y>` is not conformant, RFC 5322 has no comma here, but some
+ // clients emit it. Splitting on whitespace alone makes that whole header ONE
+ // token, and stripping its outer brackets then yields the fabricated id
+ // `a@x>,<b@y`, which is sent to the recipient as a Message-ID reference. A
+ // comma cannot occur inside a msg-id, so accepting it as a separator is free.
+ ParsedMessage message;
+ message.references = QStringLiteral("<first@example.org>,<second@example.org>");
+ message.messageId = QStringLiteral("current@example.org");
+
+ const QStringList refs = ComposeContextBuilder::referencesForReply(message);
+
+ QCOMPARE(refs.size(), 3);
+ QCOMPARE(refs.at(0), QStringLiteral("first@example.org"));
+ QCOMPARE(refs.at(1), QStringLiteral("second@example.org"));
+ QCOMPARE(refs.at(2), QStringLiteral("current@example.org"));
+}
+
+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::aSubjectMentioningReLaterStillGetsAPrefix()
+{
+ // The prefix test is ANCHORED. An unanchored search would see "re:" inside
+ // an ordinary subject and refuse to prefix a genuine first reply, which
+ // breaks threading in the recipient's client.
+ QCOMPARE(ComposeContextBuilder::replySubject(QStringLiteral("Notes re: budget")),
+ QStringLiteral("Re: Notes re: budget"));
+ QCOMPARE(ComposeContextBuilder::forwardSubject(QStringLiteral("Notes fwd: budget")),
+ QStringLiteral("Fwd: Notes fwd: budget"));
+}
+
+void TestComposeContext::aNonEnglishPrefixIsNotDoubled()
+{
+ // A mixed-locale mailbox, which this one is. An English-only pattern turns
+ // every one of these into "Re: AW: subject", and the round after that into
+ // "Re: Re: AW:".
+ QCOMPARE(ComposeContextBuilder::replySubject(QStringLiteral("AW: Angebot")),
+ QStringLiteral("AW: Angebot"));
+ QCOMPARE(ComposeContextBuilder::replySubject(QStringLiteral("SV: innkalling")),
+ QStringLiteral("SV: innkalling"));
+ QCOMPARE(ComposeContextBuilder::replySubject(QStringLiteral("RES: pedido")),
+ QStringLiteral("RES: pedido"));
+ QCOMPARE(ComposeContextBuilder::forwardSubject(QStringLiteral("WG: Angebot")),
+ QStringLiteral("WG: Angebot"));
+ QCOMPARE(ComposeContextBuilder::forwardSubject(QStringLiteral("TR: document")),
+ QStringLiteral("TR: document"));
+}
+
+void TestComposeContext::aCountedPrefixIsNotDoubled()
+{
+ // Outlook and some list managers count the rounds. Same meaning, and
+ // prefixing again produces "Re: Re[2]:".
+ QCOMPARE(ComposeContextBuilder::replySubject(QStringLiteral("Re[2]: thread")),
+ QStringLiteral("Re[2]: thread"));
+ QCOMPARE(ComposeContextBuilder::replySubject(QStringLiteral("Re(3): thread")),
+ QStringLiteral("Re(3): thread"));
+}
+
+void TestComposeContext::aSingleLetterBeforeAColonIsNotAPrefix()
+{
+ // Italian clients do send "R:" and "I:", and they are deliberately NOT
+ // recognised. Measured 2026-08-21: with them in the pattern, "R: report on
+ // Q3" reads as an existing prefix, so a genuine FIRST reply gets no "Re:"
+ // and threads nowhere in the recipient's client, with nothing wrong to see
+ // locally. A doubled "Re: R:" is cosmetic; broken threading is not.
+ //
+ // "F:" is here for the same reason: the pattern was once `fwd?`, which
+ // matched it.
+ QCOMPARE(ComposeContextBuilder::replySubject(QStringLiteral("R: report on Q3")),
+ QStringLiteral("Re: R: report on Q3"));
+ QCOMPARE(ComposeContextBuilder::forwardSubject(QStringLiteral("I: notes")),
+ QStringLiteral("Fwd: I: notes"));
+ QCOMPARE(ComposeContextBuilder::forwardSubject(QStringLiteral("F: results")),
+ QStringLiteral("Fwd: F: results"));
+}
+
+// ---------------------------------------------------------------------------
+// Account resolution
+// ---------------------------------------------------------------------------
+
+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;
+ config.load(path);
+ QCOMPARE(config.accounts().size(), 2);
+
+ const QString account = ComposeContextBuilder::accountForReply(
+ config, { QStringLiteral("/mail/home/INBOX/cur/123") },
+ { QStringLiteral("home@example.org") }, QStringLiteral("/mail"));
+
+ QCOMPARE(account, QStringLiteral("home"));
+}
+
+void TestComposeContext::anAccountIsNotMatchedByAPrefixOfItsMaildir()
+{
+ // "work" must not claim a message living in "work-archive". Without the
+ // separator in the comparison it does, and the reply is sent from the
+ // wrong account.
+ //
+ // The account KEYS are chosen so the wrong answer is reached FIRST.
+ // Config builds its list from QSettings::childGroups(), which returns
+ // groups ALPHABETICALLY rather than in file order, so the section order
+ // here decides nothing and only the keys do. With "archive" before "work"
+ // the loop happens upon the correct account before it can mismatch, and
+ // the test passes against the bug: measured, a mutation dropping the
+ // separator left the suite fully green. "a-work" (maildir "work") sorts
+ // before "b-archive" (maildir "work-archive") and puts the prefix
+ // candidate first, where a textual comparison matches it.
+ const QString path = writeConfig(QStringLiteral(
+ "[account.a-work]\nmaildir=work\ntrash=Trash\naddress=work@example.org\n"
+ "send_command=/bin/true\n"
+ "[account.b-archive]\nmaildir=work-archive\ntrash=Trash\n"
+ "address=archive@example.org\nsend_command=/bin/true\n"));
+ Config config;
+ config.load(path);
+ QCOMPARE(config.accounts().size(), 2);
+ // The ordering the mutation depends on, asserted rather than assumed: if
+ // Config ever sorts differently this test silently stops testing anything.
+ QCOMPARE(config.accounts().at(0).key, QStringLiteral("a-work"));
+
+ const QString account = ComposeContextBuilder::accountForReply(
+ config, { QStringLiteral("/mail/work-archive/INBOX/cur/1") },
+ {}, QStringLiteral("/mail"));
+
+ QCOMPARE(account, QStringLiteral("b-archive"));
+}
+
+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;
+ 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"));
+ Config config;
+ 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;
+ config.load(path);
+ QCOMPARE(config.accounts().size(), 2);
+
+ 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;
+ config.load(path);
+ QCOMPARE(config.accounts().size(), 2);
+
+ 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.
+ //
+ // The named account must NOT also be what rule 4 would answer, or the test
+ // passes with rule 2 deleted outright: measured, a mutation removing it
+ // left the suite green because the account list is ALPHABETICAL (Config
+ // builds it from QSettings::childGroups()) and the section order in this
+ // string decides nothing. "zeta" sorts last, so rule 4 would answer
+ // "alpha" and only rule 2 can produce "zeta".
+ const QString path = writeConfig(QStringLiteral(
+ "[account.alpha]\nmaildir=alpha\ntrash=Trash\nsend_command=/bin/true\n"
+ "[account.zeta]\nmaildir=zeta\ntrash=Trash\nsend_command=/bin/true\n"
+ "[compose]\ndefault_account=zeta\n"));
+ Config config;
+ config.load(path);
+ QCOMPARE(config.compose().defaultAccount, QStringLiteral("zeta"));
+ // Asserted rather than assumed, so the test stops silently proving nothing
+ // if Config ever changes its ordering.
+ QCOMPARE(config.sendingAccounts().first().key, QStringLiteral("alpha"));
+
+ QCOMPARE(ComposeContextBuilder::accountForNew(config, QString()),
+ QStringLiteral("zeta"));
+}
+
+void TestComposeContext::aNewMessageUsesStartupAccountWhenNoDefaultIsSet()
+{
+ // Rule 3. Same ordering trap as rule 2: "zeta" must not be what rule 4
+ // would answer, or a test for this rule passes with the rule deleted.
+ const QString path = writeConfig(QStringLiteral(
+ "[general]\nstartup_account=zeta\n"
+ "[account.alpha]\nmaildir=alpha\ntrash=Trash\nsend_command=/bin/true\n"
+ "[account.zeta]\nmaildir=zeta\ntrash=Trash\nsend_command=/bin/true\n"));
+ Config config;
+ config.load(path);
+ QCOMPARE(config.startupAccount(), QStringLiteral("zeta"));
+ QVERIFY(config.compose().defaultAccount.isEmpty());
+ QCOMPARE(config.sendingAccounts().first().key, QStringLiteral("alpha"));
+
+ QCOMPARE(ComposeContextBuilder::accountForNew(config, QString()),
+ QStringLiteral("zeta"));
+}
+
+void TestComposeContext::aNewMessageFallsBackToTheFirstSendingAccount()
+{
+ // Rule 4, arbitrary, and the reason rules 2 and 3 exist. The receive-only
+ // account is FIRST, so "the first account" and "the first sending account"
+ // are different answers and the test distinguishes them.
+ 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;
+ config.load(path);
+ QCOMPARE(config.accounts().size(), 2);
+
+ 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;
+ config.load(path);
+ QCOMPARE(config.accounts().size(), 1);
+
+ QVERIFY(ComposeContextBuilder::accountForNew(config, QString()).isEmpty());
+}
+
+// ---------------------------------------------------------------------------
+// Quoting
+// ---------------------------------------------------------------------------
+
+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");
+ // A CRLF body must not leave a stray carriage return before every marker.
+ ParsedMessage crlf;
+ crlf.plainBody = QStringLiteral("one\r\ntwo");
+ const QString quotedCrlf = ComposeContextBuilder::quoteBody(crlf);
+ QVERIFY2(!quotedCrlf.contains(QLatin1Char('\r')),
+ qPrintable(QStringLiteral("a carriage return survived quoting: %1")
+ .arg(quotedCrlf)));
+}
+
+QTEST_MAIN(TestComposeContext)
+#include "test_composecontext.moc"
diff --git a/tests/test_config.cpp b/tests/test_config.cpp
index ea5c363..a902425 100644
--- a/tests/test_config.cpp
+++ b/tests/test_config.cpp
@@ -121,6 +121,16 @@ private slots:
void anAccountWithoutATrashFolderWarns();
void theTrashFilterComposesPerAccount();
void theTrashFilterMatchesNothingWithoutAFolder();
+ void anAccountWithoutASendCommandIsReceiveOnly();
+ void composeSettingsDefaultWhenTheSectionIsAbsent();
+ void aZeroSendDelayIsHonouredRatherThanTreatedAsUnset();
+ void aDefaultAccountThatCannotSendIsWarnedAbout();
+ void anInstallationWhereNoAccountCanSendIsNotWarnedAbout();
+ void garbageAutosaveIntervalIsRejectedNotZero();
+ void garbageSendDelayIsRejectedNotZero();
+ void garbageAttachmentWarnBytesIsRejectedNotZero();
+ void zeroOrNegativeAutosaveIntervalIsClamped();
+ void unrecognisedQuotePositionWarnsAndFallsBackToAbove();
};
static QString writeIni(const QTemporaryDir &dir, const QString &body)
@@ -2313,5 +2323,191 @@ void TestConfig::aGeneratedEntryWritesNoRedundantKeys()
"flat must come back from the generator, not from the file");
}
+void TestConfig::anAccountWithoutASendCommandIsReceiveOnly()
+{
+ // The capability IS the command's presence, and nothing else expresses
+ // it: not a receive_only flag, not an empty-string special case.
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral(
+ "[account.work]\n"
+ "maildir=work\n"
+ "trash=Trash\n"
+ "send_command=msmtp -a work -t\n"
+ "\n"
+ "[account.listsonly]\n"
+ "maildir=listsonly\n"
+ "trash=Trash\n")));
+
+ const Account work = config.account(QStringLiteral("work"));
+ const Account listsonly = config.account(QStringLiteral("listsonly"));
+ QVERIFY2(work.canSend(), "an account with send_command must be able to send");
+ QVERIFY2(!listsonly.canSend(),
+ "an account with no send_command must not report it can send");
+
+ const QList<Account> sending = config.sendingAccounts();
+ QCOMPARE(sending.size(), 1);
+ QCOMPARE(sending.first().key, QStringLiteral("work"));
+}
+
+void TestConfig::composeSettingsDefaultWhenTheSectionIsAbsent()
+{
+ // A config that has never heard of this feature must produce working
+ // defaults rather than zeros.
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral("[general]\n")));
+
+ const ComposeSettings compose = config.compose();
+ QVERIFY2(compose.quotePosition == ComposeSettings::QuotePosition::Above,
+ "default quote position must be Above");
+ QVERIFY2(compose.sendHtml, "default send_html must be true");
+ QCOMPARE(compose.autosaveIntervalMs, 30000);
+ QCOMPARE(compose.sendDelayMs, 5000);
+ QCOMPARE(compose.attachmentWarnBytes, qint64(26214400));
+ QVERIFY(compose.defaultAccount.isEmpty());
+}
+
+void TestConfig::aZeroSendDelayIsHonouredRatherThanTreatedAsUnset()
+{
+ // Zero 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.
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral(
+ "[compose]\n"
+ "send_delay_ms=0\n")));
+
+ QCOMPARE(config.compose().sendDelayMs, 0);
+ QVERIFY2(config.compose().sendDelayMs != 5000,
+ "zero send_delay_ms was replaced by the default");
+}
+
+void TestConfig::aDefaultAccountThatCannotSendIsWarnedAbout()
+{
+ // Follows the pattern that already warns about an unresolvable
+ // startup_query: the setting is not silently corrected because a user
+ // who named an account expects mail to come from it.
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral(
+ "[compose]\n"
+ "default_account=listsonly\n"
+ "\n"
+ "[account.listsonly]\n"
+ "maildir=listsonly\n"
+ "trash=Trash\n")));
+
+ const QString joined = config.warnings().join(QLatin1Char('\n'));
+ QVERIFY2(joined.contains(QStringLiteral("listsonly")),
+ qPrintable(QStringLiteral("no warning named listsonly: %1").arg(joined)));
+}
+
+void TestConfig::anInstallationWhereNoAccountCanSendIsNotWarnedAbout()
+{
+ // A read-only installation is VALID; warning about it would train the
+ // user to ignore warnings.
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral(
+ "[account.work]\n"
+ "maildir=work\n"
+ "trash=Trash\n")));
+
+ QVERIFY(config.sendingAccounts().isEmpty());
+ for (const QString &warning : config.warnings()) {
+ QVERIFY2(!warning.contains(QStringLiteral("send"), Qt::CaseInsensitive),
+ qPrintable(QStringLiteral("unexpected sending-related warning: %1")
+ .arg(warning)));
+ }
+}
+
+void TestConfig::garbageAutosaveIntervalIsRejectedNotZero()
+{
+ // toInt() alone returns 0 on a parse failure, not the default, and 0
+ // reaches a QTimer restarted on every keystroke: a typo here would have
+ // turned the debounce into a write per keystroke, uploaded by mbsync.
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral(
+ "[compose]\n"
+ "autosave_interval_ms=oops\n")));
+
+ QCOMPARE(config.compose().autosaveIntervalMs, 30000);
+ QVERIFY2(!config.problems().isEmpty(),
+ "a garbage autosave_interval_ms was accepted silently");
+}
+
+void TestConfig::garbageSendDelayIsRejectedNotZero()
+{
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral(
+ "[compose]\n"
+ "send_delay_ms=soon\n")));
+
+ QCOMPARE(config.compose().sendDelayMs, 5000);
+ QVERIFY2(!config.problems().isEmpty(),
+ "a garbage send_delay_ms was accepted silently");
+}
+
+void TestConfig::garbageAttachmentWarnBytesIsRejectedNotZero()
+{
+ // Verified against the actual defect: attachment_warn_bytes=banana gave 0
+ // via a bare toLongLong(), which would have warned about every attachment
+ // no matter how small.
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral(
+ "[compose]\n"
+ "attachment_warn_bytes=banana\n")));
+
+ QCOMPARE(config.compose().attachmentWarnBytes, qint64(26214400));
+ QVERIFY2(!config.problems().isEmpty(),
+ "a garbage attachment_warn_bytes was accepted silently");
+}
+
+void TestConfig::zeroOrNegativeAutosaveIntervalIsClamped()
+{
+ // Independent of the parse fix: a value that parses fine but is zero or
+ // negative must still not reach setInterval(), since nothing assigns a
+ // meaning to one, unlike mark_read_delay_ms's documented negative-means-off.
+ QTemporaryDir dir;
+ Config zero;
+ zero.load(writeIni(dir, QStringLiteral(
+ "[compose]\n"
+ "autosave_interval_ms=0\n")));
+ QVERIFY2(zero.compose().autosaveIntervalMs >= 1000,
+ qPrintable(QStringLiteral("zero autosave interval was not clamped: %1")
+ .arg(zero.compose().autosaveIntervalMs)));
+
+ QTemporaryDir dir2;
+ Config negative;
+ negative.load(writeIni(dir2, QStringLiteral(
+ "[compose]\n"
+ "autosave_interval_ms=-500\n")));
+ QVERIFY2(negative.compose().autosaveIntervalMs >= 1000,
+ qPrintable(QStringLiteral("negative autosave interval was not clamped: %1")
+ .arg(negative.compose().autosaveIntervalMs)));
+}
+
+void TestConfig::unrecognisedQuotePositionWarnsAndFallsBackToAbove()
+{
+ // Matches the precedent set by sync_on_exit, language and date_format:
+ // the only silent fallbacks in this file are for ABSENT keys, never for
+ // malformed ones.
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral(
+ "[compose]\n"
+ "quote_position=abov\n")));
+
+ QVERIFY2(config.compose().quotePosition == ComposeSettings::QuotePosition::Above,
+ "an unrecognised quote_position must still fall back to Above");
+ QVERIFY2(!config.problems().isEmpty(),
+ "an unrecognised quote_position was accepted silently");
+}
+
QTEST_MAIN(TestConfig)
#include "test_config.moc"
diff --git a/tests/test_draftstore.cpp b/tests/test_draftstore.cpp
new file mode 100644
index 0000000..5818261
--- /dev/null
+++ b/tests/test_draftstore.cpp
@@ -0,0 +1,255 @@
+/*
+ * 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 <csignal>
+#include <sys/resource.h>
+
+#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 aFailedWriteLeavesNoFileBehind();
+ void anEmptyFolderPathReportsRatherThanWriting();
+};
+
+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.
+ //
+ // The failure has to happen at the WRITE, not before it. A destination
+ // whose mkpath() fails returns too early to reach either ordering, so a
+ // mutation moving the unlink ahead of the write still passes: measured,
+ // "11 passed, 0 failed" with the unlink moved above the QSaveFile. The
+ // seam is a cur/ that exists and is read-only, which mkpath() reports as
+ // success (it is already there) and QSaveFile then refuses with
+ // "Permission denied".
+ QTemporaryDir good;
+ const DraftStore::Result first = DraftStore::write(
+ good.path(), QByteArray("precious"), QStringLiteral("D"));
+ QVERIFY(first.ok());
+
+ QTemporaryDir hostile;
+ const QString cur = hostile.path() + QStringLiteral("/cur");
+ QVERIFY(QDir().mkpath(cur));
+ QVERIFY(QFile::setPermissions(cur, QFile::ReadOwner | QFile::ExeOwner));
+
+ const DraftStore::Result failed = DraftStore::write(
+ hostile.path(), QByteArray("replacement"), QStringLiteral("D"),
+ first.path);
+
+ // Restored before any assertion, so a failing assertion does not leave a
+ // directory QTemporaryDir cannot clean up.
+ QFile::setPermissions(cur, QFile::ReadOwner | QFile::WriteOwner
+ | QFile::ExeOwner);
+
+ QVERIFY2(!failed.ok(), "a write into an unwritable cur/ 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);
+}
+
+void TestDraftStore::aFailedWriteLeavesNoFileBehind()
+{
+ // A Maildir reader scans cur/ and indexes whatever it finds, so a write
+ // that fails PART WAY THROUGH must leave nothing, not a truncated
+ // message. A truncated message is the worse outcome by far: it is a
+ // plausible file that notmuch indexes and mbsync uploads.
+ //
+ // The failure has to land after a successful open() or it proves nothing
+ // about the QSaveFile choice: an unwritable directory refuses a plain
+ // QFile at open() too, and both then leave the directory empty. Measured
+ // that way, a mutation swapping QSaveFile for QFile passed.
+ //
+ // RLIMIT_FSIZE opens the real case. With the limit below the payload the
+ // open succeeds and write() returns short: measured, a plain QFile leaves
+ // a 4096-byte file in the listing, while the store leaves nothing.
+ //
+ // What produces that nothing is the ORDER of the condition, not the
+ // choice of QSaveFile, and getting this backwards is the dangerous
+ // reading. commit() is NOT the protection: called after a short write it
+ // returns true and renames the truncated bytes into place, measured as
+ // "write 4096 of 65536, commit true" with the directory then holding that
+ // file. The store never reaches it, because comparing write()'s return
+ // against the payload size short-circuits the `||` first and returns; the
+ // scratch file is then discarded by ~QSaveFile() having never been
+ // committed, and the listing is empty.
+ //
+ // So the size comparison must stay AHEAD of commit() in that condition.
+ // Reducing `write(bytes) != bytes.size() || !file.commit()` to
+ // `!file.commit()` looks like a simplification and writes a truncated
+ // draft into cur/, where notmuch indexes it and mbsync uploads it.
+ //
+ // The signal must be ignored before the limit is set, or the process is
+ // killed by SIGXFSZ rather than seeing a short write.
+ QTemporaryDir dir;
+
+ struct rlimit previous;
+ QVERIFY(getrlimit(RLIMIT_FSIZE, &previous) == 0);
+ void (*previousHandler)(int) = signal(SIGXFSZ, SIG_IGN);
+
+ struct rlimit limited;
+ limited.rlim_cur = 4096;
+ limited.rlim_max = previous.rlim_max;
+ QVERIFY(setrlimit(RLIMIT_FSIZE, &limited) == 0);
+
+ const DraftStore::Result result = DraftStore::write(
+ dir.path(), QByteArray(64 * 1024, 'x'), QStringLiteral("D"));
+
+ // Restored before any assertion, so a failing one does not leave the rest
+ // of the suite unable to write a file.
+ setrlimit(RLIMIT_FSIZE, &previous);
+ signal(SIGXFSZ, previousHandler);
+
+ QVERIFY2(!result.ok(), "a truncated write reported success");
+ QVERIFY(result.path.isEmpty() || !QFile::exists(result.path));
+
+ const QStringList entries =
+ QDir(dir.path() + QStringLiteral("/cur")).entryList(QDir::Files);
+ QVERIFY2(entries.isEmpty(),
+ qPrintable(QStringLiteral("a truncated write left a message "
+ "behind for notmuch to index: %1")
+ .arg(entries.join(QLatin1Char(' ')))));
+}
+
+void TestDraftStore::anEmptyFolderPathReportsRatherThanWriting()
+{
+ // An account with no drafts folder configured reaches here with an empty
+ // string. Without the guard the destination becomes "/cur", an absolute
+ // path at the root of the filesystem, and the only thing stopping the
+ // write is that this process does not run as root. That is not a
+ // safeguard, so the guard is asserted on its own MESSAGE rather than on
+ // the failure: a refusal naming the missing configuration is a different
+ // outcome from a permission error, and only the first survives being run
+ // by a privileged user.
+ const DraftStore::Result result =
+ DraftStore::write(QString(), QByteArray("body"), QStringLiteral("D"));
+
+ QVERIFY2(!result.ok(), "an empty folder path reported success");
+ QVERIFY(result.path.isEmpty());
+ QVERIFY2(!result.error.contains(QStringLiteral("/cur")),
+ qPrintable(QStringLiteral(
+ "the empty path reached the filesystem instead of being "
+ "refused: %1").arg(result.error)));
+ QVERIFY(!result.error.isEmpty());
+}
+
+QTEST_MAIN(TestDraftStore)
+#include "test_draftstore.moc"
diff --git a/tests/test_formattoolbar.cpp b/tests/test_formattoolbar.cpp
new file mode 100644
index 0000000..36918f9
--- /dev/null
+++ b/tests/test_formattoolbar.cpp
@@ -0,0 +1,346 @@
+/*
+ * 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 "formattoolbar.h"
+
+class TestFormatToolbar : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void wrappingASelectionKeepsItSelected();
+ void wrappingWithNoSelectionPutsTheCursorBetweenTheTokens();
+ void wrappingAppliesTheTokenOnBothSides();
+ void aBackwardsSelectionWrapsTheSameWordsAsAForwardOne();
+
+ void wrappingTwiceNestsTheTokensAroundTheSameWords();
+
+ void aLinkWithASelectionUsesItAsTheLabel();
+ void aLinkWithNoSelectionLeavesTheCursorInTheLabel();
+ void aBackwardsSelectionLinksTheSameWordsAsAForwardOne();
+ void quotingPrefixesEveryLineTheSelectionTouches();
+ void quotingAPartialLineStillQuotesTheWholeLine();
+ void quotingASingleLineWithNoSelectionQuotesThatLine();
+ void quotingWithTheCursorAtTheEndOfALineQuotesThatLineNotTheNext();
+ void quotingSelectsTheQuotedLines();
+ void quotingSelectsOnlyTheLineItQuoted();
+ void quotingTheLastLineKeepsTheRestOfTheText();
+ void quotingAnAlreadyQuotedLineNestsIt();
+ void quotingAnEmptyLineLeavesTheMarkerWithoutTrailingSpace();
+ void aSelectionPastTheEndIsClamped();
+ void aSelectionSplittingASurrogatePairKeepsTheCharacterWhole();
+};
+
+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::aBackwardsSelectionWrapsTheSameWordsAsAForwardOne()
+{
+ // A drag from right to left reports the anchor after the cursor. Qt hands
+ // that over as-is, so a transformation that trusts the order inserts the
+ // closing token before the opening one and corrupts the buffer.
+ const MarkdownFormat::Edit edit = MarkdownFormat::wrap(
+ QStringLiteral("make this bold"), 9, 5, QStringLiteral("**"));
+
+ QCOMPARE(edit.text, QStringLiteral("make **this** bold"));
+ QCOMPARE(edit.text.mid(edit.selectionStart,
+ edit.selectionEnd - edit.selectionStart),
+ QStringLiteral("this"));
+}
+
+void TestFormatToolbar::wrappingTwiceNestsTheTokensAroundTheSameWords()
+{
+ // The reason the selection is preserved at all: bold, then italic,
+ // without touching the mouse. Asserting on the second result is what
+ // makes the preserved selection load-bearing rather than decorative,
+ // since a wrong selection here produces valid-looking but wrong markdown
+ // ("make ***this** bold*" or similar).
+ //
+ // Stacking rather than toggling is the spec's behaviour, not an
+ // omission: a second Bold press gives "****this****". A toggle is wanted
+ // eventually and would make THIS gesture unreachable, which is the
+ // unanswered design question recorded as backlog item 135.
+ const MarkdownFormat::Edit first = MarkdownFormat::wrap(
+ QStringLiteral("make this bold"), 5, 9, QStringLiteral("**"));
+ const MarkdownFormat::Edit second = MarkdownFormat::wrap(
+ first.text, first.selectionStart, first.selectionEnd,
+ QStringLiteral("*"));
+
+ QCOMPARE(second.text, QStringLiteral("make ***this*** bold"));
+ QCOMPARE(second.text.mid(second.selectionStart,
+ second.selectionEnd - second.selectionStart),
+ QStringLiteral("this"));
+}
+
+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("[]()"));
+ QCOMPARE(edit.selectionStart, edit.selectionEnd);
+ QString typed = edit.text;
+ typed.insert(edit.selectionStart, QStringLiteral("label"));
+ QCOMPARE(typed, QStringLiteral("[label]()"));
+}
+
+void TestFormatToolbar::aBackwardsSelectionLinksTheSameWordsAsAForwardOne()
+{
+ const MarkdownFormat::Edit edit = MarkdownFormat::link(
+ QStringLiteral("see the docs"), 12, 8);
+
+ QCOMPARE(edit.text, QStringLiteral("see the [docs]()"));
+ QString typed = edit.text;
+ typed.insert(edit.selectionStart, QStringLiteral("https://example.org"));
+ QCOMPARE(typed, QStringLiteral("see the [docs](https://example.org)"));
+}
+
+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"));
+}
+
+void TestFormatToolbar::quotingWithTheCursorAtTheEndOfALineQuotesThatLineNotTheNext()
+{
+ // Position 3 is the end of "one", immediately BEFORE the newline, so the
+ // cursor is on the first line. Searching backwards from the cursor itself
+ // rather than from one before it finds that newline and quotes the SECOND
+ // line, which is the line the user is not on. The off-by-one is invisible
+ // in every other case because no newline sits at the search position.
+ const MarkdownFormat::Edit edit = MarkdownFormat::quote(
+ QStringLiteral("one\ntwo"), 3, 3);
+
+ QCOMPARE(edit.text, QStringLiteral("> one\ntwo"));
+}
+
+void TestFormatToolbar::quotingSelectsTheQuotedLines()
+{
+ // The quoted block stays selected, so pressing Quote again nests it and
+ // a following transformation applies to the same lines.
+ const MarkdownFormat::Edit edit = MarkdownFormat::quote(
+ QStringLiteral("one\ntwo\nthree"), 1, 5);
+
+ QCOMPARE(edit.text.mid(edit.selectionStart,
+ edit.selectionEnd - edit.selectionStart),
+ QStringLiteral("> one\n> two"));
+}
+
+void TestFormatToolbar::quotingTheLastLineKeepsTheRestOfTheText()
+{
+ // No trailing newline after the last line, so the end-of-text search
+ // returns -1 and an unguarded implementation truncates everything from
+ // the selection onwards.
+ const MarkdownFormat::Edit edit = MarkdownFormat::quote(
+ QStringLiteral("one\ntwo\nthree"), 9, 9);
+
+ QCOMPARE(edit.text, QStringLiteral("one\ntwo\n> three"));
+}
+
+void TestFormatToolbar::quotingSelectsOnlyTheLineItQuoted()
+{
+ // The line quoted here is the SECOND one, so a selection that wrongly
+ // starts at 0 is distinguishable from a correct one. The existing
+ // quotingSelectsTheQuotedLines fixture starts on the first line, where a
+ // hardcoded 0 and the right answer coincide: that coincidence let a
+ // mutation replacing firstLineStart with 0 pass the whole suite.
+ //
+ // The damage is not cosmetic. With the wrong selection a second Quote
+ // press quotes a line the user never selected, and a following Bold
+ // bolds the wrong text.
+ const MarkdownFormat::Edit edit = MarkdownFormat::quote(
+ QStringLiteral("one\ntwo\nthree"), 5, 5);
+
+ QCOMPARE(edit.text, QStringLiteral("one\n> two\nthree"));
+ QCOMPARE(edit.text.mid(edit.selectionStart,
+ edit.selectionEnd - edit.selectionStart),
+ QStringLiteral("> two"));
+}
+
+void TestFormatToolbar::quotingAnAlreadyQuotedLineNestsIt()
+{
+ // Nests rather than toggling, per the spec, which states there is
+ // deliberately no live toggle that inserts and removes the quote while
+ // editing. A second press deepens the quote. Backlog item 135 holds the
+ // toggle design if that is ever revisited.
+ const MarkdownFormat::Edit edit = MarkdownFormat::quote(
+ QStringLiteral("> one"), 0, 5);
+
+ QCOMPARE(edit.text, QStringLiteral("> > one"));
+}
+
+void TestFormatToolbar::quotingAnEmptyLineLeavesTheMarkerWithoutTrailingSpace()
+{
+ // A blank line inside a quoted block is what continues the block in
+ // markdown, so it gets the marker. "> " with nothing after it is trailing
+ // whitespace that several editors and mail clients strip, which would
+ // break the block; the marker is written bare.
+ const MarkdownFormat::Edit edit = MarkdownFormat::quote(
+ QStringLiteral("one\n\ntwo"), 0, 8);
+
+ QCOMPARE(edit.text, QStringLiteral("> one\n>\n> two"));
+}
+
+void TestFormatToolbar::aSelectionPastTheEndIsClamped()
+{
+ // A stale selection outliving an edit to the buffer would otherwise index
+ // past the end. QString tolerates that in some calls and not in others,
+ // so it is clamped once at the entry rather than relied on per call.
+ QCOMPARE(MarkdownFormat::wrap(QStringLiteral("ab"), 0, 99,
+ QStringLiteral("**")).text,
+ QStringLiteral("**ab**"));
+ QCOMPARE(MarkdownFormat::link(QStringLiteral("ab"), -5, 99).text,
+ QStringLiteral("[ab]()"));
+ QCOMPARE(MarkdownFormat::quote(QStringLiteral("ab"), -5, 99).text,
+ QStringLiteral("> ab"));
+}
+
+void TestFormatToolbar::aSelectionSplittingASurrogatePairKeepsTheCharacterWhole()
+{
+ // An emoji is two UTF-16 code units, so a boundary at 4 lands BETWEEN
+ // them. Inserting there splits the character: the result is invalid
+ // UTF-16 and the emoji is destroyed, not merely moved.
+ //
+ // Not reachable by arrow key or mouse, which both move in whole clusters,
+ // but QTextCursor::setPosition accepts it, so anything computing a
+ // position arithmetically gets there: a draft restore, a find/replace, a
+ // template insertion.
+ const QString emoji = QString::fromUcs4(U"\U0001F600");
+ const QString text = QStringLiteral("hi ") + emoji + QStringLiteral(" there");
+ QCOMPARE(text.size(), 11);
+ QVERIFY(text.at(3).isHighSurrogate());
+ QVERIFY(text.at(4).isLowSurrogate());
+
+ // Boundary inside the pair on the closing side.
+ const MarkdownFormat::Edit a =
+ MarkdownFormat::wrap(text, 3, 4, QStringLiteral("**"));
+ QVERIFY2(a.text.isValidUtf16(), "wrap split the surrogate pair");
+ QVERIFY2(a.text.contains(emoji), "wrap destroyed the character");
+
+ // Boundary inside the pair on the opening side.
+ const MarkdownFormat::Edit b =
+ MarkdownFormat::wrap(text, 4, 5, QStringLiteral("**"));
+ QVERIFY2(b.text.isValidUtf16(), "wrap split the surrogate pair");
+ QVERIFY2(b.text.contains(emoji), "wrap destroyed the character");
+
+ const MarkdownFormat::Edit c = MarkdownFormat::link(text, 3, 4);
+ QVERIFY2(c.text.isValidUtf16(), "link split the surrogate pair");
+ QVERIFY2(c.text.contains(emoji), "link destroyed the character");
+
+ // A COLLAPSED cursor inside the pair must stay collapsed. Nudging its two
+ // ends in opposite directions would keep the character whole while
+ // turning "insert an empty pair here" into "wrap the emoji", which is a
+ // character the user never selected.
+ const MarkdownFormat::Edit e =
+ MarkdownFormat::wrap(text, 4, 4, QStringLiteral("**"));
+ QVERIFY2(e.text.isValidUtf16(), "wrap split the surrogate pair");
+ QCOMPARE(e.text, QStringLiteral("hi ****") + emoji + QStringLiteral(" there"));
+ QCOMPARE(e.selectionStart, e.selectionEnd);
+ QString typedInto = e.text;
+ typedInto.insert(e.selectionStart, QStringLiteral("x"));
+ QCOMPARE(typedInto,
+ QStringLiteral("hi **x**") + emoji + QStringLiteral(" there"));
+
+ // quote() snaps to line boundaries, so it is immune by construction.
+ // Asserted rather than assumed, so a later change to how it widens the
+ // selection cannot quietly lose that.
+ const MarkdownFormat::Edit d = MarkdownFormat::quote(text, 3, 4);
+ QVERIFY2(d.text.isValidUtf16(), "quote split the surrogate pair");
+ QCOMPARE(d.text, QStringLiteral("> ") + text);
+}
+
+QTEST_APPLESS_MAIN(TestFormatToolbar)
+#include "test_formattoolbar.moc"
diff --git a/tests/test_maildirname.cpp b/tests/test_maildirname.cpp
new file mode 100644
index 0000000..dcc8fab
--- /dev/null
+++ b/tests/test_maildirname.cpp
@@ -0,0 +1,93 @@
+/*
+ * 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 "maildirname.h"
+
+#include <QSet>
+#include <QTest>
+
+class TestMaildirName : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void aFreshNameIsUniquePerCall();
+ void theFlagSuffixIsPreserved();
+ void anEmptyFlagSuffixIsPreserved();
+ void aNameWithNoSuffixGetsNone();
+ void theUidInfixIsNotCarriedAcross();
+};
+
+// Two messages written in the same second must not collide, which a
+// timestamp alone does not guarantee, and that is what the counter is for.
+void TestMaildirName::aFreshNameIsUniquePerCall()
+{
+ QSet<QString> names;
+ for (int i = 0; i < 100; ++i)
+ names.insert(MaildirName::fresh(QStringLiteral("1234.M1P1Q1.host")));
+
+ QVERIFY2(names.size() == 100,
+ qPrintable(QStringLiteral("expected 100 unique names, got %1")
+ .arg(names.size())));
+}
+
+// The flags say whether a message is read, flagged or draft, and losing them
+// on a move silently marks mail unread again.
+void TestMaildirName::theFlagSuffixIsPreserved()
+{
+ const QString name = MaildirName::fresh(QStringLiteral("1234.M1P1Q1.host:2,FS"));
+ QVERIFY2(name.endsWith(QStringLiteral(":2,FS")),
+ qPrintable(QStringLiteral("generated name did not preserve flags: %1")
+ .arg(name)));
+}
+
+// `:2,` with no flags is not the same as no suffix at all, it says the flags
+// are known and empty.
+void TestMaildirName::anEmptyFlagSuffixIsPreserved()
+{
+ const QString name = MaildirName::fresh(QStringLiteral("1234.M1P1Q1.host:2,"));
+ QVERIFY2(name.endsWith(QStringLiteral(":2,")),
+ qPrintable(QStringLiteral("generated name did not preserve empty flag suffix: %1")
+ .arg(name)));
+}
+
+// A suffix must not be invented.
+void TestMaildirName::aNameWithNoSuffixGetsNone()
+{
+ const QString name = MaildirName::fresh(QStringLiteral("1234.M1P1Q1.host"));
+ QVERIFY2(!name.contains(QStringLiteral(":2,")),
+ qPrintable(QStringLiteral("generated name invented a flag suffix: %1")
+ .arg(name)));
+}
+
+// This is the reason the function exists; carrying mbsync's `,U=` infix
+// across a folder boundary produced "Maildir error: duplicate UID" on real
+// mail.
+void TestMaildirName::theUidInfixIsNotCarriedAcross()
+{
+ const QString name = MaildirName::fresh(QStringLiteral("1234.M1P1Q1.host,U=42:2,S"));
+ QVERIFY2(!name.contains(QStringLiteral("U=42")),
+ qPrintable(QStringLiteral("generated name carried the UID infix across: %1")
+ .arg(name)));
+ QVERIFY2(name.endsWith(QStringLiteral(":2,S")),
+ qPrintable(QStringLiteral("generated name did not preserve flags: %1")
+ .arg(name)));
+}
+
+QTEST_MAIN(TestMaildirName)
+#include "test_maildirname.moc"
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index 4d70a29..98dae12 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -48,8 +48,16 @@
#include "keymap.h"
#include "mainwindow.h"
#include "messageview.h"
+#include "mimeparser.h"
#include "notmuchworker.h"
#include "carddelegate.h"
+#include "composewindow.h"
+#include "senddialog.h"
+#include "messagesender.h"
+#include <QCheckBox>
+#include <QPlainTextEdit>
+#include <QPointer>
+#include <QListWidget>
#include "cardlayout.h"
#include <QImage>
@@ -96,6 +104,35 @@ public:
/// no trash key either. A caller that names an account and wants Delete to
/// work has to say where its trash is, which is the same requirement the
/// real config imposes.
+ /// One [account.<key>] section to write.
+ ///
+ /// `sendCommand` is what makes the account able to send, and its EMPTINESS
+ /// is what makes it receive-only: the capability is the key's presence,
+ /// not a separate flag, so a receive-only account is written by omitting
+ /// it exactly as the real config expresses it.
+ struct AccountSpec
+ {
+ QString key;
+ QString maildir;
+ QString trash;
+ QString sendCommand;
+ QString address;
+ };
+
+ /// Writes several accounts, for the compose cases.
+ ///
+ /// Beside build() rather than replacing it: every existing caller passes
+ /// at most one account and none of them needs a send command, so widening
+ /// the three-argument signature further would make ten call sites carry
+ /// two empty strings each for one test's benefit.
+ bool buildWithAccounts(const QList<AccountSpec> &accounts,
+ const QString &composeKey = QString())
+ {
+ m_accounts = accounts;
+ m_composeKey = composeKey;
+ return build();
+ }
+
bool build(const QString &accountKey = QString(),
const QString &accountMaildir = QString(),
const QString &accountTrash = QString())
@@ -142,6 +179,21 @@ public:
// folder that does not exist would CREATE it.
out << "inbox=inbox\n";
}
+ if (!m_composeKey.isEmpty())
+ out << "\n[compose]\n" << m_composeKey << "\n";
+ for (const AccountSpec &account : m_accounts) {
+ out << "\n[account." << account.key << "]\n"
+ << "maildir=" << account.maildir << "\n"
+ << "inbox=inbox\n";
+ if (!account.trash.isEmpty())
+ out << "trash=" << account.trash << "\n";
+ if (!account.address.isEmpty())
+ out << "address=" << account.address << "\n";
+ // Written only when non-empty. An account with no
+ // send_command is receive-only, which is the shape under test.
+ if (!account.sendCommand.isEmpty())
+ out << "send_command=" << account.sendCommand << "\n";
+ }
}
file.close();
@@ -162,6 +214,8 @@ private:
QTemporaryDir m_confDir;
Config m_config;
QString m_error;
+ QList<AccountSpec> m_accounts;
+ QString m_composeKey;
};
/// MainWindow is mostly wiring. Cases that need a real database opt into one
@@ -197,6 +251,25 @@ private slots:
void narrowingAnEmptyQueryBarIsAPlainSearch();
void aMalformedAccountIsReportedWithoutBlockingTheConstructor();
void aWorkerBackedWindowReturnsRealThreads();
+
+ // Compose and send, item 123 task 12.
+ void theMailRootComesFromTheConfigNotTheIndex();
+ void replyIsDisabledOnAReceiveOnlyAccountsMail();
+ void theReceiveOnlyRibbonNamesTheAccount();
+ void replyIsEnabledOnASendingAccountsMail();
+ void composeIsDisabledOnlyWhenNoAccountCanSend();
+ void quittingWithACleanComposerAsksNothing();
+ void quittingWithUnsavedEditsReportsEveryComposer();
+ void closingAComposerCompactsTheRegistry();
+ void savingAMessageRefusesToEscapeTheChosenDirectory();
+ void aHostileSubjectCannotEscapeTheSaveDirectory();
+ void savingTwiceDoesNotOverwriteTheFirstFile();
+ void savingAMessageWithAHostileSubjectStaysInTheDirectory();
+ void aStuckComposeRequestDoesNotHijackTheNextPaneLoad();
+ void theSaveLoopToleratesAComposerClosedUnderTheDialog();
+ void quittingClosesEveryComposerRatherThanOrphaningIt();
+ void forwardingCarriesTheOriginalsAttachments();
+ void forwardSeedsHtmlFromTheConfigNotTheOriginal();
void aStartupAccountScopesTheStartupQuery();
void aStartupAccountAlsoScopesASavedStartupQuery();
void aGeneratedStartupQueryActuallyRuns();
@@ -331,6 +404,7 @@ private slots:
void everyActionCarriesAnIcon();
void everyActionIsReachableFromAMenu();
+ void noMenuHasTwoEntriesSharingAMnemonic();
void theToolbarDoesNotOverrideTheDesktopButtonStyle();
void theImportantActionIsLabelledImportant();
void theImportantActionStillWritesTheFlaggedTag();
@@ -391,6 +465,37 @@ private slots:
void theRefreshAfterARestoreLeavesUndoIntact();
void deletingOutsideTheTrashViewLeavesTheRowInPlace();
+ // ComposeWindow, item 123. These need a window but no worker: the composer
+ // never touches NotmuchWorker, it reads its context from the value struct
+ // MainWindow hands it, so a Config written to a temporary INI is the whole
+ // fixture.
+ void aComposerOpensClean();
+ void typingMarksTheComposerDirty();
+ void anAutosaveWritesADraftAndClearsTheDirtyFlag();
+ void anUnwritableDraftsFolderRaisesThePersistentBanner();
+ void aSuccessfulSaveClearsTheBanner();
+ void anAccountWithoutADraftsFolderReportsNoFailure();
+ void aRewrittenDraftUnlinksThePreviousRevision();
+ void theComposerBuildsTheMessageItsWidgetsShow();
+ void theFromDropdownDecidesWhichAccountSends();
+ void aFormatEditPreservesTheUndoStack();
+ void aFormatEditRestoresTheSelectionItAsksFor();
+ void aFormatEditOnAnEmptySelectionLandsBetweenTheTokens();
+ void theAttachmentWarningRespectsTheConfiguredThreshold();
+ void aDisabledAttachmentWarningWarnsAboutNothing();
+ void theQuotePositionDecidesWhereTheQuoteLands();
+ void theSeededQuoteIsNotAnUndoStep();
+ void aReplySeedsTheHtmlToggleFromTheOriginal();
+ void aNewMessageSeedsTheHtmlToggleFromConfig();
+ void disablingInputsCoversEveryFieldAndTheToolbar();
+ void aFailedSendCanBeRetriedWithoutFilingTheWrongCopy();
+ void anUnchangedMessageIsNotWrittenAgain();
+ void closingInsideTheDebounceStillSavesTheDraft();
+ void closingAfterASendWritesNoFurtherDraft();
+ void aCloseDuringTheCountdownIsRefused();
+ void aFailedSendKeepsTheTextThatFailedToGo();
+ void aSmallSizeLimitIsNotDescribedAsZeroMegabytes();
+
private:
/// Owns the throwaway lock table init() points every test at. A pointer
/// rather than a value because it is rebuilt per test, and QTemporaryDir
@@ -6444,6 +6549,185 @@ void TestMainWindow::everyActionIsReachableFromAMenu()
.arg(unreachable.join(QStringLiteral(", ")))));
}
+void TestMainWindow::noMenuHasTwoEntriesSharingAMnemonic()
+{
+ // The sibling of everyActionIsReachableFromAMenu(), and it exists because
+ // the rule it enforces had lived only in prose and in one other test's
+ // COMMENT, and was duly broken the first time a batch of entries was added
+ // to a menu (item 123: `&Reply` against the pre-existing `&Restore from
+ // trash`, both Alt+R).
+ //
+ // Qt does not error on a duplicate mnemonic. It CYCLES between the
+ // colliding entries instead of activating either, so the key silently
+ // stops working and merely moves a highlight. That is worse than it
+ // sounds in the Message menu, where `restore` is deliberately greyed
+ // outside the trash view: the ordinary case was pressing Alt+R and landing
+ // on a disabled entry.
+ //
+ // Item 57 already decided this is a property rather than a taste. It
+ // rejected the label "Starred" for `flag` precisely because it would have
+ // collided with `Mark &spam`, and theImportantActionIsLabelledImportant()
+ // pins the surviving label with that reasoning in its comment. A decision
+ // recorded only in prose is one nobody re-derives.
+ //
+ // Scoped PER MENU, which is what the collision actually is: a mnemonic is
+ // resolved among the entries of the menu that is open, so the same letter
+ // in File and in View is not a conflict.
+ const Config config;
+ MainWindow window(config);
+
+ auto *bar = window.menuBar();
+ QVERIFY(bar);
+
+ // The menu bar's own top-level titles are one such scope too, so the walk
+ // starts by treating the bar as a menu and then descends.
+ QList<QPair<QString, QList<QAction *>>> scopes;
+ scopes.append({ QStringLiteral("the menu bar"), bar->actions() });
+
+ QList<QMenu *> pending;
+ const auto topLevel = bar->actions();
+ for (QAction *action : topLevel) {
+ if (action->menu())
+ pending.append(action->menu());
+ }
+ QVERIFY2(!pending.isEmpty(), "the menu bar holds no menus");
+
+ while (!pending.isEmpty()) {
+ QMenu *menu = pending.takeFirst();
+ const auto entries = menu->actions();
+ scopes.append({ menu->title(), entries });
+ for (QAction *entry : entries) {
+ if (QMenu *sub = entry->menu())
+ pending.append(sub);
+ }
+ }
+
+ // The four collisions that PREDATE this test, measured by running it
+ // against the tree before item 123 touched any label. They are listed
+ // rather than fixed, and rather than being hidden by narrowing the test,
+ // because renaming a shipped menu entry is the user's call and not a
+ // test's: three of them are in menus a user has had in their fingers
+ // since 0.1.0.
+ //
+ // Listed as exact pairs, not as "ignore Alt+R", so this is a freeze and
+ // not an amnesty: a NEW entry colliding on any of these same keys still
+ // fails, because its pair is not on this list. Fixing one is then a
+ // one-line deletion here, which is the point of writing them out.
+ // Written as the FULL GROUP of labels sharing one key in one menu, not as
+ // a pair. A pair is keyed on which entry the walk happened to see first,
+ // so adding a colliding entry ABOVE a frozen one silently re-pairs it and
+ // the new defect gets reported as "a frozen collision no longer happens",
+ // which names the wrong thing entirely. Measured: reinstating `&Reply`
+ // did exactly that before this was changed. A group is order-independent,
+ // so a new entry grows the group and fails as a new collision.
+ static const QStringList knownPreExistingCollisions = {
+ QStringLiteral("&Message: Alt+R shared by \"&Restore from trash\", \"Mark all &read\", \"Tagging &rules...\""),
+ QStringLiteral("&Message: Alt+S shared by \"Mark &spam\", \"Find &stranded deleted mail\""),
+ QStringLiteral("&View: Alt+O shared by \"&Open thread\", \"Zoom &out\""),
+ };
+
+ QStringList collisions;
+ int compared = 0;
+
+ for (const auto &scope : scopes) {
+ // Keyed on the mnemonic Qt itself derives, not on a hand-parsed '&'.
+ // The question is which key Qt will dispatch, and only Qt answers it:
+ // "&&" is a literal ampersand and carries no mnemonic at all.
+ //
+ // A QMap rather than a QHash so the groups come out in a stable key
+ // order, which is what lets the frozen list above be written once and
+ // stay matching.
+ QMap<QString, QStringList> byMnemonic;
+ for (QAction *entry : scope.second) {
+ if (entry->isSeparator())
+ continue;
+ const QKeySequence mnemonic = QKeySequence::mnemonic(entry->text());
+ if (mnemonic.isEmpty())
+ continue;
+ ++compared;
+ byMnemonic[mnemonic.toString(QKeySequence::NativeText)]
+ .append(QStringLiteral("\"%1\"").arg(entry->text()));
+ }
+
+ for (auto it = byMnemonic.cbegin(); it != byMnemonic.cend(); ++it) {
+ if (it.value().size() < 2)
+ continue;
+ // Names the menu, the key and EVERY label in the group, so a
+ // future failure says what to rename without anyone going looking.
+ collisions.append(QStringLiteral("%1: %2 shared by %3")
+ .arg(scope.first, it.key(),
+ it.value().join(QStringLiteral(", "))));
+ }
+ }
+
+ // The guard, and it is not ceremonial: every assertion below is a loop
+ // that reports success when it runs zero times. A walk that found no
+ // mnemonics at all would pass this test against any label whatsoever.
+ QVERIFY2(compared > 20,
+ qPrintable(QStringLiteral("only %1 menu entries carried a "
+ "mnemonic, so this probe measured "
+ "almost nothing")
+ .arg(compared)));
+
+ // Matched on the menu and key only, with the labels compared separately
+ // below. Comparing whole strings made a GROWING group read as a frozen one
+ // disappearing: adding `&Reply` took Alt+R from three labels to four, the
+ // frozen three-label string stopped matching, and the failure said "this
+ // collision no longer happens" about the very key that had just got worse.
+ // Measured twice, once per attempt, which is why the two questions are
+ // asked separately.
+ const auto scopeAndKey = [](const QString &collision) {
+ return collision.left(collision.indexOf(QStringLiteral(" shared by ")));
+ };
+
+ QHash<QString, QString> frozen;
+ for (const QString &known : knownPreExistingCollisions)
+ frozen.insert(scopeAndKey(known), known);
+
+ QStringList unexpected;
+ QSet<QString> stillPresent;
+ for (const QString &collision : collisions) {
+ const QString key = scopeAndKey(collision);
+ const auto known = frozen.constFind(key);
+ if (known == frozen.constEnd()) {
+ // A collision on a key nothing froze: entirely new.
+ unexpected.append(collision);
+ continue;
+ }
+ stillPresent.insert(key);
+ if (*known != collision) {
+ // The key was already colliding, but the CAST has changed, which
+ // for a frozen entry means an entry joined it. Reported as the
+ // new collision it is, naming both what was frozen and what is
+ // there now.
+ unexpected.append(
+ QStringLiteral("%1 (frozen as [%2], now [%3])")
+ .arg(key, *known, collision));
+ }
+ }
+
+ // A frozen entry that has since been FIXED must not stay on the list
+ // silently, or the list becomes a place stale claims accumulate.
+ QStringList stale;
+ for (const QString &known : knownPreExistingCollisions) {
+ if (!stillPresent.contains(scopeAndKey(known)))
+ stale.append(known);
+ }
+ QVERIFY2(stale.isEmpty(),
+ qPrintable(QStringLiteral("%1 frozen collision(s) no longer "
+ "happen, so delete them from "
+ "knownPreExistingCollisions: %2")
+ .arg(stale.size())
+ .arg(stale.join(QStringLiteral("; ")))));
+
+ QVERIFY2(unexpected.isEmpty(),
+ qPrintable(QStringLiteral("%1 menu mnemonic collision(s), where "
+ "Qt cycles the highlight instead of "
+ "activating: %2")
+ .arg(unexpected.size())
+ .arg(unexpected.join(QStringLiteral("; ")))));
+}
+
void TestMainWindow::everyActionCarriesAnIcon()
{
// Item 56. The complaint was inconsistency, not absence: eight actions had
@@ -6967,15 +7251,22 @@ void TestMainWindow::noTwoActionsShareAnIcon()
// the words saying which. Giving them five invented shapes would be less
// clear than the pairing.
//
+ // reply_no_quote joined them in item 123 for exactly the same reason: it
+ // shares reply's icon, it is a menu entry that always carries its text,
+ // and it is not on the toolbar. The list is therefore no longer only the
+ // thread tier, which is why it is named for the PROPERTY that earns the
+ // exemption rather than for the tier that first needed it.
+ //
// Named as an exception list rather than by asking the toolbar what it
// holds, so that PUTTING one of these on the toolbar fails this test
// rather than silently passing it.
- static const QStringList menuOnlyThreadActions = {
+ static const QStringList menuOnlySharedIconActions = {
QStringLiteral("archive_thread"),
QStringLiteral("delete_thread"),
QStringLiteral("spam_thread"),
QStringLiteral("toggle_unread_thread"),
QStringLiteral("flag_thread"),
+ QStringLiteral("reply_no_quote"),
};
const Config config;
@@ -6986,7 +7277,7 @@ void TestMainWindow::noTwoActionsShareAnIcon()
// may sit on the toolbar.
auto *toolBar = window.findChild<QToolBar *>();
QVERIFY(toolBar);
- for (const QString &name : menuOnlyThreadActions) {
+ for (const QString &name : menuOnlySharedIconActions) {
auto *action = window.findChild<QAction *>(name);
QVERIFY2(action, qPrintable(QStringLiteral("no action named %1").arg(name)));
QVERIFY2(!toolBar->actions().contains(action),
@@ -7006,7 +7297,7 @@ void TestMainWindow::noTwoActionsShareAnIcon()
QVERIFY2(action, qPrintable(QStringLiteral("no action named %1").arg(name)));
if (!action->icon().isNull())
++withIcons;
- if (menuOnlyThreadActions.contains(name))
+ if (menuOnlySharedIconActions.contains(name))
continue;
if (action->icon().isNull())
continue;
@@ -7033,7 +7324,7 @@ void TestMainWindow::noTwoActionsShareAnIcon()
// And the exception list did not swallow the comparison itself.
QCOMPARE(compared, KeyMap::knownActions().size()
- - menuOnlyThreadActions.size());
+ - menuOnlySharedIconActions.size());
QVERIFY2(collisions.isEmpty(),
qPrintable(QStringLiteral("actions sharing one icon: %1")
@@ -7943,6 +8234,933 @@ void TestMainWindow::aWorkerBackedWindowReturnsRealThreads()
QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1, 15000);
}
+namespace {
+
+/// A worker-backed window with one message in one account's maildir.
+///
+/// The compose cases all need the same three things: a message on disk, an
+/// account owning the folder it landed in, and a selected row. Repeating that
+/// in six tests is how one of them ends up subtly different from the rest.
+struct WorkerComposeFixture
+{
+ WorkerBackedWindow backed;
+
+ /// Writes one message into <accountMaildir>/inbox and indexes it.
+ /// \p composeKey, when given, is written as one line under [compose].
+ bool seed(const QList<WorkerBackedWindow::AccountSpec> &accounts,
+ const QString &folder, const QString &composeKey = QString())
+ {
+ if (!backed.fixture().addMessage(
+ folder, QStringLiteral("compose1@example.org"),
+ QStringLiteral("A subject"),
+ QStringLiteral("sender@example.org"),
+ // Friday, verified with `date -d 2026-08-14 +%A`.
+ QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200"),
+ QStringLiteral("Body text."))) {
+ return false;
+ }
+ return backed.buildWithAccounts(accounts, composeKey);
+ }
+
+ /// Runs a query and puts the current index on its one row.
+ ///
+ /// Waits on the MAIL ROOT as well as on the row. The reply family is gated
+ /// on which account owns the message, which needs the root, and that
+ /// arrives on its own queued signal: asserting on an action's enabled
+ /// state before it lands measures the startup race rather than the rule.
+ static bool selectTheMessage(MainWindow &window)
+ {
+ auto *model = window.findChild<ThreadListModel *>();
+ auto *view = window.findChild<ThreadListView *>();
+ auto *queryEdit =
+ window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ if (!model || !view || !queryEdit)
+ return false;
+
+ queryEdit->setText(QStringLiteral("tag:inbox"));
+ queryEdit->returnPressed();
+
+ bool ready = false;
+ for (int attempt = 0; attempt < 150 && !ready; ++attempt) {
+ ready = model->rowCount(QModelIndex()) == 1
+ && !window.mailRootForTesting().isEmpty();
+ if (!ready)
+ QTest::qWait(100);
+ }
+ if (!ready)
+ return false;
+
+ view->setCurrentIndex(model->index(0, 0, QModelIndex()));
+ return true;
+ }
+};
+
+} // namespace
+
+void TestMainWindow::theMailRootComesFromTheConfigNotTheIndex()
+{
+ // Item 124's rule, for the path the composer composes drafts and sent
+ // copies under. splitIndex() is what makes this test able to fail at all:
+ // in the ordinary layout notmuch_database_get_path() and
+ // NOTMUCH_CONFIG_MAIL_ROOT return the SAME string, so a test written
+ // against it passes whichever accessor the code uses.
+ WorkerComposeFixture fixture;
+ fixture.backed.fixture().splitIndex();
+ QVERIFY2(fixture.seed({ { QStringLiteral("work"), QStringLiteral("work"),
+ QString(), QStringLiteral("/bin/true"),
+ QStringLiteral("you@example.org") } },
+ QStringLiteral("work/inbox")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+
+ QTRY_VERIFY_WITH_TIMEOUT(!window.mailRootForTesting().isEmpty(), 15000);
+
+ // The MAIL root, not the index directory. Under the split layout these are
+ // different directories, and a draft composed under the index one is
+ // written into the Xapian tree.
+ QCOMPARE(window.mailRootForTesting(),
+ QDir(fixture.backed.fixture().maildirPath()).absolutePath());
+ QVERIFY2(window.mailRootForTesting()
+ != QDir(fixture.backed.fixture().indexPath()).absolutePath(),
+ "the window took the index directory for the mail root");
+}
+
+void TestMainWindow::replyIsDisabledOnAReceiveOnlyAccountsMail()
+{
+ // The capability IS the send_command's presence, so this account is
+ // written without one.
+ WorkerComposeFixture fixture;
+ QVERIFY2(fixture.seed({ { QStringLiteral("listsonly"),
+ QStringLiteral("listsonly"), QString(),
+ /*sendCommand=*/QString(),
+ QStringLiteral("you@example.org") } },
+ QStringLiteral("listsonly/inbox")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ QVERIFY(WorkerComposeFixture::selectTheMessage(window));
+
+ for (const QString &name : { QStringLiteral("reply"),
+ QStringLiteral("reply_all"),
+ QStringLiteral("reply_no_quote"),
+ QStringLiteral("forward") }) {
+ auto *action = 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 = window.findChild<QAction *>(QStringLiteral("save_message"));
+ QVERIFY(save);
+ QVERIFY2(save->isEnabled(),
+ "save_message was disabled, removing the escape hatch");
+}
+
+void TestMainWindow::replyIsEnabledOnASendingAccountsMail()
+{
+ // The guard for the test above. Without it, a bug disabling the reply
+ // family unconditionally would pass every assertion there while removing
+ // the feature entirely.
+ WorkerComposeFixture fixture;
+ QVERIFY2(fixture.seed({ { QStringLiteral("work"), QStringLiteral("work"),
+ QString(), QStringLiteral("/bin/true"),
+ QStringLiteral("you@example.org") } },
+ QStringLiteral("work/inbox")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ QVERIFY(WorkerComposeFixture::selectTheMessage(window));
+
+ for (const QString &name : { QStringLiteral("reply"),
+ QStringLiteral("reply_all"),
+ QStringLiteral("reply_no_quote"),
+ QStringLiteral("forward") }) {
+ auto *action = window.findChild<QAction *>(name);
+ QVERIFY2(action, qPrintable(QStringLiteral("no action %1").arg(name)));
+ QVERIFY2(action->isEnabled(),
+ qPrintable(QStringLiteral("%1 was disabled on mail from an "
+ "account that can send").arg(name)));
+ }
+
+ // And no ribbon: this account can send, so there is nothing to explain.
+ auto *ribbon =
+ window.findChild<QLabel *>(QStringLiteral("receiveOnlyRibbon"));
+ QVERIFY(ribbon);
+ QVERIFY2(ribbon->isHidden(),
+ "the receive-only ribbon showed on an account that can send");
+}
+
+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.
+ WorkerComposeFixture fixture;
+ QVERIFY2(fixture.seed({ { QStringLiteral("listsonly"),
+ QStringLiteral("listsonly"), QString(),
+ QString(), QStringLiteral("you@example.org") } },
+ QStringLiteral("listsonly/inbox")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ QVERIFY(WorkerComposeFixture::selectTheMessage(window));
+
+ auto *ribbon =
+ window.findChild<QLabel *>(QStringLiteral("receiveOnlyRibbon"));
+ QVERIFY2(ribbon, "no ribbon widget exists");
+
+ // isHidden() rather than isVisibleTo(): under the offscreen platform an
+ // unshown window's children report not visible whatever the code does, so
+ // isVisibleTo would fail against correct code. What is being asserted is
+ // that the ribbon was not left explicitly hidden.
+ QVERIFY2(!ribbon->isHidden(),
+ "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())));
+
+ // PlainText, not AutoText. A QLabel guesses under AutoText, and this is
+ // the same protection MessageDetailsDialog states on every value.
+ QCOMPARE(ribbon->textFormat(), Qt::PlainText);
+}
+
+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.
+ {
+ WorkerComposeFixture fixture;
+ QVERIFY2(fixture.seed({ { QStringLiteral("listsonly"),
+ QStringLiteral("listsonly"), QString(),
+ QString(), QStringLiteral("you@example.org") } },
+ QStringLiteral("listsonly/inbox")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ auto *compose = window.findChild<QAction *>(QStringLiteral("compose"));
+ QVERIFY(compose);
+ QVERIFY2(!compose->isEnabled(),
+ "compose was live with no account able to send");
+ }
+ {
+ WorkerComposeFixture fixture;
+ QVERIFY2(fixture.seed(
+ { { QStringLiteral("listsonly"),
+ QStringLiteral("listsonly"), QString(), QString(),
+ QStringLiteral("you@example.org") },
+ { QStringLiteral("work"), QStringLiteral("work"),
+ QString(), QStringLiteral("/bin/true"),
+ QStringLiteral("work@example.org") } },
+ QStringLiteral("listsonly/inbox")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ auto *compose = 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.
+ WorkerComposeFixture fixture;
+ QVERIFY2(fixture.seed({ { QStringLiteral("work"), QStringLiteral("work"),
+ QString(), QStringLiteral("/bin/true"),
+ QStringLiteral("you@example.org") } },
+ QStringLiteral("work/inbox")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ QTRY_VERIFY_WITH_TIMEOUT(!window.mailRootForTesting().isEmpty(), 15000);
+
+ QVERIFY2(window.openComposerForTest(), "no composer opened");
+ QCOMPARE(window.openComposerCount(), 1);
+
+ QVERIFY2(window.composersBlockingQuit().isEmpty(),
+ "a clean composer was reported as blocking quit");
+
+ // Composers are parentless top-level windows and outlive this MainWindow,
+ // carrying a MessageSender and a running autosave timer into whatever test
+ // runs next. Closed here rather than left for the destructor, which never
+ // touches m_composers.
+ for (ComposeWindow *composer : window.openComposersForTest()) {
+ composer->show();
+ composer->close();
+ }
+}
+
+void TestMainWindow::quittingWithUnsavedEditsReportsEveryComposer()
+{
+ // Case 2: ONE dialog whatever the count, so the quit path has to see BOTH
+ // composers rather than stopping at the first dirty one.
+ WorkerComposeFixture fixture;
+ QVERIFY2(fixture.seed({ { QStringLiteral("work"), QStringLiteral("work"),
+ QString(), QStringLiteral("/bin/true"),
+ QStringLiteral("you@example.org") } },
+ QStringLiteral("work/inbox")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ QTRY_VERIFY_WITH_TIMEOUT(!window.mailRootForTesting().isEmpty(), 15000);
+
+ QVERIFY(window.openComposerForTest());
+ QVERIFY(window.openComposerForTest());
+ QCOMPARE(window.openComposerCount(), 2);
+
+ // Clean until something is typed, which is the case-1 assertion holding
+ // here too and the guard that this test can distinguish the two states.
+ QVERIFY(window.composersBlockingQuit().isEmpty());
+
+ window.markComposersDirtyForTest();
+ QCOMPARE(window.composersBlockingQuit().size(), 2);
+
+ // Left open, these are parentless top-level windows with a live autosave
+ // timer, surviving into later tests. See the note in the clean-composer
+ // case above.
+ for (ComposeWindow *composer : window.openComposersForTest()) {
+ composer->show();
+ composer->close();
+ }
+}
+
+void TestMainWindow::closingAComposerCompactsTheRegistry()
+{
+ // The closed() signal's ONE job. The QPointer alone would keep
+ // composersBlockingQuit() correct, since it nulls on destruction, but the
+ // entry would stay in the list for the session's lifetime. This asserts
+ // the list is compacted, which only the signal can do.
+ WorkerComposeFixture fixture;
+ QVERIFY2(fixture.seed({ { QStringLiteral("work"), QStringLiteral("work"),
+ QString(), QStringLiteral("/bin/true"),
+ QStringLiteral("you@example.org") } },
+ QStringLiteral("work/inbox")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ QTRY_VERIFY_WITH_TIMEOUT(!window.mailRootForTesting().isEmpty(), 15000);
+
+ ComposeWindow *composer = window.openComposerForTest();
+ QVERIFY(composer);
+ QCOMPARE(window.openComposerCount(), 1);
+
+ // A composer that was never shown returns early from close() WITHOUT
+ // reaching closeEvent(), so the signal would never fire and this test
+ // would assert nothing at all.
+ composer->show();
+ QVERIFY(composer->close());
+
+ // And the quit path must not see a destroyed window, which is the
+ // QPointer's job rather than the signal's.
+ QCOMPARE(window.openComposerCount(), 0);
+ QVERIFY(window.composersBlockingQuit().isEmpty());
+}
+
+void TestMainWindow::savingAMessageRefusesToEscapeTheChosenDirectory()
+{
+ // A subject is input from a stranger and is what the default filename is
+ // derived from, so it may carry separators and "..". Asserted through
+ // Attachment's own helpers, which is what saveDisplayedMessage() calls:
+ // a second implementation of the check here would prove nothing about the
+ // one that runs.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ const QString directory = dir.path();
+
+ Attachment naming;
+ naming.filename = QStringLiteral("../../etc/passwd");
+ const QString target =
+ QDir(directory).absoluteFilePath(naming.safeFilename());
+
+ QVERIFY2(Attachment::isPathInsideDirectory(directory, target),
+ "a traversing subject escaped the chosen directory");
+ QVERIFY2(!target.contains(QStringLiteral("/etc/passwd")),
+ qPrintable(QStringLiteral("the traversal survived: %1").arg(target)));
+
+ // Compared as PATHS, never with startsWith(): a sibling directory whose
+ // name merely begins with the chosen one's is not inside it.
+ QVERIFY2(!Attachment::isPathInsideDirectory(
+ directory, directory + QStringLiteral("-evil/message.eml")),
+ "a sibling directory passed the containment check");
+}
+
+void TestMainWindow::aHostileSubjectCannotEscapeTheSaveDirectory()
+{
+ // Asserted through MainWindow::defaultMessageFilename(), which is what
+ // saveDisplayedMessage() actually calls. The previous version of this
+ // check built an Attachment by hand and called safeFilename() directly:
+ // that proves what Attachment does and nothing about whether save_message
+ // asks it anything, and three mutations to the real path left it green.
+ // CLAUDE.md: assert through the function the production path calls, not
+ // through the one it calls INTO.
+ const QString traversal =
+ MainWindow::defaultMessageFilename(QStringLiteral("../../etc/passwd"));
+
+ // No separator survives, so the name cannot address another directory.
+ QVERIFY2(!traversal.contains(QLatin1Char('/')),
+ qPrintable(QStringLiteral("a separator survived: %1").arg(traversal)));
+ // NOT asserting the absence of "..": with every separator replaced, a
+ // literal ".." inside a filename addresses nothing and is a legitimate
+ // part of a name. What matters is that the result is a single path
+ // COMPONENT, which is what makes traversal impossible.
+ QCOMPARE(QFileInfo(traversal).fileName(), traversal);
+ QVERIFY2(traversal != QStringLiteral("..")
+ && traversal != QStringLiteral("."),
+ qPrintable(QStringLiteral("the name is a directory reference: %1")
+ .arg(traversal)));
+
+ // And joining it onto a directory really does stay inside.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Attachment naming;
+ naming.filename = traversal;
+ const QString target =
+ QDir(dir.path()).absoluteFilePath(naming.safeFilename());
+ QVERIFY2(Attachment::isPathInsideDirectory(dir.path(), target),
+ qPrintable(QStringLiteral("escaped the directory: %1").arg(target)));
+
+ // A backslash is a separator too, on a name written by Windows software.
+ const QString backslash = MainWindow::defaultMessageFilename(
+ QStringLiteral("..\\..\\Windows\\System32\\config"));
+ QVERIFY2(!backslash.contains(QLatin1Char('\\')),
+ qPrintable(QStringLiteral("a backslash survived: %1").arg(backslash)));
+
+ // A subject with nothing usable still yields a name rather than "" or a
+ // bare extension, which would make the write land on a dotfile.
+ const QString empty = MainWindow::defaultMessageFilename(QString());
+ QVERIFY2(empty.startsWith(QStringLiteral("message")),
+ qPrintable(QStringLiteral("empty subject gave: %1").arg(empty)));
+
+ // The extension survives truncation. Truncating AFTER appending it would
+ // cut ".eml" off a long subject and write an extensionless file.
+ const QString long_ = MainWindow::defaultMessageFilename(
+ QString(400, QLatin1Char('a')));
+ QVERIFY2(long_.endsWith(QStringLiteral(".eml")),
+ qPrintable(QStringLiteral("the extension was truncated away: %1")
+ .arg(long_.right(20))));
+}
+
+void TestMainWindow::savingTwiceDoesNotOverwriteTheFirstFile()
+{
+ // Two messages very often share a subject, and the filename is derived
+ // from it, so the second save must not destroy the first. Driven through
+ // saveDisplayedMessage() by way of the directory seam, which is the only
+ // way to reach the write guard at all: the file dialog is a modal the
+ // offscreen platform cannot click.
+ WorkerComposeFixture fixture;
+ QVERIFY2(fixture.seed({ { QStringLiteral("work"), QStringLiteral("work"),
+ QString(), QStringLiteral("/bin/true"),
+ QStringLiteral("you@example.org") } },
+ QStringLiteral("work/inbox")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ QVERIFY(WorkerComposeFixture::selectTheMessage(window));
+
+ QTemporaryDir out;
+ QVERIFY(out.isValid());
+
+ window.saveDisplayedMessageForTest(out.path());
+ window.saveDisplayedMessageForTest(out.path());
+
+ // Two files, not one overwritten. Asserted on the COUNT rather than on the
+ // second name, so the disambiguation scheme can change without the test
+ // caring what it is called.
+ const QStringList written =
+ QDir(out.path()).entryList(QDir::Files | QDir::NoDotAndDotDot);
+ QCOMPARE(written.size(), 2);
+
+ // And both are real copies rather than one empty placeholder.
+ for (const QString &name : written) {
+ QVERIFY2(QFileInfo(QDir(out.path()).absoluteFilePath(name)).size() > 0,
+ qPrintable(QStringLiteral("%1 is empty").arg(name)));
+ }
+}
+
+void TestMainWindow::savingAMessageWithAHostileSubjectStaysInTheDirectory()
+{
+ // Driven through saveDisplayedMessage() with a real hostile subject, which
+ // is the only shape that covers the production write path. An earlier
+ // version of this coverage built an Attachment by hand and called
+ // safeFilename() and isPathInsideDirectory() directly, which proves what
+ // Attachment does and nothing about whether save_message asks it anything.
+ //
+ // WHAT THIS CAN AND CANNOT CATCH, measured rather than assumed, because
+ // the numbers are surprising and the next person will otherwise redo the
+ // work. Three independent layers stand between a subject and the write:
+ // defaultMessageFilename() replaces separators, Attachment::safeFilename()
+ // reduces to a basename, and Attachment::isPathInsideDirectory() refuses
+ // the write. EACH ONE ALONE IS SUFFICIENT, so removing any single layer
+ // leaves this test green: measured, all three single-layer mutations pass.
+ // Removing all three fails it. That is real defence-in-depth rather than a
+ // probe pointed at the wrong object, and mimeparser.h:71-77 already says
+ // the same of isPathInsideDirectory, but it does mean this test is a guard
+ // against the DEFENCES COLLECTIVELY disappearing, not a guard on any one
+ // of them. aHostileSubjectCannotEscapeTheSaveDirectory() covers the first
+ // layer on its own, and a single-layer mutation there does fail.
+ //
+ // The subject is ABSOLUTE rather than "../..", and that matters.
+ // QDir::absoluteFilePath() does not resolve ".." (measured: it
+ // concatenates), but the collision loop below can rename a relative
+ // traversal by accident when the target happens to exist, which makes it
+ // the weaker probe. An absolute candidate replaces the directory outright.
+ WorkerComposeFixture fixture;
+ QVERIFY(fixture.backed.fixture().addMessage(
+ QStringLiteral("work/inbox"), QStringLiteral("hostile@example.org"),
+ // The subject is the attacker's input, and it is what the default
+ // filename is derived from.
+ // Absolute, not "../..". QDir::absoluteFilePath() does NOT resolve
+ // ".." (measured: it concatenates, giving "<dir>/../../x"), but an
+ // ABSOLUTE candidate replaces the directory outright, which is the
+ // escape that survives every accident. A relative traversal can be
+ // neutralised by the collision loop renaming it when the target
+ // happens to exist, so it is the weaker probe of the two.
+ QStringLiteral("/tmp/qtmaildir-pwned-probe"),
+ QStringLiteral("sender@example.org"),
+ // Friday, verified with `date -d 2026-08-14 +%A`.
+ QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200"),
+ QStringLiteral("Body text.")));
+ QVERIFY2(fixture.backed.buildWithAccounts(
+ { { QStringLiteral("work"), QStringLiteral("work"), QString(),
+ QStringLiteral("/bin/true"),
+ QStringLiteral("you@example.org") } }),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ QVERIFY(WorkerComposeFixture::selectTheMessage(window));
+
+ // A directory INSIDE another, so an escape has somewhere to land that the
+ // test can then look at. Escaping "out" writes into parent/, which is what
+ // the assertions below check is still empty.
+ QTemporaryDir parent;
+ QVERIFY(parent.isValid());
+ const QString out = parent.filePath(QStringLiteral("out"));
+ QVERIFY(QDir().mkpath(out));
+
+ window.saveDisplayedMessageForTest(out);
+
+ // The file landed inside the chosen directory.
+ // NOT QDir::Hidden. A file whose name begins with a dot is hidden on every
+ // Unix desktop, so the write would succeed while the user could not find
+ // what they saved. Listing without Hidden is what makes this assertion
+ // notice that, and it is how the leading-dot case was found: a traversing
+ // subject reduces to "..-..-etc-passwd" once its separators are replaced,
+ // which is a dotfile.
+ const QStringList inside =
+ QDir(out).entryList(QDir::Files | QDir::NoDotAndDotDot);
+ QCOMPARE(inside.size(), 1);
+ QVERIFY2(!inside.first().startsWith(QLatin1Char('.')),
+ qPrintable(QStringLiteral("the saved message is hidden: %1")
+ .arg(inside.first())));
+
+ // And nothing was written beside it, which is where a traversal would go.
+ const QStringList escaped =
+ QDir(parent.path()).entryList(QDir::Files | QDir::NoDotAndDotDot);
+ QVERIFY2(escaped.isEmpty(),
+ qPrintable(QStringLiteral("a file escaped the directory: %1")
+ .arg(escaped.join(QLatin1Char(' ')))));
+
+ // The written path really is contained, compared as PATHS rather than with
+ // startsWith(): a sibling directory whose name merely begins with the
+ // chosen one's is not inside it.
+ const QString written = QDir(out).absoluteFilePath(inside.first());
+ QVERIFY2(Attachment::isPathInsideDirectory(out, written),
+ qPrintable(QStringLiteral("escaped: %1").arg(written)));
+ QVERIFY2(QFileInfo(written).size() > 0, "the saved message is empty");
+}
+
+void TestMainWindow::aStuckComposeRequestDoesNotHijackTheNextPaneLoad()
+{
+ // A compose request for a message that is not in the index used to stay
+ // armed for ever, because it was cleared only on the branch that FOUND the
+ // id. The delayed symptom is the bad one: the pane's own loads are the
+ // traffic being matched against, so merely selecting that message later
+ // matched, opened a composer nobody asked for, and returned before
+ // renderMessages() leaving the pane blank on the row just clicked.
+ WorkerComposeFixture fixture;
+ QVERIFY2(fixture.seed({ { QStringLiteral("work"), QStringLiteral("work"),
+ QString(), QStringLiteral("/bin/true"),
+ QStringLiteral("you@example.org") } },
+ QStringLiteral("work/inbox")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ QVERIFY(WorkerComposeFixture::selectTheMessage(window));
+
+ // Arm a request for an id the database does not hold. loadMessage() emits
+ // an empty result for it, which is what must disarm the request.
+ window.requestMessageForComposeForTest(
+ QStringLiteral("nosuchmessage@example.org"),
+ ComposeContext::Kind::Reply, true);
+
+ // No composer, and the request stops being armed.
+ QTRY_VERIFY_WITH_TIMEOUT(!window.composeRequestPendingForTest(), 15000);
+ QCOMPARE(window.openComposerCount(), 0);
+
+ // Now the delayed half. Select the real message: the pane must render it,
+ // and no composer may appear. With the request still armed this failed
+ // only if the ids matched, so the request is re-armed for the REAL id to
+ // make the hijack reachable at all.
+ window.requestMessageForComposeForTest(
+ QStringLiteral("compose1@example.org"), ComposeContext::Kind::Reply,
+ true);
+ QTRY_VERIFY_WITH_TIMEOUT(!window.composeRequestPendingForTest(), 15000);
+
+ // That one DID match, so it opened a composer. Close it and clear the
+ // pane, then re-select and assert the pane renders rather than a second
+ // composer opening.
+ for (ComposeWindow *composer : window.openComposersForTest()) {
+ composer->show();
+ composer->close();
+ }
+ QCOMPARE(window.openComposerCount(), 0);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ auto *view = window.findChild<ThreadListView *>();
+ QVERIFY(model && view);
+ view->setCurrentIndex(QModelIndex());
+ view->setCurrentIndex(model->index(0, 0, QModelIndex()));
+
+ auto *pane = window.findChild<MessageView *>();
+ QVERIFY(pane);
+ QTRY_VERIFY_WITH_TIMEOUT(!pane->showingPlaceholder(), 15000);
+ QCOMPARE(window.openComposerCount(), 0);
+}
+
+void TestMainWindow::quittingClosesEveryComposerRatherThanOrphaningIt()
+{
+ // A composer is a parentless top-level window, deliberately: it must appear
+ // in the task switcher and be usable while the main window is. The cost is
+ // that closing the main window does NOT take it down, so quitting left a
+ // composer on screen with no application behind it, and Qt kept the process
+ // alive for it. Reported from a hand test: the main window closed, the
+ // orphan stayed, and its own close then raised the unsaved-edits dialog for
+ // a session the user had already ended.
+ //
+ // The quit path already ASKS about those edits and saves them; what it
+ // never did was close the windows afterwards.
+ WorkerComposeFixture fixture;
+ QVERIFY2(fixture.seed({ { QStringLiteral("work"), QStringLiteral("work"),
+ QString(), QStringLiteral("/bin/true"),
+ QStringLiteral("you@example.org") } },
+ QStringLiteral("work/inbox")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ QTRY_VERIFY_WITH_TIMEOUT(!window.mailRootForTesting().isEmpty(), 15000);
+
+ // Two, so the fix cannot be "close the last one" and pass.
+ QVERIFY2(window.openComposerForTest(), "no composer opened");
+ QVERIFY2(window.openComposerForTest(), "no second composer opened");
+ QCOMPARE(window.openComposerCount(), 2);
+
+ // Clean composers: the point here is the CLOSE, not the unsaved-edits
+ // dialog, which has its own tests and would block this one on a modal.
+ window.show();
+ window.close();
+
+ // deleteLater() is how a composer goes away, so the count settles on the
+ // next event-loop pass rather than synchronously.
+ QTRY_COMPARE_WITH_TIMEOUT(window.openComposerCount(), 0, 5000);
+}
+
+void TestMainWindow::theSaveLoopToleratesAComposerClosedUnderTheDialog()
+{
+ // The regression for a measured use-after-free. composersBlockingQuit()
+ // used to return raw pointers, and the quit path held that list across
+ // QMessageBox::exec(). A nested event loop PROCESSES deleteLater(),
+ // verified in a standalone Qt program: a parentless WA_DeleteOnClose
+ // window closed while a modal is up is destroyed BEFORE exec() returns.
+ // The dialog is window-modal to the main window only, so a user really can
+ // close a composer from under it, and Save then ran on freed memory.
+ //
+ // The modal itself cannot be driven under the offscreen platform, so what
+ // is asserted is the property that makes the loop safe: the list holds
+ // QPointers, and an entry whose window is destroyed reads as null rather
+ // than as a dangling pointer. That is exactly what the null check in the
+ // Save loop consumes. Stated plainly because it is NOT full coverage of
+ // closeEvent(): see the report.
+ WorkerComposeFixture fixture;
+ QVERIFY2(fixture.seed({ { QStringLiteral("work"), QStringLiteral("work"),
+ QString(), QStringLiteral("/bin/true"),
+ QStringLiteral("you@example.org") } },
+ QStringLiteral("work/inbox")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ QTRY_VERIFY_WITH_TIMEOUT(!window.mailRootForTesting().isEmpty(), 15000);
+
+ QVERIFY(window.openComposerForTest());
+ QVERIFY(window.openComposerForTest());
+ window.markComposersDirtyForTest();
+
+ QList<QPointer<ComposeWindow>> blocking = window.composersBlockingQuit();
+ QCOMPARE(blocking.size(), 2);
+
+ // Destroy one exactly as closing it under the dialog would, including the
+ // deleteLater() a nested exec() would process.
+ ComposeWindow *doomed = blocking.first().data();
+ QVERIFY(doomed);
+ doomed->show();
+ doomed->close();
+ QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete);
+
+ // The held list reports it as gone rather than handing back a dangling
+ // pointer. A raw QList<ComposeWindow *> could not express this at all.
+ QVERIFY2(blocking.first().isNull(),
+ "the held entry did not null when its window was destroyed");
+ QVERIFY2(!blocking.last().isNull(),
+ "the surviving composer was lost too");
+
+ // And the loop the quit path runs skips the null and still saves the
+ // survivor, which is the behaviour the crash destroyed: the remaining
+ // drafts were never written because the crash happened mid-loop.
+ int saved = 0;
+ for (const QPointer<ComposeWindow> &composer : blocking) {
+ if (composer) {
+ composer->saveDraftNow();
+ ++saved;
+ }
+ }
+ QCOMPARE(saved, 1);
+}
+
+namespace {
+
+/// Writes a multipart/mixed message with one named attachment part.
+///
+/// Hand-written rather than built with MessageBuilder: this is the INPUT to
+/// the forward path, and generating it with the same library that consumes it
+/// would let an encoding mistake agree with itself.
+bool writeMessageWithAttachment(const QString &path, const QString &attachName,
+ const QByteArray &attachBody)
+{
+ QFile file(path);
+ if (!file.open(QIODevice::WriteOnly))
+ return false;
+ QByteArray raw =
+ "From: sender@example.org\n"
+ "To: you@example.org\n"
+ "Subject: Quarterly report\n"
+ "Message-ID: <fwd-1@example.org>\n"
+ // Friday, verified with `date -d 2026-08-14 +%A`. Qt::RFC2822Date
+ // validates the weekday against the date.
+ "Date: Fri, 14 Aug 2026 10:00:00 +0200\n"
+ "MIME-Version: 1.0\n"
+ "Content-Type: multipart/mixed; boundary=\"MIX\"\n"
+ "\n"
+ "--MIX\n"
+ "Content-Type: text/plain; charset=utf-8\n"
+ "\n"
+ "See the attached document.\n"
+ "--MIX\n"
+ "Content-Type: application/octet-stream; name=\"" + attachName.toUtf8() + "\"\n"
+ "Content-Disposition: attachment; filename=\"" + attachName.toUtf8() + "\"\n"
+ "\n" + attachBody + "\n"
+ "--MIX--\n";
+ file.write(raw);
+ file.close();
+ return true;
+}
+
+/// Writes a multipart/alternative message that DOES carry a text/html part.
+bool writeHtmlMessage(const QString &path)
+{
+ QFile file(path);
+ if (!file.open(QIODevice::WriteOnly))
+ return false;
+ file.write(
+ "From: sender@example.org\n"
+ "To: you@example.org\n"
+ "Subject: Has HTML\n"
+ "Message-ID: <html-1@example.org>\n"
+ "Date: Fri, 14 Aug 2026 10:00:00 +0200\n"
+ "MIME-Version: 1.0\n"
+ "Content-Type: multipart/alternative; boundary=\"ALT\"\n"
+ "\n"
+ "--ALT\n"
+ "Content-Type: text/plain; charset=utf-8\n"
+ "\n"
+ "plain\n"
+ "--ALT\n"
+ "Content-Type: text/html; charset=utf-8\n"
+ "\n"
+ "<p>html</p>\n"
+ "--ALT--\n");
+ file.close();
+ return true;
+}
+
+} // namespace
+
+void TestMainWindow::forwardingCarriesTheOriginalsAttachments()
+{
+ // The spec requires Forward to carry attachments, twice. The context field
+ // existed and was never assigned, so a Forward opened with an empty
+ // attachment list: the composer looked entirely correct, and the recipient
+ // received a body quoting a document that was not attached, with nothing
+ // erroring anywhere.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ const QString original = dir.filePath(QStringLiteral("original.eml"));
+ QVERIFY(writeMessageWithAttachment(original, QStringLiteral("report.pdf"),
+ QByteArray("PDFBYTES")));
+
+ QTemporaryDir confDir;
+ QVERIFY(confDir.isValid());
+ const QString confPath = confDir.filePath(QStringLiteral("qtmaildir.conf"));
+ {
+ QSettings settings(confPath, QSettings::IniFormat);
+ settings.beginGroup(QStringLiteral("account.work"));
+ settings.setValue(QStringLiteral("maildir"), QStringLiteral("work"));
+ settings.setValue(QStringLiteral("address"),
+ QStringLiteral("you@example.org"));
+ settings.setValue(QStringLiteral("send_command"),
+ QStringLiteral("/bin/true"));
+ settings.endGroup();
+ settings.sync();
+ }
+ Config config;
+ config.load(confPath);
+
+ ComposeContext context;
+ context.kind = ComposeContext::Kind::Forward;
+ context.accountKey = QStringLiteral("work");
+ context.originalPath = original;
+ context.subject = QStringLiteral("Fwd: Quarterly report");
+
+ ComposeWindow composer(context, config, dir.path());
+
+ // The attachment is present, and it is a REAL FILE on disk rather than a
+ // remembered name: MessageBuilder reads every attachment by path at build
+ // time and refuses a build naming one that does not exist.
+ const QStringList attached = composer.attachments();
+ QCOMPARE(attached.size(), 1);
+ QVERIFY2(QFileInfo::exists(attached.first()),
+ qPrintable(QStringLiteral("the extracted path does not exist: %1")
+ .arg(attached.first())));
+ QCOMPARE(QFileInfo(attached.first()).fileName(),
+ QStringLiteral("report.pdf"));
+
+ // And the bytes are the original's, not an empty placeholder.
+ QFile written(attached.first());
+ QVERIFY(written.open(QIODevice::ReadOnly));
+ QCOMPARE(written.readAll(), QByteArray("PDFBYTES"));
+ written.close();
+
+ // A Reply to the same message carries NOTHING. The spec says attachments
+ // are carried "for Forward, empty otherwise", and a reply that re-attached
+ // the original's documents would send them back to their own sender.
+ ComposeContext replyContext = context;
+ replyContext.kind = ComposeContext::Kind::Reply;
+ ComposeWindow replyComposer(replyContext, config, dir.path());
+ QVERIFY2(replyComposer.attachments().isEmpty(),
+ "a reply carried the original's attachments");
+}
+
+void TestMainWindow::forwardSeedsHtmlFromTheConfigNotTheOriginal()
+{
+ // MEASURED, and it revises what the spec review reported. Forward was
+ // NEVER seeding from the original: ComposeWindow::seedFields() already
+ // implements the split itself (composewindow.cpp, `isReply ?
+ // m_context.seedHtml : m_config.compose().sendHtml`), so the context's
+ // value is IGNORED for a forward and the config won regardless. The
+ // openComposerFor() line this test also covers was therefore cosmetic
+ // rather than a live defect: it stopped the context carrying a value that
+ // nothing read, which is worth doing but changed no behaviour.
+ //
+ // The consequence for this test: EITHER layer alone enforces the rule, so
+ // neither single-layer mutation fails it, and only mutating both does.
+ // Verified in both directions rather than assumed.
+ //
+ // The spec splits these: New and Forward seed from [compose] send_html,
+ // Reply and Reply-all from whether the original carried a text/html part.
+ // An HTML part in the original is a fact about the SENDER's software, so
+ // it is the right seed when answering them and says nothing about a
+ // forward, which is a new message to somebody else.
+ //
+ // Asserted on the CONTEXT the window is built from rather than through the
+ // checkbox, because what is under test is which source the value comes
+ // from. The two sources must DISAGREE or the test passes either way: the
+ // config says false while the original is plain text, so reading the
+ // original would give false as well. Hence send_html=true against a plain
+ // original: config true, original false.
+ // The two sources must DISAGREE or the test passes whichever one is read,
+ // and getting that wrong is why an earlier version of this survived every
+ // mutation: config send_html=FALSE against an original that DOES carry a
+ // text/html part. Reading the original gives true, reading the config
+ // gives false, so the assertion below can only be satisfied one way.
+ WorkerComposeFixture fixture;
+ QVERIFY2(fixture.seed({ { QStringLiteral("work"), QStringLiteral("work"),
+ QString(), QStringLiteral("/bin/true"),
+ QStringLiteral("you@example.org") } },
+ QStringLiteral("work/inbox"),
+ QStringLiteral("send_html=false")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ QTRY_VERIFY_WITH_TIMEOUT(!window.mailRootForTesting().isEmpty(), 15000);
+ QCOMPARE(fixture.backed.config().compose().sendHtml, false);
+
+ // The original lives inside the account's maildir so accountForReply()
+ // can resolve it; its CONTENT is what matters, not that notmuch indexed it.
+ const QString original =
+ QDir(window.mailRootForTesting())
+ .absoluteFilePath(QStringLiteral("work/inbox/cur/fwd-original"));
+ QVERIFY(writeHtmlMessage(original));
+
+ MimeParser parser;
+ const ParsedMessage parsed = parser.parse(original);
+ QVERIFY(parsed.ok);
+ QCOMPARE(parsed.hasHtml(), true);
+
+ // Through openComposerFor(), which is the production line that chooses
+ // the source. Building the context by hand here and asserting on the
+ // checkbox proved only that ComposeWindow honours what it is given: the
+ // mutation putting `original.hasHtml()` back stayed green, because the
+ // test was setting seedHtml itself.
+ MessageRef ref;
+ ref.messageId = QStringLiteral("html-1@example.org");
+ ref.filePath = original;
+ ref.matched = true;
+
+ window.openComposerForTest(ref, ComposeContext::Kind::Forward, true);
+
+ QList<ComposeWindow *> opened = window.openComposersForTest();
+ QCOMPARE(opened.size(), 1);
+ auto *sendHtml =
+ opened.first()->findChild<QCheckBox *>(QStringLiteral("sendHtml"));
+ QVERIFY(sendHtml);
+ QVERIFY2(!sendHtml->isChecked(),
+ "Forward seeded sendHtml from the original's HTML part rather "
+ "than from [compose] send_html");
+
+ // The counterpart, and it is what stops this asserting "always false":
+ // a REPLY to the same message seeds from the original, so it is checked
+ // where the forward is not. Without this half, disabling the checkbox
+ // outright would pass.
+ window.openComposerForTest(ref, ComposeContext::Kind::Reply, true);
+ const QList<ComposeWindow *> both = window.openComposersForTest();
+ QCOMPARE(both.size(), 2);
+ auto *replyHtml =
+ both.last()->findChild<QCheckBox *>(QStringLiteral("sendHtml"));
+ QVERIFY(replyHtml);
+ QVERIFY2(replyHtml->isChecked(),
+ "Reply did not seed sendHtml from the original's HTML part");
+
+ for (ComposeWindow *composer : both) {
+ composer->show();
+ composer->close();
+ }
+}
+
void TestMainWindow::aStartupAccountScopesTheStartupQuery()
{
// "Start me in Work - Inbox rather than All accounts - Inbox." The account
@@ -10583,4 +11801,1120 @@ void TestMainWindow::deletingOutsideTheTrashViewLeavesTheRowInPlace()
QCOMPARE(model->rowCount(QModelIndex()), 1);
}
+// ---------------------------------------------------------------------------
+// ComposeWindow, item 123.
+//
+// The composer owns widgets and nothing else here does, which is why its cases
+// live in this file. What is asserted is deliberately NOT what it looks like:
+// the autosave dirty check, the banner state, the message its widgets produce,
+// the format edits and the seeding rules, all of which are observable without
+// a painter. CLAUDE.md's "Rendering probes lie" section covers why counting
+// pixels here would prove nothing.
+// ---------------------------------------------------------------------------
+
+namespace {
+
+/// A Config written to a temporary INI, plus a Maildir root to write into.
+///
+/// No notmuch database and no worker: the composer never touches
+/// NotmuchWorker, so building one would only cost every case a `notmuch new`.
+/// The mail root is passed to ComposeWindow explicitly, exactly as MainWindow
+/// passes what the worker reported (item 124: it is NOT database.path).
+class ComposeFixture
+{
+public:
+ /// `drafts` and `sent` are written only when non-empty, so a test can
+ /// build the account-without-a-drafts-folder case by passing an empty
+ /// string rather than by needing a second fixture.
+ /// `secondAccount` writes a SECOND sending account, which is what makes
+ /// the From dropdown have something to choose between. Off by default:
+ /// every other case here wants exactly one, so a two-account fixture
+ /// everywhere would let a test pass by picking the only entry there is.
+ bool build(const QString &drafts = QStringLiteral("Drafts"),
+ const QString &sent = QStringLiteral("Sent"),
+ const QString &extraCompose = QString(),
+ bool secondAccount = false)
+ {
+ if (!m_confDir.isValid() || !m_mailDir.isValid())
+ return false;
+
+ const QString path = m_confDir.filePath(QStringLiteral("qtmaildir.conf"));
+ QFile file(path);
+ if (!file.open(QIODevice::WriteOnly | QIODevice::Text))
+ return false;
+ {
+ QTextStream out(&file);
+ // QSettings reads `/` in a section name as a group separator, so
+ // the section is [account.acct], never [account/acct].
+ out << "[account.acct]\n"
+ << "name=Test User\n"
+ << "address=user@example.org\n"
+ << "maildir=acct\n"
+ << "trash=Trash\n";
+ if (!drafts.isEmpty())
+ out << "drafts=" << drafts << "\n";
+ if (!sent.isEmpty())
+ out << "sent=" << sent << "\n";
+ // A command that exists and does nothing. canSend() is what the
+ // From dropdown filters on, so an account without this one line
+ // would not appear in it at all.
+ out << "send_command=/bin/true\n";
+ if (secondAccount) {
+ out << "\n[account.other]\n"
+ << "name=Other User\n"
+ << "address=other@example.org\n"
+ << "maildir=other\n"
+ << "trash=Trash\n"
+ << "drafts=Drafts\n"
+ << "sent=Sent\n"
+ << "send_command=/bin/true\n";
+ }
+ out << "\n[compose]\n";
+ if (!extraCompose.isEmpty())
+ out << extraCompose << "\n";
+ }
+ file.close();
+
+ m_config.load(path);
+ return true;
+ }
+
+ const Config &config() const { return m_config; }
+ QString mailRoot() const { return m_mailDir.path(); }
+
+ /// The account's drafts folder, as the composer will resolve it.
+ QString draftsCur() const
+ {
+ return m_mailDir.path() + QStringLiteral("/acct/Drafts/cur");
+ }
+
+ /// The second account's drafts folder.
+ QString otherDraftsCur() const
+ {
+ return m_mailDir.path() + QStringLiteral("/other/Drafts/cur");
+ }
+
+ /// How many message files sit in the drafts folder.
+ int draftCount() const
+ {
+ return QDir(draftsCur(), {}, QDir::Name, QDir::Files).count();
+ }
+
+private:
+ QTemporaryDir m_confDir;
+ QTemporaryDir m_mailDir;
+ Config m_config;
+};
+
+/// A minimal New-message context for the fixture's one account.
+ComposeContext newContext()
+{
+ ComposeContext context;
+ context.accountKey = QStringLiteral("acct");
+ context.kind = ComposeContext::Kind::New;
+ return context;
+}
+
+} // namespace
+
+void TestMainWindow::aComposerOpensClean()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+
+ // Seeding fills every field, which emits every field's change signal. A
+ // composer that counted those as edits would autosave a draft nobody
+ // asked for, and would tell the quit path there is unsaved work in a
+ // window the user opened and closed without typing.
+ QVERIFY(!window.hasUnsavedEdits());
+ QVERIFY(!window.lastSaveFailed());
+
+ auto *timer = window.findChild<QTimer *>(QStringLiteral("autosave"));
+ QVERIFY2(timer, "no autosave timer: the window was never built");
+ QVERIFY2(!timer->isActive(),
+ "seeding armed the autosave timer, so a untouched composer writes");
+}
+
+void TestMainWindow::typingMarksTheComposerDirty()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+
+ QVERIFY(!window.hasUnsavedEdits());
+ body->setPlainText(QStringLiteral("Some text."));
+ QVERIFY(window.hasUnsavedEdits());
+
+ // The subject is part of the message as much as the body is: a draft that
+ // saved the body but not the address it was going to would be worse than
+ // none.
+ ComposeWindow second(newContext(), fixture.config(), fixture.mailRoot());
+ auto *subject = second.findChild<QLineEdit *>(QStringLiteral("subject"));
+ QVERIFY(subject);
+ QVERIFY(!second.hasUnsavedEdits());
+ subject->setText(QStringLiteral("A subject"));
+ QVERIFY(second.hasUnsavedEdits());
+}
+
+void TestMainWindow::anAutosaveWritesADraftAndClearsTheDirtyFlag()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ body->setPlainText(QStringLiteral("Draft body."));
+ QVERIFY(window.hasUnsavedEdits());
+
+ QVERIFY2(window.saveDraftNow(), "the draft write reported failure");
+
+ QCOMPARE(fixture.draftCount(), 1);
+ QVERIFY2(!window.hasUnsavedEdits(),
+ "the flag survived a successful save, so the quit path would ask");
+ QVERIFY(!window.lastSaveFailed());
+
+ // The bytes really are the message, not an empty file: the draft is
+ // byte-identical to what would be sent, which is the property the one
+ // built message exists for.
+ const QStringList files =
+ QDir(fixture.draftsCur(), {}, QDir::Name, QDir::Files).entryList();
+ QCOMPARE(files.size(), 1);
+ QFile written(fixture.draftsCur() + QLatin1Char('/') + files.first());
+ QVERIFY(written.open(QIODevice::ReadOnly));
+ const QByteArray bytes = written.readAll();
+ QVERIFY2(bytes.contains("Draft body."), "the draft does not carry the body");
+ // Written with the Maildir draft flag, not left bare.
+ QVERIFY2(files.first().endsWith(QStringLiteral(":2,D")),
+ qPrintable(QStringLiteral("wrong maildir flags: ") + files.first()));
+}
+
+void TestMainWindow::anUnwritableDraftsFolderRaisesThePersistentBanner()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ body->setPlainText(QStringLiteral("Draft body."));
+
+ // A FILE where the folder must go. mkpath then fails, which is a real
+ // failure mode and needs no permission games that root would defeat.
+ const QString accountDir = fixture.mailRoot() + QStringLiteral("/acct");
+ QVERIFY(QDir().mkpath(accountDir));
+ QFile blocker(accountDir + QStringLiteral("/Drafts"));
+ QVERIFY(blocker.open(QIODevice::WriteOnly));
+ blocker.write("not a directory");
+ blocker.close();
+
+ QVERIFY2(!window.saveDraftNow(), "an unwritable folder reported success");
+
+ auto *banner = window.findChild<QLabel *>(QStringLiteral("draftBanner"));
+ QVERIFY2(banner, "no banner widget");
+ QVERIFY2(!banner->text().isEmpty(), "the banner says nothing");
+ QVERIFY2(window.lastSaveFailed(),
+ "lastSaveFailed() is false after a failed write, so the quit "
+ "path would let the text go");
+ QVERIFY2(window.hasUnsavedEdits(),
+ "a failed save cleared the dirty flag, which claims the text is "
+ "safe on disk when it is not");
+}
+
+void TestMainWindow::aSuccessfulSaveClearsTheBanner()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ body->setPlainText(QStringLiteral("First."));
+
+ const QString accountDir = fixture.mailRoot() + QStringLiteral("/acct");
+ QVERIFY(QDir().mkpath(accountDir));
+ QFile blocker(accountDir + QStringLiteral("/Drafts"));
+ QVERIFY(blocker.open(QIODevice::WriteOnly));
+ blocker.close();
+
+ QVERIFY(!window.saveDraftNow());
+ QVERIFY(window.lastSaveFailed());
+
+ // Remove the obstruction and save again. The banner must go: a warning
+ // that stays after the thing it warned about is fixed teaches the user to
+ // ignore warnings, which is the second lesson in the TagRules entry.
+ QVERIFY(QFile::remove(accountDir + QStringLiteral("/Drafts")));
+ body->setPlainText(QStringLiteral("Second."));
+
+ QVERIFY2(window.saveDraftNow(), "the retry failed");
+ QVERIFY2(!window.lastSaveFailed(), "lastSaveFailed() stayed set");
+
+ auto *banner = window.findChild<QLabel *>(QStringLiteral("draftBanner"));
+ QVERIFY(banner);
+ QVERIFY2(banner->isHidden(), "the banner is still up after a good save");
+}
+
+void TestMainWindow::anAccountWithoutADraftsFolderReportsNoFailure()
+{
+ ComposeFixture fixture;
+ // No drafts key at all: a real configuration, warned about at startup.
+ QVERIFY(fixture.build(QString()));
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ body->setPlainText(QStringLiteral("Nowhere to save this."));
+
+ // Nothing was written and nothing failed. Reporting a failure here would
+ // make the quit path offer a retry for a state no retry can change.
+ QVERIFY2(window.saveDraftNow(),
+ "a missing drafts folder was reported as a save failure");
+ QVERIFY2(!window.lastSaveFailed(), "the banner state was set");
+
+ auto *banner = window.findChild<QLabel *>(QStringLiteral("draftBanner"));
+ QVERIFY(banner);
+ QVERIFY(banner->isHidden());
+}
+
+void TestMainWindow::aRewrittenDraftUnlinksThePreviousRevision()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+
+ body->setPlainText(QStringLiteral("Revision one."));
+ QVERIFY(window.saveDraftNow());
+ QCOMPARE(fixture.draftCount(), 1);
+
+ body->setPlainText(QStringLiteral("Revision two."));
+ QVERIFY(window.saveDraftNow());
+
+ // ONE file, not two. Maildir has no in-place edit, so a draft rewritten
+ // every thirty seconds would otherwise accumulate one file per pause, and
+ // every one of them is a message mbsync uploads.
+ QCOMPARE(fixture.draftCount(), 1);
+
+ const QStringList files =
+ QDir(fixture.draftsCur(), {}, QDir::Name, QDir::Files).entryList();
+ QFile written(fixture.draftsCur() + QLatin1Char('/') + files.first());
+ QVERIFY(written.open(QIODevice::ReadOnly));
+ const QByteArray bytes = written.readAll();
+ QVERIFY2(bytes.contains("Revision two."), "the surviving file is the old one");
+}
+
+void TestMainWindow::theComposerBuildsTheMessageItsWidgetsShow()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeContext context = newContext();
+ context.kind = ComposeContext::Kind::Reply;
+ context.inReplyTo = QStringLiteral("original@example.org");
+ context.references = { QStringLiteral("root@example.org"),
+ QStringLiteral("original@example.org") };
+ context.to = { QStringLiteral("one@example.org") };
+ context.subject = QStringLiteral("Re: a subject");
+
+ ComposeWindow window(context, fixture.config(), fixture.mailRoot());
+
+ auto *cc = window.findChild<QLineEdit *>(QStringLiteral("cc"));
+ auto *bcc = window.findChild<QLineEdit *>(QStringLiteral("bcc"));
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(cc && bcc && body);
+
+ // A field the user typed, split on commas. That is wrong for a RAW header
+ // and right here: this is the composer's own rendering, which joins with
+ // ", ".
+ cc->setText(QStringLiteral("two@example.org, three@example.org"));
+ bcc->setText(QStringLiteral(" four@example.org "));
+ body->setPlainText(QStringLiteral("The body."));
+
+ const OutgoingMessage message = window.currentMessage();
+ QCOMPARE(message.accountKey, QStringLiteral("acct"));
+ QCOMPARE(message.to, QStringList{ QStringLiteral("one@example.org") });
+ QCOMPARE(message.cc, (QStringList{ QStringLiteral("two@example.org"),
+ QStringLiteral("three@example.org") }));
+ // Trimmed, or the whitespace reaches the wire as part of the address.
+ QCOMPARE(message.bcc, QStringList{ QStringLiteral("four@example.org") });
+ QCOMPARE(message.subject, QStringLiteral("Re: a subject"));
+ QCOMPARE(message.markdownBody, QStringLiteral("The body."));
+
+ // NOT optional. Without them a reply appears as an orphan thread in the
+ // sender's own client, which is invisible locally.
+ QCOMPARE(message.inReplyTo, QStringLiteral("original@example.org"));
+ QCOMPARE(message.references.size(), 2);
+ QCOMPARE(message.references.last(), QStringLiteral("original@example.org"));
+}
+
+void TestMainWindow::theFromDropdownDecidesWhichAccountSends()
+{
+ // TWO sending accounts, because a dropdown with one entry cannot be
+ // changed and a test against it passes whether the code reads the dropdown
+ // or the context. The first revision of this test did exactly that: it
+ // asserted count() == 1 and then re-asserted a property another case
+ // already covers, and a mutation making currentAccount() read
+ // m_context.accountKey survived it.
+ ComposeFixture fixture;
+ QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QString(), /*secondAccount=*/true));
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+ auto *from = window.findChild<QComboBox *>(QStringLiteral("from"));
+ QVERIFY2(from, "no From dropdown");
+
+ // Both sending accounts are offered, seeded to the context's.
+ QCOMPARE(from->count(), 2);
+ QCOMPARE(from->currentData().toString(), QStringLiteral("acct"));
+ QCOMPARE(window.currentMessage().accountKey, QStringLiteral("acct"));
+
+ // Now change it. The dropdown is the authority once the window is open:
+ // reading the context here would send from the seeded account while the
+ // interface said otherwise.
+ const int other = from->findData(QStringLiteral("other"));
+ QVERIFY2(other >= 0, "the second account is not in the dropdown");
+ from->setCurrentIndex(other);
+
+ QCOMPARE(window.currentMessage().accountKey, QStringLiteral("other"));
+
+ // And the choice reaches the DRAFT's destination, not just the value:
+ // a draft is written into the sending account's own folder, so a composer
+ // that read the context would file it under the wrong account.
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ body->setPlainText(QStringLiteral("From the other account."));
+ QVERIFY(window.saveDraftNow());
+
+ QCOMPARE(QDir(fixture.otherDraftsCur(), {}, QDir::Name, QDir::Files).count(),
+ 1u);
+ QCOMPARE(QDir(fixture.draftsCur(), {}, QDir::Name, QDir::Files).count(), 0u);
+}
+
+void TestMainWindow::aFormatEditPreservesTheUndoStack()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+
+ // Typed through a cursor, which is what makes it an undoable edit;
+ // setPlainText() would not be one.
+ QTextCursor typing = body->textCursor();
+ typing.insertText(QStringLiteral("hello"));
+ QVERIFY(body->document()->isUndoAvailable());
+
+ QTextCursor selection = body->textCursor();
+ selection.setPosition(0);
+ selection.setPosition(5, QTextCursor::KeepAnchor);
+ body->setTextCursor(selection);
+
+ auto *bold = window.findChild<QAction *>(QStringLiteral("format_bold"));
+ QVERIFY2(bold, "no bold action");
+ bold->trigger();
+
+ QCOMPARE(body->toPlainText(), QStringLiteral("**hello**"));
+
+ // The property the plan's setPlainText() draft would have lost. Measured
+ // in a standalone probe: setPlainText() takes isUndoAvailable from true to
+ // false, so every toolbar press would throw away everything the user could
+ // undo.
+ QVERIFY2(body->document()->isUndoAvailable(),
+ "the format edit destroyed the undo stack");
+
+ // And it is ONE undo step, not one per character: a whole-document
+ // replacement inside an edit block collapses to a single entry, so one
+ // Ctrl+Z takes the tokens off and leaves the typed word.
+ body->undo();
+ QCOMPARE(body->toPlainText(), QStringLiteral("hello"));
+}
+
+void TestMainWindow::aFormatEditRestoresTheSelectionItAsksFor()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ body->setPlainText(QStringLiteral("hello world"));
+
+ // A BACKWARDS selection, anchor after the cursor, which is what a
+ // right-to-left drag produces and an ordinary gesture. Measured against a
+ // real widget: selectionStart()/selectionEnd() come back normalised even
+ // then, so the anchor's side does not reach MarkdownFormat.
+ QTextCursor selection = body->textCursor();
+ selection.setPosition(5);
+ selection.setPosition(0, QTextCursor::KeepAnchor);
+ body->setTextCursor(selection);
+ QCOMPARE(body->textCursor().selectionStart(), 0);
+ QCOMPARE(body->textCursor().selectionEnd(), 5);
+
+ auto *italic = window.findChild<QAction *>(QStringLiteral("format_italic"));
+ QVERIFY(italic);
+ italic->trigger();
+
+ QCOMPARE(body->toPlainText(), QStringLiteral("*hello* world"));
+
+ // The selection is preserved precisely so a second press can apply a
+ // SECOND token to the same words, bold then italic without reselecting.
+ QCOMPARE(body->textCursor().selectedText(), QStringLiteral("hello"));
+
+ auto *bold = window.findChild<QAction *>(QStringLiteral("format_bold"));
+ QVERIFY(bold);
+ bold->trigger();
+ QCOMPARE(body->toPlainText(), QStringLiteral("***hello*** world"));
+}
+
+void TestMainWindow::aFormatEditOnAnEmptySelectionLandsBetweenTheTokens()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ body->setPlainText(QStringLiteral("ab"));
+
+ QTextCursor cursor = body->textCursor();
+ cursor.setPosition(1);
+ body->setTextCursor(cursor);
+
+ auto *bold = window.findChild<QAction *>(QStringLiteral("format_bold"));
+ QVERIFY(bold);
+ bold->trigger();
+
+ QCOMPARE(body->toPlainText(), QStringLiteral("a****b"));
+
+ // The property a user notices immediately when it is wrong, and the one
+ // invisible to a test that only compares the resulting text: typing must
+ // continue INSIDE the pair, not after it.
+ QCOMPARE(body->textCursor().position(), 3);
+ QVERIFY(!body->textCursor().hasSelection());
+
+ QTextCursor typing = body->textCursor();
+ typing.insertText(QStringLiteral("x"));
+ QCOMPARE(body->toPlainText(), QStringLiteral("a**x**b"));
+}
+
+void TestMainWindow::theAttachmentWarningRespectsTheConfiguredThreshold()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("attachment_warn_bytes=1000")));
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+
+ // The threshold, not the modal. The question itself needs a user, so what
+ // is asserted is the predicate that decides whether to ask.
+ QVERIFY2(!window.attachmentNeedsWarning(999), "warned below the limit");
+ QVERIFY2(!window.attachmentNeedsWarning(1000),
+ "warned AT the limit, which is not above it");
+ QVERIFY2(window.attachmentNeedsWarning(1001), "did not warn above the limit");
+}
+
+void TestMainWindow::aDisabledAttachmentWarningWarnsAboutNothing()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("attachment_warn_bytes=0")));
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+
+ // Zero means off, not "warn about everything". Read as a threshold it
+ // would question an empty file, which is the opposite of what turning a
+ // warning off means.
+ QVERIFY(!window.attachmentNeedsWarning(0));
+ QVERIFY(!window.attachmentNeedsWarning(1));
+ QVERIFY(!window.attachmentNeedsWarning(100LL * 1024 * 1024));
+}
+
+void TestMainWindow::theQuotePositionDecidesWhereTheQuoteLands()
+{
+ const QString quote = QStringLiteral("> the original");
+
+ {
+ ComposeFixture above;
+ QVERIFY(above.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("quote_position=above")));
+ ComposeContext context = newContext();
+ context.kind = ComposeContext::Kind::Reply;
+ context.quotedBody = quote;
+
+ ComposeWindow window(context, above.config(), above.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ QVERIFY2(body->toPlainText().startsWith(quote),
+ "quote_position=above did not put the quote first");
+ }
+
+ {
+ ComposeFixture below;
+ QVERIFY(below.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("quote_position=below")));
+ ComposeContext context = newContext();
+ context.kind = ComposeContext::Kind::Reply;
+ context.quotedBody = quote;
+
+ ComposeWindow window(context, below.config(), below.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ QVERIFY2(body->toPlainText().endsWith(quote),
+ "quote_position=below did not put the quote last");
+ QVERIFY2(!body->toPlainText().startsWith(quote),
+ "the quote is at the top under quote_position=below");
+ }
+}
+
+void TestMainWindow::theSeededQuoteIsNotAnUndoStep()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeContext context = newContext();
+ context.kind = ComposeContext::Kind::Reply;
+ context.quotedBody = QStringLiteral("> the original");
+
+ ComposeWindow window(context, fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ QVERIFY(!body->toPlainText().isEmpty());
+
+ // The seeded quote is not an edit the user made. One Ctrl+Z on a fresh
+ // composer must not wipe it, which reads as the buffer losing its content.
+ //
+ // Worth knowing before judging this test dead weight: removing
+ // clearUndoRedoStacks() alone leaves it GREEN, because setPlainText()
+ // already leaves undo unavailable. The line it guards becomes load-bearing
+ // the moment seedBody() stops using setPlainText, which is a change with
+ // reasons to happen: applyEdit() switched to a QTextCursor replacement for
+ // exactly the undo-stack property this asserts, and a later revision
+ // seeding the quote the same way would put it on the stack. The combined
+ // mutation (seed through a cursor AND drop the clear) does kill this.
+ QVERIFY2(!body->document()->isUndoAvailable(),
+ "the seeded quote is on the undo stack");
+}
+
+void TestMainWindow::aReplySeedsTheHtmlToggleFromTheOriginal()
+{
+ ComposeFixture fixture;
+ // Config says yes; the original says no. The original wins for a reply:
+ // an HTML part in it is a fact about the sender's software, not a guess
+ // about their taste.
+ QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("send_html=true")));
+
+ ComposeContext context = newContext();
+ context.kind = ComposeContext::Kind::Reply;
+ context.seedHtml = false;
+
+ ComposeWindow window(context, fixture.config(), fixture.mailRoot());
+ auto *toggle = window.findChild<QCheckBox *>(QStringLiteral("sendHtml"));
+ QVERIFY2(toggle, "no send-html toggle");
+ QVERIFY2(!toggle->isChecked(),
+ "a reply seeded from config rather than from the original");
+
+ // And the other way round, so the test cannot pass by always reading
+ // false: a plain-text config with an HTML original still offers HTML.
+ ComposeFixture plain;
+ QVERIFY(plain.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("send_html=false")));
+ ComposeContext htmlReply = newContext();
+ htmlReply.kind = ComposeContext::Kind::ReplyAll;
+ htmlReply.seedHtml = true;
+
+ ComposeWindow second(htmlReply, plain.config(), plain.mailRoot());
+ auto *secondToggle =
+ second.findChild<QCheckBox *>(QStringLiteral("sendHtml"));
+ QVERIFY(secondToggle);
+ QVERIFY2(secondToggle->isChecked(),
+ "a reply-all ignored an HTML original");
+}
+
+void TestMainWindow::aNewMessageSeedsTheHtmlToggleFromConfig()
+{
+ ComposeFixture off;
+ QVERIFY(off.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("send_html=false")));
+
+ // seedHtml is deliberately TRUE here and must be ignored: a New message
+ // has no original to take evidence from, so a composer reading it would be
+ // reading a field nothing filled in.
+ ComposeContext context = newContext();
+ context.seedHtml = true;
+
+ ComposeWindow window(context, off.config(), off.mailRoot());
+ auto *toggle = window.findChild<QCheckBox *>(QStringLiteral("sendHtml"));
+ QVERIFY(toggle);
+ QVERIFY2(!toggle->isChecked(), "a New message ignored [compose] send_html");
+
+ ComposeFixture on;
+ QVERIFY(on.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("send_html=true")));
+ ComposeContext forward = newContext();
+ forward.kind = ComposeContext::Kind::Forward;
+ forward.seedHtml = false;
+
+ ComposeWindow second(forward, on.config(), on.mailRoot());
+ auto *secondToggle =
+ second.findChild<QCheckBox *>(QStringLiteral("sendHtml"));
+ QVERIFY(secondToggle);
+ QVERIFY2(secondToggle->isChecked(),
+ "a Forward seeded from the original rather than from config");
+}
+
+void TestMainWindow::disablingInputsCoversEveryFieldAndTheToolbar()
+{
+ ComposeFixture fixture;
+ // Zero delay: the countdown is skipped and the send commits at once, which
+ // is the state the inputs must already be disabled in.
+ QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("send_delay_ms=0")));
+
+ ComposeContext context = newContext();
+ context.to = { QStringLiteral("someone@example.org") };
+
+ // Heap-allocated and tracked with a QPointer, because ComposeWindow sets
+ // WA_DeleteOnClose and this case really does complete a send: the window
+ // deletes itself on the way out, so a stack instance would be destroyed
+ // twice. Every other case here stays on the stack, since none of them
+ // closes.
+ QPointer<ComposeWindow> window =
+ new ComposeWindow(context, fixture.config(), fixture.mailRoot());
+ auto *body = window->findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ body->setPlainText(QStringLiteral("Text."));
+
+ auto *toolbar = window->findChild<QToolBar *>(QStringLiteral("formatToolbar"));
+ auto *to = window->findChild<QLineEdit *>(QStringLiteral("to"));
+ auto *subject = window->findChild<QLineEdit *>(QStringLiteral("subject"));
+ auto *from = window->findChild<QComboBox *>(QStringLiteral("from"));
+ auto *toggle = window->findChild<QCheckBox *>(QStringLiteral("sendHtml"));
+ QVERIFY(toolbar && to && subject && from && toggle);
+
+ QVERIFY(to->isEnabled());
+ QVERIFY(!body->isReadOnly());
+
+ auto *sendAction = window->findChild<QAction *>(QStringLiteral("compose_send"));
+ QVERIFY2(sendAction, "no send action");
+ sendAction->trigger();
+
+ // The message must not change between pressing Send and the bytes being
+ // built, so every input goes down for the WHOLE operation, countdown
+ // included. The body is made read-only rather than disabled, so its text
+ // stays selectable and legible while the send runs.
+ QVERIFY2(!to->isEnabled(), "the To field is still editable during a send");
+ QVERIFY2(!subject->isEnabled(), "the subject is still editable");
+ QVERIFY2(!from->isEnabled(), "the account can still be changed");
+ QVERIFY2(!toggle->isEnabled(), "the HTML toggle can still be flipped");
+ QVERIFY2(body->isReadOnly(), "the body is still writable during a send");
+ QVERIFY2(!toolbar->isEnabled(), "the formatting toolbar is still live");
+ auto *attachments =
+ window->findChild<QListWidget *>(QStringLiteral("attachments"));
+ QVERIFY(attachments);
+ QVERIFY2(!attachments->isEnabled(),
+ "the attachment list is still live during a send");
+
+ // /bin/true is the fixture's send command, so the send succeeds and the
+ // composer closes itself: the message went, and holding a composer open
+ // for a message already sent invites sending it twice. Waited on rather
+ // than asserted immediately, since the process is handed to the event loop
+ // and nothing here blocks on it. WA_DeleteOnClose then destroys the
+ // window, which is what the QPointer observes.
+ QTRY_VERIFY_WITH_TIMEOUT(window.isNull(), 15000);
+
+ // And the sent copy really was filed, which is the stage after the send
+ // and the one whose failure the design treats as the worst outcome here.
+ const QString sentCur =
+ fixture.mailRoot() + QStringLiteral("/acct/Sent/cur");
+ QCOMPARE(QDir(sentCur, {}, QDir::Name, QDir::Files).count(), 1u);
+}
+
+void TestMainWindow::aFailedSendCanBeRetriedWithoutFilingTheWrongCopy()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("send_delay_ms=0")));
+
+ // A stub whose outcome is switched by a sentinel file, so ONE configured
+ // command can fail and then succeed. It appends its stdin to a log, which
+ // is what makes the delivery count observable: the defect this guards
+ // against files a sent copy of the FIRST message when the second finishes,
+ // and a receiver count is the only thing that shows it.
+ QTemporaryDir stubDir;
+ QVERIFY(stubDir.isValid());
+ const QString sentinel = stubDir.filePath(QStringLiteral("succeed"));
+ const QString stub = stubDir.filePath(QStringLiteral("send.sh"));
+ {
+ QFile script(stub);
+ QVERIFY(script.open(QIODevice::WriteOnly | QIODevice::Text));
+ QTextStream out(&script);
+ out << "#!/bin/sh\n"
+ << "cat >> " << stubDir.filePath(QStringLiteral("stdin.log")) << "\n"
+ << "[ -f " << sentinel << " ] || { echo 'refused' >&2; exit 1; }\n"
+ << "exit 0\n";
+ }
+ QVERIFY(QFile::setPermissions(
+ stub, QFileDevice::ReadOwner | QFileDevice::WriteOwner
+ | QFileDevice::ExeOwner));
+
+ // A FRESH Config, not a copy of the fixture's reloaded: Config::load()
+ // does not clear what a previous load put there, so a copy keeps the
+ // fixture's /bin/true and this test would silently exercise a command that
+ // always succeeds. Measured, and it produced a green nothing.
+ Config config;
+ {
+ const QString path = QStringLiteral("%1/retry.conf").arg(stubDir.path());
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text));
+ QTextStream out(&file);
+ out << "[account.acct]\n"
+ << "name=Test User\n"
+ << "address=user@example.org\n"
+ << "maildir=acct\n"
+ << "trash=Trash\n"
+ << "drafts=Drafts\n"
+ << "sent=Sent\n"
+ << "send_command=" << stub << "\n"
+ << "\n[compose]\n"
+ << "send_delay_ms=0\n";
+ file.close();
+ config.load(path);
+ }
+
+ ComposeContext context = newContext();
+ context.to = { QStringLiteral("someone@example.org") };
+
+ QPointer<ComposeWindow> window =
+ new ComposeWindow(context, config, fixture.mailRoot());
+ auto *body = window->findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ auto *sendAction = window->findChild<QAction *>(QStringLiteral("compose_send"));
+ QVERIFY(body && sendAction);
+
+ body->setPlainText(QStringLiteral("FIRST attempt."));
+ sendAction->trigger();
+
+ // The failure re-enables the composer intact and shows the stderr; the
+ // window stays open and the draft stays.
+ auto *pane = window->findChild<QWidget *>(QStringLiteral("sendLogPane"));
+ QVERIFY(pane);
+ QTRY_VERIFY_WITH_TIMEOUT(!pane->isHidden(), 15000);
+
+ QVERIFY2(!window.isNull(), "a failed send closed the composer");
+ QVERIFY2(body->isEnabled() && !body->isReadOnly(),
+ "a failed send left the composer disabled");
+
+ // Correct the message and send again, this time succeeding. Without
+ // Qt::SingleShotConnection on the per-send connect, the first send's
+ // lambda is still attached: the second result runs BOTH, and the first
+ // still holds the FIRST message's bytes, so it files a sent copy of the
+ // wrong message and acts on a dialog it already destroyed.
+ QFile marker(sentinel);
+ QVERIFY(marker.open(QIODevice::WriteOnly));
+ marker.close();
+
+ body->setPlainText(QStringLiteral("SECOND attempt."));
+ sendAction->trigger();
+
+ QTRY_VERIFY_WITH_TIMEOUT(window.isNull(), 15000);
+
+ // Exactly ONE sent copy, and it is the second message. Two files, or one
+ // carrying the first attempt, is the accumulated-receiver defect.
+ const QString sentCur = fixture.mailRoot() + QStringLiteral("/acct/Sent/cur");
+ const QStringList filed =
+ QDir(sentCur, {}, QDir::Name, QDir::Files).entryList();
+ QCOMPARE(filed.size(), 1);
+
+ QFile copy(sentCur + QLatin1Char('/') + filed.first());
+ QVERIFY(copy.open(QIODevice::ReadOnly));
+ const QByteArray bytes = copy.readAll();
+ QVERIFY2(bytes.contains("SECOND attempt."),
+ "the filed copy is not the message that was sent");
+ QVERIFY2(!bytes.contains("FIRST attempt."),
+ "the filed copy is the FIRST message, which never went");
+}
+
+void TestMainWindow::anUnchangedMessageIsNotWrittenAgain()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+
+ body->setPlainText(QStringLiteral("Once."));
+ QVERIFY(window.saveDraftNow());
+ QCOMPARE(fixture.draftCount(), 1);
+
+ const QStringList first =
+ QDir(fixture.draftsCur(), {}, QDir::Name, QDir::Files).entryList();
+ QCOMPARE(first.size(), 1);
+
+ // Nothing has changed, so nothing is written. Every autosave produces a
+ // Maildir write that mbsync uploads, so this check and the debounce
+ // together are what keep a message to a few revisions rather than dozens.
+ //
+ // The FILENAME is what shows it: DraftStore always generates a fresh name
+ // and unlinks the previous one, so a redundant write leaves exactly one
+ // file too, and a count alone cannot tell a skipped write from a repeated
+ // one. Two runs of this test asserting only on the count would pass
+ // against no check at all.
+ QVERIFY2(window.saveDraftNow(), "the redundant save reported failure");
+ QCOMPARE(fixture.draftCount(), 1);
+ const QStringList second =
+ QDir(fixture.draftsCur(), {}, QDir::Name, QDir::Files).entryList();
+ QCOMPARE(second, first);
+
+ // And a real change still writes: a check that skipped everything would
+ // pass the assertion above and lose the user's text.
+ body->setPlainText(QStringLiteral("Twice."));
+ QVERIFY(window.saveDraftNow());
+ const QStringList third =
+ QDir(fixture.draftsCur(), {}, QDir::Name, QDir::Files).entryList();
+ QCOMPARE(third.size(), 1);
+ QVERIFY2(third != first, "a changed message was not written");
+}
+
+void TestMainWindow::closingInsideTheDebounceStillSavesTheDraft()
+{
+ ComposeFixture fixture;
+ // A debounce far longer than this test, so the timer provably never fires
+ // and the only thing that can write is the close itself.
+ QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("autosave_interval_ms=600000")));
+
+ // Heap-allocated: WA_DeleteOnClose destroys the window on the way out, so
+ // a stack instance would be destroyed twice.
+ QPointer<ComposeWindow> window =
+ new ComposeWindow(newContext(), fixture.config(), fixture.mailRoot());
+ auto *body = window->findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+
+ body->setPlainText(QStringLiteral("A paragraph typed and not yet saved."));
+ QVERIFY(window->hasUnsavedEdits());
+
+ // The timer has NOT fired. Asserted rather than assumed: if it had, the
+ // draft below would prove nothing about the close path.
+ auto *timer = window->findChild<QTimer *>(QStringLiteral("autosave"));
+ QVERIFY(timer);
+ QVERIFY2(timer->isActive(), "the debounce is not running");
+ QCOMPARE(fixture.draftCount(), 0);
+
+ // The window manager's X button, which is the route that reaches
+ // closeEvent. Typing a paragraph and pressing it inside the debounce
+ // interval must not lose the text.
+ window->close();
+ QTRY_VERIFY_WITH_TIMEOUT(window.isNull(), 5000);
+
+ QCOMPARE(fixture.draftCount(), 1);
+ const QStringList files =
+ QDir(fixture.draftsCur(), {}, QDir::Name, QDir::Files).entryList();
+ QCOMPARE(files.size(), 1);
+ QFile written(fixture.draftsCur() + QLatin1Char('/') + files.first());
+ QVERIFY(written.open(QIODevice::ReadOnly));
+ QVERIFY2(written.readAll().contains("A paragraph typed and not yet saved."),
+ "the close wrote a draft that is not the text that was typed");
+}
+
+void TestMainWindow::closingAfterASendWritesNoFurtherDraft()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("send_delay_ms=0")));
+
+ ComposeContext context = newContext();
+ context.to = { QStringLiteral("someone@example.org") };
+
+ QPointer<ComposeWindow> window =
+ new ComposeWindow(context, fixture.config(), fixture.mailRoot());
+ auto *body = window->findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ auto *sendAction = window->findChild<QAction *>(QStringLiteral("compose_send"));
+ QVERIFY(body && sendAction);
+
+ body->setPlainText(QStringLiteral("Text that is about to be sent."));
+
+ // A draft on disk first, so the send's removal of it is observable and the
+ // close-path save has something it could wrongly put back.
+ QVERIFY(window->saveDraftNow());
+ QCOMPARE(fixture.draftCount(), 1);
+
+ // Now edit again WITHOUT saving, so m_dirty is true at the moment the
+ // send completes. This is what makes the m_finished guard load-bearing:
+ // without it the close that follows a successful send would write a draft
+ // for a message already sent, restoring the file the send just unlinked.
+ body->setPlainText(QStringLiteral("Text that is about to be sent, edited."));
+ QVERIFY(window->hasUnsavedEdits());
+
+ sendAction->trigger();
+ QTRY_VERIFY_WITH_TIMEOUT(window.isNull(), 15000);
+
+ // The message went, so the drafts folder is EMPTY. A draft left behind is
+ // a message the user sees waiting to be finished when it has already been
+ // delivered.
+ QCOMPARE(fixture.draftCount(), 0);
+
+ // And the sent copy is there, so this is a completed send rather than a
+ // send that never happened leaving nothing behind either way.
+ const QString sentCur = fixture.mailRoot() + QStringLiteral("/acct/Sent/cur");
+ QCOMPARE(QDir(sentCur, {}, QDir::Name, QDir::Files).count(), 1u);
+}
+
+void TestMainWindow::aCloseDuringTheCountdownIsRefused()
+{
+ ComposeFixture fixture;
+ // A countdown long enough to close inside. The default is 5000; this is
+ // the window the guard exists for and it must be provably still open when
+ // the close is attempted.
+ QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("send_delay_ms=30000")));
+
+ ComposeContext context = newContext();
+ context.to = { QStringLiteral("someone@example.org") };
+
+ QPointer<ComposeWindow> window =
+ new ComposeWindow(context, fixture.config(), fixture.mailRoot());
+ auto *body = window->findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ auto *sendAction = window->findChild<QAction *>(QStringLiteral("compose_send"));
+ QVERIFY(body && sendAction);
+ body->setPlainText(QStringLiteral("Sent after a countdown."));
+
+ sendAction->trigger();
+
+ // Still counting down: the popup is up and nothing has been sent. The
+ // sent folder is the evidence, since it is written only after the command
+ // succeeds.
+ auto *dialog = window->findChild<SendDialog *>();
+ QVERIFY2(dialog, "no send popup");
+ QVERIFY2(!dialog->isCommitted(), "the countdown already committed");
+
+ // Close during the countdown. Refused: accepting it would destroy this
+ // window, take the parented SendDialog down with it, and committed() would
+ // never fire. The user pressed Send, watched a countdown, and would
+ // believe the mail went.
+ window->close();
+
+ // Given a moment for a deletion event to be delivered if one was posted,
+ // then asserted still alive. An immediate check would pass against a
+ // deleteLater() already queued.
+ QTest::qWait(300);
+ QVERIFY2(!window.isNull(),
+ "the close was accepted during the countdown, so the send was "
+ "silently abandoned after the user pressed Send");
+ QVERIFY2(window->isVisible() || !window.isNull(), "the window went away");
+
+ // The send never happened, which is the point: nothing was filed.
+ const QString sentCur = fixture.mailRoot() + QStringLiteral("/acct/Sent/cur");
+ QCOMPARE(QDir(sentCur, {}, QDir::Name, QDir::Files).count(), 0u);
+
+ // Cleaned up by hand, since the window refuses to close while the popup is
+ // up and the test must not leak it into the next case.
+ delete window;
+}
+
+void TestMainWindow::aFailedSendKeepsTheTextThatFailedToGo()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("send_delay_ms=0")));
+
+ QTemporaryDir stubDir;
+ QVERIFY(stubDir.isValid());
+ const QString stub = stubDir.filePath(QStringLiteral("fail.sh"));
+ {
+ QFile script(stub);
+ QVERIFY(script.open(QIODevice::WriteOnly | QIODevice::Text));
+ QTextStream out(&script);
+ out << "#!/bin/sh\ncat > /dev/null\necho 'refused' >&2\nexit 1\n";
+ }
+ QVERIFY(QFile::setPermissions(
+ stub, QFileDevice::ReadOwner | QFileDevice::WriteOwner
+ | QFileDevice::ExeOwner));
+
+ Config config;
+ {
+ const QString path = stubDir.filePath(QStringLiteral("fail.conf"));
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text));
+ QTextStream out(&file);
+ out << "[account.acct]\n"
+ << "name=Test User\naddress=user@example.org\n"
+ << "maildir=acct\ntrash=Trash\ndrafts=Drafts\nsent=Sent\n"
+ << "send_command=" << stub << "\n"
+ << "\n[compose]\nsend_delay_ms=0\n";
+ file.close();
+ config.load(path);
+ }
+
+ ComposeContext context = newContext();
+ context.to = { QStringLiteral("someone@example.org") };
+
+ QPointer<ComposeWindow> window =
+ new ComposeWindow(context, config, fixture.mailRoot());
+ auto *body = window->findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ auto *sendAction = window->findChild<QAction *>(QStringLiteral("compose_send"));
+ QVERIFY(body && sendAction);
+
+ // An OLD revision on disk, then an edit that is not saved. send() builds
+ // from the widgets without saving, so without the fix the file left behind
+ // after the failure is the old text: the user watches their correction be
+ // sent, sees it fail, and gets the uncorrected version back.
+ body->setPlainText(QStringLiteral("The ORIGINAL text."));
+ QVERIFY(window->saveDraftNow());
+ QCOMPARE(fixture.draftCount(), 1);
+
+ body->setPlainText(QStringLiteral("The CORRECTED text."));
+ sendAction->trigger();
+
+ auto *pane = window->findChild<QWidget *>(QStringLiteral("sendLogPane"));
+ QVERIFY(pane);
+ QTRY_VERIFY_WITH_TIMEOUT(!pane->isHidden(), 15000);
+ QVERIFY2(!window.isNull(), "a failed send closed the composer");
+
+ // Exactly one draft, and it is the text that was attempted.
+ QCOMPARE(fixture.draftCount(), 1);
+ const QStringList files =
+ QDir(fixture.draftsCur(), {}, QDir::Name, QDir::Files).entryList();
+ QCOMPARE(files.size(), 1);
+ QFile written(fixture.draftsCur() + QLatin1Char('/') + files.first());
+ QVERIFY(written.open(QIODevice::ReadOnly));
+ const QByteArray bytes = written.readAll();
+ QVERIFY2(bytes.contains("The CORRECTED text."),
+ "the draft kept after a failed send is not what was attempted");
+ QVERIFY2(!bytes.contains("The ORIGINAL text."),
+ "the draft kept after a failed send is the PRE-EDIT revision");
+
+ delete window;
+}
+
+void TestMainWindow::aSmallSizeLimitIsNotDescribedAsZeroMegabytes()
+{
+ // Integer MB division made every figure under a megabyte read as "0 MB",
+ // in BOTH halves of the same sentence: "'x' is 0 MB. Many mail servers
+ // refuse messages above about 0 MB."
+ QVERIFY2(!ComposeWindow::humanSize(500 * 1024).contains(QStringLiteral("0 MB")),
+ "half a megabyte is described as 0 MB");
+ QVERIFY2(!ComposeWindow::humanSize(1000).contains(QStringLiteral("0 MB")),
+ "a kilobyte is described as 0 MB");
+
+ // The unit steps down rather than reporting zero of a larger one.
+ QVERIFY(ComposeWindow::humanSize(500 * 1024).contains(QStringLiteral("KB")));
+ QVERIFY(ComposeWindow::humanSize(512).contains(QStringLiteral("bytes")));
+
+ // A decimal while the figure is small enough for it to say something, so
+ // 26 MB and 26.2 MB are not the same string.
+ QVERIFY(ComposeWindow::humanSize(26214400).contains(QStringLiteral("MB")));
+ QVERIFY2(ComposeWindow::humanSize(1024 * 1024 * 3 / 2)
+ .contains(QStringLiteral(".")),
+ "1.5 MB lost its decimal");
+}
+
#include "test_mainwindow.moc"
diff --git a/tests/test_markdownrenderer.cpp b/tests/test_markdownrenderer.cpp
new file mode 100644
index 0000000..697a28f
--- /dev/null
+++ b/tests/test_markdownrenderer.cpp
@@ -0,0 +1,151 @@
+/*
+ * 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"
+
+/// The extension configuration cmark-gfm renders the composer's body with.
+///
+/// No QApplication is needed here: MarkdownRenderer is a pure function over
+/// strings, so QTEST_APPLESS_MAIN avoids pulling in a platform plugin for a
+/// test that has nothing to do with widgets.
+class TestMarkdownRenderer : public QObject
+{
+ Q_OBJECT
+private slots:
+ void commonMarkBasicsRender();
+ void autolinkTurnsABareUrlIntoALink();
+ void strikethroughRenders();
+ void tasklistRenders();
+ void tablesAreNotEnabled();
+ void rawHtmlIsSuppressed();
+ void unsafeLinksAreStripped();
+ void accentedTextSurvivesAsUtf8();
+ void emptyInputProducesEmptyOutput();
+};
+
+void TestMarkdownRenderer::commonMarkBasicsRender()
+{
+ const QString html = MarkdownRenderer::toHtml(
+ QStringLiteral("**bold** *italic* `code`"));
+ QVERIFY2(html.contains(QStringLiteral("<strong>")), qPrintable(html));
+ QVERIFY2(html.contains(QStringLiteral("<em>")), qPrintable(html));
+ QVERIFY2(html.contains(QStringLiteral("<code>")), qPrintable(html));
+}
+
+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(html));
+}
+
+void TestMarkdownRenderer::strikethroughRenders()
+{
+ const QString html = MarkdownRenderer::toHtml(QStringLiteral("~~gone~~"));
+ QVERIFY2(html.contains(QStringLiteral("<del>gone</del>")), qPrintable(html));
+}
+
+void TestMarkdownRenderer::tasklistRenders()
+{
+ // Known ceiling: many mail clients strip the checkbox, so those
+ // recipients see the item with no marker. The plain part still carries
+ // the literal "- [ ]", so nothing is lost, only the HTML rendering.
+ const QString html = MarkdownRenderer::toHtml(
+ QStringLiteral("- [ ] todo\n- [x] done"));
+ QVERIFY2(html.contains(QStringLiteral("type=\"checkbox\"")), qPrintable(html));
+ // Not a bare "checked": that is a common English word ordinary prose
+ // would satisfy on its own. The attribute is what proves [x] differs
+ // from [ ].
+ QVERIFY2(html.contains(QStringLiteral("checked=\"\"")), qPrintable(html));
+}
+
+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 would silently start
+ // passing the wrong way if someone attached it "for completeness".
+ const QString html = MarkdownRenderer::toHtml(
+ QStringLiteral("| a | b |\n|---|---|\n| 1 | 2 |"));
+ QVERIFY2(!html.contains(QStringLiteral("<table")), qPrintable(html));
+ QVERIFY2(html.contains(QStringLiteral("| a | b |")), qPrintable(html));
+}
+
+void TestMarkdownRenderer::rawHtmlIsSuppressed()
+{
+ // Safe mode (the cmark-gfm 0.29 default, not CMARK_OPT_SAFE, which is a
+ // no-op in this version, see markdownrenderer.cpp). 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.
+ //
+ // Asserted on the actual placeholder rather than only "no <script>",
+ // because the weaker assertion would still pass with CMARK_OPT_UNSAFE
+ // set by mistake, as long as something ELSE in the string also matched
+ // "not <script>" and "contains after" (measured: it does not distinguish
+ // safe from unsafe mode on its own). "raw HTML omitted" is what safe mode
+ // actually emits in place of the tag.
+ const QString html = MarkdownRenderer::toHtml(
+ QStringLiteral("<script>alert(1)</script>\n\nafter"));
+ QVERIFY2(!html.contains(QStringLiteral("<script>")), qPrintable(html));
+ QVERIFY2(html.contains(QStringLiteral("raw HTML omitted")), qPrintable(html));
+ QVERIFY2(html.contains(QStringLiteral("after")), qPrintable(html));
+}
+
+void TestMarkdownRenderer::unsafeLinksAreStripped()
+{
+ // A protection this gets for free from safe mode, and previously
+ // asserted nothing about: a javascript: link is replaced with an empty
+ // href rather than passed through. The body is the user's own text, but
+ // it is rendered into an HTML part sent to other people, so a
+ // javascript: link surviving into that part would be a real defect, not
+ // a cosmetic one.
+ const QString html = MarkdownRenderer::toHtml(
+ QStringLiteral("[click](javascript:alert(1))"));
+ QVERIFY2(!html.contains(QStringLiteral("javascript:")), qPrintable(html));
+}
+
+void TestMarkdownRenderer::accentedTextSurvivesAsUtf8()
+{
+ // This user writes Italian, so accented text is every message rather
+ // than an edge case, and a UTF-8 round trip through a C library is
+ // exactly where it would be lost.
+ //
+ // Includes a character outside latin-1, so a symmetric toLatin1/fromLatin1
+ // substitution cannot round-trip it and cancel itself out. Measured: with
+ // accented latin-1 text alone, mutating both sides together passes.
+ const QString source = QString::fromUtf8("perch\xC3\xA9 \xC3\xA8 cos\xC3\xAC \xE2\x82\xAC");
+ const QString html = MarkdownRenderer::toHtml(source);
+ QVERIFY2(html.contains(source), qPrintable(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.
+ const QString html = MarkdownRenderer::toHtml(QString());
+ QVERIFY2(html.trimmed().isEmpty(), qPrintable(html));
+}
+
+QTEST_APPLESS_MAIN(TestMarkdownRenderer)
+#include "test_markdownrenderer.moc"
diff --git a/tests/test_messagebuilder.cpp b/tests/test_messagebuilder.cpp
new file mode 100644
index 0000000..73d388c
--- /dev/null
+++ b/tests/test_messagebuilder.cpp
@@ -0,0 +1,466 @@
+/*
+ * 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 <QDir>
+#include <QFile>
+#include <QThread>
+#include <QObject>
+#include <QRegularExpression>
+#include <QTemporaryDir>
+#include <QTest>
+
+#include <atomic>
+#include <memory>
+
+#include "config.h"
+#include "messagebuilder.h"
+#include "types.h"
+
+/// MessageBuilder's tests assert on the GENERATED BYTES, never by round-tripping
+/// through MimeParser. A builder and a parser that agree can be wrong together:
+/// both are ours, and a shared misunderstanding of a charset or a part order
+/// would show as a green suite and as mojibake on the recipient's screen.
+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 bareMessageIdsAreBracketedRatherThanEmittedEmpty();
+ void attachmentsProduceMultipartMixed();
+ void aMissingAttachmentFailsTheBuild();
+ void aDirectoryAttachmentFailsRatherThanHangingTheProcess();
+ void anUnparseableRecipientFailsRatherThanVanishing();
+ void everyMessageCarriesADateAndMessageId();
+ void recipientsAppearInTheirOwnHeaders();
+ void anAccountWithNoAddressFailsRatherThanBuildingHeaderlessMail();
+
+private:
+ Account m_account;
+
+ /// A message with the fixture account and one recipient, so each test can
+ /// change only the field it is about.
+ OutgoingMessage baseMessage() const
+ {
+ OutgoingMessage m;
+ m.accountKey = m_account.key;
+ m.to = QStringList{QStringLiteral("someone@example.org")};
+ m.subject = QStringLiteral("A subject");
+ m.markdownBody = QStringLiteral("Hello there.");
+ return m;
+ }
+};
+
+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");
+}
+
+/// With the HTML toggle off the message must be a single text/plain part.
+/// A multipart/alternative carrying one alternative is not merely wasteful: it
+/// makes every message an attachment-bearing shape to some clients, and the
+/// toggle exists precisely so a user can send mail nothing has to negotiate.
+void TestMessageBuilder::plainOnlyWhenSendHtmlIsOff()
+{
+ OutgoingMessage m = baseMessage();
+ m.sendHtml = false;
+
+ const MessageBuilder::Result r = MessageBuilder::build(m, m_account);
+ QVERIFY2(r.ok(), qPrintable(r.error));
+
+ const QString text = QString::fromUtf8(r.bytes);
+ QVERIFY(text.contains(QStringLiteral("Content-Type: text/plain")));
+ QVERIFY(!text.contains(QStringLiteral("multipart/alternative")));
+ QVERIFY(!text.contains(QStringLiteral("text/html")));
+}
+
+/// With the toggle on both parts must be present, and text/plain must come
+/// FIRST. Order is load-bearing in multipart/alternative: a client renders the
+/// LAST part it understands, so least-rich first. Reversed, every HTML-capable
+/// client would show the markdown source and the rendered part would never be
+/// seen by anyone.
+void TestMessageBuilder::multipartAlternativeWhenSendHtmlIsOn()
+{
+ OutgoingMessage m = baseMessage();
+ m.sendHtml = true;
+
+ const MessageBuilder::Result r = MessageBuilder::build(m, m_account);
+ QVERIFY2(r.ok(), qPrintable(r.error));
+
+ const QString text = QString::fromUtf8(r.bytes);
+ QVERIFY(text.contains(QStringLiteral("multipart/alternative")));
+
+ const int plain = text.indexOf(QStringLiteral("text/plain"));
+ const int html = text.indexOf(QStringLiteral("text/html"));
+ QVERIFY(plain >= 0);
+ QVERIFY(html >= 0);
+ QVERIFY2(plain < html, "text/plain must precede text/html in multipart/alternative");
+}
+
+/// The markdown SOURCE is the plain part, not a stripped-of-syntax rendering of
+/// it. `**bold**` reads as emphasis to a human, and a plain-text renderer would
+/// mean inventing a second renderer whose output could disagree with the HTML
+/// one. The draft the user autosaves is this same text, which is the other
+/// reason it must not be rewritten on the way out.
+void TestMessageBuilder::thePlainPartCarriesTheMarkdownSourceUnmodified()
+{
+ OutgoingMessage m = baseMessage();
+ m.sendHtml = true;
+ m.markdownBody = QStringLiteral("**bold** and - [ ] a task");
+
+ const MessageBuilder::Result r = MessageBuilder::build(m, m_account);
+ QVERIFY2(r.ok(), qPrintable(r.error));
+
+ const QString text = QString::fromUtf8(r.bytes);
+ QVERIFY2(text.contains(QStringLiteral("**bold** and - [ ] a task")),
+ qPrintable(text));
+}
+
+/// The HTML part comes from the same source through MarkdownRenderer, so the
+/// two parts can never describe different messages.
+void TestMessageBuilder::theHtmlPartIsRenderedFromTheSameSource()
+{
+ OutgoingMessage m = baseMessage();
+ m.sendHtml = true;
+ m.markdownBody = QStringLiteral("**bold**");
+
+ const MessageBuilder::Result r = MessageBuilder::build(m, m_account);
+ QVERIFY2(r.ok(), qPrintable(r.error));
+
+ const QString text = QString::fromUtf8(r.bytes);
+ QVERIFY2(text.contains(QStringLiteral("<strong>bold</strong>")), qPrintable(text));
+}
+
+/// Measured 2026-08-20: g_mime_text_part_set_text() encodes with whatever
+/// charset is set at the moment it is CALLED, so setting the charset afterwards
+/// RELABELS the part without re-encoding it. That produces a part headed
+/// charset=utf-8 whose bytes are latin-1 (`Perch=E9`), which looks correct in
+/// every header and arrives as mojibake. Asserting on the label alone would
+/// pass against exactly that bug, so this asserts on the BYTES too: =C3=A9 must
+/// be there and =E9 must not.
+void TestMessageBuilder::anAccentedBodyIsUtf8QuotedPrintable()
+{
+ OutgoingMessage m = baseMessage();
+ m.markdownBody = QStringLiteral("perché è così");
+
+ const MessageBuilder::Result r = MessageBuilder::build(m, m_account);
+ QVERIFY2(r.ok(), qPrintable(r.error));
+
+ const QString text = QString::fromUtf8(r.bytes);
+ QVERIFY2(text.contains(QStringLiteral("charset=utf-8"), Qt::CaseInsensitive),
+ qPrintable(text));
+ QVERIFY2(text.contains(QStringLiteral("=C3=A9")), qPrintable(text));
+ QVERIFY2(!text.contains(QStringLiteral("=E9\n")) && !text.contains(QStringLiteral("=E9 ")),
+ "latin-1 bytes under a utf-8 label");
+}
+
+/// Measured 2026-08-20: GMime encodes a header as iso-8859-1 unless told
+/// otherwise, so g_mime_message_set_subject(msg, text, NULL) produced
+/// =?iso-8859-1?B?...?=. The explicit "utf-8" argument is what makes an Italian
+/// subject survive.
+void TestMessageBuilder::anAccentedSubjectIsRfc2047Utf8()
+{
+ OutgoingMessage m = baseMessage();
+ m.subject = QStringLiteral("Perché no");
+
+ const MessageBuilder::Result r = MessageBuilder::build(m, m_account);
+ QVERIFY2(r.ok(), qPrintable(r.error));
+
+ const QString text = QString::fromUtf8(r.bytes);
+ QVERIFY2(text.contains(QStringLiteral("=?UTF-8?"), Qt::CaseInsensitive), qPrintable(text));
+ QVERIFY2(!text.contains(QStringLiteral("=?iso-8859-1?"), Qt::CaseInsensitive),
+ qPrintable(text));
+}
+
+/// Not optional decoration. Without In-Reply-To and References a reply appears
+/// as an orphan thread in the sender's own client, since the sent copy is
+/// indexed by notmuch like any other message and notmuch threads on these
+/// headers.
+void TestMessageBuilder::inReplyToAndReferencesAreCarried()
+{
+ OutgoingMessage m = baseMessage();
+ m.inReplyTo = QStringLiteral("<orig@example.org>");
+ m.references = QStringList{QStringLiteral("<older@example.org>"),
+ QStringLiteral("<orig@example.org>")};
+
+ const MessageBuilder::Result r = MessageBuilder::build(m, m_account);
+ QVERIFY2(r.ok(), qPrintable(r.error));
+
+ const QString text = QString::fromUtf8(r.bytes);
+ QVERIFY2(text.contains(QStringLiteral("In-Reply-To: <orig@example.org>")), qPrintable(text));
+ QVERIFY2(text.contains(QStringLiteral("References:")), qPrintable(text));
+ QVERIFY2(text.contains(QStringLiteral("<older@example.org>")), qPrintable(text));
+}
+
+/// **The brackets are syntax, and a bare id ships an EMPTY header rather than a
+/// malformed one.** This is what every real caller supplies: GMime strips the
+/// brackets when MimeParser reads Message-ID, and
+/// ComposeContextBuilder::referencesForReply strips them from the References
+/// chain so the two agree, so both values arrive here bare.
+///
+/// Measured 2026-08-21: handed `orig@example.org`, GMime wrote `In-Reply-To:`
+/// with no value at all and did not complain. Every reply would have arrived as
+/// an orphan thread in the recipient's client, with nothing wrong to see
+/// locally. Asserted on the FULL header line, since a test for the id alone
+/// passes against an empty header that merely contains the name.
+void TestMessageBuilder::bareMessageIdsAreBracketedRatherThanEmittedEmpty()
+{
+ OutgoingMessage m = baseMessage();
+ m.inReplyTo = QStringLiteral("orig@example.org");
+ m.references = QStringList{QStringLiteral("older@example.org"),
+ QStringLiteral("orig@example.org")};
+
+ const MessageBuilder::Result r = MessageBuilder::build(m, m_account);
+ QVERIFY2(r.ok(), qPrintable(r.error));
+
+ const QString text = QString::fromUtf8(r.bytes);
+ QVERIFY2(text.contains(QStringLiteral("In-Reply-To: <orig@example.org>")), qPrintable(text));
+ QVERIFY2(text.contains(
+ QStringLiteral("References: <older@example.org> <orig@example.org>")),
+ qPrintable(text));
+ // The failure this exists for: the header present and empty.
+ QVERIFY2(!text.contains(QStringLiteral("In-Reply-To:\r\n"))
+ && !text.contains(QStringLiteral("In-Reply-To:\n")),
+ qPrintable(text));
+}
+
+/// The attachment wrapper must NEST the body, not sit beside it: multipart/mixed
+/// outermost, with the multipart/alternative as its first part. Beside it, a
+/// client would show the alternatives as attachments and the body would be
+/// unreadable. Position in the byte stream is what distinguishes the two, so the
+/// test asserts mixed appears BEFORE alternative.
+void TestMessageBuilder::attachmentsProduceMultipartMixed()
+{
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ const QString path = dir.filePath(QStringLiteral("notes.txt"));
+ QFile f(path);
+ QVERIFY(f.open(QIODevice::WriteOnly));
+ f.write("some attached bytes\n");
+ f.close();
+
+ OutgoingMessage m = baseMessage();
+ m.sendHtml = true;
+ m.attachments = QStringList{path};
+
+ const MessageBuilder::Result r = MessageBuilder::build(m, m_account);
+ QVERIFY2(r.ok(), qPrintable(r.error));
+
+ const QString text = QString::fromUtf8(r.bytes);
+ const int mixed = text.indexOf(QStringLiteral("multipart/mixed"));
+ const int alternative = text.indexOf(QStringLiteral("multipart/alternative"));
+ QVERIFY2(mixed >= 0, qPrintable(text));
+ QVERIFY2(alternative >= 0, qPrintable(text));
+ QVERIFY2(mixed < alternative, "multipart/mixed must wrap the body, not sit beside it");
+ QVERIFY2(text.contains(QStringLiteral("notes.txt")), qPrintable(text));
+ QVERIFY2(text.contains(QStringLiteral("Content-Disposition: attachment")), qPrintable(text));
+}
+
+/// A file can vanish between being attached and being sent, so existence is
+/// checked at BUILD time. The build must produce NOTHING sendable: an empty
+/// `bytes` is what stops a caller that only checks for content from shipping a
+/// message missing the thing it was written to carry.
+void TestMessageBuilder::aMissingAttachmentFailsTheBuild()
+{
+ OutgoingMessage m = baseMessage();
+ m.attachments = QStringList{QStringLiteral("/nonexistent/path/to/report.pdf")};
+
+ const MessageBuilder::Result r = MessageBuilder::build(m, m_account);
+ QVERIFY(!r.ok());
+ QVERIFY(r.bytes.isEmpty());
+ QVERIFY2(r.error.contains(QStringLiteral("report.pdf")), qPrintable(r.error));
+}
+
+/// A directory is not a file that can be attached, and accepting one does not
+/// produce a bad message, it produces NO message ever: QFileInfo reports a
+/// directory as existing and readable, opening one read-only is legal, and
+/// GMime's base64 encoder then loops on a read() returning EISDIR without
+/// advancing. Measured 2026-08-20 with strace at 2,169,821 failed reads in
+/// twenty seconds and still going. build() runs synchronously from autosave on
+/// the GUI thread, so this froze the whole application with the draft
+/// unrecoverable.
+///
+/// The TIMEOUT is deliberate and is the point of the test's shape. A regression
+/// here hangs the binary rather than failing it, and CLAUDE.md already records
+/// a hung test binary as a misleading failure mode that costs a session. The
+/// build runs on a worker thread so this test can outlive it and report a
+/// FAILURE instead of blocking ctest until its own timeout.
+///
+/// Two details are what make that actually work, and the first draft of this
+/// test had neither. It must NOT join the worker: a thread stuck in the defect
+/// never returns, so a wait() after the timeout hangs exactly as the bug does
+/// and the recorded failure is never printed. Verified by reverting the fix:
+/// with the join the binary had to be killed at 150s with no verdict, without
+/// it the run reports a FAIL and finishes. The worker is therefore deliberately
+/// leaked on the failing path, which is correct for a test binary about to exit
+/// and is the only way this reports rather than hangs. The result is read
+/// through a shared_ptr for the same reason: a leaked thread must not write
+/// into a stack frame that has returned.
+void TestMessageBuilder::aDirectoryAttachmentFailsRatherThanHangingTheProcess()
+{
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ const QString subdir = dir.filePath(QStringLiteral("a-folder"));
+ QVERIFY(QDir().mkpath(subdir));
+
+ // The guard this protects: a directory looks like a perfectly good
+ // attachment to the checks that were there before.
+ const QFileInfo info(subdir);
+ QVERIFY(info.exists());
+ QVERIFY(info.isReadable());
+ QVERIFY(!info.isFile());
+
+ OutgoingMessage m = baseMessage();
+ m.attachments = QStringList{subdir};
+
+ // Shared with the worker rather than captured by reference, so a thread
+ // still spinning after this function returns cannot write into a dead
+ // frame.
+ struct Shared
+ {
+ std::atomic_bool finished{false};
+ MessageBuilder::Result result;
+ };
+ auto shared = std::make_shared<Shared>();
+ const OutgoingMessage msg = m;
+ const Account account = m_account;
+
+ QThread *worker = QThread::create([shared, msg, account] {
+ shared->result = MessageBuilder::build(msg, account);
+ shared->finished = true;
+ });
+ worker->start();
+
+ // Five seconds against a defect measured at twenty seconds and unbounded.
+ // No join: see the note above, waiting on the stuck thread reproduces the
+ // hang instead of reporting it.
+ QTRY_VERIFY_WITH_TIMEOUT(shared->finished.load(), 5000);
+ if (!shared->finished.load())
+ QFAIL("build() did not return for a directory attachment: it is looping on read()");
+
+ worker->wait();
+ delete worker;
+
+ QVERIFY(!shared->result.ok());
+ QVERIFY(shared->result.bytes.isEmpty());
+ QVERIFY2(shared->result.error.contains(QStringLiteral("a-folder")),
+ qPrintable(shared->result.error));
+}
+
+/// A recipient the builder cannot parse must STOP the send, never be dropped.
+/// Measured 2026-08-20: internet_address_list_parse returns a ZERO-LENGTH list
+/// rather than NULL for garbage, so a guard on the assembled list's length
+/// built a message with no To: header at all and reported success. With
+/// `msmtp -t` the recipients come FROM the headers, so that message reaches the
+/// send command with nobody to deliver to, and the sent copy is filed in Sent
+/// looking sent and having reached no one.
+///
+/// Asserts on the error naming the offending entry, because with several
+/// recipients the user cannot otherwise tell which one to fix.
+void TestMessageBuilder::anUnparseableRecipientFailsRatherThanVanishing()
+{
+ OutgoingMessage m = baseMessage();
+ m.to = QStringList{QStringLiteral("not an address at all ((("),
+ QStringLiteral("good@example.org")};
+
+ const MessageBuilder::Result r = MessageBuilder::build(m, m_account);
+ QVERIFY2(!r.ok(), "an unparseable recipient must fail the build");
+ QVERIFY(r.bytes.isEmpty());
+ QVERIFY2(r.error.contains(QStringLiteral("not an address at all")), qPrintable(r.error));
+
+ // The other half of the same defect: with several recipients, the old code
+ // delivered the good ones and dropped the bad one without a word, so the
+ // user had no way to learn which recipient never received the message. A
+ // valid entry beside the bad one must not rescue the build.
+ QVERIFY2(!r.bytes.contains("good@example.org"),
+ "a valid recipient must not smuggle the message past a bad one");
+}
+
+/// Measured 2026-08-20: GMime generates neither header unless asked. A message
+/// without a Message-ID cannot be threaded by anything that receives it,
+/// including this application's own notmuch index once the sent copy lands.
+void TestMessageBuilder::everyMessageCarriesADateAndMessageId()
+{
+ const MessageBuilder::Result r = MessageBuilder::build(baseMessage(), m_account);
+ QVERIFY2(r.ok(), qPrintable(r.error));
+
+ const QString text = QString::fromUtf8(r.bytes);
+ QVERIFY2(text.contains(QStringLiteral("Date: ")), qPrintable(text));
+ QVERIFY2(text.contains(QStringLiteral("Message-Id: "), Qt::CaseInsensitive), qPrintable(text));
+ QVERIFY(!r.messageId.isEmpty());
+}
+
+/// Bcc must be PRESENT in the bytes. The documented send command is `msmtp -t`,
+/// which reads its recipients FROM the headers and strips Bcc itself before
+/// transmission. Removing it here would mean blind recipients never receive the
+/// message at all, silently.
+///
+/// If a later change passes recipients as command arguments instead of relying
+/// on -t, this test must change with it: under that scheme leaving Bcc in the
+/// bytes discloses the blind recipients to everyone.
+void TestMessageBuilder::recipientsAppearInTheirOwnHeaders()
+{
+ OutgoingMessage m = baseMessage();
+ m.to = QStringList{QStringLiteral("to@example.org")};
+ m.cc = QStringList{QStringLiteral("cc@example.org")};
+ m.bcc = QStringList{QStringLiteral("bcc@example.org")};
+
+ const MessageBuilder::Result r = MessageBuilder::build(m, m_account);
+ QVERIFY2(r.ok(), qPrintable(r.error));
+
+ const QString text = QString::fromUtf8(r.bytes);
+ QVERIFY2(text.contains(QStringLiteral("From: ")), qPrintable(text));
+ QVERIFY2(text.contains(QStringLiteral("user@example.org")), qPrintable(text));
+
+ const QRegularExpression to(QStringLiteral("^To:.*to@example\\.org"),
+ QRegularExpression::MultilineOption);
+ const QRegularExpression cc(QStringLiteral("^Cc:.*cc@example\\.org"),
+ QRegularExpression::MultilineOption);
+ const QRegularExpression bcc(QStringLiteral("^Bcc:.*bcc@example\\.org"),
+ QRegularExpression::MultilineOption);
+ QVERIFY2(to.match(text).hasMatch(), qPrintable(text));
+ QVERIFY2(cc.match(text).hasMatch(), qPrintable(text));
+ QVERIFY2(bcc.match(text).hasMatch(), qPrintable(text));
+}
+
+/// Config::account() returns a DEFAULT-CONSTRUCTED Account for an unknown key
+/// rather than failing, so without this guard a bad key would build a message
+/// with an empty From: silently malformed mail rather than a refusal, handed to
+/// the send command as though it were fine.
+void TestMessageBuilder::anAccountWithNoAddressFailsRatherThanBuildingHeaderlessMail()
+{
+ const Account empty;
+ const MessageBuilder::Result r = MessageBuilder::build(baseMessage(), empty);
+ QVERIFY(!r.ok());
+ QVERIFY(r.bytes.isEmpty());
+}
+
+QTEST_MAIN(TestMessageBuilder)
+#include "test_messagebuilder.moc"
diff --git a/tests/test_messagesender.cpp b/tests/test_messagesender.cpp
new file mode 100644
index 0000000..89e0fcf
--- /dev/null
+++ b/tests/test_messagesender.cpp
@@ -0,0 +1,532 @@
+/*
+ * 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 <QTemporaryDir>
+
+#include "messagesender.h"
+
+class TestMessageSender : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void aSuccessfulCommandReportsSent();
+ void theMessageArrivesOnStdinIntact();
+ void aLargeMessageArrivesWhole();
+ void aFailingCommandReportsItsStderr();
+ void aCommandThatDoesNotExistReportsAFailure();
+ void aCommandThatIsNotExecutableReportsAFailure();
+ void anEmptyCommandIsRefusedWithoutRunning();
+ void aCommandOfOnlyWhitespaceIsRefusedWithoutRunning();
+ void exitCode75IsAnOrdinaryFailure();
+ void aSilentFailureStillReportsAReason();
+ void aCommandThatNeverReadsStdinIsStillJudgedByItsExitStatus();
+ void aCrashedCommandIsAFailureWithAReason();
+ void aSecondSendIsRefusedWhileOneIsRunning();
+ void shellMetacharactersReachNoShell();
+ void nothingIsEverReportedTwice();
+ void destroyingTheSenderLetsAnInFlightSendFinish();
+ void destroyingTheSenderEmitsNothing();
+ void aPerSendConnectionMustBeSingleShot();
+
+private:
+ QString writeStub(const QString &name, const QString &body,
+ bool executable = true);
+
+ QTemporaryDir m_dir;
+};
+
+QString TestMessageSender::writeStub(const QString &name, const QString &body,
+ bool executable)
+{
+ 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();
+ QFile::Permissions permissions = QFile::ReadOwner | QFile::WriteOwner;
+ if (executable)
+ permissions |= QFile::ExeOwner;
+ file.setPermissions(permissions);
+ 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");
+ QVERIFY2(!sender.isRunning(), "the sender still reports a run in progress");
+}
+
+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::aLargeMessageArrivesWhole()
+{
+ // A message with an attachment is megabytes, not bytes, and a pipe holds
+ // 64KB. If the write were not driven by the event loop the process would
+ // deadlock on a full pipe, or the tail would be silently dropped and a
+ // truncated message would be reported as sent. Measured: 1.6MB in one
+ // write() call returns the full count only because QProcess buffers it and
+ // drains it as the reader consumes; a probe confirmed the payload arrives
+ // byte-identical.
+ const QString captured = m_dir.filePath(QStringLiteral("big.eml"));
+ const QString stub = writeStub(QStringLiteral("bigcapture.sh"),
+ QStringLiteral("cat > '%1'").arg(captured));
+ QVERIFY(!stub.isEmpty());
+
+ QByteArray bytes("From: a@example.org\r\n\r\n");
+ // Well past a pipe buffer, and not a repeating single byte, so a partial
+ // write cannot accidentally compare equal.
+ for (int i = 0; i < 60000; ++i)
+ bytes += QByteArray::number(i) + "\r\n";
+ QVERIFY(bytes.size() > 300000);
+
+ MessageSender sender;
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ QVERIFY(sender.send(stub, bytes));
+ QVERIFY(spy.wait(10000));
+ QCOMPARE(spy.at(0).at(0).toBool(), true);
+
+ QFile file(captured);
+ QVERIFY(file.open(QIODevice::ReadOnly));
+ const QByteArray got = file.readAll();
+ QCOMPARE(got.size(), bytes.size());
+ QCOMPARE(got, 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. Measured on Qt 6.11:
+ // finCount 0, errCount 1.
+ 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.count(), 1);
+ 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::aCommandThatIsNotExecutableReportsAFailure()
+{
+ // A separate case from a missing file and reached by an ordinary mistake:
+ // a script written by the user and never chmod'd. It also arrives as
+ // FailedToStart with no finished(), so the same handler covers it, but a
+ // test asserting only the missing-file case would pass against a handler
+ // keyed on the errno rather than on the error enum.
+ const QString stub = writeStub(QStringLiteral("noexec.sh"),
+ QStringLiteral("cat >/dev/null"), false);
+ QVERIFY(!stub.isEmpty());
+
+ MessageSender sender;
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ QVERIFY(sender.send(stub, QByteArray("body")));
+
+ QVERIFY2(spy.wait(5000), "no result was ever reported for a non-executable command");
+ QCOMPARE(spy.at(0).at(0).toBool(), false);
+ QVERIFY(!spy.at(0).at(1).toString().isEmpty());
+}
+
+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);
+ QVERIFY(!sender.isRunning());
+}
+
+void TestMessageSender::aCommandOfOnlyWhitespaceIsRefusedWithoutRunning()
+{
+ // A config file with `send_command = ` and a trailing space reaches
+ // exactly this, and it must not run anything.
+ //
+ // MEASURED, and worth stating precisely so this is not mistaken for a
+ // sharper test than it is: send() has TWO guards that both catch a blank
+ // command, the trimmed()-empty check and the parts.isEmpty() check after
+ // QProcess::splitCommand(" ") returns an empty list. Dropping either one
+ // alone leaves this test green, because the other still refuses. Dropping
+ // BOTH aborts the run outright: QProcess treats an empty program as fatal,
+ // and the mutation reports "Received a fatal error" rather than a failed
+ // comparison. The pair is what is under test here; the redundancy is
+ // deliberate, since the fatal path is the one thing a send must never
+ // reach.
+ MessageSender sender;
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ QVERIFY2(!sender.send(QStringLiteral(" \t "), QByteArray("body")),
+ "a whitespace-only command was accepted");
+ QCOMPARE(spy.count(), 0);
+ QVERIFY(!sender.isRunning());
+}
+
+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);
+}
+
+void TestMessageSender::aSilentFailureStillReportsAReason()
+{
+ // The mailsync.sh lesson in the other direction: a command that fails
+ // without saying anything must not produce an empty error string, because
+ // the popup would then show a failure with a blank explanation and the
+ // user would have nothing to act on.
+ const QString stub = writeStub(QStringLiteral("silent.sh"),
+ QStringLiteral("cat >/dev/null; exit 3"));
+ 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);
+ const QString error = spy.at(0).at(1).toString();
+ QVERIFY2(!error.isEmpty(), "a silent failure reported no reason at all");
+ QVERIFY2(error.contains(QStringLiteral("3")),
+ qPrintable(QStringLiteral("the exit status is not named: '%1'").arg(error)));
+}
+
+void TestMessageSender::aCommandThatNeverReadsStdinIsStillJudgedByItsExitStatus()
+{
+ // Measured on Qt 6.11: a command that exits without draining a large stdin
+ // emits errorOccurred(WriteError) BEFORE finished(). A handler that treated
+ // any error as a failure to start would report the write error and swallow
+ // the real exit status; a handler that reported on every error would report
+ // twice. The exit status is the only authority, exactly as it is for the
+ // sync script, so this asserts the reason the command GAVE.
+ const QString stub = writeStub(
+ QStringLiteral("nonreading.sh"),
+ QStringLiteral("echo 'recipient rejected' >&2; exit 1"));
+ QVERIFY(!stub.isEmpty());
+
+ MessageSender sender;
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ QVERIFY(sender.send(stub, QByteArray(1600 * 1024, 'x')));
+
+ QVERIFY(spy.wait(5000));
+ QCOMPARE(spy.count(), 1);
+ QCOMPARE(spy.at(0).at(0).toBool(), false);
+ QVERIFY2(spy.at(0).at(1).toString().contains(QStringLiteral("recipient rejected")),
+ qPrintable(QStringLiteral("the command's own reason was lost: '%1'")
+ .arg(spy.at(0).at(1).toString())));
+}
+
+void TestMessageSender::aCrashedCommandIsAFailureWithAReason()
+{
+ // A segfaulting MTA is a real failure mode and reaches a DIFFERENT branch
+ // from a nonzero exit: status is CrashExit and exitCode carries the signal
+ // number, so an error message built from the exit code alone would tell the
+ // user the command "exited with status 11", which is not what happened.
+ //
+ // Measured on Qt 6.11: a crash emits errorOccurred(Crashed) and THEN
+ // finished(11, CrashExit). Only finished() reports, because handleError
+ // filters to FailedToStart, so the count assertion below also proves that
+ // filter is doing work on a path that is not the write-error one.
+ const QString stub = writeStub(QStringLiteral("crash.sh"),
+ QStringLiteral("cat >/dev/null; kill -SEGV $$"));
+ QVERIFY(!stub.isEmpty());
+
+ MessageSender sender;
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ QVERIFY(sender.send(stub, QByteArray("body")));
+
+ QVERIFY(spy.wait(5000));
+ QTest::qWait(300);
+ QCOMPARE(spy.count(), 1);
+ QCOMPARE(spy.at(0).at(0).toBool(), false);
+ const QString error = spy.at(0).at(1).toString();
+ QVERIFY2(!error.isEmpty(), "a crashed command reported no reason");
+ QVERIFY2(error.contains(QStringLiteral("crash")),
+ qPrintable(QStringLiteral("a crash was reported as an ordinary exit: '%1'")
+ .arg(error)));
+}
+
+void TestMessageSender::aSecondSendIsRefusedWhileOneIsRunning()
+{
+ // One QProcess, so a second send would overwrite the first's program and
+ // arguments mid-flight. Refusing is what makes the popup's Sending stage
+ // mean one message.
+ const QString stub = writeStub(QStringLiteral("slow.sh"),
+ QStringLiteral("cat >/dev/null; sleep 1"));
+ QVERIFY(!stub.isEmpty());
+
+ MessageSender sender;
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ QVERIFY(sender.send(stub, QByteArray("first")));
+ QVERIFY2(sender.isRunning(), "the sender does not report the run it just started");
+ QVERIFY2(!sender.send(stub, QByteArray("second")),
+ "a second send was accepted while one was running");
+
+ QVERIFY(spy.wait(10000));
+ QCOMPARE(spy.count(), 1);
+ QCOMPARE(spy.at(0).at(0).toBool(), true);
+}
+
+void TestMessageSender::shellMetacharactersReachNoShell()
+{
+ // The security property, asserted rather than asserted-about-in-a-comment.
+ // The command is split into an argument list and handed to execve, so a
+ // `;` in it is a literal argument and there is no shell to act on it. If
+ // this ever ran through `sh -c` the stub below would be invoked and the
+ // marker file would exist.
+ //
+ // Measured: QProcess::splitCommand("msmtp; rm x") yields ("msmtp;", "rm",
+ // "x"), so the semicolon does not even separate arguments.
+ const QString marker = m_dir.filePath(QStringLiteral("shell-ran"));
+ const QString stub = writeStub(QStringLiteral("args.sh"),
+ QStringLiteral("cat >/dev/null; exit 0"));
+ QVERIFY(!stub.isEmpty());
+
+ MessageSender sender;
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ QVERIFY(sender.send(QStringLiteral("%1 ; touch %2").arg(stub, marker),
+ QByteArray("body")));
+ QVERIFY(spy.wait(5000));
+
+ QVERIFY2(!QFile::exists(marker),
+ "the send command was interpreted by a shell");
+
+ // And the same string quoted the way a shell would need it also reaches no
+ // shell: double quotes are the ONLY quoting splitCommand understands.
+ // Measured: single quotes are NOT stripped, so `-a 'my acct'` arrives as
+ // three arguments. Recorded here because the plan's comment claimed
+ // splitCommand "handles quoted arguments" without that qualification.
+ QCOMPARE(QProcess::splitCommand(QStringLiteral("m -a \"my acct\" -t")),
+ QStringList({QStringLiteral("m"), QStringLiteral("-a"),
+ QStringLiteral("my acct"), QStringLiteral("-t")}));
+ QCOMPARE(QProcess::splitCommand(QStringLiteral("m -a 'my acct' -t")),
+ QStringList({QStringLiteral("m"), QStringLiteral("-a"),
+ QStringLiteral("'my"), QStringLiteral("acct'"),
+ QStringLiteral("-t")}));
+}
+
+void TestMessageSender::nothingIsEverReportedTwice()
+{
+ // Reporting twice would close the send popup and then act on a second
+ // result, which for a caller that files a sent copy on success means two
+ // copies, or a success followed by a failure. Run every outcome through one
+ // sender and count.
+ const QString ok = writeStub(QStringLiteral("dup-ok.sh"),
+ QStringLiteral("cat >/dev/null"));
+ const QString bad = writeStub(QStringLiteral("dup-bad.sh"),
+ QStringLiteral("echo boom >&2; exit 1"));
+ QVERIFY(!ok.isEmpty() && !bad.isEmpty());
+
+ for (const QString &command :
+ {ok, bad, QStringLiteral("/nonexistent/msmtp")}) {
+ MessageSender sender;
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ QVERIFY(sender.send(command, QByteArray(1600 * 1024, 'x')));
+ QVERIFY(spy.wait(10000));
+ // Give any second signal a chance to arrive before counting.
+ QTest::qWait(300);
+ QVERIFY2(spy.count() == 1,
+ qPrintable(QStringLiteral("%1 reported %2 times")
+ .arg(command)
+ .arg(spy.count())));
+ }
+}
+
+void TestMessageSender::destroyingTheSenderLetsAnInFlightSendFinish()
+{
+ // The composer's X button is reachable mid-send, and abandoning a live
+ // SMTP conversation has a genuinely unknown outcome. Measured before the
+ // destructor existed: plain destruction 100ms into a one-second command
+ // killed the child and the work did NOT complete, announced by nothing but
+ // a "QProcess: Destroyed while process is still running" warning.
+ //
+ // The marker file is the evidence, because it is written by the command
+ // itself after its work: if the destructor killed the child, it does not
+ // exist.
+ const QString marker = m_dir.filePath(QStringLiteral("send-completed"));
+ const QString stub = writeStub(
+ QStringLiteral("slowfinish.sh"),
+ QStringLiteral("cat >/dev/null; sleep 1; touch '%1'").arg(marker));
+ QVERIFY(!stub.isEmpty());
+ QVERIFY2(!QFile::exists(marker), "the marker existed before the send ran");
+
+ {
+ MessageSender sender;
+ QVERIFY(sender.send(stub, QByteArray("body")));
+ // Destroyed well before the command could finish, which is the case
+ // that matters; without the wait this scope kills it.
+ QTest::qWait(100);
+ QVERIFY2(sender.isRunning(), "the command finished before it was abandoned");
+ }
+
+ QVERIFY2(QFile::exists(marker),
+ "destroying the sender killed a send that was in flight");
+}
+
+void TestMessageSender::destroyingTheSenderEmitsNothing()
+{
+ // After a kill the outcome is unknown, and this class reports two outcomes
+ // only. A finished(false, ...) from the destructor would report "not sent"
+ // for a message that may have been delivered, which is the mailsync.sh
+ // mistake pointing the other way.
+ //
+ // A command that outlasts the shutdown wait is what forces the kill
+ // branch, so the wait is shortened by pointing the test at a command
+ // longer than it rather than by changing the constant.
+ const QString stub = writeStub(QStringLiteral("outlast.sh"),
+ QStringLiteral("cat >/dev/null; sleep 30"));
+ QVERIFY(!stub.isEmpty());
+
+ QSignalSpy *spy = nullptr;
+ {
+ MessageSender sender;
+ spy = new QSignalSpy(&sender, &MessageSender::finished);
+ QVERIFY(sender.send(stub, QByteArray("body")));
+ QTest::qWait(100);
+ QVERIFY(sender.isRunning());
+ // The destructor runs as this scope ends: it waits kShutdownWaitMs
+ // for a command that will not finish, then kills it.
+ }
+ // The spy outlives the sender deliberately: a signal emitted during
+ // destruction would have been recorded before the object went away.
+ QCOMPARE(spy->count(), 0);
+ delete spy;
+}
+
+void TestMessageSender::aPerSendConnectionMustBeSingleShot()
+{
+ // The header's contract, asserted. m_reported collapses two QProcess
+ // signals into one emit, but it cannot stop a caller from accumulating
+ // RECEIVERS: a long-lived sender that a caller connects to inside its send
+ // path runs every previous lambda on the next result, each still holding
+ // the previous message's bytes.
+ //
+ // This is the plan's own Task 11 shape, and it is why that step now
+ // specifies Qt::SingleShotConnection.
+ const QString stub = writeStub(QStringLiteral("twice.sh"),
+ QStringLiteral("cat >/dev/null"));
+ QVERIFY(!stub.isEmpty());
+
+ MessageSender sender; // long-lived, as a ComposeWindow member is
+
+ // The broken shape: a bare connect() beside each send().
+ int bareDeliveries = 0;
+ for (int i = 0; i < 2; ++i) {
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ connect(&sender, &MessageSender::finished, this,
+ [&bareDeliveries](bool, const QString &) { ++bareDeliveries; });
+ QVERIFY(sender.send(stub, QByteArray("body")));
+ QVERIFY(spy.wait(5000));
+ QCOMPARE(spy.count(), 1); // ONE emit, both times
+ }
+ QVERIFY2(bareDeliveries == 3,
+ qPrintable(QStringLiteral("expected the documented 1+2 accumulation, got %1")
+ .arg(bareDeliveries)));
+
+ // The prescribed shape: the connection disconnects as it fires, so two
+ // sends deliver two results rather than three.
+ MessageSender clean;
+ int singleShotDeliveries = 0;
+ for (int i = 0; i < 2; ++i) {
+ QSignalSpy spy(&clean, &MessageSender::finished);
+ connect(&clean, &MessageSender::finished, this,
+ [&singleShotDeliveries](bool, const QString &) { ++singleShotDeliveries; },
+ Qt::SingleShotConnection);
+ QVERIFY(clean.send(stub, QByteArray("body")));
+ QVERIFY(spy.wait(5000));
+ }
+ QCOMPARE(singleShotDeliveries, 2);
+}
+
+QTEST_MAIN(TestMessageSender)
+#include "test_messagesender.moc"
diff --git a/tests/test_senddialog.cpp b/tests/test_senddialog.cpp
new file mode 100644
index 0000000..ac8c234
--- /dev/null
+++ b/tests/test_senddialog.cpp
@@ -0,0 +1,468 @@
+/*
+ * 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 <QLabel>
+#include <QPushButton>
+#include <QSet>
+
+#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 theStatusLabelIsWideEnoughForEveryStage();
+ void closingDuringTheCountdownIsRefused();
+ void closingADialogThatWasNeverShownIsAlsoRefused();
+ void theRefusalHintSurvivesTheNextCountdownTick();
+ void rejectDuringTheCountdownIsRefused();
+ void escapeDuringTheCountdownIsRefused();
+ void undoIsTheOneRouteThatClosesBeforeCommit();
+ void closingAfterCommitReportsAcceptedAndDoesNotUndo();
+ void undoAfterCommitIsRefused();
+ void everyStageSetsItsOwnLabelAndLeavesTheBarBusy();
+ void windingBackToCountingDownAfterCommitIsRefused();
+};
+
+void TestSendDialog::theBarIsDeterminateWhileCountingDown()
+{
+ // A countdown has measurable progress, so the bar drains rather than
+ // animating. This is the half of BusyIndicator MainWindow never uses: the
+ // status bar's sync indicator is indeterminate for its whole life.
+ //
+ // A generous delay so the assertion cannot race the countdown's own end,
+ // which would flip the bar to indeterminate for a legitimate reason and
+ // report a defect that is not there.
+ SendDialog dialog(5000);
+ dialog.show();
+
+ auto *indicator = dialog.findChild<BusyIndicator *>(
+ QStringLiteral("sendProgress"));
+ QVERIFY2(indicator, "the dialog has no BusyIndicator named sendProgress");
+ QVERIFY2(indicator->isDeterminate(),
+ "the bar was animating during a countdown that has a known end");
+}
+
+void TestSendDialog::theCountdownCommitsWhenItElapses()
+{
+ // A short delay rather than waiting out the shipped default: what is being
+ // tested is that the countdown ends in a commit, not how long it is.
+ 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. It must still be a queued commit rather than one inside the
+ // constructor, or a caller connecting to committed() after constructing the
+ // dialog would never hear it.
+ SendDialog dialog(0);
+ QSignalSpy spy(&dialog, &SendDialog::committed);
+ dialog.show();
+
+ QVERIFY2(spy.wait(1000), "a zero delay never committed");
+ QCOMPARE(spy.count(), 1);
+ QVERIFY(dialog.isCommitted());
+}
+
+void TestSendDialog::undoDuringTheCountdownEmitsUndoneAndNeverCommits()
+{
+ // THE test for this feature, and the property that matters is the NEGATIVE
+ // one. A test asserting only that undone() fired would pass against a
+ // design that started the send anyway and threw the result away, which is
+ // the whole failure the delay exists to prevent. Nothing has reached a
+ // server during the countdown, so Undo must mean that nothing happened.
+ SendDialog dialog(2000);
+ QSignalSpy committedSpy(&dialog, &SendDialog::committed);
+ QSignalSpy undoneSpy(&dialog, &SendDialog::undone);
+ dialog.show();
+
+ auto *undo = dialog.findChild<QPushButton *>(QStringLiteral("undoSend"));
+ QVERIFY2(undo, "the dialog has no button named undoSend");
+ QVERIFY2(undo->isEnabled(), "Undo was dead during the countdown");
+
+ undo->click();
+
+ QCOMPARE(undoneSpy.count(), 1);
+ QCOMPARE(committedSpy.count(), 0);
+
+ // Past the original deadline. A timer left running would commit here, after
+ // the dialog has already reported that nothing was sent.
+ QTest::qWait(2500);
+ QVERIFY2(committedSpy.count() == 0,
+ "the countdown committed after Undo was pressed");
+}
+
+void TestSendDialog::undoDisablesItselfOnceTheCommandStarts()
+{
+ // There is no cancel after commit. Killing send_command once it runs leaves
+ // an UNKNOWN send: the message may have reached the server in full before
+ // the kill, which is worse than either clean outcome.
+ SendDialog dialog(100);
+ dialog.show();
+
+ auto *undo = dialog.findChild<QPushButton *>(QStringLiteral("undoSend"));
+ QVERIFY(undo);
+
+ QSignalSpy spy(&dialog, &SendDialog::committed);
+ QVERIFY2(spy.wait(3000), "the countdown never committed");
+
+ QVERIFY2(!undo->isEnabled(),
+ "Undo was still live after the send command started");
+}
+
+void TestSendDialog::theBarBecomesIndeterminateWhenSending()
+{
+ // The bar CHANGES MODE, it does not change place: a send has no measurable
+ // progress, so the same widget stops drawing a fraction and starts
+ // animating, and nothing in the popup reflows.
+ SendDialog dialog(100);
+ dialog.show();
+
+ auto *indicator = dialog.findChild<BusyIndicator *>(
+ QStringLiteral("sendProgress"));
+ QVERIFY(indicator);
+ QVERIFY(indicator->isDeterminate());
+
+ dialog.setStage(SendDialog::Stage::Sending);
+ QVERIFY2(!indicator->isDeterminate(),
+ "the bar kept the countdown's 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);
+ QVERIFY2(spy.wait(3000), "the countdown never committed");
+
+ QVERIFY2(undo->isVisibleTo(&dialog),
+ "Undo disappeared instead of greying out");
+}
+
+void TestSendDialog::theStatusLabelIsWideEnoughForEveryStage()
+{
+ // The label is sized to the LONGEST string it can hold in the current
+ // language, not to its content, so the popup does not resize between
+ // stages. Asserted against the metrics of the strings themselves rather
+ // than a constant, so it holds in whatever language is loaded.
+ SendDialog dialog(2000);
+ dialog.show();
+
+ auto *status = dialog.findChild<QLabel *>(QStringLiteral("sendStatus"));
+ QVERIFY2(status, "the dialog has no label named sendStatus");
+
+ const QFontMetrics metrics(status->font());
+ const QStringList candidates{
+ SendDialog::tr("Sending in %1...").arg(99),
+ SendDialog::tr("Sending..."),
+ SendDialog::tr("Filing sent copy..."),
+ SendDialog::tr("Removing draft..."),
+ SendDialog::tr("Press Undo to stop sending."),
+ };
+ int widest = 0;
+ for (const QString &candidate : candidates)
+ widest = qMax(widest, metrics.horizontalAdvance(candidate));
+
+ QVERIFY2(status->minimumWidth() >= widest,
+ "the status label was sized to its content, so the popup will "
+ "resize when a longer stage name arrives");
+}
+
+void TestSendDialog::closingDuringTheCountdownIsRefused()
+{
+ // The same failure as the Undo test, reached by a different door. Removing
+ // the close BUTTON removes the visual affordance, not the code path: the
+ // window manager, close() and QDialog's own machinery all still reach
+ // done(). Left unguarded, close() hides the window and leaves the timer
+ // running, so the send starts with no window on screen and the only cancel
+ // control destroyed.
+ //
+ // The close is REFUSED rather than reinterpreted as an Undo, at the user's
+ // call: "close means undo is confusing", because a dismissed window cannot
+ // tell you whether it stopped the send or merely hid it. So the dialog
+ // stays up, the send stays scheduled, and Undo remains the only way out.
+ SendDialog dialog(2000);
+ QSignalSpy committedSpy(&dialog, &SendDialog::committed);
+ QSignalSpy undoneSpy(&dialog, &SendDialog::undone);
+ dialog.show();
+
+ QVERIFY2(!dialog.close(), "close() during the countdown was accepted");
+
+ QVERIFY2(dialog.isVisible(),
+ "the dialog vanished on a close it was supposed to refuse");
+ QVERIFY2(undoneSpy.count() == 0,
+ "a refused close silently undid the send anyway");
+
+ // Refusing must not be silent: a window that ignores a close reads as a
+ // hang, so the popup has to say where the exit is.
+ auto *status = dialog.findChild<QLabel *>(QStringLiteral("sendStatus"));
+ QVERIFY(status);
+ QVERIFY2(status->text().contains(QStringLiteral("Undo")),
+ "a refused close gave the user no hint that Undo is the way out");
+
+ // The send was never cancelled, so it still goes out. That is the whole
+ // point of refusing rather than undoing.
+ QVERIFY2(committedSpy.wait(3000),
+ "the refused close cancelled the send after all");
+}
+
+void TestSendDialog::closingADialogThatWasNeverShownIsAlsoRefused()
+{
+ // CLAUDE.md's documented companion trap: close() on a widget that was
+ // never shown returns early WITHOUT reaching done(), so a refusal written
+ // only in done() would miss this one route entirely. The countdown is
+ // running either way, because it starts in the constructor rather than on
+ // show(). Refused on the same terms as the shown case.
+ SendDialog dialog(2000);
+ QSignalSpy committedSpy(&dialog, &SendDialog::committed);
+ QSignalSpy undoneSpy(&dialog, &SendDialog::undone);
+
+ QVERIFY2(!dialog.close(),
+ "close() on an unshown dialog slipped past the refusal");
+ QVERIFY2(undoneSpy.count() == 0,
+ "closing an unshown dialog undid the send");
+
+ QVERIFY2(committedSpy.wait(3000),
+ "the unshown dialog's send was cancelled by a refused close");
+}
+
+void TestSendDialog::theRefusalHintSurvivesTheNextCountdownTick()
+{
+ // Without a hold the hint lives for one tick, which is 100ms, and the
+ // countdown text overwrites it before it can be read. A refusal the user
+ // cannot see is a window that ignores them, which reads as a hang, so the
+ // hold is what makes the refusal honest rather than decorative.
+ SendDialog dialog(5000);
+ dialog.show();
+
+ auto *status = dialog.findChild<QLabel *>(QStringLiteral("sendStatus"));
+ QVERIFY(status);
+
+ dialog.close();
+ const QString hint = status->text();
+ QVERIFY2(hint.contains(QStringLiteral("Undo")), "no hint on refusal");
+
+ // Several ticks later, well past the point the countdown would have
+ // reclaimed the label.
+ QTest::qWait(500);
+ QCOMPARE(status->text(), hint);
+
+ // And it does eventually give the label back, or the countdown would be
+ // hidden for the rest of its life.
+ QTest::qWait(1500);
+ QVERIFY2(status->text() != hint,
+ "the hint never released the label back to the countdown");
+}
+
+void TestSendDialog::rejectDuringTheCountdownIsRefused()
+{
+ // reject() is the route neither close() nor Escape goes through directly,
+ // and it is the one a caller reaches for. CLAUDE.md's rule is that every
+ // route out gets asserted: "a test used close() and the user used Cancel"
+ // is the documented way one of three gets missed.
+ SendDialog dialog(2000);
+ QSignalSpy committedSpy(&dialog, &SendDialog::committed);
+ QSignalSpy undoneSpy(&dialog, &SendDialog::undone);
+ dialog.show();
+
+ dialog.reject();
+
+ QVERIFY2(dialog.isVisible(), "reject() dismissed the countdown");
+ QVERIFY2(undoneSpy.count() == 0, "reject() undid the send");
+ QVERIFY2(committedSpy.wait(3000), "reject() cancelled the send after all");
+}
+
+void TestSendDialog::escapeDuringTheCountdownIsRefused()
+{
+ // Escape is QDialog's built-in reject(), and swallowing it in
+ // keyPressEvent is only the first line: done() refuses it too, so the
+ // dialog is safe even if the key handler is ever removed.
+ SendDialog dialog(2000);
+ QSignalSpy committedSpy(&dialog, &SendDialog::committed);
+ QSignalSpy undoneSpy(&dialog, &SendDialog::undone);
+ dialog.show();
+
+ QTest::keyClick(&dialog, Qt::Key_Escape);
+ QVERIFY2(dialog.isVisible(), "Escape dismissed the countdown");
+
+ // With modifiers too, so neither is an undocumented back door.
+ QTest::keyClick(&dialog, Qt::Key_Escape, Qt::ShiftModifier);
+ QTest::keyClick(&dialog, Qt::Key_Escape, Qt::ControlModifier);
+ QVERIFY2(dialog.isVisible(), "a modified Escape dismissed the countdown");
+
+ QVERIFY2(undoneSpy.count() == 0, "Escape undid the send");
+ QVERIFY2(committedSpy.wait(3000), "Escape cancelled the send after all");
+}
+
+void TestSendDialog::undoIsTheOneRouteThatClosesBeforeCommit()
+{
+ // The counterpart to the four refusals above: having refused every other
+ // way out, the one remaining control must actually work, or the popup is
+ // a trap with no exit at all.
+ SendDialog dialog(2000);
+ QSignalSpy undoneSpy(&dialog, &SendDialog::undone);
+ dialog.show();
+
+ auto *undo = dialog.findChild<QPushButton *>(QStringLiteral("undoSend"));
+ QVERIFY(undo);
+ undo->click();
+
+ QCOMPARE(undoneSpy.count(), 1);
+ QVERIFY2(!dialog.isVisible(), "Undo did not close the dialog");
+ QCOMPARE(dialog.result(), int(QDialog::Rejected));
+}
+
+void TestSendDialog::closingAfterCommitReportsAcceptedAndDoesNotUndo()
+{
+ // After commit there is nothing to undo, so closing is permitted. What it
+ // must NOT do is report Rejected: a caller inspecting result() would read
+ // a send that is running as one that was cancelled, and undone() must stay
+ // silent because the message is on its way.
+ SendDialog dialog(100);
+ QSignalSpy undoneSpy(&dialog, &SendDialog::undone);
+ QSignalSpy committedSpy(&dialog, &SendDialog::committed);
+ dialog.show();
+
+ QVERIFY2(committedSpy.wait(3000), "the countdown never committed");
+ QVERIFY(dialog.isCommitted());
+
+ dialog.close();
+
+ QCOMPARE(undoneSpy.count(), 0);
+ QVERIFY2(dialog.result() != QDialog::Rejected,
+ "closing a committed dialog reported the send as cancelled");
+}
+
+void TestSendDialog::undoAfterCommitIsRefused()
+{
+ // Undo is disabled at commit, but a disabled button is a UI property, not
+ // an invariant. This asserts the handler's own guard, so a future change
+ // that re-enables the button cannot turn it back into a claim that nothing
+ // was sent while send_command is already running.
+ SendDialog dialog(100);
+ QSignalSpy committedSpy(&dialog, &SendDialog::committed);
+ QSignalSpy undoneSpy(&dialog, &SendDialog::undone);
+ dialog.show();
+
+ QVERIFY2(committedSpy.wait(3000), "the countdown never committed");
+
+ auto *undo = dialog.findChild<QPushButton *>(QStringLiteral("undoSend"));
+ QVERIFY(undo);
+
+ // Deliberately re-enabled, to reach the handler that the disabled state
+ // would otherwise hide. This is the mutation a future edit could make by
+ // accident; the guard behind it is what this test is for.
+ undo->setEnabled(true);
+ undo->click();
+
+ QVERIFY2(undoneSpy.count() == 0,
+ "Undo claimed nothing was sent after the send command started");
+}
+
+void TestSendDialog::everyStageSetsItsOwnLabelAndLeavesTheBarBusy()
+{
+ // Walks all four, because a break accidentally deleted from one case would
+ // fall through to the next and nothing else would notice. FilingSentCopy
+ // and RemovingDraft are also the two whose Italian strings drove the whole
+ // label-width design, so leaving them unexercised would test the sizing of
+ // strings nothing ever displays.
+ SendDialog dialog(2000);
+ dialog.show();
+
+ auto *status = dialog.findChild<QLabel *>(QStringLiteral("sendStatus"));
+ auto *indicator = dialog.findChild<BusyIndicator *>(
+ QStringLiteral("sendProgress"));
+ QVERIFY(status);
+ QVERIFY(indicator);
+
+ const QString countingDown = status->text();
+ QVERIFY2(!countingDown.isEmpty(), "the countdown showed no text");
+ QVERIFY(indicator->isDeterminate());
+
+ QStringList seen;
+ const QVector<SendDialog::Stage> stages{
+ SendDialog::Stage::Sending,
+ SendDialog::Stage::FilingSentCopy,
+ SendDialog::Stage::RemovingDraft,
+ };
+ for (SendDialog::Stage stage : stages) {
+ dialog.setStage(stage);
+ QVERIFY2(!status->text().isEmpty(), "a stage set no text at all");
+ QVERIFY2(!indicator->isDeterminate(),
+ "a post-countdown stage left the bar drawing a fraction");
+ seen << status->text();
+ }
+
+ // Distinct from each other and from the countdown: a fallthrough would
+ // show the following stage's text and collapse two of these into one.
+ seen << countingDown;
+ QCOMPARE(QSet<QString>(seen.begin(), seen.end()).size(), seen.size());
+}
+
+void TestSendDialog::windingBackToCountingDownAfterCommitIsRefused()
+{
+ // setStage() is public and Task 12 passes values from the public enum. The
+ // enum is documented "in order", so the class enforces that itself rather
+ // than trusting its caller: winding back would relabel a running send
+ // "Sending in 0..." and redraw a full countdown bar under it, offering a
+ // cancel that no longer exists.
+ SendDialog dialog(100);
+ QSignalSpy committedSpy(&dialog, &SendDialog::committed);
+ dialog.show();
+
+ QVERIFY2(committedSpy.wait(3000), "the countdown never committed");
+
+ auto *status = dialog.findChild<QLabel *>(QStringLiteral("sendStatus"));
+ auto *indicator = dialog.findChild<BusyIndicator *>(
+ QStringLiteral("sendProgress"));
+ const QString sending = status->text();
+
+ dialog.setStage(SendDialog::Stage::CountingDown);
+
+ QCOMPARE(status->text(), sending);
+ QVERIFY2(!indicator->isDeterminate(),
+ "the bar drew a countdown fraction over a running send");
+}
+
+QTEST_MAIN(TestSendDialog)
+#include "test_senddialog.moc"
diff --git a/translations/qtmaildir_it_IT.ts b/translations/qtmaildir_it_IT.ts
index 68b42f7..f8a2b03 100644
--- a/translations/qtmaildir_it_IT.ts
+++ b/translations/qtmaildir_it_IT.ts
@@ -2,6 +2,137 @@
<!DOCTYPE TS>
<TS version="2.1" language="it_IT">
<context>
+ <name>ComposeWindow</name>
+ <message>
+ <source>Compose</source>
+ <translation>Componi</translation>
+ </message>
+ <message>
+ <source>From:</source>
+ <translation>Da:</translation>
+ </message>
+ <message>
+ <source>To:</source>
+ <translation>A:</translation>
+ </message>
+ <message>
+ <source>Cc:</source>
+ <translation>Cc:</translation>
+ </message>
+ <message>
+ <source>Bcc:</source>
+ <translation>Ccn:</translation>
+ </message>
+ <message>
+ <source>Subject:</source>
+ <translation>Oggetto:</translation>
+ </message>
+ <message>
+ <source>Also send a formatted copy</source>
+ <translation>Invia anche una copia formattata</translation>
+ </message>
+ <message>
+ <source>Sends the message as plain text with a formatted version alongside it. The plain text is what you typed.</source>
+ <translation>Invia il messaggio come testo semplice con accanto una versione formattata. Il testo semplice è quello che hai scritto.</translation>
+ </message>
+ <message>
+ <source>Send output</source>
+ <translation>Output dell’invio</translation>
+ </message>
+ <message>
+ <source>Close</source>
+ <translation>Chiudi</translation>
+ </message>
+ <message>
+ <source>Formatting</source>
+ <translation>Formattazione</translation>
+ </message>
+ <message>
+ <source>Bold</source>
+ <translation>Grassetto</translation>
+ </message>
+ <message>
+ <source>Italic</source>
+ <translation>Corsivo</translation>
+ </message>
+ <message>
+ <source>Code</source>
+ <translation>Codice</translation>
+ </message>
+ <message>
+ <source>Strikethrough</source>
+ <translation>Barrato</translation>
+ </message>
+ <message>
+ <source>Link</source>
+ <translation>Collegamento</translation>
+ </message>
+ <message>
+ <source>Quote</source>
+ <translation>Citazione</translation>
+ </message>
+ <message>
+ <source>Attach...</source>
+ <translation>Allega...</translation>
+ </message>
+ <message>
+ <source>Attach files</source>
+ <translation>Allega file</translation>
+ </message>
+ <message>
+ <source>Remove attachment</source>
+ <translation>Rimuovi allegato</translation>
+ </message>
+ <message>
+ <source>Send</source>
+ <translation>Invia</translation>
+ </message>
+ <message>
+ <source>Large attachment</source>
+ <translation>Allegato di grandi dimensioni</translation>
+ </message>
+ <message>
+ <source>&apos;%1&apos; is %2. Many mail servers refuse messages above about %3. Attach it anyway?</source>
+ <translation>&apos;%1&apos; occupa %2. Molti server di posta rifiutano messaggi oltre i %3 circa. Allegarlo comunque?</translation>
+ </message>
+ <message>
+ <source>The draft could not be saved: %1</source>
+ <translation>Non è stato possibile salvare la bozza: %1</translation>
+ </message>
+ <message>
+ <source>The send command reported no output.</source>
+ <translation>Il comando di invio non ha prodotto alcun output.</translation>
+ </message>
+ <message>
+ <source>Cannot send</source>
+ <translation>Impossibile inviare</translation>
+ </message>
+ <message>
+ <source>The account &apos;%1&apos; has no send command configured.</source>
+ <translation>L’account &apos;%1&apos; non ha un comando di invio configurato.</translation>
+ </message>
+ <message>
+ <source>Sent, but not filed</source>
+ <translation>Inviato, ma non archiviato</translation>
+ </message>
+ <message>
+ <source>The message was sent, but the copy could not be written to &apos;%1&apos; for account &apos;%2&apos;:
+
+%3
+
+The message HAS been sent. Do not send it again.</source>
+ <translation>Il messaggio è stato inviato, ma non è stato possibile scrivere la copia in &apos;%1&apos; per l’account &apos;%2&apos;:
+
+%3
+
+Il messaggio È stato inviato. Non inviarlo di nuovo.</translation>
+ </message>
+ <message>
+ <source>The send command could not be started.</source>
+ <translation>Non è stato possibile avviare il comando di invio.</translation>
+ </message>
+</context>
+<context>
<name>Config</name>
<message>
<source>Language &apos;%1&apos; is not a locale name; using the system language. Expected something like &apos;it&apos; or &apos;it_IT&apos;.</source>
@@ -48,10 +179,42 @@
<translation>L&apos;account &apos;%1&apos; non ha un cestino configurato; aggiungere una chiave &apos;trash&apos; alla sua sezione. L&apos;eliminazione non funzionerà per questo account finché non verrà fatto.</translation>
</message>
<message>
+ <source>[compose] quote_position &apos;%1&apos; is not recognised; expected above or below. Using above.</source>
+ <translation>[compose] quote_position &apos;%1&apos; non è riconosciuto; atteso above o below. Verrà usato above.</translation>
+ </message>
+ <message>
+ <source>[compose] autosave_interval_ms &apos;%1&apos; is not a number; using %2.</source>
+ <translation>[compose] autosave_interval_ms &apos;%1&apos; non è un numero; verrà usato %2.</translation>
+ </message>
+ <message>
+ <source>[compose] send_delay_ms &apos;%1&apos; is not a number; using %2.</source>
+ <translation>[compose] send_delay_ms &apos;%1&apos; non è un numero; verrà usato %2.</translation>
+ </message>
+ <message>
+ <source>[compose] attachment_warn_bytes &apos;%1&apos; is not a number; using %2.</source>
+ <translation>[compose] attachment_warn_bytes &apos;%1&apos; non è un numero; verrà usato %2.</translation>
+ </message>
+ <message>
<source>Startup account &apos;%1&apos; is not a configured account; starting on all accounts.</source>
<translation>L&apos;account iniziale &apos;%1&apos; non è un account configurato; si parte da tutti gli account.</translation>
</message>
<message>
+ <source>[compose] default_account names &apos;%1&apos;, which is not a configured account. A new message will pick a sending account by the usual rules.</source>
+ <translation>[compose] default_account indica &apos;%1&apos;, che non è un account configurato. Un nuovo messaggio sceglierà un account di invio secondo le regole abituali.</translation>
+ </message>
+ <message>
+ <source>[compose] default_account names &apos;%1&apos;, which has no send_command and cannot send. A new message will pick a sending account by the usual rules.</source>
+ <translation>[compose] default_account indica &apos;%1&apos;, che non ha un send_command e non può inviare. Un nuovo messaggio sceglierà un account di invio secondo le regole abituali.</translation>
+ </message>
+ <message>
+ <source>Account &apos;%1&apos; can send but configures no `sent` folder, so no local copy of sent mail is filed.</source>
+ <translation>L&apos;account &apos;%1&apos; può inviare ma non configura una cartella &apos;sent&apos;, quindi non viene archiviata alcuna copia locale della posta inviata.</translation>
+ </message>
+ <message>
+ <source>Account &apos;%1&apos; can send but configures no `drafts` folder, so the composer runs without draft protection.</source>
+ <translation>L&apos;account &apos;%1&apos; può inviare ma non configura una cartella &apos;drafts&apos;, quindi il compositore funziona senza protezione delle bozze.</translation>
+ </message>
+ <message>
<source>Startup query &apos;%1&apos; is not a saved query; opening &apos;%2&apos; instead.</source>
<translation>La ricerca iniziale &apos;%1&apos; non è una ricerca salvata; verrà aperta &apos;%2&apos;.</translation>
</message>
@@ -249,6 +412,22 @@
<translation>Aggiunge o rimuove l&apos;etichetta deleted</translation>
</message>
<message>
+ <source>Re&amp;ply</source>
+ <translation>Ris&amp;pondi</translation>
+ </message>
+ <message>
+ <source>Reply to a&amp;ll</source>
+ <translation>Rispondi a t&amp;utti</translation>
+ </message>
+ <message>
+ <source>Reply without &amp;quoting</source>
+ <translation>Rispon&amp;di senza citare</translation>
+ </message>
+ <message>
+ <source>Sa&amp;ve message as...</source>
+ <translation>Sal&amp;va messaggio con nome...</translation>
+ </message>
+ <message>
<source>Changes made here that a sync has not yet carried to the mail store. An external notmuch run can clear them without this count noticing.</source>
<translation>Modifiche fatte qui che nessuna sincronizzazione ha ancora trasferito all&apos;archivio di posta. Un&apos;esecuzione esterna di notmuch può azzerarle senza che questo conteggio se ne accorga.</translation>
</message>
@@ -392,6 +571,68 @@
<translation>Segna conversazione come &amp;spam</translation>
</message>
<message>
+ <source>A draft could not be saved</source>
+ <translation>Impossibile salvare una bozza</translation>
+ </message>
+ <message numerus="yes">
+ <source>%n message(s) could not be saved to the drafts folder. Quitting now loses that text.</source>
+ <translation>
+ <numerusform>Impossibile salvare %n messaggio nella cartella delle bozze. Uscendo ora quel testo va perso.</numerusform>
+ <numerusform>Impossibile salvare %n messaggi nella cartella delle bozze. Uscendo ora quel testo va perso.</numerusform>
+ </translation>
+ </message>
+ <message>
+ <source>Messages still being composed</source>
+ <translation>Messaggi ancora in composizione</translation>
+ </message>
+ <message numerus="yes">
+ <source>%n message(s) are still being composed. Drafts already saved stay in the drafts folder either way.</source>
+ <translation>
+ <numerusform>%n messaggio è ancora in composizione. Le bozze già salvate restano comunque nella cartella delle bozze.</numerusform>
+ <numerusform>%n messaggi sono ancora in composizione. Le bozze già salvate restano comunque nella cartella delle bozze.</numerusform>
+ </translation>
+ </message>
+ <message>
+ <source>No account is configured to send mail</source>
+ <translation>Nessun account configurato per inviare posta</translation>
+ </message>
+ <message>
+ <source>No message is selected</source>
+ <translation>Nessun messaggio selezionato</translation>
+ </message>
+ <message>
+ <source>That message could not be read</source>
+ <translation>Impossibile leggere quel messaggio</translation>
+ </message>
+ <message>
+ <source>That message arrived at an account that cannot send</source>
+ <translation>Quel messaggio è arrivato a un account che non può inviare</translation>
+ </message>
+ <message>
+ <source>The Maildir root is not known yet</source>
+ <translation>La radice della Maildir non è ancora nota</translation>
+ </message>
+ <message>
+ <source>That message&apos;s file could not be found</source>
+ <translation>Impossibile trovare il file di quel messaggio</translation>
+ </message>
+ <message>
+ <source>Save message to</source>
+ <translation>Salva il messaggio in</translation>
+ </message>
+ <message>
+ <source>Refusing to write outside %1</source>
+ <translation>Rifiuto di scrivere fuori da %1</translation>
+ </message>
+ <message>
+ <source>Could not write %1</source>
+ <translation>Impossibile scrivere %1</translation>
+ </message>
+ <message>
+ <source>Saved %1</source>
+ <translation>Salvato %1</translation>
+ </message>
+ <message>
<source>&amp;Restore from trash</source>
<translation>&amp;Ripristina dal cestino</translation>
</message>
@@ -560,6 +801,38 @@
<translation>Esce da qtmaildir</translation>
</message>
<message>
+ <source>&amp;New message</source>
+ <translation>Nuovo &amp;messaggio</translation>
+ </message>
+ <message>
+ <source>Compose a new message</source>
+ <translation>Componi un nuovo messaggio</translation>
+ </message>
+ <message>
+ <source>Reply to the displayed message</source>
+ <translation>Rispondi al messaggio visualizzato</translation>
+ </message>
+ <message>
+ <source>Reply to the sender and every other recipient</source>
+ <translation>Rispondi al mittente e a ogni altro destinatario</translation>
+ </message>
+ <message>
+ <source>Reply with an empty body</source>
+ <translation>Rispondi con un corpo vuoto</translation>
+ </message>
+ <message>
+ <source>&amp;Forward</source>
+ <translation>In&amp;oltra</translation>
+ </message>
+ <message>
+ <source>Forward the displayed message</source>
+ <translation>Inoltra il messaggio visualizzato</translation>
+ </message>
+ <message>
+ <source>Write the raw message to a file</source>
+ <translation>Scrive il messaggio grezzo su un file</translation>
+ </message>
+ <message>
<source>&amp;File</source>
<translation>&amp;File</translation>
</message>
@@ -967,6 +1240,21 @@
</message>
</context>
<context>
+ <name>MessageSender</name>
+ <message>
+ <source>The send command crashed.</source>
+ <translation>Il comando di invio si è arrestato in modo anomalo.</translation>
+ </message>
+ <message>
+ <source>The send command exited with status %1 and said nothing.</source>
+ <translation>Il comando di invio è terminato con stato %1 senza fornire spiegazioni.</translation>
+ </message>
+ <message>
+ <source>The send command &apos;%1&apos; could not be started. Check that the path is correct and the file is executable.</source>
+ <translation>Impossibile avviare il comando di invio &apos;%1&apos;. Verifica che il percorso sia corretto e che il file sia eseguibile.</translation>
+ </message>
+</context>
+<context>
<name>MessageView</name>
<message>
<source>Copied the selected text</source>
@@ -1136,6 +1424,10 @@
<translation>Salvato %1</translation>
</message>
<message>
+ <source>This account is receive-only. Add send_command to [account.%1] to send from it.</source>
+ <translation>Questo account è di sola ricezione. Aggiungi send_command a [account.%1] per inviare da esso.</translation>
+ </message>
+ <message>
<source>No message in this thread has an HTML part</source>
<translation>Nessun messaggio di questa conversazione ha una parte HTML</translation>
</message>
@@ -1202,6 +1494,50 @@
<source>Rule &apos;%1&apos;: adds and removes nothing; dropped</source>
<translation>Regola &apos;%1&apos;: non aggiunge né rimuove nulla; scartata</translation>
</message>
+ <message>
+ <source>The account %1 has no address configured, so no message can be sent from it.</source>
+ <translation>L&apos;account %1 non ha un indirizzo configurato, quindi non è possibile inviare messaggi da esso.</translation>
+ </message>
+ <message>
+ <source>The attachment %1 is missing or unreadable.</source>
+ <translation>L&apos;allegato %1 è mancante o non leggibile.</translation>
+ </message>
+ <message>
+ <source>%1 is not an address this can send to.</source>
+ <translation>%1 non è un indirizzo a cui sia possibile inviare.</translation>
+ </message>
+ <message>
+ <source>The attachment %1 could not be read.</source>
+ <translation>Non è stato possibile leggere l&apos;allegato %1.</translation>
+ </message>
+ <message>
+ <source>The message could not be assembled.</source>
+ <translation>Non è stato possibile comporre il messaggio.</translation>
+ </message>
+ <message>
+ <source>No folder was configured to write to.</source>
+ <translation>Nessuna cartella configurata per la scrittura.</translation>
+ </message>
+ <message>
+ <source>Cannot create the folder %1.</source>
+ <translation>Impossibile creare la cartella %1.</translation>
+ </message>
+ <message>
+ <source>Cannot write to %1: %2</source>
+ <translation>Impossibile scrivere su %1: %2</translation>
+ </message>
+ <message>
+ <source>%1 MB</source>
+ <translation>%1 MB</translation>
+ </message>
+ <message>
+ <source>%1 KB</source>
+ <translation>%1 KB</translation>
+ </message>
+ <message>
+ <source>%1 bytes</source>
+ <translation>%1 byte</translation>
+ </message>
</context>
<context>
<name>QueryCompleter</name>
@@ -1254,6 +1590,37 @@
</message>
</context>
<context>
+ <name>SendDialog</name>
+ <message>
+ <source>Sending</source>
+ <translation>Invio in corso</translation>
+ </message>
+ <message>
+ <source>Sending in %1...</source>
+ <translation>Invio tra %1...</translation>
+ </message>
+ <message>
+ <source>Sending...</source>
+ <translation>Invio in corso...</translation>
+ </message>
+ <message>
+ <source>Filing sent copy...</source>
+ <translation>Archiviazione della copia inviata...</translation>
+ </message>
+ <message>
+ <source>Removing draft...</source>
+ <translation>Rimozione della bozza...</translation>
+ </message>
+ <message>
+ <source>Press Undo to stop sending.</source>
+ <translation>Premi Annulla per fermare l&apos;invio.</translation>
+ </message>
+ <message>
+ <source>Undo</source>
+ <translation>Annulla</translation>
+ </message>
+</context>
+<context>
<name>SyncPhaseTracker</name>
<message>
<source>Reindexing (notmuch)...</source>