aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md200
1 files changed, 150 insertions, 50 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 a422317..f1db28a 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
@@ -170,7 +170,7 @@ taking that too literally.
| 101 | Sync is account-aware for edits but not for the account the user is looking at | workflow | S | open; item 49 built the edit half deliberately. Needs a decision, see the entry |
| 102 | The rules table shows no note, so the field explaining a rule is invisible until it is opened | workflow | XS | **done** 2026-08-17, unreleased. A Note column before `ColumnCount`, so the appended Matches column stays last. Found a second defect on the way: `restoreState` REFUSES a header state with a different column count, and the sized flags were being set regardless |
| 103 | What Delete does to mail on the server is undocumented and unverified | clarification | S+M | done; Delete moves to the account trash, with Restore and a stranded-mail cleanup. Section in the closed file |
-| 104 | Mail visible in Thunderbird never reaches qtmaildir | defect | ? | open, reported 2026-08-16, cause NOT established. Most likely outside this repo; see the entry before writing code |
+| 104 | Mail visible in Thunderbird never reaches qtmaildir | defect | XS | **fixed 2026-08-25**, awaiting hand test. The worker never reopened its read-only notmuch handle, so no query saw mail indexed after startup |
| 109 | A root card's own message is invisible to a message-scoped write | defect | S | **done** 2026-08-16, unreleased. Found by hand-testing 108. `applyMessageTagChange` and `messageById` searched only the loaded replies, and a root's message is never among them, so the ORDINARY gesture repainted nothing and wiped the pane's chip row |
| 110 | A card and the message pane show tags belonging to a message's siblings | defect | S | **done** 2026-08-16, unreleased. Found by hand-testing 109 against a real 4-message thread. `ThreadSummary::tags` is notmuch's UNION; a card standing for one message drew it. Also the reason a root card could not repaint at all |
| 111 | A card should show its siblings' tags smaller, not drop them | presentation | S | **done** 2026-08-16, unreleased. The user's own design, from looking at 110's result: own tags full size, the thread's others smaller and muted, so nothing appears to vanish on selection |
@@ -239,6 +239,7 @@ taking that too literally.
| 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 |
+| 166 | Mail you send to your own other account loses `inbox` | defect | S | open, found 2026-08-25. Two-repo change: the carve-out lives in the `post-new` hook, mirrored in mailctl's `mailrules.py` |
Sizes are rough: XS under an hour, S a sitting, M a session.
@@ -541,58 +542,157 @@ reaches it (item 42), so most of this exists.
New mail received on thunderbird did not appear in qtmaildir. Need to investigate
further."
-**Cause: NOT established.** Recorded because it is a defect report about mail
-going missing, which is the most serious kind this backlog carries, and it has
-been sitting in the notes unrecorded. What follows is one measured mechanism that
-would produce exactly this symptom, not a diagnosis.
-
-**qtmaildir cannot show what mbsync did not fetch, and mbsync fetches folders by
-pattern.** Three of the five channels in the user's `~/.mbsyncrc` name their
-folders explicitly:
-
-```
-Patterns "INBOX" "[Gmail]/Posta inviata" "[Gmail]/Bozze" "[Gmail]/Speciali"
-```
-
-and one names only `"INBOX"`. The two non-Gmail channels use `Patterns *`.
-Gmail applies labels, and a message whose label is not one of those four is in a
-folder mbsync never asks for. Thunderbird speaks IMAP directly and sees every
-folder, so the same message is visible there and absent locally. This is a
-configuration property of the user's mbsyncrc, outside this repository entirely.
-
-**One inconsistency worth reporting regardless**, found while checking the
-above: one of the Gmail accounts is configured in `qtmaildir.conf` with
-`sent = [Gmail]/Posta inviata` and `drafts = [Gmail]/Bozze`, while its mbsync
-channel has `Patterns "INBOX"` and fetches neither. The Sent and Drafts filters
-for that account can therefore only ever be empty. That is real, and it is
-independent of whatever this item turns out to be.
-
-**Approach.** Reproduce before anything else, and the reproduction has to
-distinguish three layers, because the fix lives in a different place for each:
-
-1. Is the message on disk? `find` in the Maildir, or `notmuch count` on a term
- from it. If not, this is mbsync or `.mbsyncrc`, and there is nothing to
- change here.
-2. If it is on disk, is it indexed? `notmuch new` and count again. If not, this
- is notmuch config, `new.ignore` or the hook.
-3. Only if it is indexed and still not shown is this qtmaildir's defect, and
- then the question is which query hid it: the account scope, the built-in
- filter, or a rule that tagged it out of the inbox.
+**Cause: ESTABLISHED 2026-08-25, and it was this repository after all.** This
+entry previously named mbsync's folder `Patterns` as the leading theory and
+concluded "most likely not a code change here at all". That was wrong; the
+superseded reasoning is kept at the bottom because the reproduction is what
+overturned it.
+
+**`NotmuchWorker` opened one read-only notmuch handle and kept it for the
+process lifetime.** A read-only handle is a Xapian SNAPSHOT taken when it is
+opened; it never observes a write made by another process afterwards. The sync
+script's `notmuch new` is exactly such a process, so every query the worker
+answered after startup was served from the index as it stood when the
+application launched. `openReadOnly()` returned early on `if (m_db) return
+true;` and there was no `notmuch_database_reopen` anywhere in the tree.
+
+This accounts for every symptom, including the ones that defeated three earlier
+theories during the diagnosis:
+
+- The post-sync refresh found nothing, so `refreshCurrentQuery()` and
+ `ThreadListModel::reconcile()` were each suspected in turn. Both are correct.
+- A query the user typed BY HAND also found nothing. That is what rules out the
+ model, the generation counter and the account scope together: a fresh query
+ clears the model and re-runs from scratch, and it still hits the same stale
+ handle.
+- A restart showed the mail instantly, with no sync in between.
+- Tag WRITES were never affected, which is why the defect reads as "reading is
+ broken" rather than "notmuch is broken". `applyTags` opens its own read-write
+ handle per call, so it always sees current data.
+
+**Why it survived from 2026-08-16 to 2026-08-25.** The symptom needs mail to
+arrive from outside the process while the window stays open, which is the
+ordinary way this application is used and the one thing no test did: every
+fixture opens a worker, queries it, and drops it. `TestNotmuchWorker::runQuery()`
+builds a FRESH worker per call, so the suite was structurally incapable of
+reproducing it, and a test written through that helper passes against the bug.
+
+**Fixed** in `NotmuchWorker::openReadOnly()`: when a handle already exists,
+`notmuch_database_reopen(m_db, NOTMUCH_DATABASE_MODE_READ_ONLY)` before
+returning it. Every read path begins by asking for the handle, so one call
+covers all of them; putting it at the call sites instead would be one more
+place to forget. A reopen failure is deliberately NOT fatal, since the existing
+handle is still usable and answering from a slightly stale index beats refusing
+to answer at all.
+
+Covered by `aQuerySeesMailIndexedAfterTheWorkerOpened`, which holds ONE worker
+across two queries and runs `notmuch new` in a second process between them.
+Mutation-checked: `after.size()` is 0 without the fix, 1 with it. The first
+query asserts zero results before the message is written, so "found nothing"
+cannot mean "the query was malformed".
+
+**The reproduction, kept because this entry's Approach section asked for exactly
+this and it took three wrong turns to get there.** Four messages sent to one
+account on 2026-08-25, viewed in that account's Inbox, synced with the app's own
+Sync button. The three layers resolved as: on disk (yes), indexed (yes), shown
+(no), which is layer 3 and therefore this repository. Two of the four matched
+the running view's exact query (`path:"<account>/**" and (tag:inbox)`, 2 results
+from the shell) and were absent from a window that had been open across the
+sync.
+
+Two measurement errors made during that diagnosis, both worth repeating because
+each produced a confident wrong answer:
+
+- `notmuch count 'inbox and path:...'` was used to check the view's contents. A
+ bare `inbox` is a FREE-TEXT term, not a tag term; the app generates
+ `tag:inbox`. The bare form returned 0 where the real query returns 2, which
+ briefly made the defect look like a tagging problem.
+- The messages' tags were first read across every file matching the subject,
+ including the sender-side Sent copies in other accounts. That mixed three
+ accounts' messages into one answer.
+
+**Superseded theory, kept for the record.** mbsync fetches Gmail folders by
+pattern and three of the five channels name their folders explicitly, so a
+message labelled anything else is in a folder mbsync never asks for while
+Thunderbird, speaking IMAP directly, sees it. That mechanism is real and would
+produce a similar symptom, but it is not what was happening here: the mail was
+on disk and indexed. It remains a plausible cause of any FUTURE report of this
+shape, so check layer 1 before assuming this fix covers it.
+
+**One inconsistency worth reporting regardless**, found while checking the above
+and still true: one of the Gmail accounts is configured in `qtmaildir.conf` with
+a sent and a drafts folder, while its mbsync channel has `Patterns "INBOX"` and
+fetches neither. The Sent and Drafts filters for that account can therefore only
+ever be empty. That is real, independent of this item, and outside this
+repository.
+
+**Size: XS.** Done.
+
+
+
+## 166. Mail you send to your own other account loses `inbox`
+
+**Observed (agent, 2026-08-25, while setting up msmtp.)** Four test messages
+were sent to one of the user's own accounts, one from each configured sending
+account. All four were delivered and indexed. The two sent from accounts whose
+Sent folder is fetched locally arrived in the recipient account's Inbox
+**without the `inbox` tag**, so they were absent from that account's Inbox view.
+The two sent from an account whose Sent folder is not fetched kept `inbox`
+normally.
+
+**Cause: established, and it is the `post-new` hook, not this binary.**
+`strip_inbox_from_sent()` in `assets/hooks/post-new` removes `inbox` from any
+message matching a configured sent folder's PATH. Its docstring states the
+assumption exactly: "the provenance is the file's own path: a message inside a
+configured sent folder is one this system sent, and `inbox` was never true of
+it."
+
+That holds for one file. It fails for one MESSAGE, because **notmuch
+deduplicates by Message-ID and a message can have several files**. When the
+sender and the recipient are both the user's own accounts, mbsync fetches two
+copies: the sender's Sent copy and the recipient's Inbox copy. notmuch stores
+them as ONE message with two filenames. The carve-out's query matches via the
+Sent filename and strips `inbox` from the message object, which is the same
+object the recipient's Inbox copy belongs to.
+
+Measured: one message, two paths, one in the sender account's sent folder and
+one in the recipient account's `Inbox/cur`.
+
+The assumption is not merely incomplete, it is false in this case: the message
+was genuinely sent AND genuinely received. There is no single right answer for
+"was `inbox` ever true of this message", because it was true of one file and
+false of another.
+
+**Approach.** Not settled, and the choice matters more than the code:
+
+1. **Strip only when EVERY file is in a sent folder.** Closest to the existing
+ intent, and it makes the predicate match the docstring's claim. A
+ self-addressed message keeps `inbox`, which is right: it did arrive.
+2. **Strip only when the message has exactly one file.** Simpler to express,
+ but it silently stops protecting any sent message that happens to be
+ duplicated for an unrelated reason.
+3. **Leave it.** Self-addressed mail is rare outside testing. The cost is that
+ it is invisible when it happens, and it looks exactly like the sync defect
+ item 104 turned out to be, which is how this was found.
+
+Option 1 is the one that makes the code true to what it already says it does.
**Constraints.**
-- Ask the user for one concrete example before investigating: which account,
- roughly when, and what Thunderbird shows for it. A general "sync doesn't work"
- cannot be reproduced, and the last four defects in this backlog were all found
- from a specific message.
-- The `post-new` hook from mailctl tags mail unattended. A rule that removes
- `inbox` would make a correctly fetched, correctly indexed message vanish from
- the default view, which looks identical to a sync failure from the outside.
- `notmuch search` without a filter is what tells them apart.
-- Do not change `.mbsyncrc` as part of this. It is the user's, it is outside the
- repo, and a Patterns change refetches folders.
-
-**Size: `?`** until reproduced. Most likely not a code change here at all.
+- **This is a two-repo change.** The carve-out is in this repo's `post-new`,
+ but the shared rule machinery is mirrored in `../mailctl`. Read "Changing the
+ shared rule format" in CLAUDE.md before touching it, and run both suites.
+- The hook **tags real mail unattended, every ten minutes, on the user's live
+ index.** A predicate that is wrong in the other direction would strip `inbox`
+ from arriving mail, which is the failure mode PROTECTED_REMOVALS exists to
+ prevent. Test against a throwaway database first.
+- `notmuch tag` matching zero messages SUCCEEDS, so a log line saying the
+ carve-out ran is not evidence it matched anything. The count added for item
+ 164 is what distinguishes them; use it.
+- Do not fix this by narrowing the query to exclude the recipient account. The
+ bug is in the per-file predicate, not in which folders are configured.
+
+**Size: S.**
## 112. Toggle unread on a whole thread cannot reach "all unread" on a partly-read thread