# 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 `. --- ### 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 ` and `` (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 &, 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 &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 `/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` to `QHash>` (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 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:`, 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.