summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-13 19:16:33 +0200
committerDanilo M. <danix@danix.xyz>2026-09-13 19:16:33 +0200
commit2623b762a90b04c57491a5d58ab72c8807c65c80 (patch)
tree1f968dec0965300173aec0216d76330d870f6513
parentb79b5e30efcd8de95fa42d84c6ed319a0347941c (diff)
downloadqtmaildir-2623b762a90b04c57491a5d58ab72c8807c65c80.tar.gz
qtmaildir-2623b762a90b04c57491a5d58ab72c8807c65c80.zip
feat(config): per-account spam folder and queries
-rw-r--r--src/config.cpp29
-rw-r--r--src/config.h22
-rw-r--r--tests/test_config.cpp84
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());