aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md')
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md665
1 files changed, 298 insertions, 367 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 6041e28..5e95804 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
@@ -131,12 +131,12 @@ taking that too literally.
| 62 | No config option for the date format on a card | presentation | XS | **done** 2026-08-11 |
| 63 | No way to see sent mail, and no filter for it | workflow | M | **done** 2026-08-11; see `specs/2026-08-11-sent-mail-design.md` |
| 64 | The Sync button carries a mailbox icon, not a refresh one | presentation | XS | **done** 2026-08-11 |
-| 65 | No full code review and optimization pass | correctness | ? | open, unspecified |
+| 65 | No full code review and optimization pass | correctness | ? | open, **narrowed 2026-08-26**: the notes now name it as a dead-code and duplication sweep, not a performance or security pass. Produces a LIST for the user to decide on, not a diff. See the entry |
| 66 | Selecting a thread root leaves the message pane blank until a reply has been selected | defect | S | **done** 2026-08-14, unreleased. Not the blank pane it was filed as: the root rendered the CONVERSATION until the thread had been expanded once, then one message. Now always one message, and the conversation view is removed at the user's request. **One case unverified by hand:** the notes also report a single-message `id:` query whose card would not open, which is the same empty-`MessageIdRole` failure and should be gone; confirmed 2026-08-15 as a SEPARATE defect with a different cause, see item 96 |
| 87 | Auto mark-read marks a whole thread, including replies never displayed | defect | S | **done** 2026-08-16, unreleased. Built on 108, which is why it stayed small: the timer tracks a MESSAGE id now, and arms for a reply too, which it never did before |
| 88 | `threadAt(current.row())` answers about the wrong thread for a reply row | defect | M | **done** 2026-08-16, unreleased. The audit found FOUR live sites, not one. `ThreadListModel::threadFor(index)` resolves a reply through its parent; every caller holding a selected index converted, and no `.row()` on a selected index remains in `mainwindow.cpp`. Unblocks 87 |
| 67 | The placeholder pane counts unread, flagged and inbox, but not sent or drafts | information | XS | **done** 2026-08-11, shipped in 0.15.0 |
-| 68 | A forwarded subject gets no `passed` tag | workflow | S | open; no subject rule exists, measured 2026-08-11. Decision needed: display mark (XS) or write the flag (S, syncs out) |
+| 68 | A forwarded subject gets no `passed` tag | workflow | S | **done 2026-08-26**, unreleased, as THREE things once the premise was measured away. The note asked to expand a subject rule to `Fw:`; there was no subject rule, and the correlation it rested on did not exist. What did exist was a gap nobody had reported: qtmaildir has never written `R` or `P`, so a reply and a forward now flag their source (off the undo stack, per the auto-mark-read precedent), and `subjectIsForwarded()` drives a SEPARATE received-forward mark, display only, extendable through `[general] forward_prefixes`. The user chose all three |
| 69 | `passed` and `replied` read as words where every other state is a glyph | presentation | S | **done** 2026-08-11, inside item 70 |
| 70 | Pane icons are a private set where the main window uses the system theme | presentation | M | **done** 2026-08-11; six shipped SVGs |
| 71 | A toolbar action does not sync, so the edit sits until the next cron run | workflow | S | **done** 2026-08-11; 2s default, `auto_sync_delay_ms` |
@@ -165,12 +165,12 @@ taking that too literally.
| 96 | A query returning the thread already on display opens onto the placeholder | defect | S | **done** 2026-08-15, unreleased. Split from 66's unverified half, which had a different cause. Reproduced from two screenshots after four measured eliminations |
| 97 | An edit made during a sync is reverted in the list when the sync ends | defect | S | **done** 2026-08-15, unreleased. Found by hand-testing item 89's fix. The sync-end refresh ran BEFORE the held-edit flush, so it read a database that still carried the old tag |
| 98 | "Important" adds the tag but cannot remove it, unlike every other toggle | defect | XS | **done** 2026-08-17, unreleased. Calls `everySelectedRowHasTag()`, as the entry required. Its reply test needed THREE different states (list-first thread, the reply's own thread, the reply) before it could tell the two wrong answers apart; with the reply defaulted to its thread's state the item 105 mutation stayed green, measured |
-| 99 | The unread action is labelled "Toggle unread" whichever way it will go | presentation | S | open; depends on 98's toggle shape, and the label is harder than it looks |
+| 99 | The unread action is labelled "Toggle unread" whichever way it will go | presentation | S | **done 2026-08-25**, unreleased, with 112: the user's note is ONE design across both. The label names the direction it will go, and the entry is hidden on a selection with no single state. `refreshUnreadAction()` reads the new three-valued `selectionTagPresence()` |
| 100 | The message pane offers Back, Forward, Reload and Save page, none of which mean anything | defect | XS | **done** 2026-08-17, unreleased. `MessageView::removeBrowserActions()` filters the standard menu by `pageAction()` POINTER, never by text; `ViewSource` went with them, and stranded separators are swept |
| 101 | Sync is account-aware for edits but not for the account the user is looking at | workflow | S | open; item 49 built the edit half deliberately. Needs a decision, see the entry |
| 102 | The rules table shows no note, so the field explaining a rule is invisible until it is opened | workflow | XS | **done** 2026-08-17, unreleased. A Note column before `ColumnCount`, so the appended Matches column stays last. Found a second defect on the way: `restoreState` REFUSES a header state with a different column count, and the sized flags were being set regardless |
| 103 | What Delete does to mail on the server is undocumented and unverified | clarification | S+M | done; Delete moves to the account trash, with Restore and a stranded-mail cleanup. Section in the closed file |
-| 104 | Mail visible in Thunderbird never reaches qtmaildir | defect | ? | open, reported 2026-08-16, cause NOT established. Most likely outside this repo; see the entry before writing code |
+| 104 | Mail visible in Thunderbird never reaches qtmaildir | defect | XS | **done 2026-08-25**, hand-tested. The worker never reopened its read-only notmuch handle, so no query saw mail indexed after startup. Confirmed on a sync run from the application that added 20 messages: they appeared without a restart |
| 109 | A root card's own message is invisible to a message-scoped write | defect | S | **done** 2026-08-16, unreleased. Found by hand-testing 108. `applyMessageTagChange` and `messageById` searched only the loaded replies, and a root's message is never among them, so the ORDINARY gesture repainted nothing and wiped the pane's chip row |
| 110 | A card and the message pane show tags belonging to a message's siblings | defect | S | **done** 2026-08-16, unreleased. Found by hand-testing 109 against a real 4-message thread. `ThreadSummary::tags` is notmuch's UNION; a card standing for one message drew it. Also the reason a root card could not repaint at all |
| 111 | A card should show its siblings' tags smaller, not drop them | presentation | S | **done** 2026-08-16, unreleased. The user's own design, from looking at 110's result: own tags full size, the thread's others smaller and muted, so nothing appears to vanish on selection |
@@ -178,13 +178,13 @@ taking that too literally.
| 106 | A tag change made on one message during a sync is silently lost | defect | XS | **done** 2026-08-16, unreleased. Found by READING while fixing 105, never reported. `flushHeldEdits` re-sent only thread-scoped edits, so a message-scoped one was shown, counted as pending, and never written |
| 107 | A thread-scoped write leaves the loaded replies showing their old tags | defect | XS | **done** 2026-08-16, unreleased. `applyTagChange` updated the summary only, so marking a thread read left its expanded replies bold |
| 108 | Acting on a thread root means the whole thread, though it displays one message | workflow | M | **done** 2026-08-16, unreleased. `messageScopeFor()` beside `scopeFor()`; five `*_thread` actions in a "Whole thread" submenu on `Ctrl+Alt+<key>`. User-visible: minor bump, `### Upgrading` written |
-| 112 | Toggle unread on a whole thread cannot reach "all unread" on a partly-read thread | defect | S | open, found 2026-08-17. A toggle over a UNION has no direction on a mixed thread |
+| 112 | Toggle unread on a whole thread cannot reach "all unread" on a partly-read thread | defect | S | **done 2026-08-25**, unreleased. Built to the user's own note rather than to this entry's approach, which had it only half right. The thread toggle splits into two absolute actions AND the message-scoped one keeps its toggle with a dynamic label, hidden when the selection disagrees. Closes 99 and 147 with it |
| 113 | No way to see a message's HTML source | information | S | open, 2026-08-17. Chromium's own View source cannot work here; needs our own plain-text dialog. Item 100 removed the dead entry, which was an overreach: the user had not asked for it |
| 114 | Save image is offered on every image and does nothing | defect | S | open, found 2026-08-17, re-confirmed by hand 2026-08-20. No `downloadRequested` handler exists, so the request is emitted and never answered. The handler is per-profile, so it must decide per request or it revives the Save link item 127 removed |
| 115 | A copy from the message pane gives no confirmation | presentation | XS | **done** 2026-08-19, unreleased. Four entries report, each naming what it copied; connected to the page's own QActions, so the entry is covered wherever it is triggered from |
| 116 | Copy image copies markup instead of the image | defect | XS | **dropped** 2026-08-17, same day. NOT A DEFECT: `wl-paste --list-types` run immediately after a copy reports `image/png`, `application/x-qt-image` and 30 more image flavours. The clipboard is correct and Chromium is behaving. The earlier "text only" reading was taken minutes late off a clipboard that had been overwritten, and a whole cause was theorised on it |
| 117 | The message pane offers no Select all | workflow | XS | **done** 2026-08-19, unreleased. `addPaneActions()` supplies it. The call site is NOT covered by a test and cannot be: the production menu needs a real context-menu event. Stated in the test rather than faked |
-| 118 | No way to empty the trash from inside the app | workflow | S | open, 2026-08-17. **Blocked on 103**, which creates the trash in the first place. Deliberately left out of 103's spec at the user's request rather than squeezed in |
+| 118 | No way to empty the trash from inside the app | workflow | S | **done 2026-08-25**, unreleased. Unblocked by 103. `Message > Empty trash...`, scoped to the account selector, no shortcut. The one confirmation in this application, and CLAUDE.md now records it as the single exception rather than leaving it to be discovered. Found a defect while testing: the count claimed messages whose files were already gone |
| 119 | The unsynced-changes count cannot be opened to see what it counts | information | S | open, 2026-08-19, from the notes. One of the four things it sums carries no message ids at all, so a list cannot be complete without a change to how the count is kept |
| 121 | The thread list shows nothing while a query is running | feedback | S | open, 2026-08-20, from the notes. Follows item 74, which fixed the status-bar half and left the list itself blank |
@@ -216,7 +216,7 @@ taking that too literally.
| 144 | "Also send a formatted copy" is prominent and does not say what it does | presentation | XS | **done** 2026-08-24, unreleased, inside 142. "Send as HTML", icon and text, alone at the right end of the editor bar where it reads as a control of the editor rather than as a formatting button. The Italian entry was refreshed with it, and `lrelease` reports 477 finished, 0 unfinished |
| 145 | Cc and Bcc are permanent rows on every composer | presentation | S | **done** 2026-08-24, unreleased, inside 142. A `QToolButton` disclosure beside To:. `revealCcBccIfUsed()` is the load-bearing half the entry called for: it only ever SHOWS, never hides, so nothing but the user's own click can make a field holding an address invisible. `ComposeContext` carries no `bcc` at all, so the seeded-Bcc case can only arrive from a reopened draft, which is what its test drives. The LABEL is hidden with each field: a `QFormLayout` holds the two as separate items, so hiding the line edit alone strands a `Cc:` over empty space |
| 146 | The unsynced-changes count cannot be opened to see what it counts | information | S | **duplicate of 119**, recorded 2026-08-23 from the notes. Same request, and 119 already carries the blocker: one of the four things the count sums holds no message ids, so a list cannot be complete without changing how the count is kept |
-| 147 | Toggle unread reads the same whichever way it will go | presentation | S | **duplicate of 99**, recorded 2026-08-23 from the notes. The notes ask for exactly what 99 describes: "Mark as read" on an unread message and the reverse. 99 already records that the label is harder than it looks, since a multi-row selection has no single direction |
+| 147 | Toggle unread reads the same whichever way it will go | presentation | S | **duplicate of 99**, recorded 2026-08-23 from the notes, and closed with it on 2026-08-25 |
| 148 | Ctrl+W does not close the composer | discoverability | XS | **done** 2026-08-24, unreleased. A `QAction` parented to the composer, so it is a WindowShortcut dispatched to the active composer only and the main window's namespace is untouched, exactly like the formatting shortcuts. It calls `close()` rather than doing anything of its own: `closeEvent()` already decides whether the draft is saved, and a second route out that skipped it would lose the message. Not registered in `KeyMap`, so item 132's rules do not apply |
| 149 | A reply's cursor lands on the attribution line, not on blank space | defect | XS | **done** 2026-08-24, unreleased, in TWO passes. The first fixed the cursor within each branch (`End` under Above, `Start` under Below) and the user still saw the old layout, because the branches were already right and the DEFAULT was wrong: `above` shipped, and the layout asked for is what `below` produces. Default flipped, and the composer now focuses the body whenever To: is already filled, which a Reply and a Forward always are. Both halves were invisible to the existing `theQuotePositionDecidesWhereTheQuoteLands`, which asserts the quote's position and never the cursor's |
| 150 | The receive-only ribbon stays up after the message that raised it is gone | defect | S | **done** 2026-08-24, unreleased. One line in `MessageView::clear()`, beside the blocked-content bar, the stale notice and the attachment bar it already reset by hand. Only `setReceiveOnlyAccount()` hid the ribbon, which every SELECTION change reaches, so a row-to-row move was never the reproducer: it survived the FOUR routes that blank the pane without one (`clear_pane`, `clear_selection`, a new query, a multi-row selection). The first test written for it passed against the defect for exactly that reason |
@@ -235,9 +235,15 @@ taking that too literally.
| 160 | The composer never says a draft was autosaved | feedback | S | **done** 2026-08-25, unreleased. A status bar on the composer: the age line left, the `○ unsaved content` cue beside it. **The fix is a funnel, not a label.** `m_dirty` had SEVEN writers and four of them clear it, only two of which are a save, so a cue hung off the save path silently missed the constructor and the send; `setDirty()` is the one writer now and refreshes both cues plus `setWindowModified()`. Presentation was **reworked after the user looked at it**: it first reused item 151's yellow ribbon treatment, which reads as a misplaced widget on a bare status label, and the cue sat in the permanent (right-hand) tray. Two defects found by probing rather than by reading, see the section |
| 161 | The composer has no menu bar | discoverability | S | **done** 2026-08-25, unreleased. File / Edit / Format, to the user's own chosen scope. **Save draft (`Ctrl+S`) is the only NEW action**; everything else is gathered, and the menus show the toolbar's own `QAction` objects rather than copies, per item 140's rule. Two needed hand-building: the HTML toggle is a `QToolButton` and cannot go in a menu, so a checkable twin mirrors it BOTH ways; and the signature entry takes the switch's own `QMenu` pointer, since that menu is rebuilt when the signatures change and copied entries would go stale. Edit's entries follow the editor's own `undoAvailable`/`copyAvailable`. `theMenuBarReachesEveryComposerAction()` is item 132's rule applied to the composer, walking the real menu bar and finding actions by `findChildren`, so a future action added to the toolbar and forgotten in the menus fails without touching the test |
-| 162 | Delete fails while a sync is renaming the file underneath it | defect | S | open, 2026-08-25, found by hand. `Cannot move <file> to <folder>`. NOT a Delete defect and not item 158's: mbsync renames an uploaded file to add its `,U=<uid>` infix, and notmuch keeps the pre-`U=` name until that sync's `notmuch new` runs, so `moveMessages` renames a path that no longer exists. Truthful, harmless and SELF-HEALING, which is why it reads as intermittent: verified a ghost present mid-sync and gone after. The message names a folder as though the folder were the problem. Delete reaches the real mail server, so read `CLAUDE.md` before touching it |
-| 163 | The message pane shows a stale path and reports the message unreadable | defect | S | open, 2026-08-25, found by hand. Same root as 162 and a DIFFERENT site: the model keeps the filename a row was loaded with, mbsync renames the file to add `,U=<uid>`, and `MimeParser` then opens a path that no longer exists and honestly reports "could not be parsed". notmuch is CORRECT by then; the UI is behind, so 162's fix (refuse the write while a sync runs) does not touch this. The read path needs to recover rather than refuse |
-| 164 | Every newly synced draft carries `inbox` | defect | S | open, 2026-08-25, found by hand. Measured `draft inbox unread` on a draft this application wrote. `strip_inbox_from_sent()` in `assets/hooks/post-new` reads `qtmaildirconf.sent_folders()` only, and `qtmaildirconf.py` has no drafts equivalent, so the carve-out never covers a drafts folder. **Contradicts a shipped 0.27.0 changelog entry** claiming both are kept out of the inbox, so it is a documentation defect as well. Item 158's measurement was correct and did not cover this: `index_file` assigns no tags, but mbsync's upload and the next `notmuch new` re-tag the file |
+| 162 | Delete fails while a sync is renaming the file underneath it | defect | S | **done, 2026-08-25.** mbsync renames an uploaded file to add its `,U=<uid>` infix and notmuch keeps the pre-`U=` name until that sync's `notmuch new` runs, so `moveMessages` renamed a path that no longer existed and Delete silently did nothing while blaming the destination folder. `moveMessages` now re-resolves by MESSAGE ID when the recorded path is gone: one reindex of that directory, then the filename that exists on disk. Bounded to one retry, so a file genuinely gone still reports. Holding the move during a sync was the other candidate and is NOT the fix: `sendMove` already refuses on notmuch's write lock, but this window sits between mbsync's rename and that sync's `notmuch new`, which touches no lock |
+| 163 | The message pane shows a stale path, and the composer forks the draft | defect | S | **done, 2026-08-25.** mbsync renames an uploaded file to add its `,U=<uid>` infix while the model still holds the name the query returned. `MaildirName::resolveRenamed()` returns the path unchanged when it exists, else finds the file in that one directory whose unique stem matches; it refuses an ambiguous match and yields nothing for a genuinely missing file. Wired into all THREE read sites: the pane, Reply/Forward, and the draft reopen. The reopen was the one that cost data, forking a draft into two files with two Message-IDs, both reaching the server |
+| 164 | A draft this application saved keeps `inbox` | defect | S | open, 2026-08-25, **cause corrected 2026-08-25**. The first diagnosis blamed a missing drafts helper and was WRONG: `NOT_ARRIVALS` in `qtmaildirconf.py` is `("sent", "drafts")`, the folder list includes every account's drafts folder, and `notmuch count` confirms the carve-out query MATCHES the affected draft. The carve-out is scoped to `tag:new`, and the draft carries `inbox` while `tag:new` is 0, so it was never in scope when the hook ran. Measured separately: an mbsync-style rename does NOT re-add `new.tags`, so the retag theory is out too. What remains unestablished is WHICH pass tagged it; establish that before writing code |
+| 165 | A draft gets a new Message-ID on every autosave | enhancement | ? | open, 2026-08-25, found while hand-testing 163 and 164. `MessageBuilder::build()` generates an id unconditionally and every autosave calls it, so each revision is a distinct MESSAGE to notmuch and to the server rather than a new version of one. Invisible while the file is replaced correctly, which item 163's fix restores; it is what turned that fork into two messages rather than one duplicated file. Needs a DECISION on what a draft's identity is before any code: a stable id reused at send, a stable id discarded at send, or the status quo. Neither `ComposeContext` nor `OutgoingMessage` has a field to carry an id, so it is not a changed call site |
+| 166 | Mail you send to your own other account loses `inbox` | defect | S | **done 2026-08-25**, unreleased. `sent_only()` keeps a message only when EVERY file is inside a sent folder, which is what the carve-out's docstring already claimed. No query can express it, measured; the root comes from `database.mail_root`, with a split-index fixture the ordinary layout cannot provide. Verified read-only against the live index: 780 of 807 still stripped, 27 spared, no arrival affected |
+| 167 | No way to tell one build of an unreleased version from another | enhancement | XS | **done 2026-08-25**, unreleased. The user chose a counter over a git description: `QTMAILDIR_BUILD_NUMBER`, a cmake option ON by default, increments a counter in the BUILD directory on every build and writes `buildnumber.h`. `QTMAILDIR_VERSION_DISPLAY` carries it; `QTMAILDIR_VERSION` stays clean and is what the window title, `applicationVersion` and the release procedure use |
+| 168 | Delete is offered on mail already in the trash, and does nothing | defect | S | **done 2026-08-25**, unreleased. Delete is hidden when every selected row is already in its account's trash, Restore when none is, both keyed on the PATH rather than the `deleted` tag. Delete also drops `unread` now, in the same TagChange so one undo returns the folder and the tag together |
+| 169 | A card shows the account only as a bar, with no fade and no avatar | presentation | M | open, 2026-08-26, from the notes. The accent bar exists (`CardLayout::accentRect`, `CardDelegate::accentLineColour()`); the gradient fade and the sender avatar do not. The avatar's initials source is decided, the vCard half is blocked on item 72 |
+| 170 | A row that stops matching the view only leaves it on the Delete path | defect | S | open, 2026-08-26, from the notes, **cause found the same day and the premise is NOT stale**. The optimistic REPAINT is universal; the optimistic MEMBERSHIP is not. `removeThreadsWithoutTag()` has exactly one caller, on the move path, so marking a message read in the Unread view repaints the row and leaves it in a list it no longer belongs to |
Sizes are rough: XS under an hour, S a sitting, M a session.
@@ -361,71 +367,36 @@ batches of 200 so a 10k-thread query paints immediately) already holds. An
optimization pass with no measurement behind it is the kind of work that
produces a large diff and no change a user can notice.
-**What it needs before it can be sized.** The user saying which of these they
-meant: a correctness/security review of a named area, a specific operation that
-feels slow with the query that makes it slow, a dead-code and duplication sweep,
-or the translatability audit that is already item 22. The first three are
-different pieces of work with different sizes, and the fourth is already
-recorded.
-
-**Size: `?`, unspecified.** Do not propose a design for this; ask.
-
-## 68. A forwarded subject gets no `passed` tag
-
-**Observed (user, from the notes):** "passed tag should appear when subject is
-`Fwd:` and `Fw:`." Refined in session on 2026-08-11: the user had noticed
-`passed` appearing on messages whose subject carried `Fwd:` and not on `Fw:`,
-and asked to expand the rule to both.
-
-**Cause:** there is no rule to expand. `passed` is the Maildir `P` flag in the
-message filename, translated into a tag by notmuch because
-`maildir.synchronize_flags=true`. The flag is written by whichever client
-forwarded the message, or by the server over IMAP; nothing reads a subject line
-anywhere in the chain. qtmaildir only ever colours the tag
-(`src/tagcolors.cpp:36-37`) and the database's `post-new` hook does not mention
-it either.
-
-**Measured against the real database (2026-08-11):**
-
-| Query | Count |
-|---|---|
-| `tag:passed` | 6 |
-| `tag:passed and subject:"Fwd:"` | 1 |
-| `tag:passed and subject:"Fw:"` | 0 |
-| `subject:"Fwd:" and not tag:passed` | 194 |
-| `subject:"Fw:" and not tag:passed` | 28 |
-
-Six tagged messages in the whole database, and every one of them carries `P` in
-its filename flags. The single overlap with `Fwd:` is a message that was
-forwarded and whose subject was already a forward, not evidence of a rule: 194
-`Fwd:` subjects carry no tag at all. The correlation the observation rests on
-does not exist.
-
-**Approach and the decision it needs first.** Two different features, and the
-measurements above decide how far apart they are.
-
-*Display only.* The card shows a forwarded mark when the subject matches. Touches
-no mail, changes no flag, reversible by deleting the rule. XS.
-
-*Write the tag.* qtmaildir sets `P` from a subject heuristic. With
-`maildir.synchronize_flags=true` that flag is a filename change that mbsync
-carries out to the server, on 222 existing messages, on a guess about a string.
-Not cleanly undoable, and it asserts a meaning for a flag this application did
-not define. Recommended against; recorded so the choice is deliberate rather than
-forgotten.
-
-**Constraints:** localised clients use their own prefixes, and `Fwd:` can appear
-inside a subject rather than at its head, so whatever matches must be anchored.
-If the tag is ever written, it must not be re-applied on every sync in a way that
-produces pending edits the user never made, item 28 is the record of a count
-going wrong. The display-only route avoids that entirely, since it derives the
-mark at paint time and stores nothing.
-
-**Size: S** as written, XS if it is display only. Most of it is the decision, not
-the code.
-
-**Status:** left open deliberately on 2026-08-11. The cause is settled and the
-options are costed; the user has not chosen, and no code was written.
+**Narrowed by the user, 2026-08-26.** The notes now name two sub-bullets, and
+they are the same piece of work rather than two: "deduplication of
+functionalities" and "check for dead code (functionalities superseded by other
+additions, rendering them useless now)". So this is a dead-code and duplication
+sweep, NOT a performance pass and not a security review. Nothing slow has been
+reported, and the translatability audit it might have meant is item 22, already
+done.
+
+**What that makes it.** A read of the whole tree looking for a function with a
+newer twin and for a path nothing reaches any more. The codebase has precedent
+for both: `threadAt(int)` survives beside `threadFor(index)` for one legitimate
+caller, `SubjectDelegate` was deleted outright at item 53, and item 132 deleted
+a whole test rule that had stopped serving. The output is a LIST first, one
+entry per candidate with the evidence that it is dead or duplicated, not a
+diff; the user decides what goes.
+
+**Constraints.**
+
+- "Unreachable from the UI" is not the same as dead. Item 16's
+ double-press-to-undelete branch reads as dead and is not, because stranded
+ mail reaches it. Every candidate needs the reachability argument written out
+ before it is cut.
+- A test is a caller. Deleting production code with only test callers is
+ usually right; deleting the test with it needs saying so explicitly.
+- The sweep is worth nothing if it is not run against a green suite before and
+ after, since the whole value is that nothing observable changed.
+
+**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
@@ -534,140 +505,6 @@ reaches it (item 42), so most of this exists.
**Size: S** for the on-demand button, XS for the visibility half. Ask which.
-## 104. Mail visible in Thunderbird never reaches qtmaildir
-
-**Observed (user, from the notes):** "sync doesn't work compared to thunderbird.
-New mail received on thunderbird did not appear in qtmaildir. Need to investigate
-further."
-
-**Cause: NOT established.** Recorded because it is a defect report about mail
-going missing, which is the most serious kind this backlog carries, and it has
-been sitting in the notes unrecorded. What follows is one measured mechanism that
-would produce exactly this symptom, not a diagnosis.
-
-**qtmaildir cannot show what mbsync did not fetch, and mbsync fetches folders by
-pattern.** Three of the five channels in the user's `~/.mbsyncrc` name their
-folders explicitly:
-
-```
-Patterns "INBOX" "[Gmail]/Posta inviata" "[Gmail]/Bozze" "[Gmail]/Speciali"
-```
-
-and one names only `"INBOX"`. The two non-Gmail channels use `Patterns *`.
-Gmail applies labels, and a message whose label is not one of those four is in a
-folder mbsync never asks for. Thunderbird speaks IMAP directly and sees every
-folder, so the same message is visible there and absent locally. This is a
-configuration property of the user's mbsyncrc, outside this repository entirely.
-
-**One inconsistency worth reporting regardless**, found while checking the
-above: one of the Gmail accounts is configured in `qtmaildir.conf` with
-`sent = [Gmail]/Posta inviata` and `drafts = [Gmail]/Bozze`, while its mbsync
-channel has `Patterns "INBOX"` and fetches neither. The Sent and Drafts filters
-for that account can therefore only ever be empty. That is real, and it is
-independent of whatever this item turns out to be.
-
-**Approach.** Reproduce before anything else, and the reproduction has to
-distinguish three layers, because the fix lives in a different place for each:
-
-1. Is the message on disk? `find` in the Maildir, or `notmuch count` on a term
- from it. If not, this is mbsync or `.mbsyncrc`, and there is nothing to
- change here.
-2. If it is on disk, is it indexed? `notmuch new` and count again. If not, this
- is notmuch config, `new.ignore` or the hook.
-3. Only if it is indexed and still not shown is this qtmaildir's defect, and
- then the question is which query hid it: the account scope, the built-in
- filter, or a rule that tagged it out of the inbox.
-
-**Constraints.**
-
-- Ask the user for one concrete example before investigating: which account,
- roughly when, and what Thunderbird shows for it. A general "sync doesn't work"
- cannot be reproduced, and the last four defects in this backlog were all found
- from a specific message.
-- The `post-new` hook from mailctl tags mail unattended. A rule that removes
- `inbox` would make a correctly fetched, correctly indexed message vanish from
- the default view, which looks identical to a sync failure from the outside.
- `notmuch search` without a filter is what tells them apart.
-- Do not change `.mbsyncrc` as part of this. It is the user's, it is outside the
- repo, and a Patterns change refetches folders.
-
-**Size: `?`** until reproduced. Most likely not a code change here at all.
-
-
-## 112. Toggle unread on a whole thread cannot reach "all unread" on a partly-read thread
-
-**Observed (user, 2026-08-17):** clicking a thread root and asking to mark the
-whole thread unread does not do it. On a seven-message thread with two unread
-replies, the result is that every message is toggled unread **except those
-two**, which are left as they were. The user asks for an explicit "mark whole
-thread read/unread" rather than a toggle.
-
-**Cause (verified in code):** the action exists, and its direction is the
-defect. `toggle_unread_thread` (`src/mainwindow.cpp:931`, `Ctrl+Alt+U`) chooses
-between adding and removing by asking
-`everySelectedRowHasTag("unread", TagScope::Thread)`, which reads
-`ThreadListModel::threadFor(index).tags`. That is notmuch's **union over the
-thread** (`CLAUDE.md`, item 110), so a thread containing even one unread message
-answers "unread" and the action picks *Mark thread read*. There is no input a
-user can give that reaches *Mark thread unread* on a mixed thread: the only
-threads that take that branch are the ones already entirely read, and the only
-threads reporting "not unread" are the ones the user does not need the action
-for.
-
-The write itself is absolute and correct. `tagSelected` with `TagScope::Thread`
-adds or removes `unread` across every message, so the two unread replies in the
-report are not skipped by the write. They are the reason the write ran in the
-opposite direction from the one the user wanted.
-
-**A union is not a state, and a toggle needs a state.** This is the same class
-as item 110 and the third time the union has produced a defect. Items 105 and 88
-fixed *which object* a toggle resolved; this one is about a thread having no
-single answer to give. `everySelectedRowHasTag` is a two-valued predicate over a
-three-valued reality: all read, all unread, or mixed. The mixed case is the one
-that has no correct toggle direction, and picking either one silently is what
-ships as "the action does the wrong thing".
-
-**Approach.** The user has already named it: stop toggling at thread scope.
-
-- Split `toggle_unread_thread` into two explicit actions, **Mark thread read**
- and **Mark thread unread**, each with a fixed direction. Both appear in the
- "Whole thread" submenu, where an entry always carries text, so a fixed label
- is honest in a way a toggle's cannot be.
-- The message-scoped `toggle_unread` stays a toggle. One message has a real
- two-valued state, so the trap does not exist there. Do not "unify" the two:
- the asymmetry is the point.
-
-**Constraints.**
-
-- **Adding an action is four places**, all enforced by tests that fail
- confusingly: `KeyMap::knownActions()`, `defaultBindings()`, the icon table,
- and the no-duplicate-icons exception list. See `CLAUDE.md`. Splitting one
- action into two means one new entry in each, and the pair shares the twin's
- icon under the existing named exemption for thread actions.
-- **`Ctrl+Alt+U` is taken by the action being split**, and the whole-thread
- bindings are already one modifier out from their twins because `Ctrl+Shift+U`
- was claimed. Two directions need two sequences; if a second chord cannot be
- found that is not worse than the menu, bind one and leave the other to the
- submenu rather than inventing a three-modifier chord nobody will press.
-- **This interacts with items 98 and 99**, which is the reason to decide all
- three together. 99 asks for a dynamic label on the message-scoped toggle,
- which is the opposite move: keep the toggle, make the label tell the truth.
- A thread cannot do that, because on a mixed thread there is no true label to
- show. Deciding 99 first will produce the wrong answer here by analogy.
-- The undo entry must name the direction that ran (`Mark thread unread`), not
- the action. `tagSelected` already takes the text, so this comes free from
- splitting.
-- **The test needs a MIXED thread**, which is the whole defect: a thread whose
- messages are all in one state answers identically whichever way the direction
- is computed, so a fixture built from a uniformly-unread thread passes against
- the bug. Same trap as item 88's opposite-states requirement, recorded in
- `CLAUDE.md`.
-
-**Size: S.** The write path is already correct and thread-scoped; the work is
-the action split, the four registration sites, the binding decision, and a test
-over a mixed thread.
-
-
## 113. No way to see a message's HTML source
**Observed (user, 2026-08-17):** reviewing item 100's removals, "view source
@@ -803,40 +640,6 @@ make Save image work must not make Save link reachable again. The test fails if
it does, which is the point: the handler is per-profile, so the natural
implementation would light up both entries at once.
-## 118. No way to empty the trash from inside the app
-
-**Observed (user, 2026-08-17):** raised while reviewing item 103's spec, as
-something that had been forgotten rather than newly noticed: "we could add
-'Empty Trash' to the backlog as a future item. I forgot it existed, but I don't
-want to squeeze it in this spec."
-
-**Blocked on 103**, which creates the trash folder this would empty. Until that
-ships there is nothing to empty: Delete writes a tag and moves no file, so no
-account has a populated trash folder except through another client.
-
-**Deliberately excluded from 103's spec**, at the user's request and recorded in
-its "Out of scope" section. Worth keeping separate for a reason beyond scope
-control: emptying the trash is the first action in this application that would
-destroy mail with no undo. Every mutation so far is a tag or, after 103, a move,
-and both are reversible. A purge is not.
-
-**Approach, unspecified.** The shape depends on decisions not yet made, and the
-spec for 103 answers none of them:
-
-- **Local or remote.** Deleting the files locally and letting `Expunge Both`
- carry it to the server is one thing; asking the provider to empty its own
- trash is another, and mbsync offers no verb for the latter. The first is
- probably what "Empty Trash" should mean here.
-- **Whether the no-confirmation rule survives it.** It does not, on the face of
- it. `CLAUDE.md` grants undo in place of confirmation dialogs, and this is the
- action where undo cannot exist. That makes it the second item, after 103, that
- re-examines the rule rather than assuming it, and unlike 103 it will probably
- have to break it.
-- **Per-account or all-accounts**, which should follow whatever the Trash filter
- does once 103 ships rather than being decided independently.
-
-**Size: S**, provisionally, and not worth sizing properly until 103 exists.
-
## 119. The unsynced-changes count cannot be opened to see what it counts
**Observed (user, from the notes):** "the bottom left statusbar message needs to
@@ -1341,138 +1144,266 @@ The 70-second duration recorded above fits a `QTRY_*` waiting for a file that
is never going to appear, which is consistent with a wrong destination rather
than a slow one.
-## 162. Delete fails while a sync is renaming the file underneath it
-
-**Observed (user, 2026-08-25):** deleting a draft reported `Cannot move
-<file> to <account>/Trash`.
-
-**Cause (verified against the live Maildir, not read):** a stale path, and
-neither Delete nor item 158 is at fault.
-
-1. The composer autosaves a draft as `<name>:2,D` and item 158 indexes it
- under exactly that filename.
-2. **mbsync uploads it and RENAMES it** to `<name>,U=<uid>:2,D`, recording the
- server UID in the filename.
-3. notmuch still holds the pre-`U=` name until that sync's `notmuch new` runs.
-4. `moveMessages()` reads the filename from notmuch and calls
- `QFile::rename()` on a path that no longer exists. It fails, the error is
- emitted, and the message is skipped.
-
-Measured, in this order: `notmuch search --output=files` named a file that was
-not on disk while `Background sync running...` was up, and the same query was
-clean once the sync finished, with the file present under its new `,U=4` name.
-That is why it reads as intermittent, and why it heals itself.
-
-**It is truthful and it loses nothing.** The move is skipped, no wrong folder
-is created, no file is destroyed, and the next sync reconciles. The defect is
-that the message blames a folder for a timing problem, and that the action
-silently does nothing when the user asked for something.
-
-**This is `CLAUDE.md`'s `,U=` trap from the other side.** `MaildirName::fresh()`
-exists because CARRYING that infix across a folder boundary produced
-`Maildir error: duplicate UID` on real mail. Here mbsync is ADDING it and the
-index lags; the same infix, the opposite direction.
-
-**Approach, and it needs a decision.** Two candidates:
-
-- **Refuse the move while a sync holds the lock.** `SyncMonitor` already
- reports this, and the held-edit machinery from items 97 and 106 already
- exists for exactly this shape: a tag edit made during a sync is held and
- flushed when it ends. Delete would join it rather than inventing anything.
- This is the likelier right answer, since it matches what every other
- mutation already does.
-- **Re-resolve the filename** from notmuch immediately before the rename and
- re-query the message if the path is gone. Smaller, but it races the same
- window it is trying to close, and a second lookup can be stale by the time
- it is used.
+## 164. A draft this application saved keeps `inbox`
+
+**Observed (developer, 2026-08-25):** `notmuch search --output=tags` on a
+draft this application had just written reported `draft inbox unread`.
+
+**The first cause recorded here was WRONG, and the correction is the useful
+part.** It said `strip_inbox_from_sent()` reads a sent-only folder list and
+that `qtmaildirconf.py` has no drafts equivalent. Neither is true:
+
+- `NOT_ARRIVALS` is `("sent", "drafts")`, so `sent_folders()` already returns
+ both. The name says "sent" and the contents do not, which is what made the
+ wrong reading plausible.
+- Run against the real config it returns every account's drafts folder.
+- `notmuch count "(<carve-out query>) and id:<the draft>"` returns **1**. The
+ query the hook builds MATCHES the affected message.
+
+So the folder list and the query are correct, and the fix is not there.
+
+**What is actually established.**
+
+- The carve-out is scoped to `SCOPE = "tag:new"` (`post-new:106`).
+- The affected draft carries `inbox`, and `notmuch count tag:new` is **0**.
+- The installed hooks are SYMLINKS into this repository, so the code read is
+ the code that runs. Verified rather than assumed.
+- An mbsync-style rename does **not** re-apply `new.tags`: measured in a
+ throwaway database, a file renamed to add `,U=4` and reindexed kept the tags
+ it had. The "the rename retags it" theory is therefore also out.
+
+**What is NOT established, and must be before any code is written:** which
+pass put `inbox` on this file, and why it was not carrying `tag:new` when the
+hook's carve-out ran. The likely shape is an ordering one, since item 158
+indexes a draft from the application itself, outside `notmuch new`, and a file
+already known to the database is not a new file on the next pass. But that is
+a hypothesis and the last two hypotheses here were both wrong.
+
+**The reproducer was built (2026-08-25) and it settles the mechanism.** Seven
+variants were driven in throwaway databases, modelling `indexDraftFile()` with
+a real `notmuch_database_index_file` call rather than the CLI, because no CLI
+command indexes an untracked path without applying `new.tags`.
+
+What the sweep established, each measured rather than reasoned:
+
+- `index_file` applies **no tags at all**. A draft the application indexes is
+ therefore never in `tag:new` scope, and the hook has nothing to carve out.
+- Whenever the file IS in `tag:new` scope, the carve-out strips `inbox`
+ correctly, in every filename shape tried: `:2,DS`, `:2,D`, no info suffix,
+ in `cur/` and in `new/`, with and without the `,U=4` infix. The real file's
+ shape (`,U=4:2,D`) is among them.
+- It survives the orderings too: `notmuch new` first then the app's index,
+ the app's index first then the rename, an autosave landing between
+ `notmuch new` and the hook, and the stale-path `remove_message` that makes
+ the renamed file arrive as new mail. All six left the draft clean.
+- The `D` flag is what puts `draft` on the message (`synchronize_flags`), and
+ the `S` flag is what removes `unread`. The affected file is `:2,D`, which is
+ why it carries `unread`, and that matches the reported tag set exactly.
+
+**The one variant that reproduces it** is the general shape rather than a
+filename detail: a pass where `inbox` is applied while `tag:new` has ALREADY
+been consumed. Modelled as a file indexed at a path the carve-out does not
+cover and moved into the drafts folder afterwards, it ends in precisely the
+live end state, `draft inbox unread` in Drafts with `,U=4` and `tag:new` at 0.
+Nothing revisits a message once the marker is gone, so the tag is permanent.
+
+**What is still NOT established, and the next step.** The affected account
+writes drafts straight to `<account>/Drafts`, which the carve-out
+covers (verified against the live config and the live query, which matches the
+message by id today), so the reproducing variant's premise does not hold for
+it as written. The live log for the pass that added it reads
+
+ 10:10:52 Added 1 new message to the database. Detected 9 file renames.
+ 10:10:52 post-new: sent-folder carve-out applied over 9 folder(s)
+
+so the hook DID run on that pass, over a path the query covers, and logged
+success. The remaining candidates are all about what the path or the marker
+looked like at that instant, not about the query text: the carve-out logs
+"applied" on a `notmuch tag` that matched zero messages, so a successful log
+line is not evidence the message was in scope. Instrumenting the hook to log
+the carve-out's MATCH COUNT, and leaving it to run until the next draft, is
+the cheapest way to close it, and is a log-only change to code that tags real
+mail unattended.
+
+The filename also rules one thing in: `1787645266.M802P16149Q3.<host>` is
+exactly `MaildirName::fresh()` output, so the application wrote this file. It
+is not a draft another client left behind.
+
+The reproducer scripts are throwaway and were not kept; `indexfile.c` is
+fifteen lines around one `notmuch_database_index_file` call and is trivial to
+rebuild from this entry if the instrumentation points back at the hook.
**Constraints.**
-- **Delete reaches the real mail server.** Read `CLAUDE.md`'s item 103 notes
- before touching `moveMessages()`: a wrong folder name is created, adopted by
- mbsync, and propagated to every other client.
-- Whatever is built, **the message must say a sync is running**, not name a
- folder. The current wording sent the user looking for a broken folder
- configuration, which was correct and configured.
-- A test cannot see this in the ordinary fixture layout, where nothing renames
- a file underneath the index. Driving it means renaming the file between the
- index write and the move, which is what the reproducer has to do.
-
-## 163. The message pane shows a stale path and reports the message unreadable
-
-**Observed (user, 2026-08-25):** selecting a draft filled the pane with
-`(unreadable message)` and `This message could not be parsed.`, naming a file
-under the account's drafts folder.
-
-**Cause (verified against the live Maildir):** the path in the pane ended
-`.dnx:2,D`, and the only file on disk ended `.dnx,U=4:2,D`. Same mechanism as
-item 162: mbsync renames an uploaded file to record its server UID, and the
-name the application is holding stops existing.
-
-**The site is different, and so is the fix.** Item 162 is the WRITE path,
-`moveMessages()` reading a filename from notmuch. This is the READ path, and
-by the time it fires notmuch is already CORRECT: measured, the index named the
-`,U=4` file while the pane still named the pre-`U=` one. The stale path is the
-MODEL's, cached when the row was loaded, so refusing to act while a sync runs
-(162's likely fix) would not help here at all.
-
-**The report is honest, which is why it is confusing.** `MimeParser` opened a
-path that did not exist and said so. Nothing is lost and the next query
-repairs it.
-
-**Approach.** The read path should RECOVER rather than refuse: on a failed
-parse, re-resolve the message id through notmuch and retry once before
-reporting. `recoverStaleThread()` already exists for the neighbouring problem
-(item 91 reuses it) and is the shape to follow.
+- **The hook tags real mail unattended every ten minutes.** Nothing here is
+ worth a speculative change.
+- The 0.27.0 changelog claims sent mail and drafts both stay out of the inbox.
+ Whatever the cause, that claim is currently false for drafts and the entry
+ needs correcting with the fix.
+- Only `inbox` may be touched. A draft legitimately carries `draft` and
+ `unread`, and `maildir.synchronize_flags` means removing `unread` rewrites
+ the filename and reaches the server.
+- The hook must keep refusing to consume `tag:new` when a carve-out fails.
+- `test_post_new.py` and `test_qtmaildirconf.py` both live beside the hook and
+ have sent-carve-out tests to copy.
+
+## 165. A draft gets a new Message-ID on every autosave
+
+**Observed (developer, 2026-08-25), while hand-testing items 163 and 164.**
+Four saved drafts produced four distinct Message-IDs, and one reopen-and-edit
+turned one id into another. A draft therefore has no stable identity across
+its own revisions.
+
+**Cause (verified in the code, not inferred).** `MessageBuilder::build()`
+calls `g_mime_utils_generate_message_id()` unconditionally on every call
+(`messagebuilder.cpp:311`), and every autosave calls `build()`.
+`OutgoingMessage` has no field to carry an existing id in, and
+`ComposeContext` has no field for the draft's OWN id either: it carries
+`inReplyTo` and `references`, which are the ORIGINAL's id when replying, and
+`ComposeContextBuilder::forDraft()` never reads the draft's Message-ID back
+out of the file it parses. So this is not a changed call site; it needs a
+field that does not exist yet, threaded from `forDraft()` through
+`ComposeContext` and `OutgoingMessage` into `build()`.
+
+**Why it matters, and why it is NOT urgent.** To notmuch and to the server,
+each revision is a different MESSAGE, not a new version of one. While the
+file is replaced correctly this is invisible: one file in, one file out. It
+becomes visible whenever a revision is NOT replaced, and item 163 is the
+proof, where a stale path forked a draft into two files that were also two
+messages and that nothing will ever collapse. Item 163's fix removes the
+known way to reach that state; this entry is about the property that turned a
+one-file mistake into a two-message one.
+
+An interrupted save is the remaining route: `DraftStore::write()` unlinks the
+previous revision only AFTER the new file is safely in place (deliberately,
+so a failed write cannot lose the draft), so a crash between the two leaves
+two files, and with two ids they are two drafts rather than one duplicated.
+
+**Approach, and it needs a decision rather than an implementation.** The
+question is what a draft's identity IS, and it is not obviously "the id it
+will be sent under":
+
+- A stable id reused at send time makes the draft and the sent message one
+ message, which is what a user means by "my draft became this email". It
+ also means the id was in a file mbsync uploaded to the drafts folder before
+ the message was ever sent, and the server has seen it.
+- A stable id DISCARDED at send time keeps revisions collapsed while drafting
+ and mints a fresh id for the sent copy. Two identities, and the sent one is
+ the one that threads.
+- The current behaviour is a third position, and its only virtue is that no
+ id is ever reused for two different things.
+
+Whichever is chosen must be checked against `In-Reply-To`/`References` on the
+eventual send, since `referencesForReply()` builds those from the ORIGINAL's
+id and a draft of a reply carries both.
**Constraints.**
-- **A retry must be bounded.** A message that genuinely cannot be parsed
- (item 41's territory) must still report, or a real defect becomes an
- infinite loop.
-- Re-resolving by id is what makes this safe; re-scanning the folder is not,
- since two files can carry the same id.
-- The placeholder wording is correct and should stay for the genuine case.
-
-## 164. Every newly synced draft carries `inbox`
-
-**Observed (developer, 2026-08-25):** `notmuch search --output=tags` on a draft
-this application had just written reported `draft inbox unread`.
-
-**Cause (verified in the hook):** `strip_inbox_from_sent()` in
-`assets/hooks/post-new` builds its query from
-`qtmaildirconf.sent_folders()`, and `assets/hooks/qtmaildirconf.py` exposes
-`sent_folders()` and `sent_query()` and **no drafts equivalent**. The
-carve-out therefore never covers a drafts folder, and every draft that
-completes a sync round trip is tagged `inbox` by `notmuch new` like any other
-newly indexed file.
-
-**Item 158's measurement was right and did not cover this.** That item
-measured `notmuch_database_index_file` assigning NO tags, which is true and is
-why a freshly autosaved draft is clean. The tagging happens later: mbsync
-uploads the file, renames it, and the next `notmuch new` indexes it as new
-mail.
+- **A Message-ID reaches the server and every recipient**, so a reused id is
+ not a local matter. Two different messages sharing an id is worse than two
+ ids for one draft, which is what makes the current behaviour defensible as
+ a default rather than simply wrong.
+- `MessageBuilder::build()` is on the SEND path as well as the autosave path.
+ A change that makes ids stable must not make two different sent messages
+ share one.
+- The comment at `messagebuilder.cpp:297` records that GMime generates
+ neither Date nor Message-ID unless asked, and that a message without one
+ cannot be threaded by anything receiving it, this application's own index
+ of the sent copy included. Any "just omit it while drafting" variant has to
+ answer that.
+- Item 163's fix stands on its own and this does not block it: the file is
+ replaced correctly now, so the fork this would have mitigated no longer
+ happens by that route.
+
+## 169. A card shows the account only as a bar, with no fade and no avatar
+
+**Observed (user, from the notes):** "the left border of a card expresses the
+account the mail belongs to. the background color of the card should fade left
+to right from the account color to the current background color we are using (or
+to transparent to work both in light and dark themes). On the left we should
+leave room for an account avatar (a squircle), for now it could be extracted
+from the sender name "From: john doe" becomes "JD" in the avatar. As soon as we
+include khard (or some other vcard provider/manager) we will switch to images if
+the corresponding vCard has one."
+
+**Cause (verified in the code):** not a defect. Half of it shipped. The account
+colour is drawn as a solid bar down the left edge, `CardLayout::accentRect`
+placed by `CardLayout`, filled by `CardDelegate::paint()` with
+`CardDelegate::accentLineColour()`. There is no gradient anywhere on a card, and
+nothing draws an avatar: `CardLayout` reserves no rect for one, so the geometry
+would have to grow before the painting could.
+
+**Approach.** Two separable pieces, and the avatar is the one that changes the
+layout.
+
+- The fade is a `QLinearGradient` fill over the card rect, from the accent
+ colour to the pane's background. `accentLineColour()` already records why
+ blending toward the background is wrong for a CHIP; a card's background is
+ exactly where such a blend belongs, so the constraint does not carry over.
+ Both themes come free if the far stop is the palette's own base rather than
+ a literal.
+- The avatar needs a rect in `CardLayout`, which is where it becomes testable
+ without a painter, and it shifts `contentLeft` for every card. The initials
+ come from the display name already carried on the summary; a sender with no
+ display name (an address only) needs an answer before this is built.
-**This contradicts shipped documentation.** The 0.27.0 changelog says "Sent
-mail and drafts no longer appear in the inbox", and the drafts half has never
-been true. Fixing the hook and correcting the entry are one item.
+**Constraints.**
-**Approach.** A `drafts_folders()` / `drafts_query()` pair beside the sent
-ones, and one more carve-out call, or a single function taking the folder kind
-so the two cannot drift.
+- The vCard half is blocked on item 72, which is itself unspecified. Build the
+ initials only; do not design the image path in advance.
+- A gradient behind the text has to keep the text readable at the left edge in
+ both themes, which is the same failure mode `accentLineColour()` guards
+ against on a dark palette.
+- This is a looks question, so it is settled by the user looking at it rather
+ than by a test: assert the geometry in `CardLayout`, and hand the appearance
+ over per `tests-only-for-measurable-things`.
+
+## 170. A row that stops matching the view only leaves it on the Delete path
+
+**Observed (user, from the notes):** "should we refactor the list UI to be
+responsive so changes are applied immediately instead of waiting for a view
+change to repaint?"
+
+**Cause (verified in the code, 2026-08-26).** Two different properties were
+being called "responsive", and only one of them was built.
+
+The optimistic **repaint** is universal. `ThreadListModel::applyTagChange()`
+covers a thread-scoped write, `applyMessageTagChange()` a message-scoped one
+(items 105 to 111), and `revertPendingTagChange()` undoes either if the write
+is rejected. A chip, a bold row and a dimmed row all move the moment the user
+acts.
+
+The optimistic **membership** is not. `ThreadListModel::removeThreadsWithoutTag()`
+has exactly ONE caller, in `trashMessages()`, added last session because Delete
+strips `inbox` and a deleted message sat in the Inbox view across restarts. The
+ordinary tag path never calls it: neither `sendMessageTagChange()` nor
+`sendThreadTagChange()` asks whether the row still belongs in the view.
+
+So in the Unread view, marking a message read repaints the row and leaves it in
+a list defined by `tag:unread`, which it no longer matches. Un-flagging in the
+Flagged view is the same, and so is removing `inbox` by hand from the Inbox
+view. It corrects itself at the next query or sync, which is exactly the "waits
+for a view change" the note describes.
+
+**Approach.** Not a refactor. `viewFilterTag()` already resolves the view's own
+tag from the query, and `removeThreadsWithoutTag()` already does the removal.
+The gap is that the guard sits in `trashMessages()` rather than at the funnel
+every tag write passes. Move it, or call it from both send paths.
**Constraints.**
-- **This is a two-repo change in spirit but not in fact.** The hooks moved
- into this repository in 0.27.0, so `mailrules.py` is not involved and the
- shared-format procedure does not apply. Check that before assuming
- otherwise: `CLAUDE.md` still describes the hook as shipping from `mailctl`
- in places.
-- The hook must keep refusing to consume `tag:new` when a carve-out fails.
- Clearing the marker while the rules did not run orphans that mail
- permanently, which the sent half already gets right.
-- Only `inbox` may be touched. A draft legitimately carries `draft` and
- `unread`, and `maildir.synchronize_flags` means removing `unread` rewrites
- the filename and reaches the server.
-- `test_post_new.py` and `test_qtmaildirconf.py` are in the same directory and
- must both be extended; the sent carve-out has tests to copy.
+- The guard's existing reasoning is what makes this safe and must be kept: only
+ a plain `tag:<x>` view has a membership one tag decides. A path query (Trash,
+ Sent, Drafts) is unaffected by a tag going away, and a hand-typed query cannot
+ be reasoned about. Both are left alone. Without that, marking read in an `id:`
+ view would empty the list.
+- A row leaving is not revertible by `revertPendingTagChange()`, which repaints
+ rather than reinserts. A REJECTED write would leave the row gone until the
+ next query. The move path already carries that exposure; check whether it is
+ acceptable at the tag path's much higher frequency, or make the removal wait
+ for confirmation there.
+- The inverse case is deliberately out of scope: a row that starts matching
+ cannot be inserted optimistically, since the model has no summary for a
+ thread the query never returned.
+- Undo goes back through the same funnel, so a removal must not make an undone
+ mark-read invisible in the view it was undone in.