aboutsummaryrefslogtreecommitdiffstats
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md549
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md181
2 files changed, 557 insertions, 173 deletions
diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md
index f2b977b..1208c42 100644
--- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md
+++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md
@@ -7942,3 +7942,552 @@ what turns a stale path into two server-side MESSAGES rather than one replaced
file. That wants its own item. A draft's id is not yet the sent message's id,
so changing it is not obviously free.
+
+## 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: 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.
+
+## 167. No way to tell one build of an unreleased version from another
+
+**Observed (user, from the notes):** "we should add a dev build number to be
+pushed everytime we rebuild after a fix, so that I can verify if I'm in the
+correct app version." The note has sat unrecorded through several sessions;
+the 2026-08-25 reconciliation is the first to pick it up.
+
+**Cause (verified in the code, 2026-08-25.)** The version lives in exactly one
+place, `project(qtmaildir VERSION ...)`, and `src/version.h.in` interpolates
+`@PROJECT_VERSION@` and nothing else. That is correct for a release and says
+nothing between two of them: the string moves only when the release procedure
+bumps it, so every rebuild of `0.27.0` reports `0.27.0`. The status table above
+shows why it bites in practice, since most closed items since 0.27.0 read
+"unreleased" and the user hand-tests each one against a binary they rebuilt
+themselves.
+
+Both surfaces that show the version take it from the same macro, so whatever is
+added reaches them at once: the window title (`mainwindow.cpp:939`), the About
+dialog (`mainwindow.cpp:2449`), the placeholder pane (`messageview.cpp:547`),
+`--version` and `--help` (`main.cpp`).
+
+**Approach.** Needs a DECISION before any code, because the two candidates fail
+in opposite directions.
+
+A git description (`git describe --always --dirty`, or the short hash) is
+accurate and self-explaining: it names the commit the binary was built from, and
+a reviewer can check out exactly that. Its cost is that CMake computes it at
+CONFIGURE time, so a build after a new commit reports the previous hash unless
+the configure step is made to re-run, which is a custom command with a dependency
+on `.git/HEAD` and the packed refs, and is the part that usually ships subtly
+wrong.
+
+A monotonic counter always moves and needs no git, but it means nothing on its
+own: build 412 does not say which fix is in it, and it differs between the user's
+machine and any other, so it cannot be quoted in a report.
+
+**Constraints.** A release build must keep printing a clean `X.Y.Z`, since the
+SlackBuild in the `my-slackbuilds` repo builds from the release tarball where
+there is no git checkout at all, and the release procedure checks
+`./build/src/qtmaildir --version`. Whatever is added is therefore an addition to
+the string in a dev build and absent in a release one, not a change to the
+version itself.
+
+**Decision (user, 2026-08-25): the counter.** The git description was
+offered as the recommendation and was not chosen; what the user wants is to
+know a rebuild happened, not which commit it was.
+
+**Built 2026-08-25, unreleased.** `QTMAILDIR_BUILD_NUMBER`, a cmake option ON
+by default, runs `cmake/BuildNumber.cmake` as a build step: it increments a
+counter and writes `buildnumber.h`, which `version.h` includes.
+`QTMAILDIR_VERSION_DISPLAY` is `X.Y.Z build N` when that macro is defined and
+plain `X.Y.Z` when it is not.
+
+Two macros, not one, and the split is the load-bearing part.
+`QTMAILDIR_VERSION` stays clean and keeps the window title, `applicationVersion`
+and anything that might ever compare versions; `QTMAILDIR_VERSION_DISPLAY` goes
+to the three surfaces the user picked: `--version`, `--help`, the About dialog
+and the placeholder pane. The window title was offered and declined, since the
+number would then sit in every screenshot.
+
+**The counter had to be a BUILD step, not `configure_file`.** That is the whole
+reason this is not two lines: `configure_file` runs once per cmake run, so a
+counter interpolated into `version.h.in` sits still across exactly the rebuilds
+this item exists to distinguish. `version.h.in` therefore includes a second
+generated header rather than carrying the number itself.
+
+The counter file lives in the build directory and is not tracked, so it cannot
+conflict on a pull or dirty the tree; a fresh build directory restarts at 1,
+which is honest, because it is a different build tree. A release build passes
+`-DQTMAILDIR_BUILD_NUMBER=OFF` and the header is written empty.
+
+**Verified by running it**, since none of this is reachable from a C++ test:
+three consecutive builds reported `build 2`, `build 3`, `build 4`, and a
+separate Release configure with the option OFF reported a clean `0.27.0`. The
+suite is 37 of 38, the one failure being item 136 on an unrelated path.
+
+**Size: XS**, as sized.
+
+## 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.**
+
+- **The hook is this repo's**, `assets/hooks/post-new`, which the live
+ `database.hook_dir` symlinks to. It has its own suites beside it; run
+ `./test_post_new.py` and `./test_mailrules.py` from `assets/hooks/`.
+- 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.
+
+**Fixed 2026-08-25, option 1**, the one the entry named: strip only when
+every file is in a sent folder.
+
+`sent_only()` in `assets/hooks/post-new` filters the matches and the tag is
+then applied per id. **It is a loop because no query can express it**, and both
+plausible query forms were measured against a real two-file message before the
+loop was written: `not path:"Inbox/**"` does NOT exclude the message, and
+`notmuch count --output=files` on a path query reports every file of every
+matching message rather than the files that matched. Both read as if they
+worked and are wrong for one reason, that a notmuch term is a predicate over a
+MESSAGE while the distinction here is between its FILES.
+
+The root comes from `database.mail_root`, not `database.path`, since this index
+is split and no message file sits under the index directory. The mutation
+putting `database.path` back passes every pre-existing test, because the
+ordinary fixture keeps the index inside the mail root and both keys return the
+same string; `setup_accounts(split_index=True)` is what catches it, and is the
+Python counterpart to `NotmuchFixture::splitIndex()`.
+
+Two mutations fail: `all` to `any` loses `inbox` on the self-addressed message,
+`mail_root` to `path` silently stops stripping anything.
+
+**Verified read-only against the live index**, tagging nothing: of 807 messages
+matching a sent path, 780 are still stripped and 27 are spared, every one of
+them two files with one in another account's Inbox. No arrival is affected.
+
+**Size: S.** Done.
+
+## 112. Toggle unread on a whole thread cannot reach "all unread" on a partly-read thread
+
+**Observed (user, 2026-08-17):** clicking a thread root and asking to mark the
+whole thread unread does not do it. On a seven-message thread with two unread
+replies, the result is that every message is toggled unread **except those
+two**, which are left as they were. The user asks for an explicit "mark whole
+thread read/unread" rather than a toggle.
+
+**Cause (verified in code):** the action exists, and its direction is the
+defect. `toggle_unread_thread` (`src/mainwindow.cpp:931`, `Ctrl+Alt+U`) chooses
+between adding and removing by asking
+`everySelectedRowHasTag("unread", TagScope::Thread)`, which reads
+`ThreadListModel::threadFor(index).tags`. That is notmuch's **union over the
+thread** (`CLAUDE.md`, item 110), so a thread containing even one unread message
+answers "unread" and the action picks *Mark thread read*. There is no input a
+user can give that reaches *Mark thread unread* on a mixed thread: the only
+threads that take that branch are the ones already entirely read, and the only
+threads reporting "not unread" are the ones the user does not need the action
+for.
+
+The write itself is absolute and correct. `tagSelected` with `TagScope::Thread`
+adds or removes `unread` across every message, so the two unread replies in the
+report are not skipped by the write. They are the reason the write ran in the
+opposite direction from the one the user wanted.
+
+**A union is not a state, and a toggle needs a state.** This is the same class
+as item 110 and the third time the union has produced a defect. Items 105 and 88
+fixed *which object* a toggle resolved; this one is about a thread having no
+single answer to give. `everySelectedRowHasTag` is a two-valued predicate over a
+three-valued reality: all read, all unread, or mixed. The mixed case is the one
+that has no correct toggle direction, and picking either one silently is what
+ships as "the action does the wrong thing".
+
+**Approach.** The user has already named it: stop toggling at thread scope.
+
+- Split `toggle_unread_thread` into two explicit actions, **Mark thread read**
+ and **Mark thread unread**, each with a fixed direction. Both appear in the
+ "Whole thread" submenu, where an entry always carries text, so a fixed label
+ is honest in a way a toggle's cannot be.
+- The message-scoped `toggle_unread` stays a toggle. One message has a real
+ two-valued state, so the trap does not exist there. Do not "unify" the two:
+ the asymmetry is the point.
+
+**Constraints.**
+
+- **Adding an action is four places**, all enforced by tests that fail
+ confusingly: `KeyMap::knownActions()`, `defaultBindings()`, the icon table,
+ and the no-duplicate-icons exception list. See `CLAUDE.md`. Splitting one
+ action into two means one new entry in each, and the pair shares the twin's
+ icon under the existing named exemption for thread actions.
+- **`Ctrl+Alt+U` is taken by the action being split**, and the whole-thread
+ bindings are already one modifier out from their twins because `Ctrl+Shift+U`
+ was claimed. Two directions need two sequences; if a second chord cannot be
+ found that is not worse than the menu, bind one and leave the other to the
+ submenu rather than inventing a three-modifier chord nobody will press.
+- **This interacts with items 98 and 99**, which is the reason to decide all
+ three together. 99 asks for a dynamic label on the message-scoped toggle,
+ which is the opposite move: keep the toggle, make the label tell the truth.
+ A thread cannot do that, because on a mixed thread there is no true label to
+ show. Deciding 99 first will produce the wrong answer here by analogy.
+- The undo entry must name the direction that ran (`Mark thread unread`), not
+ the action. `tagSelected` already takes the text, so this comes free from
+ splitting.
+- **The test needs a MIXED thread**, which is the whole defect: a thread whose
+ messages are all in one state answers identically whichever way the direction
+ is computed, so a fixture built from a uniformly-unread thread passes against
+ the bug. Same trap as item 88's opposite-states requirement, recorded in
+ `CLAUDE.md`.
+
+**Built 2026-08-25 to the USER'S NOTE, not to the approach above**, which had
+this half right and was shipped that way first. The approach proposed splitting
+the thread toggle and explicitly said to leave the message-scoped one alone,
+deciding 99 separately. The user's note is ONE design across both, and the
+entry's own constraint said so ("this interacts with items 98 and 99, which is
+the reason to decide all three together") without following it. The half-built
+version was handed over, corrected by the user, and rebuilt.
+
+Four parts, all of them the note's:
+
+- The thread toggle splits into `mark_thread_read` and `mark_thread_unread`,
+ both absolute. **Neither carries a default chord**, at the user's choice:
+ since item 132 a shortcut is a chosen subset, and `Ctrl+Alt+U` meant
+ whichever direction the union happened to pick, which is what made it wrong.
+ It is now unbound.
+- The message-scoped `toggle_unread` STAYS a toggle, because one message has a
+ real two-valued state, and gains a label naming the direction it will go.
+- On a selection with no single state that entry is **hidden**, chosen over
+ disabled by the user. There is no honest label for a mixed selection, and
+ the thread submenu is the route the note points at.
+- The label follows a WRITE as well as a selection change, keyed on the
+ model's `dataChanged` rather than on the six call sites that apply an
+ optimistic update, so a new one cannot forget. Without it, marking the
+ current row read left the entry offering to do it again.
+
+`selectionTagPresence()` is the three-valued predicate this needed;
+`everySelectedRowHasTag()` now delegates to it and keeps its two-valued
+answer, which is all a DIRECTION needs. A label needs the third value, and
+asking a two-valued predicate a three-valued question is what this item was.
+
+**Three mutations fail:** restoring the union predicate reports "wrong
+direction on a mixed thread: Mark thread read", which is the user's original
+symptom; showing the action on a mixed selection; and dropping the
+`dataChanged` refresh. The suite is 37 of 38, the one failure being item 136 on
+an unrelated path, and the four new strings are translated with `lrelease`
+reporting 0 unfinished.
+
+**Closes 99 and 147 with it**, which were the same note recorded twice.
+
+**Size: S.** Done, at roughly twice the entry's scope because the entry's scope
+was wrong.
+
+## 118. No way to empty the trash from inside the app
+
+**Observed (user, 2026-08-17):** raised while reviewing item 103's spec, as
+something that had been forgotten rather than newly noticed: "we could add
+'Empty Trash' to the backlog as a future item. I forgot it existed, but I don't
+want to squeeze it in this spec."
+
+**Blocked on 103**, which creates the trash folder this would empty. Until that
+ships there is nothing to empty: Delete writes a tag and moves no file, so no
+account has a populated trash folder except through another client.
+
+**Deliberately excluded from 103's spec**, at the user's request and recorded in
+its "Out of scope" section. Worth keeping separate for a reason beyond scope
+control: emptying the trash is the first action in this application that would
+destroy mail with no undo. Every mutation so far is a tag or, after 103, a move,
+and both are reversible. A purge is not.
+
+**Approach, unspecified.** The shape depends on decisions not yet made, and the
+spec for 103 answers none of them:
+
+- **Local or remote.** Deleting the files locally and letting `Expunge Both`
+ carry it to the server is one thing; asking the provider to empty its own
+ trash is another, and mbsync offers no verb for the latter. The first is
+ probably what "Empty Trash" should mean here.
+- **Whether the no-confirmation rule survives it.** It does not, on the face of
+ it. `CLAUDE.md` grants undo in place of confirmation dialogs, and this is the
+ action where undo cannot exist. That makes it the second item, after 103, that
+ re-examines the rule rather than assuming it, and unlike 103 it will probably
+ have to break it.
+- **Per-account or all-accounts**, which should follow whatever the Trash filter
+ does once 103 ships rather than being decided independently.
+
+**Built 2026-08-25**, unblocked by 103. The three questions the entry left open
+were put to the user and answered:
+
+- **Local, and let the sync carry it.** The files go, and a channel with
+ `Expunge Both` propagates that to the server. mbsync offers no verb for
+ asking a provider to empty its own trash, so the alternative was to delete
+ locally and not care, which brings the mail back on the next sync and reads
+ as the action having silently failed.
+- **It confirms**, naming the count and the account, defaulting to Cancel, with
+ no default shortcut. CLAUDE.md now records this as the ONE exception to the
+ no-confirmation rule, in the same paragraph that states the rule, so the next
+ reader meets both together.
+- **Scoped to the account selector**, like every other account-aware surface,
+ which is what the entry asked for.
+
+`NotmuchWorker::purgeMessages()` is a separate entry point from
+`moveMessages()` rather than a flag on it, because the two look alike and only
+one can be undone. It takes named ids only, never a folder sweep, so the blast
+radius is what the dialog enumerated and the user confirmed. It deletes EVERY
+file of a message: notmuch deduplicates by Message-ID, and leaving one behind
+would leave the message alive in the folder the user emptied, which is the same
+one-message-many-files property item 166 turned on.
+
+`resolveQueryMessages()` is a four-line wrapper over the existing private
+`resolveQuery()`, so enumerating what is about to be destroyed needed no new
+walk. The count in the dialog comes from the DATABASE rather than the model,
+which holds whatever the current view is showing and is usually not the trash.
+
+**A defect surfaced while writing the tests**, and it is the one worth
+remembering: the first version counted a message whose file was already gone as
+destroyed, so the number reported for an irreversible action overstated it. An
+absent file is correctly not an ERROR, since the index can name a path a sync
+has removed; the mistake was treating "not an error" as "destroyed". The
+mutation that restores it now fails.
+
+**A second defect was found by the user's own hand test**: the mail was
+destroyed correctly and the LIST went on showing it until they re-ran the query
+themselves. A purge is the one mutation with no optimistic update available,
+because it removes rows rather than changing them, so `messagesPurged` re-runs
+the current query. Nothing was connected to that signal at all, which is the
+kind of gap a green suite is happy to keep.
+
+Verified against the live index after the user emptied one real account's
+trash: zero files on disk, zero in the index.
+
+**Item 168 was filed from the same hand test**, on Delete being offered on mail
+already in the trash.
+
+**Size: S.** Done.
+
+## 168. Delete is offered on mail already in the trash, and does nothing
+
+**Observed (user, 2026-08-25, while hand-testing item 118):** "I noticed I can
+hit delete via context menu on a message already in the trash. Seems like a
+bug, unless that action doesn't do for one message what Empty trash does for
+the whole view."
+
+It does not, and the guess in the second half is worth recording as the reason
+this matters: the user's mental model was that Delete on already-trashed mail
+might PURGE it. It does not, and nothing about the menu says so.
+
+**Cause (verified in code, 2026-08-25.)** `moveMessages()` compares the file's
+directory against the destination and takes an early-return branch when they
+match (`notmuchworker.cpp`, the "already where it was asked to go" branch,
+added when a fresh Maildir name made a path comparison useless). That branch
+appends the id to `moved` and records an origin, so the message is reported as
+having moved when nothing happened. The UI counts an unsynced change for it.
+
+Nothing is destroyed and nothing is corrupted; the cost is a menu entry that
+lies about having done something, and a pending-changes count that overstates
+what a sync has to carry.
+
+**The mirror of the same defect is already shipped beside it.** `restore` is
+added unconditionally to both the Message menu (`mainwindow.cpp:1956`) and the
+thread context menu (`mainwindow.cpp:2119`), so it is offered on mail that was
+never deleted, where it has as little meaning as Delete has in the trash.
+
+**Approach.** The user chose to hide each action where it has no meaning,
+which is the principle item 112 established for the unread entry: an action
+with no honest meaning for the selection is absent rather than present and
+inert.
+
+- Delete is hidden when every selected row is already in a trash folder.
+- Restore is hidden when no selected row is.
+- The test for both needs a MIXED selection as well as uniform ones, for the
+ reason item 112 records: a selection whose rows agree answers identically
+ whichever way the predicate is computed.
+
+**Constraints.**
+
+- **The question is about the PATH, not the tag.** A message trashed by
+ another client carries no `deleted` tag at all, which is why item 103 made
+ the trash view path-based. Asking `tags.contains("deleted")` here would
+ offer Delete on exactly the mail the user is most likely to be looking at
+ in a trash view.
+- **`selectionTagPresence()` is the wrong instrument** for the same reason,
+ though it is the right shape. A path predicate needs the row's path, which
+ `MessageNode` carries.
+- Deciding this does not require deciding item 118's relationship to it: a
+ purge stays an explicit whole-view action, and hiding Delete does not make
+ Delete a purge.
+
+**A second request, from the same tangent (user, 2026-08-25):** "messages moved
+to the trash should be automatically marked `-unread`." Deleting is a decision
+about the message, so leaving it bold and unread in the trash is noise; the
+count of unread mail should not include what the user threw away.
+
+It is one line where Delete already composes its tag change, and it carries a
+constraint worth stating rather than discovering. `maildir.synchronize_flags`
+is true, so removing `unread` REWRITES the Maildir filename and reaches the
+server on the next mbsync. That is acceptable here and is a deliberate
+exception: it is the same mechanism the `post-new` hook refuses to touch on
+arriving mail, for the good reason that the hook acts unattended on mail the
+user has not seen. A Delete is an explicit gesture on a message in front of
+them, which is the difference.
+
+Undo must put it back. `TagChange::inverted()` already does, provided the
+removal travels as part of the SAME change rather than as a second write, so
+one undo returns both the folder and the tag.
+
+**Built 2026-08-25**, both halves, to the user's own choice of "hide each
+where it has no meaning".
+
+`everySelectedRowIsInATrashFolder()` asks each row about its own file, a reply
+row's message and a thread row's displayed message, the same rule
+`everySelectedRowHasTag()` follows. `refreshTrashActions()` runs beside
+`refreshUnreadAction()` on both the selection change and the model's
+`dataChanged`, so the entries follow a write as well as a selection.
+
+The `unread` removal travels inside the SAME `sendMove()` call rather than as a
+second write, which is what makes one undo return the folder and the tag
+together.
+
+**A mutation survived the first round and is worth recording**: comparing the
+prefix WITHOUT its trailing separator passed every test, because no fixture had
+a folder whose name starts with the trash folder's. `acct/trash-old` is a
+different folder, and under that mutation Delete silently disappeared from mail
+that had never been trashed, which is the quiet half of the same mistake. The
+fixture carries that row now and the mutation fails.
+
+All three properties are mutation-checked: the separator, Restore's visibility,
+and the `unread` removal. The suite is 37 of 38, the failure being item 136 on
+an unrelated path, and no new user-facing strings were added.
+
+**Size: S** for the visibility half, XS for the `unread` half. Done.
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..437ceda 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
@@ -165,12 +165,12 @@ taking that too literally.
| 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 | **done** 2026-08-17, unreleased. Calls `everySelectedRowHasTag()`, as the entry required. Its reply test needed THREE different states (list-first thread, the reply's own thread, the reply) before it could tell the two wrong answers apart; with the reply defaulted to its thread's state the item 105 mutation stayed green, measured |
-| 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 |
+| 99 | The unread action is labelled "Toggle unread" whichever way it will go | presentation | S | **done 2026-08-25**, unreleased, with 112: the user's note is ONE design across both. The label names the direction it will go, and the entry is hidden on a selection with no single state. `refreshUnreadAction()` reads the new three-valued `selectionTagPresence()` |
| 100 | The message pane offers Back, Forward, Reload and Save page, none of which mean anything | defect | XS | **done** 2026-08-17, unreleased. `MessageView::removeBrowserActions()` filters the standard menu by `pageAction()` POINTER, never by text; `ViewSource` went with them, and stranded separators are swept |
| 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 | **done 2026-08-25**, hand-tested. The worker never reopened its read-only notmuch handle, so no query saw mail indexed after startup. Confirmed on a sync run from the application that added 20 messages: they appeared without a restart |
| 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 |
@@ -178,13 +178,13 @@ taking that too literally.
| 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 |
-| 112 | Toggle unread on a whole thread cannot reach "all unread" on a partly-read thread | defect | S | open, found 2026-08-17. A toggle over a UNION has no direction on a mixed thread |
+| 112 | Toggle unread on a whole thread cannot reach "all unread" on a partly-read thread | defect | S | **done 2026-08-25**, unreleased. Built to the user's own note rather than to this entry's approach, which had it only half right. The thread toggle splits into two absolute actions AND the message-scoped one keeps its toggle with a dynamic label, hidden when the selection disagrees. Closes 99 and 147 with it |
| 113 | No way to see a message's HTML source | information | S | open, 2026-08-17. Chromium's own View source cannot work here; needs our own plain-text dialog. Item 100 removed the dead entry, which was an overreach: the user had not asked for it |
| 114 | Save image is offered on every image and does nothing | defect | S | open, found 2026-08-17, re-confirmed by hand 2026-08-20. No `downloadRequested` handler exists, so the request is emitted and never answered. The handler is per-profile, so it must decide per request or it revives the Save link item 127 removed |
| 115 | A copy from the message pane gives no confirmation | presentation | XS | **done** 2026-08-19, unreleased. Four entries report, each naming what it copied; connected to the page's own QActions, so the entry is covered wherever it is triggered from |
| 116 | Copy image copies markup instead of the image | defect | XS | **dropped** 2026-08-17, same day. NOT A DEFECT: `wl-paste --list-types` run immediately after a copy reports `image/png`, `application/x-qt-image` and 30 more image flavours. The clipboard is correct and Chromium is behaving. The earlier "text only" reading was taken minutes late off a clipboard that had been overwritten, and a whole cause was theorised on it |
| 117 | The message pane offers no Select all | workflow | XS | **done** 2026-08-19, unreleased. `addPaneActions()` supplies it. The call site is NOT covered by a test and cannot be: the production menu needs a real context-menu event. Stated in the test rather than faked |
-| 118 | No way to empty the trash from inside the app | workflow | S | open, 2026-08-17. **Blocked on 103**, which creates the trash in the first place. Deliberately left out of 103's spec at the user's request rather than squeezed in |
+| 118 | No way to empty the trash from inside the app | workflow | S | **done 2026-08-25**, unreleased. Unblocked by 103. `Message > Empty trash...`, scoped to the account selector, no shortcut. The one confirmation in this application, and CLAUDE.md now records it as the single exception rather than leaving it to be discovered. Found a defect while testing: the count claimed messages whose files were already gone |
| 119 | The unsynced-changes count cannot be opened to see what it counts | information | S | open, 2026-08-19, from the notes. One of the four things it sums carries no message ids at all, so a list cannot be complete without a change to how the count is kept |
| 121 | The thread list shows nothing while a query is running | feedback | S | open, 2026-08-20, from the notes. Follows item 74, which fixed the status-bar half and left the list itself blank |
@@ -216,7 +216,7 @@ taking that too literally.
| 144 | "Also send a formatted copy" is prominent and does not say what it does | presentation | XS | **done** 2026-08-24, unreleased, inside 142. "Send as HTML", icon and text, alone at the right end of the editor bar where it reads as a control of the editor rather than as a formatting button. The Italian entry was refreshed with it, and `lrelease` reports 477 finished, 0 unfinished |
| 145 | Cc and Bcc are permanent rows on every composer | presentation | S | **done** 2026-08-24, unreleased, inside 142. A `QToolButton` disclosure beside To:. `revealCcBccIfUsed()` is the load-bearing half the entry called for: it only ever SHOWS, never hides, so nothing but the user's own click can make a field holding an address invisible. `ComposeContext` carries no `bcc` at all, so the seeded-Bcc case can only arrive from a reopened draft, which is what its test drives. The LABEL is hidden with each field: a `QFormLayout` holds the two as separate items, so hiding the line edit alone strands a `Cc:` over empty space |
| 146 | The unsynced-changes count cannot be opened to see what it counts | information | S | **duplicate of 119**, recorded 2026-08-23 from the notes. Same request, and 119 already carries the blocker: one of the four things the count sums holds no message ids, so a list cannot be complete without changing how the count is kept |
-| 147 | Toggle unread reads the same whichever way it will go | presentation | S | **duplicate of 99**, recorded 2026-08-23 from the notes. The notes ask for exactly what 99 describes: "Mark as read" on an unread message and the reverse. 99 already records that the label is harder than it looks, since a multi-row selection has no single direction |
+| 147 | Toggle unread reads the same whichever way it will go | presentation | S | **duplicate of 99**, recorded 2026-08-23 from the notes, and closed with it on 2026-08-25 |
| 148 | Ctrl+W does not close the composer | discoverability | XS | **done** 2026-08-24, unreleased. A `QAction` parented to the composer, so it is a WindowShortcut dispatched to the active composer only and the main window's namespace is untouched, exactly like the formatting shortcuts. It calls `close()` rather than doing anything of its own: `closeEvent()` already decides whether the draft is saved, and a second route out that skipped it would lose the message. Not registered in `KeyMap`, so item 132's rules do not apply |
| 149 | A reply's cursor lands on the attribution line, not on blank space | defect | XS | **done** 2026-08-24, unreleased, in TWO passes. The first fixed the cursor within each branch (`End` under Above, `Start` under Below) and the user still saw the old layout, because the branches were already right and the DEFAULT was wrong: `above` shipped, and the layout asked for is what `below` produces. Default flipped, and the composer now focuses the body whenever To: is already filled, which a Reply and a Forward always are. Both halves were invisible to the existing `theQuotePositionDecidesWhereTheQuoteLands`, which asserts the quote's position and never the cursor's |
| 150 | The receive-only ribbon stays up after the message that raised it is gone | defect | S | **done** 2026-08-24, unreleased. One line in `MessageView::clear()`, beside the blocked-content bar, the stale notice and the attachment bar it already reset by hand. Only `setReceiveOnlyAccount()` hid the ribbon, which every SELECTION change reaches, so a row-to-row move was never the reproducer: it survived the FOUR routes that blank the pane without one (`clear_pane`, `clear_selection`, a new query, a multi-row selection). The first test written for it passed against the defect for exactly that reason |
@@ -239,6 +239,9 @@ 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 | **done 2026-08-25**, unreleased. `sent_only()` keeps a message only when EVERY file is inside a sent folder, which is what the carve-out's docstring already claimed. No query can express it, measured; the root comes from `database.mail_root`, with a split-index fixture the ordinary layout cannot provide. Verified read-only against the live index: 780 of 807 still stripped, 27 spared, no arrival affected |
+| 167 | No way to tell one build of an unreleased version from another | enhancement | XS | **done 2026-08-25**, unreleased. The user chose a counter over a git description: `QTMAILDIR_BUILD_NUMBER`, a cmake option ON by default, increments a counter in the BUILD directory on every build and writes `buildnumber.h`. `QTMAILDIR_VERSION_DISPLAY` carries it; `QTMAILDIR_VERSION` stays clean and is what the window title, `applicationVersion` and the release procedure use |
+| 168 | Delete is offered on mail already in the trash, and does nothing | defect | S | **done 2026-08-25**, unreleased. Delete is hidden when every selected row is already in its account's trash, Restore when none is, both keyed on the PATH rather than the `deleted` tag. Delete also drops `unread` now, in the same TagChange so one undo returns the folder and the tag together |
Sizes are rough: XS under an hour, S a sitting, M a session.
@@ -535,140 +538,6 @@ reaches it (item 42), so most of this exists.
**Size: S** for the on-demand button, XS for the visibility half. Ask which.
-## 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.
-
-
-## 112. Toggle unread on a whole thread cannot reach "all unread" on a partly-read thread
-
-**Observed (user, 2026-08-17):** clicking a thread root and asking to mark the
-whole thread unread does not do it. On a seven-message thread with two unread
-replies, the result is that every message is toggled unread **except those
-two**, which are left as they were. The user asks for an explicit "mark whole
-thread read/unread" rather than a toggle.
-
-**Cause (verified in code):** the action exists, and its direction is the
-defect. `toggle_unread_thread` (`src/mainwindow.cpp:931`, `Ctrl+Alt+U`) chooses
-between adding and removing by asking
-`everySelectedRowHasTag("unread", TagScope::Thread)`, which reads
-`ThreadListModel::threadFor(index).tags`. That is notmuch's **union over the
-thread** (`CLAUDE.md`, item 110), so a thread containing even one unread message
-answers "unread" and the action picks *Mark thread read*. There is no input a
-user can give that reaches *Mark thread unread* on a mixed thread: the only
-threads that take that branch are the ones already entirely read, and the only
-threads reporting "not unread" are the ones the user does not need the action
-for.
-
-The write itself is absolute and correct. `tagSelected` with `TagScope::Thread`
-adds or removes `unread` across every message, so the two unread replies in the
-report are not skipped by the write. They are the reason the write ran in the
-opposite direction from the one the user wanted.
-
-**A union is not a state, and a toggle needs a state.** This is the same class
-as item 110 and the third time the union has produced a defect. Items 105 and 88
-fixed *which object* a toggle resolved; this one is about a thread having no
-single answer to give. `everySelectedRowHasTag` is a two-valued predicate over a
-three-valued reality: all read, all unread, or mixed. The mixed case is the one
-that has no correct toggle direction, and picking either one silently is what
-ships as "the action does the wrong thing".
-
-**Approach.** The user has already named it: stop toggling at thread scope.
-
-- Split `toggle_unread_thread` into two explicit actions, **Mark thread read**
- and **Mark thread unread**, each with a fixed direction. Both appear in the
- "Whole thread" submenu, where an entry always carries text, so a fixed label
- is honest in a way a toggle's cannot be.
-- The message-scoped `toggle_unread` stays a toggle. One message has a real
- two-valued state, so the trap does not exist there. Do not "unify" the two:
- the asymmetry is the point.
-
-**Constraints.**
-
-- **Adding an action is four places**, all enforced by tests that fail
- confusingly: `KeyMap::knownActions()`, `defaultBindings()`, the icon table,
- and the no-duplicate-icons exception list. See `CLAUDE.md`. Splitting one
- action into two means one new entry in each, and the pair shares the twin's
- icon under the existing named exemption for thread actions.
-- **`Ctrl+Alt+U` is taken by the action being split**, and the whole-thread
- bindings are already one modifier out from their twins because `Ctrl+Shift+U`
- was claimed. Two directions need two sequences; if a second chord cannot be
- found that is not worse than the menu, bind one and leave the other to the
- submenu rather than inventing a three-modifier chord nobody will press.
-- **This interacts with items 98 and 99**, which is the reason to decide all
- three together. 99 asks for a dynamic label on the message-scoped toggle,
- which is the opposite move: keep the toggle, make the label tell the truth.
- A thread cannot do that, because on a mixed thread there is no true label to
- show. Deciding 99 first will produce the wrong answer here by analogy.
-- The undo entry must name the direction that ran (`Mark thread unread`), not
- the action. `tagSelected` already takes the text, so this comes free from
- splitting.
-- **The test needs a MIXED thread**, which is the whole defect: a thread whose
- messages are all in one state answers identically whichever way the direction
- is computed, so a fixture built from a uniformly-unread thread passes against
- the bug. Same trap as item 88's opposite-states requirement, recorded in
- `CLAUDE.md`.
-
-**Size: S.** The write path is already correct and thread-scoped; the work is
-the action split, the four registration sites, the binding decision, and a test
-over a mixed thread.
-
-
## 113. No way to see a message's HTML source
**Observed (user, 2026-08-17):** reviewing item 100's removals, "view source
@@ -804,40 +673,6 @@ make Save image work must not make Save link reachable again. The test fails if
it does, which is the point: the handler is per-profile, so the natural
implementation would light up both entries at once.
-## 118. No way to empty the trash from inside the app
-
-**Observed (user, 2026-08-17):** raised while reviewing item 103's spec, as
-something that had been forgotten rather than newly noticed: "we could add
-'Empty Trash' to the backlog as a future item. I forgot it existed, but I don't
-want to squeeze it in this spec."
-
-**Blocked on 103**, which creates the trash folder this would empty. Until that
-ships there is nothing to empty: Delete writes a tag and moves no file, so no
-account has a populated trash folder except through another client.
-
-**Deliberately excluded from 103's spec**, at the user's request and recorded in
-its "Out of scope" section. Worth keeping separate for a reason beyond scope
-control: emptying the trash is the first action in this application that would
-destroy mail with no undo. Every mutation so far is a tag or, after 103, a move,
-and both are reversible. A purge is not.
-
-**Approach, unspecified.** The shape depends on decisions not yet made, and the
-spec for 103 answers none of them:
-
-- **Local or remote.** Deleting the files locally and letting `Expunge Both`
- carry it to the server is one thing; asking the provider to empty its own
- trash is another, and mbsync offers no verb for the latter. The first is
- probably what "Empty Trash" should mean here.
-- **Whether the no-confirmation rule survives it.** It does not, on the face of
- it. `CLAUDE.md` grants undo in place of confirmation dialogs, and this is the
- action where undo cannot exist. That makes it the second item, after 103, that
- re-examines the rule rather than assuming it, and unlike 103 it will probably
- have to break it.
-- **Per-account or all-accounts**, which should follow whatever the Trash filter
- does once 103 ships rather than being decided independently.
-
-**Size: S**, provisionally, and not worth sizing properly until 103 exists.
-
## 119. The unsynced-changes count cannot be opened to see what it counts
**Observed (user, from the notes):** "the bottom left statusbar message needs to