summaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/CMakeLists.txt28
-rw-r--r--tests/test_composecontext.cpp1051
-rw-r--r--tests/test_config.cpp196
-rw-r--r--tests/test_draftstore.cpp255
-rw-r--r--tests/test_formattoolbar.cpp346
-rw-r--r--tests/test_maildirname.cpp93
-rw-r--r--tests/test_mainwindow.cpp2342
-rw-r--r--tests/test_markdownrenderer.cpp151
-rw-r--r--tests/test_messagebuilder.cpp466
-rw-r--r--tests/test_messagesender.cpp532
-rw-r--r--tests/test_senddialog.cpp468
11 files changed, 5924 insertions, 4 deletions
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index d1d8a29..1af49bb 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -68,9 +68,37 @@ add_qtmaildir_test(searchterm)
add_qtmaildir_test(busyindicator)
add_qtmaildir_test(tagstrip)
add_qtmaildir_test(messagedetailsdialog)
+add_qtmaildir_test(markdownrenderer)
+add_qtmaildir_test(messagebuilder)
+add_qtmaildir_test(maildirname)
+add_qtmaildir_test(draftstore)
+add_qtmaildir_test(messagesender)
+add_qtmaildir_test(composecontext)
+add_qtmaildir_test(formattoolbar)
+add_qtmaildir_test(senddialog)
add_qtmaildir_test(translations)
# Asserts on the tracked .ts rather than the generated .qm: an untranslated
# string is dropped by lrelease, so it is invisible in the .qm and shows up
# only as English in a running Italian UI.
target_compile_definitions(test_translations PRIVATE
TRANSLATIONS_DIR="${CMAKE_SOURCE_DIR}/translations")
+
+# The notmuch hooks (assets/hooks/), which are Python rather than C++ and are
+# therefore registered directly rather than through add_qtmaildir_test().
+#
+# They run against the user's REAL mail on every sync, so they belong in the
+# suite rather than beside it as scripts someone remembers to run. Two of the
+# three need `notmuch` on PATH and build a throwaway database in a temp
+# directory; none of them touches the real one.
+#
+# No QT_QPA_PLATFORM here: nothing Qt is involved.
+find_package(Python3 COMPONENTS Interpreter)
+if(Python3_Interpreter_FOUND)
+ foreach(hook_test mailrules post_new qtmaildirconf)
+ add_test(NAME hooks_${hook_test}
+ COMMAND ${Python3_EXECUTABLE}
+ ${CMAKE_SOURCE_DIR}/assets/hooks/test_${hook_test}.py)
+ endforeach()
+else()
+ message(STATUS "Python3 not found: the notmuch hook tests will not run")
+endif()
diff --git a/tests/test_composecontext.cpp b/tests/test_composecontext.cpp
new file mode 100644
index 0000000..fccec87
--- /dev/null
+++ b/tests/test_composecontext.cpp
@@ -0,0 +1,1051 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+// gmime BEFORE any Qt header. glib declares a struct field named "signals",
+// which Qt #defines to Q_SIGNALS. Needed here for g_log_set_handler(), which is
+// how aGroupIsDroppedByTheGuardAndNotByAFailedCast() sees the difference
+// between a group dropped by the guard and one dropped by a failed cast.
+#include <gmime/gmime.h>
+
+#include <QtTest>
+#include <QTemporaryDir>
+
+#include "composecontext.h"
+#include "config.h"
+#include "mimeparser.h"
+#include "types.h"
+
+using ComposeContextBuilder::Recipient;
+
+class TestComposeContext : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ // Own addresses.
+ void everyOwnAddressIsCollected();
+ void aBlankOwnAddressIsNotCollected();
+
+ // Header parsing, the foundation the recipient rules stand on.
+ void aDisplayNameContainingACommaIsOneRecipient();
+ void aQuotedDisplayNameSurvivesRoundTripping();
+ void aMalformedHeaderYieldsNoRecipients();
+ void anEmptyHeaderYieldsNoRecipients();
+ void aGroupContributesNoRecipient();
+ void aGroupIsDroppedByTheGuardAndNotByAFailedCast();
+ void anInjectedHeaderLineIsNotCarriedForward();
+
+ // Reply and reply-all recipient derivation.
+ void aPlainReplyGoesToTheSenderOnly();
+ void aReplyPrefersReplyToOverFrom();
+ void aReplyAllPutsTheSenderInToAndTheRestInCc();
+ void aReplyAllStripsEveryOwnAddress();
+ void aReplyAllStripsAnOwnAddressRegardlessOfCase();
+ void aReplyAllDoesNotListTheSenderTwice();
+ void aReplyAllSuppressesDuplicatesAcrossToAndCc();
+ void aReplyToOneselfStillAddressesSomeone();
+ void aReplyToOwnMessageGoesToItsOriginalRecipients();
+ void aReplyAllToOwnMessageDoesNotRepeatToInCc();
+ void aCoSenderIsStillRepliedTo();
+ void anUnparseableSenderStillProducesARecipient();
+ void aReplyAllPrefersReplyToForTheToField();
+ void aDisplayNameContainingAnOwnAddressIsNotMistakenForIt();
+
+ // References.
+ void referencesCarryTheOriginalChainPlusItsId();
+ void referencesDoNotRepeatTheMessageId();
+ void aCommaSeparatedReferencesHeaderIsSplitIntoIds();
+
+ // Subjects.
+ void aReplySubjectDoesNotDoubleItsPrefix();
+ void aForwardSubjectDoesNotDoubleItsPrefix();
+ void anEmptySubjectStillGetsAPrefix();
+ void aSubjectMentioningReLaterStillGetsAPrefix();
+ void aNonEnglishPrefixIsNotDoubled();
+ void aCountedPrefixIsNotDoubled();
+ void aSingleLetterBeforeAColonIsNotAPrefix();
+
+ // Account resolution.
+ void theReplyAccountComesFromTheMessagesMaildir();
+ void anAccountIsNotMatchedByAPrefixOfItsMaildir();
+ void anAmbiguousMessagePrefersTheMatchingRecipient();
+ void anAmbiguousMessageWithNoMatchTakesTheFirst();
+ void aNewMessagePrefersTheSelectedAccount();
+ void aNewMessageFallsThroughASelectedAccountThatCannotSend();
+ void aNewMessageUsesDefaultAccountFromAllAccounts();
+ void aNewMessageUsesStartupAccountWhenNoDefaultIsSet();
+ void aNewMessageFallsBackToTheFirstSendingAccount();
+ void aNewMessageReturnsNothingWhenNoAccountCanSend();
+
+ // Quoting.
+ void aQuotedBodyPrefixesEveryLine();
+
+private:
+ QString writeConfig(const QString &contents);
+
+ QTemporaryDir m_dir;
+};
+
+QString TestComposeContext::writeConfig(const QString &contents)
+{
+ // A unique name per call: Config caches nothing, but reusing one path
+ // across tests in one binary invites a stale read to look like a pass.
+ static int counter = 0;
+ const QString path =
+ m_dir.filePath(QStringLiteral("qtmaildir%1.conf").arg(++counter));
+ QFile file(path);
+ if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text))
+ return {};
+ file.write(contents.toUtf8());
+ file.close();
+ return path;
+}
+
+// ---------------------------------------------------------------------------
+// Own addresses
+// ---------------------------------------------------------------------------
+
+void TestComposeContext::everyOwnAddressIsCollected()
+{
+ // All five of the user's addresses. Missing one means they receive their
+ // own reply, and with five accounts that is the likeliest bug here.
+ const QString path = writeConfig(QStringLiteral(
+ "[account.one]\nmaildir=one\ntrash=Trash\naddress=first@example.org\n"
+ "[account.two]\nmaildir=two\ntrash=Trash\naddress=second@example.org\n"
+ "[account.three]\nmaildir=three\ntrash=Trash\naddress=third@example.org\n"
+ "[account.four]\nmaildir=four\ntrash=Trash\naddress=fourth@example.org\n"
+ "[account.five]\nmaildir=five\ntrash=Trash\naddress=fifth@example.org\n"));
+ QVERIFY(!path.isEmpty());
+
+ Config config;
+ config.load(path);
+ QCOMPARE(config.accounts().size(), 5);
+
+ const QStringList own = ComposeContextBuilder::ownAddresses(config);
+ QCOMPARE(own.size(), 5);
+ for (const QString &address : { QStringLiteral("first@example.org"),
+ QStringLiteral("second@example.org"),
+ QStringLiteral("third@example.org"),
+ QStringLiteral("fourth@example.org"),
+ QStringLiteral("fifth@example.org") }) {
+ QVERIFY2(own.contains(address),
+ qPrintable(QStringLiteral("own address %1 was not collected").arg(address)));
+ }
+}
+
+void TestComposeContext::aBlankOwnAddressIsNotCollected()
+{
+ // An account with no address key is legal. An empty string in this list
+ // would match nothing usefully and, in a substring filter, everything.
+ const QString path = writeConfig(QStringLiteral(
+ "[account.one]\nmaildir=one\ntrash=Trash\naddress=first@example.org\n"
+ "[account.noaddress]\nmaildir=two\ntrash=Trash\n"));
+ Config config;
+ config.load(path);
+ QCOMPARE(config.accounts().size(), 2);
+
+ const QStringList own = ComposeContextBuilder::ownAddresses(config);
+ QCOMPARE(own, QStringList{ QStringLiteral("first@example.org") });
+}
+
+// ---------------------------------------------------------------------------
+// Header parsing
+// ---------------------------------------------------------------------------
+
+void TestComposeContext::aDisplayNameContainingACommaIsOneRecipient()
+{
+ // The single most likely parsing bug: splitting on commas turns one
+ // recipient into two, one of which ("Rossi") is not an address at all and
+ // would be handed to the send command.
+ const QList<Recipient> parsed = ComposeContextBuilder::parseAddressHeader(
+ QStringLiteral("\"Rossi, Mario\" <m@example.org>, info@example.net"));
+
+ QCOMPARE(parsed.size(), 2);
+ QCOMPARE(parsed.at(0).address, QStringLiteral("m@example.org"));
+ QCOMPARE(parsed.at(1).address, QStringLiteral("info@example.net"));
+}
+
+void TestComposeContext::aQuotedDisplayNameSurvivesRoundTripping()
+{
+ // A comma in a display name must come back out QUOTED. Unquoted, the
+ // rendered form is not a legal single address: it happens to survive
+ // GMime's own lenient re-parse, but it goes into a To: header that other
+ // clients and MTAs read, and a bare comma there is a recipient separator.
+ //
+ // Asserted on the RENDERED TEXT rather than on a re-parse, and that is the
+ // point of the test: a round-trip through parseAddressHeader() passes
+ // against string-assembled "Rossi, Mario <m@example.org>" because GMime
+ // reads it back as one address anyway. Measured 2026-08-21, a mutation
+ // replacing the GMime rendering with `name + " <" + addr + ">"` left the
+ // whole suite green until this assertion was written this way.
+ const QList<Recipient> parsed = ComposeContextBuilder::parseAddressHeader(
+ QStringLiteral("\"Rossi, Mario\" <m@example.org>"));
+ QCOMPARE(parsed.size(), 1);
+ QCOMPARE(parsed.at(0).rendered,
+ QStringLiteral("\"Rossi, Mario\" <m@example.org>"));
+
+ // And it still re-parses to the same one address.
+ const QList<Recipient> again =
+ ComposeContextBuilder::parseAddressHeader(parsed.at(0).rendered);
+ QCOMPARE(again.size(), 1);
+ QCOMPARE(again.at(0).address, QStringLiteral("m@example.org"));
+}
+
+void TestComposeContext::aMalformedHeaderYieldsNoRecipients()
+{
+ // GMime returns NULL rather than an empty list for input it can make
+ // nothing of. Measured 2026-08-21: "not an address at all" and "<<<>>>"
+ // both return NULL.
+ QVERIFY(ComposeContextBuilder::parseAddressHeader(
+ QStringLiteral("not an address at all")).isEmpty());
+ QVERIFY(ComposeContextBuilder::parseAddressHeader(
+ QStringLiteral("<<<>>>")).isEmpty());
+}
+
+void TestComposeContext::anEmptyHeaderYieldsNoRecipients()
+{
+ QVERIFY(ComposeContextBuilder::parseAddressHeader(QString()).isEmpty());
+ QVERIFY(ComposeContextBuilder::parseAddressHeader(
+ QStringLiteral(" ")).isEmpty());
+}
+
+void TestComposeContext::aGroupContributesNoRecipient()
+{
+ // A group has a name and no mailbox. Carrying its name forward would put
+ // "undisclosed-recipients" in a To field as though it were a person.
+ const QList<Recipient> parsed = ComposeContextBuilder::parseAddressHeader(
+ QStringLiteral("undisclosed-recipients:;"));
+ QVERIFY2(parsed.isEmpty(),
+ qPrintable(QStringLiteral("a group produced %1 recipient(s)")
+ .arg(parsed.size())));
+}
+
+void TestComposeContext::aGroupIsDroppedByTheGuardAndNotByAFailedCast()
+{
+ // The count alone cannot see this, which is why the guard survived a
+ // mutation until 2026-08-21. Removing the INTERNET_ADDRESS_IS_MAILBOX check
+ // still yields no recipients, because the invalid cast makes GMime's own
+ // assertion return NULL and the address is skipped one line later. The
+ // count is therefore right for the wrong reason, and the reason matters: an
+ // invalid GObject cast is undefined behaviour papered over by an assertion
+ // that G_DISABLE_CHECKS compiles out and that G_DEBUG=fatal-criticals turns
+ // into an abort. A security property must not rest on assertions staying
+ // enabled.
+ //
+ // So this asserts on the CRITICAL rather than on the count. glib routes it
+ // through the log handler installed here, and a clean parse emits none.
+ struct Captured
+ {
+ static void handler(const gchar *domain, GLogLevelFlags level,
+ const gchar *messageText, gpointer userData)
+ {
+ Q_UNUSED(domain);
+ Q_UNUSED(level);
+ auto *messages = static_cast<QStringList *>(userData);
+ messages->append(QString::fromUtf8(messageText));
+ }
+ };
+
+ // Registered per DOMAIN, and the domain is the trap: the two criticals this
+ // watches for carry "GLib-GObject" and "gmime", while a NULL domain
+ // registers only for the default one. A handler on nullptr alone catches
+ // NOTHING here and the test passes against the mutation, measured
+ // 2026-08-21.
+ QStringList criticals;
+ const auto levels = GLogLevelFlags(G_LOG_LEVEL_CRITICAL | G_LOG_LEVEL_WARNING
+ | G_LOG_FLAG_FATAL | G_LOG_FLAG_RECURSION);
+ QList<guint> handlerIds;
+ for (const char *domain : { "GLib-GObject", "gmime" })
+ handlerIds.append(g_log_set_handler(domain, levels, &Captured::handler, &criticals));
+
+ // A group carrying MEMBERS, not the empty "undisclosed-recipients:;". The
+ // empty form has nothing to cast, so it cannot tell the two paths apart.
+ const QList<Recipient> parsed = ComposeContextBuilder::parseAddressHeader(
+ QStringLiteral("friends: a@example.org, b@example.net;"));
+
+ int i = 0;
+ for (const char *domain : { "GLib-GObject", "gmime" })
+ g_log_remove_handler(domain, handlerIds.at(i++));
+
+ QVERIFY2(parsed.isEmpty(),
+ qPrintable(QStringLiteral("a group with members produced %1 recipient(s)")
+ .arg(parsed.size())));
+ QVERIFY2(criticals.isEmpty(),
+ qPrintable(QStringLiteral("GMime emitted %1 during the parse: %2")
+ .arg(criticals.size())
+ .arg(criticals.join(QLatin1Char('|')))));
+}
+
+void TestComposeContext::anInjectedHeaderLineIsNotCarriedForward()
+{
+ // Header injection, from a stranger's message into the user's reply.
+ // Measured 2026-08-21: GMime parses the smuggled line as a GROUP named
+ // "Bcc", so dropping non-mailboxes drops it. If groups were kept, a reply
+ // would silently pre-fill a recipient the user never saw.
+ const QList<Recipient> parsed = ComposeContextBuilder::parseAddressHeader(
+ QStringLiteral("a@example.org\nBcc: evil@example.net"));
+
+ QCOMPARE(parsed.size(), 1);
+ QCOMPARE(parsed.at(0).address, QStringLiteral("a@example.org"));
+ for (const Recipient &recipient : parsed) {
+ QVERIFY2(!recipient.rendered.contains(QStringLiteral("evil@example.net")),
+ qPrintable(QStringLiteral("injected address survived in: %1")
+ .arg(recipient.rendered)));
+ }
+
+ // The other injection shape: the newline hidden INSIDE a quoted display
+ // name, where it does not split the header and so is not dropped as a
+ // group. It has to come back RFC 2047 encoded, never as a raw newline: a
+ // bare CR or LF in a rendered recipient is a header-injection primitive
+ // the moment anything writes it into a To: line. Rendering by hand rather
+ // than through GMime is what loses the encoding.
+ const QList<Recipient> inName = ComposeContextBuilder::parseAddressHeader(
+ QStringLiteral("\"foo\nBcc: evil@example.net\" <a@example.org>"));
+ QCOMPARE(inName.size(), 1);
+ QCOMPARE(inName.at(0).address, QStringLiteral("a@example.org"));
+ QVERIFY2(!inName.at(0).rendered.contains(QLatin1Char('\n'))
+ && !inName.at(0).rendered.contains(QLatin1Char('\r')),
+ qPrintable(QStringLiteral("a raw newline survived into a rendered "
+ "recipient: %1")
+ .arg(inName.at(0).rendered)));
+}
+
+// ---------------------------------------------------------------------------
+// Reply and reply-all
+// ---------------------------------------------------------------------------
+
+void TestComposeContext::aPlainReplyGoesToTheSenderOnly()
+{
+ ParsedMessage message;
+ message.from = QStringLiteral("Sender <sender@example.org>");
+ message.to = QStringLiteral("me@example.org, other@example.net");
+ message.cc = QStringLiteral("third@example.com");
+
+ QStringList to;
+ QStringList cc;
+ ComposeContextBuilder::recipientsForReply(
+ message, /*replyAll=*/false, { QStringLiteral("me@example.org") }, &to, &cc);
+
+ QCOMPARE(to.size(), 1);
+ QVERIFY2(to.at(0).contains(QStringLiteral("sender@example.org")),
+ qPrintable(QStringLiteral("To was %1").arg(to.join(QLatin1Char('|')))));
+ QVERIFY2(cc.isEmpty(),
+ qPrintable(QStringLiteral("a plain reply put %1 in Cc")
+ .arg(cc.join(QLatin1Char('|')))));
+}
+
+void TestComposeContext::aReplyPrefersReplyToOverFrom()
+{
+ // RFC 5322 3.6.2: Reply-To names where the author wants replies sent. This
+ // is what makes a list reply land on the list rather than on a person who
+ // never asked to be written to directly.
+ ParsedMessage message;
+ message.from = QStringLiteral("Sender <sender@example.org>");
+ message.replyTo = QStringLiteral("List <list@example.net>");
+
+ QStringList to;
+ QStringList cc;
+ ComposeContextBuilder::recipientsForReply(message, /*replyAll=*/false, {}, &to, &cc);
+
+ QCOMPARE(to.size(), 1);
+ QVERIFY2(to.at(0).contains(QStringLiteral("list@example.net")),
+ qPrintable(QStringLiteral("To was %1, expected the Reply-To")
+ .arg(to.join(QLatin1Char('|')))));
+ QVERIFY2(!to.at(0).contains(QStringLiteral("sender@example.org")),
+ "From was used despite a Reply-To being present");
+}
+
+void TestComposeContext::aReplyAllPutsTheSenderInToAndTheRestInCc()
+{
+ ParsedMessage message;
+ message.from = QStringLiteral("Sender <sender@example.org>");
+ message.to = QStringLiteral("first@example.net");
+ message.cc = QStringLiteral("second@example.com");
+
+ QStringList to;
+ QStringList cc;
+ ComposeContextBuilder::recipientsForReply(message, /*replyAll=*/true, {}, &to, &cc);
+
+ QCOMPARE(to.size(), 1);
+ QVERIFY(to.at(0).contains(QStringLiteral("sender@example.org")));
+
+ QCOMPARE(cc.size(), 2);
+ QVERIFY2(cc.join(QLatin1Char('|')).contains(QStringLiteral("first@example.net")),
+ qPrintable(QStringLiteral("Cc was %1").arg(cc.join(QLatin1Char('|')))));
+ QVERIFY2(cc.join(QLatin1Char('|')).contains(QStringLiteral("second@example.com")),
+ qPrintable(QStringLiteral("Cc was %1").arg(cc.join(QLatin1Char('|')))));
+}
+
+void TestComposeContext::aReplyAllStripsEveryOwnAddress()
+{
+ // Five accounts, and the user's address appears in the original's To under
+ // THREE of them. Stripping only the first is the exact failure this guards:
+ // the reply-all would then be addressed to the user twice over.
+ ParsedMessage message;
+ message.from = QStringLiteral("Sender <sender@example.org>");
+ message.to = QStringLiteral(
+ "first@example.org, stranger@example.net, third@example.org");
+ message.cc = QStringLiteral("fifth@example.org, another@example.com");
+
+ const QStringList own = { QStringLiteral("first@example.org"),
+ QStringLiteral("second@example.org"),
+ QStringLiteral("third@example.org"),
+ QStringLiteral("fourth@example.org"),
+ QStringLiteral("fifth@example.org") };
+
+ QStringList to;
+ QStringList cc;
+ ComposeContextBuilder::recipientsForReply(message, /*replyAll=*/true, own, &to, &cc);
+
+ const QString all = (to + cc).join(QLatin1Char('|'));
+ for (const QString &address : own) {
+ QVERIFY2(!all.contains(address, Qt::CaseInsensitive),
+ qPrintable(QStringLiteral("own address %1 survived in: %2")
+ .arg(address, all)));
+ }
+ // And the strangers must all still be there: a filter that removed
+ // everything would pass the check above while producing an unsendable reply.
+ QVERIFY2(all.contains(QStringLiteral("stranger@example.net")),
+ qPrintable(QStringLiteral("a stranger was stripped too: %1").arg(all)));
+ QVERIFY2(all.contains(QStringLiteral("another@example.com")),
+ qPrintable(QStringLiteral("a stranger was stripped too: %1").arg(all)));
+ QVERIFY2(all.contains(QStringLiteral("sender@example.org")),
+ qPrintable(QStringLiteral("the sender was stripped: %1").arg(all)));
+}
+
+void TestComposeContext::aReplyAllStripsAnOwnAddressRegardlessOfCase()
+{
+ // A domain is case-insensitive by RFC and real mail varies the local part's
+ // case too. A case-sensitive filter lets the user's own address through and
+ // they receive their own reply.
+ ParsedMessage message;
+ message.from = QStringLiteral("Sender <sender@example.org>");
+ message.to = QStringLiteral("Me@Example.ORG, stranger@example.net");
+
+ QStringList to;
+ QStringList cc;
+ ComposeContextBuilder::recipientsForReply(
+ message, /*replyAll=*/true, { QStringLiteral("me@example.org") }, &to, &cc);
+
+ const QString all = (to + cc).join(QLatin1Char('|'));
+ QVERIFY2(!all.contains(QStringLiteral("Me@Example.ORG"), Qt::CaseInsensitive),
+ qPrintable(QStringLiteral("a differently-cased own address survived: %1")
+ .arg(all)));
+ QVERIFY(all.contains(QStringLiteral("stranger@example.net")));
+}
+
+void TestComposeContext::aReplyAllDoesNotListTheSenderTwice()
+{
+ // The sender is very often also in their own message's To (a list posting
+ // reflected back). Without cross-field suppression they appear in To AND Cc.
+ ParsedMessage message;
+ message.from = QStringLiteral("Sender <sender@example.org>");
+ message.to = QStringLiteral("sender@example.org, stranger@example.net");
+
+ QStringList to;
+ QStringList cc;
+ ComposeContextBuilder::recipientsForReply(message, /*replyAll=*/true, {}, &to, &cc);
+
+ const QString all = (to + cc).join(QLatin1Char('|'));
+ QCOMPARE(all.count(QStringLiteral("sender@example.org")), 1);
+ QVERIFY(all.contains(QStringLiteral("stranger@example.net")));
+}
+
+void TestComposeContext::aReplyAllSuppressesDuplicatesAcrossToAndCc()
+{
+ // The same address in the original's To and Cc, with different display
+ // names so a whole-string comparison would treat them as distinct.
+ ParsedMessage message;
+ message.from = QStringLiteral("Sender <sender@example.org>");
+ message.to = QStringLiteral("Person One <dup@example.net>");
+ message.cc = QStringLiteral("P. One <dup@example.net>, other@example.com");
+
+ QStringList to;
+ QStringList cc;
+ ComposeContextBuilder::recipientsForReply(message, /*replyAll=*/true, {}, &to, &cc);
+
+ const QString all = (to + cc).join(QLatin1Char('|'));
+ QCOMPARE(all.count(QStringLiteral("dup@example.net")), 1);
+ QVERIFY(all.contains(QStringLiteral("other@example.com")));
+}
+
+void TestComposeContext::aReplyToOneselfStillAddressesSomeone()
+{
+ // Replying to a message the user sent themselves. Stripping own addresses
+ // from a plain Reply's To would leave a message with no recipient that
+ // still looks sendable.
+ ParsedMessage message;
+ message.from = QStringLiteral("Me <me@example.org>");
+ message.to = QStringLiteral("me@example.org");
+
+ QStringList to;
+ QStringList cc;
+ ComposeContextBuilder::recipientsForReply(
+ message, /*replyAll=*/false, { QStringLiteral("me@example.org") }, &to, &cc);
+
+ QVERIFY2(!to.isEmpty(), "a reply to oneself produced no recipient at all");
+ QVERIFY(to.join(QLatin1Char('|')).contains(QStringLiteral("me@example.org")));
+}
+
+void TestComposeContext::aReplyToOwnMessageGoesToItsOriginalRecipients()
+{
+ // The Sent view, and a follow-up on unanswered mail: the user replies to a
+ // message they sent. Addressing the sender there addresses the user, so To
+ // comes from the original's own recipients instead. The Cc entry is
+ // included because a reply to a conversation the user started belongs to
+ // everyone who was on it.
+ ParsedMessage message;
+ message.from = QStringLiteral("Me <me@example.org>");
+ message.to = QStringLiteral("Correspondent <them@example.net>");
+ message.cc = QStringLiteral("watcher@example.com");
+
+ QStringList to;
+ QStringList cc;
+ ComposeContextBuilder::recipientsForReply(
+ message, /*replyAll=*/false, { QStringLiteral("me@example.org") }, &to, &cc);
+
+ const QString joined = to.join(QLatin1Char('|'));
+ QVERIFY2(joined.contains(QStringLiteral("them@example.net")),
+ qPrintable(QStringLiteral("To was %1").arg(joined)));
+ QVERIFY2(joined.contains(QStringLiteral("watcher@example.com")),
+ qPrintable(QStringLiteral("To was %1").arg(joined)));
+ // The whole point: the user is not written back to themselves.
+ QVERIFY2(!joined.contains(QStringLiteral("me@example.org")),
+ qPrintable(QStringLiteral("the reply addressed the user: %1").arg(joined)));
+ QVERIFY2(cc.isEmpty(), "a plain reply produced a Cc");
+}
+
+void TestComposeContext::aReplyAllToOwnMessageDoesNotRepeatToInCc()
+{
+ // Reply-all to your own message MIRRORS the original's split: its To
+ // becomes To, its Cc becomes Cc. The split is the message's meaning, To
+ // being "addressed to you" and Cc "for information", and promoting a Cc'd
+ // party to To is visible to every recipient.
+ //
+ // Asserted per FIELD, not on the union. A test counting each address once
+ // across to + cc passes whether the split is preserved or collapsed, which
+ // is how the collapse shipped and survived its first mutation check.
+ ParsedMessage message;
+ message.from = QStringLiteral("Me <me@example.org>");
+ message.to = QStringLiteral("them@example.net");
+ message.cc = QStringLiteral("watcher@example.com");
+
+ QStringList to;
+ QStringList cc;
+ ComposeContextBuilder::recipientsForReply(
+ message, /*replyAll=*/true, { QStringLiteral("me@example.org") }, &to, &cc);
+
+ const QString toJoined = to.join(QLatin1Char('|'));
+ const QString ccJoined = cc.join(QLatin1Char('|'));
+ QVERIFY2(toJoined.contains(QStringLiteral("them@example.net")),
+ qPrintable(QStringLiteral("To was %1").arg(toJoined)));
+ QVERIFY2(!toJoined.contains(QStringLiteral("watcher@example.com")),
+ qPrintable(QStringLiteral("a Cc recipient was promoted to To: %1").arg(toJoined)));
+ QVERIFY2(ccJoined.contains(QStringLiteral("watcher@example.com")),
+ qPrintable(QStringLiteral("Cc was %1").arg(ccJoined)));
+ QVERIFY2(!ccJoined.contains(QStringLiteral("them@example.net")),
+ qPrintable(QStringLiteral("the To address repeated in Cc: %1").arg(ccJoined)));
+ // The whole point of the self-reply rule.
+ QVERIFY2(!(toJoined + ccJoined).contains(QStringLiteral("me@example.org")),
+ "the reply addressed the user");
+}
+
+void TestComposeContext::aCoSenderIsStillRepliedTo()
+{
+ // A message the user sent WITH somebody else is not a message to oneself.
+ // Only an all-own sender diverts To to the original recipients; here the
+ // co-sender is a real person expecting the reply.
+ ParsedMessage message;
+ message.from = QStringLiteral("Me <me@example.org>, Other <other@example.net>");
+ message.to = QStringLiteral("them@example.com");
+
+ QStringList to;
+ QStringList cc;
+ ComposeContextBuilder::recipientsForReply(
+ message, /*replyAll=*/false, { QStringLiteral("me@example.org") }, &to, &cc);
+
+ const QString joined = to.join(QLatin1Char('|'));
+ QVERIFY2(joined.contains(QStringLiteral("other@example.net")),
+ qPrintable(QStringLiteral("To was %1").arg(joined)));
+ QVERIFY2(!joined.contains(QStringLiteral("them@example.com")),
+ qPrintable(QStringLiteral("a plain reply reached the original's To: %1")
+ .arg(joined)));
+}
+
+void TestComposeContext::anUnparseableSenderStillProducesARecipient()
+{
+ // "From: Mailer Daemon" is a bare display name with no angle brackets, which
+ // is what bounces and some automated senders emit. It parses to ZERO
+ // mailboxes, so the sender contributes nothing and To would otherwise come
+ // out empty.
+ //
+ // An empty To is the worst outcome available here, because MessageBuilder
+ // treats an empty recipient list as success: the message reaches the send
+ // command with nobody to deliver to and a copy is filed in Sent that looks
+ // sent and reached no one. The original's own recipients are the remaining
+ // candidates.
+ ParsedMessage message;
+ message.from = QStringLiteral("Mailer Daemon");
+ message.to = QStringLiteral("them@example.net");
+ message.cc = QStringLiteral("watcher@example.com");
+
+ QStringList to;
+ QStringList cc;
+ ComposeContextBuilder::recipientsForReply(
+ message, /*replyAll=*/false, { QStringLiteral("me@example.org") }, &to, &cc);
+
+ QVERIFY2(!to.isEmpty(), "an unparseable sender produced a reply with no recipient");
+ QVERIFY2(to.join(QLatin1Char('|')).contains(QStringLiteral("them@example.net")),
+ qPrintable(QStringLiteral("To was %1").arg(to.join(QLatin1Char('|')))));
+
+ // Reply-all is the worse half: without the fallback it puts every recipient
+ // in Cc and leaves To empty, which is a message addressed to nobody.
+ QStringList allTo;
+ QStringList allCc;
+ ComposeContextBuilder::recipientsForReply(
+ message, /*replyAll=*/true, { QStringLiteral("me@example.org") }, &allTo, &allCc);
+ QVERIFY2(!allTo.isEmpty(), "a reply-all to an unparseable sender left To empty");
+}
+
+void TestComposeContext::aReplyAllPrefersReplyToForTheToField()
+{
+ // Reply-To precedence is not a plain-Reply-only rule: a list's reply-all
+ // must also go to the list rather than to the individual poster.
+ ParsedMessage message;
+ message.from = QStringLiteral("Poster <poster@example.org>");
+ message.replyTo = QStringLiteral("List <list@example.net>");
+ message.to = QStringLiteral("list@example.net");
+ message.cc = QStringLiteral("watcher@example.com");
+
+ QStringList to;
+ QStringList cc;
+ ComposeContextBuilder::recipientsForReply(message, /*replyAll=*/true, {}, &to, &cc);
+
+ QCOMPARE(to.size(), 1);
+ QVERIFY2(to.at(0).contains(QStringLiteral("list@example.net")),
+ qPrintable(QStringLiteral("To was %1").arg(to.join(QLatin1Char('|')))));
+ // The list is in To, so it must not repeat in Cc even though the original's
+ // To named it.
+ QVERIFY2(!cc.join(QLatin1Char('|')).contains(QStringLiteral("list@example.net")),
+ qPrintable(QStringLiteral("the To address repeated in Cc: %1")
+ .arg(cc.join(QLatin1Char('|')))));
+ QVERIFY(cc.join(QLatin1Char('|')).contains(QStringLiteral("watcher@example.com")));
+}
+
+void TestComposeContext::aDisplayNameContainingAnOwnAddressIsNotMistakenForIt()
+{
+ // A stranger whose DISPLAY NAME quotes the user's address. Comparing the
+ // rendered whole rather than the addr-spec would strip a real recipient,
+ // and the reply would silently not reach them.
+ ParsedMessage message;
+ message.from = QStringLiteral("Sender <sender@example.org>");
+ message.to = QStringLiteral("\"about me@example.org\" <stranger@example.net>");
+
+ QStringList to;
+ QStringList cc;
+ ComposeContextBuilder::recipientsForReply(
+ message, /*replyAll=*/true, { QStringLiteral("me@example.org") }, &to, &cc);
+
+ QVERIFY2((to + cc).join(QLatin1Char('|')).contains(QStringLiteral("stranger@example.net")),
+ "a stranger was stripped because their display name quoted an own address");
+}
+
+// ---------------------------------------------------------------------------
+// References
+// ---------------------------------------------------------------------------
+
+void TestComposeContext::referencesCarryTheOriginalChainPlusItsId()
+{
+ ParsedMessage message;
+ message.messageId = QStringLiteral("current@example.org");
+ message.references =
+ QStringLiteral("<first@example.org> <second@example.org>");
+
+ const QStringList refs = ComposeContextBuilder::referencesForReply(message);
+
+ QCOMPARE(refs.size(), 3);
+ QCOMPARE(refs.at(0), QStringLiteral("first@example.org"));
+ QCOMPARE(refs.at(1), QStringLiteral("second@example.org"));
+ QCOMPARE(refs.at(2), QStringLiteral("current@example.org"));
+}
+
+void TestComposeContext::referencesDoNotRepeatTheMessageId()
+{
+ ParsedMessage message;
+ message.messageId = QStringLiteral("current@example.org");
+ message.references = QStringLiteral("<first@example.org> <current@example.org>");
+
+ const QStringList refs = ComposeContextBuilder::referencesForReply(message);
+
+ QCOMPARE(refs.count(QStringLiteral("current@example.org")), 1);
+ QCOMPARE(refs.last(), QStringLiteral("current@example.org"));
+}
+
+// ---------------------------------------------------------------------------
+// Subjects
+// ---------------------------------------------------------------------------
+
+void TestComposeContext::aCommaSeparatedReferencesHeaderIsSplitIntoIds()
+{
+ // `<a@x>,<b@y>` is not conformant, RFC 5322 has no comma here, but some
+ // clients emit it. Splitting on whitespace alone makes that whole header ONE
+ // token, and stripping its outer brackets then yields the fabricated id
+ // `a@x>,<b@y`, which is sent to the recipient as a Message-ID reference. A
+ // comma cannot occur inside a msg-id, so accepting it as a separator is free.
+ ParsedMessage message;
+ message.references = QStringLiteral("<first@example.org>,<second@example.org>");
+ message.messageId = QStringLiteral("current@example.org");
+
+ const QStringList refs = ComposeContextBuilder::referencesForReply(message);
+
+ QCOMPARE(refs.size(), 3);
+ QCOMPARE(refs.at(0), QStringLiteral("first@example.org"));
+ QCOMPARE(refs.at(1), QStringLiteral("second@example.org"));
+ QCOMPARE(refs.at(2), QStringLiteral("current@example.org"));
+}
+
+void TestComposeContext::aReplySubjectDoesNotDoubleItsPrefix()
+{
+ QCOMPARE(ComposeContextBuilder::replySubject(QStringLiteral("Hello")),
+ QStringLiteral("Re: Hello"));
+ QCOMPARE(ComposeContextBuilder::replySubject(QStringLiteral("Re: Hello")),
+ QStringLiteral("Re: Hello"));
+ // Case and spacing vary between clients and neither justifies a second
+ // prefix. "RE:" from Outlook is the common one.
+ QCOMPARE(ComposeContextBuilder::replySubject(QStringLiteral("RE: Hello")),
+ QStringLiteral("RE: Hello"));
+ QCOMPARE(ComposeContextBuilder::replySubject(QStringLiteral("re:Hello")),
+ QStringLiteral("re:Hello"));
+}
+
+void TestComposeContext::aForwardSubjectDoesNotDoubleItsPrefix()
+{
+ QCOMPARE(ComposeContextBuilder::forwardSubject(QStringLiteral("Hello")),
+ QStringLiteral("Fwd: Hello"));
+ QCOMPARE(ComposeContextBuilder::forwardSubject(QStringLiteral("Fwd: Hello")),
+ QStringLiteral("Fwd: Hello"));
+ // "Fw:" is the other common spelling and means the same thing.
+ QCOMPARE(ComposeContextBuilder::forwardSubject(QStringLiteral("Fw: Hello")),
+ QStringLiteral("Fw: Hello"));
+}
+
+void TestComposeContext::anEmptySubjectStillGetsAPrefix()
+{
+ // A reply to a subjectless message is still a reply. "Re: " alone is
+ // correct and is what every other client produces.
+ QCOMPARE(ComposeContextBuilder::replySubject(QString()),
+ QStringLiteral("Re: "));
+}
+
+void TestComposeContext::aSubjectMentioningReLaterStillGetsAPrefix()
+{
+ // The prefix test is ANCHORED. An unanchored search would see "re:" inside
+ // an ordinary subject and refuse to prefix a genuine first reply, which
+ // breaks threading in the recipient's client.
+ QCOMPARE(ComposeContextBuilder::replySubject(QStringLiteral("Notes re: budget")),
+ QStringLiteral("Re: Notes re: budget"));
+ QCOMPARE(ComposeContextBuilder::forwardSubject(QStringLiteral("Notes fwd: budget")),
+ QStringLiteral("Fwd: Notes fwd: budget"));
+}
+
+void TestComposeContext::aNonEnglishPrefixIsNotDoubled()
+{
+ // A mixed-locale mailbox, which this one is. An English-only pattern turns
+ // every one of these into "Re: AW: subject", and the round after that into
+ // "Re: Re: AW:".
+ QCOMPARE(ComposeContextBuilder::replySubject(QStringLiteral("AW: Angebot")),
+ QStringLiteral("AW: Angebot"));
+ QCOMPARE(ComposeContextBuilder::replySubject(QStringLiteral("SV: innkalling")),
+ QStringLiteral("SV: innkalling"));
+ QCOMPARE(ComposeContextBuilder::replySubject(QStringLiteral("RES: pedido")),
+ QStringLiteral("RES: pedido"));
+ QCOMPARE(ComposeContextBuilder::forwardSubject(QStringLiteral("WG: Angebot")),
+ QStringLiteral("WG: Angebot"));
+ QCOMPARE(ComposeContextBuilder::forwardSubject(QStringLiteral("TR: document")),
+ QStringLiteral("TR: document"));
+}
+
+void TestComposeContext::aCountedPrefixIsNotDoubled()
+{
+ // Outlook and some list managers count the rounds. Same meaning, and
+ // prefixing again produces "Re: Re[2]:".
+ QCOMPARE(ComposeContextBuilder::replySubject(QStringLiteral("Re[2]: thread")),
+ QStringLiteral("Re[2]: thread"));
+ QCOMPARE(ComposeContextBuilder::replySubject(QStringLiteral("Re(3): thread")),
+ QStringLiteral("Re(3): thread"));
+}
+
+void TestComposeContext::aSingleLetterBeforeAColonIsNotAPrefix()
+{
+ // Italian clients do send "R:" and "I:", and they are deliberately NOT
+ // recognised. Measured 2026-08-21: with them in the pattern, "R: report on
+ // Q3" reads as an existing prefix, so a genuine FIRST reply gets no "Re:"
+ // and threads nowhere in the recipient's client, with nothing wrong to see
+ // locally. A doubled "Re: R:" is cosmetic; broken threading is not.
+ //
+ // "F:" is here for the same reason: the pattern was once `fwd?`, which
+ // matched it.
+ QCOMPARE(ComposeContextBuilder::replySubject(QStringLiteral("R: report on Q3")),
+ QStringLiteral("Re: R: report on Q3"));
+ QCOMPARE(ComposeContextBuilder::forwardSubject(QStringLiteral("I: notes")),
+ QStringLiteral("Fwd: I: notes"));
+ QCOMPARE(ComposeContextBuilder::forwardSubject(QStringLiteral("F: results")),
+ QStringLiteral("Fwd: F: results"));
+}
+
+// ---------------------------------------------------------------------------
+// Account resolution
+// ---------------------------------------------------------------------------
+
+void TestComposeContext::theReplyAccountComesFromTheMessagesMaildir()
+{
+ // The dropdown is NOT consulted: replying from the All accounts view to a
+ // message that arrived at account B sends from B.
+ const QString path = writeConfig(QStringLiteral(
+ "[account.work]\nmaildir=work\ntrash=Trash\naddress=work@example.org\n"
+ "send_command=/bin/true\n"
+ "[account.home]\nmaildir=home\ntrash=Trash\naddress=home@example.org\n"
+ "send_command=/bin/true\n"));
+ QVERIFY(!path.isEmpty());
+
+ Config config;
+ config.load(path);
+ QCOMPARE(config.accounts().size(), 2);
+
+ const QString account = ComposeContextBuilder::accountForReply(
+ config, { QStringLiteral("/mail/home/INBOX/cur/123") },
+ { QStringLiteral("home@example.org") }, QStringLiteral("/mail"));
+
+ QCOMPARE(account, QStringLiteral("home"));
+}
+
+void TestComposeContext::anAccountIsNotMatchedByAPrefixOfItsMaildir()
+{
+ // "work" must not claim a message living in "work-archive". Without the
+ // separator in the comparison it does, and the reply is sent from the
+ // wrong account.
+ //
+ // The account KEYS are chosen so the wrong answer is reached FIRST.
+ // Config builds its list from QSettings::childGroups(), which returns
+ // groups ALPHABETICALLY rather than in file order, so the section order
+ // here decides nothing and only the keys do. With "archive" before "work"
+ // the loop happens upon the correct account before it can mismatch, and
+ // the test passes against the bug: measured, a mutation dropping the
+ // separator left the suite fully green. "a-work" (maildir "work") sorts
+ // before "b-archive" (maildir "work-archive") and puts the prefix
+ // candidate first, where a textual comparison matches it.
+ const QString path = writeConfig(QStringLiteral(
+ "[account.a-work]\nmaildir=work\ntrash=Trash\naddress=work@example.org\n"
+ "send_command=/bin/true\n"
+ "[account.b-archive]\nmaildir=work-archive\ntrash=Trash\n"
+ "address=archive@example.org\nsend_command=/bin/true\n"));
+ Config config;
+ config.load(path);
+ QCOMPARE(config.accounts().size(), 2);
+ // The ordering the mutation depends on, asserted rather than assumed: if
+ // Config ever sorts differently this test silently stops testing anything.
+ QCOMPARE(config.accounts().at(0).key, QStringLiteral("a-work"));
+
+ const QString account = ComposeContextBuilder::accountForReply(
+ config, { QStringLiteral("/mail/work-archive/INBOX/cur/1") },
+ {}, QStringLiteral("/mail"));
+
+ QCOMPARE(account, QStringLiteral("b-archive"));
+}
+
+void TestComposeContext::anAmbiguousMessagePrefersTheMatchingRecipient()
+{
+ // One message, two maildirs: on a list twice under two addresses. The
+ // recipient headers are the tiebreak.
+ const QString path = writeConfig(QStringLiteral(
+ "[account.work]\nmaildir=work\ntrash=Trash\naddress=work@example.org\n"
+ "send_command=/bin/true\n"
+ "[account.home]\nmaildir=home\ntrash=Trash\naddress=home@example.org\n"
+ "send_command=/bin/true\n"));
+ QVERIFY(!path.isEmpty());
+
+ Config config;
+ config.load(path);
+
+ const QString account = ComposeContextBuilder::accountForReply(
+ config,
+ { QStringLiteral("/mail/work/Lists/cur/1"),
+ QStringLiteral("/mail/home/Lists/cur/1") },
+ { QStringLiteral("home@example.org") }, QStringLiteral("/mail"));
+
+ QCOMPARE(account, QStringLiteral("home"));
+}
+
+void TestComposeContext::anAmbiguousMessageWithNoMatchTakesTheFirst()
+{
+ // Arbitrary, and deliberately so: the From field shows the choice, which
+ // makes an arbitrary resolution visible rather than hidden.
+ const QString path = writeConfig(QStringLiteral(
+ "[account.work]\nmaildir=work\ntrash=Trash\naddress=work@example.org\n"
+ "send_command=/bin/true\n"
+ "[account.home]\nmaildir=home\ntrash=Trash\naddress=home@example.org\n"
+ "send_command=/bin/true\n"));
+ Config config;
+ config.load(path);
+
+ const QString account = ComposeContextBuilder::accountForReply(
+ config,
+ { QStringLiteral("/mail/work/Lists/cur/1"),
+ QStringLiteral("/mail/home/Lists/cur/1") },
+ { QStringLiteral("someone-else@example.org") }, QStringLiteral("/mail"));
+
+ QVERIFY2(!account.isEmpty(), "an ambiguous message resolved to no account");
+ QCOMPARE(account, QStringLiteral("work"));
+}
+
+void TestComposeContext::aNewMessagePrefersTheSelectedAccount()
+{
+ const QString path = writeConfig(QStringLiteral(
+ "[account.work]\nmaildir=work\ntrash=Trash\nsend_command=/bin/true\n"
+ "[account.home]\nmaildir=home\ntrash=Trash\nsend_command=/bin/true\n"));
+ Config config;
+ config.load(path);
+ QCOMPARE(config.accounts().size(), 2);
+
+ QCOMPARE(ComposeContextBuilder::accountForNew(config, QStringLiteral("home")),
+ QStringLiteral("home"));
+}
+
+void TestComposeContext::aNewMessageFallsThroughASelectedAccountThatCannotSend()
+{
+ // Rule 1 requires the selected account CAN send. Viewing a receive-only
+ // account and pressing compose must produce a working composer from
+ // another account, not a broken one from this.
+ const QString path = writeConfig(QStringLiteral(
+ "[account.listsonly]\nmaildir=listsonly\ntrash=Trash\n"
+ "[account.work]\nmaildir=work\ntrash=Trash\nsend_command=/bin/true\n"));
+ Config config;
+ config.load(path);
+ QCOMPARE(config.accounts().size(), 2);
+
+ QCOMPARE(ComposeContextBuilder::accountForNew(config, QStringLiteral("listsonly")),
+ QStringLiteral("work"));
+}
+
+void TestComposeContext::aNewMessageUsesDefaultAccountFromAllAccounts()
+{
+ // The All accounts view has no selected account and falls through to rule 2.
+ //
+ // The named account must NOT also be what rule 4 would answer, or the test
+ // passes with rule 2 deleted outright: measured, a mutation removing it
+ // left the suite green because the account list is ALPHABETICAL (Config
+ // builds it from QSettings::childGroups()) and the section order in this
+ // string decides nothing. "zeta" sorts last, so rule 4 would answer
+ // "alpha" and only rule 2 can produce "zeta".
+ const QString path = writeConfig(QStringLiteral(
+ "[account.alpha]\nmaildir=alpha\ntrash=Trash\nsend_command=/bin/true\n"
+ "[account.zeta]\nmaildir=zeta\ntrash=Trash\nsend_command=/bin/true\n"
+ "[compose]\ndefault_account=zeta\n"));
+ Config config;
+ config.load(path);
+ QCOMPARE(config.compose().defaultAccount, QStringLiteral("zeta"));
+ // Asserted rather than assumed, so the test stops silently proving nothing
+ // if Config ever changes its ordering.
+ QCOMPARE(config.sendingAccounts().first().key, QStringLiteral("alpha"));
+
+ QCOMPARE(ComposeContextBuilder::accountForNew(config, QString()),
+ QStringLiteral("zeta"));
+}
+
+void TestComposeContext::aNewMessageUsesStartupAccountWhenNoDefaultIsSet()
+{
+ // Rule 3. Same ordering trap as rule 2: "zeta" must not be what rule 4
+ // would answer, or a test for this rule passes with the rule deleted.
+ const QString path = writeConfig(QStringLiteral(
+ "[general]\nstartup_account=zeta\n"
+ "[account.alpha]\nmaildir=alpha\ntrash=Trash\nsend_command=/bin/true\n"
+ "[account.zeta]\nmaildir=zeta\ntrash=Trash\nsend_command=/bin/true\n"));
+ Config config;
+ config.load(path);
+ QCOMPARE(config.startupAccount(), QStringLiteral("zeta"));
+ QVERIFY(config.compose().defaultAccount.isEmpty());
+ QCOMPARE(config.sendingAccounts().first().key, QStringLiteral("alpha"));
+
+ QCOMPARE(ComposeContextBuilder::accountForNew(config, QString()),
+ QStringLiteral("zeta"));
+}
+
+void TestComposeContext::aNewMessageFallsBackToTheFirstSendingAccount()
+{
+ // Rule 4, arbitrary, and the reason rules 2 and 3 exist. The receive-only
+ // account is FIRST, so "the first account" and "the first sending account"
+ // are different answers and the test distinguishes them.
+ const QString path = writeConfig(QStringLiteral(
+ "[account.listsonly]\nmaildir=listsonly\ntrash=Trash\n"
+ "[account.work]\nmaildir=work\ntrash=Trash\nsend_command=/bin/true\n"));
+ Config config;
+ config.load(path);
+ QCOMPARE(config.accounts().size(), 2);
+
+ QCOMPARE(ComposeContextBuilder::accountForNew(config, QString()),
+ QStringLiteral("work"));
+}
+
+void TestComposeContext::aNewMessageReturnsNothingWhenNoAccountCanSend()
+{
+ // A valid read-only installation. The compose action is disabled, so this
+ // should be unreachable, and returning empty rather than a random account
+ // is what makes a mistake visible instead of silent.
+ const QString path = writeConfig(QStringLiteral(
+ "[account.listsonly]\nmaildir=listsonly\ntrash=Trash\n"));
+ Config config;
+ config.load(path);
+ QCOMPARE(config.accounts().size(), 1);
+
+ QVERIFY(ComposeContextBuilder::accountForNew(config, QString()).isEmpty());
+}
+
+// ---------------------------------------------------------------------------
+// Quoting
+// ---------------------------------------------------------------------------
+
+void TestComposeContext::aQuotedBodyPrefixesEveryLine()
+{
+ ParsedMessage message;
+ message.from = QStringLiteral("Sender <sender@example.org>");
+ message.date = QStringLiteral("Thu, 20 Aug 2026 10:00:00 +0200");
+ message.plainBody = QStringLiteral("first line\nsecond line\n\nafter a blank");
+
+ const QString quoted = ComposeContextBuilder::quoteBody(message);
+
+ QVERIFY2(quoted.contains(QStringLiteral("> first line")),
+ qPrintable(QStringLiteral("first line not quoted:\n%1").arg(quoted)));
+ QVERIFY2(quoted.contains(QStringLiteral("> second line")),
+ "second line not quoted");
+ // A blank line inside a quote must still carry the marker, or the quote
+ // visually ends there in every client that renders it.
+ QVERIFY2(quoted.contains(QStringLiteral("\n>\n")),
+ qPrintable(QStringLiteral("a blank line lost its marker:\n%1").arg(quoted)));
+ QVERIFY2(quoted.contains(QStringLiteral("sender@example.org")),
+ "no attribution line naming the sender");
+ // A CRLF body must not leave a stray carriage return before every marker.
+ ParsedMessage crlf;
+ crlf.plainBody = QStringLiteral("one\r\ntwo");
+ const QString quotedCrlf = ComposeContextBuilder::quoteBody(crlf);
+ QVERIFY2(!quotedCrlf.contains(QLatin1Char('\r')),
+ qPrintable(QStringLiteral("a carriage return survived quoting: %1")
+ .arg(quotedCrlf)));
+}
+
+QTEST_MAIN(TestComposeContext)
+#include "test_composecontext.moc"
diff --git a/tests/test_config.cpp b/tests/test_config.cpp
index ea5c363..a902425 100644
--- a/tests/test_config.cpp
+++ b/tests/test_config.cpp
@@ -121,6 +121,16 @@ private slots:
void anAccountWithoutATrashFolderWarns();
void theTrashFilterComposesPerAccount();
void theTrashFilterMatchesNothingWithoutAFolder();
+ void anAccountWithoutASendCommandIsReceiveOnly();
+ void composeSettingsDefaultWhenTheSectionIsAbsent();
+ void aZeroSendDelayIsHonouredRatherThanTreatedAsUnset();
+ void aDefaultAccountThatCannotSendIsWarnedAbout();
+ void anInstallationWhereNoAccountCanSendIsNotWarnedAbout();
+ void garbageAutosaveIntervalIsRejectedNotZero();
+ void garbageSendDelayIsRejectedNotZero();
+ void garbageAttachmentWarnBytesIsRejectedNotZero();
+ void zeroOrNegativeAutosaveIntervalIsClamped();
+ void unrecognisedQuotePositionWarnsAndFallsBackToAbove();
};
static QString writeIni(const QTemporaryDir &dir, const QString &body)
@@ -2313,5 +2323,191 @@ void TestConfig::aGeneratedEntryWritesNoRedundantKeys()
"flat must come back from the generator, not from the file");
}
+void TestConfig::anAccountWithoutASendCommandIsReceiveOnly()
+{
+ // The capability IS the command's presence, and nothing else expresses
+ // it: not a receive_only flag, not an empty-string special case.
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral(
+ "[account.work]\n"
+ "maildir=work\n"
+ "trash=Trash\n"
+ "send_command=msmtp -a work -t\n"
+ "\n"
+ "[account.listsonly]\n"
+ "maildir=listsonly\n"
+ "trash=Trash\n")));
+
+ const Account work = config.account(QStringLiteral("work"));
+ const Account listsonly = config.account(QStringLiteral("listsonly"));
+ QVERIFY2(work.canSend(), "an account with send_command must be able to send");
+ QVERIFY2(!listsonly.canSend(),
+ "an account with no send_command must not report it can send");
+
+ const QList<Account> sending = config.sendingAccounts();
+ QCOMPARE(sending.size(), 1);
+ QCOMPARE(sending.first().key, QStringLiteral("work"));
+}
+
+void TestConfig::composeSettingsDefaultWhenTheSectionIsAbsent()
+{
+ // A config that has never heard of this feature must produce working
+ // defaults rather than zeros.
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral("[general]\n")));
+
+ const ComposeSettings compose = config.compose();
+ QVERIFY2(compose.quotePosition == ComposeSettings::QuotePosition::Above,
+ "default quote position must be Above");
+ QVERIFY2(compose.sendHtml, "default send_html must be true");
+ QCOMPARE(compose.autosaveIntervalMs, 30000);
+ QCOMPARE(compose.sendDelayMs, 5000);
+ QCOMPARE(compose.attachmentWarnBytes, qint64(26214400));
+ QVERIFY(compose.defaultAccount.isEmpty());
+}
+
+void TestConfig::aZeroSendDelayIsHonouredRatherThanTreatedAsUnset()
+{
+ // Zero is a real setting meaning "send at once", and it is exactly the
+ // value an absent key would produce if the default were applied by
+ // testing for zero.
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral(
+ "[compose]\n"
+ "send_delay_ms=0\n")));
+
+ QCOMPARE(config.compose().sendDelayMs, 0);
+ QVERIFY2(config.compose().sendDelayMs != 5000,
+ "zero send_delay_ms was replaced by the default");
+}
+
+void TestConfig::aDefaultAccountThatCannotSendIsWarnedAbout()
+{
+ // Follows the pattern that already warns about an unresolvable
+ // startup_query: the setting is not silently corrected because a user
+ // who named an account expects mail to come from it.
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral(
+ "[compose]\n"
+ "default_account=listsonly\n"
+ "\n"
+ "[account.listsonly]\n"
+ "maildir=listsonly\n"
+ "trash=Trash\n")));
+
+ const QString joined = config.warnings().join(QLatin1Char('\n'));
+ QVERIFY2(joined.contains(QStringLiteral("listsonly")),
+ qPrintable(QStringLiteral("no warning named listsonly: %1").arg(joined)));
+}
+
+void TestConfig::anInstallationWhereNoAccountCanSendIsNotWarnedAbout()
+{
+ // A read-only installation is VALID; warning about it would train the
+ // user to ignore warnings.
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral(
+ "[account.work]\n"
+ "maildir=work\n"
+ "trash=Trash\n")));
+
+ QVERIFY(config.sendingAccounts().isEmpty());
+ for (const QString &warning : config.warnings()) {
+ QVERIFY2(!warning.contains(QStringLiteral("send"), Qt::CaseInsensitive),
+ qPrintable(QStringLiteral("unexpected sending-related warning: %1")
+ .arg(warning)));
+ }
+}
+
+void TestConfig::garbageAutosaveIntervalIsRejectedNotZero()
+{
+ // toInt() alone returns 0 on a parse failure, not the default, and 0
+ // reaches a QTimer restarted on every keystroke: a typo here would have
+ // turned the debounce into a write per keystroke, uploaded by mbsync.
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral(
+ "[compose]\n"
+ "autosave_interval_ms=oops\n")));
+
+ QCOMPARE(config.compose().autosaveIntervalMs, 30000);
+ QVERIFY2(!config.problems().isEmpty(),
+ "a garbage autosave_interval_ms was accepted silently");
+}
+
+void TestConfig::garbageSendDelayIsRejectedNotZero()
+{
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral(
+ "[compose]\n"
+ "send_delay_ms=soon\n")));
+
+ QCOMPARE(config.compose().sendDelayMs, 5000);
+ QVERIFY2(!config.problems().isEmpty(),
+ "a garbage send_delay_ms was accepted silently");
+}
+
+void TestConfig::garbageAttachmentWarnBytesIsRejectedNotZero()
+{
+ // Verified against the actual defect: attachment_warn_bytes=banana gave 0
+ // via a bare toLongLong(), which would have warned about every attachment
+ // no matter how small.
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral(
+ "[compose]\n"
+ "attachment_warn_bytes=banana\n")));
+
+ QCOMPARE(config.compose().attachmentWarnBytes, qint64(26214400));
+ QVERIFY2(!config.problems().isEmpty(),
+ "a garbage attachment_warn_bytes was accepted silently");
+}
+
+void TestConfig::zeroOrNegativeAutosaveIntervalIsClamped()
+{
+ // Independent of the parse fix: a value that parses fine but is zero or
+ // negative must still not reach setInterval(), since nothing assigns a
+ // meaning to one, unlike mark_read_delay_ms's documented negative-means-off.
+ QTemporaryDir dir;
+ Config zero;
+ zero.load(writeIni(dir, QStringLiteral(
+ "[compose]\n"
+ "autosave_interval_ms=0\n")));
+ QVERIFY2(zero.compose().autosaveIntervalMs >= 1000,
+ qPrintable(QStringLiteral("zero autosave interval was not clamped: %1")
+ .arg(zero.compose().autosaveIntervalMs)));
+
+ QTemporaryDir dir2;
+ Config negative;
+ negative.load(writeIni(dir2, QStringLiteral(
+ "[compose]\n"
+ "autosave_interval_ms=-500\n")));
+ QVERIFY2(negative.compose().autosaveIntervalMs >= 1000,
+ qPrintable(QStringLiteral("negative autosave interval was not clamped: %1")
+ .arg(negative.compose().autosaveIntervalMs)));
+}
+
+void TestConfig::unrecognisedQuotePositionWarnsAndFallsBackToAbove()
+{
+ // Matches the precedent set by sync_on_exit, language and date_format:
+ // the only silent fallbacks in this file are for ABSENT keys, never for
+ // malformed ones.
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral(
+ "[compose]\n"
+ "quote_position=abov\n")));
+
+ QVERIFY2(config.compose().quotePosition == ComposeSettings::QuotePosition::Above,
+ "an unrecognised quote_position must still fall back to Above");
+ QVERIFY2(!config.problems().isEmpty(),
+ "an unrecognised quote_position was accepted silently");
+}
+
QTEST_MAIN(TestConfig)
#include "test_config.moc"
diff --git a/tests/test_draftstore.cpp b/tests/test_draftstore.cpp
new file mode 100644
index 0000000..5818261
--- /dev/null
+++ b/tests/test_draftstore.cpp
@@ -0,0 +1,255 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#include <csignal>
+#include <sys/resource.h>
+
+#include <QtTest>
+#include <QTemporaryDir>
+
+#include "draftstore.h"
+
+class TestDraftStore : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void aWriteLandsInCurWithTheGivenFlags();
+ void twoWritesProduceDistinctFiles();
+ void thePreviousRevisionIsUnlinked();
+ void theNewFileExistsBeforeTheOldOneGoes();
+ void anUnwritableDirectoryReportsRatherThanThrows();
+ void theFolderIsCreatedWhenAbsent();
+ void theBytesAreWrittenVerbatim();
+ void aFailedWriteLeavesNoFileBehind();
+ void anEmptyFolderPathReportsRatherThanWriting();
+};
+
+void TestDraftStore::aWriteLandsInCurWithTheGivenFlags()
+{
+ // cur/, never new/. A file dropped in new/ is re-announced as fresh mail
+ // by every reader of the Maildir, so a draft would arrive as a new
+ // message every time it autosaved.
+ QTemporaryDir dir;
+ const DraftStore::Result result = DraftStore::write(
+ dir.path(), QByteArray("From: a@example.org\r\n\r\nbody\r\n"),
+ QStringLiteral("D"));
+
+ QVERIFY2(result.ok(), qPrintable(result.error));
+ QVERIFY2(result.path.contains(QStringLiteral("/cur/")),
+ qPrintable(QStringLiteral("not written to cur/: %1").arg(result.path)));
+ QVERIFY2(result.path.endsWith(QStringLiteral(":2,D")),
+ qPrintable(QStringLiteral("flags missing: %1").arg(result.path)));
+ QVERIFY(QFile::exists(result.path));
+}
+
+void TestDraftStore::twoWritesProduceDistinctFiles()
+{
+ QTemporaryDir dir;
+ const DraftStore::Result first = DraftStore::write(
+ dir.path(), QByteArray("one"), QStringLiteral("D"));
+ const DraftStore::Result second = DraftStore::write(
+ dir.path(), QByteArray("two"), QStringLiteral("D"));
+
+ QVERIFY(first.ok() && second.ok());
+ QVERIFY2(first.path != second.path,
+ "two writes in the same second produced the same filename");
+}
+
+void TestDraftStore::thePreviousRevisionIsUnlinked()
+{
+ // Otherwise a draft autosaved every thirty seconds accumulates one file
+ // per pause, and every one of them syncs to the server.
+ QTemporaryDir dir;
+ const DraftStore::Result first = DraftStore::write(
+ dir.path(), QByteArray("revision one"), QStringLiteral("D"));
+ QVERIFY(first.ok());
+
+ const DraftStore::Result second = DraftStore::write(
+ dir.path(), QByteArray("revision two"), QStringLiteral("D"), first.path);
+ QVERIFY(second.ok());
+
+ QVERIFY2(!QFile::exists(first.path),
+ "the previous draft revision was left behind");
+ QVERIFY(QFile::exists(second.path));
+}
+
+void TestDraftStore::theNewFileExistsBeforeTheOldOneGoes()
+{
+ // The ordering that matters: unlinking first would lose the draft
+ // entirely if the write then failed.
+ //
+ // The failure has to happen at the WRITE, not before it. A destination
+ // whose mkpath() fails returns too early to reach either ordering, so a
+ // mutation moving the unlink ahead of the write still passes: measured,
+ // "11 passed, 0 failed" with the unlink moved above the QSaveFile. The
+ // seam is a cur/ that exists and is read-only, which mkpath() reports as
+ // success (it is already there) and QSaveFile then refuses with
+ // "Permission denied".
+ QTemporaryDir good;
+ const DraftStore::Result first = DraftStore::write(
+ good.path(), QByteArray("precious"), QStringLiteral("D"));
+ QVERIFY(first.ok());
+
+ QTemporaryDir hostile;
+ const QString cur = hostile.path() + QStringLiteral("/cur");
+ QVERIFY(QDir().mkpath(cur));
+ QVERIFY(QFile::setPermissions(cur, QFile::ReadOwner | QFile::ExeOwner));
+
+ const DraftStore::Result failed = DraftStore::write(
+ hostile.path(), QByteArray("replacement"), QStringLiteral("D"),
+ first.path);
+
+ // Restored before any assertion, so a failing assertion does not leave a
+ // directory QTemporaryDir cannot clean up.
+ QFile::setPermissions(cur, QFile::ReadOwner | QFile::WriteOwner
+ | QFile::ExeOwner);
+
+ QVERIFY2(!failed.ok(), "a write into an unwritable cur/ reported success");
+ QVERIFY2(QFile::exists(first.path),
+ "the previous revision was unlinked even though the new write failed");
+}
+
+void TestDraftStore::anUnwritableDirectoryReportsRatherThanThrows()
+{
+ const DraftStore::Result result = DraftStore::write(
+ QStringLiteral("/proc/nonexistent-and-unwritable"),
+ QByteArray("body"), QStringLiteral("D"));
+
+ QVERIFY2(!result.ok(), "an unwritable directory reported success");
+ QVERIFY2(!result.error.isEmpty(), "a failure carried no message to show");
+ QVERIFY(result.path.isEmpty());
+}
+
+void TestDraftStore::theFolderIsCreatedWhenAbsent()
+{
+ // A configured drafts folder that does not exist yet is ordinary on a
+ // fresh account. Note the asymmetry with the trash folder: creating a
+ // folder here is safe because the NAME came from configuration and is
+ // validated at load, not composed from a tag.
+ QTemporaryDir dir;
+ const QString nested = dir.filePath(QStringLiteral("Drafts"));
+ const DraftStore::Result result = DraftStore::write(
+ nested, QByteArray("body"), QStringLiteral("D"));
+
+ QVERIFY2(result.ok(), qPrintable(result.error));
+ QVERIFY(QDir(nested + QStringLiteral("/cur")).exists());
+}
+
+void TestDraftStore::theBytesAreWrittenVerbatim()
+{
+ // A draft must be byte-identical to what would be sent, so nothing here
+ // may re-encode, add a trailing newline, or translate line endings.
+ QTemporaryDir dir;
+ const QByteArray bytes("From: a@example.org\r\nSubject: x\r\n\r\nbody\r\n");
+ const DraftStore::Result result =
+ DraftStore::write(dir.path(), bytes, QStringLiteral("D"));
+ QVERIFY(result.ok());
+
+ QFile file(result.path);
+ QVERIFY(file.open(QIODevice::ReadOnly));
+ QCOMPARE(file.readAll(), bytes);
+}
+
+void TestDraftStore::aFailedWriteLeavesNoFileBehind()
+{
+ // A Maildir reader scans cur/ and indexes whatever it finds, so a write
+ // that fails PART WAY THROUGH must leave nothing, not a truncated
+ // message. A truncated message is the worse outcome by far: it is a
+ // plausible file that notmuch indexes and mbsync uploads.
+ //
+ // The failure has to land after a successful open() or it proves nothing
+ // about the QSaveFile choice: an unwritable directory refuses a plain
+ // QFile at open() too, and both then leave the directory empty. Measured
+ // that way, a mutation swapping QSaveFile for QFile passed.
+ //
+ // RLIMIT_FSIZE opens the real case. With the limit below the payload the
+ // open succeeds and write() returns short: measured, a plain QFile leaves
+ // a 4096-byte file in the listing, while the store leaves nothing.
+ //
+ // What produces that nothing is the ORDER of the condition, not the
+ // choice of QSaveFile, and getting this backwards is the dangerous
+ // reading. commit() is NOT the protection: called after a short write it
+ // returns true and renames the truncated bytes into place, measured as
+ // "write 4096 of 65536, commit true" with the directory then holding that
+ // file. The store never reaches it, because comparing write()'s return
+ // against the payload size short-circuits the `||` first and returns; the
+ // scratch file is then discarded by ~QSaveFile() having never been
+ // committed, and the listing is empty.
+ //
+ // So the size comparison must stay AHEAD of commit() in that condition.
+ // Reducing `write(bytes) != bytes.size() || !file.commit()` to
+ // `!file.commit()` looks like a simplification and writes a truncated
+ // draft into cur/, where notmuch indexes it and mbsync uploads it.
+ //
+ // The signal must be ignored before the limit is set, or the process is
+ // killed by SIGXFSZ rather than seeing a short write.
+ QTemporaryDir dir;
+
+ struct rlimit previous;
+ QVERIFY(getrlimit(RLIMIT_FSIZE, &previous) == 0);
+ void (*previousHandler)(int) = signal(SIGXFSZ, SIG_IGN);
+
+ struct rlimit limited;
+ limited.rlim_cur = 4096;
+ limited.rlim_max = previous.rlim_max;
+ QVERIFY(setrlimit(RLIMIT_FSIZE, &limited) == 0);
+
+ const DraftStore::Result result = DraftStore::write(
+ dir.path(), QByteArray(64 * 1024, 'x'), QStringLiteral("D"));
+
+ // Restored before any assertion, so a failing one does not leave the rest
+ // of the suite unable to write a file.
+ setrlimit(RLIMIT_FSIZE, &previous);
+ signal(SIGXFSZ, previousHandler);
+
+ QVERIFY2(!result.ok(), "a truncated write reported success");
+ QVERIFY(result.path.isEmpty() || !QFile::exists(result.path));
+
+ const QStringList entries =
+ QDir(dir.path() + QStringLiteral("/cur")).entryList(QDir::Files);
+ QVERIFY2(entries.isEmpty(),
+ qPrintable(QStringLiteral("a truncated write left a message "
+ "behind for notmuch to index: %1")
+ .arg(entries.join(QLatin1Char(' ')))));
+}
+
+void TestDraftStore::anEmptyFolderPathReportsRatherThanWriting()
+{
+ // An account with no drafts folder configured reaches here with an empty
+ // string. Without the guard the destination becomes "/cur", an absolute
+ // path at the root of the filesystem, and the only thing stopping the
+ // write is that this process does not run as root. That is not a
+ // safeguard, so the guard is asserted on its own MESSAGE rather than on
+ // the failure: a refusal naming the missing configuration is a different
+ // outcome from a permission error, and only the first survives being run
+ // by a privileged user.
+ const DraftStore::Result result =
+ DraftStore::write(QString(), QByteArray("body"), QStringLiteral("D"));
+
+ QVERIFY2(!result.ok(), "an empty folder path reported success");
+ QVERIFY(result.path.isEmpty());
+ QVERIFY2(!result.error.contains(QStringLiteral("/cur")),
+ qPrintable(QStringLiteral(
+ "the empty path reached the filesystem instead of being "
+ "refused: %1").arg(result.error)));
+ QVERIFY(!result.error.isEmpty());
+}
+
+QTEST_MAIN(TestDraftStore)
+#include "test_draftstore.moc"
diff --git a/tests/test_formattoolbar.cpp b/tests/test_formattoolbar.cpp
new file mode 100644
index 0000000..36918f9
--- /dev/null
+++ b/tests/test_formattoolbar.cpp
@@ -0,0 +1,346 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#include <QtTest>
+
+#include "formattoolbar.h"
+
+class TestFormatToolbar : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void wrappingASelectionKeepsItSelected();
+ void wrappingWithNoSelectionPutsTheCursorBetweenTheTokens();
+ void wrappingAppliesTheTokenOnBothSides();
+ void aBackwardsSelectionWrapsTheSameWordsAsAForwardOne();
+
+ void wrappingTwiceNestsTheTokensAroundTheSameWords();
+
+ void aLinkWithASelectionUsesItAsTheLabel();
+ void aLinkWithNoSelectionLeavesTheCursorInTheLabel();
+ void aBackwardsSelectionLinksTheSameWordsAsAForwardOne();
+ void quotingPrefixesEveryLineTheSelectionTouches();
+ void quotingAPartialLineStillQuotesTheWholeLine();
+ void quotingASingleLineWithNoSelectionQuotesThatLine();
+ void quotingWithTheCursorAtTheEndOfALineQuotesThatLineNotTheNext();
+ void quotingSelectsTheQuotedLines();
+ void quotingSelectsOnlyTheLineItQuoted();
+ void quotingTheLastLineKeepsTheRestOfTheText();
+ void quotingAnAlreadyQuotedLineNestsIt();
+ void quotingAnEmptyLineLeavesTheMarkerWithoutTrailingSpace();
+ void aSelectionPastTheEndIsClamped();
+ void aSelectionSplittingASurrogatePairKeepsTheCharacterWhole();
+};
+
+void TestFormatToolbar::wrappingASelectionKeepsItSelected()
+{
+ // The selection is preserved so a second button press applies a second
+ // token to the same words: bold then italic, without reselecting.
+ const MarkdownFormat::Edit edit = MarkdownFormat::wrap(
+ QStringLiteral("make this bold"), 5, 9, QStringLiteral("**"));
+
+ QCOMPARE(edit.text, QStringLiteral("make **this** bold"));
+ QCOMPARE(edit.text.mid(edit.selectionStart,
+ edit.selectionEnd - edit.selectionStart),
+ QStringLiteral("this"));
+}
+
+void TestFormatToolbar::wrappingWithNoSelectionPutsTheCursorBetweenTheTokens()
+{
+ // The property a user notices immediately when it is wrong: press Bold,
+ // start typing, and the words must appear INSIDE the asterisks. A text
+ // comparison alone passes whether the cursor is inside or after.
+ const MarkdownFormat::Edit edit = MarkdownFormat::wrap(
+ QStringLiteral("ab"), 2, 2, QStringLiteral("**"));
+
+ QCOMPARE(edit.text, QStringLiteral("ab****"));
+ QCOMPARE(edit.selectionStart, edit.selectionEnd);
+ QCOMPARE(edit.selectionStart, 4);
+
+ // Stated as the behaviour rather than the index: typing "x" here must
+ // produce "ab**x**".
+ QString typed = edit.text;
+ typed.insert(edit.selectionStart, QStringLiteral("x"));
+ QCOMPARE(typed, QStringLiteral("ab**x**"));
+}
+
+void TestFormatToolbar::wrappingAppliesTheTokenOnBothSides()
+{
+ QCOMPARE(MarkdownFormat::wrap(QStringLiteral("x"), 0, 1,
+ QStringLiteral("~~")).text,
+ QStringLiteral("~~x~~"));
+ QCOMPARE(MarkdownFormat::wrap(QStringLiteral("x"), 0, 1,
+ QStringLiteral("`")).text,
+ QStringLiteral("`x`"));
+}
+
+void TestFormatToolbar::aBackwardsSelectionWrapsTheSameWordsAsAForwardOne()
+{
+ // A drag from right to left reports the anchor after the cursor. Qt hands
+ // that over as-is, so a transformation that trusts the order inserts the
+ // closing token before the opening one and corrupts the buffer.
+ const MarkdownFormat::Edit edit = MarkdownFormat::wrap(
+ QStringLiteral("make this bold"), 9, 5, QStringLiteral("**"));
+
+ QCOMPARE(edit.text, QStringLiteral("make **this** bold"));
+ QCOMPARE(edit.text.mid(edit.selectionStart,
+ edit.selectionEnd - edit.selectionStart),
+ QStringLiteral("this"));
+}
+
+void TestFormatToolbar::wrappingTwiceNestsTheTokensAroundTheSameWords()
+{
+ // The reason the selection is preserved at all: bold, then italic,
+ // without touching the mouse. Asserting on the second result is what
+ // makes the preserved selection load-bearing rather than decorative,
+ // since a wrong selection here produces valid-looking but wrong markdown
+ // ("make ***this** bold*" or similar).
+ //
+ // Stacking rather than toggling is the spec's behaviour, not an
+ // omission: a second Bold press gives "****this****". A toggle is wanted
+ // eventually and would make THIS gesture unreachable, which is the
+ // unanswered design question recorded as backlog item 135.
+ const MarkdownFormat::Edit first = MarkdownFormat::wrap(
+ QStringLiteral("make this bold"), 5, 9, QStringLiteral("**"));
+ const MarkdownFormat::Edit second = MarkdownFormat::wrap(
+ first.text, first.selectionStart, first.selectionEnd,
+ QStringLiteral("*"));
+
+ QCOMPARE(second.text, QStringLiteral("make ***this*** bold"));
+ QCOMPARE(second.text.mid(second.selectionStart,
+ second.selectionEnd - second.selectionStart),
+ QStringLiteral("this"));
+}
+
+void TestFormatToolbar::aLinkWithASelectionUsesItAsTheLabel()
+{
+ const MarkdownFormat::Edit edit = MarkdownFormat::link(
+ QStringLiteral("see the docs"), 8, 12);
+
+ QCOMPARE(edit.text, QStringLiteral("see the [docs]()"));
+
+ // The cursor goes inside the parentheses: the label is written and the
+ // URL is what the user still has to type.
+ QCOMPARE(edit.selectionStart, edit.selectionEnd);
+ QString typed = edit.text;
+ typed.insert(edit.selectionStart, QStringLiteral("https://example.org"));
+ QCOMPARE(typed, QStringLiteral("see the [docs](https://example.org)"));
+}
+
+void TestFormatToolbar::aLinkWithNoSelectionLeavesTheCursorInTheLabel()
+{
+ // With nothing selected there is no label yet, so the label is what the
+ // user types first.
+ const MarkdownFormat::Edit edit = MarkdownFormat::link(QString(), 0, 0);
+
+ QCOMPARE(edit.text, QStringLiteral("[]()"));
+ QCOMPARE(edit.selectionStart, edit.selectionEnd);
+ QString typed = edit.text;
+ typed.insert(edit.selectionStart, QStringLiteral("label"));
+ QCOMPARE(typed, QStringLiteral("[label]()"));
+}
+
+void TestFormatToolbar::aBackwardsSelectionLinksTheSameWordsAsAForwardOne()
+{
+ const MarkdownFormat::Edit edit = MarkdownFormat::link(
+ QStringLiteral("see the docs"), 12, 8);
+
+ QCOMPARE(edit.text, QStringLiteral("see the [docs]()"));
+ QString typed = edit.text;
+ typed.insert(edit.selectionStart, QStringLiteral("https://example.org"));
+ QCOMPARE(typed, QStringLiteral("see the [docs](https://example.org)"));
+}
+
+void TestFormatToolbar::quotingPrefixesEveryLineTheSelectionTouches()
+{
+ const MarkdownFormat::Edit edit = MarkdownFormat::quote(
+ QStringLiteral("one\ntwo\nthree"), 0, 7);
+
+ QCOMPARE(edit.text, QStringLiteral("> one\n> two\nthree"));
+}
+
+void TestFormatToolbar::quotingAPartialLineStillQuotesTheWholeLine()
+{
+ // A selection from the middle of one line into the middle of the next
+ // must quote both whole lines. Quoting half a line produces markdown that
+ // means something else entirely.
+ const MarkdownFormat::Edit edit = MarkdownFormat::quote(
+ QStringLiteral("one\ntwo\nthree"), 1, 5);
+
+ QCOMPARE(edit.text, QStringLiteral("> one\n> two\nthree"));
+}
+
+void TestFormatToolbar::quotingASingleLineWithNoSelectionQuotesThatLine()
+{
+ const MarkdownFormat::Edit edit = MarkdownFormat::quote(
+ QStringLiteral("one\ntwo"), 5, 5);
+
+ QCOMPARE(edit.text, QStringLiteral("one\n> two"));
+}
+
+void TestFormatToolbar::quotingWithTheCursorAtTheEndOfALineQuotesThatLineNotTheNext()
+{
+ // Position 3 is the end of "one", immediately BEFORE the newline, so the
+ // cursor is on the first line. Searching backwards from the cursor itself
+ // rather than from one before it finds that newline and quotes the SECOND
+ // line, which is the line the user is not on. The off-by-one is invisible
+ // in every other case because no newline sits at the search position.
+ const MarkdownFormat::Edit edit = MarkdownFormat::quote(
+ QStringLiteral("one\ntwo"), 3, 3);
+
+ QCOMPARE(edit.text, QStringLiteral("> one\ntwo"));
+}
+
+void TestFormatToolbar::quotingSelectsTheQuotedLines()
+{
+ // The quoted block stays selected, so pressing Quote again nests it and
+ // a following transformation applies to the same lines.
+ const MarkdownFormat::Edit edit = MarkdownFormat::quote(
+ QStringLiteral("one\ntwo\nthree"), 1, 5);
+
+ QCOMPARE(edit.text.mid(edit.selectionStart,
+ edit.selectionEnd - edit.selectionStart),
+ QStringLiteral("> one\n> two"));
+}
+
+void TestFormatToolbar::quotingTheLastLineKeepsTheRestOfTheText()
+{
+ // No trailing newline after the last line, so the end-of-text search
+ // returns -1 and an unguarded implementation truncates everything from
+ // the selection onwards.
+ const MarkdownFormat::Edit edit = MarkdownFormat::quote(
+ QStringLiteral("one\ntwo\nthree"), 9, 9);
+
+ QCOMPARE(edit.text, QStringLiteral("one\ntwo\n> three"));
+}
+
+void TestFormatToolbar::quotingSelectsOnlyTheLineItQuoted()
+{
+ // The line quoted here is the SECOND one, so a selection that wrongly
+ // starts at 0 is distinguishable from a correct one. The existing
+ // quotingSelectsTheQuotedLines fixture starts on the first line, where a
+ // hardcoded 0 and the right answer coincide: that coincidence let a
+ // mutation replacing firstLineStart with 0 pass the whole suite.
+ //
+ // The damage is not cosmetic. With the wrong selection a second Quote
+ // press quotes a line the user never selected, and a following Bold
+ // bolds the wrong text.
+ const MarkdownFormat::Edit edit = MarkdownFormat::quote(
+ QStringLiteral("one\ntwo\nthree"), 5, 5);
+
+ QCOMPARE(edit.text, QStringLiteral("one\n> two\nthree"));
+ QCOMPARE(edit.text.mid(edit.selectionStart,
+ edit.selectionEnd - edit.selectionStart),
+ QStringLiteral("> two"));
+}
+
+void TestFormatToolbar::quotingAnAlreadyQuotedLineNestsIt()
+{
+ // Nests rather than toggling, per the spec, which states there is
+ // deliberately no live toggle that inserts and removes the quote while
+ // editing. A second press deepens the quote. Backlog item 135 holds the
+ // toggle design if that is ever revisited.
+ const MarkdownFormat::Edit edit = MarkdownFormat::quote(
+ QStringLiteral("> one"), 0, 5);
+
+ QCOMPARE(edit.text, QStringLiteral("> > one"));
+}
+
+void TestFormatToolbar::quotingAnEmptyLineLeavesTheMarkerWithoutTrailingSpace()
+{
+ // A blank line inside a quoted block is what continues the block in
+ // markdown, so it gets the marker. "> " with nothing after it is trailing
+ // whitespace that several editors and mail clients strip, which would
+ // break the block; the marker is written bare.
+ const MarkdownFormat::Edit edit = MarkdownFormat::quote(
+ QStringLiteral("one\n\ntwo"), 0, 8);
+
+ QCOMPARE(edit.text, QStringLiteral("> one\n>\n> two"));
+}
+
+void TestFormatToolbar::aSelectionPastTheEndIsClamped()
+{
+ // A stale selection outliving an edit to the buffer would otherwise index
+ // past the end. QString tolerates that in some calls and not in others,
+ // so it is clamped once at the entry rather than relied on per call.
+ QCOMPARE(MarkdownFormat::wrap(QStringLiteral("ab"), 0, 99,
+ QStringLiteral("**")).text,
+ QStringLiteral("**ab**"));
+ QCOMPARE(MarkdownFormat::link(QStringLiteral("ab"), -5, 99).text,
+ QStringLiteral("[ab]()"));
+ QCOMPARE(MarkdownFormat::quote(QStringLiteral("ab"), -5, 99).text,
+ QStringLiteral("> ab"));
+}
+
+void TestFormatToolbar::aSelectionSplittingASurrogatePairKeepsTheCharacterWhole()
+{
+ // An emoji is two UTF-16 code units, so a boundary at 4 lands BETWEEN
+ // them. Inserting there splits the character: the result is invalid
+ // UTF-16 and the emoji is destroyed, not merely moved.
+ //
+ // Not reachable by arrow key or mouse, which both move in whole clusters,
+ // but QTextCursor::setPosition accepts it, so anything computing a
+ // position arithmetically gets there: a draft restore, a find/replace, a
+ // template insertion.
+ const QString emoji = QString::fromUcs4(U"\U0001F600");
+ const QString text = QStringLiteral("hi ") + emoji + QStringLiteral(" there");
+ QCOMPARE(text.size(), 11);
+ QVERIFY(text.at(3).isHighSurrogate());
+ QVERIFY(text.at(4).isLowSurrogate());
+
+ // Boundary inside the pair on the closing side.
+ const MarkdownFormat::Edit a =
+ MarkdownFormat::wrap(text, 3, 4, QStringLiteral("**"));
+ QVERIFY2(a.text.isValidUtf16(), "wrap split the surrogate pair");
+ QVERIFY2(a.text.contains(emoji), "wrap destroyed the character");
+
+ // Boundary inside the pair on the opening side.
+ const MarkdownFormat::Edit b =
+ MarkdownFormat::wrap(text, 4, 5, QStringLiteral("**"));
+ QVERIFY2(b.text.isValidUtf16(), "wrap split the surrogate pair");
+ QVERIFY2(b.text.contains(emoji), "wrap destroyed the character");
+
+ const MarkdownFormat::Edit c = MarkdownFormat::link(text, 3, 4);
+ QVERIFY2(c.text.isValidUtf16(), "link split the surrogate pair");
+ QVERIFY2(c.text.contains(emoji), "link destroyed the character");
+
+ // A COLLAPSED cursor inside the pair must stay collapsed. Nudging its two
+ // ends in opposite directions would keep the character whole while
+ // turning "insert an empty pair here" into "wrap the emoji", which is a
+ // character the user never selected.
+ const MarkdownFormat::Edit e =
+ MarkdownFormat::wrap(text, 4, 4, QStringLiteral("**"));
+ QVERIFY2(e.text.isValidUtf16(), "wrap split the surrogate pair");
+ QCOMPARE(e.text, QStringLiteral("hi ****") + emoji + QStringLiteral(" there"));
+ QCOMPARE(e.selectionStart, e.selectionEnd);
+ QString typedInto = e.text;
+ typedInto.insert(e.selectionStart, QStringLiteral("x"));
+ QCOMPARE(typedInto,
+ QStringLiteral("hi **x**") + emoji + QStringLiteral(" there"));
+
+ // quote() snaps to line boundaries, so it is immune by construction.
+ // Asserted rather than assumed, so a later change to how it widens the
+ // selection cannot quietly lose that.
+ const MarkdownFormat::Edit d = MarkdownFormat::quote(text, 3, 4);
+ QVERIFY2(d.text.isValidUtf16(), "quote split the surrogate pair");
+ QCOMPARE(d.text, QStringLiteral("> ") + text);
+}
+
+QTEST_APPLESS_MAIN(TestFormatToolbar)
+#include "test_formattoolbar.moc"
diff --git a/tests/test_maildirname.cpp b/tests/test_maildirname.cpp
new file mode 100644
index 0000000..dcc8fab
--- /dev/null
+++ b/tests/test_maildirname.cpp
@@ -0,0 +1,93 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#include "maildirname.h"
+
+#include <QSet>
+#include <QTest>
+
+class TestMaildirName : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void aFreshNameIsUniquePerCall();
+ void theFlagSuffixIsPreserved();
+ void anEmptyFlagSuffixIsPreserved();
+ void aNameWithNoSuffixGetsNone();
+ void theUidInfixIsNotCarriedAcross();
+};
+
+// Two messages written in the same second must not collide, which a
+// timestamp alone does not guarantee, and that is what the counter is for.
+void TestMaildirName::aFreshNameIsUniquePerCall()
+{
+ QSet<QString> names;
+ for (int i = 0; i < 100; ++i)
+ names.insert(MaildirName::fresh(QStringLiteral("1234.M1P1Q1.host")));
+
+ QVERIFY2(names.size() == 100,
+ qPrintable(QStringLiteral("expected 100 unique names, got %1")
+ .arg(names.size())));
+}
+
+// The flags say whether a message is read, flagged or draft, and losing them
+// on a move silently marks mail unread again.
+void TestMaildirName::theFlagSuffixIsPreserved()
+{
+ const QString name = MaildirName::fresh(QStringLiteral("1234.M1P1Q1.host:2,FS"));
+ QVERIFY2(name.endsWith(QStringLiteral(":2,FS")),
+ qPrintable(QStringLiteral("generated name did not preserve flags: %1")
+ .arg(name)));
+}
+
+// `:2,` with no flags is not the same as no suffix at all, it says the flags
+// are known and empty.
+void TestMaildirName::anEmptyFlagSuffixIsPreserved()
+{
+ const QString name = MaildirName::fresh(QStringLiteral("1234.M1P1Q1.host:2,"));
+ QVERIFY2(name.endsWith(QStringLiteral(":2,")),
+ qPrintable(QStringLiteral("generated name did not preserve empty flag suffix: %1")
+ .arg(name)));
+}
+
+// A suffix must not be invented.
+void TestMaildirName::aNameWithNoSuffixGetsNone()
+{
+ const QString name = MaildirName::fresh(QStringLiteral("1234.M1P1Q1.host"));
+ QVERIFY2(!name.contains(QStringLiteral(":2,")),
+ qPrintable(QStringLiteral("generated name invented a flag suffix: %1")
+ .arg(name)));
+}
+
+// This is the reason the function exists; carrying mbsync's `,U=` infix
+// across a folder boundary produced "Maildir error: duplicate UID" on real
+// mail.
+void TestMaildirName::theUidInfixIsNotCarriedAcross()
+{
+ const QString name = MaildirName::fresh(QStringLiteral("1234.M1P1Q1.host,U=42:2,S"));
+ QVERIFY2(!name.contains(QStringLiteral("U=42")),
+ qPrintable(QStringLiteral("generated name carried the UID infix across: %1")
+ .arg(name)));
+ QVERIFY2(name.endsWith(QStringLiteral(":2,S")),
+ qPrintable(QStringLiteral("generated name did not preserve flags: %1")
+ .arg(name)));
+}
+
+QTEST_MAIN(TestMaildirName)
+#include "test_maildirname.moc"
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index 4d70a29..98dae12 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -48,8 +48,16 @@
#include "keymap.h"
#include "mainwindow.h"
#include "messageview.h"
+#include "mimeparser.h"
#include "notmuchworker.h"
#include "carddelegate.h"
+#include "composewindow.h"
+#include "senddialog.h"
+#include "messagesender.h"
+#include <QCheckBox>
+#include <QPlainTextEdit>
+#include <QPointer>
+#include <QListWidget>
#include "cardlayout.h"
#include <QImage>
@@ -96,6 +104,35 @@ public:
/// no trash key either. A caller that names an account and wants Delete to
/// work has to say where its trash is, which is the same requirement the
/// real config imposes.
+ /// One [account.<key>] section to write.
+ ///
+ /// `sendCommand` is what makes the account able to send, and its EMPTINESS
+ /// is what makes it receive-only: the capability is the key's presence,
+ /// not a separate flag, so a receive-only account is written by omitting
+ /// it exactly as the real config expresses it.
+ struct AccountSpec
+ {
+ QString key;
+ QString maildir;
+ QString trash;
+ QString sendCommand;
+ QString address;
+ };
+
+ /// Writes several accounts, for the compose cases.
+ ///
+ /// Beside build() rather than replacing it: every existing caller passes
+ /// at most one account and none of them needs a send command, so widening
+ /// the three-argument signature further would make ten call sites carry
+ /// two empty strings each for one test's benefit.
+ bool buildWithAccounts(const QList<AccountSpec> &accounts,
+ const QString &composeKey = QString())
+ {
+ m_accounts = accounts;
+ m_composeKey = composeKey;
+ return build();
+ }
+
bool build(const QString &accountKey = QString(),
const QString &accountMaildir = QString(),
const QString &accountTrash = QString())
@@ -142,6 +179,21 @@ public:
// folder that does not exist would CREATE it.
out << "inbox=inbox\n";
}
+ if (!m_composeKey.isEmpty())
+ out << "\n[compose]\n" << m_composeKey << "\n";
+ for (const AccountSpec &account : m_accounts) {
+ out << "\n[account." << account.key << "]\n"
+ << "maildir=" << account.maildir << "\n"
+ << "inbox=inbox\n";
+ if (!account.trash.isEmpty())
+ out << "trash=" << account.trash << "\n";
+ if (!account.address.isEmpty())
+ out << "address=" << account.address << "\n";
+ // Written only when non-empty. An account with no
+ // send_command is receive-only, which is the shape under test.
+ if (!account.sendCommand.isEmpty())
+ out << "send_command=" << account.sendCommand << "\n";
+ }
}
file.close();
@@ -162,6 +214,8 @@ private:
QTemporaryDir m_confDir;
Config m_config;
QString m_error;
+ QList<AccountSpec> m_accounts;
+ QString m_composeKey;
};
/// MainWindow is mostly wiring. Cases that need a real database opt into one
@@ -197,6 +251,25 @@ private slots:
void narrowingAnEmptyQueryBarIsAPlainSearch();
void aMalformedAccountIsReportedWithoutBlockingTheConstructor();
void aWorkerBackedWindowReturnsRealThreads();
+
+ // Compose and send, item 123 task 12.
+ void theMailRootComesFromTheConfigNotTheIndex();
+ void replyIsDisabledOnAReceiveOnlyAccountsMail();
+ void theReceiveOnlyRibbonNamesTheAccount();
+ void replyIsEnabledOnASendingAccountsMail();
+ void composeIsDisabledOnlyWhenNoAccountCanSend();
+ void quittingWithACleanComposerAsksNothing();
+ void quittingWithUnsavedEditsReportsEveryComposer();
+ void closingAComposerCompactsTheRegistry();
+ void savingAMessageRefusesToEscapeTheChosenDirectory();
+ void aHostileSubjectCannotEscapeTheSaveDirectory();
+ void savingTwiceDoesNotOverwriteTheFirstFile();
+ void savingAMessageWithAHostileSubjectStaysInTheDirectory();
+ void aStuckComposeRequestDoesNotHijackTheNextPaneLoad();
+ void theSaveLoopToleratesAComposerClosedUnderTheDialog();
+ void quittingClosesEveryComposerRatherThanOrphaningIt();
+ void forwardingCarriesTheOriginalsAttachments();
+ void forwardSeedsHtmlFromTheConfigNotTheOriginal();
void aStartupAccountScopesTheStartupQuery();
void aStartupAccountAlsoScopesASavedStartupQuery();
void aGeneratedStartupQueryActuallyRuns();
@@ -331,6 +404,7 @@ private slots:
void everyActionCarriesAnIcon();
void everyActionIsReachableFromAMenu();
+ void noMenuHasTwoEntriesSharingAMnemonic();
void theToolbarDoesNotOverrideTheDesktopButtonStyle();
void theImportantActionIsLabelledImportant();
void theImportantActionStillWritesTheFlaggedTag();
@@ -391,6 +465,37 @@ private slots:
void theRefreshAfterARestoreLeavesUndoIntact();
void deletingOutsideTheTrashViewLeavesTheRowInPlace();
+ // ComposeWindow, item 123. These need a window but no worker: the composer
+ // never touches NotmuchWorker, it reads its context from the value struct
+ // MainWindow hands it, so a Config written to a temporary INI is the whole
+ // fixture.
+ void aComposerOpensClean();
+ void typingMarksTheComposerDirty();
+ void anAutosaveWritesADraftAndClearsTheDirtyFlag();
+ void anUnwritableDraftsFolderRaisesThePersistentBanner();
+ void aSuccessfulSaveClearsTheBanner();
+ void anAccountWithoutADraftsFolderReportsNoFailure();
+ void aRewrittenDraftUnlinksThePreviousRevision();
+ void theComposerBuildsTheMessageItsWidgetsShow();
+ void theFromDropdownDecidesWhichAccountSends();
+ void aFormatEditPreservesTheUndoStack();
+ void aFormatEditRestoresTheSelectionItAsksFor();
+ void aFormatEditOnAnEmptySelectionLandsBetweenTheTokens();
+ void theAttachmentWarningRespectsTheConfiguredThreshold();
+ void aDisabledAttachmentWarningWarnsAboutNothing();
+ void theQuotePositionDecidesWhereTheQuoteLands();
+ void theSeededQuoteIsNotAnUndoStep();
+ void aReplySeedsTheHtmlToggleFromTheOriginal();
+ void aNewMessageSeedsTheHtmlToggleFromConfig();
+ void disablingInputsCoversEveryFieldAndTheToolbar();
+ void aFailedSendCanBeRetriedWithoutFilingTheWrongCopy();
+ void anUnchangedMessageIsNotWrittenAgain();
+ void closingInsideTheDebounceStillSavesTheDraft();
+ void closingAfterASendWritesNoFurtherDraft();
+ void aCloseDuringTheCountdownIsRefused();
+ void aFailedSendKeepsTheTextThatFailedToGo();
+ void aSmallSizeLimitIsNotDescribedAsZeroMegabytes();
+
private:
/// Owns the throwaway lock table init() points every test at. A pointer
/// rather than a value because it is rebuilt per test, and QTemporaryDir
@@ -6444,6 +6549,185 @@ void TestMainWindow::everyActionIsReachableFromAMenu()
.arg(unreachable.join(QStringLiteral(", ")))));
}
+void TestMainWindow::noMenuHasTwoEntriesSharingAMnemonic()
+{
+ // The sibling of everyActionIsReachableFromAMenu(), and it exists because
+ // the rule it enforces had lived only in prose and in one other test's
+ // COMMENT, and was duly broken the first time a batch of entries was added
+ // to a menu (item 123: `&Reply` against the pre-existing `&Restore from
+ // trash`, both Alt+R).
+ //
+ // Qt does not error on a duplicate mnemonic. It CYCLES between the
+ // colliding entries instead of activating either, so the key silently
+ // stops working and merely moves a highlight. That is worse than it
+ // sounds in the Message menu, where `restore` is deliberately greyed
+ // outside the trash view: the ordinary case was pressing Alt+R and landing
+ // on a disabled entry.
+ //
+ // Item 57 already decided this is a property rather than a taste. It
+ // rejected the label "Starred" for `flag` precisely because it would have
+ // collided with `Mark &spam`, and theImportantActionIsLabelledImportant()
+ // pins the surviving label with that reasoning in its comment. A decision
+ // recorded only in prose is one nobody re-derives.
+ //
+ // Scoped PER MENU, which is what the collision actually is: a mnemonic is
+ // resolved among the entries of the menu that is open, so the same letter
+ // in File and in View is not a conflict.
+ const Config config;
+ MainWindow window(config);
+
+ auto *bar = window.menuBar();
+ QVERIFY(bar);
+
+ // The menu bar's own top-level titles are one such scope too, so the walk
+ // starts by treating the bar as a menu and then descends.
+ QList<QPair<QString, QList<QAction *>>> scopes;
+ scopes.append({ QStringLiteral("the menu bar"), bar->actions() });
+
+ QList<QMenu *> pending;
+ const auto topLevel = bar->actions();
+ for (QAction *action : topLevel) {
+ if (action->menu())
+ pending.append(action->menu());
+ }
+ QVERIFY2(!pending.isEmpty(), "the menu bar holds no menus");
+
+ while (!pending.isEmpty()) {
+ QMenu *menu = pending.takeFirst();
+ const auto entries = menu->actions();
+ scopes.append({ menu->title(), entries });
+ for (QAction *entry : entries) {
+ if (QMenu *sub = entry->menu())
+ pending.append(sub);
+ }
+ }
+
+ // The four collisions that PREDATE this test, measured by running it
+ // against the tree before item 123 touched any label. They are listed
+ // rather than fixed, and rather than being hidden by narrowing the test,
+ // because renaming a shipped menu entry is the user's call and not a
+ // test's: three of them are in menus a user has had in their fingers
+ // since 0.1.0.
+ //
+ // Listed as exact pairs, not as "ignore Alt+R", so this is a freeze and
+ // not an amnesty: a NEW entry colliding on any of these same keys still
+ // fails, because its pair is not on this list. Fixing one is then a
+ // one-line deletion here, which is the point of writing them out.
+ // Written as the FULL GROUP of labels sharing one key in one menu, not as
+ // a pair. A pair is keyed on which entry the walk happened to see first,
+ // so adding a colliding entry ABOVE a frozen one silently re-pairs it and
+ // the new defect gets reported as "a frozen collision no longer happens",
+ // which names the wrong thing entirely. Measured: reinstating `&Reply`
+ // did exactly that before this was changed. A group is order-independent,
+ // so a new entry grows the group and fails as a new collision.
+ static const QStringList knownPreExistingCollisions = {
+ QStringLiteral("&Message: Alt+R shared by \"&Restore from trash\", \"Mark all &read\", \"Tagging &rules...\""),
+ QStringLiteral("&Message: Alt+S shared by \"Mark &spam\", \"Find &stranded deleted mail\""),
+ QStringLiteral("&View: Alt+O shared by \"&Open thread\", \"Zoom &out\""),
+ };
+
+ QStringList collisions;
+ int compared = 0;
+
+ for (const auto &scope : scopes) {
+ // Keyed on the mnemonic Qt itself derives, not on a hand-parsed '&'.
+ // The question is which key Qt will dispatch, and only Qt answers it:
+ // "&&" is a literal ampersand and carries no mnemonic at all.
+ //
+ // A QMap rather than a QHash so the groups come out in a stable key
+ // order, which is what lets the frozen list above be written once and
+ // stay matching.
+ QMap<QString, QStringList> byMnemonic;
+ for (QAction *entry : scope.second) {
+ if (entry->isSeparator())
+ continue;
+ const QKeySequence mnemonic = QKeySequence::mnemonic(entry->text());
+ if (mnemonic.isEmpty())
+ continue;
+ ++compared;
+ byMnemonic[mnemonic.toString(QKeySequence::NativeText)]
+ .append(QStringLiteral("\"%1\"").arg(entry->text()));
+ }
+
+ for (auto it = byMnemonic.cbegin(); it != byMnemonic.cend(); ++it) {
+ if (it.value().size() < 2)
+ continue;
+ // Names the menu, the key and EVERY label in the group, so a
+ // future failure says what to rename without anyone going looking.
+ collisions.append(QStringLiteral("%1: %2 shared by %3")
+ .arg(scope.first, it.key(),
+ it.value().join(QStringLiteral(", "))));
+ }
+ }
+
+ // The guard, and it is not ceremonial: every assertion below is a loop
+ // that reports success when it runs zero times. A walk that found no
+ // mnemonics at all would pass this test against any label whatsoever.
+ QVERIFY2(compared > 20,
+ qPrintable(QStringLiteral("only %1 menu entries carried a "
+ "mnemonic, so this probe measured "
+ "almost nothing")
+ .arg(compared)));
+
+ // Matched on the menu and key only, with the labels compared separately
+ // below. Comparing whole strings made a GROWING group read as a frozen one
+ // disappearing: adding `&Reply` took Alt+R from three labels to four, the
+ // frozen three-label string stopped matching, and the failure said "this
+ // collision no longer happens" about the very key that had just got worse.
+ // Measured twice, once per attempt, which is why the two questions are
+ // asked separately.
+ const auto scopeAndKey = [](const QString &collision) {
+ return collision.left(collision.indexOf(QStringLiteral(" shared by ")));
+ };
+
+ QHash<QString, QString> frozen;
+ for (const QString &known : knownPreExistingCollisions)
+ frozen.insert(scopeAndKey(known), known);
+
+ QStringList unexpected;
+ QSet<QString> stillPresent;
+ for (const QString &collision : collisions) {
+ const QString key = scopeAndKey(collision);
+ const auto known = frozen.constFind(key);
+ if (known == frozen.constEnd()) {
+ // A collision on a key nothing froze: entirely new.
+ unexpected.append(collision);
+ continue;
+ }
+ stillPresent.insert(key);
+ if (*known != collision) {
+ // The key was already colliding, but the CAST has changed, which
+ // for a frozen entry means an entry joined it. Reported as the
+ // new collision it is, naming both what was frozen and what is
+ // there now.
+ unexpected.append(
+ QStringLiteral("%1 (frozen as [%2], now [%3])")
+ .arg(key, *known, collision));
+ }
+ }
+
+ // A frozen entry that has since been FIXED must not stay on the list
+ // silently, or the list becomes a place stale claims accumulate.
+ QStringList stale;
+ for (const QString &known : knownPreExistingCollisions) {
+ if (!stillPresent.contains(scopeAndKey(known)))
+ stale.append(known);
+ }
+ QVERIFY2(stale.isEmpty(),
+ qPrintable(QStringLiteral("%1 frozen collision(s) no longer "
+ "happen, so delete them from "
+ "knownPreExistingCollisions: %2")
+ .arg(stale.size())
+ .arg(stale.join(QStringLiteral("; ")))));
+
+ QVERIFY2(unexpected.isEmpty(),
+ qPrintable(QStringLiteral("%1 menu mnemonic collision(s), where "
+ "Qt cycles the highlight instead of "
+ "activating: %2")
+ .arg(unexpected.size())
+ .arg(unexpected.join(QStringLiteral("; ")))));
+}
+
void TestMainWindow::everyActionCarriesAnIcon()
{
// Item 56. The complaint was inconsistency, not absence: eight actions had
@@ -6967,15 +7251,22 @@ void TestMainWindow::noTwoActionsShareAnIcon()
// the words saying which. Giving them five invented shapes would be less
// clear than the pairing.
//
+ // reply_no_quote joined them in item 123 for exactly the same reason: it
+ // shares reply's icon, it is a menu entry that always carries its text,
+ // and it is not on the toolbar. The list is therefore no longer only the
+ // thread tier, which is why it is named for the PROPERTY that earns the
+ // exemption rather than for the tier that first needed it.
+ //
// Named as an exception list rather than by asking the toolbar what it
// holds, so that PUTTING one of these on the toolbar fails this test
// rather than silently passing it.
- static const QStringList menuOnlyThreadActions = {
+ static const QStringList menuOnlySharedIconActions = {
QStringLiteral("archive_thread"),
QStringLiteral("delete_thread"),
QStringLiteral("spam_thread"),
QStringLiteral("toggle_unread_thread"),
QStringLiteral("flag_thread"),
+ QStringLiteral("reply_no_quote"),
};
const Config config;
@@ -6986,7 +7277,7 @@ void TestMainWindow::noTwoActionsShareAnIcon()
// may sit on the toolbar.
auto *toolBar = window.findChild<QToolBar *>();
QVERIFY(toolBar);
- for (const QString &name : menuOnlyThreadActions) {
+ for (const QString &name : menuOnlySharedIconActions) {
auto *action = window.findChild<QAction *>(name);
QVERIFY2(action, qPrintable(QStringLiteral("no action named %1").arg(name)));
QVERIFY2(!toolBar->actions().contains(action),
@@ -7006,7 +7297,7 @@ void TestMainWindow::noTwoActionsShareAnIcon()
QVERIFY2(action, qPrintable(QStringLiteral("no action named %1").arg(name)));
if (!action->icon().isNull())
++withIcons;
- if (menuOnlyThreadActions.contains(name))
+ if (menuOnlySharedIconActions.contains(name))
continue;
if (action->icon().isNull())
continue;
@@ -7033,7 +7324,7 @@ void TestMainWindow::noTwoActionsShareAnIcon()
// And the exception list did not swallow the comparison itself.
QCOMPARE(compared, KeyMap::knownActions().size()
- - menuOnlyThreadActions.size());
+ - menuOnlySharedIconActions.size());
QVERIFY2(collisions.isEmpty(),
qPrintable(QStringLiteral("actions sharing one icon: %1")
@@ -7943,6 +8234,933 @@ void TestMainWindow::aWorkerBackedWindowReturnsRealThreads()
QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1, 15000);
}
+namespace {
+
+/// A worker-backed window with one message in one account's maildir.
+///
+/// The compose cases all need the same three things: a message on disk, an
+/// account owning the folder it landed in, and a selected row. Repeating that
+/// in six tests is how one of them ends up subtly different from the rest.
+struct WorkerComposeFixture
+{
+ WorkerBackedWindow backed;
+
+ /// Writes one message into <accountMaildir>/inbox and indexes it.
+ /// \p composeKey, when given, is written as one line under [compose].
+ bool seed(const QList<WorkerBackedWindow::AccountSpec> &accounts,
+ const QString &folder, const QString &composeKey = QString())
+ {
+ if (!backed.fixture().addMessage(
+ folder, QStringLiteral("compose1@example.org"),
+ QStringLiteral("A subject"),
+ QStringLiteral("sender@example.org"),
+ // Friday, verified with `date -d 2026-08-14 +%A`.
+ QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200"),
+ QStringLiteral("Body text."))) {
+ return false;
+ }
+ return backed.buildWithAccounts(accounts, composeKey);
+ }
+
+ /// Runs a query and puts the current index on its one row.
+ ///
+ /// Waits on the MAIL ROOT as well as on the row. The reply family is gated
+ /// on which account owns the message, which needs the root, and that
+ /// arrives on its own queued signal: asserting on an action's enabled
+ /// state before it lands measures the startup race rather than the rule.
+ static bool selectTheMessage(MainWindow &window)
+ {
+ auto *model = window.findChild<ThreadListModel *>();
+ auto *view = window.findChild<ThreadListView *>();
+ auto *queryEdit =
+ window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ if (!model || !view || !queryEdit)
+ return false;
+
+ queryEdit->setText(QStringLiteral("tag:inbox"));
+ queryEdit->returnPressed();
+
+ bool ready = false;
+ for (int attempt = 0; attempt < 150 && !ready; ++attempt) {
+ ready = model->rowCount(QModelIndex()) == 1
+ && !window.mailRootForTesting().isEmpty();
+ if (!ready)
+ QTest::qWait(100);
+ }
+ if (!ready)
+ return false;
+
+ view->setCurrentIndex(model->index(0, 0, QModelIndex()));
+ return true;
+ }
+};
+
+} // namespace
+
+void TestMainWindow::theMailRootComesFromTheConfigNotTheIndex()
+{
+ // Item 124's rule, for the path the composer composes drafts and sent
+ // copies under. splitIndex() is what makes this test able to fail at all:
+ // in the ordinary layout notmuch_database_get_path() and
+ // NOTMUCH_CONFIG_MAIL_ROOT return the SAME string, so a test written
+ // against it passes whichever accessor the code uses.
+ WorkerComposeFixture fixture;
+ fixture.backed.fixture().splitIndex();
+ QVERIFY2(fixture.seed({ { QStringLiteral("work"), QStringLiteral("work"),
+ QString(), QStringLiteral("/bin/true"),
+ QStringLiteral("you@example.org") } },
+ QStringLiteral("work/inbox")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+
+ QTRY_VERIFY_WITH_TIMEOUT(!window.mailRootForTesting().isEmpty(), 15000);
+
+ // The MAIL root, not the index directory. Under the split layout these are
+ // different directories, and a draft composed under the index one is
+ // written into the Xapian tree.
+ QCOMPARE(window.mailRootForTesting(),
+ QDir(fixture.backed.fixture().maildirPath()).absolutePath());
+ QVERIFY2(window.mailRootForTesting()
+ != QDir(fixture.backed.fixture().indexPath()).absolutePath(),
+ "the window took the index directory for the mail root");
+}
+
+void TestMainWindow::replyIsDisabledOnAReceiveOnlyAccountsMail()
+{
+ // The capability IS the send_command's presence, so this account is
+ // written without one.
+ WorkerComposeFixture fixture;
+ QVERIFY2(fixture.seed({ { QStringLiteral("listsonly"),
+ QStringLiteral("listsonly"), QString(),
+ /*sendCommand=*/QString(),
+ QStringLiteral("you@example.org") } },
+ QStringLiteral("listsonly/inbox")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ QVERIFY(WorkerComposeFixture::selectTheMessage(window));
+
+ for (const QString &name : { QStringLiteral("reply"),
+ QStringLiteral("reply_all"),
+ QStringLiteral("reply_no_quote"),
+ QStringLiteral("forward") }) {
+ auto *action = window.findChild<QAction *>(name);
+ QVERIFY2(action, qPrintable(QStringLiteral("no action %1").arg(name)));
+ QVERIFY2(!action->isEnabled(),
+ qPrintable(QStringLiteral("%1 was live on receive-only mail")
+ .arg(name)));
+ }
+
+ // save_message is NEVER disabled, including here. It is the escape hatch
+ // for exactly this case: write the raw message out and attach it to a new
+ // message from an account that can send.
+ auto *save = window.findChild<QAction *>(QStringLiteral("save_message"));
+ QVERIFY(save);
+ QVERIFY2(save->isEnabled(),
+ "save_message was disabled, removing the escape hatch");
+}
+
+void TestMainWindow::replyIsEnabledOnASendingAccountsMail()
+{
+ // The guard for the test above. Without it, a bug disabling the reply
+ // family unconditionally would pass every assertion there while removing
+ // the feature entirely.
+ WorkerComposeFixture fixture;
+ QVERIFY2(fixture.seed({ { QStringLiteral("work"), QStringLiteral("work"),
+ QString(), QStringLiteral("/bin/true"),
+ QStringLiteral("you@example.org") } },
+ QStringLiteral("work/inbox")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ QVERIFY(WorkerComposeFixture::selectTheMessage(window));
+
+ for (const QString &name : { QStringLiteral("reply"),
+ QStringLiteral("reply_all"),
+ QStringLiteral("reply_no_quote"),
+ QStringLiteral("forward") }) {
+ auto *action = window.findChild<QAction *>(name);
+ QVERIFY2(action, qPrintable(QStringLiteral("no action %1").arg(name)));
+ QVERIFY2(action->isEnabled(),
+ qPrintable(QStringLiteral("%1 was disabled on mail from an "
+ "account that can send").arg(name)));
+ }
+
+ // And no ribbon: this account can send, so there is nothing to explain.
+ auto *ribbon =
+ window.findChild<QLabel *>(QStringLiteral("receiveOnlyRibbon"));
+ QVERIFY(ribbon);
+ QVERIFY2(ribbon->isHidden(),
+ "the receive-only ribbon showed on an account that can send");
+}
+
+void TestMainWindow::theReceiveOnlyRibbonNamesTheAccount()
+{
+ // The ribbon is a WIDGET in MessageView's layout, not markup inside the
+ // web view. Composing HTML from configuration into the one document that
+ // renders input from strangers is the wrong direction.
+ WorkerComposeFixture fixture;
+ QVERIFY2(fixture.seed({ { QStringLiteral("listsonly"),
+ QStringLiteral("listsonly"), QString(),
+ QString(), QStringLiteral("you@example.org") } },
+ QStringLiteral("listsonly/inbox")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ QVERIFY(WorkerComposeFixture::selectTheMessage(window));
+
+ auto *ribbon =
+ window.findChild<QLabel *>(QStringLiteral("receiveOnlyRibbon"));
+ QVERIFY2(ribbon, "no ribbon widget exists");
+
+ // isHidden() rather than isVisibleTo(): under the offscreen platform an
+ // unshown window's children report not visible whatever the code does, so
+ // isVisibleTo would fail against correct code. What is being asserted is
+ // that the ribbon was not left explicitly hidden.
+ QVERIFY2(!ribbon->isHidden(),
+ "the ribbon did not appear on receive-only mail");
+ QVERIFY2(ribbon->text().contains(QStringLiteral("listsonly")),
+ qPrintable(QStringLiteral("the ribbon does not name the account: %1")
+ .arg(ribbon->text())));
+
+ // PlainText, not AutoText. A QLabel guesses under AutoText, and this is
+ // the same protection MessageDetailsDialog states on every value.
+ QCOMPARE(ribbon->textFormat(), Qt::PlainText);
+}
+
+void TestMainWindow::composeIsDisabledOnlyWhenNoAccountCanSend()
+{
+ // An installation with no send_command anywhere is a valid read-only
+ // installation and is not warned about; compose is simply unavailable.
+ {
+ WorkerComposeFixture fixture;
+ QVERIFY2(fixture.seed({ { QStringLiteral("listsonly"),
+ QStringLiteral("listsonly"), QString(),
+ QString(), QStringLiteral("you@example.org") } },
+ QStringLiteral("listsonly/inbox")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ auto *compose = window.findChild<QAction *>(QStringLiteral("compose"));
+ QVERIFY(compose);
+ QVERIFY2(!compose->isEnabled(),
+ "compose was live with no account able to send");
+ }
+ {
+ WorkerComposeFixture fixture;
+ QVERIFY2(fixture.seed(
+ { { QStringLiteral("listsonly"),
+ QStringLiteral("listsonly"), QString(), QString(),
+ QStringLiteral("you@example.org") },
+ { QStringLiteral("work"), QStringLiteral("work"),
+ QString(), QStringLiteral("/bin/true"),
+ QStringLiteral("work@example.org") } },
+ QStringLiteral("listsonly/inbox")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ auto *compose = window.findChild<QAction *>(QStringLiteral("compose"));
+ QVERIFY(compose);
+ QVERIFY2(compose->isEnabled(),
+ "compose was disabled although one account can send");
+ }
+}
+
+void TestMainWindow::quittingWithACleanComposerAsksNothing()
+{
+ // Case 1: every composer clean, quit directly, no dialog. A dialog here
+ // would be the "are you sure" this project deliberately does not do.
+ WorkerComposeFixture fixture;
+ QVERIFY2(fixture.seed({ { QStringLiteral("work"), QStringLiteral("work"),
+ QString(), QStringLiteral("/bin/true"),
+ QStringLiteral("you@example.org") } },
+ QStringLiteral("work/inbox")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ QTRY_VERIFY_WITH_TIMEOUT(!window.mailRootForTesting().isEmpty(), 15000);
+
+ QVERIFY2(window.openComposerForTest(), "no composer opened");
+ QCOMPARE(window.openComposerCount(), 1);
+
+ QVERIFY2(window.composersBlockingQuit().isEmpty(),
+ "a clean composer was reported as blocking quit");
+
+ // Composers are parentless top-level windows and outlive this MainWindow,
+ // carrying a MessageSender and a running autosave timer into whatever test
+ // runs next. Closed here rather than left for the destructor, which never
+ // touches m_composers.
+ for (ComposeWindow *composer : window.openComposersForTest()) {
+ composer->show();
+ composer->close();
+ }
+}
+
+void TestMainWindow::quittingWithUnsavedEditsReportsEveryComposer()
+{
+ // Case 2: ONE dialog whatever the count, so the quit path has to see BOTH
+ // composers rather than stopping at the first dirty one.
+ WorkerComposeFixture fixture;
+ QVERIFY2(fixture.seed({ { QStringLiteral("work"), QStringLiteral("work"),
+ QString(), QStringLiteral("/bin/true"),
+ QStringLiteral("you@example.org") } },
+ QStringLiteral("work/inbox")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ QTRY_VERIFY_WITH_TIMEOUT(!window.mailRootForTesting().isEmpty(), 15000);
+
+ QVERIFY(window.openComposerForTest());
+ QVERIFY(window.openComposerForTest());
+ QCOMPARE(window.openComposerCount(), 2);
+
+ // Clean until something is typed, which is the case-1 assertion holding
+ // here too and the guard that this test can distinguish the two states.
+ QVERIFY(window.composersBlockingQuit().isEmpty());
+
+ window.markComposersDirtyForTest();
+ QCOMPARE(window.composersBlockingQuit().size(), 2);
+
+ // Left open, these are parentless top-level windows with a live autosave
+ // timer, surviving into later tests. See the note in the clean-composer
+ // case above.
+ for (ComposeWindow *composer : window.openComposersForTest()) {
+ composer->show();
+ composer->close();
+ }
+}
+
+void TestMainWindow::closingAComposerCompactsTheRegistry()
+{
+ // The closed() signal's ONE job. The QPointer alone would keep
+ // composersBlockingQuit() correct, since it nulls on destruction, but the
+ // entry would stay in the list for the session's lifetime. This asserts
+ // the list is compacted, which only the signal can do.
+ WorkerComposeFixture fixture;
+ QVERIFY2(fixture.seed({ { QStringLiteral("work"), QStringLiteral("work"),
+ QString(), QStringLiteral("/bin/true"),
+ QStringLiteral("you@example.org") } },
+ QStringLiteral("work/inbox")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ QTRY_VERIFY_WITH_TIMEOUT(!window.mailRootForTesting().isEmpty(), 15000);
+
+ ComposeWindow *composer = window.openComposerForTest();
+ QVERIFY(composer);
+ QCOMPARE(window.openComposerCount(), 1);
+
+ // A composer that was never shown returns early from close() WITHOUT
+ // reaching closeEvent(), so the signal would never fire and this test
+ // would assert nothing at all.
+ composer->show();
+ QVERIFY(composer->close());
+
+ // And the quit path must not see a destroyed window, which is the
+ // QPointer's job rather than the signal's.
+ QCOMPARE(window.openComposerCount(), 0);
+ QVERIFY(window.composersBlockingQuit().isEmpty());
+}
+
+void TestMainWindow::savingAMessageRefusesToEscapeTheChosenDirectory()
+{
+ // A subject is input from a stranger and is what the default filename is
+ // derived from, so it may carry separators and "..". Asserted through
+ // Attachment's own helpers, which is what saveDisplayedMessage() calls:
+ // a second implementation of the check here would prove nothing about the
+ // one that runs.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ const QString directory = dir.path();
+
+ Attachment naming;
+ naming.filename = QStringLiteral("../../etc/passwd");
+ const QString target =
+ QDir(directory).absoluteFilePath(naming.safeFilename());
+
+ QVERIFY2(Attachment::isPathInsideDirectory(directory, target),
+ "a traversing subject escaped the chosen directory");
+ QVERIFY2(!target.contains(QStringLiteral("/etc/passwd")),
+ qPrintable(QStringLiteral("the traversal survived: %1").arg(target)));
+
+ // Compared as PATHS, never with startsWith(): a sibling directory whose
+ // name merely begins with the chosen one's is not inside it.
+ QVERIFY2(!Attachment::isPathInsideDirectory(
+ directory, directory + QStringLiteral("-evil/message.eml")),
+ "a sibling directory passed the containment check");
+}
+
+void TestMainWindow::aHostileSubjectCannotEscapeTheSaveDirectory()
+{
+ // Asserted through MainWindow::defaultMessageFilename(), which is what
+ // saveDisplayedMessage() actually calls. The previous version of this
+ // check built an Attachment by hand and called safeFilename() directly:
+ // that proves what Attachment does and nothing about whether save_message
+ // asks it anything, and three mutations to the real path left it green.
+ // CLAUDE.md: assert through the function the production path calls, not
+ // through the one it calls INTO.
+ const QString traversal =
+ MainWindow::defaultMessageFilename(QStringLiteral("../../etc/passwd"));
+
+ // No separator survives, so the name cannot address another directory.
+ QVERIFY2(!traversal.contains(QLatin1Char('/')),
+ qPrintable(QStringLiteral("a separator survived: %1").arg(traversal)));
+ // NOT asserting the absence of "..": with every separator replaced, a
+ // literal ".." inside a filename addresses nothing and is a legitimate
+ // part of a name. What matters is that the result is a single path
+ // COMPONENT, which is what makes traversal impossible.
+ QCOMPARE(QFileInfo(traversal).fileName(), traversal);
+ QVERIFY2(traversal != QStringLiteral("..")
+ && traversal != QStringLiteral("."),
+ qPrintable(QStringLiteral("the name is a directory reference: %1")
+ .arg(traversal)));
+
+ // And joining it onto a directory really does stay inside.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Attachment naming;
+ naming.filename = traversal;
+ const QString target =
+ QDir(dir.path()).absoluteFilePath(naming.safeFilename());
+ QVERIFY2(Attachment::isPathInsideDirectory(dir.path(), target),
+ qPrintable(QStringLiteral("escaped the directory: %1").arg(target)));
+
+ // A backslash is a separator too, on a name written by Windows software.
+ const QString backslash = MainWindow::defaultMessageFilename(
+ QStringLiteral("..\\..\\Windows\\System32\\config"));
+ QVERIFY2(!backslash.contains(QLatin1Char('\\')),
+ qPrintable(QStringLiteral("a backslash survived: %1").arg(backslash)));
+
+ // A subject with nothing usable still yields a name rather than "" or a
+ // bare extension, which would make the write land on a dotfile.
+ const QString empty = MainWindow::defaultMessageFilename(QString());
+ QVERIFY2(empty.startsWith(QStringLiteral("message")),
+ qPrintable(QStringLiteral("empty subject gave: %1").arg(empty)));
+
+ // The extension survives truncation. Truncating AFTER appending it would
+ // cut ".eml" off a long subject and write an extensionless file.
+ const QString long_ = MainWindow::defaultMessageFilename(
+ QString(400, QLatin1Char('a')));
+ QVERIFY2(long_.endsWith(QStringLiteral(".eml")),
+ qPrintable(QStringLiteral("the extension was truncated away: %1")
+ .arg(long_.right(20))));
+}
+
+void TestMainWindow::savingTwiceDoesNotOverwriteTheFirstFile()
+{
+ // Two messages very often share a subject, and the filename is derived
+ // from it, so the second save must not destroy the first. Driven through
+ // saveDisplayedMessage() by way of the directory seam, which is the only
+ // way to reach the write guard at all: the file dialog is a modal the
+ // offscreen platform cannot click.
+ WorkerComposeFixture fixture;
+ QVERIFY2(fixture.seed({ { QStringLiteral("work"), QStringLiteral("work"),
+ QString(), QStringLiteral("/bin/true"),
+ QStringLiteral("you@example.org") } },
+ QStringLiteral("work/inbox")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ QVERIFY(WorkerComposeFixture::selectTheMessage(window));
+
+ QTemporaryDir out;
+ QVERIFY(out.isValid());
+
+ window.saveDisplayedMessageForTest(out.path());
+ window.saveDisplayedMessageForTest(out.path());
+
+ // Two files, not one overwritten. Asserted on the COUNT rather than on the
+ // second name, so the disambiguation scheme can change without the test
+ // caring what it is called.
+ const QStringList written =
+ QDir(out.path()).entryList(QDir::Files | QDir::NoDotAndDotDot);
+ QCOMPARE(written.size(), 2);
+
+ // And both are real copies rather than one empty placeholder.
+ for (const QString &name : written) {
+ QVERIFY2(QFileInfo(QDir(out.path()).absoluteFilePath(name)).size() > 0,
+ qPrintable(QStringLiteral("%1 is empty").arg(name)));
+ }
+}
+
+void TestMainWindow::savingAMessageWithAHostileSubjectStaysInTheDirectory()
+{
+ // Driven through saveDisplayedMessage() with a real hostile subject, which
+ // is the only shape that covers the production write path. An earlier
+ // version of this coverage built an Attachment by hand and called
+ // safeFilename() and isPathInsideDirectory() directly, which proves what
+ // Attachment does and nothing about whether save_message asks it anything.
+ //
+ // WHAT THIS CAN AND CANNOT CATCH, measured rather than assumed, because
+ // the numbers are surprising and the next person will otherwise redo the
+ // work. Three independent layers stand between a subject and the write:
+ // defaultMessageFilename() replaces separators, Attachment::safeFilename()
+ // reduces to a basename, and Attachment::isPathInsideDirectory() refuses
+ // the write. EACH ONE ALONE IS SUFFICIENT, so removing any single layer
+ // leaves this test green: measured, all three single-layer mutations pass.
+ // Removing all three fails it. That is real defence-in-depth rather than a
+ // probe pointed at the wrong object, and mimeparser.h:71-77 already says
+ // the same of isPathInsideDirectory, but it does mean this test is a guard
+ // against the DEFENCES COLLECTIVELY disappearing, not a guard on any one
+ // of them. aHostileSubjectCannotEscapeTheSaveDirectory() covers the first
+ // layer on its own, and a single-layer mutation there does fail.
+ //
+ // The subject is ABSOLUTE rather than "../..", and that matters.
+ // QDir::absoluteFilePath() does not resolve ".." (measured: it
+ // concatenates), but the collision loop below can rename a relative
+ // traversal by accident when the target happens to exist, which makes it
+ // the weaker probe. An absolute candidate replaces the directory outright.
+ WorkerComposeFixture fixture;
+ QVERIFY(fixture.backed.fixture().addMessage(
+ QStringLiteral("work/inbox"), QStringLiteral("hostile@example.org"),
+ // The subject is the attacker's input, and it is what the default
+ // filename is derived from.
+ // Absolute, not "../..". QDir::absoluteFilePath() does NOT resolve
+ // ".." (measured: it concatenates, giving "<dir>/../../x"), but an
+ // ABSOLUTE candidate replaces the directory outright, which is the
+ // escape that survives every accident. A relative traversal can be
+ // neutralised by the collision loop renaming it when the target
+ // happens to exist, so it is the weaker probe of the two.
+ QStringLiteral("/tmp/qtmaildir-pwned-probe"),
+ QStringLiteral("sender@example.org"),
+ // Friday, verified with `date -d 2026-08-14 +%A`.
+ QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200"),
+ QStringLiteral("Body text.")));
+ QVERIFY2(fixture.backed.buildWithAccounts(
+ { { QStringLiteral("work"), QStringLiteral("work"), QString(),
+ QStringLiteral("/bin/true"),
+ QStringLiteral("you@example.org") } }),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ QVERIFY(WorkerComposeFixture::selectTheMessage(window));
+
+ // A directory INSIDE another, so an escape has somewhere to land that the
+ // test can then look at. Escaping "out" writes into parent/, which is what
+ // the assertions below check is still empty.
+ QTemporaryDir parent;
+ QVERIFY(parent.isValid());
+ const QString out = parent.filePath(QStringLiteral("out"));
+ QVERIFY(QDir().mkpath(out));
+
+ window.saveDisplayedMessageForTest(out);
+
+ // The file landed inside the chosen directory.
+ // NOT QDir::Hidden. A file whose name begins with a dot is hidden on every
+ // Unix desktop, so the write would succeed while the user could not find
+ // what they saved. Listing without Hidden is what makes this assertion
+ // notice that, and it is how the leading-dot case was found: a traversing
+ // subject reduces to "..-..-etc-passwd" once its separators are replaced,
+ // which is a dotfile.
+ const QStringList inside =
+ QDir(out).entryList(QDir::Files | QDir::NoDotAndDotDot);
+ QCOMPARE(inside.size(), 1);
+ QVERIFY2(!inside.first().startsWith(QLatin1Char('.')),
+ qPrintable(QStringLiteral("the saved message is hidden: %1")
+ .arg(inside.first())));
+
+ // And nothing was written beside it, which is where a traversal would go.
+ const QStringList escaped =
+ QDir(parent.path()).entryList(QDir::Files | QDir::NoDotAndDotDot);
+ QVERIFY2(escaped.isEmpty(),
+ qPrintable(QStringLiteral("a file escaped the directory: %1")
+ .arg(escaped.join(QLatin1Char(' ')))));
+
+ // The written path really is contained, compared as PATHS rather than with
+ // startsWith(): a sibling directory whose name merely begins with the
+ // chosen one's is not inside it.
+ const QString written = QDir(out).absoluteFilePath(inside.first());
+ QVERIFY2(Attachment::isPathInsideDirectory(out, written),
+ qPrintable(QStringLiteral("escaped: %1").arg(written)));
+ QVERIFY2(QFileInfo(written).size() > 0, "the saved message is empty");
+}
+
+void TestMainWindow::aStuckComposeRequestDoesNotHijackTheNextPaneLoad()
+{
+ // A compose request for a message that is not in the index used to stay
+ // armed for ever, because it was cleared only on the branch that FOUND the
+ // id. The delayed symptom is the bad one: the pane's own loads are the
+ // traffic being matched against, so merely selecting that message later
+ // matched, opened a composer nobody asked for, and returned before
+ // renderMessages() leaving the pane blank on the row just clicked.
+ WorkerComposeFixture fixture;
+ QVERIFY2(fixture.seed({ { QStringLiteral("work"), QStringLiteral("work"),
+ QString(), QStringLiteral("/bin/true"),
+ QStringLiteral("you@example.org") } },
+ QStringLiteral("work/inbox")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ QVERIFY(WorkerComposeFixture::selectTheMessage(window));
+
+ // Arm a request for an id the database does not hold. loadMessage() emits
+ // an empty result for it, which is what must disarm the request.
+ window.requestMessageForComposeForTest(
+ QStringLiteral("nosuchmessage@example.org"),
+ ComposeContext::Kind::Reply, true);
+
+ // No composer, and the request stops being armed.
+ QTRY_VERIFY_WITH_TIMEOUT(!window.composeRequestPendingForTest(), 15000);
+ QCOMPARE(window.openComposerCount(), 0);
+
+ // Now the delayed half. Select the real message: the pane must render it,
+ // and no composer may appear. With the request still armed this failed
+ // only if the ids matched, so the request is re-armed for the REAL id to
+ // make the hijack reachable at all.
+ window.requestMessageForComposeForTest(
+ QStringLiteral("compose1@example.org"), ComposeContext::Kind::Reply,
+ true);
+ QTRY_VERIFY_WITH_TIMEOUT(!window.composeRequestPendingForTest(), 15000);
+
+ // That one DID match, so it opened a composer. Close it and clear the
+ // pane, then re-select and assert the pane renders rather than a second
+ // composer opening.
+ for (ComposeWindow *composer : window.openComposersForTest()) {
+ composer->show();
+ composer->close();
+ }
+ QCOMPARE(window.openComposerCount(), 0);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ auto *view = window.findChild<ThreadListView *>();
+ QVERIFY(model && view);
+ view->setCurrentIndex(QModelIndex());
+ view->setCurrentIndex(model->index(0, 0, QModelIndex()));
+
+ auto *pane = window.findChild<MessageView *>();
+ QVERIFY(pane);
+ QTRY_VERIFY_WITH_TIMEOUT(!pane->showingPlaceholder(), 15000);
+ QCOMPARE(window.openComposerCount(), 0);
+}
+
+void TestMainWindow::quittingClosesEveryComposerRatherThanOrphaningIt()
+{
+ // A composer is a parentless top-level window, deliberately: it must appear
+ // in the task switcher and be usable while the main window is. The cost is
+ // that closing the main window does NOT take it down, so quitting left a
+ // composer on screen with no application behind it, and Qt kept the process
+ // alive for it. Reported from a hand test: the main window closed, the
+ // orphan stayed, and its own close then raised the unsaved-edits dialog for
+ // a session the user had already ended.
+ //
+ // The quit path already ASKS about those edits and saves them; what it
+ // never did was close the windows afterwards.
+ WorkerComposeFixture fixture;
+ QVERIFY2(fixture.seed({ { QStringLiteral("work"), QStringLiteral("work"),
+ QString(), QStringLiteral("/bin/true"),
+ QStringLiteral("you@example.org") } },
+ QStringLiteral("work/inbox")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ QTRY_VERIFY_WITH_TIMEOUT(!window.mailRootForTesting().isEmpty(), 15000);
+
+ // Two, so the fix cannot be "close the last one" and pass.
+ QVERIFY2(window.openComposerForTest(), "no composer opened");
+ QVERIFY2(window.openComposerForTest(), "no second composer opened");
+ QCOMPARE(window.openComposerCount(), 2);
+
+ // Clean composers: the point here is the CLOSE, not the unsaved-edits
+ // dialog, which has its own tests and would block this one on a modal.
+ window.show();
+ window.close();
+
+ // deleteLater() is how a composer goes away, so the count settles on the
+ // next event-loop pass rather than synchronously.
+ QTRY_COMPARE_WITH_TIMEOUT(window.openComposerCount(), 0, 5000);
+}
+
+void TestMainWindow::theSaveLoopToleratesAComposerClosedUnderTheDialog()
+{
+ // The regression for a measured use-after-free. composersBlockingQuit()
+ // used to return raw pointers, and the quit path held that list across
+ // QMessageBox::exec(). A nested event loop PROCESSES deleteLater(),
+ // verified in a standalone Qt program: a parentless WA_DeleteOnClose
+ // window closed while a modal is up is destroyed BEFORE exec() returns.
+ // The dialog is window-modal to the main window only, so a user really can
+ // close a composer from under it, and Save then ran on freed memory.
+ //
+ // The modal itself cannot be driven under the offscreen platform, so what
+ // is asserted is the property that makes the loop safe: the list holds
+ // QPointers, and an entry whose window is destroyed reads as null rather
+ // than as a dangling pointer. That is exactly what the null check in the
+ // Save loop consumes. Stated plainly because it is NOT full coverage of
+ // closeEvent(): see the report.
+ WorkerComposeFixture fixture;
+ QVERIFY2(fixture.seed({ { QStringLiteral("work"), QStringLiteral("work"),
+ QString(), QStringLiteral("/bin/true"),
+ QStringLiteral("you@example.org") } },
+ QStringLiteral("work/inbox")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ QTRY_VERIFY_WITH_TIMEOUT(!window.mailRootForTesting().isEmpty(), 15000);
+
+ QVERIFY(window.openComposerForTest());
+ QVERIFY(window.openComposerForTest());
+ window.markComposersDirtyForTest();
+
+ QList<QPointer<ComposeWindow>> blocking = window.composersBlockingQuit();
+ QCOMPARE(blocking.size(), 2);
+
+ // Destroy one exactly as closing it under the dialog would, including the
+ // deleteLater() a nested exec() would process.
+ ComposeWindow *doomed = blocking.first().data();
+ QVERIFY(doomed);
+ doomed->show();
+ doomed->close();
+ QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete);
+
+ // The held list reports it as gone rather than handing back a dangling
+ // pointer. A raw QList<ComposeWindow *> could not express this at all.
+ QVERIFY2(blocking.first().isNull(),
+ "the held entry did not null when its window was destroyed");
+ QVERIFY2(!blocking.last().isNull(),
+ "the surviving composer was lost too");
+
+ // And the loop the quit path runs skips the null and still saves the
+ // survivor, which is the behaviour the crash destroyed: the remaining
+ // drafts were never written because the crash happened mid-loop.
+ int saved = 0;
+ for (const QPointer<ComposeWindow> &composer : blocking) {
+ if (composer) {
+ composer->saveDraftNow();
+ ++saved;
+ }
+ }
+ QCOMPARE(saved, 1);
+}
+
+namespace {
+
+/// Writes a multipart/mixed message with one named attachment part.
+///
+/// Hand-written rather than built with MessageBuilder: this is the INPUT to
+/// the forward path, and generating it with the same library that consumes it
+/// would let an encoding mistake agree with itself.
+bool writeMessageWithAttachment(const QString &path, const QString &attachName,
+ const QByteArray &attachBody)
+{
+ QFile file(path);
+ if (!file.open(QIODevice::WriteOnly))
+ return false;
+ QByteArray raw =
+ "From: sender@example.org\n"
+ "To: you@example.org\n"
+ "Subject: Quarterly report\n"
+ "Message-ID: <fwd-1@example.org>\n"
+ // Friday, verified with `date -d 2026-08-14 +%A`. Qt::RFC2822Date
+ // validates the weekday against the date.
+ "Date: Fri, 14 Aug 2026 10:00:00 +0200\n"
+ "MIME-Version: 1.0\n"
+ "Content-Type: multipart/mixed; boundary=\"MIX\"\n"
+ "\n"
+ "--MIX\n"
+ "Content-Type: text/plain; charset=utf-8\n"
+ "\n"
+ "See the attached document.\n"
+ "--MIX\n"
+ "Content-Type: application/octet-stream; name=\"" + attachName.toUtf8() + "\"\n"
+ "Content-Disposition: attachment; filename=\"" + attachName.toUtf8() + "\"\n"
+ "\n" + attachBody + "\n"
+ "--MIX--\n";
+ file.write(raw);
+ file.close();
+ return true;
+}
+
+/// Writes a multipart/alternative message that DOES carry a text/html part.
+bool writeHtmlMessage(const QString &path)
+{
+ QFile file(path);
+ if (!file.open(QIODevice::WriteOnly))
+ return false;
+ file.write(
+ "From: sender@example.org\n"
+ "To: you@example.org\n"
+ "Subject: Has HTML\n"
+ "Message-ID: <html-1@example.org>\n"
+ "Date: Fri, 14 Aug 2026 10:00:00 +0200\n"
+ "MIME-Version: 1.0\n"
+ "Content-Type: multipart/alternative; boundary=\"ALT\"\n"
+ "\n"
+ "--ALT\n"
+ "Content-Type: text/plain; charset=utf-8\n"
+ "\n"
+ "plain\n"
+ "--ALT\n"
+ "Content-Type: text/html; charset=utf-8\n"
+ "\n"
+ "<p>html</p>\n"
+ "--ALT--\n");
+ file.close();
+ return true;
+}
+
+} // namespace
+
+void TestMainWindow::forwardingCarriesTheOriginalsAttachments()
+{
+ // The spec requires Forward to carry attachments, twice. The context field
+ // existed and was never assigned, so a Forward opened with an empty
+ // attachment list: the composer looked entirely correct, and the recipient
+ // received a body quoting a document that was not attached, with nothing
+ // erroring anywhere.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ const QString original = dir.filePath(QStringLiteral("original.eml"));
+ QVERIFY(writeMessageWithAttachment(original, QStringLiteral("report.pdf"),
+ QByteArray("PDFBYTES")));
+
+ QTemporaryDir confDir;
+ QVERIFY(confDir.isValid());
+ const QString confPath = confDir.filePath(QStringLiteral("qtmaildir.conf"));
+ {
+ QSettings settings(confPath, QSettings::IniFormat);
+ settings.beginGroup(QStringLiteral("account.work"));
+ settings.setValue(QStringLiteral("maildir"), QStringLiteral("work"));
+ settings.setValue(QStringLiteral("address"),
+ QStringLiteral("you@example.org"));
+ settings.setValue(QStringLiteral("send_command"),
+ QStringLiteral("/bin/true"));
+ settings.endGroup();
+ settings.sync();
+ }
+ Config config;
+ config.load(confPath);
+
+ ComposeContext context;
+ context.kind = ComposeContext::Kind::Forward;
+ context.accountKey = QStringLiteral("work");
+ context.originalPath = original;
+ context.subject = QStringLiteral("Fwd: Quarterly report");
+
+ ComposeWindow composer(context, config, dir.path());
+
+ // The attachment is present, and it is a REAL FILE on disk rather than a
+ // remembered name: MessageBuilder reads every attachment by path at build
+ // time and refuses a build naming one that does not exist.
+ const QStringList attached = composer.attachments();
+ QCOMPARE(attached.size(), 1);
+ QVERIFY2(QFileInfo::exists(attached.first()),
+ qPrintable(QStringLiteral("the extracted path does not exist: %1")
+ .arg(attached.first())));
+ QCOMPARE(QFileInfo(attached.first()).fileName(),
+ QStringLiteral("report.pdf"));
+
+ // And the bytes are the original's, not an empty placeholder.
+ QFile written(attached.first());
+ QVERIFY(written.open(QIODevice::ReadOnly));
+ QCOMPARE(written.readAll(), QByteArray("PDFBYTES"));
+ written.close();
+
+ // A Reply to the same message carries NOTHING. The spec says attachments
+ // are carried "for Forward, empty otherwise", and a reply that re-attached
+ // the original's documents would send them back to their own sender.
+ ComposeContext replyContext = context;
+ replyContext.kind = ComposeContext::Kind::Reply;
+ ComposeWindow replyComposer(replyContext, config, dir.path());
+ QVERIFY2(replyComposer.attachments().isEmpty(),
+ "a reply carried the original's attachments");
+}
+
+void TestMainWindow::forwardSeedsHtmlFromTheConfigNotTheOriginal()
+{
+ // MEASURED, and it revises what the spec review reported. Forward was
+ // NEVER seeding from the original: ComposeWindow::seedFields() already
+ // implements the split itself (composewindow.cpp, `isReply ?
+ // m_context.seedHtml : m_config.compose().sendHtml`), so the context's
+ // value is IGNORED for a forward and the config won regardless. The
+ // openComposerFor() line this test also covers was therefore cosmetic
+ // rather than a live defect: it stopped the context carrying a value that
+ // nothing read, which is worth doing but changed no behaviour.
+ //
+ // The consequence for this test: EITHER layer alone enforces the rule, so
+ // neither single-layer mutation fails it, and only mutating both does.
+ // Verified in both directions rather than assumed.
+ //
+ // The spec splits these: New and Forward seed from [compose] send_html,
+ // Reply and Reply-all from whether the original carried a text/html part.
+ // An HTML part in the original is a fact about the SENDER's software, so
+ // it is the right seed when answering them and says nothing about a
+ // forward, which is a new message to somebody else.
+ //
+ // Asserted on the CONTEXT the window is built from rather than through the
+ // checkbox, because what is under test is which source the value comes
+ // from. The two sources must DISAGREE or the test passes either way: the
+ // config says false while the original is plain text, so reading the
+ // original would give false as well. Hence send_html=true against a plain
+ // original: config true, original false.
+ // The two sources must DISAGREE or the test passes whichever one is read,
+ // and getting that wrong is why an earlier version of this survived every
+ // mutation: config send_html=FALSE against an original that DOES carry a
+ // text/html part. Reading the original gives true, reading the config
+ // gives false, so the assertion below can only be satisfied one way.
+ WorkerComposeFixture fixture;
+ QVERIFY2(fixture.seed({ { QStringLiteral("work"), QStringLiteral("work"),
+ QString(), QStringLiteral("/bin/true"),
+ QStringLiteral("you@example.org") } },
+ QStringLiteral("work/inbox"),
+ QStringLiteral("send_html=false")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ QTRY_VERIFY_WITH_TIMEOUT(!window.mailRootForTesting().isEmpty(), 15000);
+ QCOMPARE(fixture.backed.config().compose().sendHtml, false);
+
+ // The original lives inside the account's maildir so accountForReply()
+ // can resolve it; its CONTENT is what matters, not that notmuch indexed it.
+ const QString original =
+ QDir(window.mailRootForTesting())
+ .absoluteFilePath(QStringLiteral("work/inbox/cur/fwd-original"));
+ QVERIFY(writeHtmlMessage(original));
+
+ MimeParser parser;
+ const ParsedMessage parsed = parser.parse(original);
+ QVERIFY(parsed.ok);
+ QCOMPARE(parsed.hasHtml(), true);
+
+ // Through openComposerFor(), which is the production line that chooses
+ // the source. Building the context by hand here and asserting on the
+ // checkbox proved only that ComposeWindow honours what it is given: the
+ // mutation putting `original.hasHtml()` back stayed green, because the
+ // test was setting seedHtml itself.
+ MessageRef ref;
+ ref.messageId = QStringLiteral("html-1@example.org");
+ ref.filePath = original;
+ ref.matched = true;
+
+ window.openComposerForTest(ref, ComposeContext::Kind::Forward, true);
+
+ QList<ComposeWindow *> opened = window.openComposersForTest();
+ QCOMPARE(opened.size(), 1);
+ auto *sendHtml =
+ opened.first()->findChild<QCheckBox *>(QStringLiteral("sendHtml"));
+ QVERIFY(sendHtml);
+ QVERIFY2(!sendHtml->isChecked(),
+ "Forward seeded sendHtml from the original's HTML part rather "
+ "than from [compose] send_html");
+
+ // The counterpart, and it is what stops this asserting "always false":
+ // a REPLY to the same message seeds from the original, so it is checked
+ // where the forward is not. Without this half, disabling the checkbox
+ // outright would pass.
+ window.openComposerForTest(ref, ComposeContext::Kind::Reply, true);
+ const QList<ComposeWindow *> both = window.openComposersForTest();
+ QCOMPARE(both.size(), 2);
+ auto *replyHtml =
+ both.last()->findChild<QCheckBox *>(QStringLiteral("sendHtml"));
+ QVERIFY(replyHtml);
+ QVERIFY2(replyHtml->isChecked(),
+ "Reply did not seed sendHtml from the original's HTML part");
+
+ for (ComposeWindow *composer : both) {
+ composer->show();
+ composer->close();
+ }
+}
+
void TestMainWindow::aStartupAccountScopesTheStartupQuery()
{
// "Start me in Work - Inbox rather than All accounts - Inbox." The account
@@ -10583,4 +11801,1120 @@ void TestMainWindow::deletingOutsideTheTrashViewLeavesTheRowInPlace()
QCOMPARE(model->rowCount(QModelIndex()), 1);
}
+// ---------------------------------------------------------------------------
+// ComposeWindow, item 123.
+//
+// The composer owns widgets and nothing else here does, which is why its cases
+// live in this file. What is asserted is deliberately NOT what it looks like:
+// the autosave dirty check, the banner state, the message its widgets produce,
+// the format edits and the seeding rules, all of which are observable without
+// a painter. CLAUDE.md's "Rendering probes lie" section covers why counting
+// pixels here would prove nothing.
+// ---------------------------------------------------------------------------
+
+namespace {
+
+/// A Config written to a temporary INI, plus a Maildir root to write into.
+///
+/// No notmuch database and no worker: the composer never touches
+/// NotmuchWorker, so building one would only cost every case a `notmuch new`.
+/// The mail root is passed to ComposeWindow explicitly, exactly as MainWindow
+/// passes what the worker reported (item 124: it is NOT database.path).
+class ComposeFixture
+{
+public:
+ /// `drafts` and `sent` are written only when non-empty, so a test can
+ /// build the account-without-a-drafts-folder case by passing an empty
+ /// string rather than by needing a second fixture.
+ /// `secondAccount` writes a SECOND sending account, which is what makes
+ /// the From dropdown have something to choose between. Off by default:
+ /// every other case here wants exactly one, so a two-account fixture
+ /// everywhere would let a test pass by picking the only entry there is.
+ bool build(const QString &drafts = QStringLiteral("Drafts"),
+ const QString &sent = QStringLiteral("Sent"),
+ const QString &extraCompose = QString(),
+ bool secondAccount = false)
+ {
+ if (!m_confDir.isValid() || !m_mailDir.isValid())
+ return false;
+
+ const QString path = m_confDir.filePath(QStringLiteral("qtmaildir.conf"));
+ QFile file(path);
+ if (!file.open(QIODevice::WriteOnly | QIODevice::Text))
+ return false;
+ {
+ QTextStream out(&file);
+ // QSettings reads `/` in a section name as a group separator, so
+ // the section is [account.acct], never [account/acct].
+ out << "[account.acct]\n"
+ << "name=Test User\n"
+ << "address=user@example.org\n"
+ << "maildir=acct\n"
+ << "trash=Trash\n";
+ if (!drafts.isEmpty())
+ out << "drafts=" << drafts << "\n";
+ if (!sent.isEmpty())
+ out << "sent=" << sent << "\n";
+ // A command that exists and does nothing. canSend() is what the
+ // From dropdown filters on, so an account without this one line
+ // would not appear in it at all.
+ out << "send_command=/bin/true\n";
+ if (secondAccount) {
+ out << "\n[account.other]\n"
+ << "name=Other User\n"
+ << "address=other@example.org\n"
+ << "maildir=other\n"
+ << "trash=Trash\n"
+ << "drafts=Drafts\n"
+ << "sent=Sent\n"
+ << "send_command=/bin/true\n";
+ }
+ out << "\n[compose]\n";
+ if (!extraCompose.isEmpty())
+ out << extraCompose << "\n";
+ }
+ file.close();
+
+ m_config.load(path);
+ return true;
+ }
+
+ const Config &config() const { return m_config; }
+ QString mailRoot() const { return m_mailDir.path(); }
+
+ /// The account's drafts folder, as the composer will resolve it.
+ QString draftsCur() const
+ {
+ return m_mailDir.path() + QStringLiteral("/acct/Drafts/cur");
+ }
+
+ /// The second account's drafts folder.
+ QString otherDraftsCur() const
+ {
+ return m_mailDir.path() + QStringLiteral("/other/Drafts/cur");
+ }
+
+ /// How many message files sit in the drafts folder.
+ int draftCount() const
+ {
+ return QDir(draftsCur(), {}, QDir::Name, QDir::Files).count();
+ }
+
+private:
+ QTemporaryDir m_confDir;
+ QTemporaryDir m_mailDir;
+ Config m_config;
+};
+
+/// A minimal New-message context for the fixture's one account.
+ComposeContext newContext()
+{
+ ComposeContext context;
+ context.accountKey = QStringLiteral("acct");
+ context.kind = ComposeContext::Kind::New;
+ return context;
+}
+
+} // namespace
+
+void TestMainWindow::aComposerOpensClean()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+
+ // Seeding fills every field, which emits every field's change signal. A
+ // composer that counted those as edits would autosave a draft nobody
+ // asked for, and would tell the quit path there is unsaved work in a
+ // window the user opened and closed without typing.
+ QVERIFY(!window.hasUnsavedEdits());
+ QVERIFY(!window.lastSaveFailed());
+
+ auto *timer = window.findChild<QTimer *>(QStringLiteral("autosave"));
+ QVERIFY2(timer, "no autosave timer: the window was never built");
+ QVERIFY2(!timer->isActive(),
+ "seeding armed the autosave timer, so a untouched composer writes");
+}
+
+void TestMainWindow::typingMarksTheComposerDirty()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+
+ QVERIFY(!window.hasUnsavedEdits());
+ body->setPlainText(QStringLiteral("Some text."));
+ QVERIFY(window.hasUnsavedEdits());
+
+ // The subject is part of the message as much as the body is: a draft that
+ // saved the body but not the address it was going to would be worse than
+ // none.
+ ComposeWindow second(newContext(), fixture.config(), fixture.mailRoot());
+ auto *subject = second.findChild<QLineEdit *>(QStringLiteral("subject"));
+ QVERIFY(subject);
+ QVERIFY(!second.hasUnsavedEdits());
+ subject->setText(QStringLiteral("A subject"));
+ QVERIFY(second.hasUnsavedEdits());
+}
+
+void TestMainWindow::anAutosaveWritesADraftAndClearsTheDirtyFlag()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ body->setPlainText(QStringLiteral("Draft body."));
+ QVERIFY(window.hasUnsavedEdits());
+
+ QVERIFY2(window.saveDraftNow(), "the draft write reported failure");
+
+ QCOMPARE(fixture.draftCount(), 1);
+ QVERIFY2(!window.hasUnsavedEdits(),
+ "the flag survived a successful save, so the quit path would ask");
+ QVERIFY(!window.lastSaveFailed());
+
+ // The bytes really are the message, not an empty file: the draft is
+ // byte-identical to what would be sent, which is the property the one
+ // built message exists for.
+ const QStringList files =
+ QDir(fixture.draftsCur(), {}, QDir::Name, QDir::Files).entryList();
+ QCOMPARE(files.size(), 1);
+ QFile written(fixture.draftsCur() + QLatin1Char('/') + files.first());
+ QVERIFY(written.open(QIODevice::ReadOnly));
+ const QByteArray bytes = written.readAll();
+ QVERIFY2(bytes.contains("Draft body."), "the draft does not carry the body");
+ // Written with the Maildir draft flag, not left bare.
+ QVERIFY2(files.first().endsWith(QStringLiteral(":2,D")),
+ qPrintable(QStringLiteral("wrong maildir flags: ") + files.first()));
+}
+
+void TestMainWindow::anUnwritableDraftsFolderRaisesThePersistentBanner()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ body->setPlainText(QStringLiteral("Draft body."));
+
+ // A FILE where the folder must go. mkpath then fails, which is a real
+ // failure mode and needs no permission games that root would defeat.
+ const QString accountDir = fixture.mailRoot() + QStringLiteral("/acct");
+ QVERIFY(QDir().mkpath(accountDir));
+ QFile blocker(accountDir + QStringLiteral("/Drafts"));
+ QVERIFY(blocker.open(QIODevice::WriteOnly));
+ blocker.write("not a directory");
+ blocker.close();
+
+ QVERIFY2(!window.saveDraftNow(), "an unwritable folder reported success");
+
+ auto *banner = window.findChild<QLabel *>(QStringLiteral("draftBanner"));
+ QVERIFY2(banner, "no banner widget");
+ QVERIFY2(!banner->text().isEmpty(), "the banner says nothing");
+ QVERIFY2(window.lastSaveFailed(),
+ "lastSaveFailed() is false after a failed write, so the quit "
+ "path would let the text go");
+ QVERIFY2(window.hasUnsavedEdits(),
+ "a failed save cleared the dirty flag, which claims the text is "
+ "safe on disk when it is not");
+}
+
+void TestMainWindow::aSuccessfulSaveClearsTheBanner()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ body->setPlainText(QStringLiteral("First."));
+
+ const QString accountDir = fixture.mailRoot() + QStringLiteral("/acct");
+ QVERIFY(QDir().mkpath(accountDir));
+ QFile blocker(accountDir + QStringLiteral("/Drafts"));
+ QVERIFY(blocker.open(QIODevice::WriteOnly));
+ blocker.close();
+
+ QVERIFY(!window.saveDraftNow());
+ QVERIFY(window.lastSaveFailed());
+
+ // Remove the obstruction and save again. The banner must go: a warning
+ // that stays after the thing it warned about is fixed teaches the user to
+ // ignore warnings, which is the second lesson in the TagRules entry.
+ QVERIFY(QFile::remove(accountDir + QStringLiteral("/Drafts")));
+ body->setPlainText(QStringLiteral("Second."));
+
+ QVERIFY2(window.saveDraftNow(), "the retry failed");
+ QVERIFY2(!window.lastSaveFailed(), "lastSaveFailed() stayed set");
+
+ auto *banner = window.findChild<QLabel *>(QStringLiteral("draftBanner"));
+ QVERIFY(banner);
+ QVERIFY2(banner->isHidden(), "the banner is still up after a good save");
+}
+
+void TestMainWindow::anAccountWithoutADraftsFolderReportsNoFailure()
+{
+ ComposeFixture fixture;
+ // No drafts key at all: a real configuration, warned about at startup.
+ QVERIFY(fixture.build(QString()));
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ body->setPlainText(QStringLiteral("Nowhere to save this."));
+
+ // Nothing was written and nothing failed. Reporting a failure here would
+ // make the quit path offer a retry for a state no retry can change.
+ QVERIFY2(window.saveDraftNow(),
+ "a missing drafts folder was reported as a save failure");
+ QVERIFY2(!window.lastSaveFailed(), "the banner state was set");
+
+ auto *banner = window.findChild<QLabel *>(QStringLiteral("draftBanner"));
+ QVERIFY(banner);
+ QVERIFY(banner->isHidden());
+}
+
+void TestMainWindow::aRewrittenDraftUnlinksThePreviousRevision()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+
+ body->setPlainText(QStringLiteral("Revision one."));
+ QVERIFY(window.saveDraftNow());
+ QCOMPARE(fixture.draftCount(), 1);
+
+ body->setPlainText(QStringLiteral("Revision two."));
+ QVERIFY(window.saveDraftNow());
+
+ // ONE file, not two. Maildir has no in-place edit, so a draft rewritten
+ // every thirty seconds would otherwise accumulate one file per pause, and
+ // every one of them is a message mbsync uploads.
+ QCOMPARE(fixture.draftCount(), 1);
+
+ const QStringList files =
+ QDir(fixture.draftsCur(), {}, QDir::Name, QDir::Files).entryList();
+ QFile written(fixture.draftsCur() + QLatin1Char('/') + files.first());
+ QVERIFY(written.open(QIODevice::ReadOnly));
+ const QByteArray bytes = written.readAll();
+ QVERIFY2(bytes.contains("Revision two."), "the surviving file is the old one");
+}
+
+void TestMainWindow::theComposerBuildsTheMessageItsWidgetsShow()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeContext context = newContext();
+ context.kind = ComposeContext::Kind::Reply;
+ context.inReplyTo = QStringLiteral("original@example.org");
+ context.references = { QStringLiteral("root@example.org"),
+ QStringLiteral("original@example.org") };
+ context.to = { QStringLiteral("one@example.org") };
+ context.subject = QStringLiteral("Re: a subject");
+
+ ComposeWindow window(context, fixture.config(), fixture.mailRoot());
+
+ auto *cc = window.findChild<QLineEdit *>(QStringLiteral("cc"));
+ auto *bcc = window.findChild<QLineEdit *>(QStringLiteral("bcc"));
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(cc && bcc && body);
+
+ // A field the user typed, split on commas. That is wrong for a RAW header
+ // and right here: this is the composer's own rendering, which joins with
+ // ", ".
+ cc->setText(QStringLiteral("two@example.org, three@example.org"));
+ bcc->setText(QStringLiteral(" four@example.org "));
+ body->setPlainText(QStringLiteral("The body."));
+
+ const OutgoingMessage message = window.currentMessage();
+ QCOMPARE(message.accountKey, QStringLiteral("acct"));
+ QCOMPARE(message.to, QStringList{ QStringLiteral("one@example.org") });
+ QCOMPARE(message.cc, (QStringList{ QStringLiteral("two@example.org"),
+ QStringLiteral("three@example.org") }));
+ // Trimmed, or the whitespace reaches the wire as part of the address.
+ QCOMPARE(message.bcc, QStringList{ QStringLiteral("four@example.org") });
+ QCOMPARE(message.subject, QStringLiteral("Re: a subject"));
+ QCOMPARE(message.markdownBody, QStringLiteral("The body."));
+
+ // NOT optional. Without them a reply appears as an orphan thread in the
+ // sender's own client, which is invisible locally.
+ QCOMPARE(message.inReplyTo, QStringLiteral("original@example.org"));
+ QCOMPARE(message.references.size(), 2);
+ QCOMPARE(message.references.last(), QStringLiteral("original@example.org"));
+}
+
+void TestMainWindow::theFromDropdownDecidesWhichAccountSends()
+{
+ // TWO sending accounts, because a dropdown with one entry cannot be
+ // changed and a test against it passes whether the code reads the dropdown
+ // or the context. The first revision of this test did exactly that: it
+ // asserted count() == 1 and then re-asserted a property another case
+ // already covers, and a mutation making currentAccount() read
+ // m_context.accountKey survived it.
+ ComposeFixture fixture;
+ QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QString(), /*secondAccount=*/true));
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+ auto *from = window.findChild<QComboBox *>(QStringLiteral("from"));
+ QVERIFY2(from, "no From dropdown");
+
+ // Both sending accounts are offered, seeded to the context's.
+ QCOMPARE(from->count(), 2);
+ QCOMPARE(from->currentData().toString(), QStringLiteral("acct"));
+ QCOMPARE(window.currentMessage().accountKey, QStringLiteral("acct"));
+
+ // Now change it. The dropdown is the authority once the window is open:
+ // reading the context here would send from the seeded account while the
+ // interface said otherwise.
+ const int other = from->findData(QStringLiteral("other"));
+ QVERIFY2(other >= 0, "the second account is not in the dropdown");
+ from->setCurrentIndex(other);
+
+ QCOMPARE(window.currentMessage().accountKey, QStringLiteral("other"));
+
+ // And the choice reaches the DRAFT's destination, not just the value:
+ // a draft is written into the sending account's own folder, so a composer
+ // that read the context would file it under the wrong account.
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ body->setPlainText(QStringLiteral("From the other account."));
+ QVERIFY(window.saveDraftNow());
+
+ QCOMPARE(QDir(fixture.otherDraftsCur(), {}, QDir::Name, QDir::Files).count(),
+ 1u);
+ QCOMPARE(QDir(fixture.draftsCur(), {}, QDir::Name, QDir::Files).count(), 0u);
+}
+
+void TestMainWindow::aFormatEditPreservesTheUndoStack()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+
+ // Typed through a cursor, which is what makes it an undoable edit;
+ // setPlainText() would not be one.
+ QTextCursor typing = body->textCursor();
+ typing.insertText(QStringLiteral("hello"));
+ QVERIFY(body->document()->isUndoAvailable());
+
+ QTextCursor selection = body->textCursor();
+ selection.setPosition(0);
+ selection.setPosition(5, QTextCursor::KeepAnchor);
+ body->setTextCursor(selection);
+
+ auto *bold = window.findChild<QAction *>(QStringLiteral("format_bold"));
+ QVERIFY2(bold, "no bold action");
+ bold->trigger();
+
+ QCOMPARE(body->toPlainText(), QStringLiteral("**hello**"));
+
+ // The property the plan's setPlainText() draft would have lost. Measured
+ // in a standalone probe: setPlainText() takes isUndoAvailable from true to
+ // false, so every toolbar press would throw away everything the user could
+ // undo.
+ QVERIFY2(body->document()->isUndoAvailable(),
+ "the format edit destroyed the undo stack");
+
+ // And it is ONE undo step, not one per character: a whole-document
+ // replacement inside an edit block collapses to a single entry, so one
+ // Ctrl+Z takes the tokens off and leaves the typed word.
+ body->undo();
+ QCOMPARE(body->toPlainText(), QStringLiteral("hello"));
+}
+
+void TestMainWindow::aFormatEditRestoresTheSelectionItAsksFor()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ body->setPlainText(QStringLiteral("hello world"));
+
+ // A BACKWARDS selection, anchor after the cursor, which is what a
+ // right-to-left drag produces and an ordinary gesture. Measured against a
+ // real widget: selectionStart()/selectionEnd() come back normalised even
+ // then, so the anchor's side does not reach MarkdownFormat.
+ QTextCursor selection = body->textCursor();
+ selection.setPosition(5);
+ selection.setPosition(0, QTextCursor::KeepAnchor);
+ body->setTextCursor(selection);
+ QCOMPARE(body->textCursor().selectionStart(), 0);
+ QCOMPARE(body->textCursor().selectionEnd(), 5);
+
+ auto *italic = window.findChild<QAction *>(QStringLiteral("format_italic"));
+ QVERIFY(italic);
+ italic->trigger();
+
+ QCOMPARE(body->toPlainText(), QStringLiteral("*hello* world"));
+
+ // The selection is preserved precisely so a second press can apply a
+ // SECOND token to the same words, bold then italic without reselecting.
+ QCOMPARE(body->textCursor().selectedText(), QStringLiteral("hello"));
+
+ auto *bold = window.findChild<QAction *>(QStringLiteral("format_bold"));
+ QVERIFY(bold);
+ bold->trigger();
+ QCOMPARE(body->toPlainText(), QStringLiteral("***hello*** world"));
+}
+
+void TestMainWindow::aFormatEditOnAnEmptySelectionLandsBetweenTheTokens()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ body->setPlainText(QStringLiteral("ab"));
+
+ QTextCursor cursor = body->textCursor();
+ cursor.setPosition(1);
+ body->setTextCursor(cursor);
+
+ auto *bold = window.findChild<QAction *>(QStringLiteral("format_bold"));
+ QVERIFY(bold);
+ bold->trigger();
+
+ QCOMPARE(body->toPlainText(), QStringLiteral("a****b"));
+
+ // The property a user notices immediately when it is wrong, and the one
+ // invisible to a test that only compares the resulting text: typing must
+ // continue INSIDE the pair, not after it.
+ QCOMPARE(body->textCursor().position(), 3);
+ QVERIFY(!body->textCursor().hasSelection());
+
+ QTextCursor typing = body->textCursor();
+ typing.insertText(QStringLiteral("x"));
+ QCOMPARE(body->toPlainText(), QStringLiteral("a**x**b"));
+}
+
+void TestMainWindow::theAttachmentWarningRespectsTheConfiguredThreshold()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("attachment_warn_bytes=1000")));
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+
+ // The threshold, not the modal. The question itself needs a user, so what
+ // is asserted is the predicate that decides whether to ask.
+ QVERIFY2(!window.attachmentNeedsWarning(999), "warned below the limit");
+ QVERIFY2(!window.attachmentNeedsWarning(1000),
+ "warned AT the limit, which is not above it");
+ QVERIFY2(window.attachmentNeedsWarning(1001), "did not warn above the limit");
+}
+
+void TestMainWindow::aDisabledAttachmentWarningWarnsAboutNothing()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("attachment_warn_bytes=0")));
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+
+ // Zero means off, not "warn about everything". Read as a threshold it
+ // would question an empty file, which is the opposite of what turning a
+ // warning off means.
+ QVERIFY(!window.attachmentNeedsWarning(0));
+ QVERIFY(!window.attachmentNeedsWarning(1));
+ QVERIFY(!window.attachmentNeedsWarning(100LL * 1024 * 1024));
+}
+
+void TestMainWindow::theQuotePositionDecidesWhereTheQuoteLands()
+{
+ const QString quote = QStringLiteral("> the original");
+
+ {
+ ComposeFixture above;
+ QVERIFY(above.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("quote_position=above")));
+ ComposeContext context = newContext();
+ context.kind = ComposeContext::Kind::Reply;
+ context.quotedBody = quote;
+
+ ComposeWindow window(context, above.config(), above.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ QVERIFY2(body->toPlainText().startsWith(quote),
+ "quote_position=above did not put the quote first");
+ }
+
+ {
+ ComposeFixture below;
+ QVERIFY(below.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("quote_position=below")));
+ ComposeContext context = newContext();
+ context.kind = ComposeContext::Kind::Reply;
+ context.quotedBody = quote;
+
+ ComposeWindow window(context, below.config(), below.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ QVERIFY2(body->toPlainText().endsWith(quote),
+ "quote_position=below did not put the quote last");
+ QVERIFY2(!body->toPlainText().startsWith(quote),
+ "the quote is at the top under quote_position=below");
+ }
+}
+
+void TestMainWindow::theSeededQuoteIsNotAnUndoStep()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeContext context = newContext();
+ context.kind = ComposeContext::Kind::Reply;
+ context.quotedBody = QStringLiteral("> the original");
+
+ ComposeWindow window(context, fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ QVERIFY(!body->toPlainText().isEmpty());
+
+ // The seeded quote is not an edit the user made. One Ctrl+Z on a fresh
+ // composer must not wipe it, which reads as the buffer losing its content.
+ //
+ // Worth knowing before judging this test dead weight: removing
+ // clearUndoRedoStacks() alone leaves it GREEN, because setPlainText()
+ // already leaves undo unavailable. The line it guards becomes load-bearing
+ // the moment seedBody() stops using setPlainText, which is a change with
+ // reasons to happen: applyEdit() switched to a QTextCursor replacement for
+ // exactly the undo-stack property this asserts, and a later revision
+ // seeding the quote the same way would put it on the stack. The combined
+ // mutation (seed through a cursor AND drop the clear) does kill this.
+ QVERIFY2(!body->document()->isUndoAvailable(),
+ "the seeded quote is on the undo stack");
+}
+
+void TestMainWindow::aReplySeedsTheHtmlToggleFromTheOriginal()
+{
+ ComposeFixture fixture;
+ // Config says yes; the original says no. The original wins for a reply:
+ // an HTML part in it is a fact about the sender's software, not a guess
+ // about their taste.
+ QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("send_html=true")));
+
+ ComposeContext context = newContext();
+ context.kind = ComposeContext::Kind::Reply;
+ context.seedHtml = false;
+
+ ComposeWindow window(context, fixture.config(), fixture.mailRoot());
+ auto *toggle = window.findChild<QCheckBox *>(QStringLiteral("sendHtml"));
+ QVERIFY2(toggle, "no send-html toggle");
+ QVERIFY2(!toggle->isChecked(),
+ "a reply seeded from config rather than from the original");
+
+ // And the other way round, so the test cannot pass by always reading
+ // false: a plain-text config with an HTML original still offers HTML.
+ ComposeFixture plain;
+ QVERIFY(plain.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("send_html=false")));
+ ComposeContext htmlReply = newContext();
+ htmlReply.kind = ComposeContext::Kind::ReplyAll;
+ htmlReply.seedHtml = true;
+
+ ComposeWindow second(htmlReply, plain.config(), plain.mailRoot());
+ auto *secondToggle =
+ second.findChild<QCheckBox *>(QStringLiteral("sendHtml"));
+ QVERIFY(secondToggle);
+ QVERIFY2(secondToggle->isChecked(),
+ "a reply-all ignored an HTML original");
+}
+
+void TestMainWindow::aNewMessageSeedsTheHtmlToggleFromConfig()
+{
+ ComposeFixture off;
+ QVERIFY(off.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("send_html=false")));
+
+ // seedHtml is deliberately TRUE here and must be ignored: a New message
+ // has no original to take evidence from, so a composer reading it would be
+ // reading a field nothing filled in.
+ ComposeContext context = newContext();
+ context.seedHtml = true;
+
+ ComposeWindow window(context, off.config(), off.mailRoot());
+ auto *toggle = window.findChild<QCheckBox *>(QStringLiteral("sendHtml"));
+ QVERIFY(toggle);
+ QVERIFY2(!toggle->isChecked(), "a New message ignored [compose] send_html");
+
+ ComposeFixture on;
+ QVERIFY(on.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("send_html=true")));
+ ComposeContext forward = newContext();
+ forward.kind = ComposeContext::Kind::Forward;
+ forward.seedHtml = false;
+
+ ComposeWindow second(forward, on.config(), on.mailRoot());
+ auto *secondToggle =
+ second.findChild<QCheckBox *>(QStringLiteral("sendHtml"));
+ QVERIFY(secondToggle);
+ QVERIFY2(secondToggle->isChecked(),
+ "a Forward seeded from the original rather than from config");
+}
+
+void TestMainWindow::disablingInputsCoversEveryFieldAndTheToolbar()
+{
+ ComposeFixture fixture;
+ // Zero delay: the countdown is skipped and the send commits at once, which
+ // is the state the inputs must already be disabled in.
+ QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("send_delay_ms=0")));
+
+ ComposeContext context = newContext();
+ context.to = { QStringLiteral("someone@example.org") };
+
+ // Heap-allocated and tracked with a QPointer, because ComposeWindow sets
+ // WA_DeleteOnClose and this case really does complete a send: the window
+ // deletes itself on the way out, so a stack instance would be destroyed
+ // twice. Every other case here stays on the stack, since none of them
+ // closes.
+ QPointer<ComposeWindow> window =
+ new ComposeWindow(context, fixture.config(), fixture.mailRoot());
+ auto *body = window->findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ body->setPlainText(QStringLiteral("Text."));
+
+ auto *toolbar = window->findChild<QToolBar *>(QStringLiteral("formatToolbar"));
+ auto *to = window->findChild<QLineEdit *>(QStringLiteral("to"));
+ auto *subject = window->findChild<QLineEdit *>(QStringLiteral("subject"));
+ auto *from = window->findChild<QComboBox *>(QStringLiteral("from"));
+ auto *toggle = window->findChild<QCheckBox *>(QStringLiteral("sendHtml"));
+ QVERIFY(toolbar && to && subject && from && toggle);
+
+ QVERIFY(to->isEnabled());
+ QVERIFY(!body->isReadOnly());
+
+ auto *sendAction = window->findChild<QAction *>(QStringLiteral("compose_send"));
+ QVERIFY2(sendAction, "no send action");
+ sendAction->trigger();
+
+ // The message must not change between pressing Send and the bytes being
+ // built, so every input goes down for the WHOLE operation, countdown
+ // included. The body is made read-only rather than disabled, so its text
+ // stays selectable and legible while the send runs.
+ QVERIFY2(!to->isEnabled(), "the To field is still editable during a send");
+ QVERIFY2(!subject->isEnabled(), "the subject is still editable");
+ QVERIFY2(!from->isEnabled(), "the account can still be changed");
+ QVERIFY2(!toggle->isEnabled(), "the HTML toggle can still be flipped");
+ QVERIFY2(body->isReadOnly(), "the body is still writable during a send");
+ QVERIFY2(!toolbar->isEnabled(), "the formatting toolbar is still live");
+ auto *attachments =
+ window->findChild<QListWidget *>(QStringLiteral("attachments"));
+ QVERIFY(attachments);
+ QVERIFY2(!attachments->isEnabled(),
+ "the attachment list is still live during a send");
+
+ // /bin/true is the fixture's send command, so the send succeeds and the
+ // composer closes itself: the message went, and holding a composer open
+ // for a message already sent invites sending it twice. Waited on rather
+ // than asserted immediately, since the process is handed to the event loop
+ // and nothing here blocks on it. WA_DeleteOnClose then destroys the
+ // window, which is what the QPointer observes.
+ QTRY_VERIFY_WITH_TIMEOUT(window.isNull(), 15000);
+
+ // And the sent copy really was filed, which is the stage after the send
+ // and the one whose failure the design treats as the worst outcome here.
+ const QString sentCur =
+ fixture.mailRoot() + QStringLiteral("/acct/Sent/cur");
+ QCOMPARE(QDir(sentCur, {}, QDir::Name, QDir::Files).count(), 1u);
+}
+
+void TestMainWindow::aFailedSendCanBeRetriedWithoutFilingTheWrongCopy()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("send_delay_ms=0")));
+
+ // A stub whose outcome is switched by a sentinel file, so ONE configured
+ // command can fail and then succeed. It appends its stdin to a log, which
+ // is what makes the delivery count observable: the defect this guards
+ // against files a sent copy of the FIRST message when the second finishes,
+ // and a receiver count is the only thing that shows it.
+ QTemporaryDir stubDir;
+ QVERIFY(stubDir.isValid());
+ const QString sentinel = stubDir.filePath(QStringLiteral("succeed"));
+ const QString stub = stubDir.filePath(QStringLiteral("send.sh"));
+ {
+ QFile script(stub);
+ QVERIFY(script.open(QIODevice::WriteOnly | QIODevice::Text));
+ QTextStream out(&script);
+ out << "#!/bin/sh\n"
+ << "cat >> " << stubDir.filePath(QStringLiteral("stdin.log")) << "\n"
+ << "[ -f " << sentinel << " ] || { echo 'refused' >&2; exit 1; }\n"
+ << "exit 0\n";
+ }
+ QVERIFY(QFile::setPermissions(
+ stub, QFileDevice::ReadOwner | QFileDevice::WriteOwner
+ | QFileDevice::ExeOwner));
+
+ // A FRESH Config, not a copy of the fixture's reloaded: Config::load()
+ // does not clear what a previous load put there, so a copy keeps the
+ // fixture's /bin/true and this test would silently exercise a command that
+ // always succeeds. Measured, and it produced a green nothing.
+ Config config;
+ {
+ const QString path = QStringLiteral("%1/retry.conf").arg(stubDir.path());
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text));
+ QTextStream out(&file);
+ out << "[account.acct]\n"
+ << "name=Test User\n"
+ << "address=user@example.org\n"
+ << "maildir=acct\n"
+ << "trash=Trash\n"
+ << "drafts=Drafts\n"
+ << "sent=Sent\n"
+ << "send_command=" << stub << "\n"
+ << "\n[compose]\n"
+ << "send_delay_ms=0\n";
+ file.close();
+ config.load(path);
+ }
+
+ ComposeContext context = newContext();
+ context.to = { QStringLiteral("someone@example.org") };
+
+ QPointer<ComposeWindow> window =
+ new ComposeWindow(context, config, fixture.mailRoot());
+ auto *body = window->findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ auto *sendAction = window->findChild<QAction *>(QStringLiteral("compose_send"));
+ QVERIFY(body && sendAction);
+
+ body->setPlainText(QStringLiteral("FIRST attempt."));
+ sendAction->trigger();
+
+ // The failure re-enables the composer intact and shows the stderr; the
+ // window stays open and the draft stays.
+ auto *pane = window->findChild<QWidget *>(QStringLiteral("sendLogPane"));
+ QVERIFY(pane);
+ QTRY_VERIFY_WITH_TIMEOUT(!pane->isHidden(), 15000);
+
+ QVERIFY2(!window.isNull(), "a failed send closed the composer");
+ QVERIFY2(body->isEnabled() && !body->isReadOnly(),
+ "a failed send left the composer disabled");
+
+ // Correct the message and send again, this time succeeding. Without
+ // Qt::SingleShotConnection on the per-send connect, the first send's
+ // lambda is still attached: the second result runs BOTH, and the first
+ // still holds the FIRST message's bytes, so it files a sent copy of the
+ // wrong message and acts on a dialog it already destroyed.
+ QFile marker(sentinel);
+ QVERIFY(marker.open(QIODevice::WriteOnly));
+ marker.close();
+
+ body->setPlainText(QStringLiteral("SECOND attempt."));
+ sendAction->trigger();
+
+ QTRY_VERIFY_WITH_TIMEOUT(window.isNull(), 15000);
+
+ // Exactly ONE sent copy, and it is the second message. Two files, or one
+ // carrying the first attempt, is the accumulated-receiver defect.
+ const QString sentCur = fixture.mailRoot() + QStringLiteral("/acct/Sent/cur");
+ const QStringList filed =
+ QDir(sentCur, {}, QDir::Name, QDir::Files).entryList();
+ QCOMPARE(filed.size(), 1);
+
+ QFile copy(sentCur + QLatin1Char('/') + filed.first());
+ QVERIFY(copy.open(QIODevice::ReadOnly));
+ const QByteArray bytes = copy.readAll();
+ QVERIFY2(bytes.contains("SECOND attempt."),
+ "the filed copy is not the message that was sent");
+ QVERIFY2(!bytes.contains("FIRST attempt."),
+ "the filed copy is the FIRST message, which never went");
+}
+
+void TestMainWindow::anUnchangedMessageIsNotWrittenAgain()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+
+ body->setPlainText(QStringLiteral("Once."));
+ QVERIFY(window.saveDraftNow());
+ QCOMPARE(fixture.draftCount(), 1);
+
+ const QStringList first =
+ QDir(fixture.draftsCur(), {}, QDir::Name, QDir::Files).entryList();
+ QCOMPARE(first.size(), 1);
+
+ // Nothing has changed, so nothing is written. Every autosave produces a
+ // Maildir write that mbsync uploads, so this check and the debounce
+ // together are what keep a message to a few revisions rather than dozens.
+ //
+ // The FILENAME is what shows it: DraftStore always generates a fresh name
+ // and unlinks the previous one, so a redundant write leaves exactly one
+ // file too, and a count alone cannot tell a skipped write from a repeated
+ // one. Two runs of this test asserting only on the count would pass
+ // against no check at all.
+ QVERIFY2(window.saveDraftNow(), "the redundant save reported failure");
+ QCOMPARE(fixture.draftCount(), 1);
+ const QStringList second =
+ QDir(fixture.draftsCur(), {}, QDir::Name, QDir::Files).entryList();
+ QCOMPARE(second, first);
+
+ // And a real change still writes: a check that skipped everything would
+ // pass the assertion above and lose the user's text.
+ body->setPlainText(QStringLiteral("Twice."));
+ QVERIFY(window.saveDraftNow());
+ const QStringList third =
+ QDir(fixture.draftsCur(), {}, QDir::Name, QDir::Files).entryList();
+ QCOMPARE(third.size(), 1);
+ QVERIFY2(third != first, "a changed message was not written");
+}
+
+void TestMainWindow::closingInsideTheDebounceStillSavesTheDraft()
+{
+ ComposeFixture fixture;
+ // A debounce far longer than this test, so the timer provably never fires
+ // and the only thing that can write is the close itself.
+ QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("autosave_interval_ms=600000")));
+
+ // Heap-allocated: WA_DeleteOnClose destroys the window on the way out, so
+ // a stack instance would be destroyed twice.
+ QPointer<ComposeWindow> window =
+ new ComposeWindow(newContext(), fixture.config(), fixture.mailRoot());
+ auto *body = window->findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+
+ body->setPlainText(QStringLiteral("A paragraph typed and not yet saved."));
+ QVERIFY(window->hasUnsavedEdits());
+
+ // The timer has NOT fired. Asserted rather than assumed: if it had, the
+ // draft below would prove nothing about the close path.
+ auto *timer = window->findChild<QTimer *>(QStringLiteral("autosave"));
+ QVERIFY(timer);
+ QVERIFY2(timer->isActive(), "the debounce is not running");
+ QCOMPARE(fixture.draftCount(), 0);
+
+ // The window manager's X button, which is the route that reaches
+ // closeEvent. Typing a paragraph and pressing it inside the debounce
+ // interval must not lose the text.
+ window->close();
+ QTRY_VERIFY_WITH_TIMEOUT(window.isNull(), 5000);
+
+ QCOMPARE(fixture.draftCount(), 1);
+ const QStringList files =
+ QDir(fixture.draftsCur(), {}, QDir::Name, QDir::Files).entryList();
+ QCOMPARE(files.size(), 1);
+ QFile written(fixture.draftsCur() + QLatin1Char('/') + files.first());
+ QVERIFY(written.open(QIODevice::ReadOnly));
+ QVERIFY2(written.readAll().contains("A paragraph typed and not yet saved."),
+ "the close wrote a draft that is not the text that was typed");
+}
+
+void TestMainWindow::closingAfterASendWritesNoFurtherDraft()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("send_delay_ms=0")));
+
+ ComposeContext context = newContext();
+ context.to = { QStringLiteral("someone@example.org") };
+
+ QPointer<ComposeWindow> window =
+ new ComposeWindow(context, fixture.config(), fixture.mailRoot());
+ auto *body = window->findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ auto *sendAction = window->findChild<QAction *>(QStringLiteral("compose_send"));
+ QVERIFY(body && sendAction);
+
+ body->setPlainText(QStringLiteral("Text that is about to be sent."));
+
+ // A draft on disk first, so the send's removal of it is observable and the
+ // close-path save has something it could wrongly put back.
+ QVERIFY(window->saveDraftNow());
+ QCOMPARE(fixture.draftCount(), 1);
+
+ // Now edit again WITHOUT saving, so m_dirty is true at the moment the
+ // send completes. This is what makes the m_finished guard load-bearing:
+ // without it the close that follows a successful send would write a draft
+ // for a message already sent, restoring the file the send just unlinked.
+ body->setPlainText(QStringLiteral("Text that is about to be sent, edited."));
+ QVERIFY(window->hasUnsavedEdits());
+
+ sendAction->trigger();
+ QTRY_VERIFY_WITH_TIMEOUT(window.isNull(), 15000);
+
+ // The message went, so the drafts folder is EMPTY. A draft left behind is
+ // a message the user sees waiting to be finished when it has already been
+ // delivered.
+ QCOMPARE(fixture.draftCount(), 0);
+
+ // And the sent copy is there, so this is a completed send rather than a
+ // send that never happened leaving nothing behind either way.
+ const QString sentCur = fixture.mailRoot() + QStringLiteral("/acct/Sent/cur");
+ QCOMPARE(QDir(sentCur, {}, QDir::Name, QDir::Files).count(), 1u);
+}
+
+void TestMainWindow::aCloseDuringTheCountdownIsRefused()
+{
+ ComposeFixture fixture;
+ // A countdown long enough to close inside. The default is 5000; this is
+ // the window the guard exists for and it must be provably still open when
+ // the close is attempted.
+ QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("send_delay_ms=30000")));
+
+ ComposeContext context = newContext();
+ context.to = { QStringLiteral("someone@example.org") };
+
+ QPointer<ComposeWindow> window =
+ new ComposeWindow(context, fixture.config(), fixture.mailRoot());
+ auto *body = window->findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ auto *sendAction = window->findChild<QAction *>(QStringLiteral("compose_send"));
+ QVERIFY(body && sendAction);
+ body->setPlainText(QStringLiteral("Sent after a countdown."));
+
+ sendAction->trigger();
+
+ // Still counting down: the popup is up and nothing has been sent. The
+ // sent folder is the evidence, since it is written only after the command
+ // succeeds.
+ auto *dialog = window->findChild<SendDialog *>();
+ QVERIFY2(dialog, "no send popup");
+ QVERIFY2(!dialog->isCommitted(), "the countdown already committed");
+
+ // Close during the countdown. Refused: accepting it would destroy this
+ // window, take the parented SendDialog down with it, and committed() would
+ // never fire. The user pressed Send, watched a countdown, and would
+ // believe the mail went.
+ window->close();
+
+ // Given a moment for a deletion event to be delivered if one was posted,
+ // then asserted still alive. An immediate check would pass against a
+ // deleteLater() already queued.
+ QTest::qWait(300);
+ QVERIFY2(!window.isNull(),
+ "the close was accepted during the countdown, so the send was "
+ "silently abandoned after the user pressed Send");
+ QVERIFY2(window->isVisible() || !window.isNull(), "the window went away");
+
+ // The send never happened, which is the point: nothing was filed.
+ const QString sentCur = fixture.mailRoot() + QStringLiteral("/acct/Sent/cur");
+ QCOMPARE(QDir(sentCur, {}, QDir::Name, QDir::Files).count(), 0u);
+
+ // Cleaned up by hand, since the window refuses to close while the popup is
+ // up and the test must not leak it into the next case.
+ delete window;
+}
+
+void TestMainWindow::aFailedSendKeepsTheTextThatFailedToGo()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("send_delay_ms=0")));
+
+ QTemporaryDir stubDir;
+ QVERIFY(stubDir.isValid());
+ const QString stub = stubDir.filePath(QStringLiteral("fail.sh"));
+ {
+ QFile script(stub);
+ QVERIFY(script.open(QIODevice::WriteOnly | QIODevice::Text));
+ QTextStream out(&script);
+ out << "#!/bin/sh\ncat > /dev/null\necho 'refused' >&2\nexit 1\n";
+ }
+ QVERIFY(QFile::setPermissions(
+ stub, QFileDevice::ReadOwner | QFileDevice::WriteOwner
+ | QFileDevice::ExeOwner));
+
+ Config config;
+ {
+ const QString path = stubDir.filePath(QStringLiteral("fail.conf"));
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text));
+ QTextStream out(&file);
+ out << "[account.acct]\n"
+ << "name=Test User\naddress=user@example.org\n"
+ << "maildir=acct\ntrash=Trash\ndrafts=Drafts\nsent=Sent\n"
+ << "send_command=" << stub << "\n"
+ << "\n[compose]\nsend_delay_ms=0\n";
+ file.close();
+ config.load(path);
+ }
+
+ ComposeContext context = newContext();
+ context.to = { QStringLiteral("someone@example.org") };
+
+ QPointer<ComposeWindow> window =
+ new ComposeWindow(context, config, fixture.mailRoot());
+ auto *body = window->findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ auto *sendAction = window->findChild<QAction *>(QStringLiteral("compose_send"));
+ QVERIFY(body && sendAction);
+
+ // An OLD revision on disk, then an edit that is not saved. send() builds
+ // from the widgets without saving, so without the fix the file left behind
+ // after the failure is the old text: the user watches their correction be
+ // sent, sees it fail, and gets the uncorrected version back.
+ body->setPlainText(QStringLiteral("The ORIGINAL text."));
+ QVERIFY(window->saveDraftNow());
+ QCOMPARE(fixture.draftCount(), 1);
+
+ body->setPlainText(QStringLiteral("The CORRECTED text."));
+ sendAction->trigger();
+
+ auto *pane = window->findChild<QWidget *>(QStringLiteral("sendLogPane"));
+ QVERIFY(pane);
+ QTRY_VERIFY_WITH_TIMEOUT(!pane->isHidden(), 15000);
+ QVERIFY2(!window.isNull(), "a failed send closed the composer");
+
+ // Exactly one draft, and it is the text that was attempted.
+ QCOMPARE(fixture.draftCount(), 1);
+ const QStringList files =
+ QDir(fixture.draftsCur(), {}, QDir::Name, QDir::Files).entryList();
+ QCOMPARE(files.size(), 1);
+ QFile written(fixture.draftsCur() + QLatin1Char('/') + files.first());
+ QVERIFY(written.open(QIODevice::ReadOnly));
+ const QByteArray bytes = written.readAll();
+ QVERIFY2(bytes.contains("The CORRECTED text."),
+ "the draft kept after a failed send is not what was attempted");
+ QVERIFY2(!bytes.contains("The ORIGINAL text."),
+ "the draft kept after a failed send is the PRE-EDIT revision");
+
+ delete window;
+}
+
+void TestMainWindow::aSmallSizeLimitIsNotDescribedAsZeroMegabytes()
+{
+ // Integer MB division made every figure under a megabyte read as "0 MB",
+ // in BOTH halves of the same sentence: "'x' is 0 MB. Many mail servers
+ // refuse messages above about 0 MB."
+ QVERIFY2(!ComposeWindow::humanSize(500 * 1024).contains(QStringLiteral("0 MB")),
+ "half a megabyte is described as 0 MB");
+ QVERIFY2(!ComposeWindow::humanSize(1000).contains(QStringLiteral("0 MB")),
+ "a kilobyte is described as 0 MB");
+
+ // The unit steps down rather than reporting zero of a larger one.
+ QVERIFY(ComposeWindow::humanSize(500 * 1024).contains(QStringLiteral("KB")));
+ QVERIFY(ComposeWindow::humanSize(512).contains(QStringLiteral("bytes")));
+
+ // A decimal while the figure is small enough for it to say something, so
+ // 26 MB and 26.2 MB are not the same string.
+ QVERIFY(ComposeWindow::humanSize(26214400).contains(QStringLiteral("MB")));
+ QVERIFY2(ComposeWindow::humanSize(1024 * 1024 * 3 / 2)
+ .contains(QStringLiteral(".")),
+ "1.5 MB lost its decimal");
+}
+
#include "test_mainwindow.moc"
diff --git a/tests/test_markdownrenderer.cpp b/tests/test_markdownrenderer.cpp
new file mode 100644
index 0000000..697a28f
--- /dev/null
+++ b/tests/test_markdownrenderer.cpp
@@ -0,0 +1,151 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#include <QtTest>
+
+#include "markdownrenderer.h"
+
+/// The extension configuration cmark-gfm renders the composer's body with.
+///
+/// No QApplication is needed here: MarkdownRenderer is a pure function over
+/// strings, so QTEST_APPLESS_MAIN avoids pulling in a platform plugin for a
+/// test that has nothing to do with widgets.
+class TestMarkdownRenderer : public QObject
+{
+ Q_OBJECT
+private slots:
+ void commonMarkBasicsRender();
+ void autolinkTurnsABareUrlIntoALink();
+ void strikethroughRenders();
+ void tasklistRenders();
+ void tablesAreNotEnabled();
+ void rawHtmlIsSuppressed();
+ void unsafeLinksAreStripped();
+ void accentedTextSurvivesAsUtf8();
+ void emptyInputProducesEmptyOutput();
+};
+
+void TestMarkdownRenderer::commonMarkBasicsRender()
+{
+ const QString html = MarkdownRenderer::toHtml(
+ QStringLiteral("**bold** *italic* `code`"));
+ QVERIFY2(html.contains(QStringLiteral("<strong>")), qPrintable(html));
+ QVERIFY2(html.contains(QStringLiteral("<em>")), qPrintable(html));
+ QVERIFY2(html.contains(QStringLiteral("<code>")), qPrintable(html));
+}
+
+void TestMarkdownRenderer::autolinkTurnsABareUrlIntoALink()
+{
+ // The whole reason cmark-gfm was chosen over plain cmark. Under
+ // CommonMark a bare URL is text, and a bare URL in mail is expected to
+ // be clickable.
+ const QString html = MarkdownRenderer::toHtml(
+ QStringLiteral("see https://example.org for details"));
+ QVERIFY2(html.contains(QStringLiteral("<a href=\"https://example.org\"")),
+ qPrintable(html));
+}
+
+void TestMarkdownRenderer::strikethroughRenders()
+{
+ const QString html = MarkdownRenderer::toHtml(QStringLiteral("~~gone~~"));
+ QVERIFY2(html.contains(QStringLiteral("<del>gone</del>")), qPrintable(html));
+}
+
+void TestMarkdownRenderer::tasklistRenders()
+{
+ // Known ceiling: many mail clients strip the checkbox, so those
+ // recipients see the item with no marker. The plain part still carries
+ // the literal "- [ ]", so nothing is lost, only the HTML rendering.
+ const QString html = MarkdownRenderer::toHtml(
+ QStringLiteral("- [ ] todo\n- [x] done"));
+ QVERIFY2(html.contains(QStringLiteral("type=\"checkbox\"")), qPrintable(html));
+ // Not a bare "checked": that is a common English word ordinary prose
+ // would satisfy on its own. The attribute is what proves [x] differs
+ // from [ ].
+ QVERIFY2(html.contains(QStringLiteral("checked=\"\"")), qPrintable(html));
+}
+
+void TestMarkdownRenderer::tablesAreNotEnabled()
+{
+ // Deliberately off: tables render badly across mail clients regardless of
+ // who generates them. The extension EXISTS in the library, so this
+ // asserts a decision rather than a limitation, and would silently start
+ // passing the wrong way if someone attached it "for completeness".
+ const QString html = MarkdownRenderer::toHtml(
+ QStringLiteral("| a | b |\n|---|---|\n| 1 | 2 |"));
+ QVERIFY2(!html.contains(QStringLiteral("<table")), qPrintable(html));
+ QVERIFY2(html.contains(QStringLiteral("| a | b |")), qPrintable(html));
+}
+
+void TestMarkdownRenderer::rawHtmlIsSuppressed()
+{
+ // Safe mode (the cmark-gfm 0.29 default, not CMARK_OPT_SAFE, which is a
+ // no-op in this version, see markdownrenderer.cpp). The body is the
+ // user's own text, but a body that can inject markup into its own
+ // generated HTML part is a sharp edge with no upside.
+ //
+ // Asserted on the actual placeholder rather than only "no <script>",
+ // because the weaker assertion would still pass with CMARK_OPT_UNSAFE
+ // set by mistake, as long as something ELSE in the string also matched
+ // "not <script>" and "contains after" (measured: it does not distinguish
+ // safe from unsafe mode on its own). "raw HTML omitted" is what safe mode
+ // actually emits in place of the tag.
+ const QString html = MarkdownRenderer::toHtml(
+ QStringLiteral("<script>alert(1)</script>\n\nafter"));
+ QVERIFY2(!html.contains(QStringLiteral("<script>")), qPrintable(html));
+ QVERIFY2(html.contains(QStringLiteral("raw HTML omitted")), qPrintable(html));
+ QVERIFY2(html.contains(QStringLiteral("after")), qPrintable(html));
+}
+
+void TestMarkdownRenderer::unsafeLinksAreStripped()
+{
+ // A protection this gets for free from safe mode, and previously
+ // asserted nothing about: a javascript: link is replaced with an empty
+ // href rather than passed through. The body is the user's own text, but
+ // it is rendered into an HTML part sent to other people, so a
+ // javascript: link surviving into that part would be a real defect, not
+ // a cosmetic one.
+ const QString html = MarkdownRenderer::toHtml(
+ QStringLiteral("[click](javascript:alert(1))"));
+ QVERIFY2(!html.contains(QStringLiteral("javascript:")), qPrintable(html));
+}
+
+void TestMarkdownRenderer::accentedTextSurvivesAsUtf8()
+{
+ // This user writes Italian, so accented text is every message rather
+ // than an edge case, and a UTF-8 round trip through a C library is
+ // exactly where it would be lost.
+ //
+ // Includes a character outside latin-1, so a symmetric toLatin1/fromLatin1
+ // substitution cannot round-trip it and cancel itself out. Measured: with
+ // accented latin-1 text alone, mutating both sides together passes.
+ const QString source = QString::fromUtf8("perch\xC3\xA9 \xC3\xA8 cos\xC3\xAC \xE2\x82\xAC");
+ const QString html = MarkdownRenderer::toHtml(source);
+ QVERIFY2(html.contains(source), qPrintable(html));
+}
+
+void TestMarkdownRenderer::emptyInputProducesEmptyOutput()
+{
+ // reply_no_quote opens a composer with an empty body and it must not
+ // produce a stray paragraph or crash the renderer.
+ const QString html = MarkdownRenderer::toHtml(QString());
+ QVERIFY2(html.trimmed().isEmpty(), qPrintable(html));
+}
+
+QTEST_APPLESS_MAIN(TestMarkdownRenderer)
+#include "test_markdownrenderer.moc"
diff --git a/tests/test_messagebuilder.cpp b/tests/test_messagebuilder.cpp
new file mode 100644
index 0000000..73d388c
--- /dev/null
+++ b/tests/test_messagebuilder.cpp
@@ -0,0 +1,466 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#include <QDir>
+#include <QFile>
+#include <QThread>
+#include <QObject>
+#include <QRegularExpression>
+#include <QTemporaryDir>
+#include <QTest>
+
+#include <atomic>
+#include <memory>
+
+#include "config.h"
+#include "messagebuilder.h"
+#include "types.h"
+
+/// MessageBuilder's tests assert on the GENERATED BYTES, never by round-tripping
+/// through MimeParser. A builder and a parser that agree can be wrong together:
+/// both are ours, and a shared misunderstanding of a charset or a part order
+/// would show as a green suite and as mojibake on the recipient's screen.
+class TestMessageBuilder : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void initTestCase();
+
+ void plainOnlyWhenSendHtmlIsOff();
+ void multipartAlternativeWhenSendHtmlIsOn();
+ void thePlainPartCarriesTheMarkdownSourceUnmodified();
+ void theHtmlPartIsRenderedFromTheSameSource();
+ void anAccentedBodyIsUtf8QuotedPrintable();
+ void anAccentedSubjectIsRfc2047Utf8();
+ void inReplyToAndReferencesAreCarried();
+ void bareMessageIdsAreBracketedRatherThanEmittedEmpty();
+ void attachmentsProduceMultipartMixed();
+ void aMissingAttachmentFailsTheBuild();
+ void aDirectoryAttachmentFailsRatherThanHangingTheProcess();
+ void anUnparseableRecipientFailsRatherThanVanishing();
+ void everyMessageCarriesADateAndMessageId();
+ void recipientsAppearInTheirOwnHeaders();
+ void anAccountWithNoAddressFailsRatherThanBuildingHeaderlessMail();
+
+private:
+ Account m_account;
+
+ /// A message with the fixture account and one recipient, so each test can
+ /// change only the field it is about.
+ OutgoingMessage baseMessage() const
+ {
+ OutgoingMessage m;
+ m.accountKey = m_account.key;
+ m.to = QStringList{QStringLiteral("someone@example.org")};
+ m.subject = QStringLiteral("A subject");
+ m.markdownBody = QStringLiteral("Hello there.");
+ return m;
+ }
+};
+
+void TestMessageBuilder::initTestCase()
+{
+ m_account.key = QStringLiteral("work");
+ m_account.name = QStringLiteral("Danilo M.");
+ m_account.address = QStringLiteral("user@example.org");
+ m_account.maildir = QStringLiteral("work");
+ m_account.sendCommand = QStringLiteral("/bin/true");
+}
+
+/// With the HTML toggle off the message must be a single text/plain part.
+/// A multipart/alternative carrying one alternative is not merely wasteful: it
+/// makes every message an attachment-bearing shape to some clients, and the
+/// toggle exists precisely so a user can send mail nothing has to negotiate.
+void TestMessageBuilder::plainOnlyWhenSendHtmlIsOff()
+{
+ OutgoingMessage m = baseMessage();
+ m.sendHtml = false;
+
+ const MessageBuilder::Result r = MessageBuilder::build(m, m_account);
+ QVERIFY2(r.ok(), qPrintable(r.error));
+
+ const QString text = QString::fromUtf8(r.bytes);
+ QVERIFY(text.contains(QStringLiteral("Content-Type: text/plain")));
+ QVERIFY(!text.contains(QStringLiteral("multipart/alternative")));
+ QVERIFY(!text.contains(QStringLiteral("text/html")));
+}
+
+/// With the toggle on both parts must be present, and text/plain must come
+/// FIRST. Order is load-bearing in multipart/alternative: a client renders the
+/// LAST part it understands, so least-rich first. Reversed, every HTML-capable
+/// client would show the markdown source and the rendered part would never be
+/// seen by anyone.
+void TestMessageBuilder::multipartAlternativeWhenSendHtmlIsOn()
+{
+ OutgoingMessage m = baseMessage();
+ m.sendHtml = true;
+
+ const MessageBuilder::Result r = MessageBuilder::build(m, m_account);
+ QVERIFY2(r.ok(), qPrintable(r.error));
+
+ const QString text = QString::fromUtf8(r.bytes);
+ QVERIFY(text.contains(QStringLiteral("multipart/alternative")));
+
+ const int plain = text.indexOf(QStringLiteral("text/plain"));
+ const int html = text.indexOf(QStringLiteral("text/html"));
+ QVERIFY(plain >= 0);
+ QVERIFY(html >= 0);
+ QVERIFY2(plain < html, "text/plain must precede text/html in multipart/alternative");
+}
+
+/// The markdown SOURCE is the plain part, not a stripped-of-syntax rendering of
+/// it. `**bold**` reads as emphasis to a human, and a plain-text renderer would
+/// mean inventing a second renderer whose output could disagree with the HTML
+/// one. The draft the user autosaves is this same text, which is the other
+/// reason it must not be rewritten on the way out.
+void TestMessageBuilder::thePlainPartCarriesTheMarkdownSourceUnmodified()
+{
+ OutgoingMessage m = baseMessage();
+ m.sendHtml = true;
+ m.markdownBody = QStringLiteral("**bold** and - [ ] a task");
+
+ const MessageBuilder::Result r = MessageBuilder::build(m, m_account);
+ QVERIFY2(r.ok(), qPrintable(r.error));
+
+ const QString text = QString::fromUtf8(r.bytes);
+ QVERIFY2(text.contains(QStringLiteral("**bold** and - [ ] a task")),
+ qPrintable(text));
+}
+
+/// The HTML part comes from the same source through MarkdownRenderer, so the
+/// two parts can never describe different messages.
+void TestMessageBuilder::theHtmlPartIsRenderedFromTheSameSource()
+{
+ OutgoingMessage m = baseMessage();
+ m.sendHtml = true;
+ m.markdownBody = QStringLiteral("**bold**");
+
+ const MessageBuilder::Result r = MessageBuilder::build(m, m_account);
+ QVERIFY2(r.ok(), qPrintable(r.error));
+
+ const QString text = QString::fromUtf8(r.bytes);
+ QVERIFY2(text.contains(QStringLiteral("<strong>bold</strong>")), qPrintable(text));
+}
+
+/// Measured 2026-08-20: g_mime_text_part_set_text() encodes with whatever
+/// charset is set at the moment it is CALLED, so setting the charset afterwards
+/// RELABELS the part without re-encoding it. That produces a part headed
+/// charset=utf-8 whose bytes are latin-1 (`Perch=E9`), which looks correct in
+/// every header and arrives as mojibake. Asserting on the label alone would
+/// pass against exactly that bug, so this asserts on the BYTES too: =C3=A9 must
+/// be there and =E9 must not.
+void TestMessageBuilder::anAccentedBodyIsUtf8QuotedPrintable()
+{
+ OutgoingMessage m = baseMessage();
+ m.markdownBody = QStringLiteral("perché è così");
+
+ const MessageBuilder::Result r = MessageBuilder::build(m, m_account);
+ QVERIFY2(r.ok(), qPrintable(r.error));
+
+ const QString text = QString::fromUtf8(r.bytes);
+ QVERIFY2(text.contains(QStringLiteral("charset=utf-8"), Qt::CaseInsensitive),
+ qPrintable(text));
+ QVERIFY2(text.contains(QStringLiteral("=C3=A9")), qPrintable(text));
+ QVERIFY2(!text.contains(QStringLiteral("=E9\n")) && !text.contains(QStringLiteral("=E9 ")),
+ "latin-1 bytes under a utf-8 label");
+}
+
+/// Measured 2026-08-20: GMime encodes a header as iso-8859-1 unless told
+/// otherwise, so g_mime_message_set_subject(msg, text, NULL) produced
+/// =?iso-8859-1?B?...?=. The explicit "utf-8" argument is what makes an Italian
+/// subject survive.
+void TestMessageBuilder::anAccentedSubjectIsRfc2047Utf8()
+{
+ OutgoingMessage m = baseMessage();
+ m.subject = QStringLiteral("Perché no");
+
+ const MessageBuilder::Result r = MessageBuilder::build(m, m_account);
+ QVERIFY2(r.ok(), qPrintable(r.error));
+
+ const QString text = QString::fromUtf8(r.bytes);
+ QVERIFY2(text.contains(QStringLiteral("=?UTF-8?"), Qt::CaseInsensitive), qPrintable(text));
+ QVERIFY2(!text.contains(QStringLiteral("=?iso-8859-1?"), Qt::CaseInsensitive),
+ qPrintable(text));
+}
+
+/// Not optional decoration. Without In-Reply-To and References a reply appears
+/// as an orphan thread in the sender's own client, since the sent copy is
+/// indexed by notmuch like any other message and notmuch threads on these
+/// headers.
+void TestMessageBuilder::inReplyToAndReferencesAreCarried()
+{
+ OutgoingMessage m = baseMessage();
+ m.inReplyTo = QStringLiteral("<orig@example.org>");
+ m.references = QStringList{QStringLiteral("<older@example.org>"),
+ QStringLiteral("<orig@example.org>")};
+
+ const MessageBuilder::Result r = MessageBuilder::build(m, m_account);
+ QVERIFY2(r.ok(), qPrintable(r.error));
+
+ const QString text = QString::fromUtf8(r.bytes);
+ QVERIFY2(text.contains(QStringLiteral("In-Reply-To: <orig@example.org>")), qPrintable(text));
+ QVERIFY2(text.contains(QStringLiteral("References:")), qPrintable(text));
+ QVERIFY2(text.contains(QStringLiteral("<older@example.org>")), qPrintable(text));
+}
+
+/// **The brackets are syntax, and a bare id ships an EMPTY header rather than a
+/// malformed one.** This is what every real caller supplies: GMime strips the
+/// brackets when MimeParser reads Message-ID, and
+/// ComposeContextBuilder::referencesForReply strips them from the References
+/// chain so the two agree, so both values arrive here bare.
+///
+/// Measured 2026-08-21: handed `orig@example.org`, GMime wrote `In-Reply-To:`
+/// with no value at all and did not complain. Every reply would have arrived as
+/// an orphan thread in the recipient's client, with nothing wrong to see
+/// locally. Asserted on the FULL header line, since a test for the id alone
+/// passes against an empty header that merely contains the name.
+void TestMessageBuilder::bareMessageIdsAreBracketedRatherThanEmittedEmpty()
+{
+ OutgoingMessage m = baseMessage();
+ m.inReplyTo = QStringLiteral("orig@example.org");
+ m.references = QStringList{QStringLiteral("older@example.org"),
+ QStringLiteral("orig@example.org")};
+
+ const MessageBuilder::Result r = MessageBuilder::build(m, m_account);
+ QVERIFY2(r.ok(), qPrintable(r.error));
+
+ const QString text = QString::fromUtf8(r.bytes);
+ QVERIFY2(text.contains(QStringLiteral("In-Reply-To: <orig@example.org>")), qPrintable(text));
+ QVERIFY2(text.contains(
+ QStringLiteral("References: <older@example.org> <orig@example.org>")),
+ qPrintable(text));
+ // The failure this exists for: the header present and empty.
+ QVERIFY2(!text.contains(QStringLiteral("In-Reply-To:\r\n"))
+ && !text.contains(QStringLiteral("In-Reply-To:\n")),
+ qPrintable(text));
+}
+
+/// The attachment wrapper must NEST the body, not sit beside it: multipart/mixed
+/// outermost, with the multipart/alternative as its first part. Beside it, a
+/// client would show the alternatives as attachments and the body would be
+/// unreadable. Position in the byte stream is what distinguishes the two, so the
+/// test asserts mixed appears BEFORE alternative.
+void TestMessageBuilder::attachmentsProduceMultipartMixed()
+{
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ const QString path = dir.filePath(QStringLiteral("notes.txt"));
+ QFile f(path);
+ QVERIFY(f.open(QIODevice::WriteOnly));
+ f.write("some attached bytes\n");
+ f.close();
+
+ OutgoingMessage m = baseMessage();
+ m.sendHtml = true;
+ m.attachments = QStringList{path};
+
+ const MessageBuilder::Result r = MessageBuilder::build(m, m_account);
+ QVERIFY2(r.ok(), qPrintable(r.error));
+
+ const QString text = QString::fromUtf8(r.bytes);
+ const int mixed = text.indexOf(QStringLiteral("multipart/mixed"));
+ const int alternative = text.indexOf(QStringLiteral("multipart/alternative"));
+ QVERIFY2(mixed >= 0, qPrintable(text));
+ QVERIFY2(alternative >= 0, qPrintable(text));
+ QVERIFY2(mixed < alternative, "multipart/mixed must wrap the body, not sit beside it");
+ QVERIFY2(text.contains(QStringLiteral("notes.txt")), qPrintable(text));
+ QVERIFY2(text.contains(QStringLiteral("Content-Disposition: attachment")), qPrintable(text));
+}
+
+/// A file can vanish between being attached and being sent, so existence is
+/// checked at BUILD time. The build must produce NOTHING sendable: an empty
+/// `bytes` is what stops a caller that only checks for content from shipping a
+/// message missing the thing it was written to carry.
+void TestMessageBuilder::aMissingAttachmentFailsTheBuild()
+{
+ OutgoingMessage m = baseMessage();
+ m.attachments = QStringList{QStringLiteral("/nonexistent/path/to/report.pdf")};
+
+ const MessageBuilder::Result r = MessageBuilder::build(m, m_account);
+ QVERIFY(!r.ok());
+ QVERIFY(r.bytes.isEmpty());
+ QVERIFY2(r.error.contains(QStringLiteral("report.pdf")), qPrintable(r.error));
+}
+
+/// A directory is not a file that can be attached, and accepting one does not
+/// produce a bad message, it produces NO message ever: QFileInfo reports a
+/// directory as existing and readable, opening one read-only is legal, and
+/// GMime's base64 encoder then loops on a read() returning EISDIR without
+/// advancing. Measured 2026-08-20 with strace at 2,169,821 failed reads in
+/// twenty seconds and still going. build() runs synchronously from autosave on
+/// the GUI thread, so this froze the whole application with the draft
+/// unrecoverable.
+///
+/// The TIMEOUT is deliberate and is the point of the test's shape. A regression
+/// here hangs the binary rather than failing it, and CLAUDE.md already records
+/// a hung test binary as a misleading failure mode that costs a session. The
+/// build runs on a worker thread so this test can outlive it and report a
+/// FAILURE instead of blocking ctest until its own timeout.
+///
+/// Two details are what make that actually work, and the first draft of this
+/// test had neither. It must NOT join the worker: a thread stuck in the defect
+/// never returns, so a wait() after the timeout hangs exactly as the bug does
+/// and the recorded failure is never printed. Verified by reverting the fix:
+/// with the join the binary had to be killed at 150s with no verdict, without
+/// it the run reports a FAIL and finishes. The worker is therefore deliberately
+/// leaked on the failing path, which is correct for a test binary about to exit
+/// and is the only way this reports rather than hangs. The result is read
+/// through a shared_ptr for the same reason: a leaked thread must not write
+/// into a stack frame that has returned.
+void TestMessageBuilder::aDirectoryAttachmentFailsRatherThanHangingTheProcess()
+{
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ const QString subdir = dir.filePath(QStringLiteral("a-folder"));
+ QVERIFY(QDir().mkpath(subdir));
+
+ // The guard this protects: a directory looks like a perfectly good
+ // attachment to the checks that were there before.
+ const QFileInfo info(subdir);
+ QVERIFY(info.exists());
+ QVERIFY(info.isReadable());
+ QVERIFY(!info.isFile());
+
+ OutgoingMessage m = baseMessage();
+ m.attachments = QStringList{subdir};
+
+ // Shared with the worker rather than captured by reference, so a thread
+ // still spinning after this function returns cannot write into a dead
+ // frame.
+ struct Shared
+ {
+ std::atomic_bool finished{false};
+ MessageBuilder::Result result;
+ };
+ auto shared = std::make_shared<Shared>();
+ const OutgoingMessage msg = m;
+ const Account account = m_account;
+
+ QThread *worker = QThread::create([shared, msg, account] {
+ shared->result = MessageBuilder::build(msg, account);
+ shared->finished = true;
+ });
+ worker->start();
+
+ // Five seconds against a defect measured at twenty seconds and unbounded.
+ // No join: see the note above, waiting on the stuck thread reproduces the
+ // hang instead of reporting it.
+ QTRY_VERIFY_WITH_TIMEOUT(shared->finished.load(), 5000);
+ if (!shared->finished.load())
+ QFAIL("build() did not return for a directory attachment: it is looping on read()");
+
+ worker->wait();
+ delete worker;
+
+ QVERIFY(!shared->result.ok());
+ QVERIFY(shared->result.bytes.isEmpty());
+ QVERIFY2(shared->result.error.contains(QStringLiteral("a-folder")),
+ qPrintable(shared->result.error));
+}
+
+/// A recipient the builder cannot parse must STOP the send, never be dropped.
+/// Measured 2026-08-20: internet_address_list_parse returns a ZERO-LENGTH list
+/// rather than NULL for garbage, so a guard on the assembled list's length
+/// built a message with no To: header at all and reported success. With
+/// `msmtp -t` the recipients come FROM the headers, so that message reaches the
+/// send command with nobody to deliver to, and the sent copy is filed in Sent
+/// looking sent and having reached no one.
+///
+/// Asserts on the error naming the offending entry, because with several
+/// recipients the user cannot otherwise tell which one to fix.
+void TestMessageBuilder::anUnparseableRecipientFailsRatherThanVanishing()
+{
+ OutgoingMessage m = baseMessage();
+ m.to = QStringList{QStringLiteral("not an address at all ((("),
+ QStringLiteral("good@example.org")};
+
+ const MessageBuilder::Result r = MessageBuilder::build(m, m_account);
+ QVERIFY2(!r.ok(), "an unparseable recipient must fail the build");
+ QVERIFY(r.bytes.isEmpty());
+ QVERIFY2(r.error.contains(QStringLiteral("not an address at all")), qPrintable(r.error));
+
+ // The other half of the same defect: with several recipients, the old code
+ // delivered the good ones and dropped the bad one without a word, so the
+ // user had no way to learn which recipient never received the message. A
+ // valid entry beside the bad one must not rescue the build.
+ QVERIFY2(!r.bytes.contains("good@example.org"),
+ "a valid recipient must not smuggle the message past a bad one");
+}
+
+/// Measured 2026-08-20: GMime generates neither header unless asked. A message
+/// without a Message-ID cannot be threaded by anything that receives it,
+/// including this application's own notmuch index once the sent copy lands.
+void TestMessageBuilder::everyMessageCarriesADateAndMessageId()
+{
+ const MessageBuilder::Result r = MessageBuilder::build(baseMessage(), m_account);
+ QVERIFY2(r.ok(), qPrintable(r.error));
+
+ const QString text = QString::fromUtf8(r.bytes);
+ QVERIFY2(text.contains(QStringLiteral("Date: ")), qPrintable(text));
+ QVERIFY2(text.contains(QStringLiteral("Message-Id: "), Qt::CaseInsensitive), qPrintable(text));
+ QVERIFY(!r.messageId.isEmpty());
+}
+
+/// Bcc must be PRESENT in the bytes. The documented send command is `msmtp -t`,
+/// which reads its recipients FROM the headers and strips Bcc itself before
+/// transmission. Removing it here would mean blind recipients never receive the
+/// message at all, silently.
+///
+/// If a later change passes recipients as command arguments instead of relying
+/// on -t, this test must change with it: under that scheme leaving Bcc in the
+/// bytes discloses the blind recipients to everyone.
+void TestMessageBuilder::recipientsAppearInTheirOwnHeaders()
+{
+ OutgoingMessage m = baseMessage();
+ m.to = QStringList{QStringLiteral("to@example.org")};
+ m.cc = QStringList{QStringLiteral("cc@example.org")};
+ m.bcc = QStringList{QStringLiteral("bcc@example.org")};
+
+ const MessageBuilder::Result r = MessageBuilder::build(m, m_account);
+ QVERIFY2(r.ok(), qPrintable(r.error));
+
+ const QString text = QString::fromUtf8(r.bytes);
+ QVERIFY2(text.contains(QStringLiteral("From: ")), qPrintable(text));
+ QVERIFY2(text.contains(QStringLiteral("user@example.org")), qPrintable(text));
+
+ const QRegularExpression to(QStringLiteral("^To:.*to@example\\.org"),
+ QRegularExpression::MultilineOption);
+ const QRegularExpression cc(QStringLiteral("^Cc:.*cc@example\\.org"),
+ QRegularExpression::MultilineOption);
+ const QRegularExpression bcc(QStringLiteral("^Bcc:.*bcc@example\\.org"),
+ QRegularExpression::MultilineOption);
+ QVERIFY2(to.match(text).hasMatch(), qPrintable(text));
+ QVERIFY2(cc.match(text).hasMatch(), qPrintable(text));
+ QVERIFY2(bcc.match(text).hasMatch(), qPrintable(text));
+}
+
+/// Config::account() returns a DEFAULT-CONSTRUCTED Account for an unknown key
+/// rather than failing, so without this guard a bad key would build a message
+/// with an empty From: silently malformed mail rather than a refusal, handed to
+/// the send command as though it were fine.
+void TestMessageBuilder::anAccountWithNoAddressFailsRatherThanBuildingHeaderlessMail()
+{
+ const Account empty;
+ const MessageBuilder::Result r = MessageBuilder::build(baseMessage(), empty);
+ QVERIFY(!r.ok());
+ QVERIFY(r.bytes.isEmpty());
+}
+
+QTEST_MAIN(TestMessageBuilder)
+#include "test_messagebuilder.moc"
diff --git a/tests/test_messagesender.cpp b/tests/test_messagesender.cpp
new file mode 100644
index 0000000..89e0fcf
--- /dev/null
+++ b/tests/test_messagesender.cpp
@@ -0,0 +1,532 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#include <QtTest>
+#include <QTemporaryDir>
+
+#include "messagesender.h"
+
+class TestMessageSender : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void aSuccessfulCommandReportsSent();
+ void theMessageArrivesOnStdinIntact();
+ void aLargeMessageArrivesWhole();
+ void aFailingCommandReportsItsStderr();
+ void aCommandThatDoesNotExistReportsAFailure();
+ void aCommandThatIsNotExecutableReportsAFailure();
+ void anEmptyCommandIsRefusedWithoutRunning();
+ void aCommandOfOnlyWhitespaceIsRefusedWithoutRunning();
+ void exitCode75IsAnOrdinaryFailure();
+ void aSilentFailureStillReportsAReason();
+ void aCommandThatNeverReadsStdinIsStillJudgedByItsExitStatus();
+ void aCrashedCommandIsAFailureWithAReason();
+ void aSecondSendIsRefusedWhileOneIsRunning();
+ void shellMetacharactersReachNoShell();
+ void nothingIsEverReportedTwice();
+ void destroyingTheSenderLetsAnInFlightSendFinish();
+ void destroyingTheSenderEmitsNothing();
+ void aPerSendConnectionMustBeSingleShot();
+
+private:
+ QString writeStub(const QString &name, const QString &body,
+ bool executable = true);
+
+ QTemporaryDir m_dir;
+};
+
+QString TestMessageSender::writeStub(const QString &name, const QString &body,
+ bool executable)
+{
+ const QString path = m_dir.filePath(name);
+ QFile file(path);
+ if (!file.open(QIODevice::WriteOnly))
+ return {};
+ file.write(QStringLiteral("#!/bin/sh\n%1\n").arg(body).toUtf8());
+ file.close();
+ QFile::Permissions permissions = QFile::ReadOwner | QFile::WriteOwner;
+ if (executable)
+ permissions |= QFile::ExeOwner;
+ file.setPermissions(permissions);
+ return path;
+}
+
+void TestMessageSender::aSuccessfulCommandReportsSent()
+{
+ const QString stub = writeStub(QStringLiteral("ok.sh"), QStringLiteral("cat >/dev/null"));
+ QVERIFY(!stub.isEmpty());
+
+ MessageSender sender;
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ QVERIFY(sender.send(stub, QByteArray("From: a@example.org\r\n\r\nbody\r\n")));
+
+ QVERIFY(spy.wait(5000));
+ QCOMPARE(spy.count(), 1);
+ QCOMPARE(spy.at(0).at(0).toBool(), true);
+ QVERIFY2(spy.at(0).at(1).toString().isEmpty(),
+ "a successful send carried an error message");
+ QVERIFY2(!sender.isRunning(), "the sender still reports a run in progress");
+}
+
+void TestMessageSender::theMessageArrivesOnStdinIntact()
+{
+ // The property that matters most: the bytes the builder produced are the
+ // bytes the command receives. A stub that writes stdin to a file is the
+ // only way to see it, since there is no MTA to ask.
+ const QString captured = m_dir.filePath(QStringLiteral("captured.eml"));
+ const QString stub = writeStub(QStringLiteral("capture.sh"),
+ QStringLiteral("cat > '%1'").arg(captured));
+ QVERIFY(!stub.isEmpty());
+
+ const QByteArray bytes(
+ "From: a@example.org\r\n"
+ "Subject: =?UTF-8?B?UGVyY2jDqQ==?=\r\n"
+ "\r\n"
+ "Perch=C3=A9 accented body.\r\n");
+
+ MessageSender sender;
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ QVERIFY(sender.send(stub, bytes));
+ QVERIFY(spy.wait(5000));
+ QCOMPARE(spy.at(0).at(0).toBool(), true);
+
+ QFile file(captured);
+ QVERIFY2(file.open(QIODevice::ReadOnly), "the stub captured no stdin at all");
+ QCOMPARE(file.readAll(), bytes);
+}
+
+void TestMessageSender::aLargeMessageArrivesWhole()
+{
+ // A message with an attachment is megabytes, not bytes, and a pipe holds
+ // 64KB. If the write were not driven by the event loop the process would
+ // deadlock on a full pipe, or the tail would be silently dropped and a
+ // truncated message would be reported as sent. Measured: 1.6MB in one
+ // write() call returns the full count only because QProcess buffers it and
+ // drains it as the reader consumes; a probe confirmed the payload arrives
+ // byte-identical.
+ const QString captured = m_dir.filePath(QStringLiteral("big.eml"));
+ const QString stub = writeStub(QStringLiteral("bigcapture.sh"),
+ QStringLiteral("cat > '%1'").arg(captured));
+ QVERIFY(!stub.isEmpty());
+
+ QByteArray bytes("From: a@example.org\r\n\r\n");
+ // Well past a pipe buffer, and not a repeating single byte, so a partial
+ // write cannot accidentally compare equal.
+ for (int i = 0; i < 60000; ++i)
+ bytes += QByteArray::number(i) + "\r\n";
+ QVERIFY(bytes.size() > 300000);
+
+ MessageSender sender;
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ QVERIFY(sender.send(stub, bytes));
+ QVERIFY(spy.wait(10000));
+ QCOMPARE(spy.at(0).at(0).toBool(), true);
+
+ QFile file(captured);
+ QVERIFY(file.open(QIODevice::ReadOnly));
+ const QByteArray got = file.readAll();
+ QCOMPARE(got.size(), bytes.size());
+ QCOMPARE(got, bytes);
+}
+
+void TestMessageSender::aFailingCommandReportsItsStderr()
+{
+ // stderr is shown verbatim: network errors, authentication failures and
+ // server rejections all belong to send_command, and this application
+ // deliberately does not interpret them.
+ const QString stub = writeStub(
+ QStringLiteral("fail.sh"),
+ QStringLiteral("cat >/dev/null; echo 'auth failed: bad password' >&2; exit 1"));
+ QVERIFY(!stub.isEmpty());
+
+ MessageSender sender;
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ QVERIFY(sender.send(stub, QByteArray("body")));
+
+ QVERIFY(spy.wait(5000));
+ QCOMPARE(spy.at(0).at(0).toBool(), false);
+ QVERIFY2(spy.at(0).at(1).toString().contains(QStringLiteral("auth failed")),
+ qPrintable(QStringLiteral("stderr was not reported: '%1'")
+ .arg(spy.at(0).at(1).toString())));
+}
+
+void TestMessageSender::aCommandThatDoesNotExistReportsAFailure()
+{
+ // A typo'd path is the likely cause, so the message names the command.
+ // QProcess emits errorOccurred(FailedToStart) INSTEAD OF finished(), which
+ // is the trap MailSync already documents: without handling it the signal
+ // never arrives and the popup waits forever. Measured on Qt 6.11:
+ // finCount 0, errCount 1.
+ MessageSender sender;
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ QVERIFY(sender.send(QStringLiteral("/nonexistent/msmtp"), QByteArray("body")));
+
+ QVERIFY2(spy.wait(5000), "no result was ever reported for a missing command");
+ QCOMPARE(spy.count(), 1);
+ QCOMPARE(spy.at(0).at(0).toBool(), false);
+ QVERIFY2(spy.at(0).at(1).toString().contains(QStringLiteral("msmtp")),
+ qPrintable(QStringLiteral("the error does not name the command: '%1'")
+ .arg(spy.at(0).at(1).toString())));
+}
+
+void TestMessageSender::aCommandThatIsNotExecutableReportsAFailure()
+{
+ // A separate case from a missing file and reached by an ordinary mistake:
+ // a script written by the user and never chmod'd. It also arrives as
+ // FailedToStart with no finished(), so the same handler covers it, but a
+ // test asserting only the missing-file case would pass against a handler
+ // keyed on the errno rather than on the error enum.
+ const QString stub = writeStub(QStringLiteral("noexec.sh"),
+ QStringLiteral("cat >/dev/null"), false);
+ QVERIFY(!stub.isEmpty());
+
+ MessageSender sender;
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ QVERIFY(sender.send(stub, QByteArray("body")));
+
+ QVERIFY2(spy.wait(5000), "no result was ever reported for a non-executable command");
+ QCOMPARE(spy.at(0).at(0).toBool(), false);
+ QVERIFY(!spy.at(0).at(1).toString().isEmpty());
+}
+
+void TestMessageSender::anEmptyCommandIsRefusedWithoutRunning()
+{
+ // A receive-only account. The compose actions are disabled on its mail, so
+ // this should be unreachable; refusing here rather than asserting means a
+ // future caller cannot accidentally send from an account that cannot.
+ MessageSender sender;
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ QVERIFY2(!sender.send(QString(), QByteArray("body")),
+ "an empty command was accepted");
+ QCOMPARE(spy.count(), 0);
+ QVERIFY(!sender.isRunning());
+}
+
+void TestMessageSender::aCommandOfOnlyWhitespaceIsRefusedWithoutRunning()
+{
+ // A config file with `send_command = ` and a trailing space reaches
+ // exactly this, and it must not run anything.
+ //
+ // MEASURED, and worth stating precisely so this is not mistaken for a
+ // sharper test than it is: send() has TWO guards that both catch a blank
+ // command, the trimmed()-empty check and the parts.isEmpty() check after
+ // QProcess::splitCommand(" ") returns an empty list. Dropping either one
+ // alone leaves this test green, because the other still refuses. Dropping
+ // BOTH aborts the run outright: QProcess treats an empty program as fatal,
+ // and the mutation reports "Received a fatal error" rather than a failed
+ // comparison. The pair is what is under test here; the redundancy is
+ // deliberate, since the fatal path is the one thing a send must never
+ // reach.
+ MessageSender sender;
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ QVERIFY2(!sender.send(QStringLiteral(" \t "), QByteArray("body")),
+ "a whitespace-only command was accepted");
+ QCOMPARE(spy.count(), 0);
+ QVERIFY(!sender.isRunning());
+}
+
+void TestMessageSender::exitCode75IsAnOrdinaryFailure()
+{
+ // Explicitly asserted so the sync path's special handling of 75 is never
+ // copied here. There is no lock to contend for, so 75 means only what the
+ // command chose it to mean: not sent.
+ const QString stub = writeStub(QStringLiteral("busy.sh"),
+ QStringLiteral("cat >/dev/null; exit 75"));
+ QVERIFY(!stub.isEmpty());
+
+ MessageSender sender;
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ QVERIFY(sender.send(stub, QByteArray("body")));
+
+ QVERIFY(spy.wait(5000));
+ QCOMPARE(spy.at(0).at(0).toBool(), false);
+}
+
+void TestMessageSender::aSilentFailureStillReportsAReason()
+{
+ // The mailsync.sh lesson in the other direction: a command that fails
+ // without saying anything must not produce an empty error string, because
+ // the popup would then show a failure with a blank explanation and the
+ // user would have nothing to act on.
+ const QString stub = writeStub(QStringLiteral("silent.sh"),
+ QStringLiteral("cat >/dev/null; exit 3"));
+ QVERIFY(!stub.isEmpty());
+
+ MessageSender sender;
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ QVERIFY(sender.send(stub, QByteArray("body")));
+
+ QVERIFY(spy.wait(5000));
+ QCOMPARE(spy.at(0).at(0).toBool(), false);
+ const QString error = spy.at(0).at(1).toString();
+ QVERIFY2(!error.isEmpty(), "a silent failure reported no reason at all");
+ QVERIFY2(error.contains(QStringLiteral("3")),
+ qPrintable(QStringLiteral("the exit status is not named: '%1'").arg(error)));
+}
+
+void TestMessageSender::aCommandThatNeverReadsStdinIsStillJudgedByItsExitStatus()
+{
+ // Measured on Qt 6.11: a command that exits without draining a large stdin
+ // emits errorOccurred(WriteError) BEFORE finished(). A handler that treated
+ // any error as a failure to start would report the write error and swallow
+ // the real exit status; a handler that reported on every error would report
+ // twice. The exit status is the only authority, exactly as it is for the
+ // sync script, so this asserts the reason the command GAVE.
+ const QString stub = writeStub(
+ QStringLiteral("nonreading.sh"),
+ QStringLiteral("echo 'recipient rejected' >&2; exit 1"));
+ QVERIFY(!stub.isEmpty());
+
+ MessageSender sender;
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ QVERIFY(sender.send(stub, QByteArray(1600 * 1024, 'x')));
+
+ QVERIFY(spy.wait(5000));
+ QCOMPARE(spy.count(), 1);
+ QCOMPARE(spy.at(0).at(0).toBool(), false);
+ QVERIFY2(spy.at(0).at(1).toString().contains(QStringLiteral("recipient rejected")),
+ qPrintable(QStringLiteral("the command's own reason was lost: '%1'")
+ .arg(spy.at(0).at(1).toString())));
+}
+
+void TestMessageSender::aCrashedCommandIsAFailureWithAReason()
+{
+ // A segfaulting MTA is a real failure mode and reaches a DIFFERENT branch
+ // from a nonzero exit: status is CrashExit and exitCode carries the signal
+ // number, so an error message built from the exit code alone would tell the
+ // user the command "exited with status 11", which is not what happened.
+ //
+ // Measured on Qt 6.11: a crash emits errorOccurred(Crashed) and THEN
+ // finished(11, CrashExit). Only finished() reports, because handleError
+ // filters to FailedToStart, so the count assertion below also proves that
+ // filter is doing work on a path that is not the write-error one.
+ const QString stub = writeStub(QStringLiteral("crash.sh"),
+ QStringLiteral("cat >/dev/null; kill -SEGV $$"));
+ QVERIFY(!stub.isEmpty());
+
+ MessageSender sender;
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ QVERIFY(sender.send(stub, QByteArray("body")));
+
+ QVERIFY(spy.wait(5000));
+ QTest::qWait(300);
+ QCOMPARE(spy.count(), 1);
+ QCOMPARE(spy.at(0).at(0).toBool(), false);
+ const QString error = spy.at(0).at(1).toString();
+ QVERIFY2(!error.isEmpty(), "a crashed command reported no reason");
+ QVERIFY2(error.contains(QStringLiteral("crash")),
+ qPrintable(QStringLiteral("a crash was reported as an ordinary exit: '%1'")
+ .arg(error)));
+}
+
+void TestMessageSender::aSecondSendIsRefusedWhileOneIsRunning()
+{
+ // One QProcess, so a second send would overwrite the first's program and
+ // arguments mid-flight. Refusing is what makes the popup's Sending stage
+ // mean one message.
+ const QString stub = writeStub(QStringLiteral("slow.sh"),
+ QStringLiteral("cat >/dev/null; sleep 1"));
+ QVERIFY(!stub.isEmpty());
+
+ MessageSender sender;
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ QVERIFY(sender.send(stub, QByteArray("first")));
+ QVERIFY2(sender.isRunning(), "the sender does not report the run it just started");
+ QVERIFY2(!sender.send(stub, QByteArray("second")),
+ "a second send was accepted while one was running");
+
+ QVERIFY(spy.wait(10000));
+ QCOMPARE(spy.count(), 1);
+ QCOMPARE(spy.at(0).at(0).toBool(), true);
+}
+
+void TestMessageSender::shellMetacharactersReachNoShell()
+{
+ // The security property, asserted rather than asserted-about-in-a-comment.
+ // The command is split into an argument list and handed to execve, so a
+ // `;` in it is a literal argument and there is no shell to act on it. If
+ // this ever ran through `sh -c` the stub below would be invoked and the
+ // marker file would exist.
+ //
+ // Measured: QProcess::splitCommand("msmtp; rm x") yields ("msmtp;", "rm",
+ // "x"), so the semicolon does not even separate arguments.
+ const QString marker = m_dir.filePath(QStringLiteral("shell-ran"));
+ const QString stub = writeStub(QStringLiteral("args.sh"),
+ QStringLiteral("cat >/dev/null; exit 0"));
+ QVERIFY(!stub.isEmpty());
+
+ MessageSender sender;
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ QVERIFY(sender.send(QStringLiteral("%1 ; touch %2").arg(stub, marker),
+ QByteArray("body")));
+ QVERIFY(spy.wait(5000));
+
+ QVERIFY2(!QFile::exists(marker),
+ "the send command was interpreted by a shell");
+
+ // And the same string quoted the way a shell would need it also reaches no
+ // shell: double quotes are the ONLY quoting splitCommand understands.
+ // Measured: single quotes are NOT stripped, so `-a 'my acct'` arrives as
+ // three arguments. Recorded here because the plan's comment claimed
+ // splitCommand "handles quoted arguments" without that qualification.
+ QCOMPARE(QProcess::splitCommand(QStringLiteral("m -a \"my acct\" -t")),
+ QStringList({QStringLiteral("m"), QStringLiteral("-a"),
+ QStringLiteral("my acct"), QStringLiteral("-t")}));
+ QCOMPARE(QProcess::splitCommand(QStringLiteral("m -a 'my acct' -t")),
+ QStringList({QStringLiteral("m"), QStringLiteral("-a"),
+ QStringLiteral("'my"), QStringLiteral("acct'"),
+ QStringLiteral("-t")}));
+}
+
+void TestMessageSender::nothingIsEverReportedTwice()
+{
+ // Reporting twice would close the send popup and then act on a second
+ // result, which for a caller that files a sent copy on success means two
+ // copies, or a success followed by a failure. Run every outcome through one
+ // sender and count.
+ const QString ok = writeStub(QStringLiteral("dup-ok.sh"),
+ QStringLiteral("cat >/dev/null"));
+ const QString bad = writeStub(QStringLiteral("dup-bad.sh"),
+ QStringLiteral("echo boom >&2; exit 1"));
+ QVERIFY(!ok.isEmpty() && !bad.isEmpty());
+
+ for (const QString &command :
+ {ok, bad, QStringLiteral("/nonexistent/msmtp")}) {
+ MessageSender sender;
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ QVERIFY(sender.send(command, QByteArray(1600 * 1024, 'x')));
+ QVERIFY(spy.wait(10000));
+ // Give any second signal a chance to arrive before counting.
+ QTest::qWait(300);
+ QVERIFY2(spy.count() == 1,
+ qPrintable(QStringLiteral("%1 reported %2 times")
+ .arg(command)
+ .arg(spy.count())));
+ }
+}
+
+void TestMessageSender::destroyingTheSenderLetsAnInFlightSendFinish()
+{
+ // The composer's X button is reachable mid-send, and abandoning a live
+ // SMTP conversation has a genuinely unknown outcome. Measured before the
+ // destructor existed: plain destruction 100ms into a one-second command
+ // killed the child and the work did NOT complete, announced by nothing but
+ // a "QProcess: Destroyed while process is still running" warning.
+ //
+ // The marker file is the evidence, because it is written by the command
+ // itself after its work: if the destructor killed the child, it does not
+ // exist.
+ const QString marker = m_dir.filePath(QStringLiteral("send-completed"));
+ const QString stub = writeStub(
+ QStringLiteral("slowfinish.sh"),
+ QStringLiteral("cat >/dev/null; sleep 1; touch '%1'").arg(marker));
+ QVERIFY(!stub.isEmpty());
+ QVERIFY2(!QFile::exists(marker), "the marker existed before the send ran");
+
+ {
+ MessageSender sender;
+ QVERIFY(sender.send(stub, QByteArray("body")));
+ // Destroyed well before the command could finish, which is the case
+ // that matters; without the wait this scope kills it.
+ QTest::qWait(100);
+ QVERIFY2(sender.isRunning(), "the command finished before it was abandoned");
+ }
+
+ QVERIFY2(QFile::exists(marker),
+ "destroying the sender killed a send that was in flight");
+}
+
+void TestMessageSender::destroyingTheSenderEmitsNothing()
+{
+ // After a kill the outcome is unknown, and this class reports two outcomes
+ // only. A finished(false, ...) from the destructor would report "not sent"
+ // for a message that may have been delivered, which is the mailsync.sh
+ // mistake pointing the other way.
+ //
+ // A command that outlasts the shutdown wait is what forces the kill
+ // branch, so the wait is shortened by pointing the test at a command
+ // longer than it rather than by changing the constant.
+ const QString stub = writeStub(QStringLiteral("outlast.sh"),
+ QStringLiteral("cat >/dev/null; sleep 30"));
+ QVERIFY(!stub.isEmpty());
+
+ QSignalSpy *spy = nullptr;
+ {
+ MessageSender sender;
+ spy = new QSignalSpy(&sender, &MessageSender::finished);
+ QVERIFY(sender.send(stub, QByteArray("body")));
+ QTest::qWait(100);
+ QVERIFY(sender.isRunning());
+ // The destructor runs as this scope ends: it waits kShutdownWaitMs
+ // for a command that will not finish, then kills it.
+ }
+ // The spy outlives the sender deliberately: a signal emitted during
+ // destruction would have been recorded before the object went away.
+ QCOMPARE(spy->count(), 0);
+ delete spy;
+}
+
+void TestMessageSender::aPerSendConnectionMustBeSingleShot()
+{
+ // The header's contract, asserted. m_reported collapses two QProcess
+ // signals into one emit, but it cannot stop a caller from accumulating
+ // RECEIVERS: a long-lived sender that a caller connects to inside its send
+ // path runs every previous lambda on the next result, each still holding
+ // the previous message's bytes.
+ //
+ // This is the plan's own Task 11 shape, and it is why that step now
+ // specifies Qt::SingleShotConnection.
+ const QString stub = writeStub(QStringLiteral("twice.sh"),
+ QStringLiteral("cat >/dev/null"));
+ QVERIFY(!stub.isEmpty());
+
+ MessageSender sender; // long-lived, as a ComposeWindow member is
+
+ // The broken shape: a bare connect() beside each send().
+ int bareDeliveries = 0;
+ for (int i = 0; i < 2; ++i) {
+ QSignalSpy spy(&sender, &MessageSender::finished);
+ connect(&sender, &MessageSender::finished, this,
+ [&bareDeliveries](bool, const QString &) { ++bareDeliveries; });
+ QVERIFY(sender.send(stub, QByteArray("body")));
+ QVERIFY(spy.wait(5000));
+ QCOMPARE(spy.count(), 1); // ONE emit, both times
+ }
+ QVERIFY2(bareDeliveries == 3,
+ qPrintable(QStringLiteral("expected the documented 1+2 accumulation, got %1")
+ .arg(bareDeliveries)));
+
+ // The prescribed shape: the connection disconnects as it fires, so two
+ // sends deliver two results rather than three.
+ MessageSender clean;
+ int singleShotDeliveries = 0;
+ for (int i = 0; i < 2; ++i) {
+ QSignalSpy spy(&clean, &MessageSender::finished);
+ connect(&clean, &MessageSender::finished, this,
+ [&singleShotDeliveries](bool, const QString &) { ++singleShotDeliveries; },
+ Qt::SingleShotConnection);
+ QVERIFY(clean.send(stub, QByteArray("body")));
+ QVERIFY(spy.wait(5000));
+ }
+ QCOMPARE(singleShotDeliveries, 2);
+}
+
+QTEST_MAIN(TestMessageSender)
+#include "test_messagesender.moc"
diff --git a/tests/test_senddialog.cpp b/tests/test_senddialog.cpp
new file mode 100644
index 0000000..ac8c234
--- /dev/null
+++ b/tests/test_senddialog.cpp
@@ -0,0 +1,468 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#include <QtTest>
+
+#include <QLabel>
+#include <QPushButton>
+#include <QSet>
+
+#include "busyindicator.h"
+#include "senddialog.h"
+
+class TestSendDialog : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void theBarIsDeterminateWhileCountingDown();
+ void theCountdownCommitsWhenItElapses();
+ void aZeroDelayCommitsImmediately();
+ void undoDuringTheCountdownEmitsUndoneAndNeverCommits();
+ void undoDisablesItselfOnceTheCommandStarts();
+ void theBarBecomesIndeterminateWhenSending();
+ void undoStaysVisibleAfterItDisables();
+ void theStatusLabelIsWideEnoughForEveryStage();
+ void closingDuringTheCountdownIsRefused();
+ void closingADialogThatWasNeverShownIsAlsoRefused();
+ void theRefusalHintSurvivesTheNextCountdownTick();
+ void rejectDuringTheCountdownIsRefused();
+ void escapeDuringTheCountdownIsRefused();
+ void undoIsTheOneRouteThatClosesBeforeCommit();
+ void closingAfterCommitReportsAcceptedAndDoesNotUndo();
+ void undoAfterCommitIsRefused();
+ void everyStageSetsItsOwnLabelAndLeavesTheBarBusy();
+ void windingBackToCountingDownAfterCommitIsRefused();
+};
+
+void TestSendDialog::theBarIsDeterminateWhileCountingDown()
+{
+ // A countdown has measurable progress, so the bar drains rather than
+ // animating. This is the half of BusyIndicator MainWindow never uses: the
+ // status bar's sync indicator is indeterminate for its whole life.
+ //
+ // A generous delay so the assertion cannot race the countdown's own end,
+ // which would flip the bar to indeterminate for a legitimate reason and
+ // report a defect that is not there.
+ SendDialog dialog(5000);
+ dialog.show();
+
+ auto *indicator = dialog.findChild<BusyIndicator *>(
+ QStringLiteral("sendProgress"));
+ QVERIFY2(indicator, "the dialog has no BusyIndicator named sendProgress");
+ QVERIFY2(indicator->isDeterminate(),
+ "the bar was animating during a countdown that has a known end");
+}
+
+void TestSendDialog::theCountdownCommitsWhenItElapses()
+{
+ // A short delay rather than waiting out the shipped default: what is being
+ // tested is that the countdown ends in a commit, not how long it is.
+ SendDialog dialog(150);
+ QSignalSpy spy(&dialog, &SendDialog::committed);
+ dialog.show();
+
+ QVERIFY2(spy.wait(3000), "the countdown never committed");
+ QCOMPARE(spy.count(), 1);
+ QVERIFY(dialog.isCommitted());
+}
+
+void TestSendDialog::aZeroDelayCommitsImmediately()
+{
+ // send_delay_ms = 0 sends at once, for anyone who finds the delay
+ // irritating. It must still be a queued commit rather than one inside the
+ // constructor, or a caller connecting to committed() after constructing the
+ // dialog would never hear it.
+ SendDialog dialog(0);
+ QSignalSpy spy(&dialog, &SendDialog::committed);
+ dialog.show();
+
+ QVERIFY2(spy.wait(1000), "a zero delay never committed");
+ QCOMPARE(spy.count(), 1);
+ QVERIFY(dialog.isCommitted());
+}
+
+void TestSendDialog::undoDuringTheCountdownEmitsUndoneAndNeverCommits()
+{
+ // THE test for this feature, and the property that matters is the NEGATIVE
+ // one. A test asserting only that undone() fired would pass against a
+ // design that started the send anyway and threw the result away, which is
+ // the whole failure the delay exists to prevent. Nothing has reached a
+ // server during the countdown, so Undo must mean that nothing happened.
+ SendDialog dialog(2000);
+ QSignalSpy committedSpy(&dialog, &SendDialog::committed);
+ QSignalSpy undoneSpy(&dialog, &SendDialog::undone);
+ dialog.show();
+
+ auto *undo = dialog.findChild<QPushButton *>(QStringLiteral("undoSend"));
+ QVERIFY2(undo, "the dialog has no button named undoSend");
+ QVERIFY2(undo->isEnabled(), "Undo was dead during the countdown");
+
+ undo->click();
+
+ QCOMPARE(undoneSpy.count(), 1);
+ QCOMPARE(committedSpy.count(), 0);
+
+ // Past the original deadline. A timer left running would commit here, after
+ // the dialog has already reported that nothing was sent.
+ QTest::qWait(2500);
+ QVERIFY2(committedSpy.count() == 0,
+ "the countdown committed after Undo was pressed");
+}
+
+void TestSendDialog::undoDisablesItselfOnceTheCommandStarts()
+{
+ // There is no cancel after commit. Killing send_command once it runs leaves
+ // an UNKNOWN send: the message may have reached the server in full before
+ // the kill, which is worse than either clean outcome.
+ SendDialog dialog(100);
+ dialog.show();
+
+ auto *undo = dialog.findChild<QPushButton *>(QStringLiteral("undoSend"));
+ QVERIFY(undo);
+
+ QSignalSpy spy(&dialog, &SendDialog::committed);
+ QVERIFY2(spy.wait(3000), "the countdown never committed");
+
+ QVERIFY2(!undo->isEnabled(),
+ "Undo was still live after the send command started");
+}
+
+void TestSendDialog::theBarBecomesIndeterminateWhenSending()
+{
+ // The bar CHANGES MODE, it does not change place: a send has no measurable
+ // progress, so the same widget stops drawing a fraction and starts
+ // animating, and nothing in the popup reflows.
+ SendDialog dialog(100);
+ dialog.show();
+
+ auto *indicator = dialog.findChild<BusyIndicator *>(
+ QStringLiteral("sendProgress"));
+ QVERIFY(indicator);
+ QVERIFY(indicator->isDeterminate());
+
+ dialog.setStage(SendDialog::Stage::Sending);
+ QVERIFY2(!indicator->isDeterminate(),
+ "the bar kept the countdown's fraction while sending");
+}
+
+void TestSendDialog::undoStaysVisibleAfterItDisables()
+{
+ // A control that vanishes re-lays out the popup mid-operation, and a greyed
+ // Undo says WHY cancelling is no longer possible where an absent one only
+ // looks like it was never offered.
+ SendDialog dialog(100);
+ dialog.show();
+
+ auto *undo = dialog.findChild<QPushButton *>(QStringLiteral("undoSend"));
+ QVERIFY(undo);
+
+ QSignalSpy spy(&dialog, &SendDialog::committed);
+ QVERIFY2(spy.wait(3000), "the countdown never committed");
+
+ QVERIFY2(undo->isVisibleTo(&dialog),
+ "Undo disappeared instead of greying out");
+}
+
+void TestSendDialog::theStatusLabelIsWideEnoughForEveryStage()
+{
+ // The label is sized to the LONGEST string it can hold in the current
+ // language, not to its content, so the popup does not resize between
+ // stages. Asserted against the metrics of the strings themselves rather
+ // than a constant, so it holds in whatever language is loaded.
+ SendDialog dialog(2000);
+ dialog.show();
+
+ auto *status = dialog.findChild<QLabel *>(QStringLiteral("sendStatus"));
+ QVERIFY2(status, "the dialog has no label named sendStatus");
+
+ const QFontMetrics metrics(status->font());
+ const QStringList candidates{
+ SendDialog::tr("Sending in %1...").arg(99),
+ SendDialog::tr("Sending..."),
+ SendDialog::tr("Filing sent copy..."),
+ SendDialog::tr("Removing draft..."),
+ SendDialog::tr("Press Undo to stop sending."),
+ };
+ int widest = 0;
+ for (const QString &candidate : candidates)
+ widest = qMax(widest, metrics.horizontalAdvance(candidate));
+
+ QVERIFY2(status->minimumWidth() >= widest,
+ "the status label was sized to its content, so the popup will "
+ "resize when a longer stage name arrives");
+}
+
+void TestSendDialog::closingDuringTheCountdownIsRefused()
+{
+ // The same failure as the Undo test, reached by a different door. Removing
+ // the close BUTTON removes the visual affordance, not the code path: the
+ // window manager, close() and QDialog's own machinery all still reach
+ // done(). Left unguarded, close() hides the window and leaves the timer
+ // running, so the send starts with no window on screen and the only cancel
+ // control destroyed.
+ //
+ // The close is REFUSED rather than reinterpreted as an Undo, at the user's
+ // call: "close means undo is confusing", because a dismissed window cannot
+ // tell you whether it stopped the send or merely hid it. So the dialog
+ // stays up, the send stays scheduled, and Undo remains the only way out.
+ SendDialog dialog(2000);
+ QSignalSpy committedSpy(&dialog, &SendDialog::committed);
+ QSignalSpy undoneSpy(&dialog, &SendDialog::undone);
+ dialog.show();
+
+ QVERIFY2(!dialog.close(), "close() during the countdown was accepted");
+
+ QVERIFY2(dialog.isVisible(),
+ "the dialog vanished on a close it was supposed to refuse");
+ QVERIFY2(undoneSpy.count() == 0,
+ "a refused close silently undid the send anyway");
+
+ // Refusing must not be silent: a window that ignores a close reads as a
+ // hang, so the popup has to say where the exit is.
+ auto *status = dialog.findChild<QLabel *>(QStringLiteral("sendStatus"));
+ QVERIFY(status);
+ QVERIFY2(status->text().contains(QStringLiteral("Undo")),
+ "a refused close gave the user no hint that Undo is the way out");
+
+ // The send was never cancelled, so it still goes out. That is the whole
+ // point of refusing rather than undoing.
+ QVERIFY2(committedSpy.wait(3000),
+ "the refused close cancelled the send after all");
+}
+
+void TestSendDialog::closingADialogThatWasNeverShownIsAlsoRefused()
+{
+ // CLAUDE.md's documented companion trap: close() on a widget that was
+ // never shown returns early WITHOUT reaching done(), so a refusal written
+ // only in done() would miss this one route entirely. The countdown is
+ // running either way, because it starts in the constructor rather than on
+ // show(). Refused on the same terms as the shown case.
+ SendDialog dialog(2000);
+ QSignalSpy committedSpy(&dialog, &SendDialog::committed);
+ QSignalSpy undoneSpy(&dialog, &SendDialog::undone);
+
+ QVERIFY2(!dialog.close(),
+ "close() on an unshown dialog slipped past the refusal");
+ QVERIFY2(undoneSpy.count() == 0,
+ "closing an unshown dialog undid the send");
+
+ QVERIFY2(committedSpy.wait(3000),
+ "the unshown dialog's send was cancelled by a refused close");
+}
+
+void TestSendDialog::theRefusalHintSurvivesTheNextCountdownTick()
+{
+ // Without a hold the hint lives for one tick, which is 100ms, and the
+ // countdown text overwrites it before it can be read. A refusal the user
+ // cannot see is a window that ignores them, which reads as a hang, so the
+ // hold is what makes the refusal honest rather than decorative.
+ SendDialog dialog(5000);
+ dialog.show();
+
+ auto *status = dialog.findChild<QLabel *>(QStringLiteral("sendStatus"));
+ QVERIFY(status);
+
+ dialog.close();
+ const QString hint = status->text();
+ QVERIFY2(hint.contains(QStringLiteral("Undo")), "no hint on refusal");
+
+ // Several ticks later, well past the point the countdown would have
+ // reclaimed the label.
+ QTest::qWait(500);
+ QCOMPARE(status->text(), hint);
+
+ // And it does eventually give the label back, or the countdown would be
+ // hidden for the rest of its life.
+ QTest::qWait(1500);
+ QVERIFY2(status->text() != hint,
+ "the hint never released the label back to the countdown");
+}
+
+void TestSendDialog::rejectDuringTheCountdownIsRefused()
+{
+ // reject() is the route neither close() nor Escape goes through directly,
+ // and it is the one a caller reaches for. CLAUDE.md's rule is that every
+ // route out gets asserted: "a test used close() and the user used Cancel"
+ // is the documented way one of three gets missed.
+ SendDialog dialog(2000);
+ QSignalSpy committedSpy(&dialog, &SendDialog::committed);
+ QSignalSpy undoneSpy(&dialog, &SendDialog::undone);
+ dialog.show();
+
+ dialog.reject();
+
+ QVERIFY2(dialog.isVisible(), "reject() dismissed the countdown");
+ QVERIFY2(undoneSpy.count() == 0, "reject() undid the send");
+ QVERIFY2(committedSpy.wait(3000), "reject() cancelled the send after all");
+}
+
+void TestSendDialog::escapeDuringTheCountdownIsRefused()
+{
+ // Escape is QDialog's built-in reject(), and swallowing it in
+ // keyPressEvent is only the first line: done() refuses it too, so the
+ // dialog is safe even if the key handler is ever removed.
+ SendDialog dialog(2000);
+ QSignalSpy committedSpy(&dialog, &SendDialog::committed);
+ QSignalSpy undoneSpy(&dialog, &SendDialog::undone);
+ dialog.show();
+
+ QTest::keyClick(&dialog, Qt::Key_Escape);
+ QVERIFY2(dialog.isVisible(), "Escape dismissed the countdown");
+
+ // With modifiers too, so neither is an undocumented back door.
+ QTest::keyClick(&dialog, Qt::Key_Escape, Qt::ShiftModifier);
+ QTest::keyClick(&dialog, Qt::Key_Escape, Qt::ControlModifier);
+ QVERIFY2(dialog.isVisible(), "a modified Escape dismissed the countdown");
+
+ QVERIFY2(undoneSpy.count() == 0, "Escape undid the send");
+ QVERIFY2(committedSpy.wait(3000), "Escape cancelled the send after all");
+}
+
+void TestSendDialog::undoIsTheOneRouteThatClosesBeforeCommit()
+{
+ // The counterpart to the four refusals above: having refused every other
+ // way out, the one remaining control must actually work, or the popup is
+ // a trap with no exit at all.
+ SendDialog dialog(2000);
+ QSignalSpy undoneSpy(&dialog, &SendDialog::undone);
+ dialog.show();
+
+ auto *undo = dialog.findChild<QPushButton *>(QStringLiteral("undoSend"));
+ QVERIFY(undo);
+ undo->click();
+
+ QCOMPARE(undoneSpy.count(), 1);
+ QVERIFY2(!dialog.isVisible(), "Undo did not close the dialog");
+ QCOMPARE(dialog.result(), int(QDialog::Rejected));
+}
+
+void TestSendDialog::closingAfterCommitReportsAcceptedAndDoesNotUndo()
+{
+ // After commit there is nothing to undo, so closing is permitted. What it
+ // must NOT do is report Rejected: a caller inspecting result() would read
+ // a send that is running as one that was cancelled, and undone() must stay
+ // silent because the message is on its way.
+ SendDialog dialog(100);
+ QSignalSpy undoneSpy(&dialog, &SendDialog::undone);
+ QSignalSpy committedSpy(&dialog, &SendDialog::committed);
+ dialog.show();
+
+ QVERIFY2(committedSpy.wait(3000), "the countdown never committed");
+ QVERIFY(dialog.isCommitted());
+
+ dialog.close();
+
+ QCOMPARE(undoneSpy.count(), 0);
+ QVERIFY2(dialog.result() != QDialog::Rejected,
+ "closing a committed dialog reported the send as cancelled");
+}
+
+void TestSendDialog::undoAfterCommitIsRefused()
+{
+ // Undo is disabled at commit, but a disabled button is a UI property, not
+ // an invariant. This asserts the handler's own guard, so a future change
+ // that re-enables the button cannot turn it back into a claim that nothing
+ // was sent while send_command is already running.
+ SendDialog dialog(100);
+ QSignalSpy committedSpy(&dialog, &SendDialog::committed);
+ QSignalSpy undoneSpy(&dialog, &SendDialog::undone);
+ dialog.show();
+
+ QVERIFY2(committedSpy.wait(3000), "the countdown never committed");
+
+ auto *undo = dialog.findChild<QPushButton *>(QStringLiteral("undoSend"));
+ QVERIFY(undo);
+
+ // Deliberately re-enabled, to reach the handler that the disabled state
+ // would otherwise hide. This is the mutation a future edit could make by
+ // accident; the guard behind it is what this test is for.
+ undo->setEnabled(true);
+ undo->click();
+
+ QVERIFY2(undoneSpy.count() == 0,
+ "Undo claimed nothing was sent after the send command started");
+}
+
+void TestSendDialog::everyStageSetsItsOwnLabelAndLeavesTheBarBusy()
+{
+ // Walks all four, because a break accidentally deleted from one case would
+ // fall through to the next and nothing else would notice. FilingSentCopy
+ // and RemovingDraft are also the two whose Italian strings drove the whole
+ // label-width design, so leaving them unexercised would test the sizing of
+ // strings nothing ever displays.
+ SendDialog dialog(2000);
+ dialog.show();
+
+ auto *status = dialog.findChild<QLabel *>(QStringLiteral("sendStatus"));
+ auto *indicator = dialog.findChild<BusyIndicator *>(
+ QStringLiteral("sendProgress"));
+ QVERIFY(status);
+ QVERIFY(indicator);
+
+ const QString countingDown = status->text();
+ QVERIFY2(!countingDown.isEmpty(), "the countdown showed no text");
+ QVERIFY(indicator->isDeterminate());
+
+ QStringList seen;
+ const QVector<SendDialog::Stage> stages{
+ SendDialog::Stage::Sending,
+ SendDialog::Stage::FilingSentCopy,
+ SendDialog::Stage::RemovingDraft,
+ };
+ for (SendDialog::Stage stage : stages) {
+ dialog.setStage(stage);
+ QVERIFY2(!status->text().isEmpty(), "a stage set no text at all");
+ QVERIFY2(!indicator->isDeterminate(),
+ "a post-countdown stage left the bar drawing a fraction");
+ seen << status->text();
+ }
+
+ // Distinct from each other and from the countdown: a fallthrough would
+ // show the following stage's text and collapse two of these into one.
+ seen << countingDown;
+ QCOMPARE(QSet<QString>(seen.begin(), seen.end()).size(), seen.size());
+}
+
+void TestSendDialog::windingBackToCountingDownAfterCommitIsRefused()
+{
+ // setStage() is public and Task 12 passes values from the public enum. The
+ // enum is documented "in order", so the class enforces that itself rather
+ // than trusting its caller: winding back would relabel a running send
+ // "Sending in 0..." and redraw a full countdown bar under it, offering a
+ // cancel that no longer exists.
+ SendDialog dialog(100);
+ QSignalSpy committedSpy(&dialog, &SendDialog::committed);
+ dialog.show();
+
+ QVERIFY2(committedSpy.wait(3000), "the countdown never committed");
+
+ auto *status = dialog.findChild<QLabel *>(QStringLiteral("sendStatus"));
+ auto *indicator = dialog.findChild<BusyIndicator *>(
+ QStringLiteral("sendProgress"));
+ const QString sending = status->text();
+
+ dialog.setStage(SendDialog::Stage::CountingDown);
+
+ QCOMPARE(status->text(), sending);
+ QVERIFY2(!indicator->isDeterminate(),
+ "the bar drew a countdown fraction over a running send");
+}
+
+QTEST_MAIN(TestSendDialog)
+#include "test_senddialog.moc"