| Age | Commit message (Collapse) | Author | Files | Lines |
|
aSuccessfulCronSyncDrainsTheEditedAccounts named a log but no status
file, so Config fell back to ~/.local/state/qtmaildir/syncstatus.json
and the test passed or failed on the developer's last cron run.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
Escape was scoped to the pane and only cancelled an edit, so once an
event was selected nothing closed the pane again. It is a window shortcut
now, labelled Close event: with no edit open it clears the selection and
the pane hides. With an edit open it cancels only when focus is inside
the pane, so a stray Escape after clicking the grid cannot discard the
form.
That guard needed the month grid to take focus on a click, which it never
did; the view-scoped PgUp/PgDn, Delete and Ctrl+Z were likewise reachable
only by Tab until now.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
|
|
Give RepeatRule its own Q_DECLARE_TR_FUNCTIONS context so lupdate extracts
describe() and the ordinal words, mirroring the array's QT_TRANSLATE_NOOP;
without it lupdate warned and RepeatRule/last was never extractable.
|
|
|
|
save be retried
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
A vCard FN is untrusted and ContactStore faithfully decodes `\n` to a real
newline, so `Evil\nBcc: x` was inserted raw into a recipient field and flowed
through splitRecipients() to MessageBuilder. contactInsertionText() only quoted
a name carrying a comma or a double quote, so every other RFC 5322 special
(<, >, ;, @) and any control character reached the header unguarded.
The name now has control characters and whitespace runs replaced by single
spaces, and every non-empty name is quoted, with `\` escaped before `"`.
Quoting contains all the specials in one step. The parser is left faithful;
this is fixed at the consumer/trust boundary.
Tests: a name with a decoded newline inserts no control character and yields
one recipient; a name with <, >, ;, @ is quoted and yields one recipient. The
three tests that expected an unquoted plain name now expect the quoted form.
|
|
from: and to: now offer the vCard store's addresses, each quoted via
SearchTerm::quote() with the contact's name as the description. The store
is the enumerator libnotmuch does not expose, which is what the old
complete-nothing comment said was missing; it keeps that role for folder:,
subject:, attachment:, thread: and id:.
With no store configured the branch returns {} and the behaviour is
unchanged.
|
|
One shared QCompleter serves To, Cc and Bcc, attached with setWidget and
never setCompleter, which resets the prefix to the whole field and stops
matching after the first comma. The prefix is the comma-delimited token
under the cursor, set by hand from textEdited; accepting replaces only
that token and leaves the rest of the field alone.
Candidates match the name and the address case-insensitively. A display
name containing a comma is quoted on insertion, and splitRecipients() is
now quote-aware so the quoted name survives as one recipient.
Contacts reach the composer through setContacts() rather than a fourth
constructor argument, so every existing three-argument construction and
test stays as it was. An empty list leaves the fields behaving exactly
as before completion existed.
|
|
Adds Config::contactsDir(), the [general] key Task 5 will read to locate
the ContactStore. Empty means the feature is off.
The key is read WITHOUT the general/ prefix, like notmuch_config, because
QSettings' INI backend strips a section literally named [general]. Absent
or empty is silent; a set path that does not exist is reported through
addProblem(). A leading ~ is expanded by a local helper, since config.cpp
expands no other path and this is the first one to need it.
|
|
Reads a vdirsyncer contacts directory of vCard 3.0 files into a
QList<Contact> for the completion work that follows. Pure over values,
no widget and no QCompleter, so the parse is testable without a window.
unfold() joins folded lines before any field is looked at; parseCard()
splits property from value on the first colon outside a quoted
parameter, unescapes FN, and yields one contact per EMAIL line;
loadDirectory() walks recursively, skips unreadable or addressless
cards, de-duplicates on the address case-insensitively, and sorts by
name then address. N, PHOTO, ADR and TEL are deliberately not used.
23 new tests. No user-facing strings, so no tr() change.
|
|
The spam branch of populateMessageBar() was keyed on the path predicate
alone, but its only action is hidden on a reply, so a reply inside an expanded
spam conversation lost Reply, Forward and Star. Skip the branch when the
reply guard is set, so the ordinary branch populates instead.
Extend notSpamIsOfferedInTheSpamView with the message-bar assertions the
QAction-only check missed, waiting for the reply row to load first, and mark
Task 9's step checkboxes done in the plan.
|
|
Close the two Important test gaps and fold in the minor notes.
notSpamIsAbsentOnAReplyRow passed for the wrong reason: the reply node had
no filePath and the selected child was the first message, so the predicate
answered false on the empty path and hid the action with or without the reply
guard. Give the reply a real spam path and select the actual reply child;
mutation-checked that removing the guard now fails the test.
Add notSpamThreadMovesEveryMessageHome, the thread-scoped coverage
notSpamThreads()/m_pendingThreadScope/wholeThreadIds had none of, and assert
the folded thread-scoped trash Restore re-adds the inbox tag.
Rename the label Not junk -> Not spam (no free mnemonic in the Message menu)
to match the rest of the UI, with the Italian translation updated, and add a
changelog line for the thread-scoped Restore inbox-tag fix.
|
|
Backlog item 201. A message in the Spam view had no way back out: spam is
one-way and Restore is hidden outside the trash. not_spam moves each message
to the folder its moved-from: origin names, falling back to the account's
inbox (reported) for provider-caught mail with no origin.
The action is labelled "Not junk" on the free Alt+J: every letter of "Not
spam" is taken in the Message menu, and Alt+P (Re&ply) and Alt+S (Mark &spam,
frozen) are unavailable.
restoreResolvedMessages() and the undelete_thread branch are parameterised
with the cleared tag and undo description rather than copied, so Delete and
Not spam cannot drift.
|
|
|
|
|
|
|
|
|
|
Mark spam was a tag-only action that added the spam tag and removed inbox,
so a message marked as spam stayed in the inbox on disk. It now MOVES the
file into the account's configured spam folder, exactly mirroring Delete:
the account-relative spam key is the destination, the move records
moved-from: with the origin, and unread and inbox are stripped in the same
confirmed write so one undo returns the folder and the tags together.
NotmuchWorker::moveMessages already handled a folder generically and
applyTags already overwrote an older moved-from: tag, so the worker needed
no change; the tests pin that behaviour for the spam destination.
Five existing tests used spam as a worker-free, tag-only stand-in for the
old Delete. Since spam is now a move too, they are retargeted to flag, the
remaining selection-scoped tag-only action.
|
|
|
|
|
|
|
|
Item 101. The sync run was already account-aware for pending edits and
consulted the account dropdown for nothing, so looking at one account and
pressing Sync collected every one of them.
pendingSyncChannels() now reads the dropdown as well as m_editedAccounts.
The two are a UNION rather than one replacing the other, which is the whole
safety property: looking at one account while having edited another is
ordinary, and a run that dropped the edited account's channel would strand
that write with nothing on screen to say so. All accounts is the empty key
and narrows nothing, so a full fetch stays what an unselected window asks
for. The existing fallback is untouched: an account whose section names no
channel still widens the run rather than being silently skipped.
syncStartedText() is the visibility half. A run opened with "Syncing..."
whether it covered one account or all of them; it now names what it covers.
The exit paths overwrite the label with their own wording immediately, so
the parameter is defaulted rather than threaded through them.
Four tests. The narrowing one asserts the channel list is EMPTY before the
gesture, since empty is what a full fetch looks like and a test starting
from a narrowed state could not tell the fix from a window that had never
widened. Mutation-checked twice: ignoring the selection fails three of
them, and replacing the pending set instead of unioning with it fails the
union test alone, which is the version that looks correct and loses a
write.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KphFXTc2QajxXsHWyvGJ4R
|
|
Item 192's second half, answered by the user: indexing is not repainting.
The sent copy became findable the moment it was sent and a Sent view already
on screen still did not show it, because nothing re-ran the query. The model
cannot insert the row optimistically either, since item 170's constraint
applies: the query never returned that thread.
NotmuchWorker::indexChanged() is emitted at the end of both indexDraftFile()
and removeIndexedFile(), the only two entry points that change what a path
query would return without any query having run. MainWindow connects it to
refreshCurrentQuery(), which covers all three gestures a path view can miss:
a sent copy indexed, a draft saved, a draft's entry dropped on send. Wiring
only the indexing half would have left a ghost draft row visible in a Drafts
view after a send.
The signal carries nothing, so it cannot invite an optimistic insert. It is
emitted after the database closes, so a refresh reaching notmuch on the next
turn of the event loop cannot race the write handle. refreshCurrentQuery()
rather than runCurrentQuery(): a send must not clear the selection, the
expansions or the undo stack of the window behind the composer.
aSentMessageAppearsInASentViewAlreadyOnScreen drives a real send through a
worker-backed window, asserts the Sent view is empty first, and asserts the
row arrives with no second returnPressed() and no sync. Mutation-checked by
disabling the connection.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FWeBw3UqxpBktSc1AkZ6ir
|
|
ComposeWindow filed the sent copy into the account's sent folder and
discarded the path DraftStore::write() returned, using the result only to
test for failure. Nothing announced the file, so notmuch never learned it
existed, and the Sent view is a path query over the index rather than a
listing of the folder: the message was on disk and invisible until the
next notmuch new, which here is a cron tick up to ten minutes away.
Measured immediately after a send: 65 files in the account's Sent folder,
64 messages indexed for the same path.
This is item 158's defect one path over. That item established the rule
for drafts, and MainWindow wires both halves of it, so the send path was
already telling the worker to DROP the draft's entry while never telling
it to add the sent copy's. The missing half is the one the user sees.
A sentCopyFiled signal, emitted only where the write succeeded, connected
to the worker's existing indexDraftFile. That slot is generic despite its
name: it calls notmuch_database_index_file, applies nothing
draft-specific, and its previousPath already defaults to empty, which is
right for a copy that replaces nothing.
The connection is a lambda whose context object is m_worker, and that is
load-bearing. indexDraftFile takes two arguments where the signal carries
one, so a direct slot connection does not compile; the context object is
what queues the call onto the worker's thread and keeps notmuch off the
GUI thread. Simplifying it to a plain call reads as tidier and would
cross that boundary, so the comment says so.
The test asserts on the signal, on the file existing, and on it being
inside the Sent folder. Indexing itself is already covered against a real
database in test_notmuchworker; what was unproven was that anything ever
called it for a sent copy. Announcing a path that was never written is
the ghost entry removeIndexedFile exists to undo, which is why the
existence check is there.
Indexing is not repainting: a Sent view already on screen does not gain
the row from this, and whether it should refresh after a send is left as
a separate question.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jq9gXquUo9W4KXDagJXMmn
|
|
Every autosave called MessageBuilder::build(), which generated a fresh
Message-ID unconditionally, so each revision of a draft was a different
message rather than a new version of one. The entry called this invisible
while the file is replaced correctly, and that turned out to be wrong:
mbsync uploads each revision to the drafts folder before the next save
removes the local file, so the server keeps one message per revision and
syncs them all back down. Measured on real mail as four independent
messages for a single reply, all four carrying a ,U= infix, threading
into the conversation and putting a draft tag on a Sent row. Deleting a
local file does not retract an uploaded one, which is why the local
cleanup, which is correct, could never fix it.
The user chose a stable id while drafting, discarded at send: the sent
copy is a different item from the draft, and the draft is deleted once
the message goes out, which the code already did.
Three links, none of which existed. OutgoingMessage::messageId is the
field, where empty means generate, so the send path is unchanged by
construction rather than by remembering to clear it.
MessageBuilder::build() uses a supplied id when there is one.
ComposeWindow::m_draftMessageId holds the identity between revisions,
assigned from built.messageId so the first save adopts the id GMime just
generated, and ComposeContext::draftMessageId carries it across a reopen,
read in forDraft() from ParsedMessage::messageId, which the parser
already provided and nothing had ever used.
Five tests, because the property spans three objects and a test at any
one of them passes while another link is broken. Two are the safety
constraint rather than the feature: a field defaulting to a fixed value
would satisfy the reuse test and make two sent messages share an id,
which is far worse than the defect this fixes.
One comment is corrected rather than left: the autosave's dirty check
justified comparing the message rather than the built bytes with "GMime
is given a fresh Date and Message-ID on every build". Half of that is no
longer true. The Date still is, so the conclusion stands.
Revisions already on the server are not touched by this; the four found
on real mail were deleted by hand.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jq9gXquUo9W4KXDagJXMmn
|
|
Both views are lists of the user's own messages and are flat, but
walkThreads() emitted one ThreadSummary per THREAD and then picked a
single matched message to stand for it, breaking at the first one the
oldest-first walk reached. A conversation replied to twice therefore
produced one row: dated by the thread, opening the OLDER of the two
messages, with the newer one reachable nowhere in the view. Reported
against real mail, where a message sent at 12:42 was missing while the
row above it, dated 12:42, opened a message from three weeks earlier.
The same wrongly chosen message supplied firstMessagePath, so Delete or
Archive on such a row would have moved a file the user was not looking
at, silently, and mbsync would have carried it to the server. That half
was never visible.
The Sent branch now emits one summary per matched message, each carrying
its own id, tags, sender, path, date and subject. withRecipients still
selects the branch, so Sent and Drafts both get this and no second flag
can disagree with the flat-mode flag.
Ordering was a second defect under the same item, found by hand once the
rows appeared: notmuch_query_set_sort is a THREAD sort, so every row of a
thread inherits that thread's single position and an older reply drew
above a newer one. Sorting each thread's rows in place is not enough
either, since a message from another thread dated between them still
cannot land between them. Flat rows are collected and sorted as one list
before emitting.
ThreadListModel::rowKeyFor() is the second consequence and would have
broken quietly: two rows now share a threadId, and reconcile() keyed its
QHash on exactly that, so a sync would have dropped one of them by a
different route. It answers what makes a row unique, the message id in
flat mode and the thread id otherwise.
Tests cover both halves over new fixture threads F and G. The
cross-thread ordering assertion passed for the wrong reason at first,
because the existing fixture threads happen not to interleave; thread G
exists to break that and failed the moment it was added.
oldestFirstReversesTheOrder is corrected rather than satisfied:
OLDEST_FIRST orders threads by their oldest message while NEWEST_FIRST
orders by their newest, so the two lists mirror each other only while no
thread's date span contains another's, which this fixture is the first to
violate.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jq9gXquUo9W4KXDagJXMmn
|
|
Follows items 185 and 186, which established the pane's bar as where actions
on the displayed message live. Star and Archive are both selection-scoped and
fit that rule with nothing to decide; Archive leaves the main toolbar the way
Delete did, since the same icon in two places reads as two controls when the
toolbar is icon-only.
Ordered by what they do rather than by where they came from: answering the
message, then filing it, then destroying it, so the destructive button is not
between two that are not.
Mark all read deliberately stays on the main toolbar, at the user's decision.
It is the one action in this window that ignores the selection and acts on
every row in the view, so a bar whose every other entry acts on the one
displayed message is exactly where it must not be.
Item 140's toolbar test named archive as an example of a list-wide action.
That was never true of it, only untested, and this item reclassifies it: the
test now asserts archive LEFT the toolbar and keeps its guard on
mark_all_read, which is the action that genuinely is list-wide.
Closes item 189.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NY6poqw199LfFaXe5BHKNe
|
|
The pane's bar offered Reply and Forward on a message the user had thrown
away, which are the two things a trashed message is least likely to want,
while Restore and the purges lived only in menus.
The bar now has a third branch, asked before the draft one: a deleted draft
must come out of the trash before it can be edited. It is keyed on the
SELECTION being in a trash folder, the same predicate the menu entries use,
rather than on the trash VIEW, which disagree on mail reached from a search.
It carries Restore, Delete permanently and Empty trash, and only Restore is
tinted: the two purges are one act at two scopes and need no colour to tell
them from each other, only from the one action that gives mail back.
Delete moves here from the main toolbar in the same change (item 186). It
acts on the displayed message, like Reply and Forward, so it belongs on the
pane's bar by the rule items 139 to 141 settled for those two. It stays in
the Message menu and the context menu.
Delete permanently is new. It is Empty trash scoped to the selection, the
same purgeMessages() call with the ids resolved from the selection rather
than from a query, so it inherits both of that action's safeguards: it
confirms, naming the count, and it carries no default shortcut. One combined
thread:/id: query resolves a mixed selection, so a conversation and a reply
selected together still ask once.
The bar is refilled when the conversation digest arrives as well as on
selection, since a conversation's trash-ness is not known until every path
has been reported.
Closes items 185 and 186.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NY6poqw199LfFaXe5BHKNe
|
|
Item 125, the half that was genuinely missing.
Most of this item was already built and the row was stale. The exit-75
branch in onSyncFinished() predates this session and does what the entry
asks: the spinner clears, the skip is reported as neither success nor
failure, m_lastSyncFailed stays put, the log pane is not raised, the lock
latch is handed back to the external monitor, and the sync-on-exit case
has its own dialog. Item 174 then added the external half, a `skipped`
state a run the application did not start can be seen to have produced.
What nothing covered was the RE-ARM, and it is the symptom the item was
filed for. runAutoSync() re-arms when it declines to START, which is item
89 and covers a sync skipped before launching. A run that LAUNCHES, finds
the lock held and exits 75 reaches onSyncFinished() instead, and that
branch armed nothing: the edit stayed pending with nothing scheduled to
carry it, waiting for a manual sync or the next cron tick. That is "a
held edit waits for a completion that never comes".
scheduleAutoSync() in the skip branch. It re-checks the delay, the sync
command and the pending count on the way in, so it cannot arm a sync for
nothing, and against a long external run it re-arms once per debounce
interval until the lock clears.
This is the half the status file could not reach, and the distinction is
worth keeping: that file says what a run DID, this is what the
application does NEXT.
The test records a real pending edit first, since runAutoSync() correctly
declines when there is nothing to carry and a fixture without one would
arm nothing for a legitimate reason. It failed before the fix and is
mutation-checked.
Suite: 43 of 44, with undoMovesTheMessageBack failing as it does on
master (item 136).
|
|
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).
|