| Age | Commit message (Collapse) | Author | Files | Lines |
|
Item 163. mbsync renames an uploaded file to add its `,U=<uid>` infix,
and the model's `MessageRef::filePath` was captured when the query ran,
so a row loaded before that sync names a file that no longer exists.
MimeParser then honestly reports a message it cannot open.
MaildirName::resolveRenamed() answers the filesystem question: returns
the path unchanged when it still exists, otherwise looks in that one
directory for the file whose unique stem matches. mbsync preserves the
stem (`<stem>:2,D` becomes `<stem>,U=5:2,D`), which is what makes this
safe to do by filename at all. It never recurses, never crosses a folder
boundary, and refuses an ambiguous match rather than guessing, since
opening or moving the wrong message is worse than reporting none.
It lives in MaildirName because that namespace already owns the `,U=`
infix and is a pure-value unit testable without a widget. A file that
changed FOLDERS is a different question that only the message id can
answer, and NotmuchWorker::moveMessages() re-resolves that way already.
Three call sites, all of which held a stale path:
- The message pane, which reported "(unreadable message)" over a file
that was on disk and readable. Cosmetic and self-repairing.
- Reply and Forward, refused outright, so the user could not answer a
message that was sitting there.
- The draft reopen, and this is the half that costs data. The refusal
happens BEFORE any composer exists, so the user composes again into a
fresh window whose autosave has no previous path to unlink. The old
revision survives, each save mints a new Message-ID, and both files
reach the server. The unlink machinery was correct throughout and
never ran.
forDraft() seeds draftPath from the RESOLVED path, never the caller's:
seeding the stale one would let the reopen succeed and the unlink still
miss, which is the same fork arriving one step later.
Covered by five unit tests on the resolver, including the two that keep
it honest (a genuinely missing file yields nothing, and a neighbouring
message is never matched), and by an integration test that renames the
draft the way mbsync does and asserts the file COUNT, which is the shape
the fork actually takes. Both mutation-checked; the integration test
fails with the reported symptom when the resolution is removed.
The stable-Message-ID question is deliberately untouched: it is what
turns a stale path into two server-side messages rather than one
replaced file, and it wants its own item.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUQS6n3cmsFrsjCNmwNtf8
|
|
Item 153. DraftStore had a write() and no reader, and nothing opened a
composer from an existing message, so a draft rendered like ordinary mail
and could never be finished or sent.
ComposeContextBuilder::forDraft() reads one back. A new Kind::Draft seeds
every field verbatim: the subject takes no Re:/Fwd: prefix, and the body
goes in exactly as it was left, with none of seedBody()'s quote framing. It
is reachable by double-click and by an edit_draft action in the Message
menu.
Three things the shape of this depends on.
A resumed draft must OWN its file. Maildir has no in-place edit, so an
autosave writes a new file and unlinks the old one; a composer that did not
know its own path would leave the original behind and one message would
become two. ComposeContext::draftPath carries it into m_draftPath, which the
autosave already knew how to replace.
MimeParser had no bcc, and nothing had ever needed one. MessageBuilder
writes Bcc into the draft file deliberately and explains why, so a resumed
draft that ignored it would drop every blind recipient from the message the
user then finishes and sends, reporting nothing.
edit_draft is gated on the file being inside a configured drafts folder,
matched on the PATH. A `draft` tag is not enough: notmuch surfaces the
Maildir D flag as one, and a message flagged by another client sits in the
inbox. Offered on ordinary mail, the composer would own a file it did not
write and the first autosave would delete a received message.
And a live defect found on the way, which is most of why this took as long
as it did. updateComposeActions() ran only from onSelectionChanged. Both
signals fire for an ordinary click, so nothing had noticed; but running a
query and setting the current index emits currentRowChanged ALONE, so the
enablement was computed against the previously selected row. Edit draft
stayed disabled on a draft selected that way, and the reply family had the
same blind spot with no test that could see it. Now connected to both.
Reading currentRowChanged is safe here for the reason CLAUDE.md gives: it
answers "which row is current", and no count is read.
WorkerBackedWindow::AccountSpec gains a drafts field, which the two new
tests need and which no fixture could express before.
|
|
ComposeContext, task 7 of the compose-and-send plan. Address parsing,
recipient derivation, the References chain, subject prefixing and account
resolution, as free functions over values so they test without a painter.
Recipient derivation was designed from the spec rather than transcribed: the
plan's draft omitted it and its tests could not compile, calling
QVERIFY(config.load(path)) against a void return.
Six defects found in review, each pinned by a test checked against the
mutation that breaks it:
- Message-ids reached GMime bare, and GMime writes an EMPTY header for a bare
addr-spec rather than complaining. In-Reply-To and References both shipped
blank, so every reply would have arrived as an orphan thread with nothing
wrong to see locally. MessageBuilder now brackets on write, in the one place
that composes those headers rather than in each caller.
- internet_address_to_string was called with FALSE for the encode flag, so a
display name carrying a raw newline rendered with the newline intact. That
is a header-injection primitive.
- A reply to the user's own message addressed the user. It now goes to that
message's original recipients, mirroring their To/Cc split, which is what
the Sent view and a follow-up on unanswered mail need.
- A From parsing to no mailbox left To empty, reachable from real mail
("From: Mailer Daemon"). MessageBuilder treats an empty recipient list as
success, so the message would have been handed to the send command with
nobody to deliver to and filed in Sent looking sent.
- The References header was split on whitespace alone, so a client's
non-conformant "<a@x>,<b@y>" became one token and the bracket strip produced
the fabricated id "a@x>,<b@y".
- Reply and forward prefixes were recognised in English only, doubling every
AW:, SV:, WG: and Re[2]: a mixed-locale mailbox receives. Single-letter
spellings are deliberately excluded: with R: recognised, "R: report on Q3"
reads as a prefix and a genuine first reply threads nowhere.
The mailbox-only guard in parseAddressHeader survived its first mutation
check, because removing it still yields no recipients: the invalid GObject
cast makes GMime's own assertion return NULL. That is undefined behaviour
papered over by an assertion G_DISABLE_CHECKS compiles out, so the test now
asserts on the emitted critical rather than on the count. Registering the log
handler on a NULL domain catches nothing; the criticals carry "GLib-GObject"
and "gmime".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoaLBowZ6w1JNx6SEhDP1L
|