diff options
| -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 |
