aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md')
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md303
1 files changed, 303 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md
index 945aacd..84685c6 100644
--- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md
+++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md
@@ -10375,3 +10375,306 @@ repository.**
different thing in a different place.
+
+
+## 187. There is no Spam view beside Trash
+
+**Observed.** The user asks for a Spam view next to Trash. Mail can be marked
+spam today and there is no filter that lists it.
+
+**Cause.** `kQueryGenerators` (`config.cpp:62`) is a closed set of six:
+`unread`, `inbox`, `flagged`, `sent`, `drafts`, `trash`. There is no `spam`.
+The `spam` action has existed since the first toolbar and writes the tag
+(`mainwindow.cpp:1770`, adds `spam`, removes `inbox`), so the write half is
+built and the read half is missing.
+
+**Two wrong premises were corrected before any design, and both are worth
+keeping.** This entry first said no account names a spam folder, so a tag
+generator was the only option. Wrong: the accounts synced with `Patterns *`
+had a spam folder all along. It then said the accounts with an explicit
+`Patterns` list could never have one. Also wrong, and the cause was local
+rather than remote: the provider exposes the folder over IMAP and mbsync was
+simply never asked for it. Adding it to those three channels on 2026-08-29
+took one line each, verified against `mbsync --list` rather than guessed,
+which matters because `Create Both` turns a wrong folder name into a folder
+created on the server (item 103).
+
+**So every account can now reach a spam folder, and the design is Trash's.**
+The user settled three things on 2026-08-29:
+
+- **Path-based, exactly like Trash.** Not a tag generator. A tag query finds
+ only what this application marked and misses everything the server filed,
+ which is most of what those folders hold.
+- **Mark spam MOVES the file**, as Delete does. This is a change to an
+ existing action, not only a new view, and it is the part that makes the
+ path-based view honest.
+- **`Junk` is out of scope.** One account has a `Junk` folder beside its
+ `Spam`; it is not used and the key names one folder.
+
+**Approach.** Follow item 103's implementation rather than inventing one.
+
+1. A mandatory per-account `spam` key beside `trash`, an `Account::spamQuery()`
+ beside `trashQuery()`, and `Config::allSpamQuery()` beside
+ `allTrashQuery()`.
+2. `spam` added to `kQueryGenerators` and to `builtinFilter()`, threaded like
+ Trash rather than flat, composing with the account selector through the same
+ path in `resolvedQuery()`.
+3. The `spam` action moves the file instead of only writing tags, through
+ `moveMessages()`, with an origin tag so it can come back. Restore already
+ reads `deleted-from:`; this needs the same for spam, or one shared origin
+ scheme.
+4. A cleanup pass for mail tagged `spam` that never moved, which is every
+ message the action has ever touched.
+
+**The cleanup pass has a precedent and should copy it.**
+`showStrandedDeletedMail()` (item 103) is the same problem one version earlier:
+mail tagged `deleted` whose file never left its folder. It builds
+`tag:deleted and not (<all trash folders>)`, puts it in the query bar, and
+REPORTS, moving nothing, leaving the user to select and act. Do the same with
+`tag:spam and not (<all spam folders>)`. Two details of it are load-bearing:
+an empty folder list must never be written as `not ()`, which notmuch parses
+happily and matches nothing, reporting a clean database; and it runs
+`AlreadyScoped` so the account dropdown does not narrow it and hide other
+accounts' stranded mail.
+
+**Constraints.**
+
+- **A mandatory key breaks every existing config on upgrade**, exactly as
+ `trash` did under item 103. That needs an `### Upgrading` note in the
+ changelog, and the same treatment `trash` got: name the missing key rather
+ than failing silently.
+- **Naming a folder that does not exist reaches the server.** Item 103's
+ lesson, and the reason the three Gmail patterns were verified against
+ `mbsync --list` before being written. A default value is not safe here; the
+ key is named by the user or the account has no spam view.
+- **`Config::matchNothingQuery()` for an account with no spam folder**, never
+ an empty string: notmuch reads an empty query as "match everything", so the
+ Spam button would show the whole Maildir.
+- **The trash view's own predicate must not be confused by this.**
+ `everySelectedRowIsInATrashFolder()` decides which actions the message bar
+ and menus offer (items 185, 186). A spam folder is not a trash folder and
+ must not satisfy it, or Restore and the purges appear on spam.
+- **Mark spam removing `inbox` stays.** The tag half is still what makes the
+ message leave the Inbox view; the move is in addition to it, not instead.
+- **The label is translated, the generator is not.** `spam` is stored in
+ `queries.json` and matched against a closed set, so it is wire format; see
+ the `flagged`/"Important" note in `builtinFilter()`.
+- **Adding a generator changes queries.json's readable set**, so an older build
+ reading a file that names `spam` reports an unknown generator and KEEPS the
+ row. Existing behaviour, no version bump.
+## 190. Mark spam is not on the message bar, and its icon was never chosen for one
+
+**Observed (user, from the notes):** "add \"mark as spam\" to the message pane
+toolbar. Use a bug as the icon (or a skull, or something that signifies
+bad/evil)."
+
+**Cause, verified in the code.** Two independent halves, and neither is a
+regression.
+
+The action exists and has since the first toolbar: `addAction("spam", tr("Mark
+&spam"), ...)` at `mainwindow.cpp:1768` writes `spam` and removes `inbox`
+through `tagSelected()`. It is reachable from the Message menu
+(`mainwindow.cpp:2062`) and the thread context menu (`:2223`), and it carries a
+shortcut, `Ctrl+Shift+S` (`keymap.cpp:151`). What it has never been on is the
+message pane's own bar: `refreshBarActions()` fills the ordinary branch with
+exactly `reply`, `forward`, `flag`, `archive`, `delete` (`mainwindow.cpp:2387`),
+and item 189 added Star and Archive there without raising spam.
+
+It meets the bar's rule as it stands. The bar carries selection-scoped actions
+with an undo behind them, which is why `mark_all_read` was kept off it under
+item 189 and why Star and Archive were let on. `spam` is a `tagSelected()` call
+like those two, so it qualifies on both counts today.
+
+The icon is the second half and is the same latent wrong choice item 189 found
+in `flag`. `{ "spam", "mail-mark-junk" }` (`mainwindow.cpp:2144`) was chosen for
+a MENU, where the label carries the meaning and the icon only decorates it. On
+an icon-only bar the icon IS the control, which is what made Breeze's
+exclamation-mark rendering of `mail-mark-important` a defect rather than a
+preference. Whether `mail-mark-junk` reads as "bad/evil" on the user's theme is
+a question only the user can answer by looking, and the note suggests it does
+not.
+
+**Approach.** Add `spam` to the ordinary branch of `refreshBarActions()`. Order
+is a decision, not a detail: the bar reads answer, then file, then destroy, and
+spam is a filing act whose destination is hostile, so it belongs with Archive
+rather than beside Delete or before Star. For the icon, offer the user the
+theme names that exist rather than picking one unseen; a shipped SVG under
+`assets/icons/marks/` is the fallback if no theme name reads right, but that is
+the panes' convention and the bar is chrome (item 70), so it is a last resort
+rather than a first move.
+
+**Constraints.**
+
+- **The trash branch must not gain it.** `everySelectedRowIsInATrashFolder()`
+ swaps the bar to Restore, Delete permanently and Empty trash (item 185);
+ marking already-trashed mail as spam is not an act the user asked for, and the
+ same question item 187 flags applies here from the other side.
+- **The icon table forbids duplicates** for any action that can reach the
+ toolbar, by the test item 140 established. `mail-mark-junk` is unique today
+ and any replacement must stay so.
+- **Item 187 changes what this action does**, from a tag write to a file move
+ with an origin tag. Doing 190 first puts a button on the bar whose behaviour
+ then changes underneath it; doing 187 first means the button arrives already
+ correct. Neither ordering is wrong and the user chooses, but they should not
+ be built in ignorance of each other.
+
+**Verification.** The bar's contents are a list in one function, so an assertion
+on it is measurable and belongs in the same test item 189 corrected. The icon is
+a visual judgement and belongs to the user, per the rule in `CLAUDE.md`: hand it
+over and let them look.
+## 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.
+## 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.
+
+**Corrected 2026-09-14:** the interface half is item 201. Restore's visibility
+is coupled to `everySelectedRowIsInATrashFolder()`, which by design never
+answers for spam, so "Restore already covers what qtmaildir moved" was a
+capability claim that no surface offered. Read item 201 for the narrower,
+decision-free half.
+## 201. A message in the Spam view cannot be un-spammed, even one qtmaildir put there
+
+**Observed (user, 2026-09-14, testing the `spam-view` branch).** "If a message
+is in spam, how do I unmark it spam?" The only built-in answer found was Ctrl+Z
+immediately after the move, before any other action; an edit of the `spam` tag
+alone leaves the file in the spam folder. The user asked for this to be built on
+`spam-view` before that branch merges.
+
+**Cause.** Verified in `src/`. Three facts together:
+
+1. `spam` is one-way, not a toggle. `MainWindow::spamSelected()` always resolves
+ the selection and calls `spamMessages()`/`spamThreads()`; unlike `delete`
+ (`mainwindow.cpp:1745`) and `flag` (`mainwindow.cpp:1832`) it never asks
+ `everySelectedRowHasTag("spam")`, so pressing Mark spam again moves the file
+ toward the folder it is already in rather than reverting it.
+2. Restore is hidden outside the trash. `refreshTrashActions()`
+ (`mainwindow.cpp:3936`) sets `restore`'s visibility to
+ `(!haveSelection || inTrash) && !m_replySelectionHidesDelete`, where `inTrash`
+ is `everySelectedRowIsInATrashFolder()`, which compares `account.trash` only
+ BY DESIGN so the predicate never answers for spam (the spec requires that, so
+ Delete is not hidden and Purge not offered there). Restore is coupled to the
+ same predicate, which is what hid it in the Spam view.
+3. The write side already works. `restoreSelectedFromTrash()`
+ (`mainwindow.cpp:6907`) resolves the origin from the DATABASE and calls
+ `sendMove()`, which takes any destination and any tag lists. Only the entry
+ point and the gating are missing.
+
+So item 197's "Restore already covers what qtmaildir moved" is true of the
+function and false of the interface. Its stated gap was the PROVIDER-caught
+message with no origin; this item is the narrower half, mail qtmaildir itself
+moved and can put back, and it needs no new decision.
+
+**Approach.** One of two, and the difference is what the user should decide:
+
+- **Widen Restore into the Spam view**, decoupling Restore's visibility from
+ `everySelectedRowIsInATrashFolder()` so a message carrying a `moved-from:`
+ origin can be restored from spam too. Smallest change; makes one action serve
+ both folders.
+- **Add a distinct `not_spam` action** shown on a spam-folder selection, doing
+ the existing restore and also stripping `spam`. Clearer on the bar and in the
+ menu, and costs the five registration places the rule names
+ (`KeyMap::knownActions()`, `defaultBindings()` optional, the icon table, the
+ action, a menu).
+
+Either reuses `sendMove()` and resolves the origin from the database, never the
+model (the message-scoped `restoreSelected()` reads the model, the path item 176
+and the spam-view final review warn against).
+
+**Constraints.** A move's origin is resolved by the worker, never the model
+(`CLAUDE.md`). An undo covers what the write CHANGED, not what it asked for
+(item 176). No confirmation: this is a move and it is undoable. Item 197's open
+questions, where PROVIDER-caught mail with no origin goes (the inbox guess) and
+whether to tell the provider its filter was wrong, stay out of scope here.
+
+**Verification.** A `WorkerBackedWindow` test using `QTRY_VERIFY_WITH_TIMEOUT`:
+mark a message spam, then un-spam it through the new path, asserting the file
+returns to the folder it came from, `spam` and `moved-from:` are gone, and the
+row leaves the Spam view. Because it is a move, assert the undo as well.
+## 202. Mail in a spam folder keeps `inbox`, so it appears in the Inbox view
+
+**Observed (user, 2026-09-14, testing `spam-view`).** "Not spam is available in
+the inbox view; it should appear only when viewing the spam view." The action is
+not at fault: its predicate is folder-based (`everySelectedRowIsInAFolder()`,
+the same rule Restore uses), so it correctly appears on mail whose FILE is in a
+spam folder. Those rows should not have been in the Inbox view at all.
+
+**Cause.** Verified, and not in the application. notmuch's `new.tags` is
+`new;unread;inbox`, so every newly indexed FILE gets `inbox` regardless of the
+folder it sits in. The Inbox built-in filter is `tag:inbox`
+(`Config::generatorTag("inbox")`), not path-scoped, so any file carrying `inbox`
+appears there. The `post-new` hook already corrects this for mail that did not
+ARRIVE: `NOT_ARRIVALS` in `assets/hooks/qtmaildirconf.py:84` is
+`("sent", "drafts")`, and `strip_inbox_from_non_arrivals()` removes `inbox` from
+a message whose files are ALL inside one of those folders. `spam` is missing
+because the hook predates the per-account `spam` key this branch adds. Measured
+live: 49 files tagged `inbox` sitting in a `[Gmail]/Spam` folder; 0 in trash,
+drafts or sent.
+
+**Approach.** Add `spam` to `NOT_ARRIVALS` so the existing carve-out covers it
+too, correct the function/doc prose (its names say "sent" while the list means
+"not an arrival"), and extend the hook's tests with the spam cases. Then a
+ONE-TIME cleanup of the existing messages, using the same all-files rule.
+
+**Constraints.** The all-files rule is load-bearing (`not_arrival_only()`): notmuch
+deduplicates by Message-ID, so a message with one file in spam and one in an
+inbox DID genuinely arrive and must keep `inbox`. Only `inbox` is removed;
+`unread` is untouched because `maildir.synchronize_flags` is true. The cleanup
+is a write to the live index and is confirmed with the user before running (it
+was, 2026-09-14). Trash is deliberately out of scope: measured 0 such files, and
+qtmaildir's own Delete strips `inbox` (item 168).
+
+**Verification.** `assets/hooks/test_post_new.py` (and the other hook suites)
+green with a spam-folder case and a two-file (spam + inbox) case; after the
+cleanup, `notmuch search --output=files 'tag:inbox' | grep -i '/spam/'` is
+empty.