aboutsummaryrefslogtreecommitdiffstats
path: root/CHANGELOG.md
AgeCommit message (Collapse)AuthorFilesLines
5 daysfix(pane): drop Save link from a link's context menuDanilo M.1-3/+7
Reported by hand after the item 127 fix: right-clicking a link still offered Save link. It had been deferred to item 114 alongside Save image, on the grounds that both are inert without a downloadRequested handler. That is true and it was the wrong conclusion, because the two are not the same question. Save image is content the message already carries, and item 114 is about making it work. Save link fetches a remote URL chosen by the sender, through the pane's profile, which is the one profile in this application that must never fetch remote content: that is what m_allowRemote and the interceptor exist to prevent. Answering it with a download handler would put a network fetch of attacker-controlled content behind one context-menu entry. Saving what the user actually wants already has a path that never touches the network: saveAttachment(), which writes a MIME part already parsed into memory and sanitises the filename. So it is removed rather than implemented, and the test asserts its absence. Item 114 now carries the constraint that follows: a downloadRequested handler added to make Save image work must not make Save link reachable again, which the natural per-profile implementation would do by default. Mutation checked: dropping the entry from the filter fails the test with "a link action survived: Save link". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YDq53rMd3AQp7QmcZzpuBM
5 daysfix(pane): open a target="_blank" link, and drop the dead link actionsDanilo M.1-0/+13
Items 126 and 127, in one sitting because the second is only safe after the first. 126: an anchor carrying target="_blank" did nothing when clicked, with no error and nothing on screen. Chromium routes such a click to QWebEnginePage::createWindow() rather than to acceptNavigationRequest, and MessagePage did not override it, so the base implementation returned nullptr and the URL was discarded before any of our code saw it. Plain anchors were unaffected and already worked, which is why this presented as "HTML mail is broken" while a text mail's links opened: marketing HTML sets _blank on practically every anchor. createWindow() receives a WebWindowType and no URL, so an override cannot simply read the target: it arrives afterwards as a navigation on whatever page is returned. LinkRelayPage is that page. It has no view, hands the URL to the same handler the plain-link path uses, refuses the navigation, and deletes itself. Nothing is ever fetched and no second QWebEngineView is created. 127: OpenLinkInNewTab, OpenLinkInNewWindow and OpenLinkInThisWindow join removeBrowserActions()'s list. Item 100's list is the PAGE actions and was tested by right-clicking the page; these appear only over a link, so it never saw them. CopyLinkToClipboard stays, being the fallback for any link that will not open. The order matters: 126 gives the page a working createWindow(), so those entries would have stopped being dead and started opening links into a tab that does not exist. Testing needed two seams. The click cannot be synthesised, since JavaScript is off in this profile (measured: runJavaScript returns an invalid QVariant) and a synthetic press would depend on the anchor's rect and the desktop's fonts; setUrl() is no substitute because it arrives as NavigationTypeTyped. clickLinkForTest() and relayBlankTargetForTest() drive the real overrides on the real page, and setLinkOpener() substitutes a recorder for QDesktopServices::openUrl. Both routes are asserted rather than only the broken one, since they share a handler now. Three mutations checked and caught, including the filter also removing CopyLinkToClipboard, which a later sweep of "dead link actions" would otherwise take silently. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YDq53rMd3AQp7QmcZzpuBM
6 daysrelease: 0.26.1v0.26.1Danilo M.1-0/+15
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6 daysfeat(pane): move the copy confirmation into the message paneDanilo M.1-2/+3
The user's preference after seeing item 115 ship: a small transient in the bottom right of the pane with a checkmark, rather than a status bar message at the far end of the window. A copy happens in the pane, so the confirmation belongs there. Three properties are load-bearing and each has a mutation that fails. The toast is a hand-placed CHILD rather than a layout item, because it floats over the message instead of taking a strip away from it: nothing reflows when it appears and the text just copied does not jump. That is why resizeEvent() is overridden, since a hand-placed child does not follow its parent. It is autoFillBackground and painted from the theme's ToolTipBase/ToolTipText, so it stays readable over a rendered message and follows the desktop theme the way the document already does. And its timer is restarted rather than started, so a second copy gets its own full reading time instead of inheriting what is left of the first. The resize test was wrong on its first draft and passed against the mutation it exists to catch. It grew the pane, which moves the right and bottom edges away, so a toast left at its old position still satisfied "inside the pane"; measured green with the reposition deleted. It shrinks now, where a stale position lands outside the new rect, which is also what the user would see. No new strings: the four messages are unchanged, only where they appear. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6 daysfix(worker): give a moved message a fresh maildir nameDanilo M.1-0/+22
mbsync's manual, under "the more efficient default UID mapping scheme": "it is important that the MUA renames files when moving them between Maildir folders", and "the general expectation is that a completely new filename is generated as if the message was new". qtmaildir is that MUA and did not rename. moveMessages() kept QFileInfo(from).fileName() verbatim, `,U=<n>` included. That infix is mbsync's per-folder IMAP UID, so carrying it across a folder boundary makes it a claim about a folder the file is no longer in; moving a message out and back then reinserts a UID the server has since reassigned. Reported by the user as `Maildir error: duplicate UID 1`, and measured on the real Maildir: four collisions in one folder, eight distinct messages, none lost. freshMaildirName() regenerates the unique part and keeps ONLY the `:2,<flags>` suffix. Keeping the flags is not a contradiction of "as if the message was new": they record seen, flagged and replied, and maildir.synchronize_flags is true, so dropping them would mark every deleted message unread and lose Important on the way to the trash. Two things fell out of the change and both were defects waiting to happen. The already-in-the-destination guard compared full PATHS, which worked only because the name was carried across; with a fresh name it can never be true, so a message already in the destination would be renamed on every move. It compares directories now. And test_mainwindow's folderHasMessageFile() matched on the filename stem, so all fifty-odd assertions using it began reporting "the file is not there" about files that were there. It reads the Message-ID out of each file instead, which is what those assertions always meant. Three mutations fail: the old name carried across, the flags dropped, and the uniqueness counter frozen so two messages moved in one batch collide. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6 daysfeat(pane): offer Select all, and report what a copy copiedDanilo M.1-0/+8
Items 115 and 117, both from the user's notes. Select all was never in Chromium's menu for this pane, measured by hand with a selection active and against a build with removeBrowserActions() reverted, so the filter is not what removed it. MessageView::addPaneActions() supplies it, static and taking the menu, mirroring removeBrowserActions() beside it. Two comments claiming the standard menu already offered it are corrected; either would have sent the next reader down the same three wrong theories the item records. The copy entries all worked and none of them said so. Four now report through the pane's existing statusMessage, each naming what it copied rather than saying "Copied", which is the item's own constraint when three of them sit together in one menu. Connected to the page's own QActions, so the report follows the entry wherever it is triggered from. The two differ in what can be tested, and the tests say so rather than papering over it. The copy path is fully covered: triggering the action runs the production path, and mutations for a duplicated message and an unwired entry both fail. addPaneActions() is covered, but showBodyContextMenu() CALLING it is not and cannot be, since createStandardContextMenu() returns nothing outside a real context-menu event; a mutation deleting that call leaves the suite green, measured. The call site is a hand test and the test file records that so nobody adds an assertion that appears to cover it. The copy strings are QT_TR_NOOP inside an array, which CLAUDE.md warns extracts nothing at file scope. Verified rather than assumed: lupdate found all four under the MessageView context, because the array sits inside a member function. 387 finished, 0 unfinished. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6 daysrelease: 0.26.0v0.26.0Danilo M.1-0/+11
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6 daysfix(trash): refresh the list when a restore empties a rowdelete-to-trashDanilo M.1-0/+4
Reported from a hand test: Restore moved the message correctly and the row it came from sat in the trash list until the Trash filter was clicked again. The trash view is path-based, so a restored message stops matching the query the list was built from. That is a state no tag change can express, and nothing in onMessagesMoved() removes a row, deliberately: in an ordinary view a deleted message's card should stay put, since one deleted message does not doom the conversation. refreshCurrentQuery(), not runCurrentQuery(). The refresh runs immediately after the undo entry is pushed, and re-running the query outright clears the undo stack, which would make Restore the one mutation in the window with no way back. Gated on isShowingTrash() rather than on the destination, because a Delete is a move too and reaches the same slot. Three tests, each catching a different mutation: the row leaves, undo survives the refresh and still moves the file back, and a delete outside the trash view leaves its row alone. The third one was wrong on its first draft and passed against the mutation it existed to catch. It used a `tag:inbox` view, which looks ordinary but which a deleted message keeps matching, since Delete adds `deleted` and the origin tag and removes nothing. A path query on the inbox folder is the honest instrument: the file really leaves, so the row survives only because nothing refreshed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6 daysi18n: translate the trash strings, and document the trash keyDanilo M.1-0/+49
The three new strings from the cleanup action, translated into Italian. lrelease reports 383 finished and 0 unfinished; an unfinished string is silently dropped and ships as English inside an otherwise Italian UI. The changelog gains an Upgrading section for the mandatory `trash` key, the new optional `inbox` key and the `Del` binding, and states the consequence that cost real mail on this branch: a folder name that does not match the server is created rather than reported, mbsync adopts it, and under Create Both it propagates to the server where other clients see it. CLAUDE.md is corrected on two counts. Adding an action is five places, not four; the fifth is a menu, and nothing enforced it until this branch added everyActionIsReachableFromAMenu(). And the trash design is recorded: why the origin lives in a tag, why those tags are joined by a tab rather than a space, and why Restore resolves against the database rather than the model. Also repairs a race in deletingTwiceLeavesNoOriginTagBehind(). Its guard ran a query through the bar in the gap between the file rename and the tag writes, and a query bar run in that gap returns zero rows forever, since QTRY_VERIFY re-reads rowCount() and never re-runs the query. Measured 3 failures in 12 runs, each burning a full 15s timeout; 0 in 8 after asking the database directly, with the runtime down from 45s to 0.3s. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8 daysrelease: 0.25.0v0.25.0Danilo M.1-0/+11
8 daysdocs: reconcile the backlog, close 98/100/102, drop 78 and 116Danilo M.1-0/+30
The reconciliation against the user's own notes found ZERO unrecorded entries, the first clean pass: the 2026-08-16 sweep added items 98 to 104 and those absorbed the whole current "Not done yet" list. Closed this session, sections moved to the closed-items file: 98, 100, 102. Dropped: - 78, at the user's request. Never a defect. Items 85, 23 and 81 already give the whole journey (right-click a value, search it, save the query, make a rule from it); this was only a shortcut across it, and the entry had already said to gather usage evidence first. That evidence never appeared. - 116, the same day it was raised, and its section is kept for the process failure rather than the non-bug. Copy image was reported as copying markup instead of pixels. Two explanations were eliminated by real evidence, and the conclusion drawn was that something more interesting must be wrong; the actual answer was that the measurement distinguishing them was broken. A wl-paste reading taken minutes after the copy showed text flavours only, was explicitly labelled unreliable in the entry, and was then reasoned from anyway. Run immediately after a copy it reports image/png and 30 more, and pasting into GIMP immediately works. A caveat that does not stop the reasoning it qualifies is decoration. Opened: - 112, Toggle unread on a whole thread cannot reach "all unread" on a partly-read thread. The direction comes from notmuch's UNION over the thread, so one unread message anywhere makes the action pick "mark read" and no input reaches the other branch. Third defect from that union after 110. - 113, view source as our own plain-text dialog. - 114, Save image is offered and does nothing: no downloadRequested handler exists anywhere. The user corrected the first proposal, which would have refused remote images on security grounds; once remote content is granted the bytes are already fetched, so saving them is a local copy and blocking it protects nothing. - 115, no confirmation when a copy succeeds. - 117, the pane offers no Select all. NOT caused by item 100: verified against a build with that filter reverted. Three wrong theories preceded that measurement, and the lesson is one item 100 had already written down: a menu built by hand proves nothing about the menu Chromium builds. The changelog's Unreleased section gains Important-as-a-toggle, the rules Note column, the menu fix, and two Upgrading notes.
9 daysfeat(ui): act on the message a row displays, not its whole threadDanilo M.1-0/+101
A thread's card has rendered one message since item 66, but every tag action still acted on the entire conversation. Delete, Archive, Important, Mark spam and Toggle unread now act on the message the card shows; the whole-thread versions move to a "Whole thread" submenu in the Message menu and the thread list's context menu, on Ctrl+Alt+<key>. Closes items 87, 88, 105, 106, 107, 108, 109, 110 and 111. The defects fixed along the way, several found by reading rather than by report: - threadAt(current.row()) answered about the wrong thread for a reply row, because a tree numbers rows per parent. The audit found four live sites, not the one reported: Delete and Toggle unread each chose their DIRECTION from an unrelated thread, and the tag dialog counted the wrong thread's tags. threadFor(index) replaces them. - A message-scoped write made no optimistic model update and no reply row carried a doomed cue, so acting on a reply moved the pending-edit count and changed nothing on screen. - Both toggles read the state of a reply's THREAD, which a message-scoped write never changes, so they were one-way: the second press re-sent a tag the message already had. - flushHeldEdits() re-sent only thread-scoped edits, so a tag change made on one message during a sync was applied to the row, counted as unsynced, and then dropped without ever being written. - applyTagChange() updated a thread's summary but not its loaded replies, leaving an expanded thread's rows describing a state the database no longer held. - A thread's first message is not among its children, so both message-scoped lookups missed it: acting on a root card repainted nothing and emptied the message pane's chip row. - ThreadSummary::tags is notmuch's union over the thread, so a card standing for one message drew tags belonging to its siblings. The worker now reads that message's own tags in the walk that already finds its id, so the split is known before a row is ever opened. The card shows both tiers: its own message's tags at full size, the rest of the conversation's smaller and muted, so nothing appears to vanish when a row is selected. Auto mark-read is message-scoped as a result, and now arms for a reply, which it never did. With maildir.synchronize_flags on, the old thread-wide write reached the server for mail that had never been displayed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 daysrelease: 0.24.0v0.24.0Danilo M.1-0/+15
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 daysfix(sync): send held edits before the sync-end refresh reads the databaseDanilo M.1-0/+5
An edit made while a sync is running is held rather than sent, because the worker's read-write open blocks on notmuch's exclusive lock. At sync end onExternalSyncStateChanged() refreshed the list first and flushed the held edits afterwards, so the refresh read a database that still carried the old tag, reconciled it into the model, and overwrote the optimistic update the hold had deliberately left applied. The flush then wrote the tag correctly. The database ended up right and the list ended up wrong, with nothing scheduled to re-read it, which is why it looked like the edit had been lost. Reported by hand: a message read during a sync went back to unread when the sync finished. The flush moves ahead of the refresh and keeps both properties it already had. It stays outside the Idle branch, so edits held when /proc/locks becomes unreadable are not stranded waiting for an Idle that never comes, and it stays after the status-bar retire, so its own "N held changes sent" message survives. Both orders leave identical end state, so the first version of the test passed against the defect: after the handler returns the queue is empty and the write has been sent whichever ran first. flushGenerationForTesting() stamps the query generation at flush time, which is what separates them, and the test fails against the old order with Actual: 3, Expected: 2. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 daysfix(sync): re-arm the automatic sync when it skips a concurrent runDanilo M.1-0/+5
runAutoSync() returned without rescheduling when a sync was already in flight. The comment defending it argued the edits were not lost, because they reached the mail store at edit time and the running sync was "very likely" to carry them. Very likely is not always: an edit made after mbsync has already passed that account's mailbox is not carried by it, the timer had fired, nothing re-armed it, and the pending count sat non-zero until a manual sync or the next cron run. Skipping is unchanged and still required by item 71: the cron job holds the same lock and mbsync fails on a second concurrent run. What changes is that the skip schedules another attempt. scheduleAutoSync() re-checks the delay, the sync command and the pending count on the way in, so this cannot arm a sync for nothing, and against a long external sync it re-arms once per debounce interval, which is a timer rather than a sync. The test fires the timer by hand and asserts it is active again afterwards, at the configured interval rather than a shorter one, with the pending indicator still showing. It fails against the old skip path. Item 89's other half is dropped rather than built. The list churn it described is a tag-defined view working as intended: a thread that loses `unread` leaves the Unread view, and the user resolved it by living in the Inbox view instead. Three designs were drafted before asking and none is worth building. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 daysfix(ui): load a thread that was already displayed when the query ranDanilo M.1-0/+9
Running a query blanks the message pane but left m_currentThreadId, m_currentMessageId and m_currentMessageThreadId naming the thread that had been showing. Both selection handlers compare a newly selected row against those to decide whether it is already on display, so a result containing that same thread was recognised as "already showing" and onThreadSelected() was never called. The card painted as selected, the status bar reported one thread, and the pane stayed on the placeholder. This is why it looked like an `id:` query defect. The id is copied out of the details dialog of the message being read, so that thread is current at the moment the query replaces the view. Any query returning a different thread hides the fault entirely. Filed as the unverified half of item 66 and assumed to be the same empty-MessageIdRole failure. It is not: 66's fix was correct and this reproduced against it, so it is recorded as item 96. Four hypotheses were eliminated by measurement first: the row does carry the message id, the account-scoped query does return it, MimeParser parses the reported message (ok, 40701 bytes of HTML), and both real ids resolve bare and quoted. The regression test's first query must open the SAME thread the second one returns; with two different threads it passes against the defect, which is how the first version of it was green. Reverting the fix fails it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 daysrelease: 0.23.0v0.23.0Danilo M.1-2/+23
10 daysfeat(ui): highlight the built-in filter matching the current viewDanilo M.1-0/+10
The four filter buttons gave no sign of which one you were looking at, so the row said what you could do and never where you were. The active filter is drawn as a checked QToolButton, which lets the style paint its own pressed look: a hand-picked highlight colour would have to be picked once per theme and would still be wrong under a third. The check state is derived from the query TEXT rather than from the last button clicked, which is the whole design decision. A record of what was pressed goes on lying the moment the query is edited into something else, where a highlight that follows the query clears itself and lights again when a filter's query is typed by hand. It is resolved against the account box, so changing account recomputes it rather than dropping it: the same filter under two accounts is two different query strings and both are still "Inbox". Buttons are held in a hash keyed by generator, cleared at the top of the row build because the row is rebuilt wholesale on every saved-query edit and stale entries would dangle. The connections are owned by the row widget, so a rebuild takes them with it rather than leaving a second copy firing at deleted buttons. Unread opens already highlighted, which is correct rather than incidental: startup_query defaults to it, so the window opens on that view. The test asserts it, so the assertions that follow are known to be a change of state rather than a button that happened to start unchecked. Mutation checked against the design that was rejected: deriving the state from the click instead of the query fails all three tests, each naming the behaviour it protects. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 daysfeat(i18n): wire translations and ship an Italian one (item 22)Danilo M.1-0/+30
Nothing loaded a translation before this: no QTranslator, no .ts file and no build rule, so every string was English whatever the locale said. The language now comes from the environment, LANG=it_IT.UTF-8, and any other locale runs in English as before. The audit found that the tr() discipline was largely holding, and found eight strings that could never be translated into any language. kFields[] in tagrulesdialog.cpp declared the rule-builder field labels with QT_TR_NOOP inside an anonymous namespace, where lupdate reports "tr() cannot be called without context" and extracts nothing, while the use site calls TagRulesDialog::tr() on them at runtime. From, To, Cc, Subject, Tag, Folder, Attachment and Date: the whole vocabulary of the rule builder, absent from every translation file that could ever exist. The source compiles and reads correctly; only lupdate reveals it. Q_DECLARE_TR_FUNCTIONS is not the fix for that case, though it is the fix for a free function calling tr(). Measured against lupdate: a class carrying the macro beside the array still extracts 0 strings, because the context must be attached to the literal itself. QT_TRANSLATE_NOOP names it explicitly and matches the tr() that already reads them, so the use site needed no change. Twenty configuration and keybinding warnings were not translatable either. They are user-facing, reaching the status label and the "Configuration problems" dialog. Config already had the tr() macro; KeyMap needed it. Translating the filter labels then broke startup_query, found in hand testing: a filter's name is a translated label, so `startup_query = Inbox` matched nothing where the filter shows as "In arrivo". The application opened a different view and reported the user's own working config as invalid. Resolution matches the generator as well now, which is stored in queries.json and identical in every locale; the translated name still works. The regression test installs a real QTranslator rather than a stub, since the bug lives in the gap between the stored string and the displayed one, and it writes a queries.json because the warning it asserts on is guarded by a non-empty saved-query list: without one the branch never runs and the test passes against a broken check. main.cpp's --help and --version stay bare printf, as they run before QApplication exists and no translator could serve them. Verified per the backlog's own standard, that lupdate output is the evidence rather than reading: 355 strings extracted with zero context warnings, where before there were 327 with eight; lrelease reporting 355 finished and 0 unfinished; the built .qm loaded in a standalone probe printing "From -> Da" and both Italian plural forms; and the install rule placing it where main.cpp looks. test_translations guards it and was mutation checked, failing on an emptied translation and naming the defect when QT_TRANSLATE_NOOP is reverted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 daysrelease: 0.22.0v0.22.0Danilo M.1-0/+9
10 daysfeat(startup): add startup_account, the account the dropdown opens onDanilo M.1-0/+12
Answers "start me in work - Inbox rather than All accounts - Inbox". The key names an account by its [account.<key>] suffix and the dropdown is set to it before the startup query runs; because a built-in filter composes with the dropdown, that is the whole mechanism and the key never reaches a query builder. Validated on load: a name matching no account is reported and cleared, since the dropdown has no entry for it and would sit on All accounts without saying why. Which side applies the scope depends on what the startup entry is, and getting it wrong is silent in both directions. A generated filter comes back from resolvedQuery() already scoped, so letting runQuery() apply the dropdown again gives path:"work/**" and (path:"work/**" and (tag:inbox)). A saved query does not, because resolvedQuery() ignores the account key for one, so claiming it was already scoped leaves it unscoped with the dropdown pointing at Work. The first of those shipped in this session's working tree and passed its test, because the assertion used contains() and the double-scoped string contains the scope too. It asserts the exact query now. The second was found by writing the test for the case rather than by reading, and is covered by aStartupAccountAlsoScopesASavedStartupQuery. The README's startup_query documentation was wrong on two counts after the previous commit: the fallback is the Unread filter rather than the first query in the file, and the name can now match a built-in filter.
10 daysfix(startup): let startup_query name a built-in filter, and run itDanilo M.1-1/+14
Two defects, both reachable only after item 93. startupSavedQuery() searched the saved queries alone, so a startup_query of "Inbox" matched nothing once Inbox shipped as a built-in filter and the duplicated entry was removed from queries.json. It then fell back to m_savedQueries.first(), which is an arbitrary choice that used to look reasonable while every install carried an Inbox entry: with the duplicates gone it opened on a leftover search for one sender, and an empty queries.json opened on nothing at all. The search now covers the saved queries first, so the user's own entry wins a name collision, then the built-in filters; the fallback is the Unread filter, which is always present. The default startup name has always been "Unread" and now resolves for the first time: before this it named nothing unless the user happened to have such an entry. The constructor also read startup.query directly, and a generated entry stores no query at all, so even a matching filter opened an empty bar. It resolves through Config::resolvedQuery() now, unscoped, since the account dropdown starts on "All accounts". Icons per the user's choices: a star for Important rather than the flag action's own icon, since on the query row an icon reads as a category rather than as an instruction, and mail-folder-sent rather than mail-sent. Three tests changed rather than adapted, because their premises were the defect. Two asserted the first-saved-query fallback. aCronSyncDoesNotRefreshBeforeAnyQueryHasRun assumed a fresh window had run no query, which is no longer true; it is now aCronSyncRefreshesTheLastRunQueryNotTheQueryBar and asserts the property that actually matters on a cron timer, through a new lastRunQueryForTesting() seam, since a legitimate refresh bumps the generation and the counter cannot tell the two apart.
10 daysfix(filters): label the flagged filter Important, and give the four iconsDanilo M.1-0/+11
Item 57 renamed the `flag` action to "Important" in 0.14.0, chosen over "Starred" partly because &I was free where &S collided with Mark spam. Item 93 then shipped the filter for the same tag as "Flagged", so one window offered both names for one thing. The generator keeps its own name, `flagged`: that string is stored in queries.json and matched against a closed set, so it is wire format rather than a label. The filters are QToolButtons now, like the Save button at the other end of the row, carrying a themed icon with the text beside it. Icon AND text for the reason the Save button already records: this row is a row of text buttons, so an icon alone reads as a different kind of control than it is. Theme icons rather than the shipped SVGs in Marks, because item 70's split is that the panes are ours and the chrome is the system's, and the query row is chrome. mail-mark-important matches the `flag` action's own icon, since the filter finds what the action marks. The icon test asserts a NAME was requested rather than that the icon resolved: QIcon::fromTheme returns null where no icon theme is installed, so isNull() would fail for a reason unrelated to this code. Dropping the setIcon call fails it. Widening the buttons to QToolButton broke eleven tests that reached them through findChild<QPushButton *>, which does not match a sibling type. The helpers and the filter lookups take QAbstractButton; savedQueryButton() stays on QPushButton, since the user's own queries really are those.
10 daysrelease: 0.21.0v0.21.0Danilo M.1-0/+8
10 daysdocs: close items 93 and 95, record their trapsDanilo M.1-0/+42
Item 93 ships four built-in filters composing with the account dropdown, and absorbs item 90. Item 95 is the overflow-menu defect found while hand testing it: pre-existing, not caused by 93, and recorded as its own item rather than folded in. Two traps go to CLAUDE.md because they are still true of the code. Qt emits no triggered for a QAction owning a menu, which is why every entry in the saved query menu had always been inert. And a generator must be asked for one account's query rather than having its all-accounts query wrapped, since the wrap returns the right rows by accident of path: being hierarchical and a row-count test cannot tell the two apart. A third goes beside the existing rendering-probe warnings: visualRect reports a real height for a row scrolled out of the viewport, so a non-empty check passes while the pixel loop measures nothing and blames the wrong defect. The Upgrading note covers what a user sees: the row starts with four shipped buttons, a duplicate name means one of theirs is now beside a built-in, and their migrated Sent entry is unpinned for them rather than deleted.
10 daysfix(status): count threads as they arrive instead of "Searching..."Danilo M.1-0/+7
Item 74. runQuery() set the status bar once and only queryFinished cleared it, so the bar kept claiming a query was running for the whole walk while rows were visibly arriving behind it. Measured cold against a 1.1 GB index: the first batch reaches the model at 642 ms and the walk finishes at 5714 ms, so five seconds of a slow query read as a frozen one. onThreadsReady now sets the bar from the model's own row count after each batch, which is the number of rows the user can actually see. No timing changes; this only stops the bar from lying. The refresh branch returns before the new line, so a background refresh stays silent exactly as onQueryFinished already keeps it. That silence has its own test, which fails when the write is moved above the guard. beginRefreshForTesting() is a new seam: refreshCurrentQuery() returns early without a worker and a bare window has none, so a test cannot otherwise reach the refresh path.
11 daysdocs: close item 66, open 87 and 88, record the row-number trapDanilo M.1-0/+24
Item 66 turns out not to have been the defect it was filed as. The pane was never blank: an unexpanded thread root rendered the CONVERSATION, and the same click rendered one message once the thread had been opened, because the model learned the root's message id only when the replies arrived. The user's step-by-step account is what separated the two halves; two probes against a real database had failed to reproduce the blank pane because there was none. Closed by carrying firstMessageId in the query and removing the conversation view, which the user asked for after being told the stubs not expanding was itself a defect and that the feature was being judged in a broken state. Two defects came out of it and are open. 87: auto mark-read still marks a whole thread, coherent while a root rendered the conversation and not any more. 88: threadAt(current.row()) answers about the wrong thread for a reply row, because a tree numbers rows per parent. 87 is blocked on 88 and the entry says why: a fix for 87 was written, mutation-checked, shipped and reverted the same evening after it marked an unrelated message read. CLAUDE.md gains the row-number trap as its own entry rather than leaving it implied by the item 20 note, plus the rule that a test for a write path must exercise the reply case: the reverted fix was green because it asserted on a root selection, the one case where row() is correct. The cid-prefixing note is corrected to say every caller now passes one message while explaining why the prefixing stays. The changelog carries a Removed entry and an Upgrading note, including that mark_read_delay_ms accepts a negative value to disable auto mark-read entirely, verified against config.h. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 daysdocs: close item 84, spec item 36Danilo M.1-0/+7
Item 84's section moves to the closed file, recording that the fix was a split rather than a suppression, and that its mutation check reproduces the original hang at exit 124 rather than merely failing. Item 36 is specced and no longer "on demand": item 66 needs it. Two findings while writing it. The deliverable is a RED reproduction of 66, with the fix deliberately excluded, since that defect has never been isolated and designing a fix beside a hypothesis is how a wrong one gets locked in. And the item is smaller than it has read since 2026-08-04: wireWorker() already builds the worker from a config key, so a test writes a config pointing at the fixture and nothing in src/ changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 daysdocs: close item 86, excluding a value from a searchDanilo M.1-0/+12
Section moved to the closed-items file on the commit that closes it, per the backlog's own rule. It records the two decisions that are not recoverable from the code, and that the plan under-counted the signature change: three test files drive these signals, not one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 daysrelease: 0.20.0v0.20.0Danilo M.1-0/+7
11 daysdocs: close item 85, searching from the message paneDanilo M.1-0/+21
Five surfaces in the message pane offer a search built from what they show, replacing the query or narrowing it. The details dialog became rows along the way, which the user wanted independently of this feature. Item 78 is narrowed to the rule shortcut alone and drops to S: item 85 built the menus and item 81 the seeded dialog, so both halves already exist. Its approach text is corrected too, since it claimed the thread list holds a usable sender and notmuch_thread_get_authors returns a display summary, not an address. Three traps recorded in CLAUDE.md: a modal dialog must close before the action it asked for runs, Qt::RFC2822Date validates the weekday against the date, and every query goes through SearchTerm so five surfaces cannot grow five quoting rules.
11 daysrelease: 0.19.0v0.19.0Danilo M.1-0/+17
11 daysdocs: close item 81, saved query to tagging ruleDanilo M.1-0/+25
Also records item 83's fix and the warning banner in the changelog, which the earlier commit did not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 daysrelease: 0.18.0v0.18.0Danilo M.1-26/+34
12 daysfeat(queries): edit, pin and delete a saved query from the UIDanilo M.1-0/+6
Item 82. Saving a query worked and nothing else did: changing one field meant retyping the whole query under the same name, and deleting one meant editing the file by hand. An action that creates something the UI cannot then change or remove is incomplete, and the user hit it within minutes of the first hand test. Right-clicking a saved query, on its button or its menu entry, now offers Edit, Move to menu / Show as a button, and Delete. Every path funnels through one replaceSavedQuery(), which matches on the name the dialog was OPENED with rather than the one it returns, so a rename replaces the entry instead of leaving the original behind beside a new one, and which merges the stored entry's unknown fields in a single place rather than in three. Delete confirms first: the rule against confirmation dialogs covers tag mutations, which the undo stack can take back, and this writes user config that it cannot. Two cases the item did not anticipate. A generated entry has no query to edit, so the dialog shows its composed query read-only rather than offering a field that changes nothing, and carries `generated` and `flat` through an edit rather than letting it decay into a plain entry holding a snapshot of what it resolved to today. And the overwrite notice had to learn to ignore the entry being edited, since warning that "Inbox" already exists while editing Inbox is noise. This also fixes a defect that predated it and was already reachable from the save path. rebuildSavedQueryRow() called deleteLater() on the old row, which defers destruction to the event loop, so the stale row went on answering findChild() and every lookup after a rebuild reported the state from before the edit. Nothing looked wrong on screen, which is why it surfaced only as three tests failing against a row that had in fact been rebuilt correctly. Five tests, three mutations. Matching on the returned name fails two, never writing the file fails three, and dropping the unknown-field merge fails one. That last one initially proved nothing: it drove UNPIN, which copies the stored entry and so carries `unknown` along by itself, and passed with the merge deleted. It now goes through the edit path with a replacement that has none, which is what the dialog actually returns. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 daysfeat(queries): make Sent a saved query rather than a fixed buttonDanilo M.1-1/+11
The user asked whether the default queries could be unified with Sent. The answer runs the other way: Sent joins the saved queries rather than the saved queries becoming hardcoded. Inbox, Unread and Important are complete strings that depend on nothing and can never go stale, so generating them would buy nothing and would cost the four things the file just gained: reordering, unpinning, renaming and deleting. Hardcoding them would also make them undeletable, which is a regression for anyone who does not want one of them. Sent is different only in that its query CANNOT be stored: it is composed from every account's `sent` key, so a stored copy goes stale the moment a folder is renamed. That is a property of Sent, not of "default queries". Storing the GENERATOR rather than its output keeps both halves: `"generated": "sent"` still resolves from the accounts at click time, and the entry is an ordinary row that can be reordered, renamed, unpinned or removed. The row now follows one rule instead of carrying one member the user did not own. Two properties had to travel with the entry. The composed query, resolved through Config::resolvedQuery() so what lands in the bar is what actually ran; and FLAT mode, since a sent view lists messages and a threaded one folds every reply back into the conversation the user sent one message into. The sent generator implies flat rather than trusting the file to say so, because a hand-edited row would otherwise produce a threaded sent view. An unknown generator is reported but the row is KEPT: a later build may know it, and dropping it here would delete it from the file on the next save, which is the same data loss the unknown-field handling exists to prevent. A generator whose accounts configure nothing is skipped entirely, exactly as the hardcoded button was hidden rather than offering one that finds nothing. Eight new tests. The four pre-existing Sent tests reach this through migration and were left alone, which is what proves the migrated path still behaves; the new ones cover a STORED file, which is the path every launch after the first takes. Mutations: a generator resolving to nothing fails three, ignoring flat fails two, and not skipping an empty generator fails one. A rename test guards the property the change exists for, since anything keyed on the literal name "Sent" would break it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 daysfix(queries): put the Save query button beside the query barDanilo M.1-4/+5
The spec asked for "a Save query button beside the search bar" and what shipped was a menu entry and Ctrl+S. The user went looking for the button where the design said it would be and did not find it. Saving is a thing you decide on while looking at the results, so it belongs where the results came from rather than behind a menu or a remembered chord. The button takes the action through setDefaultAction rather than a second connect, so it inherits the text, icon, tooltip and enabled state and cannot end up offering to save an empty query while the menu entry correctly refuses. The mutation that replaces it with a plain clicked() connection fails the test. Also records item 82: a saved query cannot be edited, unpinned or deleted from the UI. Item 23 specified saving and nothing else, and that is exactly what was built, so the only way to unpin a query is a text editor or retyping it in full under the same name. An action that creates something the UI cannot then change or remove is incomplete, and this was found within minutes of the first hand test. It is filed as a defect rather than an enhancement, and the spec now says so where a reader would otherwise take the design for complete. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 daysfeat(queries): save a query from the UI, and split the buttons off the query rowDanilo M.1-0/+37
Second half of item 23, on top of the storage change. A query can now be kept without hand-editing a file, and the row of buttons no longer grows without bound. Ctrl+S opens a dialog on whatever is in the query bar, taking a name, an optional account scope and whether the query is pinned. It preselects the account already chosen in the dropdown, since that is the scope the user is looking at, and it says so when a name is about to replace an existing query rather than refusing the name: overwriting a saved query on purpose is a normal edit, and the only thing worth preventing is doing it without noticing. Saving over an entry keeps the stored entry's unknown fields rather than the dialog's fresh value, so a field written by a later build survives being edited here. The saved queries move to a row of their own beneath the query bar, pinned ones as buttons and the rest behind a More queries menu that only exists when something is in it. The ponytail note that stood in the query row predicted exactly this: an unbounded list of buttons sharing the row squeezed the field. Sent moves down with them and is still not a saved query, for the reason already recorded there. A saved query's account scope goes through the account DROPDOWN rather than being baked into the query text. runQuery() already wraps the query in the selected account's path, so pre-scoping here would apply it twice, and setting the dropdown also shows the user which scope they are in. An unscoped query clears the selection rather than inheriting whatever the last one left, which is the same defect the rules preview had. Seven tests, three mutations. Ignoring the pinned flag fails two of them, pre-scoping the text instead of setting the dropdown fails two, and letting an unscoped query inherit the previous account fails one. The menu-absence test initially passed against no implementation at all, since it only asserted a widget was missing; it now proves the row was populated first, which is the guard that class of test needs. Two existing invariants caught real omissions rather than needing adjustment: every registered action must appear in KeyMap::knownActions(), which is what gives it a configurable binding, and every action needs its own icon. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 daysrelease: 0.17.0v0.17.0Danilo M.1-0/+30
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 daysfeat(rules): preview a rule's mail in the thread listDanilo M.1-0/+6
Item 77. The dialog could say how many messages a rule matched and not which ones. A Preview in list button now runs the selected rule's query in the main window; the dialog stays open, since comparing the rule against its results is the point. Two constraints from the backlog entry, both now asserted and both mutation-checked. The query runs exactly as stored, with no tag:new and no wrapping parentheses. The post-new hook adds those when it applies a rule, and a preview that copied them would match nothing outside a sync window, since tag:new is set only on mail that has just arrived. The account selector is cleared first. runQuery() wraps the bar's text in the selected account's scope, and a rule query usually names its own path already, so previewing one with an account selected would scope it twice and show an empty list, which reads as "this rule collects no mail". The second mutation only fails once the test's config has an account to select: with the default empty config the selector sits on "All accounts" anyway, and asserting that a preview leaves it there passed against the mutation. Recorded in the test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 daysfix(rules): a long rule no longer squeezes the rule list awayDanilo M.1-0/+5
Item 80. A rule with eight From conditions left the list showing about one and a half rows. The list was added with stretch 1 and the form below it with none, which looks decisive and is not: a stretch factor only distributes space above each widget's minimum, and the form's minimum grew with every condition row, so each row came straight out of the list. The builder asked for 120px with one row and 414px with eight. A QSplitter now divides the list from the editor, so the balance is the user's and is saved beside the column widths, and the condition rows sit in a QScrollArea capped at 190px so the editor cannot grow without bound however the splitter is set. The scroll area is what text mode hides; hiding the builder inside it would leave an empty frame. Three measures were tried in the test before one told the bug and the fix apart, and two passed against broken code: the dialog's minimumSizeHint does not track form rows and read 580 either way, and a qMin against the scroll area's own hint read small whether or not the cap was set, since an uncapped maximumHeight is QWIDGETSIZE_MAX. What survives mutation is the editor pane's minimum inside the splitter, plus the cap read directly, and both are asserted. A row's size hint is invalid until the event loop runs, so the test calls processEvents after selecting a rule or it measures the same height twice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 daysdocs: the window size cannot be restored under a tiling compositorDanilo M.1-3/+5
Item 75 shipped claiming the rules window remembers its size. It does not, and no code here can make it. Hyprland tiles the window to fill its slot, so the size dragged belongs to the tile. saveGeometry stores frameGeometry beside normalGeometry and restoreGeometry restores the normal one, which stays at whatever resize() last set it to. Decoded from the real state file after a hand test: frame 2248x806, normal 760x664. The dialog restores 760 correctly and still opens tiled. Three diagnoses were tried before this one and each was disproved by a probe rather than argued away: that restoreGeometry rejected the blob as off-screen, that the layout overrode a geometry applied before the first show, and that a test could tell the broken and fixed versions apart. The last one matters most: the offscreen platform returns an identical frame for both, so a size assertion passed against the bug and a mutation restoring it left the suite green. That assertion is not reinstated. The column widths, which are what actually works, keep their test. The changelog and the backlog entry are corrected to say what ships, and CLAUDE.md gains both the tiling-compositor trap and the rule that the offscreen platform cannot test window sizing at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 daysfeat(rules): the rules window keeps its size and column widthsDanilo M.1-5/+10
Item 75. saveGeometry() and the rule list header's saveState() go to uistate.conf under keys of their own, written on closeEvent so a size survives Cancel as well as Save. The 760x520 resize stays as the first-run fallback. The backlog's approach was wrong on one point and a test caught it. It said to drop the resizeColumnToContents calls once a saved header state exists, which fixes the restore and leaves the original defect standing: with nothing saved, a width the user had just dragged was still discarded by the next add or delete. Each column is instead auto-sized once, on its first fill, after which the width belongs to the user however it was set. Two flags, because the count column is filled later by a reply from the worker. The window stays a QDialog. Making it a top-level window needs the unsaved-edit story that being modal currently sidesteps, and that is its own decision rather than part of this item. Both tests redirect XDG_STATE_HOME as well as XDG_CONFIG_HOME, so they cannot write the real uistate.conf. The geometry is asserted on the stored value rather than the reopened frame, per item 46: the offscreen platform does not honour a resize. Also corrects setFolders' doc comment, which still described the folder list as coming from Config. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 daysdocs: changelog for the rule builderDanilo M.1-0/+22
12 daysrelease: 0.16.0v0.16.0Danilo M.1-0/+10
12 daysdocs(backlog): record item 44 as doneDanilo M.1-0/+34
The tagging rules moved from the shell post-new hook to a shared JSON store both qtmaildir and mailctl read. Seventeen real rules were converted, each keeping its shell comment as a note, and the conversion was proved against the real index before anything was installed. Four findings are recorded in CLAUDE.md rather than only here, because they will outlive the item: a stored query carries no scope and the hook parenthesises it (a disjunction would otherwise escape tag:new and match everything); notmuch's parser rejects almost nothing, so a test asserting a provoked query failure fails against correct code; rule counts must count messages rather than threads; and a count request must not bump the query generation, which would blank the message pane.
14 daysfeat(panes): draw the pane marks from shipped SVGs, not font glyphsDanilo M.1-0/+8
Items 70 and 69, the second folded into the first as item 70's own size note predicted it should be. The panes drew their state marks as font glyphs: U+1F4CE for an attachment and U+2605 for a flagged thread, each with a fallback for a font that cannot render it. Both fell back to "*", so on such a font a flagged thread and one carrying an attachment were indistinguishable, which is a defect the fallback introduced rather than prevented. What a mark looks like was also the desktop's decision rather than this application's, and the panes are exactly where it should not be: the user asked for the toolbar and menus to keep following their icon theme while the panes stop. Six marks now ship in assets/icons/marks/: flagged, attachment, passed, replied and the two expander triangles. QIcon::fromTheme still resolves every toolbar and menu icon and was not touched. Licensing chose the shapes. The look came from a GPL3 icon theme, and this project is GPLv2-only, which are incompatible: GPLv2's "no further restrictions" clause bars shipping GPL3 assets in a v2-only work. The six were drawn fresh in the same idiom instead, with no path data copied. The idiom is generic: solid single-path silhouettes at 16x16 with no strokes. They are compiled in as string literals rather than loaded from a .qrc. src/CMakeLists.txt already records why resources belong to the executable: a qrc in the static library registers itself from a global initialiser the linker drops. The tests link the library, so a resource-based mark would be missing exactly where it needs asserting. assets/icons/marks/ stays the editable source. One asset serves both palettes. Every payload paints with fill="currentColor", which QSvgRenderer renders black rather than resolving, so Marks::pixmap composites the wanted colour with CompositionMode_SourceIn. A mark then takes the card's own pen colour and follows selection and the read/unread dimming without a second variant to keep in step. CardLayout reserves a rect per mark and CardDelegate paints into it. The marks were glyphs inside the subject STRING, so their width came free from the text metrics; as icons the geometry has to know they are there or the subject runs underneath them. The expander pill had the same trap, its triangle being a glyph in expanderLabel(), and now reserves that width explicitly. Item 69's part: passed and replied were words in the tag strip and are marks beside the subject now. The message pane's header carries the flagged and attachment marks next to the subject, per the user's decision that the right pane needs those two and only outside the message area. A duplicate that no test caught is worth recording. Every geometry assertion passed while a card showed passed as BOTH an arrow and a green tag chip: the chip filter had no reason to know a mark had appeared. It was found by rendering real cards to an image and looking at them. isDrawnAsAMark() is now one list consulted by both PillTagsRole and MessageOwnTagsRole, since two copies drifting apart is how a tag ends up drawn twice on one row and not at all on another. Fourteen tests: nine in test_marks, four in test_cardlayout, one in test_threadlistmodel. Mutation-checked at four points, each failing a test: the subject ignoring the marks, the flag not indenting the subject, the pill forgetting the triangle's width, and the recolour composite removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
14 daysfeat(sync): sync a tag change automatically after a short delayDanilo M.1-0/+18
Item 71. A tag edit reached the notmuch index at edit time and then sat there until the user clicked Sync or their cron job fired, so "mark all read" updated the view while the change itself waited, sometimes for ten minutes. A confirmed edit now arms a debounce that runs the existing sync path. The delay is auto_sync_delay_ms in [general], defaulting to 2000, and follows mark_read_delay_ms exactly, including that zero and negative are not errors: zero syncs on the next trip through the event loop, and any negative value disables the behaviour, which is the switch for a user who wants only their cron job. It is armed from onTagsApplied, where a write is confirmed and the pending count is already current, rather than where one is sent: a sync scheduled for a write the worker went on to reject would run for nothing. A debounce rather than a schedule, restarted by each edit, because "mark all read" confirms one write per thread in the view and an arm-per-edit timer would be the storm of syncs the debounce exists to prevent. Nothing is armed when no sync command is configured or when the pending count is zero, the case where an edit was netted against its own inverse. When the timer fires with a sync already running, local or cron, it skips rather than queues: mbsync's own answer to a second run is to fail on it, and the edits stay pending rather than being lost. Also fixes a pane blanked out from under the reader, found by hand testing this feature. onSyncFinished called runCurrentQuery() where the cron path calls refreshCurrentQuery(), and a re-run clears the model, the undo stack and the message pane. The stale-thread notice handles a thread that stops matching the query and has since item 35, but a re-run left nothing for it to describe. The two paths had no reason to differ; before this item a local sync only followed a click on Sync, so the difference went unnoticed. Reading a message in the Unread view, having it marked read, and watching the pane go blank two seconds later is what surfaced it. Its test asserts on the undo stack rather than the pane: both paths issue a queued query test_mainwindow has no worker to answer, so the pane ends up blank either way and an assertion on it would pass against both, while the undo stack is cleared by one and kept by the other. Nine tests, four in test_config and five in test_mainwindow, each mutation-checked: removing the schedule call, honouring a negative delay, dropping the nothing-pending guard, dropping the already-running guard, and restoring runCurrentQuery() each fail a test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
14 daysrelease: 0.15.0v0.15.0Danilo M.1-0/+29
14 daysfeat(placeholder): count sent mail and drafts on the blank paneDanilo M.1-0/+6
Item 67. The pane counted unread, flagged and inbox from three fixed tag: queries. Sent and drafts cannot join that list as tags: tag:draft counts 0 against a real database and no draft-ish tag exists in it at all, so a tag-based line would be a permanent zero that reads as working code. Both are composed from each account's folder keys instead, the same way the Sent view already composes its query. The drafts key was parsed and documented as unused in v1. Composing drafts is still v2; counting them is not, so Account::draftsQuery() and Config::allDraftsQuery() now mirror the sent pair. The shared body moved into folderQuery() and joinAccountQueries(), so the load-bearing quoting (a provider nests both folders under a bracketed parent, and [ and ] are Xapian syntax) and the bare-"or" guard exist once rather than once per folder type. The fixed array is gone rather than extended. It held queries and labels in two lists indexed in parallel, which is a hazard that grows with the list: an entry inserted in one and not the other prints a real number against the wrong name and looks entirely plausible. placeholderLines() carries each query beside the callable that labels it, so the two cannot drift, and the count reply stays paired by position as the worker requires. A line is omitted when no account configures that folder rather than shown as 0, following item 63: a missing folder is a real configuration, and "0 sent" claims the user has sent nothing. Measured against the real config: 4 sent terms over 601 threads, 5 drafts terms over 3, the extra drafts term coming from the one account that configures drafts and no sent, which is what proves the two are collected independently. Four tests here and four in test_config, mutation-checked at three points: dropping the drafts line, an off-by-one in the label pairing, and removing the -1 guard for an uncountable query. Each mutation fails a test.