# Post-0.1.0 usability backlog Status: **open, expandable by design.** This document is not a fixed release plan. It collects items found by actually using qtmaildir after 0.1.0, and it grows as more turn up. Nothing here is scheduled; picking what ships in a given release is a separate decision. Source: usage notes taken while running the app, 2026-08-03. **Numbers here are this document's own.** The user's own notes were numbered independently and the two sequences drifted apart once items were split: what those notes called 12 is item 13 here, and item 14 here (the tag column) was never in them at all. Items 15 to 17 come from a later pass over the same notes. Cite these numbers, not the notes', and do not renumber to reconcile. **The notes are the upstream source and they keep growing.** The user adds to them while using the app, so this document goes stale on its own. Items 28 to 35 came from one such pass on 2026-08-04 and included two defects that had gone unrecorded here for a while. Items 39 to 45 came from the 2026-08-05 pass, which found one more defect (41, a message body silently dropped by the MIME walk) and one item that cannot be planned at all until the user says where the thing it manages lives (44). Items 121 to 123 came from the 2026-08-20 pass, which found that item 74 had closed only half of what its note asked for, and that the README had gone stale enough to document a mandatory config key by omitting it. Compare the two at the start of a session; the procedure is in `CLAUDE.md`. Numbering is stable. New items append with the next free number and never renumber, so a note referring to "item 7" keeps meaning the same thing. An item that is dropped stays in the table marked `dropped` with a one-line reason. **The status table below is the index of every item; the sections are only the open ones.** Item 73 moved the done, dropped and postponed sections out to `2026-08-03-post-0.1.0-usability-closed.md`, which took this file from just over five thousand lines to under six hundred. Nothing was deleted and nothing was renumbered: a closed item keeps its row here, with its date and outcome, and its full Observed/Cause/Approach section is in that file under the same number. Look there when a row cites evidence you need. **Items 20 and 53 are both on master since 2026-08-10**, as the card list. Item 20's original presentation, the one the user rejected on sight, is preserved on the branch `item-20-message-rows` at 029a50e and was never merged; the branch `card-list` carries the work that was. Any file or line reference in item 20's entry, now in the closed-items file, points at that PARKED branch, not at master, where the same lines are unrelated. Item 53 records why the first attempt was rejected and is worth reading before changing the thread pane again. ## Theme 0.1.0 was built to a spec written by someone who lives in neomutt. The result is a keyboard-driven reader with almost no visible affordances. The notes below are, with few exceptions, one complaint restated in several forms: **the app does not tell the user what it can do, and it does not remember what the user told it.** Two clusters follow from that: - **Persistence.** Splitter position, font size, window geometry, and the account selection all reset on restart. Each is small on its own and aggravating every single launch. - **Discoverability.** Shortcuts are the only route to most actions, and there is no menu bar, no toolbar, and no way to see the key bindings from inside the app. Both clusters are cheap to fix. Neither was an oversight in design so much as a consequence of specifying the app as "a GUI counterpart to neomutt" and then taking that too literally. ## Status table | # | Item | Cluster | Size | Status | |---|------|---------|------|--------| | 1 | Splitter/column widths do not survive restart | persistence | S | **done** | | 2 | No way to see full message details (From/To/Cc/Subject) | information | M | **done** | | 3 | Too few clickable affordances, shortcuts are the only route | discoverability | M | **done** | | 4 | Message-pane font size does not survive restart | persistence | S | **done** | | 5 | Thread list is cramped, poor readability | presentation | S | **done** | | 6 | Opened message stays unread | behavior | S | **done** | | 7 | HTML view should be default for HTML messages | behavior | XS | **done** (already worked) | | 8 | No buttons or menu entries for archive, undo, etc | discoverability | M | **done** | | 9 | No in-app view of configured shortcuts | discoverability | S | **done** | | 10 | Reaching an account's inbox takes two steps | workflow | S | **postponed** (partly done) | | 11 | Icon, `.desktop` file, SlackBuild | packaging | M | **done** | | 12 | Message pane is light-theme only | presentation | S | **done** | | 13 | No visual feedback that an action stuck | feedback | S | **done** | | 14 | Tag column unreadable, tags need another home | presentation | M | **done** | | 15 | Attachments are parsed but unreachable from the UI | information | M | **done** | | 16 | Delete on an already-deleted thread should undelete | behavior | S | **done** | | 17 | No completion for tags in the query bar | workflow | M | **done** | | 18 | No visual cue that there are unsynced edits | feedback | S | **done** | | 19 | No prompt to sync on exit when edits are pending | behavior | S | **done** | | 20 | Thread view does not match the user's mental model | presentation | L | **done** 2026-08-10, as the card list; see 53 | | 21 | Default shortcuts are not sensible enough | discoverability | S | open; **the user is drafting the table** in their own notes (`qtmaildir shortcuts and menu structure.md`), 2026-08-23. Read it first rather than proposing one. Settles `Ctrl+Return` for Send; leaves two collisions and an unfinished menu half, see the entry | | 22 | Translatability audit and i18n wiring | correctness | M | **done** 2026-08-15, unreleased; see `specs/2026-08-15-i18n-design.md`. Found eight rule-builder labels that could never be translated in any language, and twenty untranslatable warnings. Ships an Italian translation of all 355 strings | | 23 | No way to save a search query from the UI | workflow | M | **done** 2026-08-13, shipped in 0.18.0; see `specs/2026-08-13-saved-queries-design.md` | | 24 | No right-click actions on the thread list | discoverability | S | **done** | | 25 | No select-all, and bulk actions are undiscoverable | workflow | S | **done** | | 26 | No way to add or remove an arbitrary tag from the UI | workflow | S | **done** | | 27 | The UI cannot see a sync it did not start | feedback | S | **done** | | 28 | Re-adding `unread` counts 2 unsynced changes, not 0 | correctness | S | **done** | | 29 | Sync button stays enabled during a background sync | feedback | XS | **done** | | 30 | The blank right pane is wasted space | presentation | M | **done** | | 31 | The quit prompt has no highlighted default button | discoverability | XS | **done** | | 32 | Esc does not blank the right pane | workflow | XS | **done** | | 33 | Status bar messages never expire | feedback | S | **done** | | 34 | No overview of the Maildir itself | information | M | **done** | | 35 | No refresh of the thread list after a sync | workflow | M | **done** 2026-08-10; the list now follows a sync on its own | | 36 | `test_mainwindow` cannot reach the worker | testing | S-M | **done** 2026-08-14, unreleased; see `specs/2026-08-14-mainwindow-worker-fixture-design.md`. `WorkerBackedWindow`, opt-in per test, no production change. Its first use ruled out the simple case of item 66 | | 37 | The worker stalls on a tag edit made during a background sync | correctness | S | **done** | | 38 | `test_mainwindow` fails when a real sync holds the lock | testing | XS | **done** | | 39 | Thread list cannot be sorted by clicking a column header | workflow | S | **dropped** 2026-08-10; the card list has no column headers to click, and 0.13.0 shipped a sort dropdown instead | | 40 | No live filter over the current view | workflow | M | open | | 41 | A message whose HTML body carries a `Content-Id` renders blank | correctness | S | **done** | | 42 | "Syncing..." says nothing about what is being synced | feedback | S | **done** | | 43 | No "Mark all read" for the current view | workflow | S | **done** | | 44 | No way to manage the filters applied at sync time | workflow | M | **done** 2026-08-13; see `specs/2026-08-12-tagging-rules-design.md`. Spans this repo and `mailctl` | | 45 | Two Sync buttons, and only one of them works properly | correctness | S | **done** | | 46 | `uiStateSurvivesARestart` fails under the offscreen platform | testing | XS | **done** | | 47 | The query bar looks unfinished, and cannot be cleared by mouse | presentation | XS | **done** | | 48 | Removing a tag suggests every tag, not the thread's own | workflow | XS | **done** | | 49 | Sync runs every account regardless of what changed | workflow | M | **done** | | 50 | Esc blanks the pane but leaves the row selected | workflow | XS | **done** | | 51 | Clicking a subject scrolls the list sideways | presentation | XS | **done** 2026-08-10; a card is viewport width, so there is nowhere to scroll | | 52 | `test_querycompleter` fails under Wayland, passes offscreen | testing | XS | **done** | | 53 | Message rows still read as a table, not as a conversation | presentation | M | **done** 2026-08-10, merged to master as the card list | | 54 | A cron sync carries the edits but the count still says pending | correctness | S | **done** | | 55 | In a narrow window the message pane is invisible | presentation | XS | **done** | | 56 | No action carries an icon, so the toolbar reserves space for nothing | presentation | S | **done** | | 57 | "Flag" would read better as "Important" or "Starred" | presentation | XS | **done** | | 58 | `message_zoom` documents a 0.5 to 3.0 range and enforces none of it | correctness | XS | **done** | | 59 | Archive and Mark all read shipped with the same icon | presentation | XS | **done** | | 60 | Next thread dead-ends on the last reply of an expanded thread | defect | XS | **done**; already fixed by 5487d58, see the closed-items file | | 61 | `test_mainwindow` fails intermittently, about 1 run in 20 | testing | S | **done** 2026-08-13; an `init()` fixture points every test at its own lock table | | 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, **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 | **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` | | 72 | No khard/khal integration | workflow | ? | open, unspecified; the user places it after send, so v2 at the earliest | | 73 | This backlog is past four thousand lines | maintenance | S | **done** 2026-08-13; 5056 lines to 578, closed sections moved to `2026-08-03-post-0.1.0-usability-closed.md` | | 74 | "Searching..." keeps claiming a query is running while rows are already arriving | feedback | XS | **done** 2026-08-15, unreleased. The status-bar half only: the bar now counts threads per batch. The cold-cache delay itself was measured in 2026-08-11 and is not fixable here | | 75 | The tagging rules window forgets its size and its column widths | persistence | S | **done** 2026-08-13, shipped in 0.17.0. The window-kind question is left open, see the closed-items file | | 76 | Every field in the rules dialog is free text, so a rule is easy to get wrong | workflow | M | **done** 2026-08-13, shipped in 0.17.0. See `specs/2026-08-13-rule-builder-design.md` | | 77 | No way to see what a rule would collect, in the thread list | workflow | S | **done** 2026-08-13, shipped in 0.17.0 | | 78 | No way to build a rule from something visible in a message | workflow | S | **dropped** 2026-08-17 at the user's request. Never a defect: item 85 built the road (right-click any value, search it, save the query) and item 81 the last step (a rule from a saved query), so the whole journey is available. This was only a shortcut across it, and the entry had already said to use 85 for a while before deciding which values were worth promoting. Reopen if that use turns up a value worth a one-click rule | | 80 | A rule with many conditions squeezes the rule list to one visible row | defect | XS | **done** 2026-08-13, shipped in 0.17.0. Follows item 76 | | 79 | Opening the rules dialog and saving destroys the first rule | defect | XS | **fixed on `rule-builder`** 2026-08-13, unreleased. Shipped in 0.16.0; damaged one real rule, repaired by hand | | 81 | No way to turn a saved query into a tagging rule | workflow | S | **done** 2026-08-14, unreleased; see `specs/2026-08-14-query-to-rule-design.md` | | 82 | A saved query cannot be edited, unpinned or deleted from the UI | defect | S | **done** 2026-08-13, shipped in 0.18.0. Right-click offers Edit, Pin/Unpin and Delete | | 83 | A rule named with spaces is written to the file and dropped by every reader | defect | S | **done** 2026-08-14, unreleased. The name is sanitised into an id, save validates, a bad id loads for repair | | 84 | A config problem blocks `test_mainwindow` on a modal nobody can dismiss | testing | S | **done** 2026-08-14, unreleased. `showWarnings()` split: the status label stays in the constructor, `main.cpp` raises the modal after `show()` | | 85 | Nothing on screen can be searched for by right-clicking it | workflow | M | **done** 2026-08-14, unreleased; see `specs/2026-08-14-search-from-message-design.md`. Split from 78; rebuilt the details dialog as rows | | 86 | A right-click search can replace or narrow, but never exclude | workflow | S | **done** 2026-08-14, unreleased; see `specs/2026-08-14-exclude-from-search-design.md`. Follows 85. The `extend` bool became a `SearchMode` enum across four signatures | | 89 | A sync moves the list under the user's hands, and the auto-sync skips rather than retries | workflow | XS | **done** 2026-08-15, unreleased. The timer half only: a skipped auto-sync re-arms instead of giving up. The list-churn half is **dropped**, not built: the user resolved it as a mental-model question, an Unread view is SUPPOSED to be volatile | | 90 | A saved-query button clears the account selection | workflow | S | **folded into 93** 2026-08-15. Not fixed in place: the button that misbehaves stops being a saved query at all. See `specs/2026-08-15-builtin-filters-design.md` | | 91 | Double-clicking a thread should open it on its own | workflow | S | **done** 2026-08-15, unreleased. The view is always the whole thread, EXPANDED; the pane shows whichever row was double-clicked, so a reply drills to its thread and not to itself. Reuses `recoverStaleThread()` outright | | 92 | Nothing distinguishes a tag written by a rule from one the user applied | information | M | **postponed** 2026-08-15 at the user's request: "I don't see the utility, so I don't really know how to answer." Needs per-MESSAGE provenance nothing records, a two-repo format change blank on all existing mail. Reopen only if the need appears in use | | 93 | The query buttons are whatever the user pinned, not a designed set of filters | workflow | M | **done** 2026-08-15, unreleased; see `specs/2026-08-15-builtin-filters-design.md`. Absorbs item 90. Four built-in filters composing with the account dropdown; the user's own queries unpinned, never deleted | | 95 | A query in the overflow menu cannot be run | defect | XS | **done** 2026-08-15, unreleased. Pre-existing and not caused by 93: the entry's action owned a submenu, and Qt emits no `triggered` for those, so the connection had never fired. Surfaced because 93 moved every query into the menu | | 94 | `pinned` has nothing left to decide once the buttons are built-in | maintenance | S | **done** 2026-08-24, unreleased. The query row is the six built-in filters only, every saved query is in the menu, and `SavedQuery::pinned` is gone from the struct, the reader, the writer, the save dialog's checkbox and the pin/unpin context action. The user chose to **strip** the stored key rather than leave it ignored, against this entry's own preference, so `pinned` stays named in the reader's `known` list precisely so it is NOT preserved as an unknown field and written back. Confirmed with the user first that the built-in set covers their use, since removing pinning removes the escape hatch this item was blocked on. Six tests reached saved queries through buttons that no longer exist and were converted to the menu, two more replaced outright (`onlyPinnedQueriesBecomeButtons` and friends), and `migrationPinsEveryEntry` / `aStoredGeneratedQueryIsUnpinnedNotDropped` were rewritten around the property that survives: an entry must be KEPT, which is what those assertions were really guarding. Three translated strings retired, `lrelease` reports 479 finished 0 unfinished | | 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 | **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 | 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 | | 105 | Acting on a reply changes the counter and nothing on screen | defect | M | **done** 2026-08-16, unreleased. Found by hand-testing 88, and took three passes. FOUR causes: no optimistic update for a message-scoped write, no doomed cue on a reply row, both toggles reading the reply's THREAD state so they were one-way, and the message pane's strip not following a message edit. Also bolds an unread reply, at the user's request | | 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+`. User-visible: minor bump, `### Upgrading` written | | 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 | **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 | | 122 | The README documents a version of the app that no longer exists | documentation | M | **done** 2026-08-23, unreleased, inside item 123 task 13. `trash`, `send_command` and the whole `[compose]` section were undocumented; a Composing section is added and "sending is not implemented" removed. Every default was read from `config.h` rather than from the prose, which caught `send_html` documented as false when it defaults to true | | 123 | Sending mail is not designed | v2 | L | **specified** 2026-08-20, on branch `compose-and-send`. Design in `docs/superpowers/specs/2026-08-20-compose-and-send-design.md`; read that, not this row. Send is a per-account `send_command` on stdin, so the no-network-protocol rule stands. Composer is a separate window, body is markdown via cmark-gfm, drafts autosave to the account's drafts folder. Tasks 1 to 13 of 13 built 2026-08-20 to 2026-08-23; task 13 closed the documentation out and retired the v1/v2 split, which semver had made meaningless. **Hand tested 2026-08-22 and 2026-08-23** against a fake send command: New, Reply and Forward all send, a forwarded attachment survives intact, and the sent copy is filed. Found two defects, both fixed (the orphaned composer, and sent mail carrying `inbox`). Twenty-two defects were found in the plan's own draft code across tasks 4 to 12, so treat every code block in it as a draft | | 124 | The worker reads the index directory as the mail root | defect | S | **done** 2026-08-20, unreleased. `mailRootOf()` over `NOTMUCH_CONFIG_MAIL_ROOT`, correct under both layouts. Verified by migrating the developer's own index to NVMe the same day: cold start 38.6 s to 0.67 s | | 125 | A skipped sync leaves the spinner running for ever | defect | S | open, 2026-08-20, found by hand. `mailsync.sh` exits 75 (EX_TEMPFAIL) when another run holds the lock; the indicator never clears, and a held edit waits for a completion that never comes | | 126 | A link with `target="_blank"` does nothing when clicked | defect | S | **done** 2026-08-20, unreleased. `createWindow()` returns a relay page that receives the navigation, hands the URL to the browser and refuses. The URL cannot be read in `createWindow()` itself, which is why a relay rather than a lookup | | 127 | A link's context menu offers four browser actions that cannot work | defect | XS | **done** 2026-08-20, unreleased. Three Open-in actions removed, `CopyLinkToClipboard` kept. Item 126 made them more dangerous rather than less: with a real `createWindow()` they would have started working | | 128 | No outbox: a send with no network fails instead of queueing | v2 | M | open, 2026-08-20, from the item 123 brainstorm. **Blocked on 123.** The seam is designed in (`MessageSender` is the one funnel), so this wraps it rather than reworking it. Needs its own indicator story first: items 18, 19, 28 and 54 are all an indicator lying, and 125 is one still open | | 129 | No inline images in a composed message | v2 | M | open, 2026-08-20, from the item 123 brainstorm. **Blocked on 123.** Wanted by the user. `cid:` from the HTML part with `multipart/related` nested inside the alternative, the most nesting-heavy part of MIME assembly, and markdown offers no syntax for it | | 130 | A message cannot be attached to another message directly | v2 | S | open, 2026-08-20, from the item 123 brainstorm. **Blocked on 123.** A `message/rfc822` part, which GMime builds natively. The manual route exists from 123's first commit: `save_message` writes the `.eml` and it is attached as a file | | 131 | The markdown dialect and extensions are fixed | v2 | S | open, 2026-08-20, from the item 123 brainstorm. **Blocked on 123.** Configurable in the shape Hugo's config uses. Deliberately fixed initially: CommonMark plus autolink, strikethrough and tasklist | | 132 | Every action must have a shortcut, and that no longer serves | policy | S | done, 2026-08-20. `everyActionHasAShortcut` is deleted and nothing replaces it: `everyActionIsReachableFromAMenu()` is the required rule and a shortcut is now a chosen subset. Nothing else needed changing, since `showShortcutReference()` already printed `(unbound)` for an empty sequence. Verified by unbinding `tag_rules` and running the suite green, which would have failed before | | 133 | The composer shows no markdown syntax highlighting | v2 | S | open, 2026-08-20, from the item 123 brainstorm. **Blocked on 123.** A `QSyntaxHighlighter` over the composer's editor, so `**bold**` reads as bold while the buffer stays plain markdown. Standard Qt, no dependency. Deliberately after 123's formatting toolbar: agreeing with the grammar about nesting and about code spans suppressing what is inside them is the expensive part, and the toolbar is what makes the feature usable | | 134 | The busy indicator is built inline and is about to be built twice | maintenance | S | done, 2026-08-20, af902e0. `BusyIndicator` (`src/busyindicator.h`) carries both modes: `MainWindow` uses the indeterminate one, and item 123's send popup takes the determinate half for its undo countdown, switching the same widget over when the command starts. Only the BAR was extracted, not the status label this row paired with it. `m_statusLabel` has 34 uses across `MainWindow` for transient messages, selection counts and sync phases, so it belongs to the window rather than to the indicator, and the send popup owns its own phase text | | 135 | The formatting toolbar's buttons stack rather than toggle | v2 | S | open, 2026-08-21, asked for by the user during item 123 task 8 and reverted the same session. **A spec change, not a defect**: it conflicts with spec:236 ("deliberately no live toggle") and spec:187-190. Both sites need amending FIRST, and the amendment must resolve what replaces bold-then-italic, which is the gesture spec:187's preserved selection exists to serve and which a toggle makes unreachable. That question is the work; the state machine is understood and written up in the section | | 136 | `undoMovesTheMessageBack` fails when run ALONE, passes in the full suite | defect | ? | open, 2026-08-21, re-measured 2026-08-24 and it is not what the row said. Filed as an intermittent race (1 in 6); it is in fact **deterministic on the selection**: 6 failures in 6 when named on the command line, and, as of 2026-08-24, it fails in the FULL run too: measured at 58f13ad with the day's work stashed out, 274 passed and this one failed. The "passes in the suite" half of this row is therefore no longer true, and the selection-dependence it was named for may not be either. Re-measure before theorising. All three of its 15s `QTRY` timeouts expire, giving 45s against a 25s whole-suite run, so undo never moves the file rather than losing a race. A test that needs its predecessors is the likely shape (the `init()` lock-table fixture of item 61 is one candidate), which makes it a TEST defect until shown otherwise. Not caused by item 149, and re-confirmed 2026-08-24 as not caused by item 152 either, by running the test at the preceding commit in a throwaway worktree. The assertion that fails names the real question: the restored file is in NEITHER `cur` nor `new` of the account inbox, so establish where it went before theorising about a race | | 137 | A reply to a message that arrived at two accounts can come from the wrong one | defect | S | open, 2026-08-22, found while building item 123 task 12. `ComposeContextBuilder::accountForReply()` takes `messagePaths` PLURAL to disambiguate, and nothing upstream ever gives it more than one path, so the disambiguation is inert | | 138 | No Drafts filter beside Sent and Trash | workflow | S | **done** 2026-08-24, unreleased. Smaller than sized: `Account::draftsQuery()` and `Config::allDraftsQuery()` already existed for the placeholder pane's count, so only the `kQueryGenerators` entry, the two `resolvedQuery` branches, the label and an icon were missing, and `builtinFilters()` derives the row from that set. Follows TRASH rather than Sent: folder-matched like both, but NOT flat, since a draft reply belongs with the conversation it answers. An account with no `drafts` key shows no button at all, per item 103's rule, which the existing row test surfaced by failing until its fixture configured one | | 139 | Forward is reachable only from the Message menu | discoverability | XS | **done** 2026-08-24, unreleased, inside 140/141 as that entry said it would be. Forward is on the message pane's own bar with Compose and Reply | | 140 | Compose, Reply and Forward belong over the message pane, not on the main toolbar | presentation | M | **done** 2026-08-24, unreleased, with 139 and 141, then REVISED the same day after the user looked at it. Reply and Forward move; **Compose stays on the main toolbar**, because the split that survives contact is what the action NEEDS rather than what it is about, and composing needs no message at all. The moved actions leave the toolbar rather than gaining a second home. Same `QAction` objects shown twice over, never copies, so enablement and the menu entries stay single-sourced | | 141 | The message pane has no button bar of its own | presentation | M | **done** 2026-08-24, unreleased, with 139 and 140. The design question the entry flagged was settled with the user: message actions left, view controls right, separated by an expanding spacer, with `toggle_html` the first of the latter. It sits directly above the web view, BELOW the subject and details rows, which was the user's correction after seeing it at the top of the pane read as window chrome. Icons are 7/8 of `toolbar_icon_size` (28 against the user's 32), derived so the relation survives a change to that key. `MessageView::setBarActions()` is the seam, so the pane still knows nothing about `MainWindow`'s action map. Two traps: a toolbar has no `addStretch()`, and `noTwoActionsShareAnIcon` took an UNNAMED `findChild` which now has two candidates, so it is pinned to `main_toolbar` or it would assert against the wrong bar and pass while the rule went unchecked | | 142 | The composer's formatting buttons share a toolbar with Send and Attach | presentation | S | **done** 2026-08-24, unreleased, with 143/144/145 to the user's own layout. The one `addToolBar` is gone: the composer has no window toolbar at all. Formatting is a `QToolBar` WIDGET in the central column directly above the editor, Send is a big icon-above-text button beside the headers, Attach and the HTML toggle ride the right end of the editor bar, Remove attachment sits with the attachment list. The constraint this entry named came true: the strengthened send-lock test caught Attach live during a countdown, and then a SECOND fault the entry did not predict, see the section | | 143 | The formatting buttons are text, where every editor uses icons | presentation | XS | **done** 2026-08-24, unreleased, inside 142. `QIcon::fromTheme` per CLAUDE.md's chrome rule, the words kept as the tooltip, and an action whose theme lacks the name keeps its text rather than rendering an empty button | | 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, 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 | | 151 | The message-pane bars blend into the UI and carry no severity | presentation | S | **done** 2026-08-24, unreleased. Two severities as the user asked: yellow for a warning that only explains (the receive-only ribbon), blue for one offering an action (remote content blocked, stale thread), each with its own light and dark set read off `QPalette::Base` as `HtmlBuilder` does. The blocked row had to become a WIDGET first: it was a bare `QHBoxLayout`, which has nothing to paint a ground on, and its six `hide()` sites then had to move to the wrapper or a painted empty strip would show. Both action bars put the button right of a stretch | | 152 | Signatures are not managed at all | v2 | S | **done** 2026-08-24, unreleased. One markdown file per signature under `~/.config/qtmaildir/signatures/`, spliced into the composer buffer by the `Signatures` namespace and chosen from a `QToolButton` switch on the editor bar. `[compose] signature` seeds a new message, `[account.] signature` overrides per account, `[compose] signature_position` picks end or above_quote. `MessageBuilder` is untouched: it already derives both parts from one string, the transparency the user asked for, and the per-account key does not break "not tied to an account", since an account SEEDS the choice while the switch keeps every signature reachable. A hand test caught one guard defect (a trailing newline defeated the replace guard); fixed and regression-tested | | 153 | A draft cannot be opened for editing, so it is write-only | defect | M | **done** 2026-08-24, unreleased. `ComposeContextBuilder::forDraft()` reads a draft back into a context; a new `Kind::Draft` seeds the fields verbatim, takes the body with no quote framing, and carries `draftPath` so the autosave REPLACES the file instead of leaving a second copy. `MimeParser` gained `bcc`, which nothing read before: `MessageBuilder` writes Bcc into the draft deliberately, so a resumed draft that ignored it would silently drop every blind recipient. Reachable by double-click and by an `edit_draft` action, gated on the file being in a configured drafts folder because opening ordinary mail this way would make the first autosave DELETE a received message. Found a live defect on the way, see the section | | 154 | No read confirmation | v2 | ? | open, 2026-08-24, from the notes. `Disposition-Notification-To`, which is a header `MessageBuilder` would add and a request the message pane would have to honour or ignore on the receiving side. Unspecified: whether this is send-side only, and what the reader is asked | | 155 | No urgency switch on an outgoing message | v2 | S | open, 2026-08-24, from the notes: low, regular, high. `X-Priority` and `Importance`, headers `MessageBuilder` adds; regular writes neither. A control in the composer, and the same question item 144 answered for the HTML toggle applies to where it sits | | 156 | No delivery confirmation | v2 | ? | open, 2026-08-24, from the notes. Distinct from 154: this is a DSN (`Return-Receipt-To`, or the ESMTP NOTIFY parameter), which is the sending server's to honour rather than the reader's client. Whether it can be requested at all depends on the `send_command`, so this may not be this application's to offer | | 157 | A draft on display offers Reply and Forward, not Edit | workflow | XS | **done** 2026-08-24, unreleased, and the half item 153 did not close. `populateMessageBar()` swaps the reply pair for `edit_draft`, refilled from `updateComposeActions()` so it follows the message. **Took three hand-test rounds, each finding a defect the tests could not see.** First version shipped item 150's trap one level up: it keyed on `currentIndex()`, which a query leaves VALID on a row of the discarded result, so the bar kept the draft button after clicking Inbox and the reply pair after clicking Drafts. It answers from `m_currentMessageId`/`m_currentThreadId` now, which every blanking route clears, refilled from `showPlaceholderPane()` — the one site all five of those routes share. That exposed a THIRD defect nobody had reported and which predates the bar: enablement ran only from the two selection handlers, so Reply and Forward stayed **enabled over a blank pane**, invisible while they sat on the main toolbar among always-on actions. The bar is then HIDDEN over an empty pane (`!m_items.isEmpty()` in `MessageView::setBarActions`): the user first chose a greyed-out bar, then reversed it on sight for a better reason, that the subject and details button already vanish and a persisting bar was the only piece of header furniture that did not. **The hiding half broke the showing half**, found by hand again: `setBarActions` is called from `updateComposeActions()`, which runs BEFORE `showThread()` fills `m_items`, so the first message opened after any blanking left the bar hidden and the second showed it, reading `m_items` still holding the first — one selection behind for the life of the view. `updateHeader()` shows it, beside the details button it rides with. The test missed it by asserting before the render landed, measuring the placeholder; it waits on `showingPlaceholder()` now. Several guards and `hide()` calls were written across the three rounds and then measured dead, and removed | | 158 | A freshly saved draft is invisible until a sync indexes it | defect | S | **done** 2026-08-24, unreleased. `saveDraftNow()` emits `draftSaved`, which `MainWindow` connects to a new `NotmuchWorker::indexDraftFile()` that indexes the one file (previous revision removed, so a rewrite leaves no ghost), and `draftRemoved` drops the entry when a sent draft is unlinked. Measured: `index_file` assigns NO tags, so no stripping and no tag:inbox leak. See the section | | 159 | The Drafts view lists threads, so a draft is unreachable by double-click | defect | S | **done** 2026-08-25, unreleased. Reverses item 138's own decision, confirmed with the user. `generatorIsFlat()` in `config.cpp` is now the single closed set of flat generators, replacing three hardcoded comparisons against `"sent"`: the built-in filter, the reader that reapplies the mode, and the writer that skips storing what the generator implies. Those three had to agree and nothing made them; a `drafts` entry saved and reloaded would otherwise have come back THREADED while the button was flat. `builtinFilter()` sets `flat` once from the helper rather than in a branch, so the set cannot drift from the labels | | 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 | **done, 2026-08-25.** mbsync renames an uploaded file to add its `,U=` 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=` 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. --- ## 21. Default shortcuts are not sensible enough **Observed (user, 2026-08-04):** "improve the default shortcuts to some sensed defaults." **Unspecified in detail**, so ask which bindings feel wrong before proposing a table. What is worth recording is the history, because the defaults have already moved once and the reasons still constrain any second pass. **Where the current defaults came from.** 0.1.0 used bare letters. They were replaced in the 0.2.0 menu work for two reasons that have not gone away: a single letter cannot be a menu accelerator without claiming that letter window-wide, and a bare capital such as `N` parses to an unshifted `Key_N`, which no keystroke emits, so `toggle_unread`, `flag` and `sync` were dead keys that appeared to be bound. See `KeyMap::defaultBindings()` and `normalizeSequence()`. **Constraints on any new default.** - **Do not test reachability with synthetic input.** `QTest::keyClick()` does not reproduce a keyboard layout: it reported `Ctrl++` as dead when it is exactly what the `+` key emits on the user's Italian layout. Verify against the real keyboard, as `CLAUDE.md` records. - Every binding is overridable in `[keys]`, so this is about what a fresh install feels like, not about what is possible. - `Return` is a special case already resolved: it belongs to `open_thread` but the query bar claims it back while focused, so a proposal that moves it must not resurrect that bug. **The user is drafting the table, 2026-08-23.** It lives in their own notes as `qtmaildir shortcuts and menu structure.md`, linked from the note this item came from, and it is the specification this item was waiting for: a row per action with the current binding, the proposed one, and an explicit "no shortcut" column for the actions that should have none. **Read it before starting, and do not propose a table of your own.** It is unfinished in two known places, so it is a starting point rather than a finished spec: - **The menu-structure half is one line long** ("File should hold Save message") and is where the second half of this item's work is specified. - **The compose actions are absent from it**, because it predates them being usable by hand. The user's position as of 2026-08-23, stated but not yet written into their table: `Ctrl+Return` for Send is **kept**, which closes that open question from item 123 task 11; and major actions should not go three modifiers deep, so Reply becomes `Ctrl+R`, Reply all `Ctrl+Shift+R`, and Forward `Ctrl+F`. **Two collisions that proposal creates, both to settle before building.** `Ctrl+R` is `restore` today, and the draft's own row for it says "ok if not needed for something else" — it now is, so Restore needs a new binding or none. And `Ctrl+F` is Find in most applications; the draft frees it by moving Find to `/`, so the two are coupled, and if `/` does not survive review then Forward loses its binding with it. **`/` for Find needs an event filter, not a shortcut.** Qt withholds only plain LETTERS from editable widgets, so a `/` registered as a `QAction` shortcut is dispatched before the query bar, the tag dialog and the composer's editor ever see it, and a user could not type a path or a URL in any of them. This is the same trap `CLAUDE.md` records for arrow keys, and `Return` is the worked example of the fix: claim it in `MainWindow::eventFilter` by accepting the `ShortcutOverride`, narrowly, for the one widget that needs it. **Dropping a shortcut is not dropping the action.** The draft marks the five `*_thread` actions (item 108) for removal, and the user confirmed on 2026-08-23 that this means their SHORTCUTS only. The menu entries must stay: `everyActionIsReachableFromAMenu()` is a required rule, while item 132 made the shortcut itself optional, so an action with no binding is now ordinary and prints as `(unbound)` in the shortcut reference. ## 40. No live filter over the current view **Observed (user, 2026-08-05):** "search in current view", spelled out as two things: "a light filter applied live on the current view", and "a search bar appearing as soon as we type while no entry box is focused". **Cause (verified in code):** the only search is the query bar, which runs a notmuch query and replaces the result set. There is no client-side filtering of an existing result: no `QSortFilterProxyModel` anywhere in `src/`, and `ThreadListModel` has no filter of its own. Narrowing the current view therefore means writing a new notmuch query and losing the view. **Approach.** Distinct from the query bar, and the distinction is the point: this filters rows already fetched, without touching notmuch. - A filter over the model's loaded rows, matching subject and from, case insensitively. No worker round trip. - A filter strip that appears on the first keystroke while no entry box has focus, and disappears on Escape, restoring the full result set. **Constraints.** - **Type-to-filter competes with the plain-letter shortcuts.** Item 3's outcome records that a plain-letter `QAction` shortcut is suppressed only while an editable widget has focus, which is exactly the state this feature does not start in. Any binding that is a bare letter would be swallowed by the filter strip or would swallow it. Check the current defaults before choosing the trigger, and prefer appearing only for characters no action claims. - Escape already blanks the message pane (item 32). If Escape also closes the filter, decide the precedence explicitly rather than letting whichever handler runs first win. - The filter is presentation only: it must not clear the selection, the undo stack, or the query, and the pending-edit count must not move. - Interaction with item 39: a filter and a sort over the same rows want the same proxy. Whichever is built first should leave room for the other. ## 65. No full code review and optimization pass **Observed (user, from the notes):** "full code review and optimization." **Cause:** not a defect. The codebase has grown from the 0.1.0 spec through sixty-odd backlog items, and nothing has gone back over it as a whole. **Why this cannot be planned from the backlog.** "Review and optimize" names no symptom, no measurement and no target. There is no reported slowness to chase, and the one performance property the design does commit to (threads emitted in 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. **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 **Observed (user, from the notes):** "investigate khard/khal integration (light PIM, probably worthy after we add send capabilities)." **Cause:** not a defect. v1 is read-and-organize; there is no address book and no calendar anywhere in the codebase. **Why this cannot be planned.** The user's own note places it after send, and send is v2. What "integration" means is undecided: completing recipients from khard when composing, showing a sender's card, or acting on an invitation. Those are three different features. **Size: `?`, unspecified**, and out of scope until v2 exists. Ask before designing anything. ## 99. The unread action is labelled "Toggle unread" whichever way it will go **Observed (user, from the notes):** "the label for 'toggle unread' should be dynamic: on an 'unread' message it should be 'Mark as read', on a 'read' message it should be 'Mark as unread'." **Cause (verified in the code).** `src/mainwindow.cpp:867` registers one static label, `tr("Toggle &unread")`, and the lambda decides the direction at invocation time from the current row. The action carries that text in three places at once: the Message menu (`:1060`), the thread context menu (`:1167`) and the toolbar (`:1122`, with the `mail-mark-unread` icon). Nothing updates it when the selection changes. **Not as simple as reading the current row**, which is why this is S and not XS. - The action applies to the WHOLE selection and picks one direction from the current row, so with a mixed selection any label naming a single outcome is either wrong for some rows or has to describe the rule ("Mark all as read"). - A menu action's text is read when the menu opens, but a TOOLBAR button's text is on screen continuously, so it has to track `selectionChanged` rather than being computed at popup time. `currentRowChanged` is the wrong signal for anything selection-shaped, per `CLAUDE.md`. - The accelerator is inside the word (`Toggle &unread`). Two different labels need two accelerators chosen so neither collides in the Message menu, which already holds "Mark &spam" and "&Important". - The shortcut list (Help > Keyboard shortcuts) and the config's `[keys]` section both name the action `toggle_unread`. The action NAME must not change with the label, or every user's config breaks. Same rule as item 57, which changed "Flag" to "Important" on screen and left the action and tag alone. **Approach.** Compute the label from the same state the lambda already uses, which since item 105 is `MainWindow::everySelectedRowHasTag("unread")`, update it on `selectionChanged`, and keep a neutral fallback for an empty or mixed selection. Decide with item 98, which raises the identical question for "Important". **Use that helper rather than re-deriving the state**, or the label and the action can disagree. It already encodes the two things this gets wrong on its own: a reply answers about its MESSAGE, not its thread, and the answer is over the whole selection rather than the current row. **Constraints.** Every label is user-facing and needs `tr()`. Since the strings are chosen at runtime rather than written once, all of them must exist as literals `lupdate` can see; a string built by concatenation is not translatable. `ctest -R translations` is the check. **Size: S.** Mostly the mixed-selection and toolbar decisions, not the code. ## 101. Sync is account-aware for edits but not for the account the user is looking at **Observed (user, from the notes):** "sync button should be account-aware." **Cause (verified in the code).** `MainWindow::pendingSyncChannels()` (`src/mainwindow.cpp:3550`) resolves channels from `m_editedAccounts`, the set of accounts the user has made EDITS in, and from nothing else. The account dropdown is not consulted. With nothing pending it returns empty on purpose, and `mailsync.sh` turns that into `mbsync -a`, every channel. **Item 49 built exactly this and the reasoning still holds.** The comment states it: with nothing pending the run is a FETCH, and narrowing a fetch to wherever the last edit happened would "quietly stop collecting mail everywhere else". Fetching is global by nature; carrying edits is not. **So this needs a decision, not a fix.** The note does not say which of two things the user means, and they are different features: *Sync only the selected account, on demand.* A deliberate "sync this account" that ignores the pending set, presumably beside the existing Sync rather than replacing it. Useful when one account is slow and the user wants their mail from another one now. The risk is the one item 49 named: a button that looks like Sync and quietly does not collect the rest of the mail. *Show which accounts a sync will cover.* No behaviour change at all, just making the existing account-awareness visible, since today the user cannot tell whether a run is narrowed or full. The status bar already names each channel as mbsync reaches it (item 42), so most of this exists. **Constraints.** - The account dropdown is a VIEW filter. Making it also steer sync couples two things the user may reasonably want apart: looking at one account while fetching all of them is the normal case, not an edge case. - Whatever narrows a run must still carry every pending edit, or an edit is stranded with nothing on screen to say so. `pendingSyncChannels()` already falls back to a full sync when it cannot resolve a channel for an edited account, and that safety must survive. - An account with no `[account.]` section, or one whose section names no channel, has no channel to sync. The fallback covers it today. **Size: S** for the on-demand button, XS for the visibility half. Ask which. ## 113. No way to see a message's HTML source **Observed (user, 2026-08-17):** reviewing item 100's removals, "view source could be useful, we might have to implement it." **This item exists because item 100 removed something it was not asked to.** The user named Back, Forward, Reload and Save page. `ViewSource` was added to that list by the agent, on the reasoning that it was "the same kind of thing", and it is not: the other four have nothing to act on, while view-source has a real document and a real use, checking what a message actually contains. The removal is recorded here rather than quietly reverted, because the reasoning that produced it is the part worth not repeating. **Cause (verified in code):** restoring Chromium's entry would not work anyway, which is why this is an implementation item rather than a one-line revert. `QWebEnginePage::ViewSource` navigates to `view-source:`. The pane's document is a `data:` URL (`requestinterceptor.cpp:90-105` records that `setHtml()` navigates to data: and applies the base URL afterwards), and `MessagePage::acceptNavigationRequest` accepts only a typed main-frame navigation, so the attempt is refused before the interceptor even sees it. Restoring the entry would produce a live-looking menu item that does nothing, which is the same defect item 100 was reported for. **Approach.** Our own action, not Chromium's: a dialog showing the message's HTML as plain text. The source is already in hand, since `HtmlBuilder` produced it and `MimeParser` holds the original part; nothing needs fetching. **Constraints.** - **Plain text is a SECURITY property here, not a style.** `CLAUDE.md` states it for `MessageDetailsDialog`, and it applies with more force to this: the content is a stranger's markup, and the whole point of the dialog is to show it uninterpreted. Set `Qt::PlainText` explicitly on whatever displays it; a `QLabel` guesses under `Qt::AutoText`. A `QPlainTextEdit` cannot render markup at all and is the obvious choice. - Decide which source is shown: the message's ORIGINAL HTML part, or the document `HtmlBuilder` generated around it. They are different, and the useful one is almost certainly the original, since the wrapper is ours and known. Say which in the dialog rather than leaving the user to guess. - A message with no HTML part needs an answer that is not an empty window. `messageview.cpp:933` already has the string for this case. - Reachable from the body context menu, where the removed entry was, so the gesture the user reached for keeps working. **Size: S.** ## 114. Save image is offered on every image and does nothing **Observed (user, 2026-08-17):** right-clicking an image in the message pane offers "save image", among other entries item 100 never saw because the test built a menu by hand and no real image was ever clicked. **Cause (verified in code):** there is no download handling anywhere in the tree. `grep -rn "downloadRequested\|DownloadRequest" src/` returns nothing, so Chromium emits the request and no handler answers it. The entry is present, looks live, and silently does nothing, which is the same class of defect as item 100 itself. **Approach, and the security question it raised was resolved by the user.** The first proposal was to scope this to `cid:` parts and refuse remote images, on the grounds that saving a remote image means a fetch triggered from a message. **The user pointed out that this is wrong**, and it is: once remote content has been granted and loaded, the bytes are already fetched and cached. Saving them is a local copy, not a new request, and blocking it adds no security while making the entry useless. The reasoning applied to *fetching*, which has already happened by the time the entry is reachable. The real constraint is the neighbouring one: **the save must not itself cause a fetch.** An image that was never loaded, because it is remote and not granted, has no bytes to save, and the entry should be unavailable rather than reaching for the network to satisfy it. - Connect `QWebEngineProfile::downloadRequested` and let Chromium write the file, with the directory chosen through `QFileDialog` as the attachment save already does. - The rejected alternative: fetching the bytes ourselves to reuse `Attachment::saveTo()`. That is a second network request originating from a message, which is exactly what the interceptor exists to prevent, and it would bypass the per-render remote grant. `cid:` images would map onto that path cleanly and remote ones cannot, which is why the uniform route wins. **Constraints.** - **The path checks are not optional and are already written.** `CLAUDE.md`'s web-view notes: reduce to basename, strip separators, resolve against the chosen directory, refuse anything escaping it, and compare resolved paths as paths rather than with `startsWith`. A filename suggested by a download request is untrusted input in exactly the way an attachment filename is. - Do not let this become a second general download route. It saves an image the user right-clicked, nothing else; `SavePage` stays removed. - No overwriting. `saveWithoutOverwriting` exists because a silent overwrite lost six of sixteen files while reporting every one as saved. - Report the outcome through `statusMessage`, as every other save does. A save with no feedback is the failure item 13 was about. **Size: S.** **Re-confirmed by hand on 2026-08-20**, after items 126 and 127 shipped: the user right-clicked an image whose remote content had already been loaded and reported "Save Image but does nothing". Still this item, still unfixed, and the circumstances sharpen two things. `m_allowRemote` is a live flag on the shared interceptor (`src/requestinterceptor.h:56`), granted by `loadRemoteContent()` for the displayed message and cleared by the next `showThread()`. So a download handler would be subject to whatever the flag says AT THE MOMENT OF THE CLICK, not at render time. In the reported case the grant is still live, so a naive handler would appear to work perfectly, which is exactly the trap: the same code fails for a `cid:` image after the user moves on, and succeeds for a remote one only while the grant happens to stand. **Test both against a message whose grant has been cleared**, or the implementation is only tested in its easy state. The second is a corollary of item 127's decision below. The handler is per-PROFILE, so connecting `downloadRequested` lights up every download entry Chromium offers at once, including the Save link this pane deliberately removed. The entry being gone from the menu is not the same as the capability being absent: a page can still originate a download by other means. Whatever answers `downloadRequested` must decide per request, not merely exist. **Save LINK is no longer part of this item** (2026-08-20, item 127). It was deferred here on the grounds that both are inert for want of a `downloadRequested` handler, which is true and beside the point: they are not the same question. Save image is content the message already carries, and making it work is what this item is about. Save link fetches a REMOTE URL chosen by the sender, through the pane's profile, which is the one profile in the application that must never fetch remote content. It is removed from the menu rather than implemented, and `theLinkMenuDropsTheOpenInWindowActions` asserts its absence. **That assertion constrains this item.** A `downloadRequested` handler added to 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. ## 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 be clickable and show what 'N unsynced changes' are in a modal window". **Cause (verified in the code).** `m_pendingLabel` is a plain `QLabel` added to the status bar with `addPermanentWidget` (`src/mainwindow.cpp:502-505`). A `QLabel` has no clicked signal and none is installed, so there is nothing to click and no route to a list. It carries a tooltip and nothing else. **The count is a SUM OVER FOUR SOURCES, and that is what makes this bigger than it looks.** `pendingEditCount()` returns `m_pendingTagEdits.size() + m_unnettablePendingEdits + held + heldMoves`. Three of those can name what they hold: `m_pendingTagEdits` is a `QHash` keyed by message id, `m_heldEdits` and `m_heldMoves` are queues of edits waiting for a sync to end. **`m_unnettablePendingEdits` is a bare `int`** (`src/mainwindow.h:1248`), deliberately so: it counts confirmed changes that carry no message ids and therefore cannot be netted against anything. So a dialog built from what is currently kept would list three of the four groups and then have to account for a remainder it cannot describe. Showing "and 3 more" is worse than the tooltip, because the user opened the window specifically to find out what those were. **Approach.** Two halves, and the second is the real work. - The clickable half is small: a label that emits on click (an event filter, or a flat `QToolButton` styled as a label), plus a dialog listing what the three describable groups hold. The message pane already resolves an id to a subject. - The complete half needs `m_unnettablePendingEdits` to become something that can name its entries. Its comment says why it is an int: understating the indicator is the direction that costs the user work, so it counts what it cannot identify rather than dropping it. Making it describable means finding out what those changes actually are and whether they can carry an id. **Constraints.** - **The count is deliberately conservative and must stay so.** Item 28 and item 54 both landed on this indicator being wrong in the direction that made the user think their work was safe. A dialog that lists fewer changes than the count claims is the same failure in a new place: reconcile the two, or state the remainder honestly rather than hiding it. - **An external `notmuch` run can clear pending changes without this count noticing**, which the tooltip already admits. A dialog makes that staleness much more visible, since a listed change may no longer exist. Worth deciding whether the dialog re-verifies against the database before showing. - Read-only. This is an information window, not a place to retry or discard a change; either would be a new mutation path with its own undo question. **Size: S** for the clickable half over the three describable groups. **Unknown** for the fourth, and the item is not complete without it. ## 121. The thread list shows nothing while a query is running **Observed (user, from the notes):** "can we show a spinner in the left panel while 'Searching' is going? Especially at first run, the loading wait is several seconds, and the status bar starts updating 'Searching N threads' after the first have already appeared. Before that the program seems broken." **This is the half of item 74 that was never built**, and the note is precise about which half. Item 74 closed on 2026-08-15 having fixed the status bar, which used to set "Searching..." once and hold it for the whole walk. The count the note describes is that fix working as designed: it is written from `m_model->rowCount()` in `onThreadsReady`, so by construction it cannot report anything before the first batch has landed. **Cause (verified in the code).** `MainWindow::runQuery` clears the model and sets the status text (`src/mainwindow.cpp:2414`), and nothing else in the view changes. The thread list is then an empty `QTreeView` until `appendBatch` runs on the first batch, so **a query in progress and a query that matched nothing render identically**. There is no busy state on the view at all. **The gap is measured, and item 74's numbers understate it badly.** Re-measured on 2026-08-20 against the user's real inbox, seven minutes after boot, with the index verifiably unread (0.0% of 1037 MB resident). Item 74's figures came from `posix_fadvise(POSIX_FADV_DONTNEED)` eviction, which does not reproduce a real cold boot on this hardware: | phase | item 74, 2026-08-11 | measured cold, 2026-08-20 | warm | |---|---|---|---| | `search_threads` returns | 411 ms | **673 ms** | 2 ms | | first batch of 200 rows | 642 ms | **2008 ms** | 12 ms | | walk complete | 5714 ms | **38618 ms** | 154 ms | | threads | 4444 | 4628 | 4628 | So the list is blank for **two seconds**, and keeps growing for **thirty-eight**, on 4% more mail. The user's note said "several seconds" and the note was right. **The cause is the storage, not the code**, and that is the reason to BUILD this rather than to skip it. `/data` was `/dev/sda1`, a 7200rpm platter (`rotational: 1`); warm, the identical walk is 154 ms, a 250x difference. **The developer's own index moved to NVMe on 2026-08-20** (item 124 was its prerequisite), which took the cold figures to 12 ms / 50 ms / 668 ms and makes this invisible *on that machine*. That is precisely why the item stays open. A mechanical disk is not an exotic configuration, it is the cheap one, and a user who keeps a large Maildir on spinning rust has nowhere to migrate to. The measurements above are now the best evidence this project has for what such a user sees on every cold start, and they were taken on real mail rather than simulated: | storage | first rows | complete walk | |---|---|---| | 7200rpm platter, cold | 2008 ms | 38618 ms | | NVMe, cold | 50 ms | 668 ms | Fixing one developer's hardware is not fixing the application. The indicator is what makes a slow query legible on any disk, and the slower the disk the more it matters. **Approach.** A busy state on the left pane between `runQuery` and the first `onThreadsReady`, cleared by whichever of the first batch or `queryFinished` arrives first. The empty-result case must be distinguishable from it: when `queryFinished` reports zero, the pane should say so rather than returning to a blank list, which is the same ambiguity one step later. The likely shape is an overlay or a placeholder row rather than a literal spinner widget, but that is a design question for the user, not a decision to take here. A spinner also has to be animated by the UI thread, which is free here since the work is on the worker, but that is worth stating because it is the usual reason a spinner does not spin. **Constraints.** - **A background refresh must stay silent.** `onThreadsReady` returns early on the refresh branch and `onQueryFinished` does the same, deliberately, so a sync-driven refresh does not flicker the status bar. A busy indicator that ignored that guard would make every cron sync flash the list. That silence is already a test, and it should cover this too. - **Item 74's decision not to address the cold cost was taken on wrong numbers**, and its conclusion still holds for a different reason. It judged a 5.7 s wait not worth prefaulting 1.1 GB; the real figure was 38.6 s. Do not reopen prefaulting: it trades a large fixed cost at every startup against a wait that only some users pay, and it is worse on exactly the low-memory machines most likely to have a slow disk. - **Do not treat "move the index to an SSD" as this item's fix.** It is the right advice for a user who has an SSD, and it is documented, but it is hardware guidance rather than a change to the application. This item must stand on its own for a user with one mechanical disk and no migration available. - Nothing about the query timing may change. **Size: S.** ## 122. The README documents a version of the app that no longer exists **Observed (user, from the notes):** "documentation needs updating, EG the README.md reports various things not up-to-date anymore." **Cause (verified).** `README.md` was last touched on 2026-08-15 by b405e32, which moved the SlackBuild out to the `my-slackbuilds` repo. Everything released since then is absent from it. Releases 0.19.0 through 0.26.1 all landed after that commit. **Measured, by grepping both documents for the same terms:** | term | README | CHANGELOG | |---|---|---| | `trash` | 0 | 14 | | `restore` | 0 | 7 | | `Select all` | 0 | 3 | | `deleted-from` | 0 | 0 | **One of these is worse than stale documentation.** Item 103 made a per-account `trash` key MANDATORY: an account without one produces a config warning, and Delete cannot work. The README is the only place a user reads about configuring an account, and it does not mention the key at all. So the documented config produces a warning against the current binary, and the feature that needs it is undocumented. The `deleted-from:` tag is likewise invisible, and a user who sees it on a message has nowhere to look it up. **Approach.** An audit against the changelog rather than a rewrite: walk the sections from 0.19.0 forward and check each user-visible change for a README home. The config section and the keyboard-shortcut table are the two most likely to have drifted, since both enumerate things that have been added to. **Constraints.** - **The changelog is the evidence, not memory.** Every entry since b405e32 is written down; work from it. - **`### Upgrading` sections are the priority.** They exist precisely because a user's config or habits had to change, and those are the paragraphs whose absence from the README costs the user a broken setup rather than a moment of confusion. - The "Development Approach" section at the bottom is required by the user's global preference and must survive any edit. - No personal details, per the same preference: account names in examples stay generic. **Size: M.** The audit is most of it; the writing is small once the list exists. ## 123. Sending mail is not designed **Specified 2026-08-20.** Read `docs/superpowers/specs/2026-08-20-compose-and-send-design.md` instead of this section. Brainstormed with the user on branch `compose-and-send`; no code written, which is what the note's `#plan-only` asked for. **The three constraints a reader needs before opening the spec.** - **There is no MTA on this machine**, measured 2026-08-20. `msmtp` and `sendmail` are both absent, and neomutt sends over its own built-in SMTP. So "an external script on the same model as `mailsync.sh`" had no model to copy. The design keeps the no-network-protocol rule by making send a **per-account `send_command`** taking the message on stdin, exactly as `[sync] command` works. What the user installs behind it is their choice. - **An account with no `send_command` is receive-only by construction**, which is how one of the five accounts is meant to work. Reply, reply-all and forward are disabled on its mail, with a ribbon in the message pane saying why. - **The body is markdown**, parsed by cmark-gfm (autolink, strikethrough, tasklist; tables off), sent as `multipart/alternative` or plain text per a per-message toggle. A hand-written parser for a limited set was rejected because it would be deleted wholesale the moment the set widened. **What it blocks and what it opened.** Item 72 (khard/khal) is placed after send by the user's own note. The brainstorm opened items 128 to 132: an outbox, inline images, attaching a message to a message, a configurable markdown dialect, and a review of the every-action-has-a-shortcut rule. **Size: L.** Four new units, six new actions, a formatting toolbar over the markdown source (whose shortcuts live in the composer's own scope and do not touch `KeyMap`), and one new build dependency, `cmark-gfm`. That dependency is cheap: it ships in stock Slackware (`cmark-gfm-0.29.0.gfm.13-x86_64-3`, verified 2026-08-20), so it needs a `pkg_check_modules` line here and **no** `REQUIRES` entry in the SlackBuild, which lists only non-stock dependencies. ## 125. A skipped sync leaves the spinner running for ever **Observed (user, 2026-08-20):** during the item 124 index migration, a Delete appeared to do nothing: the view did not refresh, the message did not move, and restarting the application showed it exactly where it had been. The user then reported "I see a spinner in the bottom right, is going indefinitely. maybe that's what stopped the move?" That observation is what identified the cause. **Cause (verified from `/proc` and the sync log).** The migration procedure held `/tmp/mbsync.lock` to keep cron out of the way. qtmaildir auto-syncs a couple of seconds after a tag action (item 71), so Delete queued its edit and started a sync; `mailsync.sh` found the lock held and exited **75**, by design: ``` 2026-08-20T10:00:01+02:00 === SKIPPED: previous run still in progress === 2026-08-20T10:10:01+02:00 === SKIPPED: previous run still in progress === ``` 75 is `EX_TEMPFAIL`, chosen deliberately so a click landing during a cron run is not reported as a failure (`assets/mailsync.sh`, the comment at the `flock -n` guard). The application appears to treat it as neither success nor failure: the sync indicator never cleared, and because an edit made during a sync is HELD until the sync ends, the delete sat in that queue waiting for a completion that could never arrive. `notmuch count tag:deleted` on the account confirmed the write had not reached the database. **Nothing was lost**, and that is worth recording separately: held edits are written to disk (item 106), so the delete survived and applied as soon as a real sync ran. The defect is that the user cannot tell. **Approach.** Handle exit 75 explicitly wherever `MailSync` reports a finish. It is a third outcome, not a variant of the other two: the work did not happen, nothing is wrong, and it should be retried rather than reported. Clearing the indicator is the minimum; re-arming the auto-sync timer is probably right too, and item 89 already made a skipped auto-sync re-arm rather than give up, so there is a precedent to follow rather than a policy to invent. **Constraints.** - **Do not turn 75 into an error.** The exit code exists precisely so an overlapping click is not reported as a failed sync, and item 89 settled that a skip is routine. Showing the log pane here would be a regression. - **The held-edit queue must still flush.** Whatever clears the indicator has to leave the queue in a state where the next successful sync sends it, which is what happened by luck here rather than by design. - **A stuck indicator is worse than a wrong one**, because it also blocks the quit prompt's "unsynced changes" story (items 18, 19, 28, 54). This is the same class of indicator dishonesty those four items each fixed once. **Reproducing it.** Hold the lock in one terminal and act in the application: ```bash flock /tmp/mbsync.lock -c 'sleep 300' ``` Then Delete a message. Verified by hand on 2026-08-20; this is how it was found. **Size: S.** ## 135. The formatting toolbar's buttons stack rather than toggle **Observed (user, 2026-08-21):** pressing Bold a second time on already-bold text adds another pair of asterisks rather than removing the first, so `**this**` becomes `****this****`. Quote nests the same way: a second press on `> one` gives `> > one`. The user asked for both to toggle. **A toggle was built and reverted the same session**, and the reason matters more than the code: it was not unwanted, it **conflicts with the spec**, which was not checked before the work started. - `2026-08-20-compose-and-send-design.md:236` states there is "deliberately no live toggle that inserts and removes the quote while editing". - `:187-190` is the complete statement of the wrap behaviour and describes only wrapping, with no toggle anywhere. **Cause.** This is a **spec change, not a defect**, and both sites need amending before any code is written again. Underneath sits a real design question the spec answers one way and a toggle answers the other, which is why the two cannot simply coexist. `:187` preserves the selection after a wrap **so that a second press applies a SECOND token** to the same words: bold, then italic, without touching the mouse. A toggle makes that gesture unreachable, because the second press now removes the first token instead. **What replaces bold-then-italic is unanswered**, and answering it is the substance of this item, not the state machine below. Possible directions, none chosen: a modifier on the second press, a separate un-format action, or accepting that the chord is lost and reaching nested emphasis by typing. **Approach.** When it is picked up, the transformation half is already understood, so the notes below exist to stop it being rediscovered. A toggling `wrap()` must distinguish three states, and a single "it unwraps" test passes against most of them being broken: - **INSIDE** the tokens: `**this**` with `this` selected (2..6). The tokens sit just outside the selection; the same characters stay selected afterwards. - **AROUND** them: `**this**` selected whole (0..8). The selection shrinks to the text that was between them. - **PARTIALLY overlapping** one: `*this**` (6..13). Neither of the above. It does not describe a wrapped span, and stripping would have to guess which half of a token to keep, so wrapping is the predictable answer. **INSIDE must be checked before AROUND.** On `***this***` both tests match, and only INSIDE removes the level the user actually asked for. **A naive adjacency test is wrong, and looks right.** Checking only whether the characters either side of the selection equal the token means pressing *Italic* on `**this**` finds a `*` on each side, strips one asterisk per side, and **un-bolds text the user asked to italicise**. A strip must require the adjacent RUN of token characters to be the token exactly, or the token plus one other complete emphasis token: `***` is bold+italic and divisible either way, while a run of two is one indivisible token whose half is not a token at all. This was found by writing the italic-on-bold test, not by reading the code. The quote side is simpler but has one trap: a bare `>` is what the quote path writes for a blank line, so an unquote that only recognises `"> "` leaves a stray marker on every blank line in a round trip. Whether a mixed block (some lines quoted, some not) quotes or unquotes is a decision; quoting it, so one press makes the block uniform and the next unquotes it, avoids the button doing two opposite things to two halves of one selection. **Constraints.** The spec amendment comes first and must resolve the bold-then-italic question, or the same conflict recurs. `MarkdownFormat` is painter-free and widget-free, so the whole state machine is unit-testable without the composer; keep it that way. The toolbar shortcuts belong to the composer window and do not touch `KeyMap`, so nothing here interacts with item 132. Note that toggling changes what the preserved selection is FOR, so `wrappingTwiceNestsTheTokensAroundTheSameWords` and `quotingAnAlreadyQuotedLineNestsIt` in `tests/test_formattoolbar.cpp` both assert the current spec behaviour and would be replaced rather than extended. ## Deferred, unsized, or split out Items noted while triaging but not part of the original list. Same numbering sequence, appended as they arise. | # | Item | Why here | |---|------|----------| | 12 | `HtmlBuilder` CSS is light-theme only | **Done 2026-08-07**, and moved to the main status table. Kept listed here so the split from item 5 stays traceable. | | 120 | No way to tell a tag applied by a rule from one applied by hand | **Postponed by the user**, and recorded here on 2026-08-19 from their notes ("should the UI allow to discriminate when a message has been tagged by a rule?"). Not plannable as it stands: nothing records the provenance. A rule has an `id` in `~/.config/mailrules/rules.json`, but `mailrules.py` writes only the tags the rule names and keeps no note of which rule wrote them, so the information does not exist to display. Answering it means the HOOK storing something per message, which is a shared-format change across both repos and needs the procedure in `CLAUDE.md`. Ask the user what they would do with the answer before designing that. | ## Adding to this document Append a row to the status table with the next free number, then a section using the same shape: **Observed** (what the user saw), **Cause** (the code, with file and line, verified not assumed), **Approach**, **Constraints**, and **Verification** where it is not obvious. Do not renumber. Do not delete: mark `dropped` with a reason. **When an item closes, move its section to `2026-08-03-post-0.1.0-usability-closed.md`** and leave the status table row here with its date and outcome. This is what keeps the file readable, and it is the step that was missing for seventy items: doing it only once, as item 73 did, buys a few months and then the problem returns. Move the section on the commit that closes the item, not in a later cleanup pass. Where the closed section records a trap that is still true of the code, that trap belongs in `CLAUDE.md`, which is where it will actually be read. **A fully specified item goes in its own file under `docs/superpowers/specs/`, not inline here.** This document is a backlog: its job is to say what is open, how big it is, and what decides whether it can be picked up. A design that runs to a hundred lines buries that under itself. The split is by depth, not by size on the day. An entry stays here while it records an observation, a cause and an approach. It moves out once it carries decisions the user made, measured evidence, and constraints that have to be read before writing code. Items 53 and 63 are the pattern: the entry keeps the finding and the size, and points at the spec with one line saying to read that instead. Carry the two or three constraints a reader needs in order to decide whether to open the spec at all, and leave the rest there. Name the spec `--design.md`, and state in its header which backlog items it resolves, so the numbering stays traceable in both directions. ## 137. A reply to a message that arrived at two accounts can come from the wrong one **Observed.** A message that exists in more than one maildir, because it was sent to two of the user's addresses or duplicated across accounts by mbsync, can open its reply from either account. Which one is picked is arbitrary. The consequence is visible in the composer's From field, so it is not silent, but it is only visible to somebody who thinks to look: the reply is otherwise correct and sendable, and the recipient sees a From the user did not intend. **Cause, verified in the code.** The disambiguation exists and is unreachable. `ComposeContextBuilder::accountForReply()` (`src/composecontext.cpp:405`) takes `messagePaths` as a `QStringList` precisely so it can resolve this case: with more than one candidate account it prefers the one whose own address appears among the recipients, which is the reason the copy landed there. Nothing upstream ever gives it more than one path. `NotmuchWorker::loadMessage()` (`src/notmuchworker.cpp:573`) builds its `MessageRef` from `notmuch_message_get_filename()`, the SINGULAR accessor, so `MessageRef` holds one `filePath` and `MainWindow::openComposerFor()` can only pass a one-element list. The plural parameter is therefore inert, and the branch that consumes it is dead code today. `notmuch_message_get_filenames()`, the plural accessor that would supply the rest, exists in libnotmuch and is used nowhere in this repository. **Approach.** Add `QStringList filePaths` to `MessageRef` (`src/types.h:123`) ALONGSIDE the existing `filePath` rather than replacing it, and populate it in `loadMessage()` from `notmuch_message_get_filenames()`. `filePath` stays as the render path, so `MainWindow::renderMessages()` and everything else that opens one file are untouched; only `openComposerFor()` reads the new field. That keeps the change to two files plus the one call site. **Constraints.** The test has to put the same message id in two accounts' maildirs, which `NotmuchFixture` can do by writing the same `Message-ID` into two folders before indexing. Assert on the account CHOSEN rather than on a count of paths: a test that only checks `filePaths.size() == 2` passes against `accountForReply()` still ignoring them. The recipient-preference branch is 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 task 10 was in the working tree. The failing function is `TestMainWindow::undoMovesTheMessageBack`. The run that failed took 70 seconds against a normal 25, so whatever goes wrong also blocks for a while before giving up. **Not caused by item 123.** This was checked rather than assumed, because a failure appearing during unrelated work is exactly the kind of thing that gets blamed on the change in front of it. With the branch's work `git stash`ed out, on a clean tree, it still failed **1 run in 6**. Nothing in `SendDialog` touches the model, the Maildir, or the undo stack. **Cause, unverified.** A race around the Maildir file move that Delete performs and Undo reverses. Whether the race is in the test's wait or in the production move is exactly what the item has to establish, and that is why the size is `?` rather than a guess. The two have very different consequences: a test that waits wrongly is noise, while a move that races is mail landing in the wrong folder, and CLAUDE.md already records that a wrong folder name from this code path reaches the mail server. **Approach.** Reproduce in isolation first, with the suite's own `QT_QPA_PLATFORM=offscreen` and a loop over `ctest -R mainwindow`, and capture a failing run's output before theorising. The 70-second duration is the useful clue: something is waiting on a condition that never arrives rather than asserting immediately, so find which `QTRY_*` or `qWait` is timing out. **Constraints.** A fix must not restore the real `/proc/locks` (item 61), and a flaky test must not be "fixed" by widening its timeout until it passes, which converts a real race into a slower green. If the race turns out to be in the production move rather than the test, this stops being a test-hygiene item and becomes a mail-safety one. **Measured again 2026-08-24, and the failure is now DETERMINISTIC.** Found incidentally while building item 152, by an agent that checked rather than assumed: it built a throwaway worktree at the commit before its own work and ran `test_mainwindow` there, failing identically. So the failure predates the signatures work, and the "1 run in 6" framing in this entry's own title is stale twice over. The assertion that fails, quoted exactly: ``` 'folderHasMessageFile(root + "/acct/inbox/cur", stem) || folderHasMessageFile(root + "/acct/inbox/new", stem)' returned FALSE ``` That is worth more than the flakiness history, because it says WHAT is wrong rather than how often: after the undo, the message file is in neither `cur` nor `new` of the account's inbox. The file is not where the restore was supposed to put it, so the question this item has to answer narrows to where it went instead. Check the trash folder and the account root before theorising about a race: a move landing in the wrong folder is the mail-safety half of the fork above, and it would look exactly like this. 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. ## 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 "() and id:"` 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 `/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.` 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.** - **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 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. **Constraints.** - 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.** - The guard's existing reasoning is what makes this safe and must be kept: only a plain `tag:` 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.