| Age | Commit message (Collapse) | Author | Files | Lines |
|
The spec said "disabled with a spinner" without saying where, which
leaves a popup as a reasonable reading of it. A popup is wrong here: it
would be modal over a window that is already disabled, and it can be
dismissed while the operation continues, which is the indicator
ambiguity items 18, 19, 28 and 54 each closed once.
Progress goes in the composer's own status bar, through the three
stages the operation actually has, since a failure filing the sent copy
means something different from a failure sending. The window closing is
the success message.
The indicator is an indeterminate QProgressBar built inline, matching
MainWindow's m_syncProgress rather than factoring out a shared widget:
this codebase builds small UI inline, and two progress bars do not
justify a third class.
Also settles what the staged display implies for a sent-copy write that
fails after a successful send: the composer still closes, because
holding it open for a message already sent invites sending it twice,
and the warning goes to the main window's status bar instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDq53rMd3AQp7QmcZzpuBM
|
|
The spec said "the editor is plain text" and left it there, which reads
as "you are on your own with the syntax". Storage format and editing
affordances are separate decisions and only the first was stated.
The toolbar is text transformation over the markdown source, not
rich-text editing: bold, italic, code, strikethrough, link and quote,
selection-aware, with the cursor landing between the tokens when there
is no selection.
Its shortcuts belong to the composer window's own scope and do not
touch KeyMap, which matters for item 132: the two namespaces should not
be conflated when that rule is revisited.
Live syntax highlighting is a follow-up (item 133) rather than part of
this: agreeing with the grammar about nesting and about code spans is
the expensive half, and it is better judged after living with the
toolbar.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDq53rMd3AQp7QmcZzpuBM
|
|
The spec called it a new dependency needing a SlackBuild REQUIRES entry.
It is a new dependency, but /var/log/packages/ shows
cmark-gfm-0.29.0.gfm.13-x86_64-3 with no _danix tag, so it is stock and
REQUIRES lists only non-stock dependencies.
Also records the staleness cost accepted with it: cmark-gfm tracks an
older CommonMark base (0.29 era) than the stock plain cmark (0.31.2).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDq53rMd3AQp7QmcZzpuBM
|
|
Brainstormed with the user. Design only, no code, which is what the
item's #plan-only tag asked for.
The decision that shaped everything: there is no MTA on the machine, so
"an external script on the same model as mailsync.sh" had no model to
copy. Send becomes a per-account send_command taking the message on
stdin, exactly as [sync] command already works, which keeps the
no-network-protocol rule intact without naming an MTA.
An account with no send_command is receive-only by construction, which
is how one of the five accounts is meant to work. Reply, reply-all and
forward are disabled on its mail behind a ribbon that says why.
The body is markdown parsed by cmark-gfm rather than a hand-written
parser for a limited set: the two share no code, so the small one is
deleted wholesale the moment the set widens.
Four new units, three of them widget-free and testable without a
painter. MessageSender is deliberately a separate unit rather than a
method on the composer, so a future outbox wraps the funnel instead of
reworking it.
Item 123's section is replaced by a pointer to the spec, per this
document's own rule for a fully specified item. The brainstorm opened
items 128 to 132, including a review of the every-action-has-a-shortcut
rule, which the user raised: six more actions takes it past the point
where a chord for everything is useful.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDq53rMd3AQp7QmcZzpuBM
|
|
Item 103's measurement is done, so its section carries the finding and the
three constraints that decide whether to open the spec, rather than the
design inline.
Item 118 is blocked on 103 and is the first action that would destroy mail
with no undo, which is why it is filed separately rather than folded in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Measures what Delete does today (a notmuch tag and nothing else, verified
against the tag-to-flag table and a probe on a throwaway database) and
specifies the move-to-trash behaviour that replaces 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>
|
|
The user's end state for the query row is built-in filters only, with every
saved query living in the menu. At that point SavedQuery::pinned has nothing
left to decide.
Blocked on 93 and deliberately separate from it: the four buttons have to be
lived with first, and if one of them is wrong, pinning is the escape hatch,
which has to still exist to be used.
Recorded as a user-visible removal rather than a cleanup. pinned shipped in
0.18.0 as a checkbox in SaveQueryDialog and a right-click action, so removing
it is a minor bump with an Upgrading note. The stored field is a separate
decision from the UI, and leaving it in queries.json unread is both cheaper
and reversible.
The spec for 93 gains the ordering rule this resolves: filters first in fixed
order, the user's pinned queries after them, and nothing configurable, since
the mixed row exists only until 94 lands.
|
|
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 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>
|
|
Two decisions the user made, both recorded with their rejected
alternatives so they are not revisited.
Excluding from an empty query would mean the whole Maildir minus one
value. The menu entry is greyed rather than hidden when the query bar is
empty, so the feature stays visible to someone exploring a fresh window,
and SearchTerm::exclude returns empty for that case as a second layer
against a caller that forgets the guard.
The menus cannot see the query bar, so MainWindow pushes the fact down
through MessageView::setHasQuery from the textChanged lambda it already
runs for the Save button. A callback was rejected as an indirection with
one implementation; silently doing nothing was rejected because a live
menu entry that does nothing is worse than a greyed one.
The backlog entry loses its inline approach and points at the spec,
carrying the three constraints that decide whether it can be picked up.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Eight TDD tasks. SearchTerm and the date parse first, since both are pure
logic testable without a widget; then one surface per task, the window's
wiring, and the suite check.
Two findings while planning, both now in the spec. The Date: header parse
already exists inside a file-local function in mimeparser.cpp, complete with
the fix for Qt::RFC2822Date rejecting a trailing timezone comment, so it is
extracted rather than rewritten. And queryRequested is the right precedent
but the wrong signature: these actions carry a replace-or-narrow flag, so a
second signal sits beside it and the placeholder links keep their gate.
|
|
Item 78 asked for a tagging rule built from something visible in a message.
Brainstorming narrowed it: a saved query can already be promoted to a rule,
so the road from "I see something interesting" to "a rule tags it" exists as
search, save, promote. Searching is the missing step and the safe one, since
a query costs nothing when it is wrong while a rule runs unattended against
real mail.
The search half splits out as item 85. Five surfaces gain a context menu with
Search for this and Add to search: subject, date and From/To/Cc in the header,
tag chips, body selection, and every header per message in the details dialog,
which is rebuilt as rows rather than one text box.
Item 78 stays open carrying the rule shortcut alone.
|
|
Item 81. A context-menu action on a stored saved query, seeding the rules
dialog with the query and a sanitised id, tags left empty and focused.
Three decisions worth the record. The backlog's own proposal, a checkbox
in the Save query dialog, is rejected: it would make one dialog write both
queries.json and the shared rules.json, and SaveQueryDialog is
deliberately pure UI that writes nothing. Generated entries are excluded,
since their query is composed from the accounts at runtime and a rule made
from one would freeze a snapshot that goes stale when an account is added.
And the empty tags are load-bearing rather than an omission: validate()
refuses a rule that tags nothing, so the one field the user must supply is
the one the dialog opens on.
This also turns out to be a single-repo change. The rule it creates is an
ordinary one, so mailrules.py is untouched; the backlog's note that item
81 spans two repos was about the file it lands in, not the work.
Item 78 becomes a second caller of the same seeded-dialog path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
The spec asked for "a Save query button beside the search bar" and what shipped
was a menu entry and Ctrl+S. The user went looking for the button where the
design said it would be and did not find it.
Saving is a thing you decide on while looking at the results, so it belongs
where the results came from rather than behind a menu or a remembered chord.
The button takes the action through setDefaultAction rather than a second
connect, so it inherits the text, icon, tooltip and enabled state and cannot end
up offering to save an empty query while the menu entry correctly refuses. The
mutation that replaces it with a plain clicked() connection fails the test.
Also records item 82: a saved query cannot be edited, unpinned or deleted from
the UI. Item 23 specified saving and nothing else, and that is exactly what was
built, so the only way to unpin a query is a text editor or retyping it in full
under the same name. An action that creates something the UI cannot then change
or remove is incomplete, and this was found within minutes of the first hand
test. It is filed as a defect rather than an enhancement, and the spec now says
so where a reader would otherwise take the design for complete.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Item 23 had grown past what a backlog entry should hold: a storage format, a
migration, a dialog and a layout change. This document's own rule says a fully
specified item moves to specs/ and leaves behind the two or three things that
decide whether it can be picked up, the way items 53, 63 and 76 went. The entry
now carries the observation, the three deciding constraints and the relation to
item 10, and points at the spec for the rest.
The spec pins what was still loose. The JSON is an ordered array, since the
ordering is the whole reason for moving off [queries], and nothing may sort it
on load. A query's account scope stores the account KEY, the INI group suffix,
rather than the maildir path, so it does not duplicate config that already lives
in one place and go stale when the user edits it; the scope then composes
through Account::scopedQuery(), whose parenthesisation is load-bearing for the
same reason it is in the rules hook, an unparenthesised disjunction escapes its
scope and matches every account.
Migrated entries are pinned, so the query row does not silently empty on the
first run after upgrade, and migration order is alphabetical because that is
genuinely all the INI knows. Sent stays out of the file: it is generated from
allSentQuery() rather than stored, and folding it in would mean writing a
per-account path query into stored config, which is the duplication the
account-key decision just rejected.
The testing section is written against the traps already recorded in CLAUDE.md.
The migration test asserts the INI file is byte-identical rather than re-reading
it through QSettings, which would pass against a rewrite that preserved values
while dropping comments; the round-trip test asserts order, which is the
property the INI could not provide; and the dialog is left to a hand test,
because the offscreen platform cannot assert sizing at all and a Cancel goes
through done(int) rather than closeEvent.
Every code reference in the spec was checked against the files rather than
copied from the entry.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Ticking "Edit as text" was a one-way trip: the only way back to the rows
was closing the dialog and reopening it. The checkbox was parented to the
builder widget and sat on the match row, and switching to text mode hides
that widget, so the toggle disappeared along with the rows it governs.
Move it to the query row, which is visible in both modes.
The existing tests all passed against this, because they drove the toggle
through setChecked and then asserted on the checked STATE. A hidden
checkbox reports its state perfectly well, so every one of those
assertions held while the widget was unreachable. The new test asks the
question that matters, whether the toggle would be on screen, and it uses
isVisibleTo since nothing is isVisible on a dialog that was never shown.
Worth recording how close the mutation check came to endorsing this too.
Reparenting the checkbox alone left it in the query row's layout, so it
stayed visible and the test still passed. Only restoring the full shipped
shape, parent and layout together, reproduced the fault and failed the
test. A mutation that does not reproduce the original bug proves nothing
about the test that is meant to catch it.
The spec's layout sketch carried the same error and is corrected, with
the reason, so the next reader does not reintroduce it.
|
|
The spec listed from:(((( among the queries the parser must reject, and
the plan's Task 6 asserted that rejection. Probing the built parser shows
it accepts the query as a From row whose value is the literal text, and
compiles it back byte for byte.
That is correct behaviour, not a leak in the strictness rule. notmuch
reads those parens as characters to search for rather than as grouping,
so the query is meaningful and the row displaying it tells the truth.
Rejecting it would buy nothing and would push a representable rule into
text mode.
The distinction the documents were missing: a parenthesis inside a VALUE
is not a shape question at all, only a parenthesis in grouping position
is. Restate both documents accordingly, and replace the assertion with a
round-trip one, which is the property that actually matters here.
|
|
The standing backlog reconciliation found four unrecorded entries in the
user's notes, all fallout from item 44's rules dialog now that it is in
daily use: the window forgets its geometry and column widths (75), every
field is free text (76), a rule cannot be previewed against the thread
list (77), and there is no way to build a rule from something visible in
a message (78). Each cause is verified in the code rather than copied
from the note.
Item 76 then went through a brainstorming pass and has a design. The
shape is Thunderbird's filter window, which the user supplied as the
reference: field and operator dropdowns, +/- buttons per row, an all/any
radio, and a separate "but not" block.
The structural point is that Thunderbird owns its filter format and this
project does not. The storage is a notmuch query string shared with
mailctl and executed by the post-new hook, so the builder is a view over
a string rather than a store. That decides the rest: the stored format
is untouched and this stays a single-repo change; a query the builder
cannot represent still opens, saves and runs, in a text mode every rule
carries; and the string is rewritten only when the rows actually
changed, compared against the parsed value rather than tracked with a
dirty flag, which Qt sets during programmatic population.
Measured against the seventeen rules in the live store, sixteen are flat
and one nests an or group inside an and chain, which is what the
exclusion block exists for. The parser is strict by design: it
recognises a query whole or rejects it whole, because a lenient parser
that salvages what it understands is how a not clause gets dropped and a
filter silently widens.
|
|
Item 44 sat as "open, unspecified" because nothing in this application
applies rules at sync time, and the item could not be planned until it
was known whether such rules existed anywhere. They do: the notmuch
post-new hook holds hand-written `notmuch tag` lines scoped to tag:new,
carrying their reasoning in shell comments.
The design moves them to a tool-neutral JSON store that both qtmaildir
and mailctl read, with unknown fields preserved across a write by either
tool so neither owns the format. A rule carries no scope, so the same
rule serves the hook, a dry run and a future backfill.
Also in this pass:
- Item 61's cause is established, not open. It is the user's cron sync
holding the mbsync lock: 0 failures in 30 runs with no lock held, 30
in 30 with one held. The fix is item 38's existing seam applied across
the suite. The document still said "not established" and proposed a
load hypothesis that had already failed to reproduce.
- Item 74 records the first-start latency measured this session. The
delay is the notmuch index paging in from disk, 5714 ms cold against
154 ms warm for the same 4444-thread query, and is not addressable
here. What it did expose is a real defect: the status bar holds
"Searching..." for the whole walk while rows are already arriving.
|
|
Adds a `sent` key to [account.*] naming that account's sent folder, and a
Sent button beside the saved queries that composes its query from every
account carrying one. An account without the key is omitted silently, as a
real account may keep no sent mail locally. With no account selected the
button spans all of them; selecting one narrows it through the existing
scope wrap rather than a second path.
Composed at run time rather than shipped as a [queries] entry. A saved query
is one fixed string: it cannot narrow to the selected account, and it goes
stale the moment an account is added or a provider renames a folder.
The design and the measurements behind it are in
docs/superpowers/specs/2026-08-11-sent-mail-design.md. Three things there are
worth repeating here.
The composed path is QUOTED, and that is load-bearing. A real provider nests
its sent folder under a bracketed parent, and "[" and "]" are Xapian syntax:
unquoted, the query parses rather than matches and returns nothing while
looking entirely plausible. Composition happens in one place so there is one
chance to get it right, and a bracketed path is pinned in a test.
Recipients are opt-in per query, which is a performance contract rather than
a preference. notmuch_message_get_header(m, "To") is not served from the
index, it reads the message file: folding every thread of a 4411-thread
inbox took 38.2 seconds against 251 ms for the 601-thread sent view. The
worker skips the walk entirely unless asked, and the refresh path carries the
same flag so a background sync cannot blank the column mid-read. Always
folding is mutation-tested: the data would be right and only the cost wrong,
which nothing else here would notice.
The messages reached through the thread are owned by it and freed with it, so
recipientsOf() holds them raw and finishes while the thread is alive, exactly
as walkReplies does. An NmMessage wrapper there is a double-free.
Sent mail is presented flat, and the pane follows. A message you sent
otherwise drags in the replies you received, so a view labelled Sent shows
conversations rather than what you sent. ThreadListModel::setFlatMode() makes
hasChildren() and ReplyCountRole answer differently and changes nothing else;
runQuery() sets it on EVERY run, so any other query restores the tree on its
way through and the flag cannot outlive the button that set it. The pane
needed its own fix for the same reason: the single-message path depends on a
field only filled when a thread is expanded, which never happens in a flat
list, so loadThread() gained matchedOnly and drops the messages that did not
match instead of rendering them as stubs.
Recipients replace the sender through the existing SendersRole rather than a
new one, so the delegate needs no branch and cannot disagree with the model
about which name a row shows. It falls back to the sender when a To header is
absent or unparseable, since a blank where a name belongs reads as a
rendering fault.
Address parsing uses GMime: a display name may contain a comma, so
"Rossi, Mario" <m@example.org>, info@example.net is two addresses and
splitting reports three. internet_address_list_parse returns NULL for an
empty string, which is a crash if unguarded.
Backlog item 63.
|
|
The backlog entry could not be planned because it turned on a fact about the
user's mail rather than a design choice. Measured against the real database: no
`sent` tag exists at all, so the XS branch is dead. Every account keeps sent
mail in a folder, and the folders disagree across three shapes, with one
account having no sent folder whatsoever. That rules out a `<maildir>/Sent`
convention and forces a per-account key.
Decisions recorded: a per-account `sent` key composed at run time rather than a
shipped [queries] entry, which cannot narrow to the selected account and goes
stale when an account is added; an account without the key omitted silently, as
`drafts` already is; all-accounts combines and one account scopes through the
existing wrap; and recipients on the card in a Sent view, since the sender is
the user on every row.
Three constraints came from probing rather than memory. A bracketed provider
path contains `[` and `]`, which are Xapian syntax, so quoting is load-bearing
and the composed query returns its real count only because each path is quoted.
notmuch has get_authors and no recipients call at any level, so the To summary
must be folded per message in the worker, under the same thread-ownership rule
as walkReplies. And GMime's internet_address_list_parse returns NULL for an
empty string, which is a crash if unguarded; it also parses a comma inside a
quoted display name correctly, so comma-splitting is out.
Size revised M, up from S: the recipients half is a new ThreadSummary field, a
worker-side per-message walk, the first GMime address parsing in this codebase,
and a card that has to know which view it is in.
The spec lives in its own file rather than inline. The backlog is past four
thousand lines and its job is to say what is open and how big, which a hundred
lines of design buries. The entry keeps the finding and the size and points at
the spec, following item 53. "Adding to this document" now states the split
rule so the next specified item does not land inline again.
Account keys and folder names in the spec are placeholders. The counts and the
folder shapes are the real measurement and are what the design turns on.
Backlog item 63.
|
|
The plan left the account chip unspecified, because where it sits on a card was
never decided and inventing a place would have been a guess. The user's answer
replaces it rather than placing it: a coloured bar down the card's left edge,
the reply spines inheriting that colour, and matching swatches in the account
dropdown.
It is a net simplification. The chip ate a third of line 2 on every card to
repeat a name the user already knows, which is the table-of-records texture item
53 is about; the bar says the same thing in a few pixels and leaves line 2 to
the subject.
Three details that are easy to get wrong and are specified rather than left to
the implementer:
- The raw account colour is never drawn as a line. It is chosen to be a
chip's FILL, with text drawn on top in whatever stays legible against it. A
thin line on the pane's own background has a different job: followable down
a long expansion without competing with the senders beside it. The accent
blends toward QPalette::Base by the same 0.35 weight threadLineColour()
already uses, keeping the hue and dropping the shout. The dropdown swatch
does use the raw colour, being a filled patch rather than a line.
- A reply resolves its THREAD's colour by walking to the root.
AccountColourRole is empty on a message row, so a spine reading its own
index would fall back to the neutral line under an accented root and break
the continuous edge the design is built on.
- Reply cards carry no bar of their own. Two vertical lines a few pixels
apart in one gutter is what option B looked like, and the spine already
carries the accent.
colourFor() never failing is kept deliberately: an account with no colour= key
gets a stable colour derived from its tag name, so adding an account and
forgetting to colour it degrades to something usable rather than to nothing.
kAccentWidth ships at 3px as a starting value. Whether five accounts are
tellable apart at that width, on this user's screen and theme, is not decidable
from a mockup or a test, so Task 10 gains a step that settles it against real
cards, in both themes, with the guidance to widen the bar before touching the
user's own colour choices.
|
|
Both items the spec left open are settled, and one of them turned out to be a
defect rather than the addition it was filed as.
Item 20 deferred "move between messages without returning to the list" as an
addition on top. It is a repair: next_thread and prev_thread are
selectRow(current.row() + 1), and a tree numbers rows per parent, so from the
last reply of an expanded thread row+1 names a sibling that does not exist and
the action silently does nothing. rowCount() with no argument counts top-level
threads, compounding it. Recorded as item 60, since it exists on the branch
whether or not the card list is built, and it is a fresh instance of the exact
rule that branch's own commit message states.
The user asked for arrow keys to skip within a thread and Shift+arrows to skip
between threads, scoped so the keys still work normally in the message pane,
menus and entry bars. Two findings changed the shape of that:
- Up/Down need no binding and no code. QTreeView's built-in navigation walks
VISIBLE rows, so it already steps into an expanded thread's replies, and
being the view's own key handling rather than a shortcut it is inert
whenever focus is elsewhere. The requested focus behaviour is automatic.
- Arrow keys must never become keymap actions. Every action is a QAction with
WindowShortcut, dispatched before the focused widget sees the key, and Qt
withholds only plain LETTERS from editable widgets. Return already proved
this by breaking the query bar and needing a per-widget ShortcutOverride
filter scoped to one widget and one key. Up as a window shortcut would break
the query bar, the tag dialog and the web view at once.
Shift+Up/Down was rejected for thread-jumping: it is the built-in
extend-selection that multi-row tagging and item 20's action scope depend on.
Alt+Up/Down instead, verified free across the whole keymap.
The way back to a whole thread after clicking a reply is the root card, which is
always visible above its replies. Escape is deliberately not overloaded, since
it already means clear-selection with clear-pane on Shift+Escape, and a third
meaning is the half-an-action problem item 50 exists to fix.
The remaining open item is only whether the message pane should change to match,
which stays out of scope: this spec is the left pane.
|
|
Item 53 recorded that message rows read as a table and left the approach
unspecified, with four directions ranging from spanning columns on reply rows to
abandoning message rows entirely. The user's answer is wider than all four: the
column grid is wrong for the WHOLE left pane, threads included.
Threads and replies both become cards in a single column, three lines each, at
one uniform height. Sender and date, then the subject with the flag, attachment
and reply-count marks inline, then the tag chips. Replies indent by depth with a
continuous spine, capped at depth 4.
Three decisions worth their reasoning, since each closed an option that looked
cheaper:
- Uniform height keeps setUniformRowHeights(true), which is the single
cheapest property of the design. A blank third line under untagged cards
buys constant sizeHint arithmetic everywhere else.
- Uncapped indent with a horizontally scrollable pane was asked for and
rejected: it reopens item 51 in a worse form. Cards are viewport width, so
the pane has no horizontal scroll range at all, and item 51 is resolved for
free rather than fought.
- A reply's line 3 shows only the tags its thread does not have. The full
per-message set was rejected on measurement, not taste: of 48691 messages in
the user's database, 7 carry unread and 75 carry flagged, and both are
already shown as the sender's weight and the mark on line 2. Everything else
is applied per thread and identical on every message in it, so full sets
would render blank on nearly every reply and identical chips on the rest.
The design is a net removal. ThreadListView::paintEvent, the tag strip's band
arithmetic, SubjectDelegate, RowStyleDelegate, the five Column enumerators and
the decorative header all go; one CardDelegate paints the whole card. That
retires the two bug classes CLAUDE.md documents for the strip, a deleted row cut
in half and every other row showing a bare stripe, both of which existed because
the strip spanned cells it did not own.
The column header was decorative, so a sort dropdown adds a feature rather than
replacing one. Two entries only, newest and oldest, passed to notmuch. Sorting
by sender or subject would have to happen in the model after results arrive,
which fights the batching that makes a 10k-thread query paint immediately.
Item 51 is marked resolved by 53 rather than left as separate work.
|
|
Real Maildir account keys had reached comments and test data:
provider-and-mailbox names across three source files, one of them
carrying a surname, plus a real address used as example data in the
notmuch test fixture and the design spec.
The user's standing rule is that maildir and account names never reach a
commit, and this is about to become a public repository, which is what
makes it consequential rather than untidy. Replaced with generic keys
that carry the same shape, since the length is the point in every one of
these comments: a 33-character account tag is why the chip label exists
and why the tag column was removed.
The measurements stay. They are the evidence behind those decisions and
are not personal details.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Twelve tasks, TDD throughout, tokenizer first: the parsing rules are where
the defects will be and they need neither a widget nor a database to test.
CompletionEntry gets its own header. Config needs it and QueryCompleter
needs Config, so declaring it in querycompleter.h would make the two headers
include each other.
Also corrects the spec: NmTags already exists in nmraii.h, it does not need
adding.
|
|
Mimetypes are the one completion list with no enumerator and an open-ended
set, so [completion] extra_mimetypes appends to the built-ins. Appending
rather than replacing means a typo or a short list cannot leave completion
worse off than the defaults.
Entries are comma-separated with an optional description after '|'. The two
separators differ because QSettings splits comma lists itself, so a
description containing a comma would otherwise be torn into two entries.
The other lists stay fixed: prefixes come from notmuch, paths from the
configured accounts, dates are closed once symbolic and relative forms are
covered, and tags are read from the database. A user-editable copy of any of
them would only drift from its source. For prefixes specifically, the upgrade
path is deriving the list from the installed notmuch, not making it editable.
|
|
Design for backlog item 17. Completion covers query prefixes, tag values,
date values, path values and mimetype values, each carrying a description
so the bar documents the query language while it is typed.
A new QueryCompleter class owns it, with the cursor-context tokenizer as a
pure function so the parsing rules are testable without a widget or a
database. NotmuchWorker gains an all-tags call, which did not exist.
Addresses for from:/to: are out of scope: libnotmuch exposes no all-addresses
call. Both prefixes still appear so the vocabulary reads complete.
Account maildirs belong to path:, not folder:. Account::scopedQuery builds
path:"<maildir>/**", and folder: is a different matcher in notmuch, against
the folder name rather than the directory path.
Also records the translatable-strings rule in CLAUDE.md. Existing code is
not yet audited against it.
|
|
The manual verification notes named the real threads used to test against a
live mailbox. Most of it was incidental, but one was not: the automated
sender identified in items 12, 13 and 16-18 is a healthcare provider's
patient-notification service, and naming it disclosed a medical
relationship. Also removed a correspondent's username, a locatable GitHub
thread id, a real maildir name in the spec's example config, and absolute
home paths.
Every finding is restated generically and none lost substance: "a message
with 3 inline cid: parts" carries the same technical weight as naming the
sender did. Counts and timings are kept deliberately, since those are the
evidence behind the claims rather than anything identifying.
Commit history and the v0.1.0 tag were checked and were already clean, so no
history rewrite is needed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Runs the configured sync script through QProcess, merging stdout and
stderr into one log so a failing mbsync run has something to show. The
script is never run through a shell: the command is a config value, and
splitCommand keeps its arguments literal.
Two fixes against the drafted version:
- start() no longer calls waitForStarted(). It blocked the UI thread for
up to five seconds, which contradicts the spec's requirement that the UI
stay usable during sync, and it swallowed launch failures into a bare
false return. A missing script now surfaces asynchronously through
errorOccurred as finished(false, -1) with an explanatory log line, so
the spinner cannot hang with nothing to explain it.
- Removed a double-emit guard I had added on the assumption that QProcess
follows errorOccurred(FailedToStart) with finished(). Verified it does
not: FailedToStart is emitted instead of finished, never before it. The
guard was dead state and the comment justifying it was wrong.
Also corrects the sync interval throughout: the user's cron runs every 10
minutes, not hourly. The shorter interval strengthens the flock rationale
rather than weakening it, since collisions with a manual sync are that
much more likely.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
The spec deferred NotmuchWorker to manual verification on the grounds
that testing it needs a real notmuch database. Building a fake one in a
temporary directory answers that objection, so Task 8 now gets real
coverage.
This is the only code in the project that writes to a notmuch index, so
a bug there corrupts real mail state. It was the wrong place to have the
weakest verification.
Task 13's manual checklist stays, as confirmation against real data
rather than as the only coverage.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
The interceptor previously trusted the whole qtmaildir: scheme, so a
hostile message body referencing qtmaildir://anything would have been
allowed. That made the interceptor's correctness depend on the scheme
handler in a different, later task.
It now trusts only the exact base URL passed to setHtml() and fails
closed when none is set. Records the resulting obligation on MessageView
to call setDocumentUrl() with that same URL.
Also corrects the attachment path guard to a separator-aware comparison
and notes that it is unreachable defence-in-depth: safeFilename() runs
first, so no caller-supplied name can reach it. A test driving saveTo()
expecting refusal cannot pass; test safeFilename() instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Accounts use [account.work] rather than [account/work]: QSettings' INI
backend treats "/" as its own hierarchical group separator, so a literal
slash in a section header parses as a nested group and trips
QSettings::FormatError, silently breaking childGroups() enumeration. A
dot carries no such meaning and keeps the format flat.
Saved-query order is alphabetical (QSettings::childKeys() sorts), not
file order; documented in code and tests rather than left to a false
assumption.
|
|
Both were narrowed in the first draft and are now in scope. Newsletter
threads make whole-thread rendering fundamental, and bulk archive is the
main thing a GUI buys over neomutt.
Two consequences fell out of thread rendering and are recorded in both
documents. The thread renders as one document in one web view rather than
one view per message, since a view each would spawn a Chromium render
process each. Sharing a document makes cid: references collide when two
messages use the same Content-ID, which is common across newsletters from
one sender, so every reference is namespaced to cid:<prefix>!<id>.
Deciding which messages render expanded needs the query, so loadThread now
intersects it with the thread and MessageRef carries a matched flag.
Batch tagging resolves thread ids to message ids on the worker in one
combined query. Undo stores thread ids and re-resolves, staying correct
after the selection moves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Qt6/notmuch mail client, GUI counterpart to neomutt. Read and organize
only in v1; compose and send deferred to v2.
Key decisions and their reasoning are recorded in the spec: C++/Qt6 over
PySide6, QtWebEngine over QTextDocument for HTML mail, GMime for parsing,
direct libnotmuch rather than shelling out to mailctl, and calling the
existing sync script rather than reimplementing it so qtmaildir stays
inside the flock guard shared with the hourly cron sync.
Dependency availability verified on the development machine: Qt6 6.11.1
(WebEngine included in the monolithic package), libnotmuch 0.39, GMime
3.2.15. Notably notmuch installs no notmuch.pc, so CMake must locate it
without pkg-config.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|