summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-13 18:35:23 +0200
committerDanilo M. <danix@danix.xyz>2026-09-13 18:35:23 +0200
commitb79b5e30efcd8de95fa42d84c6ed319a0347941c (patch)
tree356da04aa4e9f73b2f9d2f61840fb9857434e261
parente146e11137dc25a51f4209049b96c821a20074ec (diff)
downloadqtmaildir-b79b5e30efcd8de95fa42d84c6ed319a0347941c.tar.gz
qtmaildir-b79b5e30efcd8de95fa42d84c6ed319a0347941c.zip
plan: mark spam moves mail, and there is a spam view
-rw-r--r--docs/superpowers/plans/2026-09-13-spam-view.md468
1 files changed, 468 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-09-13-spam-view.md b/docs/superpowers/plans/2026-09-13-spam-view.md
new file mode 100644
index 0000000..f6bec47
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-13-spam-view.md
@@ -0,0 +1,468 @@
+# Mark spam moves mail, and there is a Spam view — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Turn `Mark spam` into a real move into a per-account spam folder, add a path-based Spam filter, rename the origin tag `deleted-from:` to `moved-from:`, put `spam` on the message bar, and add Empty Spam plus a stranded-spam cleanup.
+
+**Architecture:** Spam copies Trash everywhere: a mandatory per-account `spam` key, `Account::spamQuery()` and `Config::allSpamQuery()`, a seventh generated filter, and a `sendMove()` that writes `spam` plus the origin tag and strips `unread`/`inbox`. The origin-tag rename centralises the prefix into one constant and adds worker-side overwrite semantics (one origin tag ever).
+
+**Tech Stack:** Qt6 Widgets, libnotmuch, CMake/Ninja, ctest.
+
+**Spec:** `docs/superpowers/specs/2026-09-10-spam-view-design.md` — the plan argues from it; read both.
+
+## Global Constraints
+
+- `tr()` on every user-facing string. `lupdate` must report zero context warnings and `lrelease` zero unfinished.
+- The origin-tag prefix is wire format: never translated, never matched against a translated string.
+- Never run a test binary without `QT_QPA_PLATFORM=offscreen`. Never launch `./build/src/qtmaildir`.
+- `QTRY_VERIFY_WITH_TIMEOUT`, never a fixed `qWait`.
+- A move's origin is resolved by the worker, never read from the model.
+- No confirmation dialogs for tag mutations. Empty Spam is a move (undoable), so it gets no dialog.
+- Adding an action touches FIVE places: `KeyMap::knownActions()`, `defaultBindings()` (optional), the icon table, the action itself, and a menu. `everyActionIsReachableFromAMenu()` enforces the last.
+- Build/test: `cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug && cmake --build build && ctest --test-dir build --output-on-failure`. A single test: `ctest --test-dir build -R <name>`.
+
+---
+
+### Task 1: Config — the `spam` key and its queries
+
+**Files:**
+- Modify: `src/config.h` (Account field + method decls)
+- Modify: `src/config.cpp` (parse, validate, `spamQuery()`, `allSpamQuery()`)
+- Test: `tests/test_config.cpp`
+
+**Interfaces:**
+- Produces: `Account::spam` (QString), `Account::spamQuery() -> QString`, `Config::allSpamQuery() -> QString`.
+
+- [ ] **Step 1: Write the failing tests.** Add these four methods to `tests/test_config.cpp`, and register their names in the `private slots:` block (near the trash tests around line 120).
+
+```cpp
+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")));
+}
+```
+
+- [ ] **Step 2: Run and confirm failure.** `ctest --test-dir build -R config` fails to compile: `spam` is not a member of `Account`.
+
+- [ ] **Step 3: Implement.** In `src/config.h`, add a `QString spam;` field beside `trash` (line ~81) with a doc comment mirroring `trash`'s (mandatory; a missing one is a config error reported by `Config::load()`). Add `QString spamQuery() const;` near `trashQuery()` and `QString allSpamQuery() const;` near `allTrashQuery()`. In `src/config.cpp`:
+ - In `load()`, read the key beside `account.trash` (line ~528): `account.spam = settings.value(QStringLiteral("spam")).toString().trimmed();`
+ - Beside the trash-missing warning (line ~580), add a matching block naming the `spam` key and warning that Mark spam will not work for that account.
+ - After `Account::trashQuery()` (line ~156):
+
+```cpp
+QString Account::spamQuery() const
+{
+ return folderQuery(maildir, spam);
+}
+```
+
+ - After `Config::allTrashQuery()` (line ~190):
+
+```cpp
+QString Config::allSpamQuery() const
+{
+ return joinAccountQueries(m_accounts, &Account::spamQuery);
+}
+```
+
+- [ ] **Step 4: Run.** `ctest --test-dir build -R config` passes.
+- [ ] **Step 5: Commit.** `git add src/config.h src/config.cpp tests/test_config.cpp && git commit -S -m "feat(config): per-account spam folder and queries"`
+
+---
+
+### Task 2: The Spam filter
+
+**Files:**
+- Modify: `src/config.cpp` (`kQueryGenerators`, `builtinFilter()`, both `resolvedQuery()` overloads)
+- Modify: `src/mainwindow.cpp` (`filterIcons`)
+- Test: `tests/test_config.cpp`
+
+**Interfaces:**
+- Consumes: `Account::spamQuery()`, `Config::allSpamQuery()` (Task 1).
+- Produces: generator `"spam"`, `Config::builtinFilter("spam")` labelled `tr("Spam")` and threaded.
+
+- [ ] **Step 1: Write the failing tests** in `tests/test_config.cpp` (mirror the trash pair at lines 1099–1145), and register the names in `private slots:`.
+
+```cpp
+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());
+}
+```
+
+- [ ] **Step 2: Run.** `ctest --test-dir build -R config` fails: the spam generator is unknown.
+
+- [ ] **Step 3: Implement** in `src/config.cpp`:
+ - Add `QStringLiteral("spam")` to `kQueryGenerators` (line 62).
+ - In `builtinFilter()` (line ~1035), add:
+
+```cpp
+} else if (generator == QStringLiteral("spam")) {
+ filter.name = tr("Spam");
+ // NOT flat, like trash: a spam message still belongs to its conversation.
+}
+```
+
+ - In `resolvedQuery(const SavedQuery &)` (line ~982), add beside the trash case: `if (query.generated == QStringLiteral("spam")) return allSpamQuery();`
+ - In `resolvedQuery(const SavedQuery &, const QString &accountKey)`:
+ - all-accounts branch (~line 1097): add spam, returning `allSpamQuery()` or `matchNothingQuery()` when empty.
+ - per-account branch (~line 1119): add spam, returning `scope.spamQuery()` or `matchNothingQuery()` when empty. Never the all-accounts query wrapped in the account path.
+
+- [ ] **Step 4: Run.** `ctest --test-dir build -R config` passes.
+- [ ] **Step 5: Filter icon.** In `src/mainwindow.cpp` `filterIcons` (line ~3115), add `{ QStringLiteral("spam"), QStringLiteral("mail-mark-junk") },`.
+- [ ] **Step 6: Commit.** `git add src/config.cpp src/mainwindow.cpp tests/test_config.cpp && git commit -S -m "feat(config): a threaded, path-based spam filter"`
+
+---
+
+### Task 3: Rename the origin tag `deleted-from:` to `moved-from:`
+
+**Files:**
+- Modify: `src/types.h` (shared prefix constant)
+- Modify: `src/mainwindow.cpp` (composer + three readers)
+- Modify: `src/notmuchworker.cpp` (`applyTags()` overwrite rule)
+- Modify: `tests/test_mainwindow.cpp`, `tests/test_tagdialog.cpp` (all literals and comments)
+
+**Interfaces:**
+- Produces: `kOriginTagPrefix` (in `types.h`) used by both `MainWindow` and `NotmuchWorker`.
+
+- [ ] **Step 1: Centralise the prefix** in `src/types.h` (after the includes):
+
+```cpp
+/// Prefix of the origin tag a move writes. One origin tag per message,
+/// overwritten on each move, so a reader cannot be handed two and forced to
+/// pick one silently. Not translated, not user-facing.
+inline constexpr auto kOriginTagPrefix = "moved-from:";
+```
+
+- [ ] **Step 2: Composer.** In `MainWindow::originTagFor()` (`src/mainwindow.cpp:6384`), change the return to `return QString(kOriginTagPrefix) + accountRelative;` and update its doc prose from `deleted-from:` to `moved-from:`.
+
+- [ ] **Step 3: Readers.** In `src/mainwindow.cpp`, replace the three `const QString prefix = QStringLiteral("deleted-from:");` literals (lines ~6480, ~6594, ~6902) with `const QString prefix = QString::fromLatin1(kOriginTagPrefix);`. Update the surrounding comments' prose the same way.
+
+- [ ] **Step 4: Worker overwrite rule.** In `NotmuchWorker::applyTags()` (`src/notmuchworker.cpp:1060`), after `const QStringList before = tagsOf(message.get());` (line ~1123) and before the add/remove loop, insert:
+
+```cpp
+ // One origin tag ever: writing a `moved-from:` tag strips any other
+ // tag with that prefix the message still carries, so a message that
+ // travelled inbox -> spam -> trash ends with exactly one origin and
+ // Restore has one answer. Without this the reader's first-match
+ // break() picks silently.
+ const bool writingOrigin =
+ std::any_of(change.added.cbegin(), change.added.cend(),
+ [](const QString &t) {
+ return t.startsWith(QLatin1String(kOriginTagPrefix));
+ });
+ if (writingOrigin) {
+ for (const QString &tag : std::as_const(before)) {
+ if (tag.startsWith(QLatin1String(kOriginTagPrefix))
+ && !change.added.contains(tag)) {
+ moves = moves
+ || notmuch_message_remove_tag(
+ message.get(), tag.toUtf8().constData());
+ }
+ }
+ }
+```
+
+ Ensure `#include <algorithm>` and `<utility>` (for `std::as_const`) are present in `notmuchworker.cpp`; add if missing. Include `types.h` if not already included.
+
+- [ ] **Step 5: Update tests.** In `tests/test_mainwindow.cpp` and `tests/test_tagdialog.cpp`, replace every `deleted-from:` literal and query string with `moved-from:` (`deleted-from:inbox` -> `moved-from:inbox`, `deleted-from:Trash` -> `moved-from:Trash`, `deleted-from:Inbox/SlackBuilds users` likewise). Update the surrounding comment prose. Use `grep -rn "deleted-from"` to find all sites; there must be none left anywhere except the spec/plan documents.
+
+- [ ] **Step 6: Run.** `ctest --test-dir build -R 'mainwindow|tagdialog|notmuchworker'` passes.
+- [ ] **Step 7: Commit.** `git add -A && git commit -S -m "refactor: rename the origin tag to moved-from: with overwrite semantics"`
+
+---
+
+### Task 4: Mark spam moves the file
+
+**Files:**
+- Modify: `src/mainwindow.h` (three method decls)
+- Modify: `src/mainwindow.cpp` (action body, `spamSelected()`, `spamMessages()`, `spamThreads()`, `onThreadMessagesResolved()` branch)
+- Test: `tests/test_notmuchworker.cpp`, `tests/test_mainwindow.cpp`
+
+**Interfaces:**
+- Consumes: `Account::spam`, `kOriginTagPlaceholder()`, `sendMove()`, `everySelectedRowIsInATrashFolder()`.
+- Produces: `MainWindow::spamSelected()`, `spamMessages(const QStringList &, const QHash<QString,QString> &, int, const QStringList & = {})`, `spamThreads(const QStringList &)`.
+
+- [ ] **Step 1: Declare** in `src/mainwindow.h` beside the trash methods (near line 1205):
+
+```cpp
+ /// Moves each selected row's message to its account's spam folder, tagging
+ /// it `spam` and recording where it came from. Delete's sibling.
+ void spamSelected();
+ void spamMessages(const QStringList &messageIds,
+ const QHash<QString, QString> &pathById,
+ int messageCount,
+ const QStringList &wholeThreadIds = {});
+ void spamThreads(const QStringList &threadIds);
+```
+
+- [ ] **Step 2: Rewrite the action body** (`src/mainwindow.cpp:1786`):
+
+```cpp
+ addAction(QStringLiteral("spam"), tr("Mark &spam"),
+ tr("Move the selected messages to the spam folder"), [this]() {
+ spamSelected();
+ });
+```
+
+- [ ] **Step 3: Implement** the three methods as exact mirrors of `trashSelected()`/`trashThreads()`/`trashMessages()` (publicly at lines 6260–6413), changing only:
+ - the grouping key: `account.maildir + QLatin1Char('/') + account.spam` (skip and report when `account.spam.isEmpty()`),
+ - the `sendMove` call:
+
+```cpp
+ sendMove(it.value(), it.key(),
+ { QStringLiteral("spam"), kOriginTagPlaceholder() },
+ { QStringLiteral("unread"), QStringLiteral("inbox") },
+ tr("Mark spam"), false, wholeThreadIds);
+```
+
+ - `spamThreads()` optimistically repaints `m_model->applyTagChange(threadId, { QStringLiteral("spam") }, { QStringLiteral("inbox") })` and requests `"spam_thread"` via `resolveThreadMessages`.
+
+- [ ] **Step 4: Handle the resolution.** In `onThreadMessagesResolved()` (the branch block at lines 6449–6475), add beside `delete_thread`:
+
+```cpp
+ if (requestTag == QStringLiteral("spam_thread")) {
+ spamMessages(messageIds, pathById, messageIds.size(), threadScope);
+ return;
+ }
+```
+
+- [ ] **Step 5: Worker-level test** in `tests/test_notmuchworker.cpp` (mirror `moveMessagesRelocatesTheFile` at line 1693). Add `addMovableMessage` for a message in `inbox`, move it to `spam` with `worker.moveMessages({ id }, QStringLiteral("spam"))`, assert the file is under `<maildir>/spam/cur` and gone from the origin. Then apply the tag half with `worker.applyTags(TagChange{ {id}, { QStringLiteral("spam"), QStringLiteral("moved-from:inbox") }, { QStringLiteral("unread"), QStringLiteral("inbox") }, QStringLiteral("Mark spam") })` and assert via a query that the message carries `spam` and `moved-from:inbox` and not `unread`. Add a second case starting from a message already carrying `moved-from:inbox` and being moved with `moved-from:Spam`, asserting exactly one `moved-from:` remains (the overwrite rule).
+
+- [ ] **Step 6: UI-level tests** in `tests/test_mainwindow.cpp`, using `WorkerBackedWindow` and mirroring the delete-move tests around line 12000 (`QTRY_VERIFY_WITH_TIMEOUT`, never `qWait`):
+ - mark a thread spam from the inbox; assert the file is at the account's spam folder, carries `spam` + `moved-from:inbox`, and no longer carries `unread`;
+ - undo it; assert the file returns to its exact original path and both `spam` and `moved-from:inbox` are gone;
+ - assert `everySelectedRowIsInATrashFolder()` returns false for a message whose path is under `account.spam` (the trash predicate must not answer for spam).
+
+- [ ] **Step 7: Run.** `ctest --test-dir build -R 'notmuchworker|mainwindow'` passes.
+- [ ] **Step 8: Commit.** `git add src/mainwindow.h src/mainwindow.cpp tests/test_notmuchworker.cpp tests/test_mainwindow.cpp && git commit -S -m "feat: mark spam moves mail to the account's spam folder"`
+
+---
+
+### Task 5: Message bar and icon fallback
+
+**Files:**
+- Modify: `src/mainwindow.cpp` (`populateMessageBar()`, icon table and its application loop)
+- Test: `tests/test_mainwindow.cpp`
+
+**Interfaces:**
+- Consumes: the `spam` action (Task 4).
+
+- [ ] **Step 1: Put spam on the bar.** In `populateMessageBar()`, ordinary branch (line ~2405), insert `m_actions.value(QStringLiteral("spam"))` between `archive` and `delete` (a filing act whose destination is hostile, ordered before the destructive one).
+
+- [ ] **Step 2: Icon fallback.** Change the icon table (line ~2141) so a value carries a primary name and an optional fallback. Simplest shape that keeps the existing lookup: change `QHash<QString, QString>` to `QHash<QString, QPair<QString, QString>>` (primary, fallback; empty fallback means none) and update the application loop (line ~2220) to try `QIcon::fromTheme(primary)` and, when null and fallback is non-empty, `QIcon::fromTheme(fallback)`. Set the spam entry to `{ QStringLiteral("bug"), QStringLiteral("mail-mark-junk") }` and keep every other entry's fallback empty. Update the nearby comments to explain that `bug` is not a freedesktop standard name and the fallback keeps every standard theme rendering a junk icon.
+
+- [ ] **Step 3: Test.** In `tests/test_mainwindow.cpp`, add `theSpamActionCarriesTheBugIconWithAFallback()`: find the `spam` action, assert its icon is non-null, and assert the icon-table primary name for spam is `bug` with fallback `mail-mark-junk`. `everyActionCarriesAnIcon()` and `noTwoActionsShareAnIcon()` must still pass (they compare the primary name).
+
+- [ ] **Step 4: Run.** `ctest --test-dir build -R mainwindow` passes.
+- [ ] **Step 5: Commit.** `git add src/mainwindow.cpp tests/test_mainwindow.cpp && git commit -S -m "feat: spam on the message bar, with a bug icon and junk fallback"`
+
+---
+
+### Task 6: Empty Spam
+
+**Files:**
+- Modify: `src/keymap.cpp` (`knownActions()`)
+- Modify: `src/mainwindow.h` (`emptySpam()`, `isShowingSpam()`)
+- Modify: `src/mainwindow.cpp` (action, menu, icon, `emptySpam()`, `onThreadMessagesResolved()` `"empty_spam"` branch, `isShowingSpam()`, refresh gate)
+- Test: `tests/test_mainwindow.cpp`
+
+**Interfaces:**
+- Consumes: `Config::allSpamQuery()`, `sendMove()`, `kOriginTagPlaceholder()`, `accountForMessagePath()`.
+- Produces: `MainWindow::emptySpam()`, `bool MainWindow::isShowingSpam() const`.
+
+- [ ] **Step 1: Register the action.** In `src/keymap.cpp` `knownActions()` (line ~25), add `QStringLiteral("empty_spam"),` with a comment that it is Empty Trash's sibling but carries no confirmation because it is a MOVE (undoable). Add NO `defaultBindings()` entry (unbound). In `src/mainwindow.cpp` icon table (line ~2141), add an `empty_spam` entry (use `user-trash`, distinct from `empty_trash`'s `edit-delete-shred`).
+
+- [ ] **Step 2: The action + menu.** Add the action beside `empty_trash` (line ~1774):
+
+```cpp
+ addAction(QStringLiteral("empty_spam"), tr("Empty s&pam..."),
+ tr("Move every message in the spam folder to the trash"),
+ [this]() {
+ emptySpam();
+ });
+```
+
+ Add `messageMenu->addAction(m_actions.value(QStringLiteral("empty_spam")));` beside the `empty_trash` entry (line ~2094). Pick an accelerator that does not collide in the Message menu: `Mark &spam` already owns `&s`, so use none or a free letter; `noMenuHasTwoEntriesSharingAMnemonic()` must stay green.
+
+- [ ] **Step 3: Implement `emptySpam()`** mirroring `emptyTrash()` (line 6742): resolve `m_accountBox->currentData().toString().isEmpty() ? m_config.allSpamQuery() : m_config.account(key).spamQuery()`; when empty, `showTransientStatus(tr("No spam folder is configured"))` and return (an empty notmuch query matches everything); otherwise `QMetaObject::invokeMethod(m_worker, "resolveQueryMessages", Qt::QueuedConnection, Q_ARG(QString, query), Q_ARG(QString, QStringLiteral("empty_spam")));`
+
+- [ ] **Step 4: The `"empty_spam"` branch** in `onThreadMessagesResolved()` beside the `empty_trash` branch (line ~6449). It groups per account and moves each group to that account's own trash:
+
+```cpp
+ if (requestTag == QStringLiteral("empty_spam")) {
+ QHash<QString, QStringList> byTrash;
+ for (int i = 0; i < messageIds.size(); ++i) {
+ const Account account = accountForMessagePath(paths.at(i));
+ if (account.maildir.isEmpty() || account.trash.isEmpty())
+ continue;
+ byTrash[account.maildir + QLatin1Char('/') + account.trash]
+ .append(messageIds.at(i));
+ }
+ for (auto it = byTrash.cbegin(); it != byTrash.cend(); ++it) {
+ sendMove(it.value(), it.key(),
+ { QStringLiteral("deleted"), kOriginTagPlaceholder() },
+ { QStringLiteral("spam"), QStringLiteral("unread"),
+ QStringLiteral("inbox") },
+ tr("Empty spam"));
+ }
+ return;
+ }
+```
+
+ (The user confirmed `unread` is stripped alongside `spam` and `inbox`, matching Delete's item 168 precedent. The placeholder resolves per message to `moved-from:<the spam folder it is leaving>`, and Task 3's overwrite rule keeps exactly one origin.)
+
+- [ ] **Step 5: `isShowingSpam()`** mirroring `isShowingTrash()` (line 3673), comparing `m_lastQuery` against `m_config.allSpamQuery()` and each `account.spamQuery()`. Then change the refresh gate in `onMessagesMoved()` (line ~7365) to `if (isShowingTrash() || isShowingSpam()) refreshCurrentQuery();` so emptying spam while the Spam filter is open drops the rows.
+
+- [ ] **Step 6: Tests** in `tests/test_mainwindow.cpp` (WorkerBackedWindow):
+ - *groups per account*: seed two accounts each with mail in its spam folder, Empty Spam, assert each message lands under its OWN account's trash;
+ - *rewrites the origin*: move inbox -> spam, then Empty Spam, assert exactly one `moved-from:` remains and it names the spam folder (`tag:"moved-from:Spam"` count is 1, `tag:"moved-from:inbox"` is 0);
+ - *refuses an empty query*: with no spam folder configured, assert no worker round trip and a status message naming the cause.
+
+- [ ] **Step 7: Run.** `ctest --test-dir build -R 'keymap|mainwindow'` passes.
+- [ ] **Step 8: Commit.** `git add src/keymap.cpp src/mainwindow.h src/mainwindow.cpp tests/test_mainwindow.cpp && git commit -S -m "feat: empty spam moves mail to the trash, per account"`
+
+---
+
+### Task 7: Stranded spam cleanup
+
+**Files:**
+- Modify: `src/keymap.cpp` (`knownActions()`)
+- Modify: `src/mainwindow.h` (`showStrandedSpamMail()`)
+- Modify: `src/mainwindow.cpp` (action, menu, icon, `showStrandedSpamMail()`)
+- Test: `tests/test_mainwindow.cpp`
+
+**Interfaces:**
+- Consumes: `Config::allSpamQuery()`, `runQuery()`, `m_queryEdit`, `m_statusLabel`.
+- Produces: `MainWindow::showStrandedSpamMail()`.
+
+- [ ] **Step 1: Register.** In `knownActions()` add `QStringLiteral("cleanup_stranded_spam"),` (no default binding). Icon table: `system-search`. Menu: beside `cleanup_stranded` (line ~2093). Choose an accelerator that does not collide in the Message menu.
+
+- [ ] **Step 2: The action** calling `showStrandedSpamMail()`, label `tr("Find stranded s&pam")` (or another free accelerator), tip `tr("Show mail tagged spam that is not in a spam folder")`.
+
+- [ ] **Step 3: Implement `showStrandedSpamMail()`** mirroring `showStrandedDeletedMail()` (line 6852), copying both load-bearing details:
+
+```cpp
+void MainWindow::showStrandedSpamMail()
+{
+ const QString spam = m_config.allSpamQuery();
+ // Never written as `not ()`: notmuch parses that happily and matches
+ // nothing, reporting a clean database.
+ const QString query =
+ spam.isEmpty()
+ ? QStringLiteral("tag:spam")
+ : QStringLiteral("tag:spam and not (%1)").arg(spam);
+
+ m_queryEdit->setText(query);
+ runQuery(FlatResult::No, AccountScope::AlreadyScoped);
+
+ m_statusLabel->setText(tr("Mail tagged spam but not in a spam folder. "
+ "Select what should go and press Mark spam."));
+}
+```
+
+ (`AlreadyScoped` so the account dropdown does not hide another account's stranded mail, and the status is set after `runQuery()`, which overwrites it.)
+
+- [ ] **Step 4: Tests** in `tests/test_mainwindow.cpp`: assert the composed query excludes `path:"work/Spam/**"` when a spam folder is configured, and is exactly `tag:spam` when none is. Mirror the existing stranded-deleted test.
+
+- [ ] **Step 5: Run.** `ctest --test-dir build -R 'keymap|mainwindow'` passes.
+- [ ] **Step 6: Commit.** `git add src/keymap.cpp src/mainwindow.h src/mainwindow.cpp tests/test_mainwindow.cpp && git commit -S -m "feat: find stranded spam mail"`
+
+---
+
+### Task 8: Translations, changelog, README
+
+**Files:**
+- Modify: `translations/qtmaildir_it_IT.ts` (and the generated `.qm`)
+- Modify: the changelog's `[Unreleased]` section, `README.md` if it documents the config keys.
+
+- [ ] **Step 1:** Refresh the catalogue:
+
+```bash
+lupdate-qt6 src/ -ts translations/qtmaildir_it_IT.ts -no-obsolete -locations none
+```
+
+ Expect zero context warnings (`tr() cannot be called without context`).
+
+- [ ] **Step 2:** Translate the new strings in the `.ts`: the `Spam` filter label, `Empty spam...`, the stranded-spam status, the spam-shortcut/tooltip strings, and the missing-`spam`-key warning.
+
+- [ ] **Step 3:** `lrelease translations/qtmaildir_it_IT.ts` reports 0 unfinished (an unfinished string is silently dropped and ships as English).
+
+- [ ] **Step 4:** `ctest --test-dir build -R translations` passes.
+
+- [ ] **Step 5:** Add the changelog and an `### Upgrading` note: every account now needs a `spam` key beside `trash`; the origin tag is renamed `deleted-from:` -> `moved-from:`. Note that the one-time notmuch tag rename on live mail is the user's own step, not part of the code change.
+
+- [ ] **Step 6: Commit.** `git add translations/ CHANGELOG.md README.md && git commit -S -m "docs(i18n): translate the spam strings"`
+
+---
+
+## Self-review
+
+- **Spec coverage:** config key + queries (Task 1), the Spam filter (Task 2), origin rename (Task 3), the move action (Task 4), message bar + icon (Task 5), Empty Spam (Task 6), stranded cleanup (Task 7), translations/docs (Task 8). The trash-predicate test is in Task 4 Step 6. Item 196 (abusectl auto-tagging) and item 197 (not-spam) are out of scope by the spec.
+- **Type consistency:** `spamQuery`/`allSpamQuery`/`isShowingSpam`/`spamSelected`/`spamMessages`/`spamThreads`/`emptySpam`/`showStrandedSpamMail`/`kOriginTagPrefix` are used with one spelling throughout.
+- **Decided:** Empty Spam removes `spam`, `unread` and `inbox` (user confirmed `unread`), and adds `deleted` + the origin placeholder. It carries no confirmation dialog and no default shortcut; it is a move and is undoable.
+- **Known blast radius:** Task 3 renames a string literal in ~35 test sites; `grep -rn "deleted-from" src tests` must be empty afterwards.