aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/specs
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers/specs')
-rw-r--r--docs/superpowers/specs/2026-09-10-spam-view-design.md297
1 files changed, 297 insertions, 0 deletions
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.