aboutsummaryrefslogtreecommitdiffstats
path: root/docs
AgeCommit message (Collapse)AuthorFilesLines
10 daysdocs(backlog): settle item 91's open question, postpone item 92Danilo M.2-62/+82
91 needed one decision, how the user leaves a drill-down, and the answer is that nothing is built for it: "I didn't think of a back action, usually I'd go back to a known list like unread or inbox at that point." The filter buttons already are that and are one click away in every view, so the Back action, the history stack and the restore-the-previous-query scheme are all unnecessary. That also retires the undo-stack concern: a drill-down clears the stack exactly as a typed query does, which is the behaviour the user already expects from the query bar. The item is now fully specified and ready to build at S. 92 is postponed at the user's request: "I don't see the utility, so I don't really know how to answer." The clarification that preceded it named the per-message version, which needs provenance nothing records, costs a format change across both repos against a hook running on real mail every ten minutes, and stays blank on every message already tagged. A feature whose requester cannot say what it is for should not be built. The cheaper substitute is recorded beside it for if the question ever turns out to be "why does this message carry this tag", which rules.json can answer at read time with nothing stored. Section moved to the closed-items file on this commit, per CLAUDE.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 daysdocs(backlog): specify items 91 and 92 from the user's clarificationsDanilo M.1-35/+72
Both were "open, unspecified" and could not be planned from the backlog alone. 91 is not a second window. The user meant a drill-down to the selected thread or message, the same result an `id:` query gives, reached by double-clicking instead of typing. That removes the constraint the entry was mostly made of, since no second QWebEngineView and no extra render process are involved, and takes it from ? to S. What is left to decide is how the user gets back, and that runQuery() clears the undo stack, which a gesture-triggered query would do silently. 92 is the expensive half of the two it held: the user wants a per-message hint, rule-written against hand-applied, and doubts its utility in the same sentence. Nothing records that today, so it is a format change across both repos before it is any pixels, and it stays blank on every message already tagged. Recorded beside it is the cheaper question that may be the real one, "which rule would tag this message", answerable from rules.json at read time with no stored provenance and no hook change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 daysfix(sync): send held edits before the sync-end refresh reads the databaseDanilo M.2-0/+44
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.2-60/+81
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.2-1/+50
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 daysfeat(i18n): wire translations and ship an Italian one (item 22)Danilo M.3-43/+256
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>
11 daysdocs: close items 93 and 95, record their trapsDanilo M.2-39/+97
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.
11 daysdocs: open item 94, dropping pinned once the buttons are built-inDanilo M.2-0/+61
The user's end state for the query row is built-in filters only, with every saved query living in the menu. At that point SavedQuery::pinned has nothing left to decide. Blocked on 93 and deliberately separate from it: the four buttons have to be lived with first, and if one of them is wrong, pinning is the escape hatch, which has to still exist to be used. Recorded as a user-visible removal rather than a cleanup. pinned shipped in 0.18.0 as a checkbox in SaveQueryDialog and a right-click action, so removing it is a minor bump with an Upgrading note. The stored field is a separate decision from the UI, and leaving it in queries.json unread is both cheaper and reversible. The spec for 93 gains the ordering rule this resolves: filters first in fixed order, the user's pinned queries after them, and nothing configurable, since the mixed row exists only until 94 lands.
11 daysdocs: spec built-in filters as item 93, fold item 90 into itDanilo M.3-51/+278
Explaining item 90 to the user produced a reframing rather than a fix. The buttons and the "more queries" menu are two different kinds of thing sharing one mechanism: a filter narrows whatever the user is looking at and should compose with the account dropdown, while a saved query is a self-contained destination entitled to set the account itself. Nothing ships as a default today, so the buttons are whatever the user pinned, which the queries.json migration did to every [queries] entry. That drift is the defect. Item 93 ships four built-in filters, Unread, Inbox, Flagged and Sent, as generated entries in the closed kQueryGenerators set that already exists for Sent. The user's own pinned queries are unpinned rather than deleted once the buttons are confirmed working. Three findings from reading the code, all in the spec. A generator must answer per account rather than having its all-accounts query wrapped in a scope, or Sent becomes path:"a/**" and (path:"a/Sent/**" or path:"b/Sent/**"), which returns the right rows only because path: is hierarchical. Sent is flat and the other three are not, so the four match in scope and not in view mode. And m_accountBox has no signal connected to it, which is now a decision rather than an omission: changing the account runs nothing, the button is the verb. Item 90's section moves to the closed file, kept in full because its cause and the rules preview that motivated the reset are still true of the code.
11 daysfix(status): count threads as they arrive instead of "Searching..."Danilo M.2-46/+56
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: reconcile the backlog with the user's notes, open 89 to 92Danilo M.1-1/+164
The 2026-08-15 pass over ~/Documents/Obsidian/note/notes on qtmaildir.md found four entries with no item here, and one already-closed item with a case that was never verified by hand. 89: runAutoSync() returns without re-arming the timer when a sync is already in flight, so an edit made after the running mbsync passed that account waits for a manual sync or cron. The second half of the same complaint, rows leaving the list mid-interaction, is a design question and is recorded as one rather than guessed at. 90: runSavedQuery() clears the account box for a query that names no account. The line is deliberate and its comment says why, so this needs a decision rather than a fix. 91 and 92 are unspecified, as the notes themselves say. Item 66's row gains the one case its fix should already cover: a single-message id: query whose card would not open is the same empty MessageIdRole failure, unverified against an id: query.
11 daysdocs: close item 66, open 87 and 88, record the row-number trapDanilo M.2-63/+124
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 36, narrow item 66 with a negative resultDanilo M.2-44/+67
Item 36's section moves to the closed file and its three traps go into CLAUDE.md, which is where they will be read: the worker is unreachable by findChild, rowCount on an unexpanded thread row is 0 by design, and currentThreadId reports intent rather than content. The claim that this class of bug cannot be reproduced in test_mainwindow is now false, so it is corrected rather than left standing beside its replacement. One in-test reference to item 36 as a permanent limitation is reworded: bare-window cases still have no worker, but that is now a choice per case rather than a property of the binary. Item 66 stays open with the simple case ruled out. The negative result sharpens this entry's own candidate rather than contradicting it: the test drives setCurrentIndex, which updates the selection model synchronously, while the suspect guard turns on a real click not having done so yet. Two cheaper conditions are named as still unexcluded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 daysdocs: close item 84, spec item 36Danilo M.3-64/+216
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.2-33/+40
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 daysdocs(plan): six-task plan for item 86, excluding from a searchDanilo M.1-0/+817
Ordered so each task leaves the suite green: the grammar first, then the SearchMode enum alone, then the four signatures as one mechanical change with no behaviour difference, then the guard, then the menus. Two traps carried from item 85 into the task that inherits them. The details dialog's mutation check hangs rather than fails, and a hung binary is re-run stale by a later ctest. The mutation revert is by hand, never from a backup file, which silently reverted a fix last session. Construction order verified rather than left to the implementer: registerActions() runs after buildUi(), so both pointers are live and no null guard belongs in the lambda. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 daysdocs(spec): design for excluding a value from a search, item 86Danilo M.2-31/+194
Two decisions the user made, both recorded with their rejected alternatives so they are not revisited. Excluding from an empty query would mean the whole Maildir minus one value. The menu entry is greyed rather than hidden when the query bar is empty, so the feature stays visible to someone exploring a fresh window, and SearchTerm::exclude returns empty for that case as a second layer against a caller that forgets the guard. The menus cannot see the query bar, so MainWindow pushes the fact down through MessageView::setHasQuery from the textChanged lambda it already runs for the Save button. A callback was rejected as an indirection with one implementation; silently doing nothing was rejected because a live menu entry that does nothing is worse than a greyed one. The backlog entry loses its inline approach and points at the spec, carrying the three constraints that decide whether it can be picked up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 daysdocs(backlog): record item 86, excluding a value from a searchDanilo M.1-0/+54
The 2026-08-14 reconciliation against the user's notes found one entry with no item here: the right-click search offers "search for this" and "add to search" but no way to add negatively. Cause verified in the code rather than copied from the note. Item 85 shipped the two operations as a single bool, built identically in messageview.cpp:566 and messagedetailsdialog.cpp:92 and branched on in mainwindow.cpp:1654, and SearchTerm has no exclusion form at all. So this is not a missing menu entry over an existing capability; there is no third state for an entry to select. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 daysdocs: close item 85, searching from the message paneDanilo M.2-54/+97
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 daysdocs: fix a weekday in the plan's date fixturesDanilo M.1-3/+9
Qt::RFC2822Date validates the weekday against the date, so 'Thu, 14 Aug 2026' parses as invalid: that day is a Friday. Task 2 hit it and Task 6 carried the same wrong data. The failure is indistinguishable from the timezone-comment trap the date parse exists to handle, so the plan now names it.
11 daysdocs: plan item 85 as eight tasksDanilo M.2-8/+1993
Eight TDD tasks. SearchTerm and the date parse first, since both are pure logic testable without a widget; then one surface per task, the window's wiring, and the suite check. Two findings while planning, both now in the spec. The Date: header parse already exists inside a file-local function in mimeparser.cpp, complete with the fix for Qt::RFC2822Date rejecting a trailing timezone comment, so it is extracted rather than rewritten. And queryRequested is the right precedent but the wrong signature: these actions carry a replace-or-narrow flag, so a second signal sits beside it and the placeholder links keep their gate.
11 daysdocs: design searching from the message pane as item 85Danilo M.2-1/+263
Item 78 asked for a tagging rule built from something visible in a message. Brainstorming narrowed it: a saved query can already be promoted to a rule, so the road from "I see something interesting" to "a rule tags it" exists as search, save, promote. Searching is the missing step and the safe one, since a query costs nothing when it is wrong while a rule runs unattended against real mail. The search half splits out as item 85. Five surfaces gain a context menu with Search for this and Add to search: subject, date and From/To/Cc in the header, tag chips, body selection, and every header per message in the details dialog, which is rebuilt as rows rather than one text box. Item 78 stays open carrying the rule shortcut alone.
11 daysdocs: record the modal that hangs test_mainwindow as item 84Danilo M.2-3/+60
Cause verified by attaching gdb to the hung process rather than inferred: showWarnings() raises QMessageBox::warning from the MainWindow constructor, and nothing offscreen can dismiss it, so any config problem in a test's fixture blocks the constructor forever. Not a defect in the application. The modal is right for a person and the code says why; the defect is that a test cannot dismiss it and the resulting failure is a silent hang rather than an error naming the cause. Corrects item 81's closing note, which blamed the missing maildir key itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 daysdocs: close item 81, saved query to tagging ruleDanilo M.2-28/+45
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 daysdocs: plan item 81, saved query to tagging ruleDanilo M.1-0/+832
Six tasks, TDD, one commit each. Two things the spec did not anticipate and the plan has to carry: the rules dialog is non-modal and single-instance, so a second request must seed the dialog already open rather than being dropped, and the seeded id is uniqued against the working list rather than the file, since that list can hold unsaved rules whose ids collide just as hard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 daysdocs: spec turning a saved query into a tagging ruleDanilo M.2-38/+150
Item 81. A context-menu action on a stored saved query, seeding the rules dialog with the query and a sanitised id, tags left empty and focused. Three decisions worth the record. The backlog's own proposal, a checkbox in the Save query dialog, is rejected: it would make one dialog write both queries.json and the shared rules.json, and SaveQueryDialog is deliberately pure UI that writes nothing. Generated entries are excluded, since their query is composed from the accounts at runtime and a rule made from one would freeze a snapshot that goes stale when an account is added. And the empty tags are load-bearing rather than an omission: validate() refuses a rule that tags nothing, so the one field the user must supply is the one the dialog opens on. This also turns out to be a single-repo change. The rule it creates is an ordinary one, so mailrules.py is untouched; the backlog's note that item 81 spans two repos was about the file it lands in, not the work. Item 78 becomes a second caller of the same seeded-dialog path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 daysfix(rules): stop a rule with a spaced name from vanishing on saveDanilo M.2-108/+171
A rule named "justeat orders" in the field labelled Name was written to rules.json correctly and then dropped by every reader, because load() required ^[a-z0-9][a-z0-9-]*$ and the save path validated nothing. The rule stayed in the file, invisible in the dialog, never applied by the post-new hook, and the next save from the dialog would have deleted it outright. The asymmetry was the defect, not the pattern. TagRules::validate() is now the single predicate: the dialog refuses to save against it, and load() uses it to repair rather than drop, so a rule that fails is visible and fixable instead of silently discarded. - The typed name is sanitised into an id when the field is committed, so the field shows what will reach the file. uniqueId() suffixes a collision, since sanitising is many-to-one and can manufacture the duplicate that load() then drops. - An already-legal id is never rewritten, including one like "a---b" that sanitising would otherwise collapse. Rewriting valid ids would churn a file mailctl also reads. - A bad id loads repaired, with the warning kept: what is on disk is not what the hook runs until the file is saved back. Deliberately not mirrored into mailrules.py. The hook tags real mail unattended every ten minutes, where silently renaming an id is worse than dropping the rule; the file converges as soon as the dialog saves. No format change, so no version bump and no two-repo commitment. The load warning was not missing: it had been showing "1 rule could not be read and was skipped" on every open, in the same font and colour as the intro prose two lines above it, and read as more explanation. It is now a red banner beside Save, with an icon and a dismiss button, and it says the rules need attention rather than that they were skipped, which is no longer true. Dismissal is per-appearance only; a persistent one would re-hide the problem that went unnoticed for a session. Both new dialog tests were confirmed to fail with the sanitiser reverted, and the banner's styling, position and dismissal each fail under mutation. 20 of 20 suites green, 34 tests in test_tagrules. Closes item 83. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 daysfeat(queries): edit, pin and delete a saved query from the UIDanilo M.1-1/+25
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 daysfix(queries): put the Save query button beside the query barDanilo M.2-0/+55
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 daysdocs: spec the saved-query file, and reduce item 23 to a pointerDanilo M.2-105/+228
Item 23 had grown past what a backlog entry should hold: a storage format, a migration, a dialog and a layout change. This document's own rule says a fully specified item moves to specs/ and leaves behind the two or three things that decide whether it can be picked up, the way items 53, 63 and 76 went. The entry now carries the observation, the three deciding constraints and the relation to item 10, and points at the spec for the rest. The spec pins what was still loose. The JSON is an ordered array, since the ordering is the whole reason for moving off [queries], and nothing may sort it on load. A query's account scope stores the account KEY, the INI group suffix, rather than the maildir path, so it does not duplicate config that already lives in one place and go stale when the user edits it; the scope then composes through Account::scopedQuery(), whose parenthesisation is load-bearing for the same reason it is in the rules hook, an unparenthesised disjunction escapes its scope and matches every account. Migrated entries are pinned, so the query row does not silently empty on the first run after upgrade, and migration order is alphabetical because that is genuinely all the INI knows. Sent stays out of the file: it is generated from allSentQuery() rather than stored, and folding it in would mean writing a per-account path query into stored config, which is the duplication the account-key decision just rejected. The testing section is written against the traps already recorded in CLAUDE.md. The migration test asserts the INI file is byte-identical rather than re-reading it through QSettings, which would pass against a rewrite that preserved values while dropping comments; the round-trip test asserts order, which is the property the INI could not provide; and the dialog is left to a hand test, because the offscreen platform cannot assert sizing at all and a Cancel goes through done(int) rather than closeEvent. Every code reference in the spec was checked against the files rather than copied from the entry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 daysdocs: move item 23's saved queries to a JSON file of their ownDanilo M.1-20/+72
The user proposed managing saved queries the way the tagging rules are managed, in a JSON file rather than in the INI. It is a better answer than either option the entry had been weighing, a per-query pinned flag or a [general] pinned_queries list, because those each solved one problem and this solves three. Order is the one that could not be solved any other way. [queries] is read through childKeys(), which returns keys alphabetically rather than in file order, so the saved-query buttons appear alphabetically today and there is no way to arrange them; config.cpp already carries a comment saying a hand-rolled parser would be needed to change that. A JSON array is ordered intrinsically. On top of that the document has room for the pinned flag the two tiers need, and for the per-account scope the save dialog wants, which SavedQuery has nowhere to put: it is {name, query} and nothing else. The entry takes the shape of rules.json but explicitly not its machinery. rules.json is JSON because two independent implementations have to agree on it, this repo and mailctl's mailrules.py, and the unknown-field preservation and version handshake exist to keep them from destroying each other's writes. Queries have one reader, so only the versioned-document-with-unknown-fields part is worth carrying over. Migration reads [queries] once when queries.json is absent, writes the JSON, and leaves the INI section in place. Stripping it would mean rewriting a hand-edited file with QSettings, which drops comments and key order across the whole file and is the exact loss this decision was made to avoid; leaving it costs a few stale lines and keeps a downgrade working. Reading both forever was rejected as two sources of truth for one thing. This also retires the open question the entry had carried since 2026-08-04, where the write should go. Both of the original answers were poor, one machine-writing the user's hand-edited config and the other filing user intent as window state under ~/.local/state. A machine-written JSON document beside the hand-written INI is the cleaner split, in ~/.config so it lands in a config backup. Two constraints recorded that the build would otherwise meet late. startup_query names a saved query and must keep resolving, and its "first entry" fallback quietly changes meaning from alphabetically-first to first-in-the-user's-order, which is user-visible and belongs in the changelog. And the README documents [queries] in three places, one of which explains the alphabetical button order this change removes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 daysdocs: record the user's design for item 23, and split item 81 out of itDanilo M.1-19/+91
Item 23 said only that a query could not be saved from the UI, and left the presentation as a one-line sketch. The user described what they actually want: a Save query button beside the search bar, opening a dialog that takes a name and an account scope; saved queries split into two tiers, with a few kept visible as buttons and user-made ones behind a menu; and the button row moved onto a row of its own once it no longer holds everything. Two things came out of writing it down. There is no built-in default query set in the code at all: every entry in [queries] is user-written and renders identically, and Sent is the lone exception because it is built from allSentQuery() rather than living in [queries]. The two tiers therefore need a mechanism that does not exist yet, either a per-query pinned flag or a [general] pinned_queries list, and that config format choice is the one decision left on the item. The design also settles the question the entry had left open: a query the user named and scoped in a dialog is intent rather than machine state, so it goes in qtmaildir.conf beside the hand-written ones, at the cost of QSettings reformatting a hand-edited file on first save. The README has to say so. The save-as-a-filter half is split out as item 81. A saved query is a view and costs nothing if it is wrong; a rule is applied to real mail by the post-new hook every ten minutes and lives in the rules file that this repo and mailctl implement independently. Folding it into 23 would make a presentation change carry a two-repo commitment, so 23 can now ship without it. Item 81 records the constraints it will hit: a stored query carries no scope and the hook parenthesises it, which matters more here than usual because a query saved for a view is often a disjunction, and the hook refuses to remove unread or inbox, so the dialog must say so rather than failing silently. Item 23's relation to item 10 firms up as a result. Account scope in the save dialog answers item 10's remaining half as a side effect, so the entry now says that outright, and says not to reopen item 10 to do it: the user postponed it and asked that the rest not be proposed unprompted. Also corrects a stale reference. The entry claimed a SavedQueryBar class shows the saved queries; no such class exists or ever has, and the buttons are built inline in MainWindow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 daysdocs: split the closed backlog items out to a companion fileDanilo M.3-4511/+4561
Item 73. The backlog kept every item's full Observed/Cause/Approach section forever, including the sixty-eight that are closed, and had reached 5056 lines: past the point where it could be read in one pass, and past the point where a tool could open it at all. The closed sections move to 2026-08-03-post-0.1.0-usability-closed.md, taking the backlog to 570 lines. The status table stays where it was and remains the index of all 80 items, so a closed item keeps its row, its date and its outcome beside the open ones; only its evidence moved. Nothing was renumbered and nothing was deleted, which the item required: the numbering is cited from commit messages, from CLAUDE.md and from the specs, and both files share one sequence, so item 42 is `## 42.` in whichever file holds it. The split was done by script and verified by set difference rather than by reading: every non-blank line of the original appears in one of the two files, zero missing, and the only lines not in the original are the new file's header. All 80 numbers resolve, every open item has its section in the backlog, every closed one in the archive, with no duplicates and no orphans. Two things the item's own approach did not anticipate. Three cross-references said "see below" and their targets had just moved, so rows 60 and 75 and the header's note on item 20's parked branch now say where the entry went. And the cause was never the fifty done sections, it was that nothing moved a section on the day its item closed; doing this once buys a few months and then item 73 returns. The rule in "Adding to this document" now requires the move on the closing commit, and CLAUDE.md tells a future session that grepping the backlog for a closed item's evidence will find the table row and nothing else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 daysfeat(rules): preview a rule's mail in the thread listDanilo M.1-1/+13
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/+34
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-4/+23
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 daysfix(rules): save the window size on Cancel and Save, not only on XDanilo M.1-0/+12
The geometry was saved from closeEvent, and neither dialog button sends one: Cancel calls reject(), Save calls accept(), and only the window manager's X button produces a QCloseEvent. So the size and the column widths were kept for the one route out of three that a user almost never takes, and a resize followed by Cancel came back forgotten. The save moves to a done(int) override, which both buttons funnel through and which QWidget::close() also reaches. The test that covered this passed against the bug because it asserted with close(). It now drives all three routes rather than trusting one to stand for the others, and shows the dialog before the close leg: close() on a widget that was never visible returns early without reaching done(), so that assertion would otherwise prove nothing. Both traps recorded in CLAUDE.md, since neither is specific to this dialog. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 daysfeat(rules): the rules window keeps its size and column widthsDanilo M.1-2/+20
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 daysfix(rules): keep the text-mode toggle reachableDanilo M.1-2/+15
Ticking "Edit as text" was a one-way trip: the only way back to the rows was closing the dialog and reopening it. The checkbox was parented to the builder widget and sat on the match row, and switching to text mode hides that widget, so the toggle disappeared along with the rows it governs. Move it to the query row, which is visible in both modes. The existing tests all passed against this, because they drove the toggle through setChecked and then asserted on the checked STATE. A hidden checkbox reports its state perfectly well, so every one of those assertions held while the widget was unreachable. The new test asks the question that matters, whether the toggle would be on screen, and it uses isVisibleTo since nothing is isVisible on a dialog that was never shown. Worth recording how close the mutation check came to endorsing this too. Reparenting the checkbox alone left it in the query row's layout, so it stayed visible and the test still passed. Only restoring the full shipped shape, parent and layout together, reproduced the fault and failed the test. A mutation that does not reproduce the original bug proves nothing about the test that is meant to catch it. The spec's layout sketch carried the same error and is corrected, with the reason, so the next reader does not reintroduce it.
12 daysdocs: the data-loss defect took the note tooDanilo M.1-2/+10
The first pass of the field repair restored the query and the tags and stopped there. The note was also blank, which the user noticed: every sibling account rule carries an identical note and only the damaged rule had none. The reason it was missed is worth keeping. The shell backup was read for the tagging command, and the note comes from the comment block above it, which the migration had given to all five account rules alike. The handler at fault writes every field of a rule, so every field is equally exposed, and a repair that checks only the fields that first drew attention will leave some of the damage in place. Restored from the four siblings, which are byte-identical, and the whole file re-audited: no rule now has an empty query, id, note or tag list.
13 daysdocs: record the rules dialog data-loss defect as item 79Danilo M.1-0/+52
Opening the tagging rules dialog and pressing Save destroyed the first rule in the list, without any editing. The rule lost its query and its tags, then vanished entirely on the next load, since a rule with an empty query is dropped as malformed. Reproduced against the released tag rather than the branch, in a throwaway worktree at 9585674 with a two-rule fixture: constructing the dialog and running its save path left one rule of two. onSelectionChanged blocked signals for the note widget only, while m_enabled::toggled two lines later reached applyEditsToCurrentRule, which writes every field from widgets the loader has not filled yet. The existing comment there shows the hazard was known for one widget and not extended to the other. The fix landed with the builder work: the reloading flag now covers the whole load, and switchingRulesDoesNotLeakRowsBetweenThem is the regression test, verified by mutation to fail without the guard. The live rules file had one casualty, the account rule sitting first in the list, with both its query and its tags empty while every sibling was intact. Restored from the shell backup that the earlier migration kept and verified through mailctl's own reader. The rule had stopped tagging, but only one message had arrived meanwhile; that message is now tagged and the account is complete again at 14969 of 14969.
13 daysdocs: correct the claim that a paren-bearing value is unrepresentableDanilo M.2-9/+33
The spec listed from:(((( among the queries the parser must reject, and the plan's Task 6 asserted that rejection. Probing the built parser shows it accepts the query as a From row whose value is the literal text, and compiles it back byte for byte. That is correct behaviour, not a leak in the strictness rule. notmuch reads those parens as characters to search for rather than as grouping, so the query is meaningful and the row displaying it tells the truth. Rejecting it would buy nothing and would push a representable rule into text mode. The distinction the documents were missing: a parenthesis inside a VALUE is not a shape question at all, only a parenthesis in grouping position is. Restate both documents accordingly, and replace the assertion with a round-trip one, which is the property that actually matters here.
13 daysdocs,rulequery: state the tag quoting rule rather than the testDanilo M.1-2/+16
The draft compile() quoted every Is/IsNot term, which contradicted the same task's own assertion that a negated tag compiles to . The implementer resolved it in the direction the tests specify, and the resolution is right: notmuch reads tag:inbox and tag:"inbox" identically, counting 5322 either way against the live index, so quoting a tag would change the stored string without changing what it matches. That breaks the byte-for-byte round trip this type exists to guarantee. Restate the comment as the rule rather than as a note about what a test expects, correct the plan's draft so the remaining tasks do not inherit the contradiction, and warn the parser task that a quoted tag must not be read back as a quoting operator.
13 daysdocs: implementation plan for the rule builderDanilo M.1-0/+2054
Twelve tasks against the design approved today, TDD throughout: RuleQuery comes first as a value type with no widget dependency, tested on exact strings, and the dialog is wired to it only once parsing and compiling round-trip. Two tasks carry the guarantees the design was shaped around rather than merely testing behaviour. Task 7 round-trips every query shape present in the live rules file and pins it with a mutation check, since a compile that differs by one paren would rewrite a file a second tool reads. Task 6 pins whole-query rejection, because a parser that salvages the part it understands is how a not clause goes missing and a live mail filter silently widens. Queries in the tests are generic placeholders. The shapes are what is under test and they survive substitution intact.
13 daysdocs: record items 75-78 and design the rule builderDanilo M.2-0/+512
The standing backlog reconciliation found four unrecorded entries in the user's notes, all fallout from item 44's rules dialog now that it is in daily use: the window forgets its geometry and column widths (75), every field is free text (76), a rule cannot be previewed against the thread list (77), and there is no way to build a rule from something visible in a message (78). Each cause is verified in the code rather than copied from the note. Item 76 then went through a brainstorming pass and has a design. The shape is Thunderbird's filter window, which the user supplied as the reference: field and operator dropdowns, +/- buttons per row, an all/any radio, and a separate "but not" block. The structural point is that Thunderbird owns its filter format and this project does not. The storage is a notmuch query string shared with mailctl and executed by the post-new hook, so the builder is a view over a string rather than a store. That decides the rest: the stored format is untouched and this stays a single-repo change; a query the builder cannot represent still opens, saves and runs, in a text mode every rule carries; and the string is rewritten only when the rows actually changed, compared against the parsed value rather than tracked with a dirty flag, which Qt sets during programmatic population. Measured against the seventeen rules in the live store, sixteen are flat and one nests an or group inside an and chain, which is what the exclusion block exists for. The parser is strict by design: it recognises a query whole or rejects it whole, because a lenient parser that salvages what it understands is how a not clause gets dropped and a filter silently widens.
13 daystest(mainwindow): stop the suite reading the real kernel lock tableDanilo M.1-1/+27
Item 61. An init() fixture gives every test its own empty lock table in a QTemporaryDir, so no test observes the machine's real sync state. The failure was never intermittent in the usual sense: 0 failures in 30 runs with no lock held, 30 in 30 with one held. It presented as three tests failing that never mention syncing, and cost three misdiagnoses. The three tests that already used the seam each restored "/proc/locks" when finished, which was itself the defect: it handed the real table to whichever test ran next, so one test opting in re-exposed all the others. Those restores are gone and cleanup() leaves the temporary path in place. noTestCanSeeTheRealLockTable guards the fixture, since a silent revert would go back to failing for reasons no assertion mentions. Verified with the lock deliberately held: 3 failures before, 119/119 after, full suite 19/19. Mutation-checked by disabling the fixture, where the guard fails first and a real test fails behind it.
13 daysdocs(backlog): record item 44 as doneDanilo M.1-1/+45
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 daysdocs(plans): implementation plan for the shared tagging rulesDanilo M.1-0/+2955
Fifteen tasks across two repositories, TDD throughout. Tasks 1-9 build the format, the post-new hook and mailctl's read-only rules command; 10-13 add qtmaildir's reader and dialog; 14 migrates the real rules and 15 closes the backlog item. One deviation from the spec, recorded at the top of the plan: the spec called for a countRules worker slot, but requestCounts already exists and counts threads. A tagging rule tags messages, so the plan adds a general requestMessageCounts instead of a rule-specific slot.
14 daysdocs(backlog): specify item 44 as a shared tagging-rule storeDanilo M.2-23/+432
Item 44 sat as "open, unspecified" because nothing in this application applies rules at sync time, and the item could not be planned until it was known whether such rules existed anywhere. They do: the notmuch post-new hook holds hand-written `notmuch tag` lines scoped to tag:new, carrying their reasoning in shell comments. The design moves them to a tool-neutral JSON store that both qtmaildir and mailctl read, with unknown fields preserved across a write by either tool so neither owns the format. A rule carries no scope, so the same rule serves the hook, a dry run and a future backfill. Also in this pass: - Item 61's cause is established, not open. It is the user's cron sync holding the mbsync lock: 0 failures in 30 runs with no lock held, 30 in 30 with one held. The fix is item 38's existing seam applied across the suite. The document still said "not established" and proposed a load hypothesis that had already failed to reproduce. - Item 74 records the first-start latency measured this session. The delay is the notmuch index paging in from disk, 5714 ms cold against 154 ms warm for the same 4444-thread query, and is not addressable here. What it did expose is a real defect: the status bar holds "Searching..." for the whole walk while rows are already arriving.
2026-08-11feat(panes): draw the pane marks from shipped SVGs, not font glyphsDanilo M.1-2/+53
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>