| 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
|