aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-14 12:26:19 +0200
committerDanilo M. <danix@danix.xyz>2026-09-14 12:26:19 +0200
commit5f51d30e76fa20f8bd1212684ae0b2e1e773a7f3 (patch)
tree6d96cb0bbb9d6461a8a25d9cbe7a072e4968efbd
parent3d986788b9576451ef1215e0f423d587629b3f81 (diff)
downloadqtmaildir-5f51d30e76fa20f8bd1212684ae0b2e1e773a7f3.tar.gz
qtmaildir-5f51d30e76fa20f8bd1212684ae0b2e1e773a7f3.zip
feat: find stranded spam mail
-rw-r--r--src/keymap.cpp6
-rw-r--r--src/mainwindow.cpp49
-rw-r--r--src/mainwindow.h7
-rw-r--r--tests/test_mainwindow.cpp55
4 files changed, 117 insertions, 0 deletions
diff --git a/src/keymap.cpp b/src/keymap.cpp
index 098a85d..d62ba3e 100644
--- a/src/keymap.cpp
+++ b/src/keymap.cpp
@@ -33,6 +33,12 @@ QStringList KeyMap::knownActions()
QStringLiteral("delete"),
QStringLiteral("restore"),
QStringLiteral("cleanup_stranded"),
+ // Item 103's spam sibling: it reports mail tagged `spam` that is not in
+ // any account's spam folder, and moves nothing. No default binding,
+ // matching cleanup_stranded: a view-replacing report is reached from a
+ // menu, and since item 132 an unbound action is menu-reachable rather
+ // than broken.
+ QStringLiteral("cleanup_stranded_spam"),
// Item 118. No default binding, deliberately: this is the one action
// that destroys mail with no undo, and a chord is how it would be run
// by accident. Menu only, which item 132 made a legitimate choice.
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 9198839..476ff18 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -1762,6 +1762,18 @@ void MainWindow::registerActions()
[this]() {
showStrandedDeletedMail();
});
+ // The spam half of the same cleanup, beside its Delete sibling. Its
+ // mnemonic is on "&Check" rather than the brief's `s&pam`: Alt+P is
+ // already Re&ply and Alt+S is Mark &spam (and Find &stranded), and no
+ // letter of "Find stranded spam" is free in the Message menu. Menu only,
+ // like cleanup_stranded: it replaces the whole view like a filter does,
+ // and it moves nothing.
+ addAction(QStringLiteral("cleanup_stranded_spam"),
+ tr("&Check for stranded spam"),
+ tr("Show mail tagged spam that is not in a spam folder"),
+ [this]() {
+ showStrandedSpamMail();
+ });
// The ONE irreversible action in this application, and the only one that
// asks before it runs (item 118). CLAUDE.md rules out confirmation
// dialogs for mutations because every mutation pushes its inverse onto
@@ -2076,6 +2088,12 @@ const QHash<QString, QPair<QString, QString>> kThemeIcons = {
// nothing, so an icon from the delete family would promise the one
// thing it deliberately does not do.
{ QStringLiteral("cleanup_stranded"), { QStringLiteral("system-search"), QString() } },
+ // The spam cleanup shares `system-search` for the same reason it shares
+ // the action's shape: both report what they find and move nothing. It is
+ // a Message-menu-only entry that always carries its text and never
+ // reaches the main toolbar, so it is named in noTwoActionsShareAnIcon()'s
+ // exception list, exactly as reply_no_quote and empty_spam are.
+ { QStringLiteral("cleanup_stranded_spam"), { QStringLiteral("system-search"), QString() } },
{ QStringLiteral("empty_trash"), { QStringLiteral("edit-delete-shred"), QString() } },
// Item 185. Distinct from empty_trash's, because both reach the trash
// bar and there the icon IS the control: two buttons that destroy
@@ -2213,6 +2231,7 @@ void MainWindow::buildMenus()
// It replaces the whole view like a filter does, so a sixth button beside
// the five filters would read as one of them.
messageMenu->addAction(m_actions.value(QStringLiteral("cleanup_stranded")));
+ messageMenu->addAction(m_actions.value(QStringLiteral("cleanup_stranded_spam")));
messageMenu->addAction(m_actions.value(QStringLiteral("empty_trash")));
messageMenu->addAction(m_actions.value(QStringLiteral("empty_spam")));
messageMenu->addAction(m_actions.value(QStringLiteral("tag_rules")));
@@ -7102,6 +7121,36 @@ void MainWindow::showStrandedDeletedMail()
"Select what should go and press Delete."));
}
+void MainWindow::showStrandedSpamMail()
+{
+ // Not scoped to the selected account, deliberately, for the same reason
+ // showStrandedDeletedMail() is not: the stranded mail belongs to no view,
+ // and the user wants to see all of it at once. The account dropdown can
+ // still narrow it by hand afterwards.
+ const QString spam = m_config.allSpamQuery();
+
+ // No account configures a spam folder: everything tagged `spam` is by
+ // definition stranded, since there is nowhere for it to have gone. An
+ // empty exclusion must never be written as `not ()`, which notmuch parses
+ // without complaint and matches nothing, reporting a clean database.
+ const QString query =
+ spam.isEmpty()
+ ? QStringLiteral("tag:spam")
+ : QStringLiteral("tag:spam and not (%1)").arg(spam);
+
+ // Into the bar, like a filter: what ran is visible and editable, and
+ // AlreadyScoped stops runQuery() wrapping it in the selected account's
+ // path, which would hide every other account's stranded mail.
+ m_queryEdit->setText(query);
+ runQuery(FlatResult::No, AccountScope::AlreadyScoped);
+
+ // After runQuery(), which sets "Searching...": set before it, this would
+ // be overwritten and the user would be told nothing about what they are
+ // looking at.
+ m_statusLabel->setText(tr("Mail tagged spam but not in a spam folder. "
+ "Select what should go and press Mark spam."));
+}
+
void MainWindow::restoreSelected(bool fallbackToInbox)
{
const QModelIndexList rows =
diff --git a/src/mainwindow.h b/src/mainwindow.h
index ab65f1d..a7fbf26 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -1314,6 +1314,13 @@ private:
/// without moving.
void showStrandedDeletedMail();
+ /// The spam half of the same cleanup: runs the query that finds mail
+ /// tagged `spam` whose file is not inside any account's spam folder. Like
+ /// its Delete sibling it REPORTS and moves nothing, and it is repeatable
+ /// for the same reason: mail reaches this state again whenever another
+ /// client tags without moving.
+ void showStrandedSpamMail();
+
/// Asks the worker what is in the trash. The answer arrives at
/// onThreadMessagesResolved() tagged `empty_trash` and goes to
/// confirmAndPurge(): the count in the dialog has to be what will actually
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index d581933..dfce9c3 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -531,6 +531,8 @@ private slots:
void emptySpamMovesEachAccountsMailToItsOwnTrash();
void emptySpamRewritesTheOriginToTheSpamFolder();
void emptySpamRefusesAnUnconfiguredFolder();
+ void theSpamCleanupQueryExcludesTheSpamFolder();
+ void theSpamCleanupQueryWithoutASpamFolderIsJustTheTag();
// ComposeWindow, item 123. These need a window but no worker: the composer
// never touches NotmuchWorker, it reads its context from the value struct
@@ -8706,6 +8708,11 @@ void TestMainWindow::noTwoActionsShareAnIcon()
// fails if it is ever put on the toolbar, so this is not a hiding
// place.
QStringLiteral("empty_spam"),
+ // Find stranded spam shares `cleanup_stranded`'s `system-search`,
+ // Task 7. Same property again: a Message-menu-only entry that always
+ // carries its text and never reaches the main toolbar, allowed for the
+ // same reason and caught here if it is ever put on the toolbar.
+ QStringLiteral("cleanup_stranded_spam"),
};
const Config config;
@@ -13675,6 +13682,54 @@ void TestMainWindow::theCleanupQueryExcludesMailAlreadyInTrash()
QCOMPARE(notmuchCount(cfg, queryEdit->text()), 0);
}
+void TestMainWindow::theSpamCleanupQueryExcludesTheSpamFolder()
+{
+ // Task 7, mirroring theCleanupQueryExcludesMailAlreadyInTrash(). Properly
+ // spammed mail carries the tag AND sits in the spam folder, so without the
+ // exclusion this reports every message ever moved to spam.
+ WorkerBackedWindow backed;
+ QVERIFY2(backed.build(QStringLiteral("work"), QStringLiteral("work"),
+ QString(), QStringLiteral("Spam")),
+ qPrintable(backed.error()));
+
+ MainWindow window(backed.config());
+ auto *queryEdit =
+ window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ auto *cleanup =
+ window.findChild<QAction *>(QStringLiteral("cleanup_stranded_spam"));
+ QVERIFY(queryEdit);
+ QVERIFY2(cleanup, "there is no cleanup_stranded_spam action");
+
+ cleanup->trigger();
+
+ // The composed query, asserted whole: the exclusion has to wrap the
+ // account's own spam path, and the tag has to be there.
+ QCOMPARE(queryEdit->text(),
+ QStringLiteral("tag:spam and not (path:\"work/Spam/**\")"));
+}
+
+void TestMainWindow::theSpamCleanupQueryWithoutASpamFolderIsJustTheTag()
+{
+ // The other branch of the same composition. An empty exclusion must never
+ // be written as `not ()`: notmuch parses that happily and matches nothing,
+ // so an account with no spam folder would report a clean database.
+ WorkerBackedWindow backed;
+ QVERIFY2(backed.build(QStringLiteral("acct"), QStringLiteral("acct")),
+ qPrintable(backed.error()));
+
+ MainWindow window(backed.config());
+ auto *queryEdit =
+ window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ auto *cleanup =
+ window.findChild<QAction *>(QStringLiteral("cleanup_stranded_spam"));
+ QVERIFY(queryEdit);
+ QVERIFY2(cleanup, "there is no cleanup_stranded_spam action");
+
+ cleanup->trigger();
+
+ QCOMPARE(queryEdit->text(), QStringLiteral("tag:spam"));
+}
+
void TestMainWindow::aMoveThatRelocatesNothingWritesNoTag()
{
// The spec's ordering bullet, at the UI level: a failed rename must leave