aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans/2026-09-13-spam-view.md
blob: 6c33edc71751fd807673fe0b743b41ccb0338575 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
# 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"`

---

### Task 9: Not spam

**Files:**
- Modify: `src/keymap.cpp` (`knownActions()`)
- Modify: `src/mainwindow.h` (`notSpamSelected()`, `notSpamMessages()`, `notSpamThreads()`, `everySelectedRowIsInASpamFolder()`)
- Modify: `src/mainwindow.cpp` (action, icon table, both menus, message-bar spam branch, gating, the three methods, the `not_spam_*` resolution branches)
- Modify: `tests/test_mainwindow.cpp`
- Modify: `CHANGELOG.md`, `README.md`, `translations/qtmaildir_it_IT.ts`

**Interfaces:**
- Consumes: `Account::spam`, `accountForMessagePath()`, `originTagFor()`, `sendMove()`, the `resolveMessages`/`resolveThreadMessages` worker calls, `kOriginTagPrefix`, `m_replySelectionHidesDelete`.
- Produces: `MainWindow::notSpamSelected()`, `notSpamMessages(const QStringList &, const QStringList &, const QStringList &)`, `notSpamThreads(const QStringList &)`, `bool MainWindow::everySelectedRowIsInASpamFolder() const`.

**Behaviour (approved 2026-09-14).** Shown when the selection is in a spam folder, hidden on reply rows and in the trash, like Delete/Restore. Moves each message back to the folder its `moved-from:` tag names, strips `spam` + that origin, adds `inbox` when the destination is the account's inbox. A message with NO origin (provider-caught) falls back to the account's inbox and is reported in the status bar, exactly as `restoreResolvedMessages()` already does for trash. A conversation row acts on the whole conversation, a message row on that message. Undoable, no confirmation, no default shortcut.

- [x] **Step 1: Register.** Add `QStringLiteral("not_spam")` to `KeyMap::knownActions()`, no `defaultBindings()` entry. Icon table: `{ QStringLiteral("not_spam"), { QStringLiteral("mail-mark-notjunk"), QString() } }` (`mail-mark-notjunk` ships in Breeze and Adwaita and is unused in the table, so no icon exception is needed).
- [x] **Step 2: Action + menus.** `addAction(QStringLiteral("not_spam"), tr("Not s&pam"), ...)` with tip `tr("Move the selected messages out of the spam folder")`; choose a mnemonic free in the Message menu (`&p` is `Re&ply`, `&s` is `Mark &spam`; `noMenuHasTwoEntriesSharingAMnemonic()` must stay green). Add the action to `messageMenu` and `m_threadContextMenu`.
- [x] **Step 3: Predicate + gating.** Add `bool MainWindow::everySelectedRowIsInASpamFolder() const` mirroring `everySelectedRowIsInATrashFolder()` but comparing against `account.spam`. In `refreshTrashActions()` compute `const bool inSpam = everySelectedRowIsInASpamFolder();` and set `not_spam` visible with `haveSelection && inSpam && !m_replySelectionHidesDelete`, so it is hidden on a reply row and everywhere outside the spam folder.
- [x] **Step 4: Message bar.** In `populateMessageBar()`, add a branch keyed on `everySelectedRowIsInASpamFolder() && !selection empty`, between the trash branch and the draft branch, whose list is `{ not_spam }`.
- [x] **Step 5: The three methods.** `notSpamSelected()` mirrors `restoreSelectedFromTrash()` but resolves through the worker with request tag `"not_spam_messages"`; `notSpamThreads()` mirrors `untrashThreads()` with `"not_spam_thread"`. Handle both in `onThreadMessagesResolved()` beside `"restore_messages"` and `"undelete_thread"`, clearing `spam` instead of `deleted`. **Prefer parameterising the existing `restoreResolvedMessages()` and the `undelete_thread` branch with the cleared tag and the undo description over copying them**, so the two scopes cannot drift; if you copy instead, say why.
- [x] **Step 6: Tests** in `tests/test_mainwindow.cpp`, `WorkerBackedWindow`, `QTRY_VERIFY_WITH_TIMEOUT`, assertions against the database:
  - mark a message spam, then Not spam: the file returns to its exact original folder, `spam` and `moved-from:` are gone, and `inbox` is back when the origin is the inbox;
  - a provider-caught message (in the spam folder, no `moved-from:`) goes to the account's inbox and the status reports that it had no origin;
  - visibility: offered in the spam view, hidden on a reply row and in the trash view;
  - undo restores it.
- [x] **Step 7: Docs + i18n.** Add a clause to the changelog's `[Unreleased]` Added entry, mention the action in `README.md`, run `lupdate-qt6`/`lrelease-qt6`, translate the new strings, and keep `ctest -R translations` green.
- [x] **Step 8: Run + commit.** `ctest --test-dir build -R 'keymap|mainwindow|translations'`, then `git commit -S -m "feat: a Not spam action"`.

---

## 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), Not spam (Task 9, added 2026-09-14 from backlog item 201 after the branch's final review). The trash-predicate test is in Task 4 Step 6. Item 196 (abusectl auto-tagging) and item 197's provider-notification half are out of scope by the spec; item 197's destination question is settled as the inbox fallback in Task 9.
- **Type consistency:** `spamQuery`/`allSpamQuery`/`isShowingSpam`/`spamSelected`/`spamMessages`/`spamThreads`/`emptySpam`/`showStrandedSpamMail`/`notSpamSelected`/`notSpamMessages`/`notSpamThreads`/`everySelectedRowIsInASpamFolder`/`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. Not spam falls back to the account's inbox for provider-caught mail, at the user's decision on 2026-09-14.
- **Known blast radius:** Task 3 renames a string literal in ~35 test sites; `grep -rn "deleted-from" src tests` must be empty afterwards.