summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-13 20:03:17 +0200
committerDanilo M. <danix@danix.xyz>2026-09-13 20:03:17 +0200
commit18ff1f33f64428ab96ca0b5ea22e681be564c886 (patch)
tree4c283551bba03a1436cc1f1ac3b3b4c2dcdfc95b
parent2623b762a90b04c57491a5d58ab72c8807c65c80 (diff)
downloadqtmaildir-18ff1f33f64428ab96ca0b5ea22e681be564c886.tar.gz
qtmaildir-18ff1f33f64428ab96ca0b5ea22e681be564c886.zip
feat(config): a threaded, path-based spam filter
-rw-r--r--src/config.cpp21
-rw-r--r--src/mainwindow.cpp1
-rw-r--r--tests/test_config.cpp35
-rw-r--r--tests/test_mainwindow.cpp3
4 files changed, 56 insertions, 4 deletions
diff --git a/src/config.cpp b/src/config.cpp
index 0fa3c85..390848a 100644
--- a/src/config.cpp
+++ b/src/config.cpp
@@ -64,7 +64,8 @@ const QStringList kQueryGenerators = { QStringLiteral("unread"),
QStringLiteral("flagged"),
QStringLiteral("sent"),
QStringLiteral("drafts"),
- QStringLiteral("trash") };
+ QStringLiteral("trash"),
+ QStringLiteral("spam") };
/// The tag a generator matches, for the three filters that are a plain tag
/// query. Empty for "sent", "drafts" and "trash", which compose from each
@@ -1018,6 +1019,8 @@ QString Config::resolvedQuery(const SavedQuery &query) const
return allSentQuery();
if (query.generated == QStringLiteral("trash"))
return allTrashQuery();
+ if (query.generated == QStringLiteral("spam"))
+ return allSpamQuery();
// An unknown generator was reported on load. Empty rather than the
// bare stored query, which for a generated entry is empty anyway and
// would otherwise run as "match everything".
@@ -1106,6 +1109,10 @@ SavedQuery Config::builtinFilter(const QString &generator)
// NOT flat, unlike Sent. A deleted message still belongs to its
// conversation, and folding it back is what Sent had to avoid rather
// than something every folder filter wants.
+ } else if (generator == QStringLiteral("spam")) {
+ filter.name = tr("Spam");
+ // NOT flat, like trash: a spam message still belongs to its
+ // conversation.
}
return filter;
@@ -1138,6 +1145,10 @@ QString Config::resolvedQuery(const SavedQuery &query,
const QString all = allTrashQuery();
return all.isEmpty() ? matchNothingQuery() : all;
}
+ if (query.generated == QStringLiteral("spam")) {
+ const QString all = allSpamQuery();
+ return all.isEmpty() ? matchNothingQuery() : all;
+ }
return QStringLiteral("tag:%1").arg(generatorTag(query.generated));
}
@@ -1173,6 +1184,14 @@ QString Config::resolvedQuery(const SavedQuery &query,
return trash.isEmpty() ? matchNothingQuery() : trash;
}
+ if (query.generated == QStringLiteral("spam")) {
+ // The account's OWN spam query, for the reason spelled out above the
+ // sent case: wrapping the all-accounts query in this account's path
+ // works by accident of path: being hierarchical.
+ const QString spam = scope.spamQuery();
+ return spam.isEmpty() ? matchNothingQuery() : spam;
+ }
+
// A tag filter carries no path of its own, so scoping is exactly what
// scopedQuery() does. Its parentheses are load-bearing: `path:... and a or
// b` binds as `(path:... and a) or b`.
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index e7cbf33..2cba6b1 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -3119,6 +3119,7 @@ void MainWindow::buildSavedQueryRow(QWidget *parent, QVBoxLayout *layout)
{ QStringLiteral("sent"), QStringLiteral("mail-folder-sent") },
{ QStringLiteral("drafts"), QStringLiteral("document-edit") },
{ QStringLiteral("trash"), QStringLiteral("user-trash") },
+ { QStringLiteral("spam"), QStringLiteral("mail-mark-junk") },
};
button->setIcon(
QIcon::fromTheme(filterIcons.value(filter.generated)));
diff --git a/tests/test_config.cpp b/tests/test_config.cpp
index dd34e4f..70e3705 100644
--- a/tests/test_config.cpp
+++ b/tests/test_config.cpp
@@ -129,6 +129,8 @@ private slots:
void theDraftsFilterIsFlatLikeSent();
void theTrashFilterComposesPerAccount();
void theTrashFilterMatchesNothingWithoutAFolder();
+ void theSpamFilterComposesPerAccount();
+ void theSpamFilterMatchesNothingWithoutAFolder();
void anAccountWithoutASendCommandIsReceiveOnly();
void composeSettingsDefaultWhenTheSectionIsAbsent();
void aZeroSendDelayIsHonouredRatherThanTreatedAsUnset();
@@ -1198,6 +1200,34 @@ void TestConfig::theTrashFilterMatchesNothingWithoutAFolder()
QCOMPARE(config.resolvedQuery(trash, QString()), Config::matchNothingQuery());
}
+void TestConfig::theSpamFilterComposesPerAccount()
+{
+ 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")));
+ const SavedQuery spam = Config::builtinFilter(QStringLiteral("spam"));
+ QVERIFY(spam.isGenerated());
+ QVERIFY2(!spam.flat, "spam must be threaded, like trash");
+ const QString all = config.resolvedQuery(spam, QString());
+ QVERIFY(all.contains(QStringLiteral("path:\"work/Spam/**\"")));
+ QVERIFY(all.contains(QStringLiteral("path:\"personal/[Provider]/Spam/**\"")));
+ const QString scoped = config.resolvedQuery(spam, QStringLiteral("work"));
+ QCOMPARE(scoped, QStringLiteral("path:\"work/Spam/**\""));
+ QVERIFY(!scoped.contains(QStringLiteral("personal")));
+}
+
+void TestConfig::theSpamFilterMatchesNothingWithoutAFolder()
+{
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral(
+ "[account.work]\nmaildir=work\n")));
+ const SavedQuery spam = Config::builtinFilter(QStringLiteral("spam"));
+ QCOMPARE(config.resolvedQuery(spam, QString()), Config::matchNothingQuery());
+}
+
void TestConfig::sentQueryComposesWithScopedQuery()
{
// A Sent view under one account must not show another account's sent mail.
@@ -1628,7 +1658,7 @@ void TestConfig::everyBuiltinFilterIsAKnownGenerator()
Config config;
const QList<SavedQuery> filters = config.builtinFilters();
- QCOMPARE(filters.size(), 6);
+ QCOMPARE(filters.size(), 7);
QStringList names;
for (const SavedQuery &filter : filters) {
@@ -1651,7 +1681,8 @@ void TestConfig::everyBuiltinFilterIsAKnownGenerator()
QStringLiteral("Important"),
QStringLiteral("Sent"),
QStringLiteral("Drafts"),
- QStringLiteral("Trash") }));
+ QStringLiteral("Trash"),
+ QStringLiteral("Spam") }));
}
void TestConfig::aFilterAcrossAllAccountsIsTheUnscopedQuery()
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index b7f7846..9a38a95 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -11008,12 +11008,13 @@ void TestMainWindow::everyBuiltinFilterButtonCarriesAnIconAndItsText()
});
// A trash key too, or the Trash filter finds nothing and is skipped from
// the row entirely (item 103), leaving no trashButton for this loop to
- // find. Drafts behaves the same way since item 138.
+ // find. Drafts and Spam behave the same way since item 138.
{
QSettings s(path, QSettings::IniFormat);
s.beginGroup(QStringLiteral("account.work"));
s.setValue(QStringLiteral("trash"), QStringLiteral("Trash"));
s.setValue(QStringLiteral("drafts"), QStringLiteral("Drafts"));
+ s.setValue(QStringLiteral("spam"), QStringLiteral("Spam"));
s.endGroup();
}
Config config;