summaryrefslogtreecommitdiffstats
AgeCommit message (Collapse)AuthorFilesLines
6 daysfix: compare sameMeaning overrides by recurrence id and check the uidDanilo M.2-2/+50
6 daysfeat: edit a calendar event in place and write new onesDanilo M.2-3/+412
6 daysfeat: an event is editable only when the user organises itDanilo M.2-1/+41
6 daysfix: expand a long dense series completely and flag overrides' own all-dayDanilo M.2-4/+39
6 daysfeat: expand calendar occurrences in each event's own zoneDanilo M.2-2/+249
6 daysfix: an empty RRULE means does-not-repeat, not a custom ruleDanilo M.2-0/+16
6 daysfeat: model the calendar's repeat control as RRULE textDanilo M.2-4/+361
6 daysfeat: read every time form and load a calendar vdirDanilo M.2-1/+195
6 daysfeat: parse one calendar event with libical (item 206)Danilo M.11-1/+630
6 daysdocs: plan the calendar window (item 206)Danilo M.1-0/+5468
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>
6 daysdocs: close backlog item 204, contact completion shippedDanilo M.2-82/+100
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>
6 daysdocs: specify the calendar window (item 206)Danilo M.1-0/+372
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>
12 daysfix: sanitise untrusted contact names on insertionDanilo M.2-23/+100
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.
12 daysdocs: document contact completion and refresh Italian translationDanilo M.3-3/+47
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.
12 daysfeat: load the contact store once and feed both consumersDanilo M.2-0/+30
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.
12 daysfeat: complete contacts in the query barDanilo M.3-5/+153
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.
12 daysfeat: complete contacts in the composer recipientsDanilo M.3-5/+515
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.
12 daysfeat: add the contacts_dir config keyDanilo M.3-0/+150
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.
12 daysfeat: add ContactStore, the vCard address-book parseDanilo M.14-0/+711
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.
12 daysdocs: add the contact completion implementation planDanilo M.2-3/+342
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>
12 daysdocs: reconcile the backlog with the notes, and split item 72Danilo M.1-19/+270
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>
2026-09-14docs: close the backlog items shipped in 0.29.0Danilo M.2-319/+309
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.
2026-09-14docs: add the CLI selectors implementation planDanilo M.1-0/+2044
2026-09-14release: 0.29.0v0.29.0Danilo M.2-1/+9
2026-09-14docs: use the renamed helper names in item 202Danilo M.1-3/+3
2026-09-14fix(hooks): spam is not an inbox arrivalDanilo M.5-73/+232
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.
2026-09-14fix: keep the message bar populated on a spam replyDanilo M.3-9/+64
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.
2026-09-14fix: the Not spam review findingsDanilo M.5-19/+145
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.
2026-09-14feat: a Not spam actionDanilo M.9-93/+713
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.
2026-09-14fix: gate spam like delete, and keep one origin in the modelDanilo M.6-19/+267
2026-09-14docs(i18n): correct the spam docs review findingsDanilo M.2-13/+11
2026-09-14docs(i18n): fix the spam docs review findingsDanilo M.3-15/+24
2026-09-14docs(i18n): translate the spam stringsDanilo M.4-10/+103
2026-09-14feat: find stranded spam mailDanilo M.4-0/+117
2026-09-14feat: empty spam moves mail to the trash, per accountDanilo M.4-2/+285
2026-09-14feat: spam on the message bar, with a bug icon and junk fallbackDanilo M.3-92/+144
2026-09-13feat: mark spam moves mail to the account's spam folderDanilo M.5-62/+482
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.
2026-09-13refactor: rename the origin tag to moved-from: with overwrite semanticsDanilo M.10-67/+163
2026-09-13feat(config): a threaded, path-based spam filterDanilo M.4-4/+56
2026-09-13feat(config): per-account spam folder and queriesDanilo M.3-11/+124
2026-09-13plan: mark spam moves mail, and there is a spam viewDanilo M.1-0/+468
2026-09-13docs: specify the CLI selectors and single-instance launchDanilo M.1-0/+253
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
2026-09-13docs: reconcile the backlog and close item 184Danilo M.2-67/+236
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
2026-09-10docs: specify the Spam view, the spam move and Empty SpamDanilo M.2-1/+390
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
2026-09-08docs: record the composer headings control and abuse reportingDanilo M.1-0/+127
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
2026-09-08feat: make Sync follow the account being looked atDanilo M.8-63/+322
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
2026-09-07fix: refresh the current query when the index changesDanilo M.7-9/+166
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
2026-09-06fix: index the sent copy so it appears in the Sent view at onceDanilo M.7-0/+199
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
2026-09-06fix: keep one Message-ID across a draft's revisionsDanilo M.11-111/+436
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
2026-09-06docs: settle item 165 on a stable draft id, discarded at sendDanilo M.1-6/+42
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