aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md')
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md417
1 files changed, 340 insertions, 77 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 7b44e7e..944dfee 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
@@ -131,8 +131,8 @@ taking that too literally.
| 64 | The Sync button carries a mailbox icon, not a refresh one | presentation | XS | **done** 2026-08-11 |
| 65 | No full code review and optimization pass | correctness | ? | open, unspecified |
| 66 | Selecting a thread root leaves the message pane blank until a reply has been selected | defect | S | **done** 2026-08-14, unreleased. Not the blank pane it was filed as: the root rendered the CONVERSATION until the thread had been expanded once, then one message. Now always one message, and the conversation view is removed at the user's request. **One case unverified by hand:** the notes also report a single-message `id:` query whose card would not open, which is the same empty-`MessageIdRole` failure and should be gone; confirmed 2026-08-15 as a SEPARATE defect with a different cause, see item 96 |
-| 87 | Auto mark-read marks a whole thread, including replies never displayed | defect | S | open; measured 2026-08-14. Reachable only after 66 removed the conversation view. Blocked on 88 |
-| 88 | `threadAt(current.row())` answers about the wrong thread for a reply row | defect | S | open; found 2026-08-14 by shipping a fix that marked an unrelated message read. Row numbers are per parent in a tree |
+| 87 | Auto mark-read marks a whole thread, including replies never displayed | defect | S | **done** 2026-08-16, unreleased. Built on 108, which is why it stayed small: the timer tracks a MESSAGE id now, and arms for a reply too, which it never did before |
+| 88 | `threadAt(current.row())` answers about the wrong thread for a reply row | defect | M | **done** 2026-08-16, unreleased. The audit found FOUR live sites, not one. `ThreadListModel::threadFor(index)` resolves a reply through its parent; every caller holding a selected index converted, and no `.row()` on a selected index remains in `mainwindow.cpp`. Unblocks 87 |
| 67 | The placeholder pane counts unread, flagged and inbox, but not sent or drafts | information | XS | **done** 2026-08-11, shipped in 0.15.0 |
| 68 | A forwarded subject gets no `passed` tag | workflow | S | open; no subject rule exists, measured 2026-08-11. Decision needed: display mark (XS) or write the flag (S, syncs out) |
| 69 | `passed` and `replied` read as words where every other state is a glyph | presentation | S | **done** 2026-08-11, inside item 70 |
@@ -162,6 +162,20 @@ taking that too literally.
| 94 | `pinned` has nothing left to decide once the buttons are built-in | maintenance | S | open; **blocked on 93**, and deliberately not part of it. A user-visible removal: the row becomes built-ins only and every saved query lives in the menu |
| 96 | A query returning the thread already on display opens onto the placeholder | defect | S | **done** 2026-08-15, unreleased. Split from 66's unverified half, which had a different cause. Reproduced from two screenshots after four measured eliminations |
| 97 | An edit made during a sync is reverted in the list when the sync ends | defect | S | **done** 2026-08-15, unreleased. Found by hand-testing item 89's fix. The sync-end refresh ran BEFORE the held-edit flush, so it read a database that still carried the old tag |
+| 98 | "Important" adds the tag but cannot remove it, unlike every other toggle | defect | XS | open, found 2026-08-16 in the notes reconciliation |
+| 99 | The unread action is labelled "Toggle unread" whichever way it will go | presentation | S | open; depends on 98's toggle shape, and the label is harder than it looks |
+| 100 | The message pane offers Back, Forward, Reload and Save page, none of which mean anything | defect | XS | open, found 2026-08-16. Chromium's standard menu is added wholesale |
+| 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 | open, found 2026-08-16 |
+| 103 | What Delete does to mail on the server is undocumented and unverified | clarification | S | open; a question first, possibly no code at all |
+| 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 |
+| 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 |
+| 105 | Acting on a reply changes the counter and nothing on screen | defect | M | **done** 2026-08-16, unreleased. Found by hand-testing 88, and took three passes. FOUR causes: no optimistic update for a message-scoped write, no doomed cue on a reply row, both toggles reading the reply's THREAD state so they were one-way, and the message pane's strip not following a message edit. Also bolds an unread reply, at the user's request |
+| 106 | A tag change made on one message during a sync is silently lost | defect | XS | **done** 2026-08-16, unreleased. Found by READING while fixing 105, never reported. `flushHeldEdits` re-sent only thread-scoped edits, so a message-scoped one was shown, counted as pending, and never written |
+| 107 | A thread-scoped write leaves the loaded replies showing their old tags | defect | XS | **done** 2026-08-16, unreleased. `applyTagChange` updated the summary only, so marking a thread read left its expanded replies bold |
+| 108 | Acting on a thread root means the whole thread, though it displays one message | workflow | M | **done** 2026-08-16, unreleased. `messageScopeFor()` beside `scopeFor()`; five `*_thread` actions in a "Whole thread" submenu on `Ctrl+Alt+<key>`. User-visible: minor bump, `### Upgrading` written |
Sizes are rough: XS under an hour, S a sitting, M a session.
@@ -255,81 +269,6 @@ recorded.
**Size: `?`, unspecified.** Do not propose a design for this; ask.
-## 87. Auto mark-read marks a whole thread, including replies never displayed
-
-**Observed (user, 2026-08-14):** "with the first message in a thread selected
-(not expanded), the 2s delay that marks it read applies to the whole thread, so
-all answers are marked read as well."
-
-**Cause (verified in the code).** `markCurrentThreadRead`
-(`src/mainwindow.cpp`) sends `sendThreadTagChange` with the thread id, so
-`unread` is removed from every message in the thread.
-
-**This was coherent until item 66 and is not any more.** While a thread root
-rendered the whole conversation, everything marked read HAD been displayed.
-Item 66 made a root render one message; the thread-wide write stayed.
-
-**Not cosmetic.** `maildir.synchronize_flags` is on, so removing `unread`
-rewrites Maildir filenames and the next sync carries it to the server. Mail the
-user never opened stops being unread everywhere, and nothing here can put it
-back except reading the messages again by hand.
-
-**Approach: blocked on item 88, and that ordering is the lesson.** The obvious
-fix is to send `sendMessageTagChange` for the message on screen. That was
-written, tested, mutation-checked, shipped and reverted the same evening,
-because it exposed item 88: the guards above the write read
-`threadAt(current.row())`, which answers about an unrelated thread when a reply
-row is current, and a fix that narrows the write turns that mismatch into "a
-random message is now read". Fix 88 first.
-
-**Constraints for the second attempt.**
-
-- **Test selecting a REPLY, not only a root.** The reverted work asserted on a
- root selection, which is the one case where `current.row()` is correct, so a
- mutation-checked test passed against a bug that corrupted mail.
-- The card must repaint. `sendMessageTagChange` deliberately makes no
- optimistic model update, since `applyTagChange` is thread-keyed; without a
- message-scoped equivalent the write lands, the unsynced count rises, and the
- card stays bold, which the user reported as a separate fault.
-- A thread reads as unread while ANY message does, so the card can only stop
- looking unread when nothing else in it carries the tag. For an unexpanded
- thread the per-message tags are not loaded, so the honest answer needs
- per-message counts in `ThreadSummary` or nothing at all.
-
-**Size: S**, after 88.
-
-## 88. `threadAt(current.row())` answers about the wrong thread for a reply row
-
-**Observed (user, 2026-08-14):** "I clicked on a message and another random
-message was marked read."
-
-**Cause (verified).** `markCurrentThreadRead` calls
-`m_model->threadAt(current.row())`. A `QTreeView` numbers rows PER PARENT, so a
-reply's `row()` indexes its siblings: `threadAt(0)` on the first reply of any
-thread returns the FIRST THREAD IN THE LIST. The guards then compare correct ids
-against the wrong thread and let a write through.
-
-**`CLAUDE.md` already records this trap**, from item 20: "what did NOT survive
-that port is anything keyed on a row NUMBER". This is a surviving instance, not
-a new discovery, which is the uncomfortable part.
-
-**Mostly masked before item 87's attempted fix.** With a thread-wide write the
-mismatch usually resolved to the same thread or to a no-op; scoping the write to
-one message made it visible immediately, as unrelated mail being marked read.
-
-**Approach.** `threadAt(int)` takes a row and cannot be safe here. Every caller
-that has a `QModelIndex` should reach the thread through the index: for a
-message row, `ThreadListModel::threadIdForMessage()` or the parent index; for a
-thread row, `index.row()` is correct. Audit every `threadAt(` and every
-`.row()` in `mainwindow.cpp` rather than fixing this one site, since the same
-shape almost certainly appears elsewhere.
-
-**Consider deleting the row-taking overload** once the callers are converted, so
-the trap cannot be re-entered. That is the change that makes this permanent
-rather than fixed once.
-
-**Size: S** for the one site, **M** if the audit finds several.
-
## 68. A forwarded subject gets no `passed` tag
**Observed (user, from the notes):** "passed tag should appear when subject is
@@ -494,6 +433,330 @@ bump either way: an ignored optional field is not a breaking change.
**Size: S.** Removing a field, two UI affordances and their tests.
+## 98. "Important" adds the tag but cannot remove it, unlike every other toggle
+
+**Observed (user, from the notes):** "the add 'Important' action should be a
+toggle (like unread)."
+
+**Cause (verified in the code).** `src/mainwindow.cpp:863` registers `flag` as a
+one-way add:
+
+```cpp
+addAction(QStringLiteral("flag"), tr("&Important"),
+ tr("Mark the selected threads as important"), [this]() {
+ tagSelected({ QStringLiteral("flagged") }, {}, tr("Mark important"));
+});
+```
+
+Adding a tag that is already there is a no-op the user cannot see, so pressing
+the key or the button on an already-important thread appears to do nothing at
+all. Nothing in the UI removes `flagged` except the general tag dialog.
+
+**The two neighbouring actions are already toggles**, so this is an
+inconsistency rather than a missing feature. `delete`
+(`src/mainwindow.cpp:825`) and `toggle_unread` (`:867`) both read the current
+state and choose a direction, and `delete`'s comment states the rule this should
+follow: one direction for the WHOLE selection, flipping only when every selected
+thread is already in the target state, because a single keystroke that leaves a
+selection in two states is worse than either outcome.
+
+**Everything needed is already loaded.** `ThreadSummary::isFlagged()`
+(`src/types.h:64`) reads the tag off the summary, so the direction can be
+decided without a worker round trip, exactly as `isDeleted()` is.
+
+**Approach, now a two-line change.** Item 105 extracted
+`MainWindow::everySelectedRowHasTag()`, which is the whole of the direction
+logic:
+`everySelectedRowHasTag("flagged") ? tagSelected({}, {"flagged"}, tr("Unmark important")) : the current add`.
+The undo stack needs nothing new, since `TagChange::inverted()` already covers
+both directions.
+
+**Constraints.**
+
+- Call `everySelectedRowHasTag()`, never a hand-rolled loop. Two separate fixes
+ went into that logic on 2026-08-16 (items 88 and 105) and both were bugs a
+ copy of the then-current `delete` loop would have inherited: resolving a
+ reply's row number to the wrong thread, and asking a reply's thread instead
+ of the reply.
+- The action's tooltip says "Mark the selected threads as important" and would
+ become wrong. Item 99 is the same problem for `toggle_unread` and the two
+ should be decided together.
+- The label question belongs to item 99, not here. This item is the behaviour
+ only: the key stops being a no-op.
+
+**Size: XS.**
+
+## 99. The unread action is labelled "Toggle unread" whichever way it will go
+
+**Observed (user, from the notes):** "the label for 'toggle unread' should be
+dynamic: on an 'unread' message it should be 'Mark as read', on a 'read'
+message it should be 'Mark as unread'."
+
+**Cause (verified in the code).** `src/mainwindow.cpp:867` registers one static
+label, `tr("Toggle &unread")`, and the lambda decides the direction at
+invocation time from the current row. The action carries that text in three
+places at once: the Message menu (`:1060`), the thread context menu (`:1167`)
+and the toolbar (`:1122`, with the `mail-mark-unread` icon). Nothing updates it
+when the selection changes.
+
+**Not as simple as reading the current row**, which is why this is S and not XS.
+
+- The action applies to the WHOLE selection and picks one direction from the
+ current row, so with a mixed selection any label naming a single outcome is
+ either wrong for some rows or has to describe the rule ("Mark all as read").
+- A menu action's text is read when the menu opens, but a TOOLBAR button's text
+ is on screen continuously, so it has to track `selectionChanged` rather than
+ being computed at popup time. `currentRowChanged` is the wrong signal for
+ anything selection-shaped, per `CLAUDE.md`.
+- The accelerator is inside the word (`Toggle &unread`). Two different labels
+ need two accelerators chosen so neither collides in the Message menu, which
+ already holds "Mark &spam" and "&Important".
+- The shortcut list (Help > Keyboard shortcuts) and the config's `[keys]`
+ section both name the action `toggle_unread`. The action NAME must not change
+ with the label, or every user's config breaks. Same rule as item 57, which
+ changed "Flag" to "Important" on screen and left the action and tag alone.
+
+**Approach.** Compute the label from the same state the lambda already uses,
+which since item 105 is `MainWindow::everySelectedRowHasTag("unread")`, update
+it on `selectionChanged`, and keep a neutral fallback for an empty or mixed
+selection. Decide with item 98, which raises the identical question for
+"Important".
+
+**Use that helper rather than re-deriving the state**, or the label and the
+action can disagree. It already encodes the two things this gets wrong on its
+own: a reply answers about its MESSAGE, not its thread, and the answer is over
+the whole selection rather than the current row.
+
+**Constraints.** Every label is user-facing and needs `tr()`. Since the strings
+are chosen at runtime rather than written once, all of them must exist as
+literals `lupdate` can see; a string built by concatenation is not translatable.
+`ctest -R translations` is the check.
+
+**Size: S.** Mostly the mixed-selection and toolbar decisions, not the code.
+
+## 100. The message pane offers Back, Forward, Reload and Save page, none of which mean anything
+
+**Observed (user, from the notes):** "back/forward/save page in the right pane
+don't make sense, shouldn't be visible."
+
+**Cause (verified in the code).** `MessageView::showBodyContextMenu`
+(`src/messageview.cpp:619`) starts from Chromium's own menu:
+
+```cpp
+QMenu *menu = m_view->createStandardContextMenu();
+```
+
+That menu is built for a browser and carries the navigation and page actions
+whole. The pane is not a browser: every document arrives through `setHtml()`
+with a fixed base URL, so there is no history to go back to, nothing to reload
+from, and the request interceptor blocks everything by default anyway. The
+entries are inert as well as meaningless.
+
+**Deliberate as far as it goes.** The comment above the call says the page's own
+menu comes first so "copy, select all and the rest stay exactly as they were",
+which is right for the editing actions and wrong for the navigation ones. The
+item is that the filter was never applied, not that the base menu was a mistake.
+
+**Approach.** Keep the menu, drop the actions that cannot apply. Qt names them
+as `QWebEnginePage::WebAction` values (`Back`, `Forward`, `Reload`,
+`SavePage`, and `ViewSource` is worth the same look), and each has a
+`pageAction()` whose pointer can be matched against the standard menu's entries
+and removed. Removing by matching the action pointer is safer than matching by
+text, which is translated.
+
+**Constraints.**
+
+- Do not rebuild the menu from scratch. Copy, Copy link address and Select all
+ are the reason the standard menu is used, and item 85's search entries are
+ appended to it.
+- `Save page` is not the attachment save. Attachments have their own bar and
+ their own path-traversal checks (see the web view security notes in
+ `CLAUDE.md`); nothing here should grow a second way to write a file.
+- Verify against a real right-click on a real message. The offscreen platform
+ builds the menu but a screenshot of it proves nothing, and the entry list
+ depends on what the page reports as available at that moment.
+
+**Size: XS.**
+
+## 101. Sync is account-aware for edits but not for the account the user is looking at
+
+**Observed (user, from the notes):** "sync button should be account-aware."
+
+**Cause (verified in the code).** `MainWindow::pendingSyncChannels()`
+(`src/mainwindow.cpp:3550`) resolves channels from `m_editedAccounts`, the set of
+accounts the user has made EDITS in, and from nothing else. The account dropdown
+is not consulted. With nothing pending it returns empty on purpose, and
+`mailsync.sh` turns that into `mbsync -a`, every channel.
+
+**Item 49 built exactly this and the reasoning still holds.** The comment states
+it: with nothing pending the run is a FETCH, and narrowing a fetch to wherever
+the last edit happened would "quietly stop collecting mail everywhere else".
+Fetching is global by nature; carrying edits is not.
+
+**So this needs a decision, not a fix.** The note does not say which of two
+things the user means, and they are different features:
+
+*Sync only the selected account, on demand.* A deliberate "sync this account"
+that ignores the pending set, presumably beside the existing Sync rather than
+replacing it. Useful when one account is slow and the user wants their mail from
+another one now. The risk is the one item 49 named: a button that looks like
+Sync and quietly does not collect the rest of the mail.
+
+*Show which accounts a sync will cover.* No behaviour change at all, just making
+the existing account-awareness visible, since today the user cannot tell whether
+a run is narrowed or full. The status bar already names each channel as mbsync
+reaches it (item 42), so most of this exists.
+
+**Constraints.**
+
+- The account dropdown is a VIEW filter. Making it also steer sync couples two
+ things the user may reasonably want apart: looking at one account while
+ fetching all of them is the normal case, not an edge case.
+- Whatever narrows a run must still carry every pending edit, or an edit is
+ stranded with nothing on screen to say so. `pendingSyncChannels()` already
+ falls back to a full sync when it cannot resolve a channel for an edited
+ account, and that safety must survive.
+- An account with no `[account.<key>]` section, or one whose section names no
+ channel, has no channel to sync. The fallback covers it today.
+
+**Size: S** for the on-demand button, XS for the visibility half. Ask which.
+
+## 102. The rules table shows no note, so the field explaining a rule is invisible until it is opened
+
+**Observed (user, from the notes):** "add 'notes' column to the filters table."
+
+**Cause (verified in the code).** `TagRule` carries a `note` field
+(`src/tagrules.h:37`, "Why the rule is shaped this way. Shown in the dialog"),
+and the editor below the table edits it, but the table itself lists five columns
+and none of them is the note (`src/tagrulesdialog.cpp:116`):
+
+```cpp
+m_list->setHeaderLabels({ tr("On"), tr("Stage"), tr("Rule"), tr("Tags"),
+ tr("Matches") });
+```
+
+So the one field written specifically to explain a rule can only be read one rule
+at a time, by selecting it. With several rules the note is exactly the thing that
+would let the user pick the right one without opening each.
+
+**Approach.** A sixth column. The column widths already persist (item 75), so a
+new column needs a sensible default width and nothing else in the way of state.
+
+**Constraints.**
+
+- The note is free text of any length and would stretch the column. Elide it and
+ put the full text in the tooltip; the `Rule` column already faces the same
+ problem with a long query and is the pattern to match.
+- `ColumnCount + 1` in `setColumnCount` is load-bearing: the enum drives the
+ column indices and there is a spare. Add the enum value rather than hardcoding
+ 5, and check every place that indexes a column by number.
+- Notes are the user's own words and can be empty. An empty cell is correct
+ here; do not substitute a placeholder.
+
+**Size: XS.**
+
+## 103. What Delete does to mail on the server is undocumented and unverified
+
+**Observed (user, from the notes):** "verify how 'delete' works", with two
+sub-questions of their own: "trash bin (?)" and "delete from server (?)".
+
+**This is a question first.** The user is not reporting a defect; they are saying
+they do not know what the button does to their mail, which for a destructive
+action is its own problem regardless of the answer.
+
+**What the code does (verified).** `src/mainwindow.cpp:825` adds and removes the
+`deleted` tag, and nothing else. It is a toggle, it goes through the undo stack,
+and it writes a notmuch tag.
+
+**What that means downstream is what needs verifying, and it is NOT in this
+repo.** `maildir.synchronize_flags` is true, so notmuch maps certain tags to
+Maildir filename flags, and mbsync carries filename flags to the server. Whether
+`deleted` is one of those, whether the user's `~/.mbsyncrc` has `Expunge Both`
+(it does, on every channel), and what each provider does with a message flagged
+deleted, together decide whether this button is reversible. The undo stack makes
+the TAG reversible; it says nothing about what a sync did with it in between.
+
+**Approach.** Measure before designing anything: the notmuch config's
+`maildir.synchronize_flags` and its tag-to-flag mapping, one real message tagged
+and synced in a test account, and what the server shows afterwards. Then decide
+whether the UI needs to say what it does, whether "Delete" is even the right
+word for it, and whether a trash view is wanted.
+
+**Constraints.**
+
+- **This is the one place the no-confirmation rule should be re-examined rather
+ than assumed.** `CLAUDE.md` records that a human at a GUI gets undo instead of
+ confirmation dialogs, and that is right for tags. If the measurement shows the
+ next sync expunges mail from the server, then undo does not in fact cover this
+ action, and the premise the rule rests on does not hold for it.
+- Do not test this against the user's real accounts. A message that is expunged
+ to prove that it is expunged is still gone.
+- Any answer that involves a trash view is a much larger item and should be
+ split out rather than folded in here.
+
+**Size: S** for the investigation and whatever the UI needs to say. Unknown
+beyond that, and deliberately not sized further until the measurement exists.
+
+## 104. Mail visible in Thunderbird never reaches qtmaildir
+
+**Observed (user, from the notes):** "sync doesn't work compared to thunderbird.
+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.
+
+**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.
+
+
## Deferred, unsized, or split out
Items noted while triaging but not part of the original list. Same numbering