diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-25 17:09:26 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-25 17:09:26 +0200 |
| commit | a46d28f940e58cf4c829ef7fc269dc2d22dea24a (patch) | |
| tree | d69af054d100feea41bbcefdbd6f1ca88cd2c9fb | |
| parent | e741c9b9087cff616f81d6c2d0f0ee7873d142cc (diff) | |
| download | qtmaildir-a46d28f940e58cf4c829ef7fc269dc2d22dea24a.tar.gz qtmaildir-a46d28f940e58cf4c829ef7fc269dc2d22dea24a.zip | |
docs: mailctl is retired, the hooks are ours
The user has not run mailctl in months and the coupling this document
described is gone: the live database.hook_dir symlinks post-new,
mailrules.py and qtmaildirconf.py into assets/hooks/ here, where their
three suites also live. All three pass.
The format discipline survives the move and is kept, because what makes
it necessary is two independent readers of one file, not two
repositories: src/tagrules.cpp and assets/hooks/mailrules.py still share
no code and still agree by test. What changes is the procedure around
it, which no longer sends anyone to a sibling checkout, and the round
trip, which is now verified by running the hook rather than by a CLI
that is retired.
Item 166 said the same thing and was filed a day before this was
noticed; its row and its two-repo constraint are corrected with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HFuRPtzFrSxCQjFk6tq7gD
| -rw-r--r-- | CLAUDE.md | 101 | ||||
| -rw-r--r-- | docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md | 8 |
2 files changed, 56 insertions, 53 deletions
@@ -233,8 +233,8 @@ process-wide, so holding it open would block the user's cron `notmuch new`. `app closes the read-only handle, opens read-write, applies, closes. notmuch permits only one open handle per process, so that close-first ordering is required, not stylistic. -**No dry-run, no destructive-action confirmation.** Those gates exist in the companion -project `../mailctl` to restrain an agent; a human at a GUI gets **undo** instead — every +**No dry-run, no destructive-action confirmation.** Those gates belonged to the retired +`mailctl` CLI, where they restrained an agent; a human at a GUI gets **undo** instead — every mutation pushes its inverse (`TagChange::inverted()`) onto a `QUndoStack`. Do not add confirmation dialogs for tag mutations. All actions funnel through one `applyTags` path; multi-row selections go through `applyTagsToThreads`, which resolves every thread in ONE @@ -295,9 +295,9 @@ irreversibly. A restore must be right about its destination or it is worse than doing nothing. **The sync script lives here, in `assets/mailsync.sh`.** It moved from the -companion `mailctl` project, which documents that it never calls it: the script -is `mbsync` plus `notmuch new` with a lock, and qtmaildir is the only thing that -runs it programmatically. Two properties exist for this application's sake and +retired `mailctl` project, which never called it: the script is `mbsync` plus +`notmuch new` with a lock, and qtmaildir is the only thing that runs it +programmatically. Two properties exist for this application's sake and must survive any edit. It **prints to stdout as well as its log file**, because `MailSync` shows what the command prints and a self-redirecting script leaves the pane empty; and it **exits with the real status**, because a `0` from a @@ -357,18 +357,19 @@ asserts on is guarded by `!m_savedQueries.isEmpty()`, so a test with no `queries.json` never reaches the branch and passes against a broken check. It writes one, and asserts the file loaded before asserting on what it produced. -**This application has a sibling, and one file couples them.** `mailctl` -(`../mailctl`) is a narrow, agent-safe CLI over the same notmuch index. The two -are independent except for `~/.config/mailrules/rules.json`, which both read and -write. **Before changing anything about that file's format, read -"Changing the shared rule format" at the bottom of this document.** Nothing else -here can break mailctl: it never imports from this repo, and this repo never -calls it. - -**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. +**One config file has two readers, and both are now in this repo.** +`~/.config/mailrules/rules.json` is read and written by `src/tagrules.cpp` and +by `assets/hooks/mailrules.py`, which share no code and agree by test. +**Before changing anything about that file's format, read "Changing the rule +format" at the bottom of this document.** It used to be a cross-repo coupling +with the `mailctl` CLI; that project is retired and the hooks moved here on +2026-08-23, so a format change is now one repo and two suites. + +**The auto-tagging rules live in a config file, not in the source, and notmuch's +parser rejects almost nothing.** Rules are in `~/.config/mailrules/rules.json`, +applied by the notmuch `post-new` hook in `assets/hooks/`, which the live +`database.hook_dir` symlinks to; `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 @@ -885,12 +886,12 @@ backlog had already specified and that shipped unbuilt (item 29). A note saying "X does not work" is a bug report, and it will sit in a personal notes file indefinitely unless someone goes looking. -**The backlog covers the mail system, not only this binary.** Item 44 shipped as -commits in BOTH this repo and `../mailctl`, and any future item touching the -shared rule format will too. An item is not "not ours" because its work lands in -the sibling repo; note where the work goes in the table's Note column. mailctl -keeps its own `TODO.md` for things that are purely its own, and that file is not -part of this reconciliation. +**The backlog covers the mail system, not only this binary.** An item can land +in `assets/hooks/` rather than in `src/`, and item 166 is one: the tagging hook +is part of the mail system the user sees, so a defect there gets an item here +like any other. Item 44 predates that and shipped as commits in this repo and in +the retired `mailctl`, which is why older entries mention a sibling repo; there +is no longer one to split work across. **Then print the open items as a table, and stop.** The user picks what to work on; do not start on one, and do not recommend a single item as though the choice @@ -993,39 +994,41 @@ was: bumping it is a task in that repo, which has its own workflow in its tracks upstream through an nvchecker stanza there, so a release here is picked up by that repo's own sweep. -## Changing the shared rule format +## Changing the rule format -`~/.config/mailrules/rules.json` has **two independent implementations**, and -they agree by test rather than by sharing code: +`~/.config/mailrules/rules.json` has **two independent implementations**, both +in this repo, and they agree by test rather than by sharing code: | | reads/writes | applies rules | |---|---|---| -| `src/tagrules.cpp` (here) | yes | no | -| `mailrules.py` (`../mailctl`) | yes | via the `post-new` hook | - -**This is the only way work here can break mailctl.** It never imports from this -repo and this repo never calls it, so nothing else is shared. The file is -deliberately owned by neither: both readers preserve fields they do not -understand (`TagRule::unknown`, `Rule.unknown`), which is what lets one tool -save a file the other wrote without stripping it. - -**A format change is therefore a two-repo change, and the live hook runs every -ten minutes on real mail.** Before touching the schema: - -1. Change both readers, not one. A field added here and not there is silently - dropped on the next save from the other side, which looks like data loss with - no error anywhere. +| `src/tagrules.cpp` | yes | no | +| `assets/hooks/mailrules.py` | yes | via the `post-new` hook | + +They are two languages either side of one file, so nothing but the format +couples them. Both readers preserve fields they do not understand +(`TagRule::unknown`, `Rule.unknown`), which is what lets one save a file the +other wrote without stripping it. This was a cross-repo coupling with the +`mailctl` CLI until that project was retired and the hooks moved here on +2026-08-23; the discipline below survives the move because the two readers do. + +**The live hook runs every ten minutes on real mail.** Before touching the +schema: + +1. Change both readers, not one. A field added on one side and not the other is + silently dropped on the next save from the other, which looks like data loss + with no error anywhere. 2. Bump `kFormatVersion` / `FORMAT_VERSION` together only for a BREAKING change. Both readers refuse a file whose version they do not know, which is the - correct behaviour and also means a half-deployed bump stops the hook from + correct behaviour and also means a half-applied bump stops the hook from tagging. Adding an optional field needs no bump. -3. Run both suites: `ctest --test-dir build -R tagrules` here, and - `./test_mailrules.py && ./test_post_new.py` there. -4. Verify the round trip across tools by hand, since no automated test spans - both repos: save from the dialog, then `mailctl rules list`, and confirm the - rule count and a note survive. - -**Two hook properties are safety-critical and are not this repo's to weaken.** +3. Run both suites: `ctest --test-dir build -R tagrules`, and + `./test_post_new.py && ./test_mailrules.py` from `assets/hooks/`. +4. Verify the round trip by hand, since no automated test spans the C++ and the + Python: save from the dialog, then run the hook over a throwaway index, and + confirm the rule count and a note survive. + +**Two hook properties are safety-critical, and being ours now is not a reason to +weaken them.** The hook refuses to remove `unread` or `inbox` (`maildir.synchronize_flags` is true, so removing `unread` rewrites Maildir filenames and reaches the server), and it does not consume the `tag:new` marker when the rules fail to load 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 5b843a4..6c29fe0 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 @@ -239,7 +239,7 @@ taking that too literally. | 163 | The message pane shows a stale path, and the composer forks the draft | defect | S | **done, 2026-08-25.** mbsync renames an uploaded file to add its `,U=<uid>` infix while the model still holds the name the query returned. `MaildirName::resolveRenamed()` returns the path unchanged when it exists, else finds the file in that one directory whose unique stem matches; it refuses an ambiguous match and yields nothing for a genuinely missing file. Wired into all THREE read sites: the pane, Reply/Forward, and the draft reopen. The reopen was the one that cost data, forking a draft into two files with two Message-IDs, both reaching the server | | 164 | A draft this application saved keeps `inbox` | defect | S | open, 2026-08-25, **cause corrected 2026-08-25**. The first diagnosis blamed a missing drafts helper and was WRONG: `NOT_ARRIVALS` in `qtmaildirconf.py` is `("sent", "drafts")`, the folder list includes every account's drafts folder, and `notmuch count` confirms the carve-out query MATCHES the affected draft. The carve-out is scoped to `tag:new`, and the draft carries `inbox` while `tag:new` is 0, so it was never in scope when the hook ran. Measured separately: an mbsync-style rename does NOT re-add `new.tags`, so the retag theory is out too. What remains unestablished is WHICH pass tagged it; establish that before writing code | | 165 | A draft gets a new Message-ID on every autosave | enhancement | ? | open, 2026-08-25, found while hand-testing 163 and 164. `MessageBuilder::build()` generates an id unconditionally and every autosave calls it, so each revision is a distinct MESSAGE to notmuch and to the server rather than a new version of one. Invisible while the file is replaced correctly, which item 163's fix restores; it is what turned that fork into two messages rather than one duplicated file. Needs a DECISION on what a draft's identity is before any code: a stable id reused at send, a stable id discarded at send, or the status quo. Neither `ComposeContext` nor `OutgoingMessage` has a field to carry an id, so it is not a changed call site | -| 166 | Mail you send to your own other account loses `inbox` | defect | S | open, found 2026-08-25. Two-repo change: the carve-out lives in the `post-new` hook, mirrored in mailctl's `mailrules.py` | +| 166 | Mail you send to your own other account loses `inbox` | defect | S | open, found 2026-08-25. Wholly this repo's: the hooks live in `assets/hooks/` with their own suites, and the live `post-new` symlinks to them | | 167 | No way to tell one build of an unreleased version from another | enhancement | XS | open, found 2026-08-25 from the notes, unrecorded until now. `src/version.h.in` interpolates `PROJECT_VERSION` alone, so every build between two releases reports the same string and a rebuilt binary cannot be told from the one it replaced. The user runs unreleased builds daily, which is when it matters. Needs a DECISION on the source: the git describe/short hash (accurate, needs the build to re-run cmake to pick up a new commit) or a monotonic counter (always moves, means nothing on its own) | Sizes are rough: XS under an hour, S a sitting, M a session. @@ -586,9 +586,9 @@ Option 1 is the one that makes the code true to what it already says it does. **Constraints.** -- **This is a two-repo change.** The carve-out is in this repo's `post-new`, - but the shared rule machinery is mirrored in `../mailctl`. Read "Changing the - shared rule format" in CLAUDE.md before touching it, and run both suites. +- **The hook is this repo's**, `assets/hooks/post-new`, which the live + `database.hook_dir` symlinks to. It has its own suites beside it; run + `./test_post_new.py` and `./test_mailrules.py` from `assets/hooks/`. - The hook **tags real mail unattended, every ten minutes, on the user's live index.** A predicate that is wrong in the other direction would strip `inbox` from arriving mail, which is the failure mode PROTECTED_REMOVALS exists to |
