| Age | Commit message (Collapse) | Author | Files | Lines |
|
Item 174, and half of item 125.
The premise was corrected before any code. The note asks for an external
`notmuch new` to clear the pending count; it must not. That count means
tag mutations not yet known to have reached the MAIL STORE, which is the
server: an edit is in notmuch the moment it is made, and what is
outstanding is mbsync pushing the renamed Maildir files. `notmuch new`
re-indexes local files and pushes nothing, so clearing on it would tell
the user their work was safe to quit on while it was still local. The
entry's own proposal to watch notmuch_database_get_revision() was
rejected for the same reason: a revision moves when mail ARRIVES too, and
in neither case does it say anything about the server.
What was actually wrong was the reporting channel. The application
inferred a finished run from an inode in /proc/locks and from grepping
the log for its RUN END banner, which made a human-readable line into
wire format and could not say WHICH channels a run carried. The local
sync path has always narrowed its clear to the accounts it carried; the
external path could not, and cleared everything, so an edit to an
account a run never touched was reported as delivered.
So the script reports instead of leaving evidence to be inferred. It
writes ~/.local/state/qtmaildir/syncstatus.json atomically at the end of
every run, including a skip, naming the channels, both exit statuses and
a state of ok, failed or skipped. MailSync::readStatus() reads it,
MainWindow prefers it over the log banner and narrows the clear through
Account::syncChannel(). A skipped run clears nothing, which is item 125's
first half: the application can now see that a run happened and carried
nothing. The log banner and lastRunOutcome() stay as the fallback for a
missing file, which is what a first run after upgrading looks like.
This is the user's own framing of the scope: the script was written for
another system and adapted, and is now qtmaildir's only consumer, so it
serves the application rather than the reverse. Two facts made it safe to
act on: their crontab runs mailsync.sh and nothing else touches mail, and
~/bin/mailsync.sh is a symlink into this repo, so an edit is live on the
next tick.
Two bugs found while wiring it in, both recorded in the closed item.
A test read the developer's real sync state, twice: a [sync] section
naming only `log` leaves syncStatus() defaulting to the real file, so two
tests asserting that a FAILED run leaves the count alone read the last
real cron run, found ok, and cleared. Pinning only `status` has the
mirror problem. noSyncTestReadsTheRealSyncState() is the guard, modelled
on noTestCanSeeTheRealLockTable().
And Qt::ISODate carries no milliseconds. The status file is preferred
only when it describes THIS run, compared against when the lock appeared,
so a stale success cannot outrank a fresh failure; but the script writes
date -Iseconds, and a round trip of "now" comes back 329 ms behind,
measured. A fast sync's own file therefore parsed as stale and fell back
to the log, with nothing failing to say so. One second of slack matches
the precision the format carries.
Design: docs/superpowers/specs/2026-08-29-sync-status-file-design.md
Suite: 43 of 44, with undoMovesTheMessageBack failing as it does on
master (item 136).
|
|
|
|
Item 182, found by hand: a thread of 9 messages with 5 unread, marked
read while a sync was running, reported "<subject>: mark as read" and
then reported the same work again when the sync finished. The user read
it as double reporting.
Not a double write, and the mail was correct. It is one action reported
twice because the FIRST report was the wrong one.
A sync holds notmuch's exclusive write lock and the worker's read-write
open blocks on it rather than failing, so an edit made during a sync is
held and sent when the lock frees. All three hold branches say exactly
that, in a label chosen deliberately: NOT transient, because it
describes state lasting until the sync ends, and a message that expired
would leave rows showing a tag the database has not got and no
explanation of why.
That label never survived. Every caller announced the action itself a
line later through showTransientStatus(), which overwrote it, so the
user was told the write had happened and the hold was never mentioned.
The flush at the end of the sync then reported the same work again and
read as a duplicate rather than as its completion.
announceAction() asks whether a sync holds the lock and, when one does,
sets a non-transient label naming the action AND the wait. The action is
still named because that announcement is what stands in for the
confirmation dialog this project rules out: it is how a user tells that
something larger than they meant has just happened, so the hold is added
to it rather than replacing it. The flush message is untouched and is
the only signal that held work actually landed, whose absence was item
106.
The test drives toggle_unread, the route the user took, and asserts both
halves: the text mentions the sync, and it still says what is waiting.
Asserting only the first would pass against an announcement that dropped
the action entirely. Mutation-checked by forcing the non-held branch,
which fails with the exact text the user reported.
The new string is translated, since one that misses the Italian ships as
English inside an otherwise Italian UI: lupdate found it with no context
warnings, lrelease reports 552 finished and 0 unfinished.
Suite: 42 of 43, with undoMovesTheMessageBack failing as it does on
master (item 136).
|
|
Item 181, from the user's notes: "the thread dashboard doesn't update
live with the modifications applied to the list pane. If I mark the
thread as read, the dash still reports N unread".
ThreadDashboard draws a ThreadDigest, which the worker builds from the
index and which reached the pane only when a conversation was selected.
A tag write updated the model optimistically and repainted the card
beside it, and nothing touched the digest, so the pane went on reporting
the unread count, the progress bar and the Waiting-for-you list the
conversation had when it was opened.
Reachable from the dashboard's own Mark all read button, which is the
worst version of it: the number sits directly above the button that
fails to move it.
refreshDashboardDigest() re-asks the worker for the digest of the
conversation on display, and returns at once when the pane is showing
anything else. It bumps m_digestGeneration like any other request, so
the guards in onThreadDigestLoaded() discard a reply that arrives after
the user has moved on. No placeholder digest, unlike the selection path:
the pane already holds this conversation, and blanking it to re-fill it
would flicker the whole dashboard for a change to one number.
Called from onTagsApplied(), where a write is CONFIRMED, and not from
the two write funnels. The first attempt put it beside the optimistic
model update by analogy with every other optimistic repaint, and that
analogy does not hold here: the digest is rebuilt from the index, so a
refresh queued beside the write reaches the worker before the write does
and answers from the state before it. The test failed identically to no
fix at all.
Every write rather than a chosen subset, at the user's decision:
narrowing it to the writes that change what the dashboard happens to
draw today is a list the dashboard can outgrow silently, and this costs
a round trip only while a conversation is on screen. Re-requested rather
than edited in place, because the digest is a derived summary and
recomputing it here would be a second place that has to agree with the
worker about what a write did.
The test is worker-backed over a real two-message conversation and is
driven through the mark_all_read action rather than the private funnel,
which is the path the dashboard's own button takes. It asserts the pane
carries the unread state before the write, so the assertion after it
means something.
Suite: 42 of 43, with undoMovesTheMessageBack failing as it does on
master (item 136).
|
|
Item 178. everySelectedRowIsInATrashFolder() read
ThreadSummary::firstMessagePath for any row that was not a message row.
That was correct while a thread row MEANT that message (item 108) and
stopped being correct when item 177 made it mean the conversation. A
conversation is in the trash only when ALL of its messages are, so a
partly trashed thread answered on whichever message the query returned
first: Delete could be hidden on a conversation that still had mail
outside the trash, and Restore offered on one that mostly did not.
Not data-affecting. Both actions are no-ops in the wrong direction:
Delete on already-trashed mail takes moveMessages()' already-there
branch, and Restore on mail that was never trashed finds nothing to
move.
qtmaildir cannot produce such a thread itself, since Delete is absent on
a reply row and Restore is thread-scoped. Two things outside it can:
another client trashing a single message, and a reply arriving after the
conversation was trashed.
ThreadDigest already walks every message of the selected conversation
for its sender counts, and a filename is served from the index like
everything else in it, so the paths ride along on a request the
selection already makes rather than costing a walk on every query.
ThreadDigest::messagePaths is relative to the mail root, for the reason
firstMessagePath records: an absolute path matches no account and
silently resolves every row to none. MainWindow keeps them beside the
dashboard's thread id and clears them when the dashboard is left, so a
late digest cannot answer about another row.
One limit, stated in the code rather than hidden. The digest is
requested only for a single selected conversation row, so that is the
only case with a real answer; any other selection falls back to the
summary's one path. That fallback IS the pre-177 answer and is wrong in
exactly the same partial case, which is the point: a multi-row selection
is left no worse than it was, rather than given a second, differently
wrong rule of its own. Making it exhaustive costs a per-query walk over
every message, which is what this avoids.
Two tests, both mutation-checked. The worker test puts its two messages
in different folders, since two in one folder answer identically
whichever way the code resolves them. The window test asserts both
directions, so a fix that simply hid Delete everywhere would fail it,
and sets totalCount explicitly: a summary left at the default is a
message row, and the test would otherwise exercise the other branch and
pass for the wrong reason.
Suite: 42 of 43, with undoMovesTheMessageBack failing as it does on
master (item 136).
|
|
AGENTS.md is the single source of truth for how to work on this
repository, shared across every agent tool; CLAUDE.md is now a thin
pointer to it, mirroring the arrangement in the user's own config.
Prose throughout the specs, plans, source comments and tests refers to
this document as CLAUDE.md, which is what it was called until now.
Those references are historical records and are correct about the
content, so they are left alone and AGENTS.md says how to read them.
Two rules added while reconciling:
- The reconcile writes its open-items table to the user's vault as well,
rewritten whole each time so a closed item cannot linger there.
- Build a test only when it asserts something measurable, and hand a
visual change to the user instead. This is the conclusion the
"Rendering probes lie" section was already building toward, and it
sits at the end of it. Guarded against the opposite reading: it does
not license dropping a test that can fail, and items 87, 105 and 176
are each a case where the missing test was the whole problem.
The 2026-08-29 pass over the user's notes found two entries with no item
here, both causes verified in the code rather than copied from the note:
- 179, undo is one level deep in practice and there is no Redo. The
QUndoStack is real and multi-level; what is missing is a redo action
and an answer to the stack being cleared on every new query.
- 180, the repaint rules are discovered one hole at a time. A question
rather than a defect, and the user's own framing: items 105, 107, 109,
110 and 170 are each one hole in the same surface, all found by hand.
|
|
|
|
One key: mixed reads Mark thread read and marks every message read, a fully
read conversation reads Mark thread unread. Two presses reach either state,
which is item 112's requirement met with a label rule rather than a second
action.
|
|
Replying to the sender alone in a multi-person thread drops everyone else
from a conversation they are part of, while reading as a reply to it.
|
|
Forward and Save need a message and disappear; Reply becomes one lean
'Reply to this thread', quoting nothing, reply-all, threaded off the newest
message. composeReply already supports it, so no new compose machinery.
|
|
Eleven tasks over the five stages the spec sets out, TDD throughout. Tasks
1 to 6 are one coherent change and the tree behaves oddly between 2 and 6,
so the first useful hand test is at the end of 6.
|
|
Every colour on the pane already means something elsewhere: a sender's
hashed identity, a tag's, or the palette's own emphasis. Avatar::colourFor()
hashes the address, so a participant's colour is already stable across
threads and nothing new had to be decided.
|
|
|
|
Four separate questions in one session turned out to be one question: a row
means a message for display and action, and a thread for existence and
membership. The spec settles it as the conversation, and records item 177
plus item 176, the thread-scoped undo defect found while hand-testing.
|
|
The row still described the multipart/alternative build that was reversed;
the shipped forward sends one part chosen by the Send-as-HTML toggle, with
the original in a read-only pane beside the editor.
|
|
Item 171. A forward carried only the plain-text version of the original,
so formatting was lost; and an original with no plain-text part at all
(30 of 342 sampled inbox messages, ~9%) forwarded as an empty quote with
its content silently gone.
A forward now sends ONE part chosen by the Send-as-HTML toggle: the
original's markup when on, the text quote when off. Not a
multipart/alternative, at the user's decision: a forward's shape is
already decided by that toggle, and sending both hands the choice to the
recipient's client. The toggle is honoured even for an HTML-only
original, which then forwards as a text fallback.
HtmlSanitiser strips remote content from the forwarded markup, checked
by default with a per-forward opt-out. This is the security-critical
part: the markup leaves this process and is rendered by the recipient's
client, where none of MessageView's protections apply, so forwarding a
tracking pixel forwards the tracking. It is an ALLOW-LIST, unlike
HtmlBuilder::namespaceCids(), because a missed rewrite is a broken image
while a missed strip is a beacon reaching the recipient.
An HTML forward does not seed a text quote into the editor. The first
build did, then subtracted it when building the HTML part, so the user
could edit a quote whose edits were discarded; what the composer shows
must be what gets sent. The forwarded message appears in a read-only
pane beside the editor instead, a QSplitter at 60/40 with a toggle in
the Format menu. A plain forward is unchanged.
ComposeContextBuilder::quoteBody() renders htmlBody down to text when
there is no plain part, so the plain path never emits an empty quote.
Design in docs/superpowers/specs/2026-08-27-forward-html-design.md.
Two tests repaired for the splitter: the 60/40 assertion reads stretch
factors rather than pixels, since the offscreen platform gives the
splitter no width and reports 49/49 whatever the code asks; and
theComposerSplitsItsToolbarByScope looked for the body directly in the
composer's column.
Not yet hand-tested in this arrangement.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtUzfNjMD8fiYfamDd3ywW
|
|
Re-measured at message level: 0 of 12 drafts carry `inbox`, including
nine written on or before 2026-08-25, when the item was filed. The
thread that produced the original report splits into an arrived message
tagged `inbox` and a draft reply tagged `draft unread`; neither carries
both.
The premise came from `notmuch search --output=tags`, which displays the
union over a thread. The trap has a second half: a thread-level
`notmuch count 'tag:draft and tag:inbox'` also returns 0, because search
terms match per message even in a thread query, so the count and the
displayed tag list disagree and the displayed list is the one that looks
like evidence.
The investigation is kept above the correction rather than deleted: it
cost a week open, two wrong causes and a seven-variant reproducer built
to explain an end state a union produces for free, and it caught a fresh
reader again on 2026-08-27.
The `unread` half of the original observation was real and is item 172,
fixed in edbf393. The reported `draft inbox unread` is fully explained:
`unread` from the missing S flag, `inbox` from the arrived message
sharing the thread.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtUzfNjMD8fiYfamDd3ywW
|
|
DraftStore::write() was called with "D", and it uses the flag string
verbatim, so every draft this application wrote landed as :2,D. With
maildir.synchronize_flags on, notmuch tags any message lacking the S
flag `unread`, and a draft the user authored is seen by definition.
The symptom heals itself: the next sync of that folder round-trips the
file, adds S, and the tag goes away. Only the newest draft in a folder
that has not synced since shows it, which is why it read as
intermittent and why measuring an older draft finds nothing wrong.
TestComposeWindow::aSavedDraftIsFlaggedSeen() asserts both flags on the
written filename, verified failing first against "D".
TestMainWindow::anAutosaveWritesADraftAndClearsTheDirtyFlag() asserted
endsWith(":2,D"), pinning the whole flag set where its own comment said
the point was the draft flag "not left bare", so it failed against the
corrected behaviour. It checks for D within the flag set now.
Also reconciles the backlog with the user's notes: records the
forwarded-HTML defect as item 171, closes item 169 (shipped last
session, its row still read open and its section was still in the open
file), and records this fix as item 172.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtUzfNjMD8fiYfamDd3ywW
|
|
The status rows for 119 and its duplicate 146, 119's section moved to the
closed file on this commit rather than left for a later cleanup, and the
README and changelog entries for the feature.
CLAUDE.md gains three findings, all of which cost time to learn here:
A defensive counter for an unreachable case is worse than nothing, because
it blocks the feature that needs the data. Reading the code said that branch
was reachable and the reading was wrong; instrumenting it and running the
suite is what settled it, and the tests that appeared to exercise it were
driving it from outside the production path.
PendingChangesDialog groups by a run rather than a map, which is why the
snapshot is stable-sorted, and startsMessage is carried rather than inferred
so a stale row still opens its own run.
A queued call carrying a container deserves the same suspicion as a Q_ENUM,
with the measurement: both containers cross intact on Qt 6.11, but a
standalone probe found QMetaType::fromName("QList<int>") invalid while
QList<bool> resolved, so the property does not follow from the type.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P88Q3MCSCSQxKDy7pmXh9F
|
|
A week-long scope is right once the list is in use and wrong on the first
run, when it proposes almost nothing and leaves the file taking months to
become useful. BusinessSenders::scanQuery() returns "*" while the file
holds no active entry and date:1week.. afterwards.
A file holding only rejected candidates counts as unused, which costs one
more full scan and re-proposes nothing, since appendCandidates already
skips every address the file mentions in any form.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LXCZFLXbAii5n5wtovpdhh
|
|
Fourteen tasks against the item 169 spec, TDD throughout. Two new
namespaces of free functions over values, Avatar and BusinessSenders, so
the initials, the fill choice and the list parsing are all assertable
without a painter or a widget.
The plan records where the existing traps apply rather than leaving them
to be rediscovered: the two separate switches in data(), the inclusive
QRect::right(), the queued-connection metatype registration, and the
rule that a count request must not bump the query generation.
Task 14 is the hand-off: this item is judged by looking, so the plan
names the five things to look at and the constants most likely to move.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LXCZFLXbAii5n5wtovpdhh
|
|
Reconciliation against the user's notes found two unrecorded lines, and
corrected the cause of one entry that was recorded wrongly.
- 169, new: a card shows the account only as a bar, with no fade and no
avatar. Half of it shipped as the accent bar.
- 170, new: a row that stops matching the view only leaves it on the
Delete path. Filed from a note that reads as a stale request for
optimistic updates; it is not. removeThreadsWithoutTag() has exactly
one caller, so marking a message read in the Unread view repaints the
row and leaves it in a list it no longer belongs to.
- 65, narrowed: the notes now name it as a dead-code and duplication
sweep rather than a performance or security pass, so it produces a
list to decide on rather than a diff.
The spec for 169 covers the avatar geometry, the fade, the two
hash-generated fills and the sender list that chooses between them. It
also records the one structural change the feature needs and the
measurement that forced it: ThreadSummary::authors carries display names
only, with no address anywhere, so the identicon has nothing stable to
hash and the sender list has nothing to match. ThreadSummary gains
firstMessageSender, filled by the walk that already fills firstMessageId.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LXCZFLXbAii5n5wtovpdhh
|
|
Item 68, which turned out to be three things once its premise was
measured. The note asked to extend a "passed" subject rule to "Fw:";
there was no subject rule, and the correlation it rested on did not
exist. What did exist was a gap nobody had reported.
Reply and forward now flag their source. The Maildir R and P flags,
which every other client sets and notmuch reads back as "replied" and
"passed", had never been written here: measured on the developer's
index, all 317 "replied" and all 6 "passed" came from other clients.
ComposeWindow emits sourceMessageAnswered after a successful send and
MainWindow routes it through sendMessageTagChange, message-scoped and
off the undo stack, for the reason auto mark-read is: the flag records
that the mail went, and the send cannot be undone.
ComposeContext carries sourceMessageId rather than reusing inReplyTo,
which is deliberately empty on a forward so the recipient's client does
not file it under the thread it left. Keying on it made the "passed"
half dead code that compiled and never fired. A resumed draft is
excluded: its kind records how the file was opened, not what the user is
doing, so flagging on it would set R from a guess.
A received forward gets its own mark. Derived from the subject at paint
time, storing nothing and reaching no server, because "passed" means "I
forwarded this" and setting it from a guess would assert something false
on 222 existing messages. subjectIsForwarded() shares forwardSubject()'s
prefix table so the two cannot disagree, strips a Re: chain first, and
takes extra locale spellings from [general] forward_prefixes, which
extends the built-in table rather than replacing it.
A mutation survived the first round and corrected a claim in the code:
QRegularExpression::escape already makes a punctuation prefix inert, so
the word guard is not about pattern validity. It stops a configured "-"
matching "-: x". The comment and test say that now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LXCZFLXbAii5n5wtovpdhh
|
|
Item 168, found by the user while hand-testing 118: Delete could be
triggered on a message already in the trash. Not dangerous, which is how
it survived. moveMessages() finds the file already in the destination
and takes its early-return branch, so the message is reported as moved,
an unsynced change is counted, and nothing happened. Restore had the
mirror of the same problem, added unconditionally to both menus and so
offered on mail that was never deleted.
Each is now hidden where it has no meaning, which is the rule item 112
established for the unread entry. The question is about the PATH, never
the deleted tag: a message trashed by another client carries no such
tag, which is why the trash view is path-based, and asking the tag would
hide Delete on exactly the mail a trash view is full of.
Delete also removes unread now, at the user's request on the same
tangent. It travels inside the same sendMove() call rather than as a
second write, so one undo returns the folder and the tag together. This
rewrites the Maildir filename, because maildir.synchronize_flags is
true, and so reaches the server: the same mechanism the post-new hook
refuses to touch, and the difference is that the hook acts unattended on
arriving mail while this is an explicit gesture on a message in front of
the user.
A mutation survived the first round and found a real hole: comparing the
prefix without its trailing separator passed every test, because no
fixture had a folder whose name starts with the trash folder's. Under it
Delete silently vanished from mail in acct/trash-old, which is not the
trash. The fixture carries that row now and all three properties are
mutation-checked. The suite is 37 of 38, the failure being item 136 on
an unrelated path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HFuRPtzFrSxCQjFk6tq7gD
|
|
Item 118, unblocked by 103. Message > Empty trash..., scoped to the
account selector, with no default shortcut.
purgeMessages() is a separate worker entry point from moveMessages()
rather than a flag on it, because the two look alike and only one can be
undone. It takes named ids, never a folder sweep, so the blast radius is
what the dialog enumerated and the user confirmed, and it deletes every
file of a message: notmuch deduplicates by Message-ID, so leaving one
behind leaves the message alive in the folder the user emptied.
It confirms, naming the count and the account, defaulting to Cancel.
That breaks CLAUDE.md's no-confirmation rule deliberately and the rule
now records it as its single exception, in the same paragraph: a purge
has no inverse to push onto the undo stack, so the protection the rule
provides has to come from somewhere, and the dialog is where.
Two defects found rather than reasoned. The count claimed messages whose
files were already gone, overstating an irreversible action; an absent
file is correctly not an error, but that is not the same as destroyed.
And the user's hand test found the list still showing mail that no
longer existed: a purge removes rows rather than changing them, so there
is no optimistic update to apply and nothing was connected to
messagesPurged at all. It re-runs the current query now.
Verified against the live index after the user emptied one real
account's trash: zero files on disk, zero in the index. The suite is 37
of 38, the failure being item 136 on an unrelated path. Ten new strings
translated, lrelease reports 0 unfinished.
Item 168 is filed from the same hand test, on Delete being offered on
mail already in the trash.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HFuRPtzFrSxCQjFk6tq7gD
|
|
Item 112, and 99 and 147 with it: the user's note is one design across
all three. A union is not a state. ThreadSummary::tags is notmuch's
union over the conversation, so a thread holding even one unread message
answered "unread" and the thread toggle always chose "mark read". There
was no input that reached "mark thread unread" on a mixed thread, which
is the thread a user wants it for.
The thread toggle becomes two absolute actions, mark_thread_read and
mark_thread_unread. Neither takes a default chord, at the user's choice:
Ctrl+Alt+U meant whichever direction the union picked, and since item
132 a shortcut is a chosen subset rather than a requirement. It is now
unbound.
The message-scoped toggle stays a toggle, because one message has a real
two-valued state, and its label now names the direction it will go. On a
selection with no single state the entry is hidden rather than labelled
wrongly, chosen over disabling it; the thread submenu is the route then,
and its entries are absolute.
selectionTagPresence() is the three-valued predicate that needed to
exist. everySelectedRowHasTag() delegates to it and keeps its two-valued
answer, which is all a direction needs; a label needs the third value.
The refresh is keyed on the model's dataChanged as well as on the
selection, so a write moves the label without reselecting and none of
the six optimistic-update call sites has to remember.
Three mutations fail: restoring the union predicate reports the user's
original symptom, showing the action on a mixed selection, and dropping
the dataChanged refresh. The suite is 37 of 38, the failure being item
136 on an unrelated path. Four new strings translated, lrelease reports
0 unfinished.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HFuRPtzFrSxCQjFk6tq7gD
|
|
The fix landed in 6ea6980 and the row was left open. Its section moves to
the closed file with what the fix turned out to need: the loop, the two
query forms that were measured and rejected, the split-index fixture,
and the live read-only verification.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HFuRPtzFrSxCQjFk6tq7gD
|
|
The version alone cannot tell one build of an unreleased X.Y.Z from
another, and the user rebuilds and hand-tests unreleased builds daily.
They chose a counter over a git description: what they want to know is
that the binary is newer than the one they were running, not which
commit it came from.
QTMAILDIR_BUILD_NUMBER is a cmake option, ON by default, that runs
cmake/BuildNumber.cmake as a build step to increment a counter and write
buildnumber.h. It had to be a build step: configure_file runs once per
cmake run, so a counter interpolated into version.h.in would sit still
across exactly the rebuilds this exists to distinguish, which is why
version.h.in includes a second generated header rather than carrying the
number itself.
Two macros, and the split is load-bearing. QTMAILDIR_VERSION stays a
clean X.Y.Z and keeps the window title, applicationVersion and anything
that might ever compare versions; QTMAILDIR_VERSION_DISPLAY carries the
number and goes to the three surfaces the user picked, --version and
--help, the About dialog, and the placeholder pane. The window title was
offered and declined, since the number would then be in every
screenshot.
The counter lives in the build directory and is not tracked, so it
cannot conflict on a pull or leave the tree dirty; a fresh build
directory restarts at 1, which is honest, because it is a different
build tree. A release passes -DQTMAILDIR_BUILD_NUMBER=OFF and the header
is written empty. The SlackBuild in the my-slackbuilds repo needs that
flag and is a separate commit there.
Verified by running it, since none of this is reachable from a C++ test:
three consecutive builds reported build 2, 3 and 4, and a separate
Release configure with the option OFF reported a clean 0.27.0. Passing
the flag to a tree that does not have the option yet is an unused-cli
warning and exit 0, so the SlackBuild change is safe before 0.28.0
ships. The suite is 37 of 38, the one failure being item 136 on an
unrelated path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HFuRPtzFrSxCQjFk6tq7gD
|
|
The user has not run mailctl in months and the coupling this document
described is gone: the live database.hook_dir symlinks post-new,
mailrules.py and qtmaildirconf.py into assets/hooks/ here, where their
three suites also live. All three pass.
The format discipline survives the move and is kept, because what makes
it necessary is two independent readers of one file, not two
repositories: src/tagrules.cpp and assets/hooks/mailrules.py still share
no code and still agree by test. What changes is the procedure around
it, which no longer sends anyone to a sibling checkout, and the round
trip, which is now verified by running the hook rather than by a CLI
that is retired.
Item 166 said the same thing and was filed a day before this was
noticed; its row and its two-repo constraint are corrected with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HFuRPtzFrSxCQjFk6tq7gD
|
|
The reconciliation against the user's own notes found one entry with no
item here: a dev build number, so a rebuilt binary can be told from the
one it replaced. Verified in the code rather than copied from the note.
`src/version.h.in` interpolates PROJECT_VERSION alone, which moves only
when the release procedure bumps it, and the user hand-tests unreleased
builds daily.
It needs a decision before any code. A git description is accurate and
costs a configure-time dependency that is easy to ship wrong; a counter
always moves and identifies nothing. Either way a release build must
keep printing a clean X.Y.Z, since the SlackBuild builds from a tarball
with no git checkout.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HFuRPtzFrSxCQjFk6tq7gD
|
|
The fix landed in b1db3e8 and the row said "awaiting hand test". A sync
run from the application added 20 messages and they appeared without a
restart, which is the property the reopened read-only handle exists to
give, so the row is now done and the section moves to the closed file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HFuRPtzFrSxCQjFk6tq7gD
|
|
Item 104 is fixed and its entry was wrong. It named mbsync's folder
patterns as the leading theory and concluded the cause was most likely
outside this repository; the reproduction put it at layer 3, in the
worker's own handle. The superseded theory is kept, since it would
produce a similar symptom and remains worth checking first in any future
report of this shape.
Two measurement errors from the diagnosis are recorded with it. A bare
`inbox` in a notmuch query is a free-text term rather than a tag term,
and the application generates `tag:inbox`; reading a message's tags
across every file matching a subject mixes several accounts' copies into
one answer. Each produced a confident wrong answer before it was caught.
Item 166 is new, found while setting up msmtp. The `post-new` hook's
sent-folder carve-out judges provenance by a file's path, but notmuch
deduplicates by Message-ID, so mail sent between two of the user's own
accounts is one message with a file in each. The carve-out matches the
sent copy and strips `inbox` from the message the recipient's inbox copy
also belongs to. Three options are laid out; the fix is a two-repo
change and the hook runs unattended on live mail, so it needs a decision
rather than a patch.
|
|
Found while hand-testing items 163 and 164: four saves produced four
distinct ids, and one reopen-and-edit turned one into another.
Cause verified in the code rather than inferred. MessageBuilder::build()
calls g_mime_utils_generate_message_id() unconditionally and every
autosave calls build(); OutgoingMessage has no field to carry an id in,
and ComposeContext has none for the draft's own id either, since
inReplyTo and references are the ORIGINAL's when replying. So a stable
id needs a field threaded from forDraft() through both structs, not a
changed call site.
Filed as needing a DECISION rather than an implementation, because what
a draft's identity is is not obvious: a stable id reused at send makes
the draft and the sent message one message but means the server saw that
id before anything was sent; a stable id discarded at send keeps
revisions collapsed while drafting and threads under a fresh one; the
status quo never reuses an id for two different things, which is its one
real virtue.
Not urgent and explicitly not blocking item 163, whose fix restores
correct file replacement. This is the property that turned that fork
into two MESSAGES rather than one duplicated file, and the remaining
route to it is an interrupted save, since DraftStore::write() unlinks
the previous revision only after the new one is safely on disk.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUQS6n3cmsFrsjCNmwNtf8
|
|
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
|
|
The entry blamed a missing drafts helper. Neither half of that was true.
NOT_ARRIVALS in qtmaildirconf.py is ("sent", "drafts"), so sent_folders()
already returns both; the function name says sent and its contents do not,
which is what made the wrong reading plausible. Run against the real
config it returns every account's drafts folder, and notmuch count over
the carve-out query and the affected message id returns 1: the query the
hook builds MATCHES the draft. The folder list and the query are correct
and the fix is not there.
A second theory is also recorded as dead. An mbsync-style rename does not
re-apply new.tags: measured in a throwaway database, a file renamed to add
,U=4 and reindexed kept the tags it had.
What is established: the carve-out is scoped to tag:new, the draft carries
inbox, and tag:new is 0, so it was never in scope when the hook ran. The
installed hooks are symlinks into this repository, verified rather than
assumed, so the code read is the code that runs.
What is not established is which pass tagged the file. The likely shape is
an ordering one, since item 158 indexes a draft from the application
itself and a file already known to the database is not new on the next
pass, but that is a third hypothesis and the first two were both wrong.
The entry now calls for a reproducer driving the real sequence before any
code is written, since the hook tags real mail unattended every ten
minutes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUQS6n3cmsFrsjCNmwNtf8
|
|
163: the message pane names a path that no longer exists and reports the
message unreadable. Same mechanism as 162, different site and different
fix. mbsync renames the file to add ,U=<uid>; by the time the pane fails,
notmuch is already CORRECT and the stale path is the MODEL's, cached when
the row was loaded. Measured: the index named the ,U=4 file while the
pane named the pre-U= one. 162's likely fix, refusing to write while a
sync runs, does not touch the read path. Points at recovering by
re-resolving the id, the way recoverStaleThread() already does, with a
bounded retry so a genuinely unparseable message still reports.
164: every newly synced draft carries inbox. Measured "draft inbox
unread" on a draft this application wrote. strip_inbox_from_sent() reads
qtmaildirconf.sent_folders() only, and qtmaildirconf.py has no drafts
equivalent, so the carve-out never covers a drafts folder. Item 158's
measurement was right and did not reach this: index_file assigns no tags,
but mbsync's upload and the next notmuch new re-tag the file.
164 also contradicts the shipped 0.27.0 changelog, which claims sent mail
and drafts both stay out of the inbox. The drafts half has never been
true, so correcting the entry is part of that item.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUQS6n3cmsFrsjCNmwNtf8
|
|
Found by hand while deleting a draft: "Cannot move <file> to <folder>".
Neither Delete nor item 158 is at fault. mbsync uploads a saved draft and
RENAMES it to add its ,U=<uid> infix, and notmuch keeps the pre-U= name
until that sync's notmuch new runs, so moveMessages() calls QFile::rename
on a path that no longer exists.
Verified against the live Maildir rather than read: notmuch named a file
that was not on disk while a sync was running, and the same query was
clean afterwards with the file present under its new name. That is why it
reads as intermittent and why it heals itself.
Truthful and lossless, but the action silently does nothing and the
message blames a folder for a timing problem, which sent the user looking
at a configuration that was correct.
Records both candidate approaches and notes the likelier one: refuse the
move while a sync holds the lock, joining the held-edit machinery items
97 and 106 already built for exactly this shape, rather than re-resolving
the filename and racing the same window. Also notes that this is the ,U=
trap CLAUDE.md records for MaildirName::fresh(), seen from the other
side.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUQS6n3cmsFrsjCNmwNtf8
|
|
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
|
|
Both are composer feedback, and both causes are verified in the code
rather than copied from the note.
160: autosave works and is silent on success. The only feedback is the
failure banner, whose comment explicitly rejected the fading status line
that success actually wants. m_dirty and the draftSaved signal already
carry both states; nothing displays them.
161: the composer has no menu bar, and Save draft does not exist as an
action at all. saveDraftNow() is reachable only from the timer, Send and
closeEvent, so there is no way for the user to ask for a save. Notes that
the composer's actions stay out of KeyMap per item 148, that
everyActionIsReachableFromAMenu() walks the main window only, and that
"duplicate the other actions" needs the user to say which, since most
message actions are meaningless over a message being written.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUQS6n3cmsFrsjCNmwNtf8
|
|
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
|
|
|
|
|
|
indexes it
Found by hand: autosave writes the draft to the Maildir drafts folder but
never indexes it, and the Drafts view is a notmuch path: query, so the
draft cannot be reopened until notmuch new runs. Approach reuses the
single-file index moveMessages already performs.
|
|
Found incidentally while building item 152, by an agent that checked rather
than assumed: it ran test_mainwindow at the preceding commit in a throwaway
worktree and got the identical failure, so the signatures work is ruled out.
Records the assertion text, which is worth more than the flakiness history.
After the undo the message file is in neither cur nor new of the account
inbox, so the question narrows from "why does this race" to "where did the
file go", and the trash folder and the account root are the places to look
first. A move landing in the wrong folder is the mail-safety half of the
fork this entry already described, and it would present exactly this way.
The 70-second duration already recorded fits a QTRY_* waiting for a file
that is never going to appear.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEcn3u19xPqv6ggD15PG4c
|
|
The plan's test fixtures carried the maintainer's own first name as the
signature text, which reaches a committed test file. Task 1 caught and
corrected it in the code; this corrects the source so tasks 2, 3, 5 and 6
do not reintroduce it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEcn3u19xPqv6ggD15PG4c
|
|
Eight tasks, TDD throughout, against
specs/2026-08-24-signatures-design.md.
Tasks 1 to 3 build the Signatures namespace: reading the directory, the
splice for both placements, and the match guard that keeps a "-- "
delimiter from authorising a deletion. Task 3 carries a mutation check on
that guard, since it is the one piece preventing data loss.
Task 4 adds the three config keys, task 5 the editor-bar switch and the
seeding, task 6 the From: follow that stops once the user chooses. Task 7
is documentation and the Italian translation; task 8 closes the backlog
item, and deliberately hands the work over for a hand test first rather
than marking it done on a green suite.
MessageBuilder is untouched by every task, which the plan states twice: the
signature lives in the composer's buffer and both MIME parts are already
derived from it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEcn3u19xPqv6ggD15PG4c
|
|
Finding a "-- " delimiter is not enough to authorise removing what
follows it. The block is replaced only when its text matches one of the
signatures on disk; otherwise the new signature is inserted and nothing
is removed.
"-- " can reach the buffer without the user ever choosing a signature,
most plausibly pasted in with quoted text from another client, and the
unguarded scan would have deleted everything after it silently. The
failure is now directional: a block that matches is replaced, and one
that does not produces a second signature, visible in the editor and one
undo away. A wrong guess adds text rather than losing it.
Two markers were considered for the same problem and refused, both
recorded with the reasons. A zero-width character ships in the sent
message, fingerprinting the client in outgoing mail, and has to survive
the draft round trip through GMime, quoted-printable and MimeParser,
which is the pipeline that normalises such characters away. A doubled
delimiter is not the RFC 3676 separator, so no receiving client would
recognise the signature, and it would not have caught the pasted-text
case that prompted it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEcn3u19xPqv6ggD15PG4c
|
|
One markdown file per signature under ~/.config/qtmaildir/signatures/,
spliced into the composer's buffer and switched from a control on the
editor bar.
MessageBuilder needs no change: it already builds text/plain from
markdownBody verbatim and text/html from MarkdownRenderer over the same
string, so one markdown signature in the buffer yields both forms
correctly. That is the "transparent to the user" requirement the note
asked for, and it is why a two-file text/HTML variant was dropped after
being chosen: it buys designed HTML signatures at the cost of the
signature no longer being visible while composing.
The switch stays stateless. seedBody() deliberately refuses to track
"my text" and "the quote" as separate pieces, and a toggle cannot duck
that question the way the quote did; it answers it by scanning for the
last "-- " block not followed by quoted lines, so nothing can desync
from the undo stack. That same scan is what lets signature_position
offer both end (the default) and above_quote over one implementation.
The per-account key does not reopen the note's constraint: an account
seeds the choice, the switch keeps every signature reachable, and the
automatic follow stops once the user picks one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEcn3u19xPqv6ggD15PG4c
|