aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans/2026-10-31-rule-apply-and-archive.md
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-31 11:37:06 +0100
committerDanilo M. <danix@danix.xyz>2026-10-31 11:37:06 +0100
commit8078e8180920984de475db796a2725bb2f470dd8 (patch)
treebc97578d81932afde86ef4cfbf2a2a44f0dc467d /docs/superpowers/plans/2026-10-31-rule-apply-and-archive.md
parente4e8922b590066cec7b9135eac0ecdf343e345c9 (diff)
downloadqtmaildir-8078e8180920984de475db796a2725bb2f470dd8.tar.gz
qtmaildir-8078e8180920984de475db796a2725bb2f470dd8.zip
docs: plan applying a rule to existing mailHEADmaster
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'docs/superpowers/plans/2026-10-31-rule-apply-and-archive.md')
-rw-r--r--docs/superpowers/plans/2026-10-31-rule-apply-and-archive.md1546
1 files changed, 1546 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-10-31-rule-apply-and-archive.md b/docs/superpowers/plans/2026-10-31-rule-apply-and-archive.md
new file mode 100644
index 0000000..ddafaea
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-31-rule-apply-and-archive.md
@@ -0,0 +1,1546 @@
+# Rule Apply and Archive-on-Arrival Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Let the rules dialog apply a saved rule to existing mail (confirmed, undoable), and let a rule opt in to removing `inbox` on arrival so the `post-new` hook stops skipping it.
+
+**Architecture:** Spec: `docs/superpowers/specs/2026-10-31-rule-apply-and-archive-design.md`. Part B adds an optional `allow_archive` rule field to both readers (`src/tagrules.cpp`, `assets/hooks/mailrules.py`) and teaches `post-new` to honour it for `inbox` only. Part A adds `SearchTerm::changes()` (the "would change" query), a worker slot `applyTagsToQuery` that resolves ids and calls the existing `applyTags()`, an Apply button in `TagRulesDialog` that saves first and emits `applyRequested(TagRule)`, and `MainWindow::applyRuleToExistingMail()` that counts, confirms, and writes the rule one tag at a time, each as a `MessageTagCommand` filled from its confirmation, grouped in one undo macro.
+
+**Tech Stack:** C++17, Qt 6.11 (Widgets, Test), libnotmuch, Python 3 stdlib (hook), CMake/Ninja.
+
+**Conventions for every task:**
+- Build: `cmake --build build`. Run a C++ test binary ONLY as `QT_QPA_PLATFORM=offscreen ./build/tests/test_<name> [function]`. Never launch `./build/src/qtmaildir`.
+- Python hook tests run from `assets/hooks/`: `./test_mailrules.py && ./test_post_new.py`.
+- `tests/test_mainwindow` has 4 failures on clean master (`everyBuiltinFilterButtonCarriesAnIconAndItsText`, `everyActionCarriesAnIcon`, `noTwoActionsShareAnIcon`, `undoMovesTheMessageBack`). They are environmental and pre-existing; any OTHER failure is yours.
+- Commits are GPG-signed: `git commit -S`. End every message with `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`. Never stage `HANDOFF.md`.
+- No em dashes anywhere (code comments, docs, commit messages). No real names, addresses or subjects in fixtures: use `example.org` / `example.net`.
+- Every user-facing string goes through `tr()`. Notmuch query syntax is never translated.
+
+---
+
+### Task 1: `SearchTerm::changes()`, the "would change" query
+
+**Files:**
+- Modify: `src/searchterm.h` (after `exclude()`, before the closing `} // namespace SearchTerm`)
+- Modify: `src/searchterm.cpp`
+- Test: `tests/test_searchterm.cpp`
+
+- [ ] **Step 1: Write the failing tests**
+
+Add to the `private slots:` list in `tests/test_searchterm.cpp`:
+
+```cpp
+ void changesNarrowsToMessagesTheTagsWouldMove();
+ void changesKeepsADisjunctionInsideItsScope();
+ void changesWithNothingToDoMatchesNothing();
+```
+
+Add the bodies before `QTEST_MAIN`:
+
+```cpp
+void TestSearchTerm::changesNarrowsToMessagesTheTagsWouldMove()
+{
+ // Item 212. A message already carrying every added tag and none of the
+ // removed ones would not change, so it must not be counted or written.
+ QCOMPARE(SearchTerm::changes(QStringLiteral("from:a@example.org"),
+ { QStringLiteral("promo") },
+ { QStringLiteral("inbox") }),
+ QStringLiteral("(from:a@example.org) AND "
+ "(not tag:promo or tag:inbox)"));
+ QCOMPARE(SearchTerm::changes(QStringLiteral("from:a@example.org"),
+ { QStringLiteral("x"), QStringLiteral("y") },
+ {}),
+ QStringLiteral("(from:a@example.org) AND "
+ "(not tag:x or not tag:y)"));
+ QCOMPARE(SearchTerm::changes(QStringLiteral("from:a@example.org"), {},
+ { QStringLiteral("inbox") }),
+ QStringLiteral("(from:a@example.org) AND (tag:inbox)"));
+}
+
+void TestSearchTerm::changesKeepsADisjunctionInsideItsScope()
+{
+ // `a or b AND c` binds as `a or (b AND c)`, which would write to every
+ // message matching `a` whatever its tags. extend() parenthesises both.
+ QCOMPARE(SearchTerm::changes(QStringLiteral("from:a@example.org or "
+ "from:b@example.org"),
+ { QStringLiteral("promo") }, {}),
+ QStringLiteral("(from:a@example.org or from:b@example.org) AND "
+ "(not tag:promo)"));
+}
+
+void TestSearchTerm::changesWithNothingToDoMatchesNothing()
+{
+ // notmuch reads an EMPTY query as "match everything". A caller writing
+ // through this must never be handed one.
+ QCOMPARE(SearchTerm::changes(QString(), { QStringLiteral("x") }, {}),
+ Config::matchNothingQuery());
+ QCOMPARE(SearchTerm::changes(QStringLiteral("from:a@example.org"), {}, {}),
+ Config::matchNothingQuery());
+}
+```
+
+Add `#include "config.h"` beside `#include "searchterm.h"` in the test if it is not already there.
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `cmake --build build 2>&1 | grep -m3 error`
+Expected: compile error, `'changes' is not a member of 'SearchTerm'`.
+
+- [ ] **Step 3: Implement**
+
+In `src/searchterm.h`, before the namespace's closing brace:
+
+```cpp
+/// The messages among `query` that `+add -remove` would actually change:
+/// `(query) AND (not tag:a1 or ... or tag:r1 ...)`.
+///
+/// Used to count and to write a rule applied to existing mail (item 212), so
+/// the number in the confirmation is the number of messages touched, not the
+/// number matched. Built on extend(), so a disjunction in the rule cannot
+/// escape the scope.
+///
+/// **Never empty.** An empty query or an empty change yields
+/// Config::matchNothingQuery(), because notmuch reads an empty query as
+/// "match everything" and this string is written to.
+QString changes(const QString &query, const QStringList &add,
+ const QStringList &remove);
+```
+
+In `src/searchterm.cpp`, add `#include "config.h"` below `#include "searchterm.h"`, and at the end of the namespace:
+
+```cpp
+QString changes(const QString &query, const QStringList &add,
+ const QStringList &remove)
+{
+ QStringList terms;
+ for (const QString &name : add) {
+ const QString term = tag(name);
+ if (!term.isEmpty())
+ terms.append(QStringLiteral("not ") + term);
+ }
+ for (const QString &name : remove) {
+ const QString term = tag(name);
+ if (!term.isEmpty())
+ terms.append(term);
+ }
+ if (query.trimmed().isEmpty() || terms.isEmpty())
+ return Config::matchNothingQuery();
+ return extend(query, terms.join(QStringLiteral(" or ")));
+}
+```
+
+- [ ] **Step 4: Run to verify it passes**
+
+Run: `cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_searchterm`
+Expected: all PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/searchterm.h src/searchterm.cpp tests/test_searchterm.cpp
+git commit -S -m "feat: build the query for what a tag change would move
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 2: `allow_archive` in both rule readers
+
+Read "Changing the rule format" at the bottom of `AGENTS.md` first. Optional field, so **no version bump**.
+
+**Files:**
+- Modify: `src/tagrules.h` (`struct TagRule`, and a new static in `class TagRules`)
+- Modify: `src/tagrules.cpp` (`isKnownKey`, `load`, `save`, new `hookWouldSkip`)
+- Modify: `assets/hooks/mailrules.py` (`KNOWN_KEYS`, `Rule`, `_parse_rule`, `_rule_to_dict`)
+- Test: `tests/test_tagrules.cpp`, `assets/hooks/test_mailrules.py`
+
+- [ ] **Step 1: Write the failing C++ tests**
+
+Add to `private slots:` in `tests/test_tagrules.cpp`:
+
+```cpp
+ void allowArchiveRoundTripsAndIsOmittedWhenFalse();
+ void allowArchiveIsDroppedFromARuleThatKeepsInbox();
+ void hookWouldSkipMirrorsThePostNewRefusal();
+```
+
+Bodies, before `QTEST_MAIN`:
+
+```cpp
+void TestTagRules::allowArchiveRoundTripsAndIsOmittedWhenFalse()
+{
+ // Item 213. Optional, so a file with no archiving rule must save
+ // unchanged: no "allow_archive": false appears from nowhere.
+ const QString path = writeRules(R"({
+ "version": 1,
+ "rules": [
+ {"id": "promo", "add": ["promo"], "remove": ["inbox"],
+ "query": "from:a@example.org", "allow_archive": true},
+ {"id": "plain", "add": ["x"], "query": "from:b@example.org"}
+ ]
+ })");
+
+ TagRules rules;
+ rules.load(path);
+ QCOMPARE(rules.rules().size(), 2);
+ QVERIFY(rules.rules().at(0).allowArchive);
+ QVERIFY(!rules.rules().at(1).allowArchive);
+ // Known now, so it is not ALSO carried as an unknown field.
+ QVERIFY(!rules.rules().at(0).unknown.contains(QStringLiteral("allow_archive")));
+
+ QVERIFY(rules.save(path));
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::ReadOnly));
+ const QJsonArray saved = QJsonDocument::fromJson(file.readAll())
+ .object().value(QStringLiteral("rules")).toArray();
+ QCOMPARE(saved.at(0).toObject().value(QStringLiteral("allow_archive")),
+ QJsonValue(true));
+ QVERIFY(!saved.at(1).toObject().contains(QStringLiteral("allow_archive")));
+}
+
+void TestTagRules::allowArchiveIsDroppedFromARuleThatKeepsInbox()
+{
+ // A flag that does nothing is cleared by the WRITER, never rejected by a
+ // reader: the hook must not drop a whole rule over a harmless flag.
+ TagRule rule;
+ rule.id = QStringLiteral("x");
+ rule.query = QStringLiteral("from:a@example.org");
+ rule.add = { QStringLiteral("x") };
+ rule.allowArchive = true;
+
+ TagRules rules;
+ rules.setRules({ rule });
+ QVERIFY(TagRules::validate(rules.rules()).isEmpty());
+ const QString path = m_dir.filePath(QStringLiteral("cleared.json"));
+ QVERIFY(rules.save(path));
+
+ TagRules reloaded;
+ reloaded.load(path);
+ QVERIFY(!reloaded.rules().at(0).allowArchive);
+}
+
+void TestTagRules::hookWouldSkipMirrorsThePostNewRefusal()
+{
+ // Mirrors PROTECTED_REMOVALS in assets/hooks/post-new, which the Python
+ // suite tests against the hook itself.
+ TagRule rule;
+ rule.remove = { QStringLiteral("inbox") };
+ QVERIFY(TagRules::hookWouldSkip(rule));
+ rule.allowArchive = true;
+ QVERIFY(!TagRules::hookWouldSkip(rule));
+
+ rule.remove = { QStringLiteral("unread") };
+ QVERIFY(TagRules::hookWouldSkip(rule)); // never opted out
+
+ rule.remove = { QStringLiteral("promo") };
+ rule.allowArchive = false;
+ QVERIFY(!TagRules::hookWouldSkip(rule));
+}
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `cmake --build build 2>&1 | grep -m3 error`
+Expected: `'struct TagRule' has no member named 'allowArchive'`.
+
+- [ ] **Step 3: Implement the C++ reader and writer**
+
+In `src/tagrules.h`, in `struct TagRule`, after `bool enabled = true;`:
+
+```cpp
+ /// Lets the post-new hook remove `inbox` from arriving mail for this rule
+ /// (item 213). Meaningless, and cleared on save, unless `remove` holds
+ /// `inbox`. `unread` stays refused regardless.
+ bool allowArchive = false;
+```
+
+In `class TagRules`, beside `validate()`:
+
+```cpp
+ /// Whether the post-new hook will skip this rule whole. Mirrors
+ /// PROTECTED_REMOVALS in assets/hooks/post-new: `unread` always, `inbox`
+ /// unless the rule opts in with allowArchive.
+ static bool hookWouldSkip(const TagRule &rule);
+```
+
+In `src/tagrules.cpp`:
+
+1. `isKnownKey`: add `QStringLiteral("allow_archive"),` to the list.
+2. In `load()`, after `rule.note = ...`:
+
+```cpp
+ rule.allowArchive =
+ object.value(QStringLiteral("allow_archive")).toBool(false);
+```
+
+3. In `save()`, after `object.insert(QStringLiteral("note"), rule.note);`:
+
+```cpp
+ // Written only where it does something, so a file without an
+ // archiving rule saves byte-identical (item 213).
+ if (rule.allowArchive && rule.remove.contains(QStringLiteral("inbox")))
+ object.insert(QStringLiteral("allow_archive"), true);
+```
+
+4. New function, after `validate()`:
+
+```cpp
+bool TagRules::hookWouldSkip(const TagRule &rule)
+{
+ if (rule.remove.contains(QStringLiteral("unread")))
+ return true;
+ return rule.remove.contains(QStringLiteral("inbox")) && !rule.allowArchive;
+}
+```
+
+- [ ] **Step 4: Run C++ tests**
+
+Run: `cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_tagrules`
+Expected: all PASS.
+
+- [ ] **Step 5: Write the failing Python tests**
+
+Append to `assets/hooks/test_mailrules.py`, above `def run_all():`:
+
+```python
+def test_allow_archive_round_trips_and_is_omitted_when_false():
+ """Item 213. Known, so not carried as unknown; written only when true."""
+ with tempfile.TemporaryDirectory() as tmp:
+ path = write_rules(tmp, {
+ "version": 1,
+ "rules": [
+ {"id": "promo", "add": ["promo"], "remove": ["inbox"],
+ "query": "from:a@example.com", "allow_archive": True},
+ {"id": "plain", "add": ["x"], "query": "from:b@example.com"},
+ ],
+ })
+ store = mailrules.load(path)
+ assert store.rules[0].allow_archive is True
+ assert store.rules[1].allow_archive is False
+ assert "allow_archive" not in store.rules[0].unknown
+
+ mailrules.save(store, path)
+ raw = json.loads(path.read_text())
+ assert raw["rules"][0]["allow_archive"] is True
+ assert "allow_archive" not in raw["rules"][1]
+
+
+def test_allow_archive_on_a_rule_keeping_inbox_is_accepted_and_ignored():
+ """Never a reason to drop a rule: the hook runs unattended."""
+ with tempfile.TemporaryDirectory() as tmp:
+ path = write_rules(tmp, {
+ "version": 1,
+ "rules": [{"id": "x", "add": ["x"], "query": "from:a@example.com",
+ "allow_archive": True}],
+ })
+ store = mailrules.load(path)
+ assert len(store.rules) == 1
+ assert store.warnings == []
+```
+
+- [ ] **Step 6: Run to verify it fails**
+
+Run: `cd assets/hooks && ./test_mailrules.py`
+Expected: `AttributeError: 'Rule' object has no attribute 'allow_archive'`.
+
+- [ ] **Step 7: Implement the Python reader and writer**
+
+In `assets/hooks/mailrules.py`:
+
+1. `KNOWN_KEYS = {"id", "stage", "enabled", "add", "remove", "query", "note", "allow_archive"}`
+2. In `class Rule`, after `note: str = ""`: `allow_archive: bool = False`
+3. In `_parse_rule`'s `return Rule(...)`, after the `note=` line:
+
+```python
+ allow_archive=obj.get("allow_archive") is True,
+```
+
+4. In `_rule_to_dict`, before `out.update(rule.unknown)`:
+
+```python
+ # Written only where it does something, matching src/tagrules.cpp, so a
+ # file without an archiving rule round-trips unchanged (item 213).
+ if rule.allow_archive and "inbox" in rule.remove:
+ out["allow_archive"] = True
+```
+
+- [ ] **Step 8: Run both suites**
+
+Run: `cd assets/hooks && ./test_mailrules.py && ./test_post_new.py; cd ../.. && QT_QPA_PLATFORM=offscreen ./build/tests/test_tagrules | tail -1`
+Expected: `all passed` twice, and `0 failed`.
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add src/tagrules.h src/tagrules.cpp tests/test_tagrules.cpp assets/hooks/mailrules.py assets/hooks/test_mailrules.py
+git commit -S -m "feat: an optional allow_archive field on a tagging rule
+
+Both readers learn it together, as the rule format requires. No version
+bump: the field is optional, and each writer emits it only on a rule that
+removes inbox, so a file without one saves unchanged.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 3: the hook honours `allow_archive`
+
+**Files:**
+- Modify: `assets/hooks/post-new` (`protected_removals`, the skip log line, the header comments that describe the policy)
+- Test: `assets/hooks/test_post_new.py`
+
+- [ ] **Step 1: Write the failing tests**
+
+Add above `def setup_accounts(` in `assets/hooks/test_post_new.py`:
+
+```python
+def test_an_opted_in_rule_archives_arriving_mail():
+ """Item 213: the promo rule removed inbox and was skipped on every sync,
+ so arrivals were never even tagged."""
+ with tempfile.TemporaryDirectory() as tmp:
+ env = setup_database(tmp)
+ write_rules(env, [
+ {"id": "promo", "stage": 50, "add": ["promo"],
+ "remove": ["inbox"], "allow_archive": True,
+ "query": "from:promo@example.net"},
+ ])
+ result = subprocess.run([str(HOOK)], env=env, capture_output=True,
+ text=True)
+ assert result.returncode == 0, result.stderr
+ assert count(env, "tag:promo") == 1
+ assert count(env, "tag:inbox and from:promo@example.net") == 0
+ # unread is untouched by an archive
+ assert count(env, "tag:unread and from:promo@example.net") == 1
+ assert count(env, "tag:inbox") == 2
+ assert count(env, "tag:new") == 0
+
+
+def test_without_the_flag_the_rule_is_skipped_and_the_log_says_how_to_allow_it():
+ with tempfile.TemporaryDirectory() as tmp:
+ env = setup_database(tmp)
+ write_rules(env, [
+ {"id": "promo", "stage": 50, "add": ["promo"],
+ "remove": ["inbox"], "query": "from:promo@example.net"},
+ ])
+ result = subprocess.run([str(HOOK)], env=env, capture_output=True,
+ text=True)
+ assert result.returncode == 0, result.stderr
+ assert count(env, "tag:promo") == 0
+ assert "Archive on arrival" in result.stderr, result.stderr
+
+
+def test_the_flag_never_unlocks_unread():
+ with tempfile.TemporaryDirectory() as tmp:
+ env = setup_database(tmp)
+ write_rules(env, [
+ {"id": "greedy", "stage": 50, "add": ["seen"],
+ "remove": ["inbox", "unread"], "allow_archive": True,
+ "query": "from:promo@example.net"},
+ ])
+ result = subprocess.run([str(HOOK)], env=env, capture_output=True,
+ text=True)
+ assert result.returncode == 0, result.stderr
+ assert count(env, "tag:seen") == 0
+ assert count(env, "tag:unread") == 3
+ assert count(env, "tag:inbox") == 3
+```
+
+- [ ] **Step 2: Run to verify they fail**
+
+Run: `cd assets/hooks && ./test_post_new.py`
+Expected: `AssertionError` in `test_an_opted_in_rule_archives_arriving_mail` (`tag:promo` is 0).
+
+- [ ] **Step 3: Implement**
+
+In `assets/hooks/post-new`, replace `protected_removals`:
+
+```python
+def protected_removals(rule):
+ """The protected tags this rule would remove, if any.
+
+ `inbox` is released by the rule's own `allow_archive` opt-in (item 213),
+ which a human set in the rules dialog. `unread` never is: removing it
+ rewrites Maildir filenames and reaches the server.
+ """
+ refused = set(PROTECTED_REMOVALS)
+ if rule.allow_archive:
+ refused.discard("inbox")
+ return sorted(refused.intersection(rule.remove))
+```
+
+Replace the skip log call in `main()`:
+
+```python
+ if refused:
+ hint = (' (set "Archive on arrival" on the rule to allow it)'
+ if refused == ["inbox"] else "")
+ log(f"rule '{rule.id}' would remove {', '.join(refused)}; "
+ f"skipped, this hook does not remove those unattended{hint}")
+ continue
+```
+
+Update the comment block above `PROTECTED_REMOVALS`: replace the paragraph starting `DELIBERATELY CONSERVATIVE, AND EXPECTED TO BE RELAXED.` (through `... not whether the guard is annoying.`) with:
+
+```python
+# RELAXED ONCE, FOR INBOX ONLY (item 213). A rule may opt in with
+# `allow_archive`, set by a human in the rules dialog, and then this hook
+# removes `inbox` for it. That checkbox is the confirmation the old comment
+# here asked for before relaxing anything. `unread` has no opt-in.
+```
+
+Update the module docstring line 23-24 so it reads: `Rules may add any tag and remove most, but this hook REFUSES to remove \`unread\`, and \`inbox\` unless the rule sets allow_archive, and skips any rule that asks: see PROTECTED_REMOVALS`.
+
+- [ ] **Step 4: Run both suites**
+
+Run: `cd assets/hooks && ./test_post_new.py && ./test_mailrules.py`
+Expected: `all passed` twice. The existing `test_a_protected_removal_is_skipped_whole_and_the_run_continues` must still pass (its rule removes `unread`).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add assets/hooks/post-new assets/hooks/test_post_new.py
+git commit -S -m "fix: let an opted-in rule archive arriving mail
+
+The hook refused to remove inbox by skipping the whole rule, so the promo
+rule (+promo -inbox) had never tagged a single arrival. A rule carrying
+allow_archive now runs, and inbox is removed for it. unread is still
+refused, and a rule without the flag is still skipped, with a log line
+saying how to allow it.
+
+Item 213.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 4: worker slot `applyTagsToQuery`
+
+**Files:**
+- Modify: `src/notmuchworker.h` (public slots, beside `applyTagsToThreads`)
+- Modify: `src/notmuchworker.cpp` (after `applyTagsToThreads`)
+- Test: `tests/test_notmuchworker.cpp`
+
+- [ ] **Step 1: Write the failing test**
+
+Add to `private slots:` in `tests/test_notmuchworker.cpp`:
+
+```cpp
+ void applyTagsToQueryWritesOnlyWhatWouldChange();
+```
+
+Body, beside `applyTagsToThreadsTagsEveryMessage`:
+
+```cpp
+void TestNotmuchWorker::applyTagsToQueryWritesOnlyWhatWouldChange()
+{
+ // Item 212. Two messages in OPPOSITE states (item 87's lesson): a1
+ // already carries the tag, a2 does not. Only a2 may be reported, or the
+ // undo would strip the tag from a message that had it before.
+ const QString threadId = threadIdOf(QStringLiteral("Release notes"));
+ QVERIFY(!threadId.isEmpty());
+
+ NotmuchWorker setup(m_fixture.configPath());
+ setup.applyTags(TagChange{ { QStringLiteral("a1@example.org") },
+ { QStringLiteral("backfill") }, {},
+ QStringLiteral("seed") });
+
+ NotmuchWorker worker(m_fixture.configPath());
+ QSignalSpy applied(&worker, &NotmuchWorker::tagsApplied);
+ worker.applyTagsToQuery(QStringLiteral("thread:%1").arg(threadId),
+ { QStringLiteral("backfill") }, {},
+ QStringLiteral("Apply rule"));
+
+ QCOMPARE(applied.size(), 1);
+ const TagChange emitted = applied.first().at(0).value<TagChange>();
+ QCOMPARE(emitted.messageIds, QStringList{ QStringLiteral("a2@example.org") });
+ QCOMPARE(emitted.description, QStringLiteral("Apply rule"));
+ QVERIFY(tagsOf(QStringLiteral("a2@example.org"))
+ .contains(QStringLiteral("backfill")));
+
+ NotmuchWorker restore(m_fixture.configPath());
+ restore.applyTags(TagChange{ { QStringLiteral("a1@example.org"),
+ QStringLiteral("a2@example.org") },
+ {}, { QStringLiteral("backfill") },
+ QStringLiteral("restore") });
+}
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `cmake --build build 2>&1 | grep -m3 error`
+Expected: `'class NotmuchWorker' has no member named 'applyTagsToQuery'`.
+
+- [ ] **Step 3: Implement**
+
+`src/notmuchworker.h`, in the same public-slots block as `applyTagsToThreads`:
+
+```cpp
+ /// Applies `+add -remove` to every message the change would move within
+ /// `query` (item 212, a rule applied to existing mail). The query is
+ /// narrowed with SearchTerm::changes() so already-correct messages are
+ /// never written, then the ids go through applyTags(), which reports the
+ /// effective set for undo. Emits nothing when nothing would change.
+ void applyTagsToQuery(const QString &query, const QStringList &add,
+ const QStringList &remove,
+ const QString &description);
+```
+
+`src/notmuchworker.cpp`: add `#include "searchterm.h"` with the other project includes if absent (remember gmime/notmuch headers stay before Qt headers; project headers already follow that order in this file, so put it beside the other `#include "..."` lines). Then, after `applyTagsToThreads`:
+
+```cpp
+void NotmuchWorker::applyTagsToQuery(const QString &query,
+ const QStringList &add,
+ const QStringList &remove,
+ const QString &description)
+{
+ if (!openReadOnly())
+ return;
+
+ QStringList messageIds;
+ if (!collectMessageIds(m_db, SearchTerm::changes(query, add, remove),
+ &messageIds)) {
+ emit errorOccurred(QStringLiteral("Cannot resolve the rule's query"));
+ return;
+ }
+
+ // Empty is not an error: the rule has nothing left to do. applyTags()
+ // returns at once on an empty list, and stays silent, as it must
+ // (CLAUDE.md, item 176).
+ applyTags(TagChange{ messageIds, add, remove, description });
+}
+```
+
+- [ ] **Step 4: Run to verify it passes**
+
+Run: `cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_notmuchworker`
+Expected: all PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/notmuchworker.h src/notmuchworker.cpp tests/test_notmuchworker.cpp
+git commit -S -m "feat: apply a tag change to every message a query would move
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 5: undo bookkeeping stays linear on a large write
+
+**Files:**
+- Modify: `src/mainwindow.h` (`TagCommand::addChangedMessageIds`, `forgetChangedMessageIds`)
+
+No test of its own: a performance property, and Task 7's test exercises the code path.
+
+- [ ] **Step 1: Make `addChangedMessageIds` linear**
+
+It runs `QStringList::contains` per id, which is quadratic on a 10k-message backfill (and on item 211's 10k archive). Replace the method and the member in `class TagCommand`:
+
+```cpp
+ void addChangedMessageIds(const QStringList &ids)
+ {
+ // A set beside the list, so a 10k-message write is not quadratic
+ // here (items 211, 212). The list keeps the order for the undo.
+ for (const QString &id : ids) {
+ if (!m_seen.contains(id)) {
+ m_seen.insert(id);
+ m_changedMessageIds.append(id);
+ }
+ }
+ }
+```
+
+```cpp
+ void forgetChangedMessageIds()
+ {
+ m_changedMessageIds.clear();
+ m_seen.clear();
+ }
+
+private:
+ QStringList m_changedMessageIds;
+ QSet<QString> m_seen;
+```
+
+(`QSet` is already included by `mainwindow.h`.)
+
+No redo change: the application has no Redo action (it is an open wish in
+the user's notes), so `MessageTagCommand::redo()` after the first push is
+unreachable. When Redo is built, a command constructed without ids (Task 7)
+must replay `changedMessageIds()`; Task 8 records that in AGENTS.md.
+
+- [ ] **Step 2: Build and run the main suite**
+
+Run: `cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_mainwindow 2>&1 | grep -E "^FAIL|^Totals"`
+Expected: only the 4 known failures.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/mainwindow.h
+git commit -S -m "fix: keep undo bookkeeping linear on large writes
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 6: the rules dialog, Apply button, archive checkbox, skip warning
+
+**Files:**
+- Modify: `src/tagrulesdialog.h`
+- Modify: `src/tagrulesdialog.cpp`
+- Test: `tests/test_tagrules.cpp` (the dialog tests live here)
+
+- [ ] **Step 1: Write the failing tests**
+
+Add to `private slots:`:
+
+```cpp
+ void applySavesFirstAndEmitsTheSavedRule();
+ void applyEmitsNothingWhenTheSaveFails();
+ void theArchiveBoxMapsToAllowArchive();
+ void aRuleTheHookWillSkipIsNamedInTheWarning();
+```
+
+Bodies, before `QTEST_MAIN`. Each points `XDG_CONFIG_HOME` at its own temp dir, as the existing dialog tests do:
+
+```cpp
+static QString writeDialogRules(const QTemporaryDir &home, const QByteArray &json)
+{
+ QDir().mkpath(home.filePath(QStringLiteral("mailrules")));
+ const QString path = home.filePath(QStringLiteral("mailrules/rules.json"));
+ QFile out(path);
+ if (out.open(QIODevice::WriteOnly))
+ out.write(json);
+ return path;
+}
+
+void TestTagRules::applySavesFirstAndEmitsTheSavedRule()
+{
+ // Item 212, decided with the user: only what the hook would run is
+ // applied, so Apply writes the file before asking for anything.
+ QTemporaryDir home;
+ QVERIFY(home.isValid());
+ qputenv("XDG_CONFIG_HOME", home.path().toUtf8());
+ const QString path = writeDialogRules(home, R"({"version": 1, "rules": [
+ {"id": "promo", "add": ["promo"], "query": "from:a@example.org"}]})");
+
+ TagRulesDialog dialog;
+ QSignalSpy spy(&dialog, &TagRulesDialog::applyRequested);
+ dialog.selectRuleForTest(0);
+ dialog.setTagsForTest(QStringLiteral("promo, sale")); // unsaved edit
+ dialog.applyForTest();
+
+ QCOMPARE(spy.size(), 1);
+ const TagRule emitted = spy.first().at(0).value<TagRule>();
+ QCOMPARE(emitted.add, (QStringList{ QStringLiteral("promo"),
+ QStringLiteral("sale") }));
+
+ TagRules onDisk;
+ onDisk.load(path);
+ QCOMPARE(onDisk.rules().at(0).add, emitted.add);
+}
+
+void TestTagRules::applyEmitsNothingWhenTheSaveFails()
+{
+ QTemporaryDir home;
+ QVERIFY(home.isValid());
+ qputenv("XDG_CONFIG_HOME", home.path().toUtf8());
+ writeDialogRules(home, R"({"version": 1, "rules": [
+ {"id": "promo", "add": ["promo"], "query": "from:a@example.org"}]})");
+
+ TagRulesDialog dialog;
+ QSignalSpy spy(&dialog, &TagRulesDialog::applyRequested);
+ dialog.selectRuleForTest(0);
+ dialog.setTagsForTest(QString()); // adds and removes nothing
+ dialog.applyForTest();
+
+ QVERIFY(spy.isEmpty());
+ QVERIFY(!dialog.warningTextForTest().isEmpty());
+}
+
+void TestTagRules::theArchiveBoxMapsToAllowArchive()
+{
+ QTemporaryDir home;
+ QVERIFY(home.isValid());
+ qputenv("XDG_CONFIG_HOME", home.path().toUtf8());
+ const QString path = writeDialogRules(home, R"({"version": 1, "rules": [
+ {"id": "promo", "add": ["promo"], "remove": ["inbox"],
+ "query": "from:a@example.org"}]})");
+
+ {
+ TagRulesDialog dialog;
+ dialog.selectRuleForTest(0);
+ dialog.setArchiveForTest(true);
+ dialog.saveForTest();
+ }
+ TagRules onDisk;
+ onDisk.load(path);
+ QVERIFY(onDisk.rules().at(0).allowArchive);
+}
+
+void TestTagRules::aRuleTheHookWillSkipIsNamedInTheWarning()
+{
+ // CLAUDE.md: a rule the hook will refuse must be flagged in the UI.
+ QTemporaryDir home;
+ QVERIFY(home.isValid());
+ qputenv("XDG_CONFIG_HOME", home.path().toUtf8());
+ writeDialogRules(home, R"({"version": 1, "rules": [
+ {"id": "promo", "add": ["promo"], "remove": ["inbox"],
+ "query": "from:a@example.org"},
+ {"id": "fine", "add": ["x"], "query": "from:b@example.org"}]})");
+
+ TagRulesDialog dialog;
+ QVERIFY2(dialog.warningTextForTest().contains(QStringLiteral("promo")),
+ qPrintable(dialog.warningTextForTest()));
+ QVERIFY(!dialog.warningTextForTest().contains(QStringLiteral("fine")));
+
+ dialog.selectRuleForTest(0);
+ dialog.setArchiveForTest(true);
+ QVERIFY2(!dialog.warningTextForTest().contains(QStringLiteral("promo")),
+ qPrintable(dialog.warningTextForTest()));
+}
+```
+
+`TagRule` must be a metatype for `QSignalSpy::value<TagRule>()`: add `Q_DECLARE_METATYPE(TagRule)` at the bottom of `src/tagrules.h`, outside the class, and `#include <QMetaType>` there.
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `cmake --build build 2>&1 | grep -m3 error`
+Expected: `'applyRequested' is not a member of 'TagRulesDialog'`.
+
+- [ ] **Step 3: Header changes**
+
+In `src/tagrulesdialog.h`:
+
+- Test seams, beside `saveForTest()`:
+
+```cpp
+ void applyForTest() { onApply(); }
+ void setArchiveForTest(bool on);
+```
+
+- Signal, after `previewRequested`:
+
+```cpp
+ /// Asks the owner to apply one SAVED rule to existing mail (item 212).
+ /// Emitted only after the dialog has written rules.json, so the rule
+ /// applied is exactly the rule the hook will run.
+ void applyRequested(const TagRule &rule);
+```
+
+- Private slots: add `void onApply();`.
+- Private: add `bool saveRules();` and `QString hookSkipWarning() const;`.
+- Members: `QCheckBox *m_archive = nullptr;` and `QPushButton *m_applyButton = nullptr;`.
+
+- [ ] **Step 4: Split saving from closing**
+
+In `src/tagrulesdialog.cpp`, replace `onSave()` with:
+
+```cpp
+bool TagRulesDialog::saveRules()
+{
+ applyEditsToCurrentRule();
+
+ const QStringList problems = TagRules::validate(m_working);
+ if (!problems.isEmpty()) {
+ setWarning(tr("%n rule(s) cannot be saved as they are: %1", "",
+ problems.size())
+ .arg(problems.join(QStringLiteral("; "))));
+ return false;
+ }
+
+ m_rules.setRules(m_working);
+ if (!m_rules.save()) {
+ QMessageBox::warning(this, tr("Tagging rules"),
+ tr("Could not write %1.")
+ .arg(TagRules::defaultPath()));
+ return false;
+ }
+ return true;
+}
+
+void TagRulesDialog::onSave()
+{
+ if (saveRules())
+ accept();
+}
+
+void TagRulesDialog::onApply()
+{
+ // Saved first, decided with the user (item 212): the rule applied is the
+ // rule the hook runs, and a failed save stops here exactly as OK does.
+ if (!saveRules())
+ return;
+ const int index = currentIndex();
+ if (index < 0 || index >= m_working.size())
+ return;
+ emit applyRequested(m_working.at(index));
+}
+```
+
+Keep any comment that sat inside the old `onSave()` between `applyEditsToCurrentRule();` and `validate`: move it into `saveRules()` at the same place.
+
+- [ ] **Step 5: The Apply button**
+
+In the constructor, after the `m_previewButton` block (`buttons->addWidget(m_previewButton);`):
+
+```cpp
+ m_applyButton = new QPushButton(tr("&Apply to existing mail"), this);
+ m_applyButton->setToolTip(
+ tr("Save, then apply this rule to mail already in the index. Asks "
+ "first, naming how many messages would change. Undoable from the "
+ "main window."));
+ buttons->addWidget(m_applyButton);
+```
+
+With the other connects:
+
+```cpp
+ connect(m_applyButton, &QPushButton::clicked, this, &TagRulesDialog::onApply);
+```
+
+Enable it only for a rule worth applying. At the end of `onSelectionChanged()` and at the end of `applyEditsToCurrentRule()`:
+
+```cpp
+ if (index >= 0 && index < m_working.size()) {
+ const TagRule &current = m_working.at(index);
+ m_applyButton->setEnabled(current.enabled && !current.query.isEmpty()
+ && !(current.add.isEmpty()
+ && current.remove.isEmpty()));
+ }
+```
+
+(Both functions already compute `index` from `currentIndex()`; reuse that variable.)
+
+- [ ] **Step 6: The archive checkbox**
+
+In the constructor, right after `m_remove` is created and added to the form (find `form->addRow(tr("Remove tags"), m_remove);`):
+
+```cpp
+ m_archive = new QCheckBox(
+ tr("Archive matching mail on arrival (removes inbox)"), this);
+ m_archive->setToolTip(
+ tr("Without this, the sync hook skips a rule that removes inbox, "
+ "since it runs unattended."));
+ form->addRow(QString(), m_archive);
+ connect(m_remove, &QLineEdit::textChanged, this, [this](const QString &text) {
+ m_archive->setVisible(splitTags(text).contains(QStringLiteral("inbox")));
+ });
+ connect(m_archive, &QCheckBox::toggled,
+ this, &TagRulesDialog::applyEditsToCurrentRule);
+```
+
+In `onSelectionChanged()`, beside `m_remove->setText(...)`:
+
+```cpp
+ m_archive->setChecked(rule.allowArchive);
+ m_archive->setVisible(rule.remove.contains(QStringLiteral("inbox")));
+```
+
+In `applyEditsToCurrentRule()`, after `rule.remove = splitTags(m_remove->text());` (find the line that assigns `rule.remove`):
+
+```cpp
+ // Cleared in the editor when inbox leaves the list, so a flag that does
+ // nothing never reaches the file (item 213).
+ rule.allowArchive = m_archive->isChecked()
+ && rule.remove.contains(QStringLiteral("inbox"));
+```
+
+Seam:
+
+```cpp
+void TagRulesDialog::setArchiveForTest(bool on)
+{
+ m_archive->setChecked(on); // toggled -> applyEditsToCurrentRule
+}
+```
+
+`splitTags` is the file's existing helper used by `applyEditsToCurrentRule`; if it is a member or a static in an anonymous namespace, the lambda can call it the same way.
+
+- [ ] **Step 7: The skip warning**
+
+Add:
+
+```cpp
+QString TagRulesDialog::hookSkipWarning() const
+{
+ QStringList skipped;
+ for (const TagRule &rule : m_working) {
+ if (rule.enabled && TagRules::hookWouldSkip(rule))
+ skipped.append(rule.id);
+ }
+ if (skipped.isEmpty())
+ return {};
+ return tr("The sync hook skips %n rule(s), which remove inbox or unread "
+ "on mail nobody has looked at: %1. Tick \"Archive matching "
+ "mail on arrival\" to let a rule remove inbox; unread is never "
+ "removed on arrival.", "", skipped.size())
+ .arg(skipped.join(QStringLiteral(", ")));
+}
+```
+
+Replace `showWarnings()` so both sources share the one banner:
+
+```cpp
+void TagRulesDialog::showWarnings()
+{
+ QStringList parts;
+ const QStringList warnings = m_rules.warnings();
+ if (!warnings.isEmpty()) {
+ parts.append(tr("%n rule(s) in the file need attention: %1. Save to "
+ "write them back.", "", warnings.size())
+ .arg(warnings.join(QStringLiteral("; "))));
+ }
+ const QString skips = hookSkipWarning();
+ if (!skips.isEmpty())
+ parts.append(skips);
+ setWarning(parts.join(QStringLiteral(" ")));
+}
+```
+
+Keep the comment that sat between the early return and the old `setWarning(tr("%n rule(s) in the file need attention...` call, above the `if (!warnings.isEmpty())`.
+
+At the END of `applyEditsToCurrentRule()`, re-evaluate only when the skip text actually changed, so a dismissed banner is not resurrected by every keystroke:
+
+```cpp
+ const QString skips = hookSkipWarning();
+ if (skips != m_lastSkipWarning) {
+ m_lastSkipWarning = skips;
+ showWarnings();
+ }
+```
+
+Add the member `QString m_lastSkipWarning;` and set `m_lastSkipWarning = hookSkipWarning();` at the end of `reloadList()` (where `showWarnings()` is first called on load).
+
+- [ ] **Step 8: Run**
+
+Run: `cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_tagrules 2>&1 | grep -E "^FAIL|^Totals"`
+Expected: `0 failed`. If an existing warning test compares the banner text exactly and now sees a skip sentence appended, check its fixture: a rule removing `inbox` without the flag legitimately gets the new sentence. Do not weaken the new assertions.
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add src/tagrulesdialog.h src/tagrulesdialog.cpp src/tagrules.h tests/test_tagrules.cpp
+git commit -S -m "feat: apply a rule from the rules dialog, and opt a rule into archiving
+
+Apply saves first, so only what the hook would run is applied, then asks
+the owner to apply the saved rule. A checkbox maps to allow_archive, and
+the banner names every rule the sync hook will skip.
+
+Items 212 and 213.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 7: `MainWindow` counts, confirms, writes, and undoes
+
+**Files:**
+- Modify: `src/mainwindow.h`
+- Modify: `src/mainwindow.cpp` (`showTagRulesDialog`, `onRuleCountsReady`, `announceAction`, new `applyRuleToExistingMail`)
+- Test: `tests/test_mainwindow.cpp`
+
+**Two deviations from the spec, deliberate.** First, the rule is written one tag at a time inside one undo macro, because the worker's effective set is per message, not per tag (see `ruleOperations()` below): a single `+promo -inbox` write would make the undo strip `promo` from messages that had it before. Second, the spec said a backfill during a sync is held and replayed like a list edit. `HeldEdit` carries message or thread ids, and a backfill has neither until the worker resolves the query. Instead, Apply during a sync is refused with a status message and does nothing; the user presses it again after the sync. Record this in the spec in Task 8.
+
+- [ ] **Step 1: Write the failing test**
+
+Add to `private slots:` beside `aWriteSaysItIsApplyingUntilTheWorkerHasRunIt`:
+
+```cpp
+ void applyingARuleConfirmsWithTheCountAndUndoesExactly();
+```
+
+Body, beside that test:
+
+```cpp
+void TestMainWindow::applyingARuleConfirmsWithTheCountAndUndoesExactly()
+{
+ // Items 212 and 176. Two matching messages in OPPOSITE states: `had`
+ // already carries promo, `lacked` does not. The confirmation must count
+ // one, and undo must leave `had` tagged.
+ WorkerBackedWindow backed;
+ const QString date = QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200");
+ QVERIFY(backed.fixture().addMessage(
+ QStringLiteral("inbox"), QStringLiteral("had@example.org"),
+ QStringLiteral("One"), QStringLiteral("deals@example.net"), date,
+ QStringLiteral("Body.")));
+ QVERIFY(backed.fixture().addMessage(
+ QStringLiteral("inbox"), QStringLiteral("lacked@example.org"),
+ QStringLiteral("Two"), QStringLiteral("deals@example.net"), date,
+ QStringLiteral("Body.")));
+ QVERIFY2(backed.build(), qPrintable(backed.error()));
+
+ const auto notmuch = [&backed](const QStringList &args) {
+ QProcess p;
+ QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
+ env.insert(QStringLiteral("NOTMUCH_CONFIG"),
+ backed.fixture().configPath());
+ p.setProcessEnvironment(env);
+ p.start(QStringLiteral("notmuch"), args);
+ p.waitForFinished();
+ return QString::fromUtf8(p.readAllStandardOutput()).trimmed();
+ };
+ notmuch({ QStringLiteral("tag"), QStringLiteral("+promo"), QStringLiteral("--"),
+ QStringLiteral("id:had@example.org") });
+
+ MainWindow window(backed.config());
+ auto *undo = window.findChild<QAction *>(QStringLiteral("undo"));
+ QVERIFY(undo);
+
+ TagRule rule;
+ rule.id = QStringLiteral("promo");
+ rule.query = QStringLiteral("from:deals@example.net");
+ rule.add = { QStringLiteral("promo") };
+ rule.remove = { QStringLiteral("inbox") };
+
+ // Answer the confirmation when it appears, recording what it said.
+ QString asked;
+ QTimer poll;
+ poll.setInterval(20);
+ QObject::connect(&poll, &QTimer::timeout, [&]() {
+ auto *box = qobject_cast<QMessageBox *>(QApplication::activeModalWidget());
+ if (!box)
+ return;
+ asked = box->text();
+ poll.stop();
+ box->button(QMessageBox::Yes)->click();
+ });
+ poll.start();
+
+ window.applyRuleToExistingMail(rule);
+ QTRY_VERIFY_WITH_TIMEOUT(!asked.isEmpty(), 15000);
+ // The whole rule's count: both messages still carry inbox, so both
+ // would move, even though `had` already has promo.
+ QVERIFY2(asked.contains(QStringLiteral("2")), qPrintable(asked));
+
+ QTRY_COMPARE_WITH_TIMEOUT(
+ notmuch({ QStringLiteral("count"), QStringLiteral("tag:inbox") }),
+ QStringLiteral("0"), 15000);
+
+ undo->trigger();
+ QTRY_COMPARE_WITH_TIMEOUT(
+ notmuch({ QStringLiteral("count"), QStringLiteral("tag:inbox") }),
+ QStringLiteral("2"), 15000);
+ // `had` carried promo before the backfill, so undo must not take it off.
+ QCOMPARE(notmuch({ QStringLiteral("count"),
+ QStringLiteral("tag:promo and id:had@example.org") }),
+ QStringLiteral("1"));
+ QCOMPARE(notmuch({ QStringLiteral("count"),
+ QStringLiteral("tag:promo and id:lacked@example.org") }),
+ QStringLiteral("0"));
+}
+```
+
+The undo action is named `undo` (`src/keymap.cpp`). Add `#include <QMessageBox>` and `#include <QProcess>` to the test file if absent, and `#include "tagrules.h"`.
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `cmake --build build 2>&1 | grep -m3 error`
+Expected: `'class MainWindow' has no member named 'applyRuleToExistingMail'`.
+
+- [ ] **Step 3: `announceAction` learns an optional completion callback**
+
+In `src/mainwindow.h`, change the declaration to:
+
+```cpp
+ void announceAction(const QString &text,
+ const std::function<void()> &onDone = {});
+```
+
+In `src/mainwindow.cpp`, change the definition's signature to match, and in the worker round trip capture and run it (the bare-window branch runs it at once):
+
+```cpp
+ if (!m_worker) {
+ showTransientStatus(text);
+ if (onDone)
+ onDone();
+ return;
+ }
+```
+
+```cpp
+ QMetaObject::invokeMethod(m_worker, [this, text, ticket, onDone]() {
+ QMetaObject::invokeMethod(this, [this, text, ticket, onDone]() {
+ if (onDone)
+ onDone();
+ // A newer announcement, or an error, owns the label now.
+ if (ticket == m_announceTicket)
+ showTransientStatus(tr("%1, done").arg(text));
+ }, Qt::QueuedConnection);
+ }, Qt::QueuedConnection);
+```
+
+`onDone` runs whatever the ticket says: it is the caller's follow-up (a refresh), not a label. The sync-held branch at the bottom of `announceAction` does not run it; nothing calls it with a callback while a sync holds the lock (Step 4 refuses first).
+
+- [ ] **Step 4: `applyRuleToExistingMail` and the count reply**
+
+`src/mainwindow.h`, public slots (it is driven by the dialog and by the test):
+
+```cpp
+ /// Item 212. Counts what `rule` would change across the whole index,
+ /// confirms with that count, then writes through the worker with exact
+ /// undo. The rule has already been saved by the dialog.
+ void applyRuleToExistingMail(const TagRule &rule);
+```
+
+Private members, beside `m_ruleCountGeneration`:
+
+```cpp
+ /// The rule awaiting its change count, and the generation that count
+ /// will carry. Shares m_ruleCountGeneration's sequence, so a pending
+ /// Apply and the dialog's Count matches can never be confused.
+ TagRule m_pendingApplyRule;
+ quint64 m_pendingApplyGeneration = 0;
+```
+
+Add `#include "tagrules.h"` to `mainwindow.h` if absent.
+
+`src/mainwindow.cpp`, in `showTagRulesDialog`, beside the other dialog connects:
+
+```cpp
+ connect(dialog, &TagRulesDialog::applyRequested,
+ this, &MainWindow::applyRuleToExistingMail);
+```
+
+New function, after `onRuleCountsReady`:
+
+```cpp
+void MainWindow::applyRuleToExistingMail(const TagRule &rule)
+{
+ if (!m_worker)
+ return;
+
+ // Refused rather than held: HeldEdit replays ids, and a backfill has none
+ // until the worker resolves the query. A deliberate deviation from the
+ // spec, recorded there.
+ if (aSyncHoldsTheWriteLock()) {
+ showTransientStatus(
+ tr("A sync is running; apply the rule again when it finishes."));
+ return;
+ }
+
+ // One count for the whole rule (what the confirmation names), then one per
+ // single-tag operation, in the order ruleOperations() lists them. The
+ // per-operation counts decide which writes are sent at all; see
+ // confirmRuleApply() for why the rule is split.
+ QStringList queries{ SearchTerm::changes(rule.query, rule.add, rule.remove) };
+ for (const auto &op : ruleOperations(rule))
+ queries.append(SearchTerm::changes(rule.query, op.first, op.second));
+
+ m_pendingApplyRule = rule;
+ m_pendingApplyGeneration = ++m_ruleCountGeneration;
+ QMetaObject::invokeMethod(
+ m_worker, "requestMessageCounts", Qt::QueuedConnection,
+ Q_ARG(QStringList, queries),
+ Q_ARG(quint64, m_pendingApplyGeneration));
+}
+```
+
+And a file-local helper at the top of `mainwindow.cpp`'s anonymous namespace (create one beside the other file-local helpers if needed):
+
+```cpp
+/// A rule as single-tag operations: `+a`, `+b`, `-c`, each as (add, remove).
+/// Item 212: the worker's effective set is per MESSAGE, not per tag, so one
+/// `+promo -inbox` write reports a message that already had promo (it lost
+/// inbox) and its undo would strip promo from it. One operation per tag makes
+/// each effective set, and therefore each undo, exact.
+QList<QPair<QStringList, QStringList>> ruleOperations(const TagRule &rule)
+{
+ QList<QPair<QStringList, QStringList>> ops;
+ for (const QString &tag : rule.add)
+ ops.append({ { tag }, {} });
+ for (const QString &tag : rule.remove)
+ ops.append({ {}, { tag } });
+ return ops;
+}
+```
+
+Replace the body of `onRuleCountsReady` so it routes the Apply count first (keep its existing comment above the dialog branch):
+
+```cpp
+void MainWindow::onRuleCountsReady(const QVector<int> &counts,
+ quint64 generation)
+{
+ if (m_pendingApplyGeneration != 0
+ && generation == m_pendingApplyGeneration) {
+ m_pendingApplyGeneration = 0;
+ confirmRuleApply(counts);
+ return;
+ }
+
+ if (generation != m_ruleCountGeneration || !m_tagRulesDialog)
+ return;
+
+ m_tagRulesDialog->setCounts(counts);
+}
+```
+
+Private helper, declared in the header beside `applyRuleToExistingMail` (private section): `void confirmRuleApply(const QVector<int> &counts);`
+
+```cpp
+void MainWindow::confirmRuleApply(const QVector<int> &counts)
+{
+ const TagRule rule = m_pendingApplyRule;
+ const int count = counts.value(0, 0);
+ if (count <= 0) {
+ showTransientStatus(
+ tr("Rule \"%1\" has nothing to change.").arg(rule.id));
+ return;
+ }
+
+ // The one confirmation for a reversible write, and why (CLAUDE.md): the
+ // scope is the whole index, so the user cannot see what it will touch.
+ QStringList what;
+ if (!rule.add.isEmpty())
+ what.append(tr("Adds: %1.").arg(rule.add.join(QStringLiteral(", "))));
+ if (!rule.remove.isEmpty())
+ what.append(tr("Removes: %1.").arg(rule.remove.join(QStringLiteral(", "))));
+
+ QWidget *parent = m_tagRulesDialog ? static_cast<QWidget *>(m_tagRulesDialog)
+ : this;
+ QMessageBox box(parent);
+ box.setIcon(QMessageBox::Question);
+ box.setWindowTitle(tr("Apply rule"));
+ box.setText(tr("Apply rule \"%1\" to %n message(s)?", "", count).arg(rule.id));
+ box.setInformativeText(what.join(QStringLiteral(" "))
+ + QStringLiteral("\n")
+ + tr("You can undo this from the main window."));
+ box.setStandardButtons(QMessageBox::Yes | QMessageBox::Cancel);
+ box.setDefaultButton(QMessageBox::Cancel);
+ if (box.exec() != QMessageBox::Yes)
+ return;
+
+ const QString description = tr("Apply rule \"%1\"").arg(rule.id);
+
+ // One write per single-tag operation, grouped so one Ctrl+Z reverts the
+ // whole rule (see ruleOperations() for why it is split). An operation
+ // whose count was 0 is NOT sent: applyTags() stays silent on an empty
+ // write, and an armed command that never hears back would steal the next
+ // write's confirmation. Commands are built with no ids; the worker
+ // resolves them and the confirmation fills each one (item 176).
+ const auto ops = ruleOperations(rule);
+ m_undoStack.beginMacro(description);
+ bool first = true;
+ for (int i = 0; i < ops.size(); ++i) {
+ if (counts.value(i + 1, 0) <= 0)
+ continue;
+ auto *command = new MessageTagCommand(this, {}, ops.at(i).first,
+ ops.at(i).second, description);
+ awaitTagConfirmation(command, first);
+ first = false;
+ QMetaObject::invokeMethod(
+ m_worker, "applyTagsToQuery", Qt::QueuedConnection,
+ Q_ARG(QString, rule.query), Q_ARG(QStringList, ops.at(i).first),
+ Q_ARG(QStringList, ops.at(i).second), Q_ARG(QString, description));
+ m_undoStack.push(command);
+ }
+ m_undoStack.endMacro();
+
+ // The rows a backfill touches are mostly not in the model, so the list is
+ // re-queried once the write has run rather than updated optimistically.
+ announceAction(tr("%1: %n message(s)", "", count).arg(description),
+ [this]() { refreshCurrentQuery(); });
+}
+```
+
+Add `#include "searchterm.h"` to `mainwindow.cpp` if absent.
+
+**Verified while planning:** `awaitTagConfirmation(command, first)` keeps a FIFO; `first = true` clears anything an earlier gesture stranded, so the loop passes `true` for the first operation sent and `false` after, which is the "both halves of one gesture" usage `tagSelected()` already has. `QUndoStack::push()` calls `redo()` on each child inside a macro too, which `m_firstRedo` turns into the no-op it expects.
+
+**Residual gap, accepted:** an operation counted > 0 can still move nothing if another writer (a sync, the hook) changed those messages between the count and the write. Its command then never hears back and steals a later confirmation. The count is taken a moment before, so this is a race rather than a path. Do not invent new plumbing for it; Task 8 records it in AGENTS.md.
+
+- [ ] **Step 5: Run**
+
+Run: `cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_mainwindow applyingARuleConfirmsWithTheCountAndUndoesExactly 2>&1 | grep -E "^(PASS|FAIL)|Totals"`
+Expected: PASS.
+
+Then the whole binary: `QT_QPA_PLATFORM=offscreen ./build/tests/test_mainwindow 2>&1 | grep -E "^FAIL|^Totals"` shows only the 4 known failures.
+
+- [ ] **Step 6: Mutation check**
+
+Temporarily make `ruleOperations()` return ONE operation, `{ rule.add, rule.remove }`, so the rule is written in a single call. Rebuild and run the test: the assertion that `had` keeps `promo` after undo must FAIL. That is the defect the split exists for. Restore, rebuild, confirm PASS.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add src/mainwindow.h src/mainwindow.cpp tests/test_mainwindow.cpp
+git commit -S -m "feat: apply a saved rule to existing mail, confirmed and undoable
+
+The dialog's Apply reaches MainWindow, which counts the messages the rule
+would actually move, confirms with that count, then writes through the
+worker. The undo entry is filled from the confirmation, so it reverts
+exactly what moved. Refused while a sync holds the write lock.
+
+Item 212.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 8: documents, translations, the hand round trip
+
+**Files:**
+- Modify: `AGENTS.md`
+- Modify: `docs/superpowers/specs/2026-10-31-rule-apply-and-archive-design.md`
+- Modify: `CHANGELOG.md`
+- Modify: `translations/qtmaildir_it_IT.ts`
+- Modify: `docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md` and `-closed.md`
+
+- [ ] **Step 1: AGENTS.md, the confirmation rule**
+
+In the paragraph beginning `**There is exactly ONE exception, and its shape is the rule's own logic rather than a hole in it.**`, change `exactly ONE exception` to `two exceptions` and append after the sentence ending `...unless the action is likewise irreversible.`:
+
+```markdown
+The second is **a write scoped to the whole index rather than to rows on
+screen**: applying a tagging rule to existing mail (item 212). It IS undoable,
+through the same exact-undo path as every tag write, and it confirms anyway,
+naming the count, because the user cannot see what it will touch. The two
+exceptions have different reasons and must keep them: `empty_trash` confirms
+because it cannot be undone, Apply because it cannot be seen.
+```
+
+In "Changing the rule format", under the paragraph `**Two hook properties are safety-critical...`, replace `The hook refuses to remove \`unread\` or \`inbox\`` with `The hook refuses to remove \`unread\`, and \`inbox\` unless the rule sets \`allow_archive\` (item 213),`. Delete the closing paragraph `Backfill, applying a rule to existing mail, is deliberately unbuilt. See ...` and replace it with:
+
+```markdown
+Backfill exists since item 212 as the dialog's **Apply to existing mail**, and
+it runs in qtmaildir, attended and confirmed, never in the hook. **The rule is
+written one tag at a time, grouped in one undo macro, and that is
+load-bearing**: `applyTags()` reports which MESSAGES moved, not which tags, so
+one `+promo -inbox` write reports a message that already had `promo` (it lost
+`inbox`), and its undo strips `promo` from it. One write per tag makes every
+effective set exact. Each command is built with no ids and filled from its confirmation; the
+application has no Redo yet, and when it gets one, such a command must redo
+`changedMessageIds()`, not its empty constructor list. An operation counted at 0
+is not sent, since a silent write would leave its armed command stealing the
+next confirmation; a write racing another writer between count and send can
+still do that, and is accepted rather than plumbed.
+```
+
+- [ ] **Step 2: The spec**
+
+In the spec's Part A, item 4, replace the bullet `- the write lock: if a sync holds it, the edit is held and replayed the same way a list edit is.` with:
+
+```markdown
+ - the write lock: Apply during a sync is REFUSED with a status message.
+ Changed during planning: `HeldEdit` replays message or thread ids, and a
+ backfill has neither until the worker resolves its query.
+```
+
+Also in the spec's Part A, item 4, after the line `calls the existing \`applyTags()\`. Everything downstream is inherited`, add a paragraph:
+
+```markdown
+ **Written one tag at a time** (changed during planning). `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, and only the operations whose own count is above zero are sent.
+```
+
+- [ ] **Step 3: CHANGELOG**
+
+Under `## [Unreleased]`, in `### Added`:
+
+```markdown
+- **Apply to existing mail** in the tagging rules dialog. It saves the
+ rules, counts the messages the selected rule would actually change, asks
+ with that count, and applies it. Undo reverts exactly what moved.
+- **Archive matching mail on arrival**, a per-rule checkbox. A rule that
+ removes `inbox` used to be skipped whole by the sync hook; with the box
+ ticked it runs, and `inbox` is removed. `unread` is never removed on
+ arrival. The dialog now names every rule the hook will skip.
+```
+
+And under `### Upgrading` (create it below the version summary area of `[Unreleased]`, after `### Fixed`, if absent):
+
+```markdown
+- A rule that removes `inbox` was never applied by the sync hook. Open the
+ rules dialog: such rules are named in the warning. Tick "Archive matching
+ mail on arrival" on each one you want to run, then use "Apply to existing
+ mail" to catch up on what arrived meanwhile.
+```
+
+- [ ] **Step 4: Translations**
+
+Run: `lupdate-qt6 src/ -ts translations/qtmaildir_it_IT.ts -no-obsolete -locations none`
+Expected: zero "cannot be called without context" warnings. Fill every `type="unfinished"` entry with Italian. Suggested:
+
+| English | Italian |
+|---|---|
+| `&Apply to existing mail` | `&Applica alla posta esistente` |
+| `Save, then apply this rule ...` (tooltip) | `Salva, poi applica questa regola alla posta già indicizzata. Chiede prima, indicando quanti messaggi cambierebbero. Annullabile dalla finestra principale.` |
+| `Archive matching mail on arrival (removes inbox)` | `Archivia all'arrivo la posta corrispondente (rimuove inbox)` |
+| `Without this, the sync hook skips ...` | `Senza questa opzione, l'hook di sincronizzazione salta le regole che rimuovono inbox, perché agisce senza supervisione.` |
+| `The sync hook skips %n rule(s) ...` | numerus forms: `L'hook di sincronizzazione salta %n regola, che rimuove inbox o unread su posta che nessuno ha visto: %1. Spunta "Archivia all'arrivo la posta corrispondente" per permettere a una regola di rimuovere inbox; unread non viene mai rimosso all'arrivo.` and the plural `... salta %n regole, che rimuovono ...` |
+| `A sync is running; apply the rule again when it finishes.` | `Sincronizzazione in corso; applica di nuovo la regola al termine.` |
+| `Rule "%1" has nothing to change.` | `La regola "%1" non ha nulla da cambiare.` |
+| `Adds: %1.` / `Removes: %1.` | `Aggiunge: %1.` / `Rimuove: %1.` |
+| `Apply rule` | `Applica regola` |
+| `Apply rule "%1" to %n message(s)?` | `Applicare la regola "%1" a %n messaggio?` / `... a %n messaggi?` |
+| `You can undo this from the main window.` | `Puoi annullare dalla finestra principale.` |
+| `Apply rule "%1"` | `Applica regola "%1"` |
+
+Then `cmake --build build 2>&1 | grep -i unfinished` must show `0 unfinished`, and `ctest --test-dir build -R translations --output-on-failure` must pass.
+
+- [ ] **Step 5: Full suites**
+
+Run:
+```bash
+ctest --test-dir build -j8 2>&1 | tail -5
+cd assets/hooks && ./test_mailrules.py && ./test_post_new.py && cd ../..
+```
+Expected: only `mainwindow` fails, with only the 4 known failures; both Python suites `all passed`.
+
+- [ ] **Step 6: Hand round trip (rule-format step 4)**
+
+No automated test spans the C++ and the Python. In a scratch dir:
+
+```bash
+S=$(mktemp -d)
+mkdir -p "$S/Mail/"{new,cur,tmp} "$S/config/mailrules"
+printf 'From: promo@example.net\nTo: you@example.org\nSubject: sale\nMessage-ID: <s1@example.net>\nDate: Fri, 14 Aug 2026 10:00:00 +0200\n\nbody\n' > "$S/Mail/new/1"
+printf '[database]\npath=%s/Mail\n[new]\ntags=new;unread;inbox\n[user]\nname=T\nprimary_email=you@example.org\n' "$S" > "$S/nm"
+export NOTMUCH_CONFIG="$S/nm" XDG_CONFIG_HOME="$S/config"
+notmuch new
+```
+
+Write a rule file FROM THE C++ SIDE, using a tiny throwaway test or by calling `TagRules::save` from the dialog test harness: the quickest route is to copy the JSON that `allowArchiveRoundTripsAndIsOmittedWhenFalse` saves, adding a `"note": "kept"`. Then:
+
+```bash
+./assets/hooks/post-new; echo "exit $?"
+notmuch count tag:promo # expect 1
+notmuch count tag:inbox # expect 0
+python3 -c "import json,os;d=json.load(open(os.environ['XDG_CONFIG_HOME']+'/mailrules/rules.json'));r=d['rules'][0];print(r.get('allow_archive'),r.get('note'))" # expect: True kept
+```
+
+Report the measured counts in the commit message.
+
+- [ ] **Step 7: Backlog**
+
+Mark items 212 and 213 `**done** 2026-10-31, unreleased` in the status table with a one-line summary each, and move both `## 212.` and `## 213.` sections from the backlog to `2026-08-03-post-0.1.0-usability-closed.md` on this commit, per `AGENTS.md`.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add AGENTS.md CHANGELOG.md translations/qtmaildir_it_IT.ts docs/
+git commit -S -m "docs: record rule apply and archive-on-arrival
+
+The no-confirmation rule gains its second exception, by property: a write
+scoped to the whole index confirms with the count, though it can be undone.
+Hand round trip: <paste the measured counts>.
+
+Items 212 and 213.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+- [ ] **Step 9: Hand over**
+
+Do not launch the application. Tell the user what to look at: open the rules dialog, see `promo` named in the warning, tick "Archive matching mail on arrival", press "Apply to existing mail", read the count, confirm, watch "applying… / done", and Ctrl+Z.