aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-10 12:31:08 +0200
committerDanilo M. <danix@danix.xyz>2026-09-10 12:31:08 +0200
commit93a3959a1ebf630bfb0ade6f82b2d86e4f17b4a6 (patch)
tree799fbb25a62fe9b29c55425ad80bd3ba79cc5762 /docs/superpowers/plans
parent79d30c128157937056dc2abf63f1f74728ca1a92 (diff)
downloadqtmaildir-93a3959a1ebf630bfb0ade6f82b2d86e4f17b4a6.tar.gz
qtmaildir-93a3959a1ebf630bfb0ade6f82b2d86e4f17b4a6.zip
docs: specify the Spam view, the spam move and Empty Spam
Items 187, 190 and 195, settled with the user and specified together in docs/superpowers/specs/2026-09-10-spam-view-design.md. Nothing is built yet; implementation follows on a branch. Mark spam becomes a move into the account's spam folder, following item 103's Delete-to-trash design rather than inventing a second mechanism: a mandatory per-account `spam` key, a path-based threaded Spam filter, and a repeatable cleanup pass for the mail the tag-only action stranded. `unread` is stripped by the move (item 195, verified in the code: the call names `spam` and `inbox` and nothing else). The message-bar button carries `bug` with `mail-mark-junk` as its fallback, measured against the user's icon theme, where the standard name draws a warning octagon and `bug` draws the beetle the notes asked for; a bare `bug` was rejected because it resolves in 0 of the 24 system themes and would leave a blank button. Two changes came from the user after the first draft and both improved it. The origin tag is renamed `deleted-from:` -> `moved-from:`, which the first pass had rejected on a migration cost that turned out to be 8 messages carrying one distinct value; and Empty Spam moves mail to the trash per account, which needs no new grouping because trashMessages() already resolves each message's own account. A message can therefore leave two folders in turn, and the existing reader takes the first matching tag and breaks. Rather than encode ordering in the tag, which notmuch's unordered tag set cannot answer, a move overwrites the origin instead of appending: one tag ever, one hop back per Restore. Empty Spam inherits neither of empty_trash's safeguards, deliberately. It moves rather than destroys, so it has an inverse, and a confirmation on an undoable action is the defect AGENTS.md names. Item 197 is filed for a future "not spam": Restore already covers what this application moved, and reverting the provider's own filter needs a destination rule and possibly a sidecar, neither of which is decided. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LrqM5LJGAQEryvs5r7usM1
Diffstat (limited to 'docs/superpowers/plans')
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md94
1 files changed, 93 insertions, 1 deletions
diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
index f48479e..27da155 100644
--- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
+++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
@@ -260,7 +260,7 @@ taking that too literally.
| 184 | New mail waits up to ten minutes, because sync is a fixed cron tick | workflow | ? | open, 2026-08-29, from the user: the 10 minute tick "has always bothered me", and it is already a compromise down from 30. Outgoing edits are immediate (`auto_sync_delay_ms`), so this is the INCOMING half only. Polling faster is not the answer; IMAP IDLE is, and it lives in a watcher that triggers `mailsync.sh`, NOT in qtmaildir, which does no network protocol work. Needs decisions first: which watcher, whether it packages on Slackware, and what the server supports. **Blocked on 174**, whose status file is the reporting channel this needs anyway |
| 185 | The message-pane bar offers Reply and Forward on a trashed message | presentation | S | **done 2026-08-29**, unreleased, with 186. The bar has a third branch keyed on the SELECTION being in a trash folder, the same predicate the menus use: Restore, Delete permanently and Empty trash replace the reply pair, and Restore alone is tinted. Added `purge`, the selection-scoped sibling of `empty_trash`, which inherits both its safeguards. Refilled from the digest as well as from the selection, since a conversation's trash-ness is not known until every path is reported. Section in the closed file. Original entry: `MainWindow::refreshMessageBarActions()` (`mainwindow.cpp:2311`) swaps the bar's message half for a DRAFT and for nothing else, so the trash view shows the two actions that make least sense there. The notes ask for Restore and Delete permanently in their place, and for Delete to move here from the main toolbar (item 186). The visibility rules already exist in `refreshTrashActions()`; what is missing is the bar consulting them |
| 186 | Delete sits on the main toolbar rather than beside Reply and Forward | presentation | XS | **done 2026-08-29**, unreleased, with 185. Moved to the message bar's ordinary branch; still in the Message and context menus. Section in the closed file. Original entry: `toolBar->addAction(... "delete")` at `mainwindow.cpp:2251`. The user places it with the message actions, so this rides with item 185 rather than being done alone: moving it before the bar is trash-aware leaves Delete in a bar that still offers Reply on trashed mail |
-| 187 | There is no Spam view beside Trash | workflow | M | open, 2026-08-29, from the notes; **shape settled 2026-08-29** after two corrections and three decisions from the user. Spam works like Trash: path-based, a mandatory per-account `spam` key, and Mark spam MOVES the file. Every account can now reach a spam folder, the three Gmail ones having gained `[Gmail]/Spam` in `.mbsyncrc` this session. Grew from S to M: the move path, the origin tag and a cleanup pass are three parts, and it changes what an existing action does. See the entry |
+| 187 | There is no Spam view beside Trash | workflow | M | open, **specified 2026-09-10** in `specs/2026-09-10-spam-view-design.md`, which covers 190 and 195 too; read that rather than this row. Grew again: the user added Empty Spam (a MOVE to the trash, per account) and the `deleted-from:` -> `moved-from:` rename. 2026-08-29, from the notes; **shape settled 2026-08-29** after two corrections and three decisions from the user. Spam works like Trash: path-based, a mandatory per-account `spam` key, and Mark spam MOVES the file. Every account can now reach a spam folder, the three Gmail ones having gained `[Gmail]/Spam` in `.mbsyncrc` this session. Grew from S to M: the move path, the origin tag and a cleanup pass are three parts, and it changes what an existing action does. See the entry |
| 188 | Does Empty trash respect the account selector? | question | XS | **answered 2026-08-29** by reading the code, no work needed. It does: `MainWindow::emptyTrash()` (`mainwindow.cpp:6567`) reads `m_accountBox->currentData()` and uses `allTrashQuery()` only for All accounts, and the confirmation names which. Recorded so the notes' question has an answer rather than sitting open |
| 189 | The message bar carries only Reply, Forward and Delete | presentation | S | **done 2026-08-29**, unreleased. Star and Archive joined the bar's ordinary branch, Archive leaving the main toolbar as Delete did. `mark_all_read` deliberately did NOT move, at the user's decision: it is the one action that ignores the selection. Item 140's toolbar test listed `archive` as a list-wide action and had to be corrected, which is the classification this item changed. Section in the closed file. Original entry: Asks for Star (`flag`) and Archive on the bar, and raises Mark all read as a question. Two of the three are selection-scoped and fit the bar's rule as it stands; **`mark_all_read` does not**, since it deliberately ignores the selection and acts on every row in the view, which is the one action in the window that does. Needs a decision from the user on that one and on whether Archive LEAVES the main toolbar the way Delete did |
| 190 | Mark spam is not on the message bar, and its icon was never chosen for one | presentation | XS | open, 2026-09-06, from the notes. The bar's ordinary branch carries Reply, Forward, Star, Archive, Delete after item 189 and `spam` is not among them, though it meets the bar's rule (selection-scoped, undoable). Two halves: put it on the bar, and settle the icon, which the note asks to be "a bug, or a skull, or something that signifies bad/evil" and which is `mail-mark-junk` today, chosen for a menu where the label carries the meaning. **Paired with 187**, which changes what the action DOES (moves the file); ordering is the user's call |
@@ -268,6 +268,9 @@ taking that too literally.
| 192 | A sent message does not appear in the Sent view until the next sync | defect | XS | **done 2026-09-06**, unreleased. The sent copy was filed correctly and never announced, so the index did not know it and the Sent view, a path query, could not show it. Measured as 65 files against 64 indexed. One signal to the worker, mirroring what drafts have had since item 158. The open question, whether the view should also refresh, was answered yes by the user on 2026-09-07 and built: `indexChanged()` to `refreshCurrentQuery()`. Section in the closed file |
| 193 | The composer has no headings control | v2 | S | open, 2026-09-08, from the notes: "headers dropdown in the editor, H1 to H6 translating to #, ## ... already supported by the html render". The note is right about the renderer: cmark-gfm parses ATX headings in the core grammar, so `## x` already renders. The gap is composer-side. A heading is a LINE PREFIX, not a wrap, so it cannot go through `applyFormat()`/`MarkdownFormat::wrap()`; it is `quote()`'s shape, and unlike quote it must REPLACE an existing prefix rather than stack one, or a second press gives `## ## x`. That makes it the first formatting control that has to read the line's current state, which is item 135's question arriving early on one control |
| 194 | No abuse reporting from a flagged message | workflow | L, split | open, 2026-09-08, from the notes and **confirmed by the user the same day as a feature they want and will build**. Parse a flagged `.eml`, extract IOCs, resolve abuse contacts via RDAP, generate X-ARF (RFC 5965), fan out to AbuseIPDB/URLhaus/VirusTotal and to abuse desks, backed by MISP via PyMISP. **One gesture here, the engine in a sidecar**: the split is architectural (four outbound protocols, which `src/` does not do) and not a judgement on the feature. qtmaildir's half is a message-bar button that marks spam and offers to report, with a confirmation; it is S and buildable before the sidecar exists. The user is a security consultant filling a phishing database, so the sidecar is the point rather than an accessory. Needs a spec for the sidecar; the qtmaildir half needs only 187/190 settled. Two of the user's constraints are safety properties: redact recipient identifiers before submission, and never fetch remote content during parsing |
+| 195 | Mark spam leaves the message unread | defect | XS | open, 2026-09-10, from the notes ("marking a message as spam without reading it doesn't remove the unread tag"). Verified: the action at `mainwindow.cpp:1788` adds `spam` and removes `inbox`, and names no other tag, so an unmarked message keeps `unread` and every unread count keeps counting it. Small on its own; it touches the same action item 187 rewrites into a move, so doing it inside 187 costs nothing and doing it alone is a two-word change to one `tagSelected()` call. One question for the user: whether marking spam should mark read, or whether the tag should simply not be part of the unread views once 187 makes the view path-based |
+| 196 | Spam is never tagged automatically | workflow | ? | open, 2026-09-10, from the notes ("the app should be able to tag spam automatically leveraging intel from abusectl"). Depends on 194's sidecar existing: `~/Programming/GIT/abusectl` is a repo but nothing is on `PATH`, so the intel this would read does not yet have a shape to read. Also unspecified in direction: the natural home is the `post-new` hook rather than `src/`, since tagging at sync time is what `assets/hooks/mailrules.py` already does, and a rule sourced from an external database is a format question for both readers (see "Changing the rule format"). Ask the user what abusectl would expose before designing anything |
+| 197 | No way to say a message is not spam | workflow | S | open, 2026-09-10, split out of the 187 design at the user's decision rather than built into it. Restore already covers what qtmaildir moved: a message it marked carries `moved-from:` and goes back where it came from. The gap is mail the PROVIDER's filter caught, which was never in an inbox and carries no origin tag, so "not spam" has no recorded destination to return it to. Needs two answers before it can be planned: where such a message goes (the account's inbox is the obvious guess and is a guess), and whether anything should tell the provider its filter was wrong, which is network work this application does not do and would belong in a sidecar like item 194's. No seam is needed in the meantime: `sendMove()` already takes any destination and any tags |
Sizes are rough: XS under an hour, S a sitting, M a session.
@@ -1602,3 +1605,92 @@ with the exit status and stdout as the report); and what qtmaildir shows when a
report succeeds, partially succeeds, or fails, since a fan-out to four
destinations can do all three at once.
+
+## 195. Mark spam leaves the message unread
+
+**Observed (user, from the notes):** "marking a message as spam without reading
+it doesn't remove the unread tag."
+
+**Cause.** Verified, not assumed. The `spam` action at `mainwindow.cpp:1786`
+calls `tagSelected({ "spam" }, { "inbox" }, ...)`: it names exactly two tags,
+so `unread` is untouched by construction. The message leaves the inbox and
+keeps counting toward every unread view.
+
+**Approach.** Add `unread` to the removal list of that one call. It is a
+two-word change and the surrounding machinery already covers it: the write goes
+through `applyTags`, which reports only the ids whose tags actually moved (item
+176), so a spam mark on an already-read message pushes no bogus undo, and
+`syncViewMembership()` evicts it from Unread on the same funnel as any other
+read.
+
+**Constraints.** Item 187 rewrites this action into a file move, so the cheapest
+path is to fold this in there rather than shipping a separate commit that 187
+then rewrites. Doing it alone is still fine and costs nothing.
+
+**One question for the user.** Whether marking spam should mark READ, or whether
+the right answer is that a spam message stops matching the unread views at all
+once 187 makes those views path-based. The first is what the note literally
+asks for; the second falls out of 187 for free and means an unread spam message
+is still honestly unread if it is ever restored. They are not the same and the
+choice is theirs.
+
+## 196. Spam is never tagged automatically
+
+**Observed (user, from the notes):** "the app should be able to tag spam
+automatically leveraging intel from abusectl."
+
+**Cause.** Nothing tags spam except the user pressing the action. The one
+automatic tagging path in the system is the `post-new` hook
+(`assets/hooks/mailrules.py`), which applies the rules in
+`~/.config/mailrules/rules.json` and knows nothing outside them.
+
+**Blocked on 194.** `~/Programming/GIT/abusectl` exists as a repository and
+nothing of that name is on `PATH`, so the intel this item would consume has no
+shape yet. What it exposes, and how, is 194's sidecar design.
+
+**Approach, in outline only.** The home for this is the hook, not `src/`:
+tagging at sync time is exactly what `mailrules.py` already does, and doing it
+in the GUI would mean the tag depends on the application being open. That makes
+it a rule sourced from an external lookup rather than from a stored query, which
+is a format question for BOTH readers of `rules.json` and needs the procedure in
+`AGENTS.md` under "Changing the rule format".
+
+**Constraints.** Two of the hook's safety properties apply directly. It refuses
+to remove `unread` or `inbox`, so an automatic spam rule can only ADD a tag; and
+it must not consume `tag:new` when its rules fail to load, which matters more
+here because an external lookup can fail in ways a stored query cannot. Whatever
+the design, a network lookup inside the hook is a new failure mode for a process
+that currently runs offline against the local index.
+
+**Ask the user what abusectl would expose before designing this.** A local
+database queried per message, a periodically refreshed blocklist file, and a
+callable command are three different items sharing one sentence in the notes.
+
+## 197. No way to say a message is not spam
+
+**Observed.** Split out of the item 187 design on 2026-09-10, at the user's
+decision, rather than built into it: "maybe we could already provision for a
+future 'unmark spam' action so that we can revert a filter decision".
+
+**What already covers half of it.** Restore handles every message this
+application moved. Mark spam writes `moved-from:<folder>` and Restore reads it
+back, so unmarking is the existing gesture under a different name.
+
+**The real gap is the provider's filter, not ours.** Mail the provider caught
+was never in an inbox, arrived directly in the spam folder, and carries no
+origin tag. Restore falls back to the account's inbox for exactly this case,
+which is a documented guess rather than a recorded destination.
+
+**Two questions decide the shape, and neither is answerable from the code.**
+
+1. Where does a message with no origin go? The account's inbox is the obvious
+ answer and is still a guess; a user who wants it filed somewhere else has no
+ way to say so.
+2. Should anything tell the PROVIDER its filter was wrong, so it learns? That
+ is outbound network work, which this application does not do by design. It
+ would belong in a sidecar, like item 194's.
+
+**No seam is needed in the meantime.** `sendMove()` already takes any
+destination and any tag lists, so a Not-spam action is a caller rather than a
+capability. Provisioning for it now would be a hook with one hypothetical
+caller, which is what YAGNI names.