aboutsummaryrefslogtreecommitdiffstats
path: root/docs
AgeCommit message (Collapse)AuthorFilesLines
35 hoursdocs: settle the unread toggle on a mixed conversationDanilo M.1-0/+16
One key: mixed reads Mark thread read and marks every message read, a fully read conversation reads Mark thread unread. Two presses reach either state, which is item 112's requirement met with a label rule rather than a second action.
35 hoursdocs: state why the thread reply is reply-allDanilo M.1-4/+6
Replying to the sender alone in a multi-person thread drops everyone else from a conversation they are part of, while reading as a reply to it.
35 hoursdocs: settle reply, forward and save on a conversation rowDanilo M.1-0/+24
Forward and Save need a message and disappear; Reply becomes one lean 'Reply to this thread', quoting nothing, reply-all, threaded off the newest message. composeReply already supports it, so no new compose machinery.
36 hoursdocs: 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.
36 hoursdocs: settle the dashboard's coloursDanilo M.1-3/+21
Every colour on the pane already means something elsewhere: a sender's hashed identity, a tag's, or the palette's own emphasis. Avatar::colourFor() hashes the address, so a participant's colour is already stable across threads and nothing new had to be decided.
36 hoursdocs: settle the dashboard's scrolling, action strip and unread capDanilo M.1-6/+21
36 hoursdocs: specify what a thread row stands forDanilo M.2-1/+374
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.
38 hoursdocs: 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.
2 daysfeat: forward an HTML message with its formattingDanilo M.3-60/+388
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
2 daysdocs: 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
2 daysfix: 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
3 daysdocs: 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
3 daysdocs: scan every message on the first candidate runDanilo M.2-3/+87
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
3 daysdocs: 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
3 daysdocs: reconcile the backlog, and specify card avatarsDanilo M.2-8/+424
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
3 daysfeat: 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
4 daysfix: 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
4 daysfeat: 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
4 daysfeat: 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
4 daysdocs(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
4 daysfeat: 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
4 daysdocs: 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
4 daysdocs(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
4 daysdocs(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
4 daysdocs(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.
4 daysdocs(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
4 daysdocs(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
4 daysdocs(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
5 daysdocs(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
5 daysdocs(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
5 daysdocs(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
5 daysfeat(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
5 daysfeat(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
5 daysdocs(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
5 daysfix(drafts): list drafts as messages, not threadssignaturesDanilo M.2-0/+67
The Drafts filter shipped threaded in item 138, reasoning that a draft reply belongs with the conversation it answers. That reasoning cost the feature: a thread row stands for its first matched message, which for a draft reply is the message being replied to, so the draft itself had no row of its own and double-clicking the conversation opened nothing. Reversed with the user. Drafts now follows Sent; Trash deliberately does not, since a deleted message still belongs to its conversation and nothing there has to be reachable for editing. The view mode was decided in three places that each compared against "sent" and had to agree: builtinFilter(), the reader that reapplies the mode, and the writer that skips storing what the generator implies. generatorIsFlat() is now the one closed set they share, and builtinFilter() sets flat from it rather than inside a branch so the set cannot drift from the labels. Setting only the branch would have looked correct. Its save/load pair survives by accident, because the writer's skip knew only "sent" and so would have stored the key for drafts. The gap is the reader's fallback, for a file carrying no flat key at all: an older build, a migration or a hand edit comes back threaded against a flat button, and the next save persists the disagreement. theDraftsFilterIsThreadedNotFlat is inverted rather than deleted, keeping its history, and now also pins Trash as threaded. The round trip is covered by extending aGeneratedEntryWritesNoRedundantKeys, which already asserted that property for Sent. Mutation-checked: reverting generatorIsFlat() to "sent" alone fails both. Suite 37 of 38; undoMovesTheMessageBack is item 136, pre-existing and on an unrelated path. Closes item 159. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UUQS6n3cmsFrsjCNmwNtf8
5 daysdocs(backlog): close item 158, drafts are indexed on saveDanilo M.2-42/+36
5 daysdocs(backlog): close item 152, signaturesDanilo M.2-72/+76
5 daysdocs(backlog): record item 158, a saved draft is invisible until a sync ↵Danilo M.1-0/+41
indexes it Found by hand: autosave writes the draft to the Maildir drafts folder but never indexes it, and the Drafts view is a notmuch path: query, so the draft cannot be reopened until notmuch new runs. Approach reuses the single-file index moveMessages already performs.
5 daysdocs(backlog): item 136 fails deterministically, and names its own causeDanilo M.1-1/+26
Found incidentally while building item 152, by an agent that checked rather than assumed: it ran test_mainwindow at the preceding commit in a throwaway worktree and got the identical failure, so the signatures work is ruled out. Records the assertion text, which is worth more than the flakiness history. After the undo the message file is in neither cur nor new of the account inbox, so the question narrows from "why does this race" to "where did the file go", and the trash folder and the account root are the places to look first. A move landing in the wrong folder is the mail-safety half of the fork this entry already described, and it would present exactly this way. The 70-second duration already recorded fits a QTRY_* waiting for a file that is never going to appear. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEcn3u19xPqv6ggD15PG4c
5 daysdocs(plans): use a placeholder name in the signature fixturesDanilo M.1-30/+30
The plan's test fixtures carried the maintainer's own first name as the signature text, which reaches a committed test file. Task 1 caught and corrected it in the code; this corrects the source so tasks 2, 3, 5 and 6 do not reintroduce it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEcn3u19xPqv6ggD15PG4c
5 daysdocs(plans): implementation plan for signatures, item 152Danilo M.1-0/+1939
Eight tasks, TDD throughout, against specs/2026-08-24-signatures-design.md. Tasks 1 to 3 build the Signatures namespace: reading the directory, the splice for both placements, and the match guard that keeps a "-- " delimiter from authorising a deletion. Task 3 carries a mutation check on that guard, since it is the one piece preventing data loss. Task 4 adds the three config keys, task 5 the editor-bar switch and the seeding, task 6 the From: follow that stops once the user chooses. Task 7 is documentation and the Italian translation; task 8 closes the backlog item, and deliberately hands the work over for a hand test first rather than marking it done on a green suite. MessageBuilder is untouched by every task, which the plan states twice: the signature lives in the composer's buffer and both MIME parts are already derived from it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEcn3u19xPqv6ggD15PG4c
5 daysdocs(specs): guard the signature splice against deleting the user's textDanilo M.2-5/+72
Finding a "-- " delimiter is not enough to authorise removing what follows it. The block is replaced only when its text matches one of the signatures on disk; otherwise the new signature is inserted and nothing is removed. "-- " can reach the buffer without the user ever choosing a signature, most plausibly pasted in with quoted text from another client, and the unguarded scan would have deleted everything after it silently. The failure is now directional: a block that matches is replaced, and one that does not produces a second signature, visible in the editor and one undo away. A wrong guess adds text rather than losing it. Two markers were considered for the same problem and refused, both recorded with the reasons. A zero-width character ships in the sent message, fingerprinting the client in outgoing mail, and has to survive the draft round trip through GMime, quoted-printable and MimeParser, which is the pipeline that normalises such characters away. A doubled delimiter is not the RFC 3676 separator, so no receiving client would recognise the signature, and it would not have caught the pasted-text case that prompted it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEcn3u19xPqv6ggD15PG4c
5 daysdocs(specs): design signatures, item 152Danilo M.2-1/+316
One markdown file per signature under ~/.config/qtmaildir/signatures/, spliced into the composer's buffer and switched from a control on the editor bar. MessageBuilder needs no change: it already builds text/plain from markdownBody verbatim and text/html from MarkdownRenderer over the same string, so one markdown signature in the buffer yields both forms correctly. That is the "transparent to the user" requirement the note asked for, and it is why a two-file text/HTML variant was dropped after being chosen: it buys designed HTML signatures at the cost of the signature no longer being visible while composing. The switch stays stateless. seedBody() deliberately refuses to track "my text" and "the quote" as separate pieces, and a toggle cannot duck that question the way the quote did; it answers it by scanning for the last "-- " block not followed by quoted lines, so nothing can desync from the undo stack. That same scan is what lets signature_position offer both end (the default) and above_quote over one implementation. The per-account key does not reopen the note's constraint: an account seeds the choice, the switch keeps every signature reachable, and the automatic follow stops once the user picks one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEcn3u19xPqv6ggD15PG4c
5 daysfeat(queries): drop pinning, the menu is every saved query's homeDanilo M.2-46/+47
Item 94. The query row is the six built-in filters (Unread, Inbox, Important, Sent, Drafts, Trash), which compose with the account dropdown, and every saved query lives in the More queries menu. Nothing has to decide which of the user's queries get button space, which is the question item 93 would otherwise have had to answer. SavedQuery::pinned is gone from the struct, the reader, the writer, the save dialog's checkbox and the pin/unpin context action. The stored key is stripped rather than left ignored, at the user's choice. That has one non-obvious requirement: `pinned` stays named in loadSavedQueries' `known` list precisely so it is NOT collected as an unknown field, since those are preserved and written straight back. A mutation removing that name puts the key in the file for ever. Confirmed with the user before starting that the built-in set covers their use, since removing pinning removes the escape hatch this item was blocked on. Tests: four pinning tests replaced by two on the new rule, four more converted from buttons to menu entries. migrationPinsEveryEntry and aStoredGeneratedQueryIsUnpinnedNotDropped are rewritten around the property that outlived the flag rather than deleted: an entry must be KEPT, which is what both assertions were really guarding. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEcn3u19xPqv6ggD15PG4c
5 daysfeat(compose): offer Edit on a draft in the message pane's barDanilo M.1-1/+2
Item 157, the half item 153 did not close. A draft was editable by double-click and by a Message-menu entry, neither of which is where the user looks while reading one. populateMessageBar() swaps the reply pair for edit_draft on a displayed draft. Three things came out of hand-testing it, each invisible to the tests written before them. The bar keyed on currentIndex(), which a query leaves valid on a row of the discarded result, so it kept the draft button after switching to the inbox and the reply pair after switching to drafts. This is item 150's trap one level up. 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 defect predating the bar: updateComposeActions() 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 hidden over an empty pane, so it comes and goes with the subject and the details button rather than hovering over the logo. That in turn broke the showing half: setBarActions() runs before showThread() fills m_items, so the first message opened after a blanking left the bar hidden and the second showed it from stale items, one selection behind for the life of the view. updateHeader() shows it, beside the details button it rides with. The test missed the last one by asserting before the render landed, measuring the placeholder; it waits on showingPlaceholder() now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEcn3u19xPqv6ggD15PG4c
5 daysfeat(compose): open a draft to finish itDanilo M.2-1/+45
Item 153. DraftStore had a write() and no reader, and nothing opened a composer from an existing message, so a draft rendered like ordinary mail and could never be finished or sent. ComposeContextBuilder::forDraft() reads one back. A new Kind::Draft seeds every field verbatim: the subject takes no Re:/Fwd: prefix, and the body goes in exactly as it was left, with none of seedBody()'s quote framing. It is reachable by double-click and by an edit_draft action in the Message menu. Three things the shape of this depends on. A resumed draft must OWN its file. Maildir has no in-place edit, so an autosave writes a new file and unlinks the old one; a composer that did not know its own path would leave the original behind and one message would become two. ComposeContext::draftPath carries it into m_draftPath, which the autosave already knew how to replace. MimeParser had no bcc, and nothing had ever needed one. MessageBuilder writes Bcc into the draft file deliberately and explains why, so a resumed draft that ignored it would drop every blind recipient from the message the user then finishes and sends, reporting nothing. edit_draft is gated on the file being inside a configured drafts folder, matched on the PATH. A `draft` tag is not enough: notmuch surfaces the Maildir D flag as one, and a message flagged by another client sits in the inbox. Offered on ordinary mail, the composer would own a file it did not write and the first autosave would delete a received message. And a live defect found on the way, which is most of why this took as long as it did. updateComposeActions() ran only from onSelectionChanged. Both signals fire for an ordinary click, so nothing had noticed; but running a query and setting the current index emits currentRowChanged ALONE, so the enablement was computed against the previously selected row. Edit draft stayed disabled on a draft selected that way, and the reply family had the same blind spot with no test that could see it. Now connected to both. Reading currentRowChanged is safe here for the reason CLAUDE.md gives: it answers "which row is current", and no count is read. WorkerBackedWindow::AccountSpec gains a drafts field, which the two new tests need and which no fixture could express before.
5 daysdocs(backlog): record items 153 to 156 from the notesDanilo M.1-1/+5
Item 138 gave drafts a button and immediately surfaced that they cannot be edited: DraftStore has a write() and no reader, and no action opens a composer from an existing message, so a draft renders as ordinary mail and can never be finished. Filed as 153, a defect rather than an enhancement. The user's note splits it in two, "double-clicking a draft should open the editor" and "there's no edit action anywhere". They are one item: the second is the general case and the first is one route into it. 154 to 156 are the rest of that note's list, all v2 and all unspecified to different degrees. 154 and 156 are deliberately separate: a read receipt is the reader's client to honour, a delivery receipt the sending server's, and whether the latter can be requested at all depends on the send_command. 152 gains the constraint the user added today: a signature is not tied to an account and is switched from the editor bar, which rules out a plain [account.*] key as the whole answer.
5 daysfeat: add a Drafts filter, and close the composer with Ctrl+WDanilo M.2-30/+30
Items 138 and 148. The query row carried Unread, Inbox, Important, Sent and Trash, and no Drafts, though the composer has been autosaving into each account's drafts folder since compose shipped. Reaching them meant typing a query by hand. Smaller than its size suggested: Account::draftsQuery() and Config::allDraftsQuery() already existed for the placeholder pane's drafts count, and builtinFilters() derives the row from kQueryGenerators, so the work was the generator entry, two resolvedQuery branches, a label and an icon. It follows TRASH rather than Sent. Folder-matched like both, because `draft` is a Maildir flag notmuch surfaces as a tag while the folder is what the user means and what the composer actually writes into. But NOT flat: Sent is flat so a thread cannot fold the user's own message back into the conversation it answers, and a draft reply belongs with its conversation for the same reason a trashed message does. An account with no drafts folder shows no button, per item 103's rule. The existing row test surfaced that by failing until its fixture configured one, which is the rule working rather than a defect. Ctrl+W closes the composer, which bound nothing at all: the only way out was the title bar. The action is parented to the composer, so it is a WindowShortcut dispatched to the active one only and the main window's namespace is untouched, exactly like the formatting shortcuts. It calls close() rather than doing anything of its own, since closeEvent() already decides whether the draft is saved and a second route out that skipped it would lose the message. The Italian gains "Bozze"; lrelease reports 478 finished, 0 unfinished.
5 daysfeat(compose): lay the composer out by scopeDanilo M.2-91/+109
Items 142, 143, 144 and 145, to the layout the user described. The composer had one addToolBar carrying three scopes at once: text formatting, message composition, and the terminal action. It read as a menu bar that is not one. There is now no window toolbar at all. From: [.............] +--------+ To: [.........] [v Cc/Bcc] | Send | Subject: [...........................] + [B][I][</>][S][link]["] [Attach] [Send as HTML] +---------------------------------------------+ | message text | +---------------------------------------------+ [Remove] * report.pdf <- only when attached Send is a large icon-above-text button beside the headers: it is the terminal action and carries the weight to match. Formatting is a toolbar widget in the central column directly above the text it formats, icon-only with the words kept as tooltips, which is where a tooltip stops being decoration. Attach and the HTML toggle ride the right end of that bar, past a stretch, because neither formats text. Remove attachment sits with the list it acts on and appears only once something is attached. "Also send a formatted copy" becomes "Send as HTML": the old label described a mechanism without naming it, leaving the reader to infer that "formatted" meant HTML and that "copy" meant a MIME part rather than a second message. Cc and Bcc hide behind a disclosure beside To:. revealCcBccIfUsed() only ever shows, never hides, so nothing but the user's own click can make a field holding an address invisible: a hidden recipient is a message going somewhere the sender cannot see, which is worse than the clutter this removes. The label is hidden with each field, since a QFormLayout holds the two as separate items and hiding the line edit alone strands a "Cc:" over empty space. Two send-lock faults, one predicted and one not. The backlog warned that setInputsEnabled() disabled the single toolbar wholesale, so the send-path test was strengthened to name every control BEFORE the split; it then caught Attach live during a countdown, where a file appended after MessageBuilder has run is either dropped or added to bytes already sent, silently either way. With every control named it failed again on format_bold: disabling a QToolBar greys its buttons but leaves each QAction enabled, so Ctrl+B during a send would have edited a message already being built, through a button that looked unavailable. setInputsEnabled() now walks the bar's actions too. The Italian translation is refreshed; lrelease reports 477 finished, 0 unfinished.
6 daysfix(ui): move Compose back, drop the bar below the header, size its iconsDanilo M.2-2/+28
Three corrections from looking at the built bar. Compose returns to the main toolbar. The split this was built to, "about a message" against "about the list", does not survive contact: what matters is what the action NEEDS. Reply and Forward are meaningless without a message on display, while Compose needs none and is disabled only when no account can send. So the pane's bar holds exactly the two actions that depend on what it is showing, and Compose sits with the window-wide ones. The bar moves below the subject and details rows, directly above the web view. At the top of the pane it read as window chrome rather than as belonging to the message. The transient notice bars stay above it: they explain the message rather than offer an action on it. Its icons were the style's own default, 16px, which is tiny beside a 32px toolbar. They are now 7/8 of toolbar_icon_size, which is the 28 the user asked for at their 32, derived rather than hardcoded so the relation holds if that key changes. The test asserts the relation as well as the value, since a bare 28 would stop meaning anything the moment the key moved. m_headerLabel gains an object name so the placement test can find the row it must sit below.