aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md34
-rw-r--r--CLAUDE.md23
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md46
3 files changed, 102 insertions, 1 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a555fb4..846e5c6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,15 @@ point at which they are stable.
### Added
+- The rules that tag your mail as it arrives are now visible and editable from
+ Message > Tagging rules, and each one shows how much mail it matches so you
+ can judge a rule before the next sync applies it. They live in a shared file,
+ `~/.config/mailrules/rules.json`, which the notmuch `post-new` hook reads to
+ do the tagging and which the companion `mailctl` tool can read too. The rules
+ previously lived inside the hook as shell, where nothing but a text editor
+ could see them. Each rule keeps a note, so the reasoning behind it (which
+ senders it deliberately excludes, and why) travels with the rule instead of
+ being a comment only one program could read.
- A tag change now syncs itself out, about two seconds after you stop making
changes, instead of waiting for the Sync button or your cron job. The delay is
a debounce, so tagging several threads in a row produces one sync rather than
@@ -37,6 +46,31 @@ point at which they are stable.
message you are reading in Unread gets marked read, the pane keeps showing it
and offers "Show it anyway" instead of going blank.
+### Upgrading
+
+The tagging rules moved out of the notmuch `post-new` hook and into
+`~/.config/mailrules/rules.json`. The dialog reads and writes that file, but
+nothing applies the rules until the new hook is installed, so this needs two
+files copied from the companion `mailctl` project into your notmuch hooks
+directory:
+
+```bash
+DB="$(notmuch config get database.path)"
+cp post-new mailrules.py "$DB/.notmuch/hooks/"
+chmod +x "$DB/.notmuch/hooks/post-new"
+```
+
+Keep a backup of your previous hook until a sync has run with the new one. Your
+existing rules do not convert themselves: each `notmuch tag` line becomes one
+entry in the JSON file, with the part after `tag:new and` as its query. Leave
+`tag:new` out of the stored query, the hook adds it, and do not carry over the
+final `notmuch tag -new` line, which the hook now does itself.
+
+Two behaviours of the new hook are worth knowing. It refuses to remove `unread`
+or `inbox`, since neither belongs in an unattended job that runs every ten
+minutes, and it will not consume the `tag:new` marker if the rules file fails
+to load, so a broken file delays tagging rather than losing it.
+
## [0.15.0] - 2026-08-11
Sent mail becomes a place you can go. A Sent button beside Inbox, Unread and
diff --git a/CLAUDE.md b/CLAUDE.md
index d1d2984..c9d6cb3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -214,6 +214,29 @@ completion descriptions. Query syntax itself is not user-facing text — notmuch
like `tag:` and `date:` are wire format and must never be translated, only the prose
describing them. Pre-existing code has not been audited against this rule.
+**The auto-tagging rules are NOT in this repo, and notmuch's parser rejects
+almost nothing.** Rules live in `~/.config/mailrules/rules.json`, applied by a
+notmuch `post-new` hook that ships from the companion `mailctl` project;
+`TagRules` here reads and writes the same file and `TagRulesDialog` edits it.
+Two things bite. A stored query carries NO scope: the hook supplies `tag:new`
+and wraps the query in parentheses, because `tag:new and a or b` binds as
+`(tag:new and a) or b` and a rule that is a disjunction of senders would escape
+its scope and match everything. And **a malformed query is not an error to
+notmuch**: `from:((((` parses cleanly and matches nothing, so a test asserting
+a failure or a `-1` count fails against correct code. This was recorded in
+`test_notmuchworker.cpp` for thread counts and then learned again, twice, while
+building the rules. Assert on the positional contract, never on a provoked
+failure.
+
+**Rule counts must count MESSAGES.** `requestCounts` counts threads, which is
+right for the placeholder pane because a click there produces thread rows. A
+rule tags messages, so a thread count understates every rule matching part of a
+large thread; `requestMessageCounts` exists beside it for that reason. The two
+are separate signals with separate generation counters, and a count request
+must never bump `m_generation`: that is the *query* generation, and bumping it
+discards any thread load in flight, blanking the message pane because the user
+asked for counts.
+
**Config format gotcha:** QSettings treats `/` in a section name as a group separator, so
account sections are `[account.work]`, not `[account/work]`. `childKeys` returns keys
sorted alphabetically, never in file order. **`[general]` keys are read WITHOUT the
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 c0c1c1e..656773b 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
@@ -100,7 +100,7 @@ taking that too literally.
| 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** |
| 43 | No "Mark all read" for the current view | workflow | S | **done** |
-| 44 | No way to manage the filters applied at sync time | workflow | M | open, specified 2026-08-12; see `specs/2026-08-12-tagging-rules-design.md`. Spans this repo and `mailctl` |
+| 44 | No way to manage the filters applied at sync time | workflow | M | **done** 2026-08-13; see `specs/2026-08-12-tagging-rules-design.md`. Spans this repo and `mailctl` |
| 45 | Two Sync buttons, and only one of them works properly | correctness | S | **done** |
| 46 | `uiStateSurvivesARestart` fails under the offscreen platform | testing | XS | **done** |
| 47 | The query bar looks unfinished, and cannot be cleared by mouse | presentation | XS | **done** |
@@ -3040,6 +3040,50 @@ A filter engine that rewrites the Maildir would not violate that literally, but
it would put qtmaildir in the business of moving mail, which is a decision to
take deliberately rather than by implementing a dialog.
+### Outcome (done 2026-08-13)
+
+The rules moved from the shell `post-new` hook to
+`~/.config/mailrules/rules.json`, read by both qtmaildir and `mailctl`. The
+hook and the shared `mailrules.py` live in the mailctl repository; this repo
+has `TagRules`, `tests/test_tagrules.cpp`, a management dialog on the Message
+menu, and `NotmuchWorker::requestMessageCounts` for the dry run. Seventeen real
+rules were converted and each one's shell comment became its `note`.
+
+**Four things learned that outlive the item.**
+
+The parenthesisation of a rule's own query is load-bearing. `tag:new and a or b`
+binds as `(tag:new and a) or b`, so a rule that is a disjunction of senders
+escapes its scope and matches the whole corpus. Several real rules have exactly
+that shape. `mailrules.scoped_query` is the only place that string is built.
+
+The hook must not consume `tag:new` when the rules failed to load. Clearing the
+marker while the rules did not run orphans that mail permanently and silently,
+and the gap would surface months later as "why did this stop being tagged".
+
+**notmuch's query parser rejects almost nothing**, which invalidated two
+assertions written into the plan from memory. `from:((((` parses cleanly and
+matches nothing rather than failing, so a test expecting a non-zero exit or a
+`-1` count fails against correct code. `tests/test_notmuchworker.cpp` already
+recorded this for thread counts and the lesson had to be learned twice.
+
+`requestCounts` counts THREADS, which is right for the placeholder pane and
+wrong for a rule: a rule tags messages, so a thread count understates any rule
+matching part of a large thread. Hence `requestMessageCounts` beside it rather
+than a change to it.
+
+**Verification, since a rules file that tags the wrong mail is expensive.** All
+seventeen converted rules were counted against the real index and matched the
+shell hook exactly, tags and counts, before anything was installed. The staged
+hook was then run against real mail with a tag deliberately removed, and
+restored it. The live swap was confirmed by a real sync: `status=OK`,
+`applied 17 rule(s)`, marker consumed, and the per-rule counts moved as new mail
+arrived.
+
+**Backfill remains out of scope**, and is the piece that will force a revision
+to the "no destructive-action confirmation, undo instead" rule in `CLAUDE.md`.
+The user has said that rule is due for revision anyway. See the spec's "Out of
+scope" section.
+
## 45. Two Sync buttons on the main window
**Observed (user, 2026-08-05):** "there's currently 2 Sync buttons on the main