diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-14 19:15:38 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-14 19:15:38 +0200 |
| commit | 299eeff6ab6f61f07f1c0ed9e039f00036c1d31a (patch) | |
| tree | f672b39bb722281f7879ce2966e1584b33574f50 /docs | |
| parent | 6367b48d93d3c57b0ba17af0c8736e03bcc2067d (diff) | |
| download | qtmaildir-299eeff6ab6f61f07f1c0ed9e039f00036c1d31a.tar.gz qtmaildir-299eeff6ab6f61f07f1c0ed9e039f00036c1d31a.zip | |
187 (Spam view), 190 (Mark spam on the bar + icon), 195 (Mark spam leaves
unread), 197 (no way to say not spam), 201 (un-spam from the Spam view) and
202 (spam kept inbox). Sections moved to the closed file and the status rows
mark them done.
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md | 303 | ||||
| -rw-r--r-- | docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md | 325 |
2 files changed, 309 insertions, 319 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. 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 dc50983..2a73673 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,22 +260,22 @@ taking that too literally. | 184 | New mail waits up to ten minutes, because sync is a fixed cron tick | workflow | ? | **done 2026-09-13**, outside this repo and confirmed running on this machine (PID watching, `~/bin/mail-watcher.sh --config ~/.config/mail-watcher/config.ini`). Built as `mail-watcher`, its own repository at `~/Programming/GIT/mail-watcher`, designed in its own `docs/superpowers/specs/2026-09-13-mail-watcher-design.md`. It took the shape this entry argued for and settled the three decisions it listed: a watcher of ours rather than a third-party daemon, so no new SlackBuild; one Python file, standard library only; one thread per watched folder with its own reconnect, one trigger loop owning every `mailsync.sh` invocation with a debounce, default-watch with an explicit exclude list. Both constraints held: the cron tick stays as a backstop, and `/tmp/mbsync.lock` is still the shared mutex. Nothing in `src/` changed, which is the point, the no-network-protocol rule is intact. Section in the closed file. Original entry: 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, **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 | +| 187 | There is no Spam view beside Trash | workflow | M | **done 2026-09-14**, released in 0.29.0, on `spam-view`. Spec: `specs/2026-09-10-spam-view-design.md`. A per-account `spam` key with `spamQuery()`/`allSpamQuery()`, a threaded path-based Spam filter, and Mark spam now MOVES the file (origin tag rewritten to `moved-from:`) with Undo and Restore. Also shipped Empty Spam (per account, into the trash, no confirmation) and Find stranded spam. Covers 190 and 195 too. Section in the closed file | | 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 | +| 190 | Mark spam is not on the message bar, and its icon was never chosen for one | presentation | XS | **done 2026-09-14**, released in 0.29.0, with 187. `spam` joined the bar's ordinary branch between Archive and Delete; the icon is `bug` with a `mail-mark-junk` fallback (the icon table gained primary+fallback support). Hidden on a reply row and in the trash. Section in the closed file | | 191 | The Sent view collapses two messages you sent in one conversation into one row | defect | S | **done 2026-09-06**, unreleased, from a hand test. The Sent and Drafts views are flat, but the worker emitted one summary per THREAD and picked a single matched message to stand for it, oldest-first. A conversation replied to twice showed one row, dated by the thread and opening the OLDER message, and the newer one was reachable nowhere. Also a data-safety defect: `firstMessagePath` named the wrong file, so Delete would have moved it. A second half, found by hand once the rows appeared: the sort notmuch applies is a THREAD sort, so both rows took their thread's position and an older reply drew above a newer one. Flat rows are now sorted as one list. Section in the closed file | | 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 | +| 195 | Mark spam leaves the message unread | defect | XS | **done 2026-09-14**, released in 0.29.0, folded into 187's move as the note asked: Mark spam now strips `unread` (and `inbox`) in the same move, so it leaves the Unread views. Section in the closed file | | 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 | | 198 | The unsynced-changes list never says which account a message belongs to | presentation | S | open, 2026-09-13, from the notes ("when clicking on the bottom right status bar, there's no way to discriminate what message belongs to what account"). The click opens `PendingChangesDialog` (item 119). Verified: `PendingChangeRow` (`pendingchangesdialog.h:32-50`) carries subject, action, `startsMessage` and `messageCount` and no account, so a list of five subjects across five accounts reads as one undifferentiated run. The data is reachable rather than missing: `accountForMessagePath()` (`mainwindow.cpp:6219`) resolves an account from a path, and `resolvePendingSubjects()` already walks every id in the worker and answers positionally, so the account is one more field on an existing round trip. One asymmetry to settle first: a held THREAD edit carries a thread id rather than a message id (`pendingChangeSnapshot()`, `mainwindow.cpp:5699`), and a thread can in principle span accounts, so the thread rows need a rule of their own rather than the message answer | | 199 | The window chrome uses the system icon theme, and the user wants a shipped set | presentation | M-L | open, 2026-09-13, from the notes ("we should ship our own icons, color themeable to be consistent in every theme a user may implement, since icons are a brand identity"). This deliberately REVERSES item 70, which drew the split as "panes are ours, chrome is the system's" and shipped `Marks` for the panes only; the note asks for the other half too, so it is a decision to revisit rather than a defect. Verified: the `themeIcons` table at `mainwindow.cpp:2211` and six `QIcon::fromTheme` sites in `composewindow.cpp` are every chrome icon, all resolved from the desktop theme. The mechanism already exists and is proven, `Marks::pixmap` compositing `fill="currentColor"` with `CompositionMode_SourceIn` so one asset serves a light and a dark palette, and `src/marks.h` records why it is compiled-in string literals rather than a `.qrc`. The size is the ARTWORK, not the code: item 70's six marks are shipped, this is roughly forty actions, each needing a drawing. Needs a decision from the user on scope before it can be sized honestly, and on whether the system theme stays as a fallback for an action with no shipped icon | | 200 | qtmaildir cannot be launched at a given account, thread or message | workflow | M | open, **specified 2026-09-13** in `specs/2026-09-13-cli-selectors-design.md`; read that rather than this row. The user settled three things: a second launch STEERS the running window over a `QLocalServer` rather than opening a second one, the selectors are `--account`/`--thread`/`--message` (`--query` dropped as the one with no caller), and a selector matching nothing opens the window normally and says so in the status bar. The design shrank on one side and grew on the other: `recoverStaleThread()` already runs `thread:<id>` with a deferred selection and is reused as a third caller, so the selectors are the small half, while the socket (connect-first ordering, stale-socket recovery, a degrade path when no socket is possible) is the real work and adds `Qt6::Network` to the component list. Original entry: open, 2026-09-13, from the notes ("the program should accept cli parameters like `--account` or `--thread`/`--message`, so that another app can launch qtmaildir opening that account's inbox or a certain message/thread"). Verified: `main.cpp:38-66` hand-rolls a `strcmp` loop over `argv` for `--version` and `--help` only, both answering before `QApplication` exists, which is deliberate and documented. Parsing is the small half and `QCommandLineParser` covers it; the item is bigger than it looks for two reasons. There is NO single-instance mechanism (no `QLocalServer` anywhere in `src/`), so a second launch opens a second window against the same notmuch database rather than steering the running one, and notmuch permits only one open handle per process. And the selector has to reach a query the startup path does not currently take, since `--thread` names a row that may not be in the configured startup view at all. Needs a decision from the user first: whether a second launch should focus the running window (which is the useful behaviour for "another app launches qtmaildir" and is the whole cost of the item) or simply start with a different query | -| 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 | -| 201 | A message in the Spam view cannot be un-spammed, even one qtmaildir put there | defect | S | open, 2026-09-14, found while testing the `spam-view` branch. Corrects item 197's claim that Restore already covers what qtmaildir moved: the CAPABILITY does (`restoreSelectedFromTrash()` reads the origin from the database), but no SURFACE offers it for spam, so only Ctrl+Z immediately after the move reverses it. The user asked for it to be built on `spam-view` before that branch merges. See the section | -| 202 | Mail in a spam folder keeps `inbox`, so it appears in the Inbox view | defect | XS | open, 2026-09-14, found while testing the `spam-view` branch. notmuch's `new.tags` is `new;unread;inbox`, the Inbox filter is `tag:inbox`, and the hook's non-arrival carve-out (`NOT_ARRIVALS` in `assets/hooks/qtmaildirconf.py:84`) lists only sent and drafts, so spam-folder mail keeps `inbox` and shows in the Inbox view. Surfaced as "Not spam is offered in the Inbox view": the action's folder-based predicate is correct, the ROWS are the defect. Measured live: 49 files tagged `inbox` in a `[Gmail]/Spam` folder, 0 in trash/drafts/sent. Fix on `spam-view`. See the section | +| 197 | No way to say a message is not spam | workflow | S | **done 2026-09-14**, released in 0.29.0. A `Not spam` action now exists (item 201): it returns each message to its `moved-from:` origin, strips `spam`, and for provider-caught mail with no origin falls back to the account's inbox, reported in the status bar (the destination question answered as the inbox guess). The provider-notification half stays out of scope as network work. Section in the closed file | +| 201 | A message in the Spam view cannot be un-spammed, even one qtmaildir put there | defect | S | **done 2026-09-14**, released in 0.29.0. Built as the distinct `not_spam` action (the second option): shown on a spam-folder selection, hidden on a reply row and in the trash, worker-resolved origin, undoable. Section in the closed file | +| 202 | Mail in a spam folder keeps `inbox`, so it appears in the Inbox view | defect | XS | **done 2026-09-14**, released in 0.29.0. Root cause was the `post-new` hook's non-arrival set, not the UI: `spam` added to `NOT_ARRIVALS`, helpers renamed `not_arrival_*`, tests added. Live one-time cleanup stripped `inbox` from the 49 affected messages (`tag:inbox` 5904 -> 5855). Section in the closed file | Sizes are rough: XS under an hour, S a sitting, M a session. @@ -429,7 +429,6 @@ diff; the user decides what goes. **Size: still `?` until the list exists.** The sweep that produces the list is S to M; what it finds is the work. - ## 72. No khard/khal integration **Observed (user, from the notes):** "investigate khard/khal integration (light @@ -876,70 +875,6 @@ composer window and do not touch `KeyMap`, so nothing here interacts with item `quotingAnAlreadyQuotedLineNestsIt` in `tests/test_formattoolbar.cpp` both assert the current spec behaviour and would be replaced rather than extended. - ---- - -## 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. - --- ## Deferred, unsized, or split out @@ -1025,7 +960,6 @@ what needs covering, so the two accounts must have different addresses and the message must be addressed to one of them, or either answer is correct and the test proves nothing. - ## 136. `undoMovesTheMessageBack` fails about one run in six **Observed.** `test_mainwindow` failed during a full-suite run while item 123 @@ -1322,93 +1256,6 @@ same discipline applies here. - The suite baseline is currently ONE known failure. Anything that makes it two intermittently costs the property that a red suite means something. - -## 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. - ## 188. Does Empty trash respect the account selector? **Answered on 2026-08-29 by reading the code; no work follows from it.** It @@ -1544,35 +1391,6 @@ 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 @@ -1605,41 +1423,6 @@ that currently runs offline against the local index. 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. - -**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. - ## 198. The unsynced-changes list never says which account a message belongs to **Observed (user, from the notes):** "when clicking on the bottom right status @@ -1775,99 +1558,3 @@ in the notes. quoting like every other query this application builds, rather than being concatenated at the call site. -## 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. |
