diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-23 21:15:13 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-23 21:15:13 +0200 |
| commit | fabcf080652c6e5d57bf234be5e100769a9b965b (patch) | |
| tree | 0de4222c1e2aab58c38d34c9e0e3c37c68298cc8 /tests | |
| parent | c50bea78e036518ce1a2a3eb899bbb5e305affea (diff) | |
| parent | ddcae8d02ef46db522b3cf6c228196c7a66a6432 (diff) | |
| download | qtmaildir-fabcf080652c6e5d57bf234be5e100769a9b965b.tar.gz qtmaildir-fabcf080652c6e5d57bf234be5e100769a9b965b.zip | |
Merge branch 'compose-and-send': composing and sending mail
Item 123, built over 2026-08-20 to 2026-08-23 in thirteen tasks against
docs/superpowers/specs/2026-08-20-compose-and-send-design.md.
The application writes mail now. A composer window per message, markdown as
the body, drafts autosaving into the account's Maildir, and sending through a
per-account command on stdin rather than any network protocol of this
program's own. A countdown with an Undo stands between pressing Send and the
command running.
Two things came in alongside it. The notmuch auto-tagging hooks moved here
from the retiring `mailctl` project and learned that mail this application
files itself never arrived, so sent mail and drafts stop appearing in the
inbox. And the v1/v2 language is retired: semver on the user-visible surface
is the rule, and those labels described a split that composing made obsolete.
Hand tested against a fake send command rather than a real one, deliberately:
New, Reply and Forward all produce correct messages, a forwarded attachment
survives intact, and the sent copy is filed. That testing found the two
defects fixed on this branch, and both were invisible to the suite: a composer
orphaned by quitting the main window, and every sent message tagged `inbox`.
Twenty-two defects were found in the plan document's own draft code while
building it, which is why CLAUDE.md says to treat every code block in a plan
as a draft.
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/CMakeLists.txt | 28 | ||||
| -rw-r--r-- | tests/test_composecontext.cpp | 1051 | ||||
| -rw-r--r-- | tests/test_config.cpp | 196 | ||||
| -rw-r--r-- | tests/test_draftstore.cpp | 255 | ||||
| -rw-r--r-- | tests/test_formattoolbar.cpp | 346 | ||||
| -rw-r--r-- | tests/test_maildirname.cpp | 93 | ||||
| -rw-r--r-- | tests/test_mainwindow.cpp | 2342 | ||||
| -rw-r--r-- | tests/test_markdownrenderer.cpp | 151 | ||||
| -rw-r--r-- | tests/test_messagebuilder.cpp | 466 | ||||
| -rw-r--r-- | tests/test_messagesender.cpp | 532 | ||||
| -rw-r--r-- | tests/test_senddialog.cpp | 468 |
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" |
