aboutsummaryrefslogtreecommitdiffstats
path: root/docs
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-25 12:09:54 +0200
committerDanilo M. <danix@danix.xyz>2026-08-25 12:09:54 +0200
commit8b26c3e83a0328adb3f24d808b629fb73250d377 (patch)
tree0ad763bf64cf8d21a83fde09ca633b125b484140 /docs
parent66c36f0a7f869eac10f7cfa552f23cf076ba7548 (diff)
downloadqtmaildir-8b26c3e83a0328adb3f24d808b629fb73250d377.tar.gz
qtmaildir-8b26c3e83a0328adb3f24d808b629fb73250d377.zip
docs(backlog): record item 165, a draft's Message-ID changes on every save
Found while hand-testing items 163 and 164: four saves produced four distinct ids, and one reopen-and-edit turned one into another. Cause verified in the code rather than inferred. MessageBuilder::build() calls g_mime_utils_generate_message_id() unconditionally and every autosave calls build(); OutgoingMessage has no field to carry an id in, and ComposeContext has none for the draft's own id either, since inReplyTo and references are the ORIGINAL's when replying. So a stable id needs a field threaded from forDraft() through both structs, not a changed call site. Filed as needing a DECISION rather than an implementation, because what a draft's identity is is not obvious: a stable id reused at send makes the draft and the sent message one message but means the server saw that id before anything was sent; a stable id discarded at send keeps revisions collapsed while drafting and threads under a fresh one; the status quo never reuses an id for two different things, which is its one real virtue. Not urgent and explicitly not blocking item 163, whose fix restores correct file replacement. This is the property that turned that fork into two MESSAGES rather than one duplicated file, and the remaining route to it is an interrupted save, since DraftStore::write() unlinks the previous revision only after the new one is safely on disk. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UUQS6n3cmsFrsjCNmwNtf8
Diffstat (limited to 'docs')
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md69
1 files changed, 69 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
index 5e3df58..a422317 100644
--- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
+++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
@@ -238,6 +238,7 @@ taking that too literally.
| 162 | Delete fails while a sync is renaming the file underneath it | defect | S | **done, 2026-08-25.** mbsync renames an uploaded file to add its `,U=<uid>` infix and notmuch keeps the pre-`U=` name until that sync's `notmuch new` runs, so `moveMessages` renamed a path that no longer existed and Delete silently did nothing while blaming the destination folder. `moveMessages` now re-resolves by MESSAGE ID when the recorded path is gone: one reindex of that directory, then the filename that exists on disk. Bounded to one retry, so a file genuinely gone still reports. Holding the move during a sync was the other candidate and is NOT the fix: `sendMove` already refuses on notmuch's write lock, but this window sits between mbsync's rename and that sync's `notmuch new`, which touches no lock |
| 163 | The message pane shows a stale path, and the composer forks the draft | defect | S | **done, 2026-08-25.** mbsync renames an uploaded file to add its `,U=<uid>` infix while the model still holds the name the query returned. `MaildirName::resolveRenamed()` returns the path unchanged when it exists, else finds the file in that one directory whose unique stem matches; it refuses an ambiguous match and yields nothing for a genuinely missing file. Wired into all THREE read sites: the pane, Reply/Forward, and the draft reopen. The reopen was the one that cost data, forking a draft into two files with two Message-IDs, both reaching the server |
| 164 | A draft this application saved keeps `inbox` | defect | S | open, 2026-08-25, **cause corrected 2026-08-25**. The first diagnosis blamed a missing drafts helper and was WRONG: `NOT_ARRIVALS` in `qtmaildirconf.py` is `("sent", "drafts")`, the folder list includes every account's drafts folder, and `notmuch count` confirms the carve-out query MATCHES the affected draft. The carve-out is scoped to `tag:new`, and the draft carries `inbox` while `tag:new` is 0, so it was never in scope when the hook ran. Measured separately: an mbsync-style rename does NOT re-add `new.tags`, so the retag theory is out too. What remains unestablished is WHICH pass tagged it; establish that before writing code |
+| 165 | A draft gets a new Message-ID on every autosave | enhancement | ? | open, 2026-08-25, found while hand-testing 163 and 164. `MessageBuilder::build()` generates an id unconditionally and every autosave calls it, so each revision is a distinct MESSAGE to notmuch and to the server rather than a new version of one. Invisible while the file is replaced correctly, which item 163's fix restores; it is what turned that fork into two messages rather than one duplicated file. Needs a DECISION on what a draft's identity is before any code: a stable id reused at send, a stable id discarded at send, or the status quo. Neither `ComposeContext` nor `OutgoingMessage` has a field to carry an id, so it is not a changed call site |
Sizes are rough: XS under an hour, S a sitting, M a session.
@@ -1443,3 +1444,71 @@ rebuild from this entry if the instrumentation points back at the hook.
- The hook must keep refusing to consume `tag:new` when a carve-out fails.
- `test_post_new.py` and `test_qtmaildirconf.py` both live beside the hook and
have sent-carve-out tests to copy.
+
+## 165. A draft gets a new Message-ID on every autosave
+
+**Observed (developer, 2026-08-25), while hand-testing items 163 and 164.**
+Four saved drafts produced four distinct Message-IDs, and one reopen-and-edit
+turned one id into another. A draft therefore has no stable identity across
+its own revisions.
+
+**Cause (verified in the code, not inferred).** `MessageBuilder::build()`
+calls `g_mime_utils_generate_message_id()` unconditionally on every call
+(`messagebuilder.cpp:311`), and every autosave calls `build()`.
+`OutgoingMessage` has no field to carry an existing id in, and
+`ComposeContext` has no field for the draft's OWN id either: it carries
+`inReplyTo` and `references`, which are the ORIGINAL's id when replying, and
+`ComposeContextBuilder::forDraft()` never reads the draft's Message-ID back
+out of the file it parses. So this is not a changed call site; it needs a
+field that does not exist yet, threaded from `forDraft()` through
+`ComposeContext` and `OutgoingMessage` into `build()`.
+
+**Why it matters, and why it is NOT urgent.** To notmuch and to the server,
+each revision is a different MESSAGE, not a new version of one. While the
+file is replaced correctly this is invisible: one file in, one file out. It
+becomes visible whenever a revision is NOT replaced, and item 163 is the
+proof, where a stale path forked a draft into two files that were also two
+messages and that nothing will ever collapse. Item 163's fix removes the
+known way to reach that state; this entry is about the property that turned a
+one-file mistake into a two-message one.
+
+An interrupted save is the remaining route: `DraftStore::write()` unlinks the
+previous revision only AFTER the new file is safely in place (deliberately,
+so a failed write cannot lose the draft), so a crash between the two leaves
+two files, and with two ids they are two drafts rather than one duplicated.
+
+**Approach, and it needs a decision rather than an implementation.** The
+question is what a draft's identity IS, and it is not obviously "the id it
+will be sent under":
+
+- A stable id reused at send time makes the draft and the sent message one
+ message, which is what a user means by "my draft became this email". It
+ also means the id was in a file mbsync uploaded to the drafts folder before
+ the message was ever sent, and the server has seen it.
+- A stable id DISCARDED at send time keeps revisions collapsed while drafting
+ and mints a fresh id for the sent copy. Two identities, and the sent one is
+ the one that threads.
+- The current behaviour is a third position, and its only virtue is that no
+ id is ever reused for two different things.
+
+Whichever is chosen must be checked against `In-Reply-To`/`References` on the
+eventual send, since `referencesForReply()` builds those from the ORIGINAL's
+id and a draft of a reply carries both.
+
+**Constraints.**
+
+- **A Message-ID reaches the server and every recipient**, so a reused id is
+ not a local matter. Two different messages sharing an id is worse than two
+ ids for one draft, which is what makes the current behaviour defensible as
+ a default rather than simply wrong.
+- `MessageBuilder::build()` is on the SEND path as well as the autosave path.
+ A change that makes ids stable must not make two different sent messages
+ share one.
+- The comment at `messagebuilder.cpp:297` records that GMime generates
+ neither Date nor Message-ID unless asked, and that a message without one
+ cannot be threaded by anything receiving it, this application's own index
+ of the sent copy included. Any "just omit it while drafting" variant has to
+ answer that.
+- Item 163's fix stands on its own and this does not block it: the file is
+ replaced correctly now, so the fork this would have mitigated no longer
+ happens by that route.