aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-11 12:41:14 +0200
committerDanilo M. <danix@danix.xyz>2026-08-11 12:41:14 +0200
commit44d62143a83af8acbd1c1d14653d39da37e5de4a (patch)
tree360b8fae46487055fbd65bb1601f78345db7e27f /tests
parent694ec02eb652fcfdbf65c27f68f4607f88615f76 (diff)
downloadqtmaildir-44d62143a83af8acbd1c1d14653d39da37e5de4a.tar.gz
qtmaildir-44d62143a83af8acbd1c1d14653d39da37e5de4a.zip
feat(sent): add a Sent view, flat and by recipient
Adds a `sent` key to [account.*] naming that account's sent folder, and a Sent button beside the saved queries that composes its query from every account carrying one. An account without the key is omitted silently, as a real account may keep no sent mail locally. With no account selected the button spans all of them; selecting one narrows it through the existing scope wrap rather than a second path. Composed at run time rather than shipped as a [queries] entry. A saved query is one fixed string: it cannot narrow to the selected account, and it goes stale the moment an account is added or a provider renames a folder. The design and the measurements behind it are in docs/superpowers/specs/2026-08-11-sent-mail-design.md. Three things there are worth repeating here. The composed path is QUOTED, and that is load-bearing. A real provider nests its sent folder under a bracketed parent, and "[" and "]" are Xapian syntax: unquoted, the query parses rather than matches and returns nothing while looking entirely plausible. Composition happens in one place so there is one chance to get it right, and a bracketed path is pinned in a test. Recipients are opt-in per query, which is a performance contract rather than a preference. notmuch_message_get_header(m, "To") is not served from the index, it reads the message file: folding every thread of a 4411-thread inbox took 38.2 seconds against 251 ms for the 601-thread sent view. The worker skips the walk entirely unless asked, and the refresh path carries the same flag so a background sync cannot blank the column mid-read. Always folding is mutation-tested: the data would be right and only the cost wrong, which nothing else here would notice. The messages reached through the thread are owned by it and freed with it, so recipientsOf() holds them raw and finishes while the thread is alive, exactly as walkReplies does. An NmMessage wrapper there is a double-free. Sent mail is presented flat, and the pane follows. A message you sent otherwise drags in the replies you received, so a view labelled Sent shows conversations rather than what you sent. ThreadListModel::setFlatMode() makes hasChildren() and ReplyCountRole answer differently and changes nothing else; runQuery() sets it on EVERY run, so any other query restores the tree on its way through and the flag cannot outlive the button that set it. The pane needed its own fix for the same reason: the single-message path depends on a field only filled when a thread is expanded, which never happens in a flat list, so loadThread() gained matchedOnly and drops the messages that did not match instead of rendering them as stubs. Recipients replace the sender through the existing SendersRole rather than a new one, so the delegate needs no branch and cannot disagree with the model about which name a row shows. It falls back to the sender when a To header is absent or unparseable, since a blank where a name belongs reads as a rendering fault. Address parsing uses GMime: a display name may contain a comma, so "Rossi, Mario" <m@example.org>, info@example.net is two addresses and splitting reports three. internet_address_list_parse returns NULL for an empty string, which is a crash if unguarded. Backlog item 63.
Diffstat (limited to 'tests')
-rw-r--r--tests/notmuchfixture.h7
-rw-r--r--tests/test_config.cpp161
-rw-r--r--tests/test_mainwindow.cpp147
-rw-r--r--tests/test_mimeparser.cpp76
-rw-r--r--tests/test_notmuchworker.cpp164
-rw-r--r--tests/test_threadlistmodel.cpp104
6 files changed, 644 insertions, 15 deletions
diff --git a/tests/notmuchfixture.h b/tests/notmuchfixture.h
index 4d2e59b..bb25526 100644
--- a/tests/notmuchfixture.h
+++ b/tests/notmuchfixture.h
@@ -47,10 +47,13 @@ public:
/// Writes one message into <folder>/cur (or new/ when unread).
///
/// Returns false if the file could not be written. Call index() afterwards.
+ /// `to` defaults to a single generic recipient. Pass one explicitly to
+ /// exercise the recipient summary, which is the only thing that reads it.
bool addMessage(const QString &folder, const QString &messageId,
const QString &subject, const QString &from,
const QString &date, const QString &body,
- bool unread = true, const QString &inReplyTo = QString())
+ bool unread = true, const QString &inReplyTo = QString(),
+ const QString &to = QStringLiteral("you@example.org"))
{
// Unread messages must not carry the maildir "S" flag, so they go to
// new/ where no flags exist at all.
@@ -77,7 +80,7 @@ public:
QTextStream out(&file);
out << "From: " << from << "\n"
- << "To: you@example.org\n"
+ << "To: " << to << "\n"
<< "Subject: " << subject << "\n"
<< "Message-ID: <" << messageId << ">\n"
<< "Date: " << date << "\n";
diff --git a/tests/test_config.cpp b/tests/test_config.cpp
index b9321e0..60ed071 100644
--- a/tests/test_config.cpp
+++ b/tests/test_config.cpp
@@ -64,6 +64,13 @@ private slots:
void malformedExtraMimetypeIsSkipped();
void syncChannelDefaultsToTheAccountKey();
void syncChannelIsActuallyRead();
+ void sentQueryIsEmptyWithoutTheKey();
+ void sentQueryComposesThePath();
+ void sentQuerySurvivesABracketedPath();
+ void sentQueryComposesWithScopedQuery();
+ void allSentQueryIsEmptyWhenNoAccountHasOne();
+ void allSentQuerySkipsAccountsWithoutTheKey();
+ void allSentQueryJoinsEveryConfiguredAccount();
};
static QString writeIni(const QTemporaryDir &dir, const QString &body)
@@ -755,5 +762,159 @@ void TestConfig::syncChannelIsActuallyRead()
QStringLiteral("mail-firstlast"));
}
+void TestConfig::sentQueryIsEmptyWithoutTheKey()
+{
+ // Optional exactly as drafts is. A real account can legitimately have no
+ // sent folder at all, and the Sent view omits it silently rather than
+ // reporting a config problem on every launch about nothing.
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral(
+ "[account.provider-c]\n"
+ "maildir = provider-c\n")));
+
+ QCOMPARE(config.accounts().size(), 1);
+ QVERIFY(config.accounts().at(0).sentQuery().isEmpty());
+ QVERIFY(config.problems().isEmpty());
+}
+
+void TestConfig::sentQueryComposesThePath()
+{
+ // Relative to maildir, the same way the account's own scope is, so the two
+ // cannot disagree about where the account lives.
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral(
+ "[account.webmail-primary]\n"
+ "maildir = webmail-primary\n"
+ "sent = Sent\n")));
+
+ QCOMPARE(config.accounts().at(0).sentQuery(),
+ QStringLiteral("path:\"webmail-primary/Sent/**\""));
+}
+
+void TestConfig::sentQuerySurvivesABracketedPath()
+{
+ // The load-bearing case, and the reason this is a config key rather than a
+ // <maildir>/Sent convention. A real provider nests its sent folder under a
+ // BRACKETED parent and localises the name: "[Provider]/Posta inviata".
+ //
+ // "[" and "]" are Xapian syntax. The quotes around the whole path are what
+ // make the query work at all, and an implementation that built this without
+ // them returns nothing while looking entirely plausible.
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral(
+ "[account.provider-a]\n"
+ "maildir = provider-a\n"
+ "sent = [Provider]/Posta inviata\n")));
+
+ const QString query = config.accounts().at(0).sentQuery();
+ QCOMPARE(query,
+ QStringLiteral("path:\"provider-a/[Provider]/Posta inviata/**\""));
+
+ // Stated separately from the QCOMPARE above: the quoting is the property
+ // that matters, and a later change to the surrounding syntax must not be
+ // able to drop it while still matching a rewritten expected string.
+ QVERIFY2(query.contains(QStringLiteral("\"provider-a/[Provider]")),
+ "the composed path is not quoted, so Xapian will read the "
+ "brackets as syntax and the query will match nothing");
+}
+
+void TestConfig::sentQueryComposesWithScopedQuery()
+{
+ // A Sent view under one account must not show another account's sent mail.
+ // The account selector wraps whatever query runs, so the composed sent
+ // query has to survive being scoped rather than bypassing it.
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral(
+ "[account.webmail-primary]\n"
+ "maildir = webmail-primary\n"
+ "sent = Sent\n")));
+
+ const Account account = config.accounts().at(0);
+ const QString scoped = account.scopedQuery(account.sentQuery());
+
+ QCOMPARE(scoped,
+ QStringLiteral("path:\"webmail-primary/**\" and "
+ "(path:\"webmail-primary/Sent/**\")"));
+}
+
+void TestConfig::allSentQueryIsEmptyWhenNoAccountHasOne()
+{
+ // Empty rather than a query matching nothing, so the caller can hide the
+ // Sent button entirely instead of offering one that finds no mail.
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral(
+ "[account.provider-c]\n"
+ "maildir = provider-c\n")));
+
+ QVERIFY(config.allSentQuery().isEmpty());
+}
+
+void TestConfig::allSentQuerySkipsAccountsWithoutTheKey()
+{
+ // Joining an account with no `sent` key would leave a bare "or" in the
+ // query, and notmuch does not reject that: it silently returns a DIFFERENT
+ // result. Measured directly against a real database, `A or or B` returns
+ // 190 where the correct pair returns 211.
+ //
+ // A malformed query that still returns plausible mail is the failure that
+ // ships, so this asserts the shape of the string rather than a count.
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral(
+ "[account.webmail-primary]\n"
+ "maildir = webmail-primary\n"
+ "sent = Sent\n"
+ "\n"
+ "[account.provider-c]\n"
+ "maildir = provider-c\n"
+ "\n"
+ "[account.webmail-secondary]\n"
+ "maildir = webmail-secondary\n"
+ "sent = Sent\n")));
+
+ const QString all = config.allSentQuery();
+
+ QVERIFY2(!all.contains(QStringLiteral("or or")),
+ "an account without a sent key left a bare 'or' in the query");
+ QVERIFY2(!all.trimmed().endsWith(QStringLiteral("or")),
+ "the query ends in a dangling 'or'");
+ QVERIFY2(!all.trimmed().startsWith(QStringLiteral("or")),
+ "the query starts with a dangling 'or'");
+ QVERIFY(!all.contains(QStringLiteral("provider-c")));
+
+ // Exactly two terms joined, one per account that configures the key.
+ QCOMPARE(all.count(QStringLiteral("path:")), 2);
+ QCOMPARE(all.count(QStringLiteral(" or ")), 1);
+}
+
+void TestConfig::allSentQueryJoinsEveryConfiguredAccount()
+{
+ // Including a bracketed provider path, which is the case the quoting
+ // exists for and the one most likely to be broken by a later rewrite of
+ // this composition.
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral(
+ "[account.webmail-primary]\n"
+ "maildir = webmail-primary\n"
+ "sent = Sent\n"
+ "\n"
+ "[account.provider-a]\n"
+ "maildir = provider-a\n"
+ "sent = [Provider]/Posta inviata\n")));
+
+ const QString all = config.allSentQuery();
+
+ QVERIFY(all.contains(QStringLiteral("path:\"webmail-primary/Sent/**\"")));
+ QVERIFY(all.contains(
+ QStringLiteral("path:\"provider-a/[Provider]/Posta inviata/**\"")));
+ QCOMPARE(all.count(QStringLiteral(" or ")), 1);
+}
+
QTEST_MAIN(TestConfig)
#include "test_config.moc"
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index 504c10c..faa8481 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -170,6 +170,10 @@ private slots:
void theImportantActionIsLabelledImportant();
void theImportantActionStillWritesTheFlaggedTag();
void theToolbarUsesTheConfiguredIconSize();
+ void thereIsNoSentButtonWithoutASentKey();
+ void theSentButtonRunsEveryConfiguredAccount();
+ void theSentButtonSurvivesABracketedPath();
+ void flatModeDoesNotSurviveTheNextQuery();
void noTwoActionsShareAnIcon();
};
@@ -4540,6 +4544,149 @@ void TestMainWindow::theToolbarUsesTheConfiguredIconSize()
QCOMPARE(toolBar->iconSize(), QSize(40, 40));
}
+namespace {
+
+/// A config whose accounts carry the given maildir/sent pairs. An empty `sent`
+/// writes no key at all, which is the account-without-a-sent-folder case.
+QString writeSentConfig(const QTemporaryDir &dir,
+ const QList<QPair<QString, QString>> &accounts)
+{
+ const QString path = dir.filePath(QStringLiteral("qtmaildir.conf"));
+ QSettings s(path, QSettings::IniFormat);
+ for (const auto &account : accounts) {
+ s.beginGroup(QStringLiteral("account.") + account.first);
+ s.setValue(QStringLiteral("maildir"), account.first);
+ if (!account.second.isEmpty())
+ s.setValue(QStringLiteral("sent"), account.second);
+ s.endGroup();
+ }
+ s.sync();
+ return path;
+}
+
+} // namespace
+
+void TestMainWindow::thereIsNoSentButtonWithoutASentKey()
+{
+ // Hidden entirely rather than present and finding nothing. An account may
+ // legitimately keep no sent mail locally, and a button that always returns
+ // an empty list reads as a broken feature rather than an absent one.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ config.load(writeSentConfig(dir, {{QStringLiteral("provider-c"), {}}}));
+ QVERIFY(config.allSentQuery().isEmpty());
+
+ MainWindow window(config);
+ QVERIFY(!window.findChild<QPushButton *>(QStringLiteral("sentButton")));
+}
+
+void TestMainWindow::theSentButtonRunsEveryConfiguredAccount()
+{
+ // The button composes its query rather than storing one, which is the whole
+ // reason it is not a [queries] entry: a saved query is a fixed string and
+ // would not gain the third account here without the user editing it.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ config.load(writeSentConfig(dir, {
+ {QStringLiteral("webmail-primary"), QStringLiteral("Sent")},
+ {QStringLiteral("provider-c"), {}},
+ {QStringLiteral("webmail-secondary"), QStringLiteral("Sent")},
+ }));
+
+ MainWindow window(config);
+ auto *button = window.findChild<QPushButton *>(QStringLiteral("sentButton"));
+ QVERIFY(button);
+
+ auto *queryEdit = window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ QVERIFY(queryEdit);
+
+ button->click();
+ const QString query = queryEdit->text();
+
+ QVERIFY(query.contains(QStringLiteral("webmail-primary/Sent")));
+ QVERIFY(query.contains(QStringLiteral("webmail-secondary/Sent")));
+
+ // The account with no key contributes nothing, and leaves no bare "or"
+ // behind: notmuch accepts that and silently returns a different result.
+ QVERIFY(!query.contains(QStringLiteral("provider-c")));
+ QVERIFY(!query.contains(QStringLiteral("or or")));
+ QCOMPARE(query.count(QStringLiteral(" or ")), 1);
+}
+
+void TestMainWindow::theSentButtonSurvivesABracketedPath()
+{
+ // A real provider nests its sent folder under a bracketed parent, and "["
+ // and "]" are Xapian syntax. The quoting has to survive the trip from the
+ // config through Account::sentQuery() into the query bar; unquoted, the
+ // query looks plausible and matches nothing.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ config.load(writeSentConfig(dir, {
+ {QStringLiteral("provider-a"), QStringLiteral("[Provider]/Posta inviata")},
+ }));
+
+ MainWindow window(config);
+ auto *button = window.findChild<QPushButton *>(QStringLiteral("sentButton"));
+ QVERIFY(button);
+ auto *queryEdit = window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ QVERIFY(queryEdit);
+
+ button->click();
+ QCOMPARE(queryEdit->text(),
+ QStringLiteral("path:\"provider-a/[Provider]/Posta inviata/**\""));
+}
+
+void TestMainWindow::flatModeDoesNotSurviveTheNextQuery()
+{
+ // The condition the user set for this feature: a flat Sent list is fine, a
+ // flat anything-else is not. Asserted at the window rather than the model,
+ // because the leak this guards against is in the WIRING, not in the model:
+ // setFlatMode(true) from the button with no matching false anywhere else
+ // passes every model test and flattens the app from the first Sent click
+ // until it restarts.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ config.load(writeSentConfig(dir, {
+ {QStringLiteral("webmail-primary"), QStringLiteral("Sent")},
+ }));
+
+ MainWindow window(config);
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *button = window.findChild<QPushButton *>(QStringLiteral("sentButton"));
+ QVERIFY(button);
+ auto *queryEdit = window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ QVERIFY(queryEdit);
+
+ QVERIFY2(!model->flatMode(), "the model starts flat");
+
+ button->click();
+ QVERIFY2(model->flatMode(), "the Sent button did not flatten the list");
+
+ // Any other query restores the tree. Typed by hand rather than through a
+ // saved-query button, since that is the route with no flag of its own and
+ // therefore the one most likely to be forgotten.
+ queryEdit->setText(QStringLiteral("tag:inbox"));
+ QMetaObject::invokeMethod(&window, "runCurrentQuery");
+ QVERIFY2(!model->flatMode(),
+ "flat mode survived into an ordinary query, so every view after "
+ "one Sent click lost its replies");
+
+ // And back, so the button still works after the round trip.
+ button->click();
+ QVERIFY(model->flatMode());
+
+ // Even the SAME query typed by hand comes back as a tree: the flag follows
+ // the button, not the text, which is the rule the user chose.
+ queryEdit->setText(config.allSentQuery());
+ QMetaObject::invokeMethod(&window, "runCurrentQuery");
+ QVERIFY(!model->flatMode());
+}
+
void TestMainWindow::noTwoActionsShareAnIcon()
{
// Reported by the user against the icons shipped in 0.12.0: Archive and
diff --git a/tests/test_mimeparser.cpp b/tests/test_mimeparser.cpp
index d16735f..bac71f6 100644
--- a/tests/test_mimeparser.cpp
+++ b/tests/test_mimeparser.cpp
@@ -41,6 +41,10 @@ private slots:
void safeFilenameStripsPathComponents();
void pathInsideDirectoryRejectsSiblingPrefix();
void attachmentFolderNameIsASinglePlainComponent();
+ void recipientSummaryPrefersDisplayNames();
+ void recipientSummaryKeepsACommaInsideADisplayName();
+ void recipientSummaryCollapsesTheOverflow();
+ void recipientSummarySurvivesUnusableInput();
void folderNameSurvivesATimezoneComment();
void savingABatchNeverOverwrites();
@@ -409,5 +413,77 @@ void TestMimeParser::savingABatchNeverOverwrites()
qPrintable(second_tar));
}
+void TestMimeParser::recipientSummaryPrefersDisplayNames()
+{
+ // A display name where there is one, the address where there is not, so a
+ // list does not mix "Mario Rossi" with a bare address for no reason the
+ // reader can see.
+ QCOMPARE(recipientSummary(
+ QStringLiteral("Mario Rossi <mario@example.org>")),
+ QStringLiteral("Mario Rossi"));
+
+ QCOMPARE(recipientSummary(QStringLiteral("info@example.net")),
+ QStringLiteral("info@example.net"));
+
+ QCOMPARE(recipientSummary(QStringLiteral(
+ "Mario Rossi <mario@example.org>, info@example.net")),
+ QStringLiteral("Mario Rossi, info@example.net"));
+}
+
+void TestMimeParser::recipientSummaryKeepsACommaInsideADisplayName()
+{
+ // The reason this uses GMime rather than QString::split(','). A quoted
+ // display name may CONTAIN a comma, and splitting reports three recipients
+ // where there are two, with "Mario" alone as one of them.
+ const QString summary = recipientSummary(QStringLiteral(
+ "\"Rossi, Mario\" <mario@example.org>, info@example.net"));
+
+ QCOMPARE(summary, QStringLiteral("Rossi, Mario, info@example.net"));
+
+ // Stated separately, because the QCOMPARE above would also pass a naive
+ // implementation that happened to rejoin the pieces in the same order.
+ QVERIFY2(!summary.contains(QStringLiteral("+")),
+ "a comma inside a display name was counted as another recipient");
+}
+
+void TestMimeParser::recipientSummaryCollapsesTheOverflow()
+{
+ // "+N" rather than eliding mid-name, mirroring the tag strip's overflow
+ // chip: a card has one line for this and a truncated name is worse than an
+ // honest count.
+ QCOMPARE(recipientSummary(QStringLiteral(
+ "a@example.org, b@example.org, c@example.org, "
+ "d@example.org")),
+ QStringLiteral("a@example.org, b@example.org +2"));
+
+ // Exactly at the limit does not collapse: a "+0" would be absurd.
+ QCOMPARE(recipientSummary(
+ QStringLiteral("a@example.org, b@example.org")),
+ QStringLiteral("a@example.org, b@example.org"));
+}
+
+void TestMimeParser::recipientSummarySurvivesUnusableInput()
+{
+ // The header is untrusted and every one of these is real mail.
+ //
+ // The empty string is the one that crashes if unguarded:
+ // internet_address_list_parse returns NULL for it rather than an empty
+ // list, verified against GMime directly.
+ QVERIFY(recipientSummary(QString()).isEmpty());
+ QVERIFY(recipientSummary(QStringLiteral("")).isEmpty());
+ QVERIFY(recipientSummary(QStringLiteral(" ")).isEmpty());
+
+ // Not a crash and not a lie: a group with no members has no names to show.
+ const QString group =
+ recipientSummary(QStringLiteral("undisclosed-recipients:;"));
+ QVERIFY2(!group.contains(QStringLiteral("@")),
+ qPrintable(QStringLiteral("a memberless group produced an "
+ "address: %1").arg(group)));
+
+ // Garbage parses to something or to nothing, but never to a crash.
+ recipientSummary(QStringLiteral("<<<>>>"));
+ recipientSummary(QStringLiteral("\"unterminated <a@example.org>"));
+}
+
QTEST_MAIN(TestMimeParser)
#include "test_mimeparser.moc"
diff --git a/tests/test_notmuchworker.cpp b/tests/test_notmuchworker.cpp
index 88dcf0b..fe0247c 100644
--- a/tests/test_notmuchworker.cpp
+++ b/tests/test_notmuchworker.cpp
@@ -65,6 +65,13 @@ private slots:
void loadThreadTreeReportsReplyDepth();
void loadThreadTreeCarriesTheFactsARowNeeds();
+ void loadThreadMatchedOnlyDropsTheRest();
+ void loadThreadMatchedOnlyWithNoQueryKeepsEverything();
+
+ void recipientsAreAbsentUnlessAskedFor();
+ void recipientsAreFoldedWhenAskedFor();
+ void recipientsCrossAQueuedCall();
+
void requestCountsAnswersOneCountPerQuery();
void requestCountsKeepsPositionOnAnInvalidQuery();
void requestDatabaseStatsCountsMessagesNotThreads();
@@ -74,10 +81,12 @@ private:
/// Tags of one message, read back through a fresh worker query.
QStringList tagsOf(const QString &messageId);
QVector<MessageRef> messagesOfThread(const QString &threadId,
- const QString &matchQuery = QString());
+ const QString &matchQuery = QString(),
+ bool matchedOnly = false);
QVector<ThreadSummary> runQuery(
const QString &query,
- NotmuchWorker::SortOrder sort = NotmuchWorker::NewestFirst);
+ NotmuchWorker::SortOrder sort = NotmuchWorker::NewestFirst,
+ bool withRecipients = false);
QString threadIdOf(const QString &subject);
NotmuchFixture m_fixture;
@@ -114,17 +123,38 @@ void TestNotmuchWorker::initTestCase()
QStringLiteral("Thu, 4 Jun 2026 10:00:00 +0000"),
QStringLiteral("fourth message"), false));
+ // Thread D: in a "sent" folder, with real recipients. The To header is the
+ // only thing that distinguishes these from the threads above, and it is
+ // what the recipient fold reads.
+ QVERIFY(m_fixture.addMessage(QStringLiteral("sent"), QStringLiteral("d1@example.org"),
+ QStringLiteral("Preventivo"),
+ QStringLiteral("You <you@example.org>"),
+ QStringLiteral("Fri, 5 Jun 2026 10:00:00 +0000"),
+ QStringLiteral("fifth message"), false, QString(),
+ QStringLiteral("Mario Rossi <mario@example.org>")));
+
+ // Thread E: several recipients, one of them with a comma inside a quoted
+ // display name, which is what defeats splitting on commas.
+ QVERIFY(m_fixture.addMessage(QStringLiteral("sent"), QStringLiteral("e1@example.org"),
+ QStringLiteral("Riunione"),
+ QStringLiteral("You <you@example.org>"),
+ QStringLiteral("Sat, 6 Jun 2026 10:00:00 +0000"),
+ QStringLiteral("sixth message"), false, QString(),
+ QStringLiteral("\"Rossi, Mario\" <mario@example.org>, "
+ "info@example.net, "
+ "third@example.org")));
+
QVERIFY2(m_fixture.index(), qPrintable(m_fixture.error()));
}
QVector<ThreadSummary> TestNotmuchWorker::runQuery(
- const QString &query, NotmuchWorker::SortOrder sort)
+ const QString &query, NotmuchWorker::SortOrder sort, bool withRecipients)
{
NotmuchWorker worker(m_fixture.configPath());
QSignalSpy ready(&worker, &NotmuchWorker::threadsReady);
QSignalSpy finished(&worker, &NotmuchWorker::queryFinished);
- worker.runQuery(query, 1, sort);
+ worker.runQuery(query, 1, sort, withRecipients);
QVector<ThreadSummary> all;
for (const QList<QVariant> &args : ready)
@@ -143,11 +173,12 @@ QString TestNotmuchWorker::threadIdOf(const QString &subject)
}
QVector<MessageRef> TestNotmuchWorker::messagesOfThread(const QString &threadId,
- const QString &matchQuery)
+ const QString &matchQuery,
+ bool matchedOnly)
{
NotmuchWorker worker(m_fixture.configPath());
QSignalSpy loaded(&worker, &NotmuchWorker::threadLoaded);
- worker.loadThread(threadId, matchQuery, 1);
+ worker.loadThread(threadId, matchQuery, 1, matchedOnly);
if (loaded.isEmpty())
return {};
return loaded.first().at(0).value<QVector<MessageRef>>();
@@ -256,7 +287,7 @@ void TestNotmuchWorker::loadThreadTreeCarriesTheFactsARowNeeds()
void TestNotmuchWorker::queryReturnsAllThreads()
{
const QVector<ThreadSummary> threads = runQuery(QStringLiteral("*"));
- QCOMPARE(threads.size(), 3);
+ QCOMPARE(threads.size(), 5);
}
void TestNotmuchWorker::queryFiltersByTag()
@@ -325,7 +356,7 @@ void TestNotmuchWorker::queryPassesGenerationThrough()
QCOMPARE(ready.size(), 1);
QCOMPARE(ready.first().at(1).value<quint64>(), quint64(42));
QCOMPARE(finished.size(), 1);
- QCOMPARE(finished.first().at(0).toInt(), 3);
+ QCOMPARE(finished.first().at(0).toInt(), 5);
QCOMPARE(finished.first().at(1).value<quint64>(), quint64(42));
}
@@ -513,7 +544,7 @@ void TestNotmuchWorker::queryStillWorksAfterWrite()
worker.runQuery(QStringLiteral("*"), 2);
QCOMPARE(ready.size(), 2);
- QCOMPARE(ready.at(1).at(0).value<QVector<ThreadSummary>>().size(), 3);
+ QCOMPARE(ready.at(1).at(0).value<QVector<ThreadSummary>>().size(), 5);
worker.applyTags(change.inverted());
}
@@ -611,6 +642,113 @@ void TestNotmuchWorker::requestAllTagsOnUnreadableConfigEmitsError()
QVERIFY(ready.isEmpty());
}
+void TestNotmuchWorker::loadThreadMatchedOnlyDropsTheRest()
+{
+ // Thread A is two messages, and only the reply carries "hamsterwheel".
+ const QString threadId = threadIdOf(QStringLiteral("Release notes"));
+ QVERIFY(!threadId.isEmpty());
+
+ // Without the flag: both messages, the non-matching one marked as a stub.
+ // This is the reading pane's normal behaviour and must not change.
+ const QVector<MessageRef> whole =
+ messagesOfThread(threadId, QStringLiteral("hamsterwheel"));
+ QCOMPARE(whole.size(), 2);
+
+ // With it: only the message that matched. The pane in a Sent view shows
+ // what the user sent, not the conversation their message started.
+ const QVector<MessageRef> matched =
+ messagesOfThread(threadId, QStringLiteral("hamsterwheel"), true);
+ QCOMPARE(matched.size(), 1);
+ QVERIFY(matched.at(0).matched);
+ QCOMPARE(matched.at(0).messageId, QStringLiteral("a2@example.org"));
+}
+
+void TestNotmuchWorker::loadThreadMatchedOnlyWithNoQueryKeepsEverything()
+{
+ // No query means nothing was filtered, so every message counts as matched
+ // and the flag has nothing to drop.
+ //
+ // This does NOT prove the haveMatchSet guard in loadThread: ref.matched is
+ // already true for every message in this case, so removing that guard
+ // leaves this passing, confirmed by mutation. It pins the BEHAVIOUR, which
+ // is what a caller depends on, and the guard is a stated invariant rather
+ // than a branch a test can reach.
+ const QString threadId = threadIdOf(QStringLiteral("Release notes"));
+ QVERIFY(!threadId.isEmpty());
+
+ const QVector<MessageRef> all = messagesOfThread(threadId, QString(), true);
+ QCOMPARE(all.size(), 2);
+}
+
+void TestNotmuchWorker::recipientsAreAbsentUnlessAskedFor()
+{
+ // Opt-in, and this is a PERFORMANCE contract rather than a preference.
+ // notmuch_message_get_header(m, "To") is not served from the index, it
+ // reads the message file: measured 2026-08-11 against a real database,
+ // folding every thread of a 4411-thread inbox took 38.2 seconds, 8.7 ms
+ // per thread, against 1.1 ms per thread over the 601-thread sent view.
+ //
+ // A version that always folds is correct in every other respect, which is
+ // exactly why it needs a test: nothing else here would notice.
+ const QVector<ThreadSummary> threads =
+ runQuery(QStringLiteral("subject:Preventivo"));
+
+ QCOMPARE(threads.size(), 1);
+ QVERIFY2(threads.at(0).recipients.isEmpty(),
+ "the To header was read for a query that never asked for it");
+}
+
+void TestNotmuchWorker::recipientsAreFoldedWhenAskedFor()
+{
+ const QVector<ThreadSummary> one =
+ runQuery(QStringLiteral("subject:Preventivo"),
+ NotmuchWorker::NewestFirst, true);
+ QCOMPARE(one.size(), 1);
+ QCOMPARE(one.at(0).recipients, QStringLiteral("Mario Rossi"));
+
+ // The comma-inside-a-display-name case, end to end through the worker
+ // rather than only against recipientSummary(): the header survives being
+ // written to a real maildir, indexed, and read back out of notmuch.
+ const QVector<ThreadSummary> many =
+ runQuery(QStringLiteral("subject:Riunione"),
+ NotmuchWorker::NewestFirst, true);
+ QCOMPARE(many.size(), 1);
+
+ const QString summary = many.at(0).recipients;
+ QVERIFY2(summary.startsWith(QStringLiteral("Rossi, Mario")),
+ qPrintable(QStringLiteral("lost the quoted display name: %1")
+ .arg(summary)));
+ QVERIFY2(summary.endsWith(QStringLiteral("+1")),
+ qPrintable(QStringLiteral("three recipients did not collapse to "
+ "two plus one: %1").arg(summary)));
+}
+
+void TestNotmuchWorker::recipientsCrossAQueuedCall()
+{
+ // The trap CLAUDE.md records for SortOrder, in the shape it takes for this
+ // argument. A bool is a registered metatype already, so this cannot fail
+ // the way an unregistered enum would, and the test exists to prove that
+ // rather than to assume it: the flag arriving as a default-constructed
+ // false would silently give an empty recipients column and nothing else.
+ NotmuchWorker worker(m_fixture.configPath());
+ QSignalSpy ready(&worker, &NotmuchWorker::threadsReady);
+
+ QVERIFY(QMetaObject::invokeMethod(
+ &worker, "runQuery", Qt::DirectConnection,
+ Q_ARG(QString, QStringLiteral("subject:Preventivo")),
+ Q_ARG(quint64, 1),
+ Q_ARG(NotmuchWorker::SortOrder, NotmuchWorker::NewestFirst),
+ Q_ARG(bool, true)));
+
+ QVector<ThreadSummary> all;
+ for (const QList<QVariant> &args : ready)
+ all += args.at(0).value<QVector<ThreadSummary>>();
+
+ QCOMPARE(all.size(), 1);
+ QVERIFY2(!all.at(0).recipients.isEmpty(),
+ "the recipients flag was dropped crossing invokeMethod");
+}
+
void TestNotmuchWorker::requestCountsAnswersOneCountPerQuery()
{
NotmuchWorker worker(m_fixture.configPath());
@@ -626,7 +764,7 @@ void TestNotmuchWorker::requestCountsAnswersOneCountPerQuery()
// Threads, not messages: thread A holds two messages and must count once,
// which is the number the pane's "N in inbox" line claims to be showing.
const QVector<int> counts = spy.at(0).at(0).value<QVector<int>>();
- QCOMPARE(counts, QVector<int>({ 1, 3, 0 }));
+ QCOMPARE(counts, QVector<int>({ 1, 5, 0 }));
}
void TestNotmuchWorker::requestCountsKeepsPositionOnAnInvalidQuery()
@@ -656,7 +794,7 @@ void TestNotmuchWorker::requestCountsKeepsPositionOnAnInvalidQuery()
// The queries either side keep their own answers, which is the property
// the pane depends on.
QCOMPARE(counts.at(0), 1);
- QCOMPARE(counts.at(2), 3);
+ QCOMPARE(counts.at(2), 5);
}
void TestNotmuchWorker::requestDatabaseStatsCountsMessagesNotThreads()
@@ -676,8 +814,8 @@ void TestNotmuchWorker::requestDatabaseStatsCountsMessagesNotThreads()
// one counts messages, which is what a user means by "how much mail". A
// reimplementation that reused the thread count would report 3 here and be
// confidently wrong under the label "messages".
- QCOMPARE(stats.messages, 4);
- QCOMPARE(stats.threads, 3);
+ QCOMPARE(stats.messages, 6);
+ QCOMPARE(stats.threads, 5);
QVERIFY2(stats.messages != stats.threads,
"messages and threads are equal, so this fixture cannot prove the "
"two counts are distinct: add a reply to it");
diff --git a/tests/test_threadlistmodel.cpp b/tests/test_threadlistmodel.cpp
index 947a6b3..1fb8a1f 100644
--- a/tests/test_threadlistmodel.cpp
+++ b/tests/test_threadlistmodel.cpp
@@ -81,6 +81,9 @@ private slots:
void reconcileWithAnIdenticalResultChangesNothing();
void reconcileMovesAThreadBumpedByANewReply();
void reconcileKeepsAMovedRowsPersistentIndex();
+ void flatModeOffersNoExpanderAndNoReplyCount();
+ void flatModeIsOffByDefaultAndReversible();
+ void recipientsReplaceTheSenderWhenPresent();
};
static ThreadSummary makeThread(const QString &id, const QString &subject)
@@ -1420,5 +1423,106 @@ void TestThreadListModel::reconcileKeepsAMovedRowsPersistentIndex()
QCOMPARE(moved.row(), 2);
}
+void TestThreadListModel::flatModeOffersNoExpanderAndNoReplyCount()
+{
+ // A sent message lives on its own: the user's mental model of "what I sent"
+ // is a list, not a set of conversations, and a thread pulled in whole shows
+ // the replies they received under a view that claims to be their outbox.
+ //
+ // Deliberately not a second model or a filtered query. The expander is
+ // driven by hasChildren() and the card's count by ReplyCountRole, both
+ // already here, so flat mode is those two answering differently.
+ ThreadListModel model;
+ model.appendBatch({ makeThread(QStringLiteral("t1"),
+ QStringLiteral("Subject")) });
+
+ const QModelIndex thread = model.index(0, 0);
+ QVERIFY(thread.isValid());
+
+ // The tree shape, before anything is turned off. Guards the assertions
+ // below: a test whose subject was already flat would pass either way.
+ QVERIFY2(model.hasChildren(thread),
+ "the fixture thread is not expandable, so this proves nothing");
+ QCOMPARE(model.data(thread, ThreadListModel::ReplyCountRole).toInt(), 1);
+
+ model.setFlatMode(true);
+
+ QVERIFY2(!model.hasChildren(thread),
+ "a flat list still offered an expander");
+ QCOMPARE(model.data(thread, ThreadListModel::ReplyCountRole).toInt(), 0);
+
+ // rowCount has to agree, or the view draws an expander it cannot open, or
+ // opens onto rows the card said were not there.
+ QCOMPARE(model.rowCount(thread), 0);
+
+ // The thread itself is still a row. Flat means one row per thread, not
+ // fewer threads.
+ QCOMPARE(model.rowCount(), 1);
+}
+
+void TestThreadListModel::flatModeIsOffByDefaultAndReversible()
+{
+ // The whole condition the user set for this feature: it must not leak into
+ // any other view. Off by default is what guarantees that, and returning to
+ // false has to restore the tree rather than leaving the model flattened
+ // for the next query.
+ ThreadListModel model;
+ model.appendBatch({ makeThread(QStringLiteral("t1"),
+ QStringLiteral("Subject")) });
+
+ const QModelIndex thread = model.index(0, 0);
+ QVERIFY2(model.hasChildren(thread),
+ "a fresh model is flat, so every ordinary view lost its replies");
+
+ model.setFlatMode(true);
+ QVERIFY(!model.hasChildren(thread));
+
+ model.setFlatMode(false);
+ QVERIFY2(model.hasChildren(thread),
+ "leaving flat mode did not restore the tree");
+ QCOMPARE(model.data(thread, ThreadListModel::ReplyCountRole).toInt(), 1);
+}
+
+void TestThreadListModel::recipientsReplaceTheSenderWhenPresent()
+{
+ // In a Sent view the sender is the user on every row, so the card shows
+ // who it went TO instead. One role, so the delegate needs no branch and
+ // cannot disagree with the model about which name a row is showing.
+ ThreadListModel model;
+
+ ThreadSummary sent = makeThread(QStringLiteral("t1"),
+ QStringLiteral("Preventivo"));
+ sent.authors = QStringLiteral("You");
+ sent.recipients = QStringLiteral("Mario Rossi");
+
+ // No recipients: an ordinary view, where authors is the answer. Same
+ // fixture otherwise, so the difference is the field and nothing else.
+ ThreadSummary received = makeThread(QStringLiteral("t2"),
+ QStringLiteral("Newsletter"));
+ received.authors = QStringLiteral("Carol");
+
+ model.appendBatch({ sent, received });
+
+ QCOMPARE(model.data(model.index(0, 0),
+ ThreadListModel::SendersRole).toString(),
+ QStringLiteral("Mario Rossi"));
+ QCOMPARE(model.data(model.index(1, 0),
+ ThreadListModel::SendersRole).toString(),
+ QStringLiteral("Carol"));
+
+ // A thread whose To could not be parsed falls back rather than showing an
+ // empty name. The fold returns an empty string for a malformed or absent
+ // header, and a blank where a sender belongs reads as a rendering fault.
+ ThreadSummary unparseable = makeThread(QStringLiteral("t3"),
+ QStringLiteral("Broken"));
+ unparseable.authors = QStringLiteral("You");
+ unparseable.recipients = QString();
+ model.appendBatch({ unparseable });
+
+ QCOMPARE(model.data(model.index(2, 0),
+ ThreadListModel::SendersRole).toString(),
+ QStringLiteral("You"));
+}
+
QTEST_MAIN(TestThreadListModel)
#include "test_threadlistmodel.moc"