| Age | Commit message (Collapse) | Author | Files | Lines |
|
Its section moves to the closed file on this commit, with the outcome
recorded: MaildirName::resolveRenamed() wired into all three read sites,
the two deliberate refusals (ambiguous match, genuinely missing file)
and why each has a test, and the note that forDraft() must seed
draftPath from the resolved path or the fork simply arrives one step
later.
The stable-Message-ID question is recorded as left undecided rather than
quietly dropped: it is what turns a stale path into two server-side
messages rather than one replaced file, and a draft's id is not yet the
sent message's id, so it wants its own item.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUQS6n3cmsFrsjCNmwNtf8
|
|
Item 162 is done and its section moves to the closed file on this
commit, with the outcome recorded on it: moveMessages() re-resolves by
message id when the recorded path is gone, and the hold candidate was
investigated and rejected because aSyncHoldsTheWriteLock() guards
notmuch's lock while the window sits between mbsync's rename and that
sync's notmuch new.
Item 163 gains a second site, found while hand-testing 164 and worse
than the one it was filed for. openComposerFor() passes ref.filePath to
forDraft(); after mbsync renames the file the parse fails and the reopen
is refused BEFORE any composer exists, so composing again starts fresh
with no previous path to unlink. The old revision survives, each save
mints a new Message-ID, and both revisions reach the server. The unlink
machinery is entirely correct and never runs. Its heading and row now
name both sites, and the note that 162's fix would cover it is removed:
that fix re-resolves inside the worker, while these hold a stale path in
the UI.
Item 164 gains the reproducer's findings. Seven variants in throwaway
databases establish that index_file applies no tags, that the carve-out
strips inbox correctly in every filename shape and ordering tried, and
that the only reproduction is a pass applying inbox while tag:new is
already spent. The trigger is still not established, and the entry says
so: the live log shows the hook ran on the affected pass and logged
success, which the new match-count instrumentation will disambiguate on
the next occurrence.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUQS6n3cmsFrsjCNmwNtf8
|
|
File, Edit and Format, to the scope the user chose. Save draft (Ctrl+S)
is the only new action: saveDraftNow() was reachable from the autosave
timer, the send path and closeEvent, so there was no way for the user to
ask for a save. It routes through that same function, which is what
emits draftSaved for item 158's indexing, reports through item 160's
status bar and raises the failure banner; a second write path would have
to repeat all three.
The menus show the toolbar's own QAction objects rather than copies, as
item 140 required for the message pane's bar. Two needed hand-building.
The HTML toggle is a QToolButton and cannot go in a menu, so a checkable
twin mirrors it in both directions, since a menu entry that only follows
the button is half a control. The signature entry takes the switch's own
QMenu pointer, because that menu is rebuilt whenever the signatures
change and copied entries would go stale.
Edit's entries drive QPlainTextEdit and follow its own undoAvailable and
copyAvailable, so a greyed entry tells the truth about what pressing it
would do.
theMenuBarReachesEveryComposerAction() is item 132's reachability rule
applied to the composer: it walks the real menu bar and collects the
composer's actions with findChildren, so an action added to the toolbar
and forgotten in the menus fails without the test being touched. It
skips actions owning a submenu, since Qt emits no triggered for those.
The composer's actions stay out of KeyMap, per item 148: they are
parented to this window, so they are WindowShortcuts dispatched to the
active composer and the main window's namespace is untouched.
lrelease reports 496 finished, 0 unfinished.
Closes item 161.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUQS6n3cmsFrsjCNmwNtf8
|
|
Autosave worked and said nothing on success. The only feedback was
m_banner, which is the failure channel and whose persistence is
load-bearing for the quit path, so success got its own channel rather
than sharing one.
The fix is a funnel, not a label. m_dirty had seven writers, four of
which clear it and only two of those are a save: the constructor clears
it because seeding is not an edit, and the send handler clears it
because the message is gone. A cue hung off saveDraftNow() would have
been silently wrong in both. setDirty() is the only writer now, and it
refreshes the status cue and setWindowModified() together so neither
display can drift from the flag.
The age line needs a tick of its own, since it moves with no edit to
drive it. Five seconds against a label that reads in tens of them.
Two defects found by probing rather than by reading. The %n plural
rendered as "2 minute(s) ago" for every English user, because Qt picks a
plural form only when a translation supplies the forms and there is no
English .ts; it uses %1 and "min" now, which Italian substitutes
identically. And the status mark was inside the translatable string,
where a translator could drop it; it is concatenated outside tr().
Presentation reworked after the user looked at it. The first version
reused item 151's yellow ribbon treatment, which reads as a misplaced
widget on a bare status label rather than as a warning, and put both
labels in the permanent widget area, which is the right-hand tray. They
are ordinary status text on the left now.
onlyTheSetterWritesTheDirtyFlag() asserts the funnel structurally, by
reading composewindow.cpp: the first test for the send path called
markClean() directly and a mutation restoring a direct assignment left
the whole suite green. Four mutations now fail. The suite still cannot
see the presentation, which is why that half needed a hand test.
lrelease reports 487 finished, 0 unfinished.
Closes item 160, and unblocks 161.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUQS6n3cmsFrsjCNmwNtf8
|
|
The Drafts filter shipped threaded in item 138, reasoning that a draft
reply belongs with the conversation it answers. That reasoning cost the
feature: a thread row stands for its first matched message, which for a
draft reply is the message being replied to, so the draft itself had no
row of its own and double-clicking the conversation opened nothing.
Reversed with the user. Drafts now follows Sent; Trash deliberately does
not, since a deleted message still belongs to its conversation and
nothing there has to be reachable for editing.
The view mode was decided in three places that each compared against
"sent" and had to agree: builtinFilter(), the reader that reapplies the
mode, and the writer that skips storing what the generator implies.
generatorIsFlat() is now the one closed set they share, and
builtinFilter() sets flat from it rather than inside a branch so the set
cannot drift from the labels.
Setting only the branch would have looked correct. Its save/load pair
survives by accident, because the writer's skip knew only "sent" and so
would have stored the key for drafts. The gap is the reader's fallback,
for a file carrying no flat key at all: an older build, a migration or a
hand edit comes back threaded against a flat button, and the next save
persists the disagreement.
theDraftsFilterIsThreadedNotFlat is inverted rather than deleted, keeping
its history, and now also pins Trash as threaded. The round trip is
covered by extending aGeneratedEntryWritesNoRedundantKeys, which already
asserted that property for Sent. Mutation-checked: reverting
generatorIsFlat() to "sent" alone fails both.
Suite 37 of 38; undoMovesTheMessageBack is item 136, pre-existing and on
an unrelated path.
Closes item 159.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUQS6n3cmsFrsjCNmwNtf8
|
|
|
|
|
|
Item 94. The query row is the six built-in filters (Unread, Inbox,
Important, Sent, Drafts, Trash), which compose with the account
dropdown, and every saved query lives in the More queries menu. Nothing
has to decide which of the user's queries get button space, which is the
question item 93 would otherwise have had to answer.
SavedQuery::pinned is gone from the struct, the reader, the writer, the
save dialog's checkbox and the pin/unpin context action.
The stored key is stripped rather than left ignored, at the user's
choice. That has one non-obvious requirement: `pinned` stays named in
loadSavedQueries' `known` list precisely so it is NOT collected as an
unknown field, since those are preserved and written straight back. A
mutation removing that name puts the key in the file for ever.
Confirmed with the user before starting that the built-in set covers
their use, since removing pinning removes the escape hatch this item was
blocked on.
Tests: four pinning tests replaced by two on the new rule, four more
converted from buttons to menu entries. migrationPinsEveryEntry and
aStoredGeneratedQueryIsUnpinnedNotDropped are rewritten around the
property that outlived the flag rather than deleted: an entry must be
KEPT, which is what both assertions were really guarding.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEcn3u19xPqv6ggD15PG4c
|
|
Item 153. DraftStore had a write() and no reader, and nothing opened a
composer from an existing message, so a draft rendered like ordinary mail
and could never be finished or sent.
ComposeContextBuilder::forDraft() reads one back. A new Kind::Draft seeds
every field verbatim: the subject takes no Re:/Fwd: prefix, and the body
goes in exactly as it was left, with none of seedBody()'s quote framing. It
is reachable by double-click and by an edit_draft action in the Message
menu.
Three things the shape of this depends on.
A resumed draft must OWN its file. Maildir has no in-place edit, so an
autosave writes a new file and unlinks the old one; a composer that did not
know its own path would leave the original behind and one message would
become two. ComposeContext::draftPath carries it into m_draftPath, which the
autosave already knew how to replace.
MimeParser had no bcc, and nothing had ever needed one. MessageBuilder
writes Bcc into the draft file deliberately and explains why, so a resumed
draft that ignored it would drop every blind recipient from the message the
user then finishes and sends, reporting nothing.
edit_draft is gated on the file being inside a configured drafts folder,
matched on the PATH. A `draft` tag is not enough: notmuch surfaces the
Maildir D flag as one, and a message flagged by another client sits in the
inbox. Offered on ordinary mail, the composer would own a file it did not
write and the first autosave would delete a received message.
And a live defect found on the way, which is most of why this took as long
as it did. updateComposeActions() ran only from onSelectionChanged. Both
signals fire for an ordinary click, so nothing had noticed; but running a
query and setting the current index emits currentRowChanged ALONE, so the
enablement was computed against the previously selected row. Edit draft
stayed disabled on a draft selected that way, and the reply family had the
same blind spot with no test that could see it. Now connected to both.
Reading currentRowChanged is safe here for the reason CLAUDE.md gives: it
answers "which row is current", and no count is read.
WorkerBackedWindow::AccountSpec gains a drafts field, which the two new
tests need and which no fixture could express before.
|
|
Items 138 and 148.
The query row carried Unread, Inbox, Important, Sent and Trash, and no
Drafts, though the composer has been autosaving into each account's drafts
folder since compose shipped. Reaching them meant typing a query by hand.
Smaller than its size suggested: Account::draftsQuery() and
Config::allDraftsQuery() already existed for the placeholder pane's drafts
count, and builtinFilters() derives the row from kQueryGenerators, so the
work was the generator entry, two resolvedQuery branches, a label and an
icon.
It follows TRASH rather than Sent. Folder-matched like both, because `draft`
is a Maildir flag notmuch surfaces as a tag while the folder is what the
user means and what the composer actually writes into. But NOT flat: Sent is
flat so a thread cannot fold the user's own message back into the
conversation it answers, and a draft reply belongs with its conversation for
the same reason a trashed message does.
An account with no drafts folder shows no button, per item 103's rule. The
existing row test surfaced that by failing until its fixture configured one,
which is the rule working rather than a defect.
Ctrl+W closes the composer, which bound nothing at all: the only way out was
the title bar. The action is parented to the composer, so it is a
WindowShortcut dispatched to the active one only and the main window's
namespace is untouched, exactly like the formatting shortcuts. It calls
close() rather than doing anything of its own, since closeEvent() already
decides whether the draft is saved and a second route out that skipped it
would lose the message.
The Italian gains "Bozze"; lrelease reports 478 finished, 0 unfinished.
|
|
Items 142, 143, 144 and 145, to the layout the user described.
The composer had one addToolBar carrying three scopes at once: text
formatting, message composition, and the terminal action. It read as a menu
bar that is not one. There is now no window toolbar at all.
From: [.............] +--------+
To: [.........] [v Cc/Bcc] | Send |
Subject: [...........................] +
[B][I][</>][S][link]["] [Attach] [Send as HTML]
+---------------------------------------------+
| message text |
+---------------------------------------------+
[Remove] * report.pdf <- only when attached
Send is a large icon-above-text button beside the headers: it is the
terminal action and carries the weight to match. Formatting is a toolbar
widget in the central column directly above the text it formats, icon-only
with the words kept as tooltips, which is where a tooltip stops being
decoration. Attach and the HTML toggle ride the right end of that bar, past
a stretch, because neither formats text. Remove attachment sits with the
list it acts on and appears only once something is attached.
"Also send a formatted copy" becomes "Send as HTML": the old label described
a mechanism without naming it, leaving the reader to infer that "formatted"
meant HTML and that "copy" meant a MIME part rather than a second message.
Cc and Bcc hide behind a disclosure beside To:. revealCcBccIfUsed() only
ever shows, never hides, so nothing but the user's own click can make a
field holding an address invisible: a hidden recipient is a message going
somewhere the sender cannot see, which is worse than the clutter this
removes. The label is hidden with each field, since a QFormLayout holds the
two as separate items and hiding the line edit alone strands a "Cc:" over
empty space.
Two send-lock faults, one predicted and one not. The backlog warned that
setInputsEnabled() disabled the single toolbar wholesale, so the send-path
test was strengthened to name every control BEFORE the split; it then caught
Attach live during a countdown, where a file appended after MessageBuilder
has run is either dropped or added to bytes already sent, silently either
way. With every control named it failed again on format_bold: disabling a
QToolBar greys its buttons but leaves each QAction enabled, so Ctrl+B during
a send would have edited a message already being built, through a button
that looked unavailable. setInputsEnabled() now walks the bar's actions too.
The Italian translation is refreshed; lrelease reports 477 finished, 0
unfinished.
|
|
Three corrections from looking at the built bar.
Compose returns to the main toolbar. The split this was built to, "about a
message" against "about the list", does not survive contact: what matters is
what the action NEEDS. Reply and Forward are meaningless without a message on
display, while Compose needs none and is disabled only when no account can
send. So the pane's bar holds exactly the two actions that depend on what it
is showing, and Compose sits with the window-wide ones.
The bar moves below the subject and details rows, directly above the web
view. At the top of the pane it read as window chrome rather than as
belonging to the message. The transient notice bars stay above it: they
explain the message rather than offer an action on it.
Its icons were the style's own default, 16px, which is tiny beside a 32px
toolbar. They are now 7/8 of toolbar_icon_size, which is the 28 the user
asked for at their 32, derived rather than hardcoded so the relation holds
if that key changes. The test asserts the relation as well as the value,
since a bare 28 would stop meaning anything the moment the key moved.
m_headerLabel gains an object name so the placement test can find the row it
must sit below.
|
|
Items 139, 140 and 141, built together because the seam between them is
wasted work: 140 needs a container and 141 is that container.
The main toolbar had grown to mix two scopes. Sync, Archive, Delete, Mark
all read and Undo act on the list or the selection; Compose, Reply and
Forward are about one message. With everything in one row the distinction
was invisible, and Forward was on no toolbar at all, reachable only from the
Message menu, which is item 139.
Compose, Reply and Forward now sit on a bar above the message pane, and
LEAVE the main toolbar rather than gaining a second home: that is what makes
the toolbar's remaining contents mean one thing. Toggle HTML joins them at
the right end, separated by an expanding spacer, since changing how a
message is displayed is a different scope from acting on it. That layout was
the open design question item 141 recorded, and it was settled with the user
rather than guessed.
The actions are MainWindow's own QAction objects shown a second time, never
copies: a duplicate would carry its own enablement and drift from the menu
entry updateComposeActions() keeps in step. MessageView::setBarActions() is
the seam, so the pane still knows nothing about the window's action map.
Two things worth recording:
QToolBar has no addStretch(), so the separation is an expanding spacer
widget. A test asserting only on action ORDER passes with that spacer
deleted, measured, so it asserts on the spacer's size policy instead.
noTwoActionsShareAnIcon looked up the toolbar with an unnamed
findChild<QToolBar*>(). There are two toolbars now, so it is pinned to
main_toolbar: pointed at the pane's bar it would have asserted that a
thread action is absent from a bar that never holds any, and passed while
the rule it exists for went unchecked.
|
|
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
|
|
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
|
|
Item 124 shipped and is proven: the index moved from a 7200rpm platter to
NVMe with the mail staying at /data/Mail. Cold start went from 38618 ms to
668 ms for a complete walk, and 2008 ms to 50 ms for the first rows.
Counts held at 49174 messages / 5594 inbox / 100 tags at every step, and
Delete then Restore round-tripped through the account's trash by hand.
Item 121 stays open, and its entry now says why. The measured platter
figures are the evidence FOR building the indicator, not against it: a
mechanical disk is the cheap configuration, not an exotic one, and a user
with a large Maildir on spinning rust has nowhere to migrate to. Fixing
one developer's hardware is not fixing the application. The constraint
that pointed at item 124 as the answer is replaced by one saying the
opposite, and prefaulting stays rejected on its own merits since it is
worst on the low-memory machines most likely to have a slow disk.
Item 125 is new, found by hand during the migration. mailsync.sh exits 75
(EX_TEMPFAIL) when another run holds the lock, and the sync indicator
never clears; because an edit made during a sync is held until the sync
ends, a Delete sat queued for a completion that could not arrive and
looked like it had done nothing. Nothing was lost, since held edits reach
the disk, but the user cannot tell that.
Also records in CLAUDE.md that notmuch_database_get_path() is not the mail
root, that database.hook_dir defaults into the index directory and
silently stops post-new under a split config, and that the ordinary
fixture layout cannot tell the two accessors apart.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDq53rMd3AQp7QmcZzpuBM
|
|
Item 115's closed section, which the previous commit failed to update: its
python edit asserted on wording that did not match the file and aborted, so the
code shipped and the record did not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
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>
|
|
Every step of the plan is done. Item 103's section moves to the closed file on
the same commit, per this repo's own rule, with its outcome recorded: what was
built, the ten defects hand testing found that the suite did not, and the two
process gaps closed alongside them.
The fact worth carrying forward is the one that damaged real mail. Under
mbsync's Create Both, a wrongly named origin folder propagates to the mail
server, so any code composing a folder name reaches the server whether it means
to or not.
Item 118, emptying the trash, remains deferred at the user's request.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
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.
|
|
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>
|
|
Double-clicking any row drills into its thread: the list becomes that thread
alone, expanded, and the pane shows the double-clicked row's own message. A
reply therefore opens its WHOLE thread with itself selected, never itself alone,
which is what the user asked for and is not the obvious reading of "open it by
itself".
This is recoverStaleThread() triggered by a gesture. That function already ran
thread:<id>, expanded the thread when the row arrived, selected the target
message once the replies landed, and fell back to the root when the message had
gone; all three cases are existing paths through it, so the new code resolves a
row to a thread id and a message id and hands both over.
The row is reached through the INDEX and never through index.row(): a tree
numbers rows per parent, so threadAt(row) on a reply answers about an unrelated
thread. That is item 88's trap, avoided here by construction.
The first click of a double-click arms the mark-read timer, and the handler
cancels it, because a gesture that navigates must not mutate mail. The timer is
armed again for whichever row the recovery lands on, so only the arming for the
row being left is cancelled. Its test asserts the timer was active beforehand,
so it cannot pass by the timer never having been armed at all.
The expander keeps its own double-click: ThreadListView::mousePressEvent accepts
a press inside its rect and returns, so Qt never pairs one into a double-click
there.
Nothing is built for getting back. The filter buttons already are that, per the
user.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
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>
|
|
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>
|
|
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>
|
|
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>
|
|
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>
|
|
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.
|
|
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.
|
|
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.
|
|
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>
|
|
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>
|
|
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>
|
|
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>
|
|
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.
|
|
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>
|
|
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>
|
|
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>
|
|
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>
|