| Age | Commit message (Collapse) | Author | Files | Lines |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Sixteen TDD tasks from the libical build wiring to the hand-test
hand-off. Four rulings against the spec are recorded at the top:
calendars_dir absent means off, preserved properties are compared by
value rather than bytes, views read CalendarItem, and expansion runs
from DTSTART without icalrecur_iterator_set_start.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
Its section moves to the closed-items file with the execution record,
and the status row points there. The reconcile against the user's notes
found every open note line already covered by an existing item.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
Month and agenda views over the vdirsyncer vdir, editing in the side
pane, atomic stale-checked writes with a debounced sync and a post-sync
comparison, and undo for create, edit and delete. Names and colours come
from the vdir's own metadata files; qtmaildir depends on neither khal
nor its config.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
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.
|
|
Translate the new config warning for contacts_dir, add the README
subsection with the vdirsyncer path and the Akonadi warning, correct
the query bar's from:/to: entry which had become false, and add the
[Unreleased] changelog entry.
|
|
MainWindow reads contactsDir() once while building its UI, holds the
result in m_contacts, and hands it to the query bar's completer right
after that is constructed and to every ComposeWindow as it opens. An
empty contactsDir() skips the call, so a machine with no address book
pays nothing and warns about nothing.
The directory is not watched: a restart picks up a vdirsyncer update,
and a QFileSystemWatcher would be a live-index feature nobody asked for.
No action is added, so none of the five places in "Adding an action is
FIVE places" applies: there is no name in KeyMap::knownActions(), no
default binding, no icon-table entry and no menu entry to add.
|
|
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.
|
|
Item 204, six tasks, no spec: the shape was settled with the user on
2026-09-18 rather than brainstormed, so the plan carries the four decisions
itself (the config key, loading once, matching on name and address, and the
four vCard fields that matter).
No new dependency. libical is installed but its vCard parser is 4.0 and this
machine has 3.0.20, and libicalvcal is the old vCalendar converter rather
than a vCard reader, so four fields of a hand parse is proportionate.
The measurements are in the plan because two of them change what a correct
implementation looks like. Of 117 cards in the real store only 14 carry an
address, so a short candidate list is right rather than broken, and a fixture
where every card completes would not be representative. The store is
unfolded, so the folded-card fixture is the one the real data could never
have caught.
The largest trap is one this repository has already paid for twice:
QLineEdit::setCompleter() is unusable for a field holding a list, and a test
written with setText() passes against that bug because setText does not drive
a completer at all. The plan requires typed keys and a mutation check on the
second-recipient case.
The query bar half comes with a correction: querycompleter.cpp:652-656 says
addresses need an enumerator libnotmuch does not expose, which stops being
true for from: and to: the moment the store exists.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
The 2026-09-18 pass over the user's notes. One new defect, one item split
into five, and one stale blocker cleared.
Item 203 is new: marking a message as spam crashed the application, and the
user can replicate it. The cause is NOT verified and the entry says so. The
path was read without finding a null dereference, and the account the note
names now carries a spam key, so the observation may predate 0.29.0's config
change. It needs a reproduction with the terminal output or a backtrace
before it can be worked.
Item 72 held four different features in one line of the notes, which is why
it sat unplannable for six weeks. The user named what they want, so it splits
into 204 (recipient completion, in the composer and the query bar), 205
(editing contacts), 206 (a calendar window: its own top-level window and
libical, both settled by the user) and 207 (invitations, blocked on 206).
Item 208 came out of the same conversation: an "add to contacts" gesture,
which writes a vcard and so lands on 205's writer rather than 204's reader.
A sender's card in the message pane was offered and declined; the notes'
edge-tts vocal reminders are their own project, since nothing here runs on a
timer. 72 stays as the index entry the notes' single line maps to.
The data those items need is on disk and was verified rather than assumed:
117 vcards and 312 events under the vdirsyncer paths, libical 3.0.20 with
headers and a .pc file. Two traps are recorded because they cost a wrong
answer each: the contacts vdir is not the obvious path, which belongs to
Akonadi, and libical does not parse vCard at all (that is 4.0; libicalvcal
is the old vCalendar converter), so contacts are a hand parse.
Item 123 (send) was still marked "specified" although it shipped, so five
items read as blocked on it. Both corrected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
187 (Spam view), 190 (Mark spam on the bar + icon), 195 (Mark spam leaves
unread), 197 (no way to say not spam), 201 (un-spam from the Spam view) and
202 (spam kept inbox). Sections moved to the closed file and the status rows
mark them done.
|
|
|
|
|
|
|
|
notmuch's new.tags applies inbox to every newly indexed file and the Inbox
filter is tag:inbox, so spam-folder mail appeared in the Inbox view. Add
spam to the post-new hook's NOT_ARRIVALS set so the existing all-files
carve-out covers it, and rename the sent_* helpers to not_arrival_* now
that the list means more than sent mail. Trash stays out: measured 0
affected files and it is out of scope.
|
|
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 200. Another program that knows which message it cares about has no way
to say so: qtmaildir accepts no arguments beyond --version and --help.
Three decisions from the user shape it. A second launch STEERS the running
window over a QLocalServer rather than opening a second one, which is what the
note's own framing needs, since a caller will usually find the client already
running, and it fixes the two-notmuch-handles problem that exists today as a
side effect. The selectors are --account, --thread and --message; --query was
dropped as the most general and the one with no caller, the query bar already
serving the only party who would type one. A selector matching nothing opens
the window normally and names the miss in the status bar, rather than showing
an empty result that makes a stale link look like a broken client.
The design shrank on one side and grew on the other. recoverStaleThread()
already runs thread:<id>, holds its target across the two queued round trips a
load takes, expands when the row arrives and selects the message when the
replies land; item 91's double-click reuses it and a CLI selector is a third
caller, so the selectors are the small half. The socket is the real work:
connect-first ordering, which is also how a stale socket file is detected,
stale-socket recovery, and a degrade path that starts the window anyway when no
socket is possible. It adds Qt6::Network to a component list that is currently
Widgets, Svg and WebEngineWidgets.
Quoting is the security-relevant part and is called out. Existing code
interpolates ids unquoted because they came from notmuch itself; an id from
argv did not, so every one goes through SearchTerm::quote(). notmuch parses
garbage happily and matches zero, so this cannot be checked by asking it.
Raising the window under a Wayland compositor is handed to the user to look at
rather than tested: it is compositor policy and the offscreen platform reports
the same result whatever the code does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P3HQXLauwQgzxR4YfJBB3x
|
|
Three entries in the user's notes had no item here, all verified in the code
rather than copied from the note:
- 198, the unsynced-changes list naming no account. PendingChangeRow carries
subject, action, startsMessage and messageCount and nothing else, so changes
across five accounts draw as one undifferentiated run. The data is reachable:
accountForMessagePath() answers from a path and resolvePendingSubjects()
already crosses to the worker with every id. Held thread edits are keyed on a
thread id rather than a message id and need their own answer first.
- 199, shipping our own chrome icons. This reverses the split item 70 chose
deliberately, panes ours and chrome the system's, so it is a decision to
revisit rather than a defect. The mechanism is proven by Marks; the artwork
is the item, roughly forty actions against item 70's six marks.
- 200, launching at an account, thread or message. main.cpp recognises only
--version and --help, both answering before QApplication exists. There is no
single-instance mechanism anywhere in src/.
Item 184 closes, built outside this repository as mail-watcher and confirmed
running on this machine. It took the shape the entry argued for: a watcher of
ours rather than a third-party daemon, one Python file on the standard library,
the cron tick kept as a backstop and /tmp/mbsync.lock still the shared mutex.
Nothing in src/ changed, which was the point. Its section moves to the closed
file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P3HQXLauwQgzxR4YfJBB3x
|
|
Items 187, 190 and 195, settled with the user and specified together in
docs/superpowers/specs/2026-09-10-spam-view-design.md. Nothing is built
yet; implementation follows on a branch.
Mark spam becomes a move into the account's spam folder, following item
103's Delete-to-trash design rather than inventing a second mechanism: a
mandatory per-account `spam` key, a path-based threaded Spam filter, and
a repeatable cleanup pass for the mail the tag-only action stranded.
`unread` is stripped by the move (item 195, verified in the code: the
call names `spam` and `inbox` and nothing else). The message-bar button
carries `bug` with `mail-mark-junk` as its fallback, measured against the
user's icon theme, where the standard name draws a warning octagon and
`bug` draws the beetle the notes asked for; a bare `bug` was rejected
because it resolves in 0 of the 24 system themes and would leave a blank
button.
Two changes came from the user after the first draft and both improved
it. The origin tag is renamed `deleted-from:` -> `moved-from:`, which the
first pass had rejected on a migration cost that turned out to be 8
messages carrying one distinct value; and Empty Spam moves mail to the
trash per account, which needs no new grouping because trashMessages()
already resolves each message's own account.
A message can therefore leave two folders in turn, and the existing
reader takes the first matching tag and breaks. Rather than encode
ordering in the tag, which notmuch's unordered tag set cannot answer, a
move overwrites the origin instead of appending: one tag ever, one hop
back per Restore.
Empty Spam inherits neither of empty_trash's safeguards, deliberately. It
moves rather than destroys, so it has an inverse, and a confirmation on
an undoable action is the defect AGENTS.md names.
Item 197 is filed for a future "not spam": Restore already covers what
this application moved, and reverting the provider's own filter needs a
destination rule and possibly a sidecar, neither of which is decided.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LrqM5LJGAQEryvs5r7usM1
|
|
Two items from the 2026-09-08 reconcile of the user's notes, both with
their causes verified in the code rather than copied from the note.
193, a headings dropdown for the composer. The note is right that the
renderer already handles it: ATX headings are CommonMark core, so `## x`
parses today and only the composer-side control is missing. The work is
that a heading is a line prefix rather than a wrap, so it cannot go
through applyFormat(), and unlike quote() it must REPLACE its prefix
instead of stacking one. That makes it the first formatting control that
reads the line's existing state, which is item 135's question arriving
early on a control where it is much smaller.
194, abuse reporting from a flagged message. Split as one gesture here
and the engine in a sidecar: RDAP, three vendor APIs and abuse-desk mail
are four outbound protocols, and this binary does no network protocol
work by design. The split is architectural and not a judgement on the
feature, which the user wants and will build the sidecar for. The
qtmaildir half is S and follows 187 and 190, since those settle what
Mark spam does and put it on the bar.
The report action takes a confirmation, which is consistent with the
no-confirmation rule rather than an exception to it: that rule offers
undo in place of a dialog, and a submission to a third party has no
inverse to push, exactly as with empty_trash. Marking spam stays
undoable and silent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KphFXTc2QajxXsHWyvGJ4R
|
|
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
|
|
The item recorded three positions on what a draft's identity is and
needed a decision before it could be built. The user chose the second: a
stable id across a draft's revisions, minted fresh for the sent copy, so
the sent message is a different item from the draft. The draft is deleted
on a successful send, which is what the code already does, and the sent
message carries no draft tag.
Three things the decision describes were measured rather than assumed,
and all three are already built: DraftStore::write() removes the previous
revision, ComposeWindow removes the draft on a successful send, and the
sent copy is built fresh so it never carries the tag. The draft tag the
user saw on a Sent row came from orphan revisions threading into the
conversation, not from the sent message.
So the id is the whole remaining defect, and the mechanism is the server
rather than the local files. Measured on real mail: four revisions of one
reply, four distinct Message-IDs, every file carrying a ,U= infix. mbsync
uploads each revision before the next save removes it locally, so the
server holds four independent messages and syncs them all back. The local
cleanup is correct and cannot help, because deleting a local file does
not retract an uploaded one.
Reclassified from enhancement to defect and sized S. The entry said this
was invisible while the file is replaced correctly; the file is replaced
correctly and four messages exist anyway.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jq9gXquUo9W4KXDagJXMmn
|