aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-10 12:31:08 +0200
committerDanilo M. <danix@danix.xyz>2026-09-10 12:31:08 +0200
commit93a3959a1ebf630bfb0ade6f82b2d86e4f17b4a6 (patch)
tree799fbb25a62fe9b29c55425ad80bd3ba79cc5762
parent79d30c128157937056dc2abf63f1f74728ca1a92 (diff)
downloadqtmaildir-93a3959a1ebf630bfb0ade6f82b2d86e4f17b4a6.tar.gz
qtmaildir-93a3959a1ebf630bfb0ade6f82b2d86e4f17b4a6.zip
docs: specify the Spam view, the spam move and Empty Spam
Items 187, 190 and 195, settled with the user and specified together in docs/superpowers/specs/2026-09-10-spam-view-design.md. Nothing is built yet; implementation follows on a branch. Mark spam becomes a move into the account's spam folder, following item 103's Delete-to-trash design rather than inventing a second mechanism: a mandatory per-account `spam` key, a path-based threaded Spam filter, and a repeatable cleanup pass for the mail the tag-only action stranded. `unread` is stripped by the move (item 195, verified in the code: the call names `spam` and `inbox` and nothing else). The message-bar button carries `bug` with `mail-mark-junk` as its fallback, measured against the user's icon theme, where the standard name draws a warning octagon and `bug` draws the beetle the notes asked for; a bare `bug` was rejected because it resolves in 0 of the 24 system themes and would leave a blank button. Two changes came from the user after the first draft and both improved it. The origin tag is renamed `deleted-from:` -> `moved-from:`, which the first pass had rejected on a migration cost that turned out to be 8 messages carrying one distinct value; and Empty Spam moves mail to the trash per account, which needs no new grouping because trashMessages() already resolves each message's own account. A message can therefore leave two folders in turn, and the existing reader takes the first matching tag and breaks. Rather than encode ordering in the tag, which notmuch's unordered tag set cannot answer, a move overwrites the origin instead of appending: one tag ever, one hop back per Restore. Empty Spam inherits neither of empty_trash's safeguards, deliberately. It moves rather than destroys, so it has an inverse, and a confirmation on an undoable action is the defect AGENTS.md names. Item 197 is filed for a future "not spam": Restore already covers what this application moved, and reverting the provider's own filter needs a destination rule and possibly a sidecar, neither of which is decided. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LrqM5LJGAQEryvs5r7usM1
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md94
-rw-r--r--docs/superpowers/specs/2026-09-10-spam-view-design.md297
2 files changed, 390 insertions, 1 deletions
diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
index f48479e..27da155 100644
--- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
+++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
@@ -260,7 +260,7 @@ taking that too literally.
| 184 | New mail waits up to ten minutes, because sync is a fixed cron tick | workflow | ? | open, 2026-08-29, from the user: the 10 minute tick "has always bothered me", and it is already a compromise down from 30. Outgoing edits are immediate (`auto_sync_delay_ms`), so this is the INCOMING half only. Polling faster is not the answer; IMAP IDLE is, and it lives in a watcher that triggers `mailsync.sh`, NOT in qtmaildir, which does no network protocol work. Needs decisions first: which watcher, whether it packages on Slackware, and what the server supports. **Blocked on 174**, whose status file is the reporting channel this needs anyway |
| 185 | The message-pane bar offers Reply and Forward on a trashed message | presentation | S | **done 2026-08-29**, unreleased, with 186. The bar has a third branch keyed on the SELECTION being in a trash folder, the same predicate the menus use: Restore, Delete permanently and Empty trash replace the reply pair, and Restore alone is tinted. Added `purge`, the selection-scoped sibling of `empty_trash`, which inherits both its safeguards. Refilled from the digest as well as from the selection, since a conversation's trash-ness is not known until every path is reported. Section in the closed file. Original entry: `MainWindow::refreshMessageBarActions()` (`mainwindow.cpp:2311`) swaps the bar's message half for a DRAFT and for nothing else, so the trash view shows the two actions that make least sense there. The notes ask for Restore and Delete permanently in their place, and for Delete to move here from the main toolbar (item 186). The visibility rules already exist in `refreshTrashActions()`; what is missing is the bar consulting them |
| 186 | Delete sits on the main toolbar rather than beside Reply and Forward | presentation | XS | **done 2026-08-29**, unreleased, with 185. Moved to the message bar's ordinary branch; still in the Message and context menus. Section in the closed file. Original entry: `toolBar->addAction(... "delete")` at `mainwindow.cpp:2251`. The user places it with the message actions, so this rides with item 185 rather than being done alone: moving it before the bar is trash-aware leaves Delete in a bar that still offers Reply on trashed mail |
-| 187 | There is no Spam view beside Trash | workflow | M | open, 2026-08-29, from the notes; **shape settled 2026-08-29** after two corrections and three decisions from the user. Spam works like Trash: path-based, a mandatory per-account `spam` key, and Mark spam MOVES the file. Every account can now reach a spam folder, the three Gmail ones having gained `[Gmail]/Spam` in `.mbsyncrc` this session. Grew from S to M: the move path, the origin tag and a cleanup pass are three parts, and it changes what an existing action does. See the entry |
+| 187 | There is no Spam view beside Trash | workflow | M | open, **specified 2026-09-10** in `specs/2026-09-10-spam-view-design.md`, which covers 190 and 195 too; read that rather than this row. Grew again: the user added Empty Spam (a MOVE to the trash, per account) and the `deleted-from:` -> `moved-from:` rename. 2026-08-29, from the notes; **shape settled 2026-08-29** after two corrections and three decisions from the user. Spam works like Trash: path-based, a mandatory per-account `spam` key, and Mark spam MOVES the file. Every account can now reach a spam folder, the three Gmail ones having gained `[Gmail]/Spam` in `.mbsyncrc` this session. Grew from S to M: the move path, the origin tag and a cleanup pass are three parts, and it changes what an existing action does. See the entry |
| 188 | Does Empty trash respect the account selector? | question | XS | **answered 2026-08-29** by reading the code, no work needed. It does: `MainWindow::emptyTrash()` (`mainwindow.cpp:6567`) reads `m_accountBox->currentData()` and uses `allTrashQuery()` only for All accounts, and the confirmation names which. Recorded so the notes' question has an answer rather than sitting open |
| 189 | The message bar carries only Reply, Forward and Delete | presentation | S | **done 2026-08-29**, unreleased. Star and Archive joined the bar's ordinary branch, Archive leaving the main toolbar as Delete did. `mark_all_read` deliberately did NOT move, at the user's decision: it is the one action that ignores the selection. Item 140's toolbar test listed `archive` as a list-wide action and had to be corrected, which is the classification this item changed. Section in the closed file. Original entry: Asks for Star (`flag`) and Archive on the bar, and raises Mark all read as a question. Two of the three are selection-scoped and fit the bar's rule as it stands; **`mark_all_read` does not**, since it deliberately ignores the selection and acts on every row in the view, which is the one action in the window that does. Needs a decision from the user on that one and on whether Archive LEAVES the main toolbar the way Delete did |
| 190 | Mark spam is not on the message bar, and its icon was never chosen for one | presentation | XS | open, 2026-09-06, from the notes. The bar's ordinary branch carries Reply, Forward, Star, Archive, Delete after item 189 and `spam` is not among them, though it meets the bar's rule (selection-scoped, undoable). Two halves: put it on the bar, and settle the icon, which the note asks to be "a bug, or a skull, or something that signifies bad/evil" and which is `mail-mark-junk` today, chosen for a menu where the label carries the meaning. **Paired with 187**, which changes what the action DOES (moves the file); ordering is the user's call |
@@ -268,6 +268,9 @@ taking that too literally.
| 192 | A sent message does not appear in the Sent view until the next sync | defect | XS | **done 2026-09-06**, unreleased. The sent copy was filed correctly and never announced, so the index did not know it and the Sent view, a path query, could not show it. Measured as 65 files against 64 indexed. One signal to the worker, mirroring what drafts have had since item 158. The open question, whether the view should also refresh, was answered yes by the user on 2026-09-07 and built: `indexChanged()` to `refreshCurrentQuery()`. Section in the closed file |
| 193 | The composer has no headings control | v2 | S | open, 2026-09-08, from the notes: "headers dropdown in the editor, H1 to H6 translating to #, ## ... already supported by the html render". The note is right about the renderer: cmark-gfm parses ATX headings in the core grammar, so `## x` already renders. The gap is composer-side. A heading is a LINE PREFIX, not a wrap, so it cannot go through `applyFormat()`/`MarkdownFormat::wrap()`; it is `quote()`'s shape, and unlike quote it must REPLACE an existing prefix rather than stack one, or a second press gives `## ## x`. That makes it the first formatting control that has to read the line's current state, which is item 135's question arriving early on one control |
| 194 | No abuse reporting from a flagged message | workflow | L, split | open, 2026-09-08, from the notes and **confirmed by the user the same day as a feature they want and will build**. Parse a flagged `.eml`, extract IOCs, resolve abuse contacts via RDAP, generate X-ARF (RFC 5965), fan out to AbuseIPDB/URLhaus/VirusTotal and to abuse desks, backed by MISP via PyMISP. **One gesture here, the engine in a sidecar**: the split is architectural (four outbound protocols, which `src/` does not do) and not a judgement on the feature. qtmaildir's half is a message-bar button that marks spam and offers to report, with a confirmation; it is S and buildable before the sidecar exists. The user is a security consultant filling a phishing database, so the sidecar is the point rather than an accessory. Needs a spec for the sidecar; the qtmaildir half needs only 187/190 settled. Two of the user's constraints are safety properties: redact recipient identifiers before submission, and never fetch remote content during parsing |
+| 195 | Mark spam leaves the message unread | defect | XS | open, 2026-09-10, from the notes ("marking a message as spam without reading it doesn't remove the unread tag"). Verified: the action at `mainwindow.cpp:1788` adds `spam` and removes `inbox`, and names no other tag, so an unmarked message keeps `unread` and every unread count keeps counting it. Small on its own; it touches the same action item 187 rewrites into a move, so doing it inside 187 costs nothing and doing it alone is a two-word change to one `tagSelected()` call. One question for the user: whether marking spam should mark read, or whether the tag should simply not be part of the unread views once 187 makes the view path-based |
+| 196 | Spam is never tagged automatically | workflow | ? | open, 2026-09-10, from the notes ("the app should be able to tag spam automatically leveraging intel from abusectl"). Depends on 194's sidecar existing: `~/Programming/GIT/abusectl` is a repo but nothing is on `PATH`, so the intel this would read does not yet have a shape to read. Also unspecified in direction: the natural home is the `post-new` hook rather than `src/`, since tagging at sync time is what `assets/hooks/mailrules.py` already does, and a rule sourced from an external database is a format question for both readers (see "Changing the rule format"). Ask the user what abusectl would expose before designing anything |
+| 197 | No way to say a message is not spam | workflow | S | open, 2026-09-10, split out of the 187 design at the user's decision rather than built into it. Restore already covers what qtmaildir moved: a message it marked carries `moved-from:` and goes back where it came from. The gap is mail the PROVIDER's filter caught, which was never in an inbox and carries no origin tag, so "not spam" has no recorded destination to return it to. Needs two answers before it can be planned: where such a message goes (the account's inbox is the obvious guess and is a guess), and whether anything should tell the provider its filter was wrong, which is network work this application does not do and would belong in a sidecar like item 194's. No seam is needed in the meantime: `sendMove()` already takes any destination and any tags |
Sizes are rough: XS under an hour, S a sitting, M a session.
@@ -1602,3 +1605,92 @@ with the exit status and stdout as the report); and what qtmaildir shows when a
report succeeds, partially succeeds, or fails, since a fan-out to four
destinations can do all three at once.
+
+## 195. Mark spam leaves the message unread
+
+**Observed (user, from the notes):** "marking a message as spam without reading
+it doesn't remove the unread tag."
+
+**Cause.** Verified, not assumed. The `spam` action at `mainwindow.cpp:1786`
+calls `tagSelected({ "spam" }, { "inbox" }, ...)`: it names exactly two tags,
+so `unread` is untouched by construction. The message leaves the inbox and
+keeps counting toward every unread view.
+
+**Approach.** Add `unread` to the removal list of that one call. It is a
+two-word change and the surrounding machinery already covers it: the write goes
+through `applyTags`, which reports only the ids whose tags actually moved (item
+176), so a spam mark on an already-read message pushes no bogus undo, and
+`syncViewMembership()` evicts it from Unread on the same funnel as any other
+read.
+
+**Constraints.** Item 187 rewrites this action into a file move, so the cheapest
+path is to fold this in there rather than shipping a separate commit that 187
+then rewrites. Doing it alone is still fine and costs nothing.
+
+**One question for the user.** Whether marking spam should mark READ, or whether
+the right answer is that a spam message stops matching the unread views at all
+once 187 makes those views path-based. The first is what the note literally
+asks for; the second falls out of 187 for free and means an unread spam message
+is still honestly unread if it is ever restored. They are not the same and the
+choice is theirs.
+
+## 196. Spam is never tagged automatically
+
+**Observed (user, from the notes):** "the app should be able to tag spam
+automatically leveraging intel from abusectl."
+
+**Cause.** Nothing tags spam except the user pressing the action. The one
+automatic tagging path in the system is the `post-new` hook
+(`assets/hooks/mailrules.py`), which applies the rules in
+`~/.config/mailrules/rules.json` and knows nothing outside them.
+
+**Blocked on 194.** `~/Programming/GIT/abusectl` exists as a repository and
+nothing of that name is on `PATH`, so the intel this item would consume has no
+shape yet. What it exposes, and how, is 194's sidecar design.
+
+**Approach, in outline only.** The home for this is the hook, not `src/`:
+tagging at sync time is exactly what `mailrules.py` already does, and doing it
+in the GUI would mean the tag depends on the application being open. That makes
+it a rule sourced from an external lookup rather than from a stored query, which
+is a format question for BOTH readers of `rules.json` and needs the procedure in
+`AGENTS.md` under "Changing the rule format".
+
+**Constraints.** Two of the hook's safety properties apply directly. It refuses
+to remove `unread` or `inbox`, so an automatic spam rule can only ADD a tag; and
+it must not consume `tag:new` when its rules fail to load, which matters more
+here because an external lookup can fail in ways a stored query cannot. Whatever
+the design, a network lookup inside the hook is a new failure mode for a process
+that currently runs offline against the local index.
+
+**Ask the user what abusectl would expose before designing this.** A local
+database queried per message, a periodically refreshed blocklist file, and a
+callable command are three different items sharing one sentence in the notes.
+
+## 197. No way to say a message is not spam
+
+**Observed.** Split out of the item 187 design on 2026-09-10, at the user's
+decision, rather than built into it: "maybe we could already provision for a
+future 'unmark spam' action so that we can revert a filter decision".
+
+**What already covers half of it.** Restore handles every message this
+application moved. Mark spam writes `moved-from:<folder>` and Restore reads it
+back, so unmarking is the existing gesture under a different name.
+
+**The real gap is the provider's filter, not ours.** Mail the provider caught
+was never in an inbox, arrived directly in the spam folder, and carries no
+origin tag. Restore falls back to the account's inbox for exactly this case,
+which is a documented guess rather than a recorded destination.
+
+**Two questions decide the shape, and neither is answerable from the code.**
+
+1. Where does a message with no origin go? The account's inbox is the obvious
+ answer and is still a guess; a user who wants it filed somewhere else has no
+ way to say so.
+2. Should anything tell the PROVIDER its filter was wrong, so it learns? That
+ is outbound network work, which this application does not do by design. It
+ would belong in a sidecar, like item 194's.
+
+**No seam is needed in the meantime.** `sendMove()` already takes any
+destination and any tag lists, so a Not-spam action is a caller rather than a
+capability. Provisioning for it now would be a hook with one hypothetical
+caller, which is what YAGNI names.
diff --git a/docs/superpowers/specs/2026-09-10-spam-view-design.md b/docs/superpowers/specs/2026-09-10-spam-view-design.md
new file mode 100644
index 0000000..d5ffdc7
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-10-spam-view-design.md
@@ -0,0 +1,297 @@
+# Mark spam moves mail, and there is a Spam view
+
+**Resolves items 187, 190 and 195.** Design settled 2026-09-10 with the user,
+on top of the shape item 187 recorded on 2026-08-29. Item 196 (automatic spam
+tagging from abusectl) is out of scope and blocked on item 194's sidecar.
+
+## The problem, measured
+
+`Mark spam` adds the notmuch tag `spam` and removes `inbox`
+(`src/mainwindow.cpp:1786`). Nothing else happens:
+
+1. The file never moves. It sits in the inbox folder with a tag on it, which is
+ exactly the half-done state item 103 removed for Delete.
+2. There is no view that lists it. `kQueryGenerators` (`src/config.cpp:67`) is a
+ closed set of six: `unread`, `inbox`, `flagged`, `sent`, `drafts`, `trash`.
+3. `unread` is not touched, so a message marked spam without being read keeps
+ counting toward every unread view. This is item 195, reported by the user in
+ their own notes and verified in the code rather than assumed: the call names
+ two tags and `unread` is not one of them.
+4. The action is absent from the message bar (item 190), though it meets the
+ bar's rule of being selection-scoped and undoable.
+
+So Mark spam is a filing action that files nothing, and the mail it marks is
+invisible afterwards.
+
+## Prerequisite, already true
+
+Every account has a spam folder, and every one of them is synced. Verified
+2026-09-10 against the live Maildir and `~/.mbsyncrc` rather than assumed:
+
+| account | spam folder | how it syncs |
+|---|---|---|
+| three provider-a accounts | `[Gmail]/Spam` | named explicitly in `Patterns` |
+| provider-b, account one | `Spam` | covered by `Patterns *` |
+| provider-b, account two | `Spam` | covered by `Patterns *` |
+
+Two folders were rejected rather than overlooked. One account has a `Junk`
+beside its `Spam`, which item 187 already ruled out of scope as unused. The
+other has a `confirmed-spam` beside its `Spam`; the user chose `Spam`, so
+`confirmed-spam` stays a folder they file into by hand and this application
+never writes to it.
+
+This matters because of item 103's trap: `Create Both` turns a wrong folder
+name into a folder created on the server, where every other client then sees
+it. The names above were read off the disk, not composed.
+
+## Config
+
+One new per-account key, `spam`, relative to `maildir`, beside `trash`.
+
+**Mandatory**, exactly as `trash` is, and for the same reason: a per-account
+optional folder reintroduces the "some accounts can mark spam and some cannot"
+branch that item 103 deliberately removed. A missing `spam` is a config error
+reported through the existing warnings path.
+
+`Account::spamQuery()` beside `trashQuery()`, and `Config::allSpamQuery()`
+beside `allTrashQuery()`. Both are the existing helpers with a different field:
+`folderQuery(maildir, spam)` and `joinAccountQueries(m_accounts,
+&Account::spamQuery)`. Nothing new is invented here.
+
+## The view
+
+A seventh built-in filter, `spam`, appended to `kQueryGenerators` and named in
+`Config::builtinFilter()`.
+
+**Trash is the template in every respect**, which is the whole reason this item
+is M rather than L:
+
+- **Path-based, not `tag:spam` based.** The folder is the truth. This is what
+ lets the view show mail the provider's own filter caught, which is most of
+ what those folders hold, and it is the user's stated reason for choosing it on
+ Trash: it distinguishes what this application marked from what something else
+ did.
+- **Threaded, not flat.** Like Trash and unlike Sent: a spam message still
+ belongs to its conversation, and folding it back is a problem Sent had to
+ avoid rather than one every folder filter has.
+- **Composes with the account dropdown.** All accounts gives the union,
+ `matchNothingQuery()` when that union is empty. One account gives
+ `scope.spamQuery()`, the account's OWN query, never the all-accounts query
+ wrapped in this account's path. That wrap returns the right rows by accident
+ of `path:` being hierarchical, which is exactly why the test asserts on the
+ generated STRING.
+
+## The action
+
+`spam` moves the file into the account's spam folder, through the existing
+`sendMove()`. It stops being a tag-only action and becomes the same shape as
+Delete.
+
+```
+sendMove(ids, <account>/<spam folder>,
+ { "spam", kOriginTagPlaceholder() }, // add
+ { "unread", "inbox" }, // remove
+ tr("Mark spam"))
+```
+
+Three things about that call are decisions:
+
+**The origin tag is `moved-from:`, renamed from `deleted-from:`.** The user
+chose this over reusing the deletion-named tag, and the objection that killed
+reuse in the first pass was priced wrong. The migration cost was estimated as
+"hundreds of messages, so a dual-prefix reader indefinitely"; measured on the
+live index on 2026-09-10 it is **8 messages carrying one distinct tag value**,
+all of them drafts. A one-time rename does it:
+
+```
+notmuch tag +moved-from:'<folder>' -deleted-from:'<folder>' \
+ -- tag:'deleted-from:<folder>'
+```
+
+So the reader stays single-prefix, there is no compatibility branch, and the
+tag stops being named for an action it no longer only serves. Delete and Mark
+spam both write it; `originTagFor()` changes the prefix it composes and nothing
+else.
+
+**One origin tag ever, overwritten on each move.** This is the rule the user
+chose, and it is what makes Empty Spam below safe. A move STRIPS any existing
+`moved-from:` and writes the folder it is leaving now, so a message can never
+carry two. Restore is then one hop back per press: out of the trash into spam,
+out of spam into the inbox.
+
+The alternative, keeping the whole chain, was rejected on a property of notmuch
+rather than on taste: **notmuch tags are an unordered set**, so a reader cannot
+ask which origin came first without the tag encoding its own ordering. The
+existing reader takes the FIRST tag matching the prefix and breaks
+(`mainwindow.cpp:6901`), which with two origins picks one silently. That is the
+same defect the code already records at `mainwindow.cpp:5971`, where a message
+held `deleted-from:inbox` and `deleted-from:Trash` at once "and no way to tell".
+Overwriting removes the question instead of answering it.
+
+The cost is accepted and is small: a message that reached the trash by way of
+spam has forgotten it was ever in the inbox, so returning it home is two
+presses rather than one. The user's own words: "once it goes back in spam it can
+be restored in inbox, no need to remember where it came from."
+
+**`unread` is stripped, in the SAME write.** This is item 195, and it follows
+Delete's precedent from item 168 exactly. In the same change rather than as a
+second write, so one undo returns the folder and the tag together:
+`TagChange::inverted()` gives it back only if it travelled with the move. As
+with Delete, this rewrites the Maildir filename because
+`maildir.synchronize_flags` is true, and so reaches the server on the next
+mbsync. That is the same mechanism the `post-new` hook refuses to touch, and the
+difference is the same one: the hook tags arriving mail unattended, while this
+is an explicit gesture on a message in front of the user.
+
+**`inbox` goes with it**, for the reason item 168 measured on Delete: without
+it, a message marked spam FROM the inbox keeps the tag the Inbox filter matches
+on and stays in that view after being thrown away.
+
+**Undo needs no new code, and Restore needs only the new prefix.** A move is already undoable and
+`restoreSelectedFromTrash()` already resolves the origin from the database
+rather than from the model, which is the property item 170 exists to protect.
+
+## The message bar
+
+`spam` joins the bar's ordinary branch beside Reply, Forward, Star, Archive and
+Delete (item 190). It meets the bar's rule: selection-scoped and undoable.
+
+**Icon: `bug`, falling back to `mail-mark-junk`.** Measured on the user's
+machine rather than chosen from memory:
+
+- Their theme draws `mail-mark-junk` as a warning octagon with an exclamation
+ mark, which is not what the note asked for ("a bug, or a skull, or something
+ that signifies bad/evil") and reads as generic warning beside the other bar
+ icons.
+- Their theme ships `bug` as a clear ladybug, which is.
+- `bug` is NOT a freedesktop standard name and appears in 0 of the 24 system
+ themes on that machine; `mail-mark-junk` is standard and appears in 8.
+
+So the chain is strictly better on their desktop and identical to today's
+behaviour everywhere else, where `bug` resolves to nothing and the fallback
+answers. A bare `bug` was rejected because it gives a BLANK toolbar button on
+every standard theme, which is the failure item 70's split exists to avoid.
+
+This is the first entry in the icon table to carry a fallback, so the table's
+value becomes a name plus an optional fallback rather than a single name. The
+no-duplicate-icons test compares what the table holds, so it keeps working on
+the primary name.
+
+## The trash predicate must not answer for spam
+
+`everySelectedRowIsInATrashFolder()` decides what the message bar offers:
+Delete hides when it is true, Restore and Purge appear. A spam folder must NOT
+satisfy it, or marking a message spam would hide Delete on it and offer Purge,
+which destroys mail with no undo.
+
+The predicate compares against `account.trash` only, so it is already correct.
+It is named here because the handoff flagged it and because the mistake is
+invisible: nothing would fail, the bar would simply offer the wrong actions on
+spam. A test asserts it directly.
+
+## Empty Spam
+
+**A MOVE into the trash, not a purge**, and that difference decides everything
+else about it. `empty_trash` destroys files and is the one irreversible action
+in this application, so item 118 gave it a confirmation dialog and no default
+shortcut. Empty Spam destroys nothing: it moves mail one folder further along,
+the move is undoable like any other, and Restore brings it back. It therefore
+inherits NEITHER safeguard, and adding a confirmation to it would be the defect
+`AGENTS.md` names, a second confirmation on an action that has an inverse.
+
+**Per account, never pooled.** The user's constraint, and it is item 103's trap
+restated: one account's spam must not land in another account's trash. The
+grouping already exists and is reused rather than rebuilt. `trashMessages()`
+builds a `QHash<QString, QStringList> byTrash` keyed on
+`account.maildir + "/" + account.trash`, resolved from each message's OWN path
+through `accountForMessagePath()`, precisely because a selection can span
+accounts. Empty Spam groups the same way, so five accounts produce up to five
+moves and each message reaches its own account's trash.
+
+Scoped to the account dropdown like every other account-aware surface, exactly
+as `emptyTrash()` is: All accounts empties every configured spam folder, a
+selected account empties only its own.
+
+**The origin tag is rewritten, not appended**, per the one-tag rule above. Each
+message leaves with `moved-from:<its own account's spam folder>`, replacing the
+`moved-from:<inbox>` it may have carried. Restore from the trash then returns it
+to Spam, and a second Restore returns it to the inbox.
+
+**An empty query must never be run.** `emptyTrash()` refuses when the resolved
+query is empty and says why, because an empty notmuch query matches EVERYTHING.
+This inherits that guard rather than trusting the worker's own.
+
+## Cleanup of stranded spam
+
+Every message the action has ever marked is tagged `spam` and sitting where it
+always was. That mail is now half-filed in exactly the way item 103 described:
+absent from the Spam view, and unreachable by Restore.
+
+A menu entry beside `cleanup_stranded`, copying
+`showStrandedDeletedMail()` rather than inventing a second mechanism. It runs
+`tag:spam and not <spam paths>` into the ordinary thread list, reports what it
+finds, and moves nothing. The user selects what should go and presses Mark
+spam.
+
+Two details of the precedent are load-bearing and must be copied:
+
+- An empty folder list must never be written as `not ()`, which notmuch parses
+ happily and matches nothing, reporting a clean database.
+- It runs `AlreadyScoped`, so the account dropdown does not narrow it and hide
+ another account's stranded mail.
+
+**Repeatable, not a one-time migration**, for the reason the user gave on item
+103: "I don't like that it's one time. Maybe I don't have time to deal with it
+at that moment."
+
+## What is NOT in scope
+
+- **Automatic spam tagging from abusectl** is item 196, blocked on item 194's
+ sidecar. Nothing here reads an external source.
+- **Purging spam outright.** `empty_trash` and `purge` stay trash-only. Empty
+ Spam moves mail to the trash; destroying it is then the trash's job, through
+ the machinery that already asks before it runs. Spam never gains a second
+ irreversible action.
+- **An "unmark spam" / "not spam" action.** Filed as a backlog item rather than
+ built here, at the user's decision. Restore already covers the case this
+ application created: a message it moved carries `moved-from:` and goes back
+ where it came from. What is genuinely missing is reverting a decision made by
+ the PROVIDER's filter, on mail that was never in the inbox and carries no
+ origin tag, and that needs its own answer about where such a message should
+ go and whether the provider can be told its filter was wrong. Building a
+ seam for it now is what YAGNI names: `sendMove()` already takes any
+ destination and any tags, so there is nothing left to provision.
+- **A `Junk` folder.** One account has one; it is unused and the key names one
+ folder.
+
+## Testing
+
+Following the rule this repo states for itself: test what has a right answer,
+and hand the visual half to the user.
+
+- **The generated query STRING**, for both the all-accounts and the per-account
+ case, as every other filter is tested. A row count passes against the
+ double-scoped wrap, which is the trap `Config::resolvedQuery` documents.
+- **The move**, against the throwaway database the worker tests already build:
+ assert the file is at the destination, that the index knows it there and not
+ at the origin, and that `spam` and the origin tag both landed.
+- **`unread` is stripped by the move** (item 195), asserted on a message that
+ was unread when it was marked.
+- **Undo**, asserting the round trip returns the file to its exact original
+ path and strips both tags.
+- **The trash predicate answers false for a message in a spam folder**, which
+ is the silent failure named above.
+- **The cleanup query** excludes mail already in a spam folder, and is not
+ `not ()` when no account configures one.
+- **A missing `spam` key warns**, through the same path `trash` uses.
+- **Empty Spam groups per account**, asserted on a selection spanning two
+ accounts: each message reaches its OWN account's trash. This is the user's
+ stated constraint and the one that silently corrupts filing if wrong.
+- **Empty Spam rewrites the origin tag rather than appending**, so a message
+ moved inbox -> spam -> trash carries exactly one `moved-from:`, naming the
+ spam folder.
+- **Empty Spam refuses an empty query**, since an empty notmuch query matches
+ everything.
+
+The icon and the bar's appearance are handed over to be looked at, per the rule
+that a green suite is not evidence a UI design is right.