/* * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs * Copyright (C) 2026 Danilo M. * * 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 #include #include #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 parsed = ComposeContextBuilder::parseAddressHeader( QStringLiteral("\"Rossi, Mario\" , 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 " 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 parsed = ComposeContextBuilder::parseAddressHeader( QStringLiteral("\"Rossi, Mario\" ")); QCOMPARE(parsed.size(), 1); QCOMPARE(parsed.at(0).rendered, QStringLiteral("\"Rossi, Mario\" ")); // And it still re-parses to the same one address. const QList 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 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(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 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 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 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 inName = ComposeContextBuilder::parseAddressHeader( QStringLiteral("\"foo\nBcc: evil@example.net\" ")); 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 "); 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 "); message.replyTo = QStringLiteral("List "); 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 "); 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 "); 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 "); 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 "); 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 "); message.to = QStringLiteral("Person One "); message.cc = QStringLiteral("P. One , 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 "); 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 "); message.to = QStringLiteral("Correspondent "); 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 "); 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 , Other "); 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 "); message.replyTo = QStringLiteral("List "); 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 "); message.to = QStringLiteral("\"about me@example.org\" "); 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(" "); 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(" "); 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() { // `,` 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>,,"); 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 "); 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"