diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-12 11:42:14 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-12 11:42:14 +0200 |
| commit | f1890fc7b5fe1094445ce8b83c2d2f15258fceec (patch) | |
| tree | 6caf922da23b5a17ee758402b5687fe0a305750c | |
| parent | f2feb50773b945aafed896b7d75e66035315aa5b (diff) | |
| download | mailctl-f1890fc7b5fe1094445ce8b83c2d2f15258fceec.tar.gz mailctl-f1890fc7b5fe1094445ce8b83c2d2f15258fceec.zip | |
feat(rules): read-only rules list and show
Editing stays out of this tool: a rule edit affects every future sync
and its gate is not designed yet. qtmaildir has the editor.
The install step now copies mailrules.py beside mailctl.py. The import
resolves from the script's own directory, so copying only mailctl.py
would break every command, not just the new ones.
| -rw-r--r-- | CLAUDE.md | 11 | ||||
| -rw-r--r-- | README.md | 9 | ||||
| -rwxr-xr-x | mailctl.py | 50 |
3 files changed, 68 insertions, 2 deletions
@@ -18,7 +18,7 @@ simply nothing to sync with in this tree. No installer, just copy files into place: ```bash -cp mailctl.py ~/bin/ # executable (skill calls ~/bin/mailctl.py) +cp mailctl.py mailrules.py ~/bin/ # executable + module (skill calls ~/bin/mailctl.py) cp -r mail-organize ~/.claude/skills/ # skill in its own directory ``` @@ -36,6 +36,9 @@ The skill's `allowed-tools` expects the tool at `~/bin/mailctl.py`; keep that pa ./mailctl.py subjects QUERY [--account NAME] [--top N] # subject terms ranked by thread count ./mailctl.py tag QUERY --account NAME --add work --remove inbox # dry-run ./mailctl.py tag QUERY --account NAME --add work --apply # commit +./mailctl.py rules list [--enabled-only] # shared tagging rules, read-only +./mailctl.py rules show <id> +./mailctl.py rules dry-run [<id>] # what each rule matches now ``` Requires `notmuch` on PATH and a synced Maildir at `~/Mail`. No build, no deps beyond the stdlib and the `notmuch` binary. The only test is `./test_mailctl.py` (plain asserts, no framework), covering the `senders` address-merge and `subjects` term-counting logic. @@ -49,6 +52,12 @@ These invariants are the point of the tool. Preserve them when editing: - **Destructive changes need a second gate.** Adding a tag in `DESTRUCTIVE_TAGS` (`deleted`/`trash`/`spam`) or removing one in `PROTECTED_REMOVALS` (`inbox`) requires `--apply` AND `--confirm-destructive`. - **Bulk mutations are capped.** `tag --apply` aborts if the match count exceeds `--max-messages` (default `DEFAULT_MAX_MESSAGES`, 5000). Raise the flag to override for a deliberate large batch. - **Every applied mutation is audited** to `~/.local/state/mailctl/audit.log` (tab-separated, timestamped) via `log_mutation`. +- **Rules are read-only from this tool.** `mailctl rules` lists, shows and + dry-runs the shared store at `~/.config/mailrules/rules.json`, and cannot + edit it. A rule edit is a mutation whose blast radius is every future sync, + and the gate for that is not designed yet; qtmaildir has the editor. + `mailrules.save()` exists because the format's write semantics must be + shared, but no CLI surface reaches it. ## Key structures @@ -23,10 +23,14 @@ An agent given raw `notmuch`/`mbsync` access can do anything, including mangle t No installer, just copy files into place: ```bash -cp mailctl.py ~/bin/ # executable +cp mailctl.py mailrules.py ~/bin/ # executable, plus the module it imports cp -r mail-organize ~/.claude/skills/ # Claude Code skill, in its own dir ``` +`mailrules.py` is copied beside `mailctl.py`, not instead of it: `mailctl rules` +imports it, and Python resolves that import from the directory the script lives +in. Copying only `mailctl.py` leaves every command failing at startup. + The skill invokes the tool at `~/bin/mailctl.py`; keep that path. ## Configuration @@ -69,6 +73,9 @@ mailctl tag "<query>" --account NAME [--add TAG]... [--remove TAG]... [--apply] [--confirm-destructive] mailctl draft --account NAME --to ADDR --subject TEXT [--cc ADDR] [--body TEXT | --body-file PATH] +mailctl rules list [--enabled-only] # shared tagging rules, read-only +mailctl rules show <id> +mailctl rules dry-run [<id>] # what each rule matches now ``` Reads default to global scope (all accounts) when `--account` is omitted. `tag` refuses to run without either `--account NAME` or `--all-accounts`. @@ -59,6 +59,8 @@ from email.message import EmailMessage from email.utils import make_msgid, formatdate from pathlib import Path +import mailrules + AUDIT_LOG = Path.home() / ".local" / "state" / "mailctl" / "audit.log" MAIL_ROOT = Path(os.environ.get("MAILCTL_MAIL_ROOT", Path.home() / "Mail")) @@ -472,6 +474,41 @@ def log_mutation(scope, query, tag_expr, count): f"change={' '.join(tag_expr)}\tcount={count}\n") +def cmd_rules_list(args): + store = mailrules.load() + for warning in store.warnings: + print(f"warning: {warning}", file=sys.stderr) + if store.missing: + print(f"No rules file at {mailrules.default_path()}", file=sys.stderr) + return + for rule in mailrules.ordered(store.rules) if args.enabled_only \ + else store.rules: + state = " " if rule.enabled else "-" + tags = " ".join(mailrules.tag_arguments(rule)) + print(f"{state} {rule.stage:>4} {rule.id:<28.28} {tags}") + total = len(store.rules) + disabled = sum(1 for r in store.rules if not r.enabled) + print(f"\n{total} rule(s), {disabled} disabled", file=sys.stderr) + + +def cmd_rules_show(args): + store = mailrules.load() + for rule in store.rules: + if rule.id != args.id: + continue + print(f"id: {rule.id}") + print(f"stage: {rule.stage}") + print(f"enabled: {rule.enabled}") + print(f"add: {', '.join(rule.add) or '(none)'}") + print(f"remove: {', '.join(rule.remove) or '(none)'}") + print(f"query: {rule.query}") + if rule.note: + print(f"note: {rule.note}") + return + print(f"No rule with id '{args.id}'", file=sys.stderr) + sys.exit(1) + + def build_parser(): p = argparse.ArgumentParser( prog="mailctl", @@ -550,6 +587,19 @@ def build_parser(): sp.add_argument("--body-file", help="read body from a file instead") sp.set_defaults(func=cmd_draft) + sp = sub.add_parser("rules", help="inspect the shared tagging rules " + "(read-only)") + rules_sub = sp.add_subparsers(dest="rules_command", required=True) + + rp = rules_sub.add_parser("list", help="list every rule in stage order") + rp.add_argument("--enabled-only", action="store_true", + help="only the rules the hook would run") + rp.set_defaults(func=cmd_rules_list) + + rp = rules_sub.add_parser("show", help="show one rule in full") + rp.add_argument("id") + rp.set_defaults(func=cmd_rules_show) + return p |
