diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-11 12:41:14 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-11 12:41:14 +0200 |
| commit | 44d62143a83af8acbd1c1d14653d39da37e5de4a (patch) | |
| tree | 360b8fae46487055fbd65bb1601f78345db7e27f /tests/test_config.cpp | |
| parent | 694ec02eb652fcfdbf65c27f68f4607f88615f76 (diff) | |
| download | qtmaildir-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/test_config.cpp')
| -rw-r--r-- | tests/test_config.cpp | 161 |
1 files changed, 161 insertions, 0 deletions
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" |
