1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
|
# Applying a rule to existing mail, and rules that archive on arrival
Date: 2026-10-31. Backlog items 212 and 213. Settled with the user in one
brainstorm; the three decisions they made are marked **Decided**.
## Problem
Two gaps, found together.
1. **A rule only ever reaches new mail.** The `post-new` hook scopes every rule
to `tag:new`, so a rule edited today, a new sender added to `promo` say,
leaves every message already in the index untouched. Applying it to the
backlog is a hand-typed `notmuch tag`. The tagging-rules spec
(`2026-08-12-tagging-rules-design.md`, "Out of scope") deferred this as
backfill and named the two things it needed settled first: an unscoped
rule is not as safe as a scoped one, and `CLAUDE.md`'s "undo, never
confirm" rule does not obviously cover a write over the whole mailbox.
2. **A rule that removes `inbox` never runs at all (item 213, a defect).** The
hook refuses to remove `inbox` or `unread` unattended, and it does so by
skipping the WHOLE rule, not only the removal. The user's `promo` rule is
`+promo -inbox`, so every sync logs
```
post-new: rule 'promo' would remove inbox; skipped, this hook does not remove those unattended
```
and new promotional mail arrives untagged and in the inbox. That is how
10k of it accumulated before item 211's archive. The hook's own comment
expected this day ("DELIBERATELY CONSERVATIVE, AND EXPECTED TO BE
RELAXED ... the question to answer first is what confirms the rule before
it runs").
The rules dialog does not warn about either. `CLAUDE.md` says a rule the hook
will refuse should be flagged in the UI; nothing does that today.
## Part A: Apply to existing mail
### Behaviour
A button, **Apply to existing mail**, sits beside *Preview in list* in the
rules dialog and acts on the SELECTED rule only. It is disabled for a rule
that is disabled, has an empty query, or has neither `add` nor `remove`.
**Decided: only what the hook would run is applied.** Pressing Apply first
SAVES the dialog, with the same validation `onSave()` does, but without
closing it. A validation failure or a write failure stops there, exactly as
OK does. So the rule that gets applied is byte for byte the rule the hook will
run on the next sync, and the user does not have to close and reopen the
dialog to apply an edit.
Then:
1. **Count what would change, not what matches.** The worker counts messages
matching
```
(<rule query>) and (not tag:a1 or not tag:a2 ... or tag:r1 or tag:r2 ...)
```
built through `SearchTerm` (`tag()` for each term, `extend()` for the
conjunction, so the rule's own disjunctions cannot escape the scope). A
message already in the target state is not counted, so the number in the
confirmation is the number of messages that will actually be touched.
It goes through the existing `requestMessageCounts`, which already
counts messages rather than threads and has its own generation counter.
2. **Zero: nothing to ask.** The status bar says the rule has nothing to
change, and no dialog appears.
3. **Otherwise, confirm.** **Decided: confirm with the count.** A
`QMessageBox::question` parented to the rules dialog:
> Apply rule "promo" to 1,234 messages?
> Adds: promo. Removes: inbox.
> You can undo this until the next search.
(Changed during implementation from "from the main window": `runQuery()`
clears the undo stack, so the undo lasts until the next search.)
Cancel is the default button. The count and the tag lists are the
information the user cannot get from the list on screen, which is the
whole reason this write gets a confirmation and others do not.
4. **Write.** A new worker slot, `applyRule(query, add, remove,
description)`, resolves the message ids ONCE with
`SearchTerm::changes(query, add, remove)`, then writes one tag operation
at a time over THAT FIXED id list, and always answers with one
`ruleApplied(description, changes, error)` carrying each operation's
effective ids. It never emits `tagsApplied` or `errorOccurred`.
(Changed during implementation; the planned `applyTagsToQuery` calling
`applyTags()` once is gone, for the three reasons below.)
**One tag at a time.** `applyTags()` reports which messages moved, not
which tags, so one `+promo -inbox` write would list a message that
already had `promo`, and undo would strip it. Each tag is its own write
and its own undo command, grouped in one undo macro.
**Ids resolved once.** Re-resolving per operation made a rule whose query
names its own tags apply only half of itself.
**Nothing armed in advance.** The planned `MessageTagCommand` armed with
`awaitTagConfirmation()` before the send was built and then removed: a
user gesture during a slow backfill cleared the confirmation queue, and
the user's own command took the backfill's ids (Ctrl+Z on Archive
re-inboxed thousands). `MainWindow::onRuleApplied()` instead records each
non-empty change through `recordConfirmedTagChange()`, which is the
bookkeeping of `onTagsApplied()` without crediting a queued command, and
pushes `MessageTagCommand`s built with the known ids in one undo macro.
What is inherited:
- exact undo: each command holds the ids its operation moved (item 176);
- the pending-changes count, from `recordConfirmedTagChange()`, and every
account marked edited, since a backfill reaches mail no row shows and
its accounts cannot be resolved through the model;
- the "applying… / done" status from item 211's `announceAction()`; a
failure goes to the status bar, and a partial write still gets its undo;
- the write lock: Apply during a sync is REFUSED with a status message,
checked before the count and again after the confirmation box.
Changed during planning: `HeldEdit` replays message or thread ids, and
a backfill has neither until the worker resolves its query.
After the answer arrives, the current query is refreshed, since the
rows a backfill touches are mostly not in the model and cannot be updated
optimistically.
5. **Protected tags.** Removing `inbox` or `unread` is ALLOWED here. The
hook's refusal exists because "there is no human present to confirm
anything when cron runs a sync"; Apply has a human present who has just
confirmed a named count.
### The undo command's ids come from the worker's answer
`MessageTagCommand` is constructed with the ids it acts on. For a backfill
those ids are unknown until the worker has resolved the query, so the
commands are built only when `ruleApplied` arrives, with the effective ids
each operation reported, and `addChangedMessageIds()` fills what undo reads.
(Changed during implementation from "constructed with an empty id list and
filled from the confirmation", which needed the arming removed above.) The
application has no Redo action; since the commands carry real ids, they
would redo correctly if one is added.
### Rule change to CLAUDE.md
The "no destructive-action confirmation" section gains a second exception
beside `empty_trash`, stated by its property rather than by its name: **a
write whose scope is the whole mailbox, rather than rows on screen, confirms
with the count.** Undo still applies. The difference from `empty_trash` is
recorded too: that one confirms because it cannot be undone, this one
because the user cannot see what it will touch.
## Part B: rules that archive on arrival
### Format
**Decided: a per-rule opt-in.** A new optional boolean field on a rule:
```json
{ "id": "promo", "query": "...", "add": ["promo"], "remove": ["inbox"],
"allow_archive": true }
```
- Absent or `false` means today's behaviour.
- Optional, so **no `kFormatVersion` / `FORMAT_VERSION` bump** ("Changing the
rule format", step 2).
- Both readers learn it in the same change (step 1): `TagRule::allowArchive`
in `src/tagrules.cpp`, `Rule.allow_archive` in `assets/hooks/mailrules.py`,
each added to its known-keys list so it is no longer carried as an unknown
field. A writer omits it when false, so a file with no archiving rules is
unchanged by a save.
- A flag on a rule that does not remove `inbox` does nothing, and is NOT a
validation error. Making it one would have the hook drop a whole rule over
a harmless flag, and `CLAUDE.md` already records that repairs belong in the
editor, never in the unattended hook. The editor keeps it from arising:
the checkbox exists only while `inbox` is in the remove list, and the
writer clears the flag on a rule that no longer removes `inbox`.
### Hook
`protected_removals(rule)` drops `inbox` from the refused set when the rule
has `allow_archive`. `unread` stays refused unconditionally: removing it
rewrites Maildir filenames under `maildir.synchronize_flags` and reaches the
server, and nothing about archiving needs it.
A rule that removes `inbox` WITHOUT the opt-in is still skipped whole, as
today. Skipping only the removal would be a smaller change in behaviour, but
it would silently run a rule differently from how it reads. The log line
gains the remedy:
```
post-new: rule 'promo' would remove inbox; skipped (set "Archive on arrival" on the rule to allow it)
```
The `tag:new` scope and the non-arrival carve-out are untouched. An archiving
rule still only ever sees newly indexed mail.
### Dialog
- A checkbox, **Archive matching mail on arrival (removes inbox)**, under the
remove field. It is shown only while the rule's remove list holds `inbox`,
and it maps to `allow_archive`.
- The warning banner, through the existing `setWarning()`, names any rule
the hook will skip: one that removes `inbox` without the opt-in, or one
that removes `unread` at all. This closes the gap `CLAUDE.md` already
describes, where the dialog would let the user write a rule the hook then
refuses with no word in the UI.
## Testing
Measurable properties only, per `CLAUDE.md`.
- **SearchTerm:** the change query for `+a -r` over a query containing ` or `
is the exact expected string, with both sides parenthesised. Add-only and
remove-only rules each produce the right half.
- **Worker:** `applyRule` against a throwaway index holding messages in
BOTH states (some already tagged, some not; the item 87 lesson) reports
only the ones that changed, and leaves the rest alone.
- **MainWindow, worker-backed:** Apply on a rule with N changeable messages
produces one undo entry, and undo restores the exact prior tags of a
message that was already in the target state AND one that was not.
- **Dialog:** Apply with an unsaved edit writes the file before emitting;
Apply with a validation failure emits nothing. Assert every route, per the
`done(int)` lesson.
- **Format, both suites:** `allow_archive` round-trips through each reader;
absent stays absent after a save; the C++ writer clears it on a rule
without `-inbox`, while the Python reader accepts and ignores it there;
an unknown sibling field still survives.
- **Hook:** an opted-in `-inbox` rule archives a `tag:new` message; the same
rule without the flag is skipped and logs the remedy; `-unread` with the
flag set is still refused.
- **By hand (rule-format step 4):** save an opted-in rule from the dialog, run
the hook over a throwaway index, and confirm the rule count, the note and
the flag survive.
The confirmation box itself is not unit-tested beyond its count reaching it;
the user looks at it.
## Out of scope
- **Applying every rule at once** in stage order. The user chose the selected
rule; a whole-set run is a separate decision with ordering questions of its
own.
- **Relaxing `unread`.** Nothing asks for it.
- **A dry-run list** of the affected messages. *Preview in list* already shows
which mail a rule collects.
|