summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-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
31 files changed, 5165 insertions, 63 deletions
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)