diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-10 20:43:05 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-10 20:43:05 +0200 |
| commit | ed0e085377440a68cef63ade6dc2afd322c22df9 (patch) | |
| tree | a8b4e5631a6eb5ff56341253f3775b45e3a4c026 /docs/superpowers | |
| parent | 39a055fef99a3ce6877829753f384843b6a19177 (diff) | |
| download | qtmaildir-ed0e085377440a68cef63ade6dc2afd322c22df9.tar.gz qtmaildir-ed0e085377440a68cef63ade6dc2afd322c22df9.zip | |
feat(view): follow a background sync without a keystroke
The thread list now updates itself when a sync finishes, whether it is
empty or populated. New threads appear where the sort puts them, threads
that stopped matching leave, and threads whose state changed repaint.
Refreshing used to mean re-running the query, which cleared the model,
the selection, the message pane and the undo stack, so 0.8.0 declined to
do it on a cron timer and asked the user to press Enter instead. The
result was a list that quietly disagreed with the database: mail indexed
by cron never appeared, and an Unread view read to the end sat empty in
front of it.
ThreadListModel::reconcile() diffs a result against the current rows by
thread id instead, so a surviving thread keeps its row, its persistent
index and its loaded replies. Order comes from the result and is never
imposed here, which is what makes the sort dropdown authoritative.
The undo constraint this was sized around did not exist: no undo entry
was ever keyed on a row. ThreadTagCommand stores thread ids and
MessageTagCommand stores message ids, and applyTagChange() looks its
target up by id, so an entry already survived its rows leaving the view.
A thread read out of the current view now leaves the list, which is
correct and would otherwise strand the reader, so MessageView grows a
notice saying the open thread no longer matches, with a button that
re-queries it. Recovery lists the whole conversation, expands it, and
restores the message that was on screen rather than reopening at the
first one.
Ten defects were found building this, nine of them by hand testing:
- SyncMonitor::start() polls synchronously, so an idle lock file emits
stateChanged(Idle) from inside buildUi() and the first handler to
touch a widget segfaults before the window exists.
- QTreeView sets a current index when it takes focus with none set, and
current drives loading, so new mail opened itself and was marked read
without the user having looked at it. Selection is now required.
- The notice outlived what it described, both when the pane was blanked
and when another message replaced it.
- Retiring the "Background sync completed" message left the bar claiming
a sync was still running: silent means saying nothing new, not leaving
a stale claim on screen.
- A thread root sets both the thread id and the message id, so treating
the message id as the message-row case discarded it for the commonest
way to open a thread.
- A freshly queried root does not know its own first message until the
tree loads, so recovery selected nothing and left the pane blank.
- A user query mid-recovery had its result hijacked by the pending
selection.
- MessageView emitted the recovery signal with its own members, so a
direct connection handed MainWindow references that runCurrentQuery()
then cleared by blanking the pane. The ids went empty mid-slot and no
recovery ever ran. Every test passed against this, because reaching a
slot through invokeMethod copies its arguments.
A Qt signal argument is a reference until something copies it. Emitting
a member to a slot that can re-enter the emitter is a use-after-write,
and it presents as a wrong value rather than as a crash.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'docs/superpowers')
| -rw-r--r-- | docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md | 383 |
1 files changed, 381 insertions, 2 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 50d6c42..dccea2d 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 @@ -91,11 +91,11 @@ taking that too literally. | 32 | Esc does not blank the right pane | workflow | XS | **done** | | 33 | Status bar messages never expire | feedback | S | **done** | | 34 | No overview of the Maildir itself | information | M | **done** | -| 35 | No refresh of the thread list after a sync | workflow | M | open | +| 35 | No refresh of the thread list after a sync | workflow | M | **done** 2026-08-10; the list now follows a sync on its own | | 36 | `test_mainwindow` cannot reach the worker | testing | S | open, on demand | | 37 | The worker stalls on a tag edit made during a background sync | correctness | S | **done** | | 38 | `test_mainwindow` fails when a real sync holds the lock | testing | XS | **done** | -| 39 | Thread list cannot be sorted by clicking a column header | workflow | S | open | +| 39 | Thread list cannot be sorted by clicking a column header | workflow | S | **dropped** 2026-08-10; the card list has no column headers to click, and 0.13.0 shipped a sort dropdown instead | | 40 | No live filter over the current view | workflow | M | open | | 41 | A message whose HTML body carries a `Content-Id` renders blank | correctness | S | **done** | | 42 | "Syncing..." says nothing about what is being synced | feedback | S | **done** | @@ -118,6 +118,8 @@ taking that too literally. | 59 | Archive and Mark all read shipped with the same icon | presentation | XS | **done** | | 60 | Next thread dead-ends on the last reply of an expanded thread | defect | XS | **done**; already fixed by 5487d58, see below | | 61 | `test_mainwindow` fails intermittently, about 1 run in 20 | testing | S | open; predates the card list, reproduced on f72dba9 | +| 62 | No config option for the date format on a card | presentation | XS | open | +| 63 | No way to see sent mail, and no filter for it | workflow | S | open | Sizes are rough: XS under an hour, S a sitting, M a session. @@ -2175,6 +2177,292 @@ from a current one, so the plumbing for a second concurrent query exists. - Scroll position likewise. - A refresh must not re-trigger mark-read for the thread already on screen. +### Refined by the user, 2026-08-10: new mail never appears at all + +The complaint is sharper than "the list is stale". Read every message in an +Unread view and the list empties. The cron sync then runs, `notmuch new` indexes +new mail, and **the new messages do not appear**, even though the view is empty, +nothing is selected, no thread is open and the undo stack has nothing in it. The +only way to see them is to re-run the query by hand. + +**Cause, verified in code, and it is the deliberate choice above meeting its +worst case.** The app does observe the cron sync: +`MainWindow::onExternalSyncStateChanged()` (`src/mainwindow.cpp:2140`) is driven +by `SyncMonitor`'s lock polling and reaches `State::Idle` when the run ends. At +`src/mainwindow.cpp:2180-2192` it deliberately shows "Background sync completed. +Press Enter in the query bar to refresh." and calls nothing. The comment there +gives the reason: `runCurrentQuery()` clears the undo stack, the selection and +the message pane, which is hostile to fire six times an hour under a reader. + +**Every one of those costs is zero in the case the user hit.** There is nothing +to clear: no undo entries, no selection, no open thread, and the scroll position +of an empty list is meaningless. So the guard is protecting state that does not +exist, and the result is an empty Unread view sitting in front of unread mail. + +**This makes the item shippable in two stages, and the first is XS.** Refresh on +external sync completion when the refresh is provably free: the model is empty +**or** the undo stack is empty and nothing is selected. Fall back to the current +status-bar message otherwise. That fixes the reported case immediately without +needing the reconciling refresh, which stays as the M-sized second stage for the +case where the user does have state worth keeping. + +**Constraint on the cheap stage.** "Nothing selected" must be read from the +selection model, not from `currentRowChanged` state, per `CLAUDE.md`. And the +condition has to be re-checked at the moment the sync ends rather than when it +started, since the user may have selected a row during the run. + +### Outcome (done, 2026-08-10) + +**35a was superseded before it shipped, by the user's own answer to it.** Asked +whether new mail would accumulate into a POPULATED view, the answer was no, and +the requirement was restated in full: "changes should be applied automatically, +without the user needing to refresh the view, whether it's empty or populated", +new mail appearing at the top as it is fetched, and the message being read not +disappearing. So the conditional refresh was removed rather than kept beside the +reconciling one, and `externalRefreshIsFree()` no longer exists. The "press +Enter in the query bar to refresh" message is gone with it: a refresh that +changes nothing is invisible, and one that brings mail announces itself by the +mail appearing. + +**The undo constraint this item was sized around turned out not to exist.** The +text above calls keeping the undo stack "the hard part" and the reason this is +M-sized. It is not hard, because no undo entry was ever keyed on a row: +`ThreadTagCommand` stores THREAD ids and `MessageTagCommand` stores MESSAGE ids +(`src/mainwindow.h`), both re-sending through `sendThreadTagChange()` / +`sendMessageTagChange()`, and `ThreadListModel::applyTagChange()` looks its +target up by id and does nothing when the row is absent. An entry therefore +already survives its rows leaving the view. `runCurrentQuery()` clears the stack +because a query the USER typed means "show me something else", not because the +entries would corrupt anything. + +**What was built.** + +- `ThreadListModel::reconcile()` diffs a result against the current rows by + thread id: arrivals inserted, departures removed, survivors keeping their row, + their persistent index and their loaded replies. Order comes from the result, + never from a rule of the model's own, so the sort the user selected is + respected: newest-first puts new mail at the top, oldest-first at the bottom. +- `MainWindow::refreshCurrentQuery()` re-runs `m_lastQuery` under its own + generation, accumulates every batch, and reconciles ONCE at the end. Batch by + batch would be wrong in a way that looks right: reconcile decides removals + from what the result lacks, so the first batch would delete every row after it + and the next would put some back. +- `onExternalSyncStateChanged()` calls it unconditionally on `Idle`. +- A stale-thread notice in `MessageView`, shaped like the remote-content bar as + the user asked, with recovery that runs `thread:<id>`, expands it and + re-selects the message that was on screen. + +**Four defects found by the tests, three of them real.** + +1. **The first test crashed the constructor.** `SyncMonitor::start()` polls + SYNCHRONOUSLY (`src/syncmonitor.cpp:52`), so on an idle lock file it emits + `stateChanged(Idle)` from inside `buildUi()`, while the view, the model and + the worker are all still null. The old code survived only because reporting + to the status bar touches nothing built later; the first handler to + dereference a widget segfaults before the window exists. +2. **A downward-move branch that could never run.** `reconcile()` walks the + result front to back, so rows ahead of the target are already final and a + misplaced survivor is always pulled FORWARD. The branch was written with the + usual `beginMoveRows` +1 adjustment and two mutation tests passed against it + being wrong, which is the signal that a probe is not measuring what it + claims. Deleted, with `Q_ASSERT(row > target)` recording the invariant. +3. **A user query hijacked by a pending recovery.** Recovery spans two queued + round-trips, so a query typed in the middle of one found its target in the + new result and moved the selection there. `runCurrentQuery()` now abandons a + pending recovery, and `recoverStaleThread()` sets its target after calling + it. +4. **The stale notice never fired for the reader deepest in a thread.** + Selecting a message row CLEARS `m_currentThreadId` and sets + `m_currentMessageId` instead, so a notice keyed on the thread alone was + silent for exactly the case the user described, reply four of eight. The + window remembers the message's thread separately; + `ThreadListModel::threadIdForMessage()` cannot help, since it searches the + rows and by then the thread has left them. + +**A fifth defect, found by the user in hand testing rather than by any test.** +The notice outlived the message it describes: running a new query blanked the +pane and left the bar above it, still naming the previous thread, with a button +offering to recover a thread the user had deliberately navigated away from. The +bar belongs to the rendered message exactly as the remote-content bar does, and +`MessageView::clear()` already hides that one for this precise reason; the new +bar simply was not added beside it. Fixed there, which covers all six paths that +blank the pane at once, rather than at the query path where it was noticed. + +Worth recording because the tests could not have caught it as written: every +one of them asserted that the notice APPEARS, and none that it goes away. A +feature's off-switch needs its own test, and "it shows up when it should" passes +identically whether or not it ever stops showing up. + +**A sixth defect, also found by the user in hand testing, and the same mistake +in a different place.** The status bar sat on "Background sync running..." with +no sync running. That string is written straight to the label when the lock +appears, and the "Background sync completed" message on the way out was the only +thing that ever replaced it; removing that message to make the refresh silent +left the claim standing indefinitely. + +The rule this establishes is worth more than the fix: **silent means saying +nothing NEW, not leaving a stale claim on screen.** The Idle branch now retires +its own running message and nothing else, tracked by a flag rather than by +matching the text, so it cannot overwrite a selection count or a tag result the +user is reading. Both directions are pinned by mutation: never retiring +reproduces the reported bug, and always writing the default stamps over the +selection message. + +Note the shape shared with the fifth defect above. Both are a piece of UI state +that outlived the thing it described, and in both cases the tests asserted only +that the state APPEARS. An "it goes away" test is a separate test. + +**A seventh and eighth defect, one report, and the worse of the two mutates +mail.** The user came back to the window from another desktop and found the new +message the refresh had brought in already OPEN in the pane, with the stale +notice above it still naming the four-message thread they had been reading. + +- **Nothing in `MainWindow` selected it.** `QTreeView` gives itself a current + index when it takes FOCUS with none set, and current is what drives loading. + Probed rather than assumed, because the obvious hypothesis is wrong: inserting + rows into an empty view does NOT set current, focusing the view does, which is + exactly why the report came with "as I go back to the window from another + desktop" attached. Before item 35b this was unreachable, since a populated + list always had a current row; a refresh dropping mail into a view the user + read empty created the state. The consequence is not cosmetic: opening a + message arms the mark-read timer, so a cron sync plus a window switch marked + mail read that nobody looked at. `onThreadSelected()` now requires the row to + be SELECTED, which every real route (click, arrow key, `selectRowAt`) does and + Qt's housekeeping does not. +- **The notice was correct when raised and became a lie underneath.** It named + the thread that was rendered; the auto-open then replaced the pane without + touching the bar. `MessageView::clear()` retires it, but selecting a row + RE-RENDERS rather than blanking, so that path never ran. Retired in + `onThreadSelected()` as well. + +The first of these was reported as one bug and is two, and only the second was +visible on screen. Worth remembering that "the wrong thing is displayed" and +"the wrong thing happened to the mail" can arrive in the same sentence. + +**A ninth defect: recovery brought the thread back collapsed and blank.** The +user reported it as minor and livable, and it was three faults stacked, each of +which alone would have produced roughly the symptom they saw. + +- **The notice threw away a message id it had.** A thread ROOT sets BOTH + `m_currentThreadId` and `m_currentMessageId`, because the root card is the + thread's first message and the pane renders exactly that message. The notice + read the message id only when the thread id was empty, treating it as the + message-row case, so opening a thread the ordinary way lost it and recovery + had nothing to reopen. +- **Recovery never expanded.** It selected the row and returned, so the + conversation the user asked to get back to was not on screen. It expands + first now, in every branch, which is also what asks the worker for the + replies. +- **A freshly queried root does not know its own first message either.** + `MessageIdRole` on a thread row returns `first.messageId`, which is empty + until the tree loads, so the root check could not match on the pass that + matters and the code fell through to `rowCount(thread) == 0` and returned, + selecting nothing. Recovery now selects the thread PROVISIONALLY on that + pass, without clearing the target, and refines to the exact reply when the + replies arrive. + +**One change here is not demonstrated and is recorded as such.** Recovery also +moved from `setCurrentIndex()` to `selectRowAt()`, on the reasoning that +`onThreadSelected()` ignores an unselected current index since the auto-open +fix. A mutation reverting it passes the whole suite: under +`ExtendedSelection`, `setCurrentIndex()` selects as a side effect, so the two +are indistinguishable here. It is kept as the honest expression of the intent, +not as a fix, and nothing should be claimed for it. + +**A tenth defect, and the only one in this item that six rounds of reasoning +failed to find: a dangling reference across a signal.** Recovery brought the +thread back collapsed with a blank pane. The user reported it three times, each +time after a fix that was aimed at the wrong thing. + +`MessageView` emitted `staleThreadRecoveryRequested(m_staleThreadId, +m_staleMessageId)`, passing its own members. The connection is direct, so +`MainWindow::recoverStaleThread()` received REFERENCES to those members. It then +called `runCurrentQuery()`, which blanks the pane, which calls `setStaleThread()` +and assigns to exactly those members. From that line onward the slot's own +parameters read as empty, so `m_recoverThreadId = threadId` stored an empty +string and `applyPendingRecovery()` returned at its first line, forever. The +thread was re-queried and expanded correctly, which is why the symptom looked +like a layout or expansion problem rather than a lifetime one. + +**Every existing recovery test passed against it, and could not have failed.** +They all reach the slot through `QMetaObject::invokeMethod`, which COPIES its +arguments; the reference never dangles under a test. The defect needed the real +button and the real signal, which is what the new test uses. + +**Six wrong mechanisms were proposed and rejected before the log named this +one**, each one plausible and each one disproved by a probe rather than by +argument: `QTreeView::expanded` not re-firing, expansion collapsing when +children arrive, the multi-row guard blanking the pane, `selectRowAt` not +clearing the previous selection, a stale `m_refreshGeneration` swallowing the +result, and `onQueryFinished` not running at all. The thing that ended it was +instrumenting the running application and reading `RECOVER target set to ` with +nothing after the `to`, which no amount of reading the code had produced. + +**The rule worth keeping: a Qt signal argument is a reference until something +copies it.** Emitting a member across a direct connection to a slot that can +reach back and modify that member is a use-after-write, and it presents as the +value being "wrong" rather than as a crash. Copy at the emit site when the slot +can plausibly re-enter the emitter. + +**A trap the recovery had to handle.** `setThreadMessages()` drops the depth-0 +message because the root card IS the thread's first message, so a reader +recovering from message one must land on the ROOT row. Looking for it among the +children finds nothing and leaves the selection nowhere. + +**Verification.** 105 tests in `test_mainwindow`, 56 in `test_threadlistmodel`, +17/17 binaries. Every reconcile test was mutation-checked: an unconditional +refresh, a skipped move, corrupted index bookkeeping (which trips +`QAbstractItemModelTester` fatally), a per-batch reconcile, a notice that never +fires and one that always fires are each caught by a named test. The +abandoned-recovery test initially passed for the wrong reason, because its +recovery never reached its target, and was rewritten until it failed against the +missing guard. + +**One measurement worth carrying to item 61.** During this work +`test_mainwindow` failed three runs in a row on the pair recorded there +(`anActionOnAMessageRowTagsThatMessageNotTheThread`, +`aSuccessfulCronSyncDrainsTheEditedAccounts`), then passed twelve consecutive +runs unchanged. The recorded rate is about one in twenty; a cluster of three +consecutive failures does not fit an independent one-in-twenty event and +suggests the trigger is a machine state that persists across runs rather than a +per-run race. + +### Outcome (35a, superseded by the above) + +`MainWindow::externalRefreshIsFree()` gates the `State::Idle` branch of +`onExternalSyncStateChanged()`: it refreshes when the undo stack is empty, the +selection model reports no selection, and the model holds no rows, and prints +the existing "press Enter" message otherwise. The M-sized reconciling refresh +(35b) is untouched and still open. + +**The test crashed the constructor, and the crash was real.** +`SyncMonitor::start()` polls SYNCHRONOUSLY (`src/syncmonitor.cpp:52`), so on a +machine whose lock file is idle it emits `stateChanged(Idle)` from inside +`buildUi()` (`src/mainwindow.cpp:535`), while `m_threadView` and `m_model` are +still null. The old code survived that only because reporting to the status bar +touches no widget built later; the first handler to dereference a view segfaults +before the window exists. `externalRefreshIsFree()` returns false on a null view +or model, which is also correct on the merits: the startup query has not run at +that point, so there is nothing to refresh. + +**The undo check is not redundant with the row check**, and that is asserted +rather than argued. Removing it alone leaves +`aCronSyncDoesNotRefreshOverPendingUndo` failing, because an empty model with +live undo entries is reachable by tagging the last thread out of the current +view. + +**Both guard tests passed before the fix existed**, since nothing refreshed at +all, so each was verified by mutation: an unconditional `return true` fails both, +and dropping the undo check fails the undo one. + +**One existing test changed, and the change is a narrowing.** +`aSkippedLocalSyncStillReportsTheOtherRunFinishing` asserted the words +"Background" in the status bar, and its fixture is an empty list with nothing +selected, so it now takes the refresh branch and the words never appear. Its +actual subject is that a handed-back lock is attributed to the other run rather +than swallowed, so it asserts the query generation instead. Pinning the wording +there would fail again the next time this decision is revisited. + ## 36. `test_mainwindow` cannot reach the worker **Observed:** twice in one session (0.8.0), a defect could not be given a @@ -2406,6 +2694,15 @@ line and `lockHeldIn()` correctly finds no lock in it. ## 39. Thread list cannot be sorted by clicking a column header +**Dropped 2026-08-10 (user).** The item asked for a column header to click and +there is no longer one to click: the card list (items 20 and 53) collapsed the +five columns into a single column of cards, and the header is gone with them. +0.13.0 shipped a sort dropdown in the query row, which is the same capability +reached a different way, so the complaint behind this item is answered and the +mechanism it proposed is unbuildable. The analysis below is kept because its +constraints outlived it: the batched-append problem and the sort-on-timestamp +rule apply to any future sort, including the dropdown's. + **Observed (user, 2026-08-05):** "left pane columns order by clicking on the column header." @@ -3751,6 +4048,88 @@ initially mistaken for a regression that change had introduced. **Size: S**, most of it in reproducing reliably rather than in the fix. +## 62. No config option for the date format on a card + +**Observed (user, from the notes):** "option in config file for date format". + +**Cause, verified in code.** `CardLayout::formatDate()` +(`src/cardlayout.cpp:24-30`) is a single unconditional line: +`QLocale::system().toString(date, QLocale::ShortFormat)`. It reads no setting, +takes no format argument, and is the only date formatter on a card +(`src/carddelegate.cpp:176` is its only caller besides +`CardLayout`'s own `widestDate`). `Config` parses no date key: the `[general]` +keys it reads are `notmuch_config`, `startup_query`, `message_zoom`, +`completion_on_focus`, `toolbar_icon_size`, `sync_on_exit` and +`mark_read_delay_ms` (`src/config.cpp:78-184`). So there is nothing to +configure, not a setting that is being ignored. + +The current behaviour is a deliberate choice rather than an oversight, and the +comment at `src/cardlayout.cpp:26-28` says why: the system short format is what +every other application on the desktop shows, and a mail client that disagrees +looks wrong. This item is about giving the user an override, not about +replacing that default. + +**Approach.** A `[general] date_format` key, empty by default meaning "the +system short format", otherwise a `QDateTime::toString()` pattern. +`CardLayout::formatDate()` takes the format as a parameter rather than reading +`Config` itself, keeping the struct free of dependencies the way it already is, +and `CardDelegate` passes it down. + +**Constraints.** + +- **`CardLayout::widestDate()` reserves the date column's width and must agree + with whatever the format produces**, or a long custom pattern is elided or + overlaps. It currently computes the widest string the short format can return; + with a custom pattern it has to measure that pattern instead. +- An unparseable or absurd pattern must not blank the date. `toString()` with a + pattern containing no field returns the pattern itself verbatim, so validate + in `Config` and fall back to the system format with a problem reported, the + way `message_zoom` and `toolbar_icon_size` already do. +- Document all three states in the README's `[general]` block: absent, empty, + and a pattern. + +**Size: XS.** One key, one parameter, one width calculation. + +## 63. No way to see sent mail, and no filter for it + +**Observed (user, from the notes):** "Sent mail filter". + +**Cause, verified in code, and it is not one missing feature but two.** + +- **Nothing in the codebase knows what "sent" means.** `Account` carries + `name`, `address`, `maildir`, `drafts`, `label`, `channel` and `color` + (`src/config.cpp:254-270`); there is no `sent` field, and no query anywhere + composes one. `Account::scopedQuery()` (`src/config.cpp:42-45`) scopes by + `path:"<maildir>/**"`, which covers a sent folder only in the sense that it + covers everything in the account. +- **The saved-query mechanism could express it today and nothing ships one.** + `[queries]` is read wholesale from `childKeys()` (`src/config.cpp:291-297`), + so a user can already write `Sent = tag:sent` by hand. The README's example + block (`README.md:178-181`) offers Inbox, Unread and Important and no Sent, so + nothing points the user at it. + +Which of the two this item is depends on a decision the notes do not make: +whether "sent" is a **notmuch tag** the user's own filters apply, or a +**maildir path** per account. If it is a tag, this is a documentation and +defaults change and it is XS. If it is a path, `Account` needs a `sent` key +beside `drafts`, and the query has to be composed per account, which is where +the S comes from. + +**Approach, pending that decision.** Ask the user first which their setup +already produces. Their mail is filtered outside qtmaildir (`assets/mailsync.sh` +is `mbsync` plus `notmuch new`), so the answer is a property of their existing +filters, not something to design here. + +**Constraints.** + +- **Do not invent a tag qtmaildir applies itself.** v1 is read-and-organize; + nothing here sends mail, so nothing here can know a message was sent except by + where it landed or what tagged it. +- If it becomes a per-account key, it composes with `scopedQuery()` and must not + bypass it, or a Sent view in one account shows another account's mail. + +**Size: S**, and XS if the answer is "it is a tag". + ## Deferred, unsized, or split out Items noted while triaging but not part of the original list. Same numbering |
