diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-13 19:16:33 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-13 19:16:33 +0200 |
| commit | 2623b762a90b04c57491a5d58ab72c8807c65c80 (patch) | |
| tree | 1f968dec0965300173aec0216d76330d870f6513 | |
| parent | b79b5e30efcd8de95fa42d84c6ed319a0347941c (diff) | |
| download | qtmaildir-2623b762a90b04c57491a5d58ab72c8807c65c80.tar.gz qtmaildir-2623b762a90b04c57491a5d58ab72c8807c65c80.zip | |
feat(config): per-account spam folder and queries
| -rw-r--r-- | src/config.cpp | 29 | ||||
| -rw-r--r-- | src/config.h | 22 | ||||
| -rw-r--r-- | tests/test_config.cpp | 84 |
3 files changed, 124 insertions, 11 deletions
diff --git a/src/config.cpp b/src/config.cpp index 0f0caf1..0fa3c85 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -158,6 +158,11 @@ QString Account::trashQuery() const return folderQuery(maildir, trash); } +QString Account::spamQuery() const +{ + return folderQuery(maildir, spam); +} + QString Account::inboxFolder() const { // Never empty: Restore needs a folder to name, and "Inbox" is both the @@ -192,6 +197,11 @@ QString Config::allTrashQuery() const return joinAccountQueries(m_accounts, &Account::trashQuery); } +QString Config::allSpamQuery() const +{ + return joinAccountQueries(m_accounts, &Account::spamQuery); +} + QString Config::defaultPath() { const QString base = @@ -528,6 +538,12 @@ void Config::load(const QString &path) account.trash = settings.value(QStringLiteral("trash")).toString().trimmed(); + // Mandatory, unlike sent: Mark spam moves a file into this folder, so + // an account without one cannot mark spam at all. Trimmed for the same + // reason as sent, above. + account.spam = + settings.value(QStringLiteral("spam")).toString().trimmed(); + // Optional, unlike trash: inboxFolder() defaults it to "Inbox", which // is right for any ordinary Maildir. Read so an account whose inbox is // named otherwise can say so, rather than having Restore create a @@ -585,6 +601,19 @@ void Config::load(const QString &path) .arg(account.key)); } + // Mandatory, unlike sent: Mark spam moves a file into this folder, so + // an account without one cannot mark spam at all. Reported rather than + // silently disabled, so the user finds out from a warning rather than + // from a Mark spam that quietly does nothing. The account still loads; + // only Mark spam is unusable. + if (account.spam.isEmpty()) { + addProblem( + tr("Account '%1' has no spam folder configured; add a " + "'spam' key to its section. Mark spam will not work for " + "this account until it does.") + .arg(account.key)); + } + m_accounts.append(account); } diff --git a/src/config.h b/src/config.h index 1ab923d..aa3c332 100644 --- a/src/config.h +++ b/src/config.h @@ -80,6 +80,14 @@ struct Account /// reports a missing key through the warnings path. QString trash; + /// The account's spam folder, relative to maildir. + /// + /// MANDATORY, unlike `sent` and `drafts`. Mark spam moves a file into this + /// folder, so an account without one cannot mark spam at all, and the user + /// chose a config error over a per-account disabled state. Config::load() + /// reports a missing key through the warnings path. + QString spam; + /// The command that sends mail from this account, receiving the complete /// RFC822 message on stdin. Optional, and its ABSENCE is meaningful: /// an account without one is receive-only by construction. @@ -160,6 +168,13 @@ struct Account /// uniformly; it is Config::load() that reports the problem. QString trashQuery() const; + /// Matches this account's spam folder, or empty when `spam` is unset. + /// + /// Empty is a config error rather than a legitimate state, unlike + /// sentQuery(). The query helper still returns empty so callers compose + /// uniformly; it is Config::load() that reports the problem. + QString spamQuery() const; + /// Matches this account's inbox folder, using inboxFolder(). QString inboxQuery() const; @@ -408,6 +423,13 @@ public: /// silently answers a different question. QString allTrashQuery() const; + /// Matches every configured account's spam, or empty when none has one. + /// + /// Joins only the NON-EMPTY spamQuery() results, for the same reason + /// allTrashQuery() does: notmuch accepts a bare "or" without complaint and + /// silently answers a different question. + QString allSpamQuery() const; + /// Matches every configured account's drafts, or empty when none has one. /// /// Joins only the NON-EMPTY draftsQuery() results, for the same reason diff --git a/tests/test_config.cpp b/tests/test_config.cpp index e69a073..dd34e4f 100644 --- a/tests/test_config.cpp +++ b/tests/test_config.cpp @@ -120,6 +120,10 @@ private slots: void anAccountCarriesItsTrashFolder(); void aBracketedTrashFolderIsQuoted(); void anAccountWithoutATrashFolderWarns(); + void anAccountCarriesItsSpamFolder(); + void aBracketedSpamFolderIsQuoted(); + void anAccountWithoutASpamFolderWarns(); + void allSpamQueryJoinsAndSkips(); void theDraftsFilterComposesPerAccount(); void theDraftsFilterMatchesNothingWithoutAFolder(); void theDraftsFilterIsFlatLikeSent(); @@ -451,7 +455,8 @@ void TestConfig::absentSyncCommandIsNoticeNotProblem() const QString path = writeIni(dir, QStringLiteral( "[account.work]\n" "maildir=work-mail\n" - "trash=Trash\n")); + "trash=Trash\n" + "spam=Spam\n")); Config config; config.load(path); @@ -472,7 +477,8 @@ void TestConfig::brokenSyncCommandIsAProblem() "\n" "[account.work]\n" "maildir=work-mail\n" - "trash=Trash\n")); + "trash=Trash\n" + "spam=Spam\n")); Config config; config.load(path); @@ -508,7 +514,8 @@ void TestConfig::validConfigHasNoProblems() "[account.work]\n" "maildir=work-mail\n" "address=user@example.org\n" - "trash=Trash\n")); + "trash=Trash\n" + "spam=Spam\n")); Config config; config.load(path); @@ -929,7 +936,8 @@ void TestConfig::sentQueryIsEmptyWithoutTheKey() config.load(writeIni(dir, QStringLiteral( "[account.provider-c]\n" "maildir = provider-c\n" - "trash = Trash\n"))); + "trash = Trash\n" + "spam = Spam\n"))); QCOMPARE(config.accounts().size(), 1); QVERIFY(config.accounts().at(0).sentQuery().isEmpty()); @@ -1028,6 +1036,52 @@ void TestConfig::anAccountWithoutATrashFolderWarns() QVERIFY(joined.contains(QStringLiteral("trash"))); } +void TestConfig::anAccountCarriesItsSpamFolder() +{ + QTemporaryDir dir; + Config config; + config.load(writeIni(dir, QStringLiteral( + "[account.work]\nmaildir=work\nspam=Spam\n"))); + const Account account = config.account(QStringLiteral("work")); + QCOMPARE(account.spam, QStringLiteral("Spam")); + QCOMPARE(account.spamQuery(), QStringLiteral("path:\"work/Spam/**\"")); +} + +void TestConfig::aBracketedSpamFolderIsQuoted() +{ + Account account; + account.maildir = QStringLiteral("provider-a"); + account.spam = QStringLiteral("[Provider]/Spam"); + QCOMPARE(account.spamQuery(), + QStringLiteral("path:\"provider-a/[Provider]/Spam/**\"")); +} + +void TestConfig::anAccountWithoutASpamFolderWarns() +{ + QTemporaryDir dir; + Config config; + config.load(writeIni(dir, QStringLiteral( + "[account.work]\nmaildir=work\n"))); + QVERIFY(config.account(QStringLiteral("work")).isValid()); + const QString joined = config.warnings().join(QLatin1Char('\n')); + QVERIFY(joined.contains(QStringLiteral("work"))); + QVERIFY(joined.contains(QStringLiteral("spam"))); +} + +void TestConfig::allSpamQueryJoinsAndSkips() +{ + QTemporaryDir dir; + Config config; + config.load(writeIni(dir, QStringLiteral( + "[account.work]\nmaildir=work\nspam=Spam\n" + "\n[account.personal]\nmaildir=personal\nspam=[Provider]/Spam\n" + "\n[account.none]\nmaildir=none\n"))); + const QString all = config.allSpamQuery(); + QVERIFY(all.contains(QStringLiteral("path:\"work/Spam/**\""))); + QVERIFY(all.contains(QStringLiteral("path:\"personal/[Provider]/Spam/**\""))); + QVERIFY(!all.contains(QStringLiteral("none"))); +} + void TestConfig::theDraftsFilterComposesPerAccount() { // Item 138. Follows `sent` and `trash`, which match a FOLDER: `draft` is a @@ -1265,10 +1319,12 @@ void TestConfig::theStartupAccountIsReadAndValidated() "[account.work]\n" "maildir=work\n" "trash=Trash\n" + "spam=Spam\n" "\n" "[account.personal]\n" "maildir=personal\n" - "trash=Trash\n"))); + "trash=Trash\n" + "spam=Spam\n"))); QCOMPARE(config.startupAccount(), QStringLiteral("work")); QVERIFY(config.problems().isEmpty()); @@ -1294,7 +1350,8 @@ void TestConfig::theStartupAccountIsReadAndValidated() "\n" "[account.work]\n" "maildir=work\n" - "trash=Trash\n"))); + "trash=Trash\n" + "spam=Spam\n"))); QVERIFY2(wrong.startupAccount().isEmpty(), "an unknown startup account was passed through rather than " "falling back to All accounts"); @@ -1320,7 +1377,8 @@ void TestConfig::theStartupAccountTakesTheKeyNotTheSyncChannel() "[account.provider-work.mailbox]\n" "maildir=provider-work.mailbox\n" "channel=provider-workmailbox\n" - "trash=Trash\n"))); + "trash=Trash\n" + "spam=Spam\n"))); QCOMPARE(config.accounts().size(), 1); QCOMPARE(config.accounts().constFirst().key, @@ -1343,7 +1401,8 @@ void TestConfig::theStartupAccountTakesTheKeyNotTheSyncChannel() "[account.provider-work.mailbox]\n" "maildir=provider-work.mailbox\n" "channel=provider-workmailbox\n" - "trash=Trash\n"))); + "trash=Trash\n" + "spam=Spam\n"))); QVERIFY2(byChannel.startupAccount().isEmpty(), "the sync channel was accepted as an account key"); @@ -1428,7 +1487,8 @@ void TestConfig::theStartupQuerySurvivesATranslatedFilterName() "[account.work]\n" "maildir=work\n" "sent=Sent\n" - "trash=Trash\n"))); + "trash=Trash\n" + "spam=Spam\n"))); QVERIFY2(!config.savedQueries().isEmpty(), "queries.json did not load, so the warning path is unreachable"); @@ -1454,7 +1514,8 @@ void TestConfig::theStartupQuerySurvivesATranslatedFilterName() "[account.work]\n" "maildir=work\n" "sent=Sent\n" - "trash=Trash\n"))); + "trash=Trash\n" + "spam=Spam\n"))); QVERIFY(!byLabel.savedQueries().isEmpty()); QCOMPARE(byLabel.startupSavedQuery().generated, QStringLiteral("inbox")); QVERIFY(byLabel.problems().isEmpty()); @@ -1684,7 +1745,8 @@ void TestConfig::draftsQueryIsEmptyWithoutTheKey() config.load(writeIni(dir, QStringLiteral( "[account.provider-c]\n" "maildir = provider-c\n" - "trash = Trash\n"))); + "trash = Trash\n" + "spam = Spam\n"))); QCOMPARE(config.accounts().size(), 1); QVERIFY(config.accounts().at(0).draftsQuery().isEmpty()); |
