diff options
Diffstat (limited to 'docs/superpowers/plans/2026-09-13-spam-view.md')
| -rw-r--r-- | docs/superpowers/plans/2026-09-13-spam-view.md | 468 |
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. |
