aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans
AgeCommit message (Collapse)AuthorFilesLines
6 daysdocs: record the composer headings control and abuse reportingDanilo M.1-0/+127
Two items from the 2026-09-08 reconcile of the user's notes, both with their causes verified in the code rather than copied from the note. 193, a headings dropdown for the composer. The note is right that the renderer already handles it: ATX headings are CommonMark core, so `## x` parses today and only the composer-side control is missing. The work is that a heading is a line prefix rather than a wrap, so it cannot go through applyFormat(), and unlike quote() it must REPLACE its prefix instead of stacking one. That makes it the first formatting control that reads the line's existing state, which is item 135's question arriving early on a control where it is much smaller. 194, abuse reporting from a flagged message. Split as one gesture here and the engine in a sidecar: RDAP, three vendor APIs and abuse-desk mail are four outbound protocols, and this binary does no network protocol work by design. The split is architectural and not a judgement on the feature, which the user wants and will build the sidecar for. The qtmaildir half is S and follows 187 and 190, since those settle what Mark spam does and put it on the bar. The report action takes a confirmation, which is consistent with the no-confirmation rule rather than an exception to it: that rule offers undo in place of a dialog, and a submission to a third party has no inverse to push, exactly as with empty_trash. Marking spam stays undoable and silent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KphFXTc2QajxXsHWyvGJ4R
6 daysfeat: make Sync follow the account being looked atDanilo M.2-44/+47
Item 101. The sync run was already account-aware for pending edits and consulted the account dropdown for nothing, so looking at one account and pressing Sync collected every one of them. pendingSyncChannels() now reads the dropdown as well as m_editedAccounts. The two are a UNION rather than one replacing the other, which is the whole safety property: looking at one account while having edited another is ordinary, and a run that dropped the edited account's channel would strand that write with nothing on screen to say so. All accounts is the empty key and narrows nothing, so a full fetch stays what an unselected window asks for. The existing fallback is untouched: an account whose section names no channel still widens the run rather than being silently skipped. syncStartedText() is the visibility half. A run opened with "Syncing..." whether it covered one account or all of them; it now names what it covers. The exit paths overwrite the label with their own wording immediately, so the parameter is defaulted rather than threaded through them. Four tests. The narrowing one asserts the channel list is EMPTY before the gesture, since empty is what a full fetch looks like and a test starting from a narrowed state could not tell the fix from a window that had never widened. Mutation-checked twice: ignoring the selection fails three of them, and replacing the pending set instead of unioning with it fails the union test alone, which is the version that looks correct and loses a write. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KphFXTc2QajxXsHWyvGJ4R
7 daysfix: refresh the current query when the index changesDanilo M.2-8/+45
Item 192's second half, answered by the user: indexing is not repainting. The sent copy became findable the moment it was sent and a Sent view already on screen still did not show it, because nothing re-ran the query. The model cannot insert the row optimistically either, since item 170's constraint applies: the query never returned that thread. NotmuchWorker::indexChanged() is emitted at the end of both indexDraftFile() and removeIndexedFile(), the only two entry points that change what a path query would return without any query having run. MainWindow connects it to refreshCurrentQuery(), which covers all three gestures a path view can miss: a sent copy indexed, a draft saved, a draft's entry dropped on send. Wiring only the indexing half would have left a ghost draft row visible in a Drafts view after a send. The signal carries nothing, so it cannot invite an optimistic insert. It is emitted after the database closes, so a refresh reaching notmuch on the next turn of the event loop cannot race the write handle. refreshCurrentQuery() rather than runCurrentQuery(): a send must not clear the selection, the expansions or the undo stack of the window behind the composer. aSentMessageAppearsInASentViewAlreadyOnScreen drives a real send through a worker-backed window, asserts the Sent view is empty first, and asserts the row arrives with no second returnPressed() and no sync. Mutation-checked by disabling the connection. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FWeBw3UqxpBktSc1AkZ6ir
8 daysfix: index the sent copy so it appears in the Sent view at onceDanilo M.2-0/+99
ComposeWindow filed the sent copy into the account's sent folder and discarded the path DraftStore::write() returned, using the result only to test for failure. Nothing announced the file, so notmuch never learned it existed, and the Sent view is a path query over the index rather than a listing of the folder: the message was on disk and invisible until the next notmuch new, which here is a cron tick up to ten minutes away. Measured immediately after a send: 65 files in the account's Sent folder, 64 messages indexed for the same path. This is item 158's defect one path over. That item established the rule for drafts, and MainWindow wires both halves of it, so the send path was already telling the worker to DROP the draft's entry while never telling it to add the sent copy's. The missing half is the one the user sees. A sentCopyFiled signal, emitted only where the write succeeded, connected to the worker's existing indexDraftFile. That slot is generic despite its name: it calls notmuch_database_index_file, applies nothing draft-specific, and its previousPath already defaults to empty, which is right for a copy that replaces nothing. The connection is a lambda whose context object is m_worker, and that is load-bearing. indexDraftFile takes two arguments where the signal carries one, so a direct slot connection does not compile; the context object is what queues the call onto the worker's thread and keeps notmuch off the GUI thread. Simplifying it to a plain call reads as tidier and would cross that boundary, so the comment says so. The test asserts on the signal, on the file existing, and on it being inside the Sent folder. Indexing itself is already covered against a real database in test_notmuchworker; what was unproven was that anything ever called it for a sent copy. Announcing a path that was never written is the ghost entry removeIndexedFile exists to undo, which is why the existence check is there. Indexing is not repainting: a Sent view already on screen does not gain the row from this, and whether it should refresh after a send is left as a separate question. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jq9gXquUo9W4KXDagJXMmn
8 daysfix: keep one Message-ID across a draft's revisionsDanilo M.2-104/+160
Every autosave called MessageBuilder::build(), which generated a fresh Message-ID unconditionally, so each revision of a draft was a different message rather than a new version of one. The entry called this invisible while the file is replaced correctly, and that turned out to be wrong: mbsync uploads each revision to the drafts folder before the next save removes the local file, so the server keeps one message per revision and syncs them all back down. Measured on real mail as four independent messages for a single reply, all four carrying a ,U= infix, threading into the conversation and putting a draft tag on a Sent row. Deleting a local file does not retract an uploaded one, which is why the local cleanup, which is correct, could never fix it. The user chose a stable id while drafting, discarded at send: the sent copy is a different item from the draft, and the draft is deleted once the message goes out, which the code already did. Three links, none of which existed. OutgoingMessage::messageId is the field, where empty means generate, so the send path is unchanged by construction rather than by remembering to clear it. MessageBuilder::build() uses a supplied id when there is one. ComposeWindow::m_draftMessageId holds the identity between revisions, assigned from built.messageId so the first save adopts the id GMime just generated, and ComposeContext::draftMessageId carries it across a reopen, read in forDraft() from ParsedMessage::messageId, which the parser already provided and nothing had ever used. Five tests, because the property spans three objects and a test at any one of them passes while another link is broken. Two are the safety constraint rather than the feature: a field defaulting to a fixed value would satisfy the reuse test and make two sent messages share an id, which is far worse than the defect this fixes. One comment is corrected rather than left: the autosave's dirty check justified comparing the message rather than the built bytes with "GMime is given a fresh Date and Message-ID on every build". Half of that is no longer true. The Date still is, so the conclusion stands. Revisions already on the server are not touched by this; the four found on real mail were deleted by hand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jq9gXquUo9W4KXDagJXMmn
8 daysdocs: settle item 165 on a stable draft id, discarded at sendDanilo M.1-6/+42
The item recorded three positions on what a draft's identity is and needed a decision before it could be built. The user chose the second: a stable id across a draft's revisions, minted fresh for the sent copy, so the sent message is a different item from the draft. The draft is deleted on a successful send, which is what the code already does, and the sent message carries no draft tag. Three things the decision describes were measured rather than assumed, and all three are already built: DraftStore::write() removes the previous revision, ComposeWindow removes the draft on a successful send, and the sent copy is built fresh so it never carries the tag. The draft tag the user saw on a Sent row came from orphan revisions threading into the conversation, not from the sent message. So the id is the whole remaining defect, and the mechanism is the server rather than the local files. Measured on real mail: four revisions of one reply, four distinct Message-IDs, every file carrying a ,U= infix. mbsync uploads each revision before the next save removes it locally, so the server holds four independent messages and syncs them all back. The local cleanup is correct and cannot help, because deleting a local file does not retract an uploaded one. Reclassified from enhancement to defect and sized S. The entry said this was invisible while the file is replaced correctly; the file is replaced correctly and four messages exist anyway. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jq9gXquUo9W4KXDagJXMmn
8 daysfix: give the Sent and Drafts views one row per message, in date orderDanilo M.2-0/+135
Both views are lists of the user's own messages and are flat, but walkThreads() emitted one ThreadSummary per THREAD and then picked a single matched message to stand for it, breaking at the first one the oldest-first walk reached. A conversation replied to twice therefore produced one row: dated by the thread, opening the OLDER of the two messages, with the newer one reachable nowhere in the view. Reported against real mail, where a message sent at 12:42 was missing while the row above it, dated 12:42, opened a message from three weeks earlier. The same wrongly chosen message supplied firstMessagePath, so Delete or Archive on such a row would have moved a file the user was not looking at, silently, and mbsync would have carried it to the server. That half was never visible. The Sent branch now emits one summary per matched message, each carrying its own id, tags, sender, path, date and subject. withRecipients still selects the branch, so Sent and Drafts both get this and no second flag can disagree with the flat-mode flag. Ordering was a second defect under the same item, found by hand once the rows appeared: notmuch_query_set_sort is a THREAD sort, so every row of a thread inherits that thread's single position and an older reply drew above a newer one. Sorting each thread's rows in place is not enough either, since a message from another thread dated between them still cannot land between them. Flat rows are collected and sorted as one list before emitting. ThreadListModel::rowKeyFor() is the second consequence and would have broken quietly: two rows now share a threadId, and reconcile() keyed its QHash on exactly that, so a sync would have dropped one of them by a different route. It answers what makes a row unique, the message id in flat mode and the thread id otherwise. Tests cover both halves over new fixture threads F and G. The cross-thread ordering assertion passed for the wrong reason at first, because the existing fixture threads happen not to interleave; thread G exists to break that and failed the moment it was added. oldestFirstReversesTheOrder is corrected rather than satisfied: OLDEST_FIRST orders threads by their oldest message while NEWEST_FIRST orders by their newest, so the two lists mirror each other only while no thread's date span contains another's, which this fixture is the first to violate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jq9gXquUo9W4KXDagJXMmn
8 daysdocs: record Mark spam's absence from the message bar as item 190Danilo M.1-0/+64
The session's backlog reconcile against the user's notes found one entry with no item. Their spam note asks for two things and only the view half was recorded, as item 187: the other half asks for Mark spam on the message pane's bar, with an icon that signifies bad or evil. Verified before filing. The action exists and has since the first toolbar, reachable from the Message menu, the thread context menu and Ctrl+Shift+S, but refreshBarActions() fills the ordinary branch with reply, forward, flag, archive and delete, and item 189 added Star and Archive there without raising spam. It meets the bar's rule as it stands, being selection-scoped with an undo behind it. The icon is the same latent wrong choice item 189 found in flag: mail-mark-junk was picked for a menu, where the label carries the meaning and the icon only decorates it. On an icon-only bar the icon is the control. Paired with item 187, which changes what the action does, so the entry records why the two want thinking about together and leaves the ordering to the user. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jq9gXquUo9W4KXDagJXMmn
2026-08-29docs: settle item 187 on Trash's shapeDanilo M.1-57/+67
Three decisions from the user. Spam is path-based like Trash, not a tag generator: a tag query finds only what this application marked and misses everything the server filed, which is most of what those folders hold. Mark spam moves the file, as Delete does, which is what makes a path-based view honest and is a change to an existing action rather than only a new view. The Junk folder one account carries is out of scope. Every account can reach a spam folder now. The three channels that listed their folders explicitly gained the provider's spam folder this session, verified against mbsync --list rather than guessed, since Create Both turns a wrong name into a folder created on the server. Records the cleanup pass as a copy of showStrandedDeletedMail() from item 103, which solves the same problem one version earlier: it reports mail whose file never moved and leaves the user to act, and its empty-list and AlreadyScoped details are load-bearing. Notes that a mandatory spam key breaks existing configs on upgrade exactly as trash did, and that the trash predicate behind the message bar must not treat a spam folder as a trash one. Resized S to M: the move path, the origin tag and the cleanup are three parts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NY6poqw199LfFaXe5BHKNe
2026-08-29docs: correct item 187 against the real folder layoutDanilo M.1-16/+60
The entry was written from the assumption that no account names a spam folder, and concluded a tag generator was therefore the answer. The user corrected it: some accounts do have one, and the split is not the provider but the mbsync configuration. A channel with Patterns * receives everything the server has; the channels that list their folders one by one never name the spam folder, which the provider does expose. That rules out both existing shapes. A tag generator finds only what this application's own Mark spam tagged, missing everything the server filed, which is most of what those folders hold. A mandatory folder key like trash cannot be filled for the accounts that have none, and item 103's hazard is live: a configured folder that does not exist is created and propagates to the server. Records the layout generically, proposes an optional per-account key composing a union like sent does, and names the two questions only the user can answer: whether the view means the folder, the tag or either, and which folder to use for the account that has two. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NY6poqw199LfFaXe5BHKNe
2026-08-29feat: put Star and Archive on the message bar tooDanilo M.2-66/+66
Follows items 185 and 186, which established the pane's bar as where actions on the displayed message live. Star and Archive are both selection-scoped and fit that rule with nothing to decide; Archive leaves the main toolbar the way Delete did, since the same icon in two places reads as two controls when the toolbar is icon-only. Ordered by what they do rather than by where they came from: answering the message, then filing it, then destroying it, so the destructive button is not between two that are not. Mark all read deliberately stays on the main toolbar, at the user's decision. It is the one action in this window that ignores the selection and acts on every row in the view, so a bar whose every other entry acts on the one displayed message is exactly where it must not be. Item 140's toolbar test named archive as an example of a list-wide action. That was never true of it, only untested, and this item reclassifies it: the test now asserts archive LEFT the toolbar and keeps its guard on mark_all_read, which is the action that genuinely is list-wide. Closes item 189. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NY6poqw199LfFaXe5BHKNe
2026-08-29docs: record the message bar's next actions as item 189Danilo M.1-0/+66
From the user's notes: Star and Archive on the pane's own bar, and Mark all read raised as a question. Two of the three fit the rule items 185 and 186 settled, that the bar carries what acts on the displayed message; mark_all_read does not, since it deliberately ignores the selection and acts on every row in the view. Also records why Archive stays off the trash branch, which is narrower than it looks: Delete already strips inbox, so Archive is a genuine no-op on mail this application trashed. It is a real but pointless write on mail trashed by another client, which never had its tags rewritten and can still carry inbox. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NY6poqw199LfFaXe5BHKNe
2026-08-29feat: give the trash its own actions on the message barDanilo M.2-0/+120
The pane's bar offered Reply and Forward on a message the user had thrown away, which are the two things a trashed message is least likely to want, while Restore and the purges lived only in menus. The bar now has a third branch, asked before the draft one: a deleted draft must come out of the trash before it can be edited. It is keyed on the SELECTION being in a trash folder, the same predicate the menu entries use, rather than on the trash VIEW, which disagree on mail reached from a search. It carries Restore, Delete permanently and Empty trash, and only Restore is tinted: the two purges are one act at two scopes and need no colour to tell them from each other, only from the one action that gives mail back. Delete moves here from the main toolbar in the same change (item 186). It acts on the displayed message, like Reply and Forward, so it belongs on the pane's bar by the rule items 139 to 141 settled for those two. It stays in the Message menu and the context menu. Delete permanently is new. It is Empty trash scoped to the selection, the same purgeMessages() call with the ids resolved from the selection rather than from a query, so it inherits both of that action's safeguards: it confirms, naming the count, and it carries no default shortcut. One combined thread:/id: query resolves a mixed selection, so a conversation and a reply selected together still ask once. The bar is refilled when the conversation digest arrives as well as on selection, since a conversation's trash-ness is not known until every path has been reported. Closes items 185 and 186. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NY6poqw199LfFaXe5BHKNe
2026-08-29fix: retry a sync that was skipped because another held the lockDanilo M.2-60/+101
Item 125, the half that was genuinely missing. Most of this item was already built and the row was stale. The exit-75 branch in onSyncFinished() predates this session and does what the entry asks: the spinner clears, the skip is reported as neither success nor failure, m_lastSyncFailed stays put, the log pane is not raised, the lock latch is handed back to the external monitor, and the sync-on-exit case has its own dialog. Item 174 then added the external half, a `skipped` state a run the application did not start can be seen to have produced. What nothing covered was the RE-ARM, and it is the symptom the item was filed for. runAutoSync() re-arms when it declines to START, which is item 89 and covers a sync skipped before launching. A run that LAUNCHES, finds the lock held and exits 75 reaches onSyncFinished() instead, and that branch armed nothing: the edit stayed pending with nothing scheduled to carry it, waiting for a manual sync or the next cron tick. That is "a held edit waits for a completion that never comes". scheduleAutoSync() in the skip branch. It re-checks the delay, the sync command and the pending count on the way in, so it cannot arm a sync for nothing, and against a long external run it re-arms once per debounce interval until the lock clears. This is the half the status file could not reach, and the distinction is worth keeping: that file says what a run DID, this is what the application does NEXT. The test records a real pending edit first, since runAutoSync() correctly declines when there is nothing to carry and a fixture without one would arm nothing for a legitimate reason. It failed before the fix and is mutation-checked. Suite: 43 of 44, with undoMovesTheMessageBack failing as it does on master (item 136).
2026-08-29feat: have the sync script report what it didDanilo M.2-44/+180
Item 174, and half of item 125. The premise was corrected before any code. The note asks for an external `notmuch new` to clear the pending count; it must not. That count means tag mutations not yet known to have reached the MAIL STORE, which is the server: an edit is in notmuch the moment it is made, and what is outstanding is mbsync pushing the renamed Maildir files. `notmuch new` re-indexes local files and pushes nothing, so clearing on it would tell the user their work was safe to quit on while it was still local. The entry's own proposal to watch notmuch_database_get_revision() was rejected for the same reason: a revision moves when mail ARRIVES too, and in neither case does it say anything about the server. What was actually wrong was the reporting channel. The application inferred a finished run from an inode in /proc/locks and from grepping the log for its RUN END banner, which made a human-readable line into wire format and could not say WHICH channels a run carried. The local sync path has always narrowed its clear to the accounts it carried; the external path could not, and cleared everything, so an edit to an account a run never touched was reported as delivered. So the script reports instead of leaving evidence to be inferred. It writes ~/.local/state/qtmaildir/syncstatus.json atomically at the end of every run, including a skip, naming the channels, both exit statuses and a state of ok, failed or skipped. MailSync::readStatus() reads it, MainWindow prefers it over the log banner and narrows the clear through Account::syncChannel(). A skipped run clears nothing, which is item 125's first half: the application can now see that a run happened and carried nothing. The log banner and lastRunOutcome() stay as the fallback for a missing file, which is what a first run after upgrading looks like. This is the user's own framing of the scope: the script was written for another system and adapted, and is now qtmaildir's only consumer, so it serves the application rather than the reverse. Two facts made it safe to act on: their crontab runs mailsync.sh and nothing else touches mail, and ~/bin/mailsync.sh is a symlink into this repo, so an edit is live on the next tick. Two bugs found while wiring it in, both recorded in the closed item. A test read the developer's real sync state, twice: a [sync] section naming only `log` leaves syncStatus() defaulting to the real file, so two tests asserting that a FAILED run leaves the count alone read the last real cron run, found ok, and cleared. Pinning only `status` has the mirror problem. noSyncTestReadsTheRealSyncState() is the guard, modelled on noTestCanSeeTheRealLockTable(). And Qt::ISODate carries no milliseconds. The status file is preferred only when it describes THIS run, compared against when the lock appeared, so a stale success cannot outrank a fresh failure; but the script writes date -Iseconds, and a round trip of "now" comes back 329 ms behind, measured. A fast sync's own file therefore parsed as stale and fell back to the log, with nothing failing to say so. One second of slack matches the precision the format carries. Design: docs/superpowers/specs/2026-08-29-sync-status-file-design.md Suite: 43 of 44, with undoMovesTheMessageBack failing as it does on master (item 136).
2026-08-29docs: record the item 176 regression test flake as item 183Danilo M.1-0/+64
2026-08-29fix: say an edit is waiting for the sync instead of claiming it landedDanilo M.2-0/+86
Item 182, found by hand: a thread of 9 messages with 5 unread, marked read while a sync was running, reported "<subject>: mark as read" and then reported the same work again when the sync finished. The user read it as double reporting. Not a double write, and the mail was correct. It is one action reported twice because the FIRST report was the wrong one. A sync holds notmuch's exclusive write lock and the worker's read-write open blocks on it rather than failing, so an edit made during a sync is held and sent when the lock frees. All three hold branches say exactly that, in a label chosen deliberately: NOT transient, because it describes state lasting until the sync ends, and a message that expired would leave rows showing a tag the database has not got and no explanation of why. That label never survived. Every caller announced the action itself a line later through showTransientStatus(), which overwrote it, so the user was told the write had happened and the hold was never mentioned. The flush at the end of the sync then reported the same work again and read as a duplicate rather than as its completion. announceAction() asks whether a sync holds the lock and, when one does, sets a non-transient label naming the action AND the wait. The action is still named because that announcement is what stands in for the confirmation dialog this project rules out: it is how a user tells that something larger than they meant has just happened, so the hold is added to it rather than replacing it. The flush message is untouched and is the only signal that held work actually landed, whose absence was item 106. The test drives toggle_unread, the route the user took, and asserts both halves: the text mentions the sync, and it still says what is waiting. Asserting only the first would pass against an announcement that dropped the action entirely. Mutation-checked by forcing the non-held branch, which fails with the exact text the user reported. The new string is translated, since one that misses the Italian ships as English inside an otherwise Italian UI: lupdate found it with no context warnings, lrelease reports 552 finished and 0 unfinished. Suite: 42 of 43, with undoMovesTheMessageBack failing as it does on master (item 136).
2026-08-29fix: refresh the conversation dashboard after a writeDanilo M.2-0/+60
Item 181, from the user's notes: "the thread dashboard doesn't update live with the modifications applied to the list pane. If I mark the thread as read, the dash still reports N unread". ThreadDashboard draws a ThreadDigest, which the worker builds from the index and which reached the pane only when a conversation was selected. A tag write updated the model optimistically and repainted the card beside it, and nothing touched the digest, so the pane went on reporting the unread count, the progress bar and the Waiting-for-you list the conversation had when it was opened. Reachable from the dashboard's own Mark all read button, which is the worst version of it: the number sits directly above the button that fails to move it. refreshDashboardDigest() re-asks the worker for the digest of the conversation on display, and returns at once when the pane is showing anything else. It bumps m_digestGeneration like any other request, so the guards in onThreadDigestLoaded() discard a reply that arrives after the user has moved on. No placeholder digest, unlike the selection path: the pane already holds this conversation, and blanking it to re-fill it would flicker the whole dashboard for a change to one number. Called from onTagsApplied(), where a write is CONFIRMED, and not from the two write funnels. The first attempt put it beside the optimistic model update by analogy with every other optimistic repaint, and that analogy does not hold here: the digest is rebuilt from the index, so a refresh queued beside the write reaches the worker before the write does and answers from the state before it. The test failed identically to no fix at all. Every write rather than a chosen subset, at the user's decision: narrowing it to the writes that change what the dashboard happens to draw today is a list the dashboard can outgrow silently, and this costs a round trip only while a conversation is on screen. Re-requested rather than edited in place, because the digest is a derived summary and recomputing it here would be a second place that has to agree with the worker about what a write did. The test is worker-backed over a real two-message conversation and is driven through the mark_all_read action rather than the private funnel, which is the path the dashboard's own button takes. It asserts the pane carries the unread state before the write, so the assertion after it means something. Suite: 42 of 43, with undoMovesTheMessageBack failing as it does on master (item 136).
2026-08-29fix: judge a conversation's trash state on all of its messagesDanilo M.2-1/+67
Item 178. everySelectedRowIsInATrashFolder() read ThreadSummary::firstMessagePath for any row that was not a message row. That was correct while a thread row MEANT that message (item 108) and stopped being correct when item 177 made it mean the conversation. A conversation is in the trash only when ALL of its messages are, so a partly trashed thread answered on whichever message the query returned first: Delete could be hidden on a conversation that still had mail outside the trash, and Restore offered on one that mostly did not. Not data-affecting. Both actions are no-ops in the wrong direction: Delete on already-trashed mail takes moveMessages()' already-there branch, and Restore on mail that was never trashed finds nothing to move. qtmaildir cannot produce such a thread itself, since Delete is absent on a reply row and Restore is thread-scoped. Two things outside it can: another client trashing a single message, and a reply arriving after the conversation was trashed. ThreadDigest already walks every message of the selected conversation for its sender counts, and a filename is served from the index like everything else in it, so the paths ride along on a request the selection already makes rather than costing a walk on every query. ThreadDigest::messagePaths is relative to the mail root, for the reason firstMessagePath records: an absolute path matches no account and silently resolves every row to none. MainWindow keeps them beside the dashboard's thread id and clears them when the dashboard is left, so a late digest cannot answer about another row. One limit, stated in the code rather than hidden. The digest is requested only for a single selected conversation row, so that is the only case with a real answer; any other selection falls back to the summary's one path. That fallback IS the pre-177 answer and is wrong in exactly the same partial case, which is the point: a multi-row selection is left no worse than it was, rather than given a second, differently wrong rule of its own. Making it exhaustive costs a per-query walk over every message, which is what this avoids. Two tests, both mutation-checked. The worker test puts its two messages in different folders, since two in one folder answer identically whichever way the code resolves them. The window test asserts both directions, so a fix that simply hid Delete everywhere would fail it, and sets totalCount explicitly: a summary left at the default is a message row, and the test would otherwise exercise the other branch and pass for the wrong reason. Suite: 42 of 43, with undoMovesTheMessageBack failing as it does on master (item 136).
2026-08-29docs: move CLAUDE.md to AGENTS.md, and reconcile the backlogDanilo M.1-0/+101
AGENTS.md is the single source of truth for how to work on this repository, shared across every agent tool; CLAUDE.md is now a thin pointer to it, mirroring the arrangement in the user's own config. Prose throughout the specs, plans, source comments and tests refers to this document as CLAUDE.md, which is what it was called until now. Those references are historical records and are correct about the content, so they are left alone and AGENTS.md says how to read them. Two rules added while reconciling: - The reconcile writes its open-items table to the user's vault as well, rewritten whole each time so a closed item cannot linger there. - Build a test only when it asserts something measurable, and hand a visual change to the user instead. This is the conclusion the "Rendering probes lie" section was already building toward, and it sits at the end of it. Guarded against the opposite reading: it does not license dropping a test that can fail, and items 87, 105 and 176 are each a case where the missing test was the whole problem. The 2026-08-29 pass over the user's notes found two entries with no item here, both causes verified in the code rather than copied from the note: - 179, undo is one level deep in practice and there is no Redo. The QUndoStack is real and multi-level; what is missing is a redo action and an answer to the stack being cleared on every new query. - 180, the repaint rules are discovered one hole at a time. A question rather than a defect, and the user's own framing: items 105, 107, 109, 110 and 170 are each one hole in the same surface, all found by hand.
2026-08-28docs: close items 170, 176 and 177Danilo M.2-91/+163
2026-08-28docs: plan the thread row identity changeDanilo M.1-0/+1390
Eleven tasks over the five stages the spec sets out, TDD throughout. Tasks 1 to 6 are one coherent change and the tree behaves oddly between 2 and 6, so the first useful hand test is at the end of 6.
2026-08-28docs: specify what a thread row stands forDanilo M.1-1/+106
Four separate questions in one session turned out to be one question: a row means a message for display and action, and a thread for existence and membership. The spec settles it as the conversation, and records item 177 plus item 176, the thread-scoped undo defect found while hand-testing.
2026-08-28docs: record the item 171 hand test and its final shapeDanilo M.2-3/+5
The row still described the multipart/alternative build that was reversed; the shipped forward sends one part chosen by the Send-as-HTML toggle, with the original in a read-only pane beside the editor.
2026-08-27feat: forward an HTML message with its formattingDanilo M.2-60/+207
Item 171. A forward carried only the plain-text version of the original, so formatting was lost; and an original with no plain-text part at all (30 of 342 sampled inbox messages, ~9%) forwarded as an empty quote with its content silently gone. A forward now sends ONE part chosen by the Send-as-HTML toggle: the original's markup when on, the text quote when off. Not a multipart/alternative, at the user's decision: a forward's shape is already decided by that toggle, and sending both hands the choice to the recipient's client. The toggle is honoured even for an HTML-only original, which then forwards as a text fallback. HtmlSanitiser strips remote content from the forwarded markup, checked by default with a per-forward opt-out. This is the security-critical part: the markup leaves this process and is rendered by the recipient's client, where none of MessageView's protections apply, so forwarding a tracking pixel forwards the tracking. It is an ALLOW-LIST, unlike HtmlBuilder::namespaceCids(), because a missed rewrite is a broken image while a missed strip is a beacon reaching the recipient. An HTML forward does not seed a text quote into the editor. The first build did, then subtracted it when building the HTML part, so the user could edit a quote whose edits were discarded; what the composer shows must be what gets sent. The forwarded message appears in a read-only pane beside the editor instead, a QSplitter at 60/40 with a toggle in the Format menu. A plain forward is unchanged. ComposeContextBuilder::quoteBody() renders htmlBody down to text when there is no plain part, so the plain path never emits an empty quote. Design in docs/superpowers/specs/2026-08-27-forward-html-design.md. Two tests repaired for the splitter: the 60/40 assertion reads stretch factors rather than pixels, since the offscreen platform gives the splitter no width and reports 49/49 whatever the code asks; and theComposerSplitsItsToolbarByScope looked for the body directly in the composer's column. Not yet hand-tested in this arrangement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AtUzfNjMD8fiYfamDd3ywW
2026-08-27docs: drop item 164, the inbox tag on a draft was never thereDanilo M.2-104/+145
Re-measured at message level: 0 of 12 drafts carry `inbox`, including nine written on or before 2026-08-25, when the item was filed. The thread that produced the original report splits into an arrived message tagged `inbox` and a draft reply tagged `draft unread`; neither carries both. The premise came from `notmuch search --output=tags`, which displays the union over a thread. The trap has a second half: a thread-level `notmuch count 'tag:draft and tag:inbox'` also returns 0, because search terms match per message even in a thread query, so the count and the displayed tag list disagree and the displayed list is the one that looks like evidence. The investigation is kept above the correction rather than deleted: it cost a week open, two wrong causes and a seven-variant reproducer built to explain an end state a union produces for free, and it caught a fresh reader again on 2026-08-27. The `unread` half of the original observation was real and is item 172, fixed in edbf393. The reported `draft inbox unread` is fully explained: `unread` from the missing S flag, `inbox` from the arrived message sharing the thread. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AtUzfNjMD8fiYfamDd3ywW
2026-08-27fix: flag a saved draft seen so it is not tagged unreadDanilo M.2-44/+145
DraftStore::write() was called with "D", and it uses the flag string verbatim, so every draft this application wrote landed as :2,D. With maildir.synchronize_flags on, notmuch tags any message lacking the S flag `unread`, and a draft the user authored is seen by definition. The symptom heals itself: the next sync of that folder round-trips the file, adds S, and the tag goes away. Only the newest draft in a folder that has not synced since shows it, which is why it read as intermittent and why measuring an older draft finds nothing wrong. TestComposeWindow::aSavedDraftIsFlaggedSeen() asserts both flags on the written filename, verified failing first against "D". TestMainWindow::anAutosaveWritesADraftAndClearsTheDirtyFlag() asserted endsWith(":2,D"), pinning the whole flag set where its own comment said the point was the draft flag "not left bare", so it failed against the corrected behaviour. It checks for D within the flag set now. Also reconciles the backlog with the user's notes: records the forwarded-HTML defect as item 171, closes item 169 (shipped last session, its row still read open and its section was still in the open file), and records this fix as item 172. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AtUzfNjMD8fiYfamDd3ywW
2026-08-26docs: record item 119 and close it in the backlogDanilo M.2-56/+103
The status rows for 119 and its duplicate 146, 119's section moved to the closed file on this commit rather than left for a later cleanup, and the README and changelog entries for the feature. CLAUDE.md gains three findings, all of which cost time to learn here: A defensive counter for an unreachable case is worse than nothing, because it blocks the feature that needs the data. Reading the code said that branch was reachable and the reading was wrong; instrumenting it and running the suite is what settled it, and the tests that appeared to exercise it were driving it from outside the production path. PendingChangesDialog groups by a run rather than a map, which is why the snapshot is stable-sorted, and startsMessage is carried rather than inferred so a stale row still opens its own run. A queued call carrying a container deserves the same suspicion as a Q_ENUM, with the measurement: both containers cross intact on Qt 6.11, but a standalone probe found QMetaType::fromName("QList<int>") invalid while QList<bool> resolved, so the property does not follow from the type. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P88Q3MCSCSQxKDy7pmXh9F
2026-08-26docs: scan every message on the first candidate runDanilo M.1-3/+77
A week-long scope is right once the list is in use and wrong on the first run, when it proposes almost nothing and leaves the file taking months to become useful. BusinessSenders::scanQuery() returns "*" while the file holds no active entry and date:1week.. afterwards. A file holding only rejected candidates counts as unused, which costs one more full scan and re-proposes nothing, since appendCandidates already skips every address the file mentions in any form. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LXCZFLXbAii5n5wtovpdhh
2026-08-26docs: plan the card avatar and account fade workDanilo M.1-0/+1907
Fourteen tasks against the item 169 spec, TDD throughout. Two new namespaces of free functions over values, Avatar and BusinessSenders, so the initials, the fill choice and the list parsing are all assertable without a painter or a widget. The plan records where the existing traps apply rather than leaving them to be rediscovered: the two separate switches in data(), the inclusive QRect::right(), the queued-connection metatype registration, and the rule that a count request must not bump the query generation. Task 14 is the hand-off: this item is judged by looking, so the plan names the five things to look at and the constants most likely to move. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LXCZFLXbAii5n5wtovpdhh
2026-08-26docs: reconcile the backlog, and specify card avatarsDanilo M.1-8/+124
Reconciliation against the user's notes found two unrecorded lines, and corrected the cause of one entry that was recorded wrongly. - 169, new: a card shows the account only as a bar, with no fade and no avatar. Half of it shipped as the accent bar. - 170, new: a row that stops matching the view only leaves it on the Delete path. Filed from a note that reads as a stale request for optimistic updates; it is not. removeThreadsWithoutTag() has exactly one caller, so marking a message read in the Unread view repaints the row and leaves it in a list it no longer belongs to. - 65, narrowed: the notes now name it as a dead-code and duplication sweep rather than a performance or security pass, so it produces a list to decide on rather than a diff. The spec for 169 covers the avatar geometry, the fade, the two hash-generated fills and the sender list that chooses between them. It also records the one structural change the feature needs and the measurement that forced it: ThreadSummary::authors carries display names only, with no address anywhere, so the identicon has nothing stable to hash and the sender list has nothing to match. ThreadSummary gains firstMessageSender, filled by the walk that already fills firstMessageId. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LXCZFLXbAii5n5wtovpdhh
2026-08-26feat: flag what you answered, mark what was forwarded to youDanilo M.2-57/+117
Item 68, which turned out to be three things once its premise was measured. The note asked to extend a "passed" 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. Reply and forward now flag their source. The Maildir R and P flags, which every other client sets and notmuch reads back as "replied" and "passed", had never been written here: measured on the developer's index, all 317 "replied" and all 6 "passed" came from other clients. ComposeWindow emits sourceMessageAnswered after a successful send and MainWindow routes it through sendMessageTagChange, message-scoped and off the undo stack, for the reason auto mark-read is: the flag records that the mail went, and the send cannot be undone. ComposeContext carries sourceMessageId rather than reusing inReplyTo, which is deliberately empty on a forward so the recipient's client does not file it under the thread it left. Keying on it made the "passed" half dead code that compiled and never fired. A resumed draft is excluded: its kind records how the file was opened, not what the user is doing, so flagging on it would set R from a guess. A received forward gets its own mark. Derived from the subject at paint time, storing nothing and reaching no server, because "passed" means "I forwarded this" and setting it from a guess would assert something false on 222 existing messages. subjectIsForwarded() shares forwardSubject()'s prefix table so the two cannot disagree, strips a Re: chain first, and takes extra locale spellings from [general] forward_prefixes, which extends the built-in table rather than replacing it. A mutation survived the first round and corrected a claim in the code: QRegularExpression::escape already makes a punctuation prefix inert, so the word guard is not about pattern validity. It stops a configured "-" matching "-: x". The comment and test say that now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LXCZFLXbAii5n5wtovpdhh
2026-08-25fix: offer Delete and Restore only where they mean somethingDanilo M.2-73/+97
Item 168, found by the user while hand-testing 118: Delete could be triggered on a message already in the trash. Not dangerous, which is how it survived. moveMessages() finds the file already in the destination and takes its early-return branch, so the message is reported as moved, an unsynced change is counted, and nothing happened. Restore had the mirror of the same problem, added unconditionally to both menus and so offered on mail that was never deleted. Each is now hidden where it has no meaning, which is the rule item 112 established for the unread entry. The question is about the PATH, never the deleted tag: a message trashed by another client carries no such tag, which is why the trash view is path-based, and asking the tag would hide Delete on exactly the mail a trash view is full of. Delete also removes unread now, at the user's request on the same tangent. It travels inside the same sendMove() call rather than as a second write, so one undo returns the folder and the tag together. This rewrites the Maildir filename, because maildir.synchronize_flags is true, and so reaches the server: the same mechanism the post-new hook refuses to touch, and the difference is that the hook acts unattended on arriving mail while this is an explicit gesture on a message in front of the user. A mutation survived the first round and found a real hole: comparing the prefix without its trailing separator passed every test, because no fixture had a folder whose name starts with the trash folder's. Under it Delete silently vanished from mail in acct/trash-old, which is not the trash. The fixture carries that row now and all three properties are mutation-checked. The suite is 37 of 38, the failure being item 136 on an unrelated path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HFuRPtzFrSxCQjFk6tq7gD
2026-08-25feat: empty the trash, the one action that asks firstDanilo M.2-35/+156
Item 118, unblocked by 103. Message > Empty trash..., scoped to the account selector, with no default shortcut. purgeMessages() is a separate worker entry point from moveMessages() rather than a flag on it, because the two look alike and only one can be undone. It takes named ids, never a folder sweep, so the blast radius is what the dialog enumerated and the user confirmed, and it deletes every file of a message: notmuch deduplicates by Message-ID, so leaving one behind leaves the message alive in the folder the user emptied. It confirms, naming the count and the account, defaulting to Cancel. That breaks CLAUDE.md's no-confirmation rule deliberately and the rule now records it as its single exception, in the same paragraph: a purge has no inverse to push onto the undo stack, so the protection the rule provides has to come from somewhere, and the dialog is where. Two defects found rather than reasoned. The count claimed messages whose files were already gone, overstating an irreversible action; an absent file is correctly not an error, but that is not the same as destroyed. And the user's hand test found the list still showing mail that no longer existed: a purge removes rows rather than changing them, so there is no optimistic update to apply and nothing was connected to messagesPurged at all. It re-runs the current query now. Verified against the live index after the user emptied one real account's trash: zero files on disk, zero in the index. The suite is 37 of 38, the failure being item 136 on an unrelated path. Ten new strings translated, lrelease reports 0 unfinished. Item 168 is filed from the same hand test, on Delete being offered on mail already in the trash. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HFuRPtzFrSxCQjFk6tq7gD
2026-08-25feat: say which way the unread action will go, and hide it when it cannotDanilo M.2-77/+114
Item 112, and 99 and 147 with it: the user's note is one design across all three. A union is not a state. ThreadSummary::tags is notmuch's union over the conversation, so a thread holding even one unread message answered "unread" and the thread toggle always chose "mark read". There was no input that reached "mark thread unread" on a mixed thread, which is the thread a user wants it for. The thread toggle becomes two absolute actions, mark_thread_read and mark_thread_unread. Neither takes a default chord, at the user's choice: Ctrl+Alt+U meant whichever direction the union picked, and since item 132 a shortcut is a chosen subset rather than a requirement. It is now unbound. The message-scoped toggle stays a toggle, because one message has a real two-valued state, and its label now names the direction it will go. On a selection with no single state the entry is hidden rather than labelled wrongly, chosen over disabling it; the thread submenu is the route then, and its entries are absolute. selectionTagPresence() is the three-valued predicate that needed to exist. everySelectedRowHasTag() delegates to it and keeps its two-valued answer, which is all a direction needs; a label needs the third value. The refresh is keyed on the model's dataChanged as well as on the selection, so a write moves the label without reselecting and none of the six optimistic-update call sites has to remember. Three mutations fail: restoring the union predicate reports the user's original symptom, showing the action on a mixed selection, and dropping the dataChanged refresh. The suite is 37 of 38, the failure being item 136 on an unrelated path. Four new strings translated, lrelease reports 0 unfinished. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HFuRPtzFrSxCQjFk6tq7gD
2026-08-25docs(backlog): close item 166Danilo M.2-66/+91
The fix landed in 6ea6980 and the row was left open. Its section moves to the closed file with what the fix turned out to need: the loop, the two query forms that were measured and rejected, the split-index fixture, and the live read-only verification. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HFuRPtzFrSxCQjFk6tq7gD
2026-08-25feat: number each build of a dev treeDanilo M.2-46/+79
The version alone cannot tell one build of an unreleased X.Y.Z from another, and the user rebuilds and hand-tests unreleased builds daily. They chose a counter over a git description: what they want to know is that the binary is newer than the one they were running, not which commit it came from. QTMAILDIR_BUILD_NUMBER is a cmake option, ON by default, that runs cmake/BuildNumber.cmake as a build step to increment a counter and write buildnumber.h. It had to be a build step: configure_file runs once per cmake run, so a counter interpolated into version.h.in would sit still across exactly the rebuilds this exists to distinguish, which is why version.h.in includes a second generated header rather than carrying the number itself. Two macros, and the split is load-bearing. QTMAILDIR_VERSION stays a clean X.Y.Z and keeps the window title, applicationVersion and anything that might ever compare versions; QTMAILDIR_VERSION_DISPLAY carries the number and goes to the three surfaces the user picked, --version and --help, the About dialog, and the placeholder pane. The window title was offered and declined, since the number would then be in every screenshot. The counter lives in the build directory and is not tracked, so it cannot conflict on a pull or leave the tree dirty; a fresh build directory restarts at 1, which is honest, because it is a different build tree. A release passes -DQTMAILDIR_BUILD_NUMBER=OFF and the header is written empty. The SlackBuild in the my-slackbuilds repo needs that flag and is a separate commit there. Verified by running it, since none of this is reachable from a C++ test: three consecutive builds reported build 2, 3 and 4, and a separate Release configure with the option OFF reported a clean 0.27.0. Passing the flag to a tree that does not have the option yet is an unused-cli warning and exit 0, so the SlackBuild change is safe before 0.28.0 ships. The suite is 37 of 38, the one failure being item 136 on an unrelated path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HFuRPtzFrSxCQjFk6tq7gD
2026-08-25docs: mailctl is retired, the hooks are oursDanilo M.1-4/+4
The user has not run mailctl in months and the coupling this document described is gone: the live database.hook_dir symlinks post-new, mailrules.py and qtmaildirconf.py into assets/hooks/ here, where their three suites also live. All three pass. The format discipline survives the move and is kept, because what makes it necessary is two independent readers of one file, not two repositories: src/tagrules.cpp and assets/hooks/mailrules.py still share no code and still agree by test. What changes is the procedure around it, which no longer sends anyone to a sibling checkout, and the round trip, which is now verified by running the hook rather than by a CLI that is retired. Item 166 said the same thing and was filed a day before this was noticed; its row and its two-repo constraint are corrected with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HFuRPtzFrSxCQjFk6tq7gD
2026-08-25docs(backlog): file item 167, no build identity between releasesDanilo M.1-0/+46
The reconciliation against the user's own notes found one entry with no item here: a dev build number, so a rebuilt binary can be told from the one it replaced. Verified in the code rather than copied from the note. `src/version.h.in` interpolates PROJECT_VERSION alone, which moves only when the release procedure bumps it, and the user hand-tests unreleased builds daily. It needs a decision before any code. A git description is accurate and costs a configure-time dependency that is easy to ship wrong; a counter always moves and identifies nothing. Either way a release build must keep printing a clean X.Y.Z, since the SlackBuild builds from a tarball with no git checkout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HFuRPtzFrSxCQjFk6tq7gD
2026-08-25docs(backlog): close item 104 after its hand testDanilo M.2-95/+93
The fix landed in b1db3e8 and the row said "awaiting hand test". A sync run from the application added 20 messages and they appeared without a restart, which is the property the reopened read-only handle exists to give, so the row is now done and the section moves to the closed file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HFuRPtzFrSxCQjFk6tq7gD
2026-08-25docs(backlog): close item 104, file item 166Danilo M.1-50/+150
Item 104 is fixed and its entry was wrong. It named mbsync's folder patterns as the leading theory and concluded the cause was most likely outside this repository; the reproduction put it at layer 3, in the worker's own handle. The superseded theory is kept, since it would produce a similar symptom and remains worth checking first in any future report of this shape. Two measurement errors from the diagnosis are recorded with it. A bare `inbox` in a notmuch query is a free-text term rather than a tag term, and the application generates `tag:inbox`; reading a message's tags across every file matching a subject mixes several accounts' copies into one answer. Each produced a confident wrong answer before it was caught. Item 166 is new, found while setting up msmtp. The `post-new` hook's sent-folder carve-out judges provenance by a file's path, but notmuch deduplicates by Message-ID, so mail sent between two of the user's own accounts is one message with a file in each. The carve-out matches the sent copy and strips `inbox` from the message the recipient's inbox copy also belongs to. Three options are laid out; the fix is a two-repo change and the hook runs unattended on live mail, so it needs a decision rather than a patch.
2026-08-25docs(backlog): record item 165, a draft's Message-ID changes on every saveDanilo M.1-0/+69
Found while hand-testing items 163 and 164: four saves produced four distinct ids, and one reopen-and-edit turned one into another. Cause verified in the code rather than inferred. MessageBuilder::build() calls g_mime_utils_generate_message_id() unconditionally and every autosave calls build(); OutgoingMessage has no field to carry an id in, and ComposeContext has none for the draft's own id either, since inReplyTo and references are the ORIGINAL's when replying. So a stable id needs a field threaded from forDraft() through both structs, not a changed call site. Filed as needing a DECISION rather than an implementation, because what a draft's identity is is not obvious: a stable id reused at send makes the draft and the sent message one message but means the server saw that id before anything was sent; a stable id discarded at send keeps revisions collapsed while drafting and threads under a fresh one; the status quo never reuses an id for two different things, which is its one real virtue. Not urgent and explicitly not blocking item 163, whose fix restores correct file replacement. This is the property that turned that fork into two MESSAGES rather than one duplicated file, and the remaining route to it is an interrupted save, since DraftStore::write() unlinks the previous revision only after the new one is safely on disk. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UUQS6n3cmsFrsjCNmwNtf8
2026-08-25docs(backlog): close item 163Danilo M.2-76/+113
Its section moves to the closed file on this commit, with the outcome recorded: MaildirName::resolveRenamed() wired into all three read sites, the two deliberate refusals (ambiguous match, genuinely missing file) and why each has a test, and the note that forDraft() must seed draftPath from the resolved path or the fork simply arrives one step later. The stable-Message-ID question is recorded as left undecided rather than quietly dropped: it is what turns a stale path into two server-side messages rather than one replaced file, and a draft's id is not yet the sent message's id, so it wants its own item. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UUQS6n3cmsFrsjCNmwNtf8
2026-08-25docs(backlog): close item 162, record a second site for 163Danilo M.2-64/+174
Item 162 is done and its section moves to the closed file on this commit, with the outcome recorded on it: moveMessages() re-resolves by message id when the recorded path is gone, and the hold candidate was investigated and rejected because aSyncHoldsTheWriteLock() guards notmuch's lock while the window sits between mbsync's rename and that sync's notmuch new. Item 163 gains a second site, found while hand-testing 164 and worse than the one it was filed for. openComposerFor() passes ref.filePath to forDraft(); after mbsync renames the file the parse fails and the reopen is refused BEFORE any composer exists, so composing again starts fresh with no previous path to unlink. The old revision survives, each save mints a new Message-ID, and both revisions reach the server. The unlink machinery is entirely correct and never runs. Its heading and row now name both sites, and the note that 162's fix would cover it is removed: that fix re-resolves inside the worker, while these hold a stale path in the UI. Item 164 gains the reproducer's findings. Seven variants in throwaway databases establish that index_file applies no tags, that the carve-out strips inbox correctly in every filename shape and ordering tried, and that the only reproduction is a pass applying inbox while tag:new is already spent. The trigger is still not established, and the entry says so: the live log shows the hook ran on the affected pass and logged success, which the new match-count instrumentation will disambiguate on the next occurrence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UUQS6n3cmsFrsjCNmwNtf8
2026-08-25docs(backlog): correct item 164, the first cause was wrongDanilo M.1-32/+43
The entry blamed a missing drafts helper. Neither half of that was true. NOT_ARRIVALS in qtmaildirconf.py is ("sent", "drafts"), so sent_folders() already returns both; the function name says sent and its contents do not, which is what made the wrong reading plausible. Run against the real config it returns every account's drafts folder, and notmuch count over the carve-out query and the affected message id returns 1: the query the hook builds MATCHES the draft. The folder list and the query are correct and the fix is not there. A second theory is also recorded as dead. 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. What is established: the carve-out is scoped to tag:new, the draft carries inbox, and tag:new is 0, so it was never in scope when the hook ran. The installed hooks are symlinks into this repository, verified rather than assumed, so the code read is the code that runs. What is not established is which pass tagged the file. The likely shape is an ordering one, since item 158 indexes a draft from the application itself and a file already known to the database is not new on the next pass, but that is a third hypothesis and the first two were both wrong. The entry now calls for a reproducer driving the real sequence before any code is written, since the hook tags real mail unattended every ten minutes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UUQS6n3cmsFrsjCNmwNtf8
2026-08-25docs(backlog): record items 163 and 164, both found by handDanilo M.1-0/+82
163: the message pane names a path that no longer exists and reports the message unreadable. Same mechanism as 162, different site and different fix. mbsync renames the file to add ,U=<uid>; by the time the pane fails, notmuch is already CORRECT and the stale path is the MODEL's, cached when the row was loaded. Measured: the index named the ,U=4 file while the pane named the pre-U= one. 162's likely fix, refusing to write while a sync runs, does not touch the read path. Points at recovering by re-resolving the id, the way recoverStaleThread() already does, with a bounded retry so a genuinely unparseable message still reports. 164: every newly synced draft carries inbox. Measured "draft inbox unread" on a draft this application wrote. strip_inbox_from_sent() reads qtmaildirconf.sent_folders() only, and qtmaildirconf.py has no drafts equivalent, so the carve-out never covers a drafts folder. Item 158's measurement was right and did not reach this: index_file assigns no tags, but mbsync's upload and the next notmuch new re-tag the file. 164 also contradicts the shipped 0.27.0 changelog, which claims sent mail and drafts both stay out of the inbox. The drafts half has never been true, so correcting the entry is part of that item. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UUQS6n3cmsFrsjCNmwNtf8
2026-08-25docs(backlog): record item 162, Delete races a sync's renameDanilo M.1-38/+55
Found by hand while deleting a draft: "Cannot move <file> to <folder>". Neither Delete nor item 158 is at fault. mbsync uploads a saved draft and RENAMES it to add its ,U=<uid> infix, and notmuch keeps the pre-U= name until that sync's notmuch new runs, so moveMessages() calls QFile::rename on a path that no longer exists. Verified against the live Maildir rather than read: notmuch named a file that was not on disk while a sync was running, and the same query was clean afterwards with the file present under its new name. That is why it reads as intermittent and why it heals itself. Truthful and lossless, but the action silently does nothing and the message blames a folder for a timing problem, which sent the user looking at a configuration that was correct. Records both candidate approaches and notes the likelier one: refuse the move while a sync holds the lock, joining the held-edit machinery items 97 and 106 already built for exactly this shape, rather than re-resolving the filename and racing the same window. Also notes that this is the ,U= trap CLAUDE.md records for MaildirName::fresh(), seen from the other side. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UUQS6n3cmsFrsjCNmwNtf8
2026-08-25feat(compose): a menu bar on the composerDanilo M.1-0/+42
File, Edit and Format, to the scope the user chose. Save draft (Ctrl+S) is the only new action: saveDraftNow() was reachable from the autosave timer, the send path and closeEvent, so there was no way for the user to ask for a save. It routes through that same function, which is what emits draftSaved for item 158's indexing, reports through item 160's status bar and raises the failure banner; a second write path would have to repeat all three. The menus show the toolbar's own QAction objects rather than copies, as item 140 required for the message pane's bar. Two needed hand-building. The HTML toggle is a QToolButton and cannot go in a menu, so a checkable twin mirrors it in both directions, since a menu entry that only follows the button is half a control. The signature entry takes the switch's own QMenu pointer, because that menu is rebuilt whenever the signatures change and copied entries would go stale. Edit's entries drive QPlainTextEdit and follow its own undoAvailable and copyAvailable, so a greyed entry tells the truth about what pressing it would do. theMenuBarReachesEveryComposerAction() is item 132's reachability rule applied to the composer: it walks the real menu bar and collects the composer's actions with findChildren, so an action added to the toolbar and forgotten in the menus fails without the test being touched. It skips actions owning a submenu, since Qt emits no triggered for those. The composer's actions stay out of KeyMap, per item 148: they are parented to this window, so they are WindowShortcuts dispatched to the active composer and the main window's namespace is untouched. lrelease reports 496 finished, 0 unfinished. Closes item 161. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UUQS6n3cmsFrsjCNmwNtf8
2026-08-25feat(compose): report autosave state in a status barDanilo M.2-48/+101
Autosave worked and said nothing on success. The only feedback was m_banner, which is the failure channel and whose persistence is load-bearing for the quit path, so success got its own channel rather than sharing one. The fix is a funnel, not a label. m_dirty had seven writers, four of which clear it and only two of those are a save: the constructor clears it because seeding is not an edit, and the send handler clears it because the message is gone. A cue hung off saveDraftNow() would have been silently wrong in both. setDirty() is the only writer now, and it refreshes the status cue and setWindowModified() together so neither display can drift from the flag. The age line needs a tick of its own, since it moves with no edit to drive it. Five seconds against a label that reads in tens of them. Two defects found by probing rather than by reading. The %n plural rendered as "2 minute(s) ago" for every English user, because Qt picks a plural form only when a translation supplies the forms and there is no English .ts; it uses %1 and "min" now, which Italian substitutes identically. And the status mark was inside the translatable string, where a translator could drop it; it is concatenated outside tr(). Presentation reworked after the user looked at it. The first version reused item 151's yellow ribbon treatment, which reads as a misplaced widget on a bare status label rather than as a warning, and put both labels in the permanent widget area, which is the right-hand tray. They are ordinary status text on the left now. onlyTheSetterWritesTheDirtyFlag() asserts the funnel structurally, by reading composewindow.cpp: the first test for the send path called markClean() directly and a mutation restoring a direct assignment left the whole suite green. Four mutations now fail. The suite still cannot see the presentation, which is why that half needed a hand test. lrelease reports 487 finished, 0 unfinished. Closes item 160, and unblocks 161. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UUQS6n3cmsFrsjCNmwNtf8
2026-08-25docs(backlog): record items 160 and 161 from the notesDanilo M.1-0/+87
Both are composer feedback, and both causes are verified in the code rather than copied from the note. 160: autosave works and is silent on success. The only feedback is the failure banner, whose comment explicitly rejected the fading status line that success actually wants. m_dirty and the draftSaved signal already carry both states; nothing displays them. 161: the composer has no menu bar, and Save draft does not exist as an action at all. saveDraftNow() is reachable only from the timer, Send and closeEvent, so there is no way for the user to ask for a save. Notes that the composer's actions stay out of KeyMap per item 148, that everyActionIsReachableFromAMenu() walks the main window only, and that "duplicate the other actions" needs the user to say which, since most message actions are meaningless over a message being written. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UUQS6n3cmsFrsjCNmwNtf8