aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CLAUDE.md11
-rw-r--r--README.md9
-rwxr-xr-xmailctl.py50
3 files changed, 68 insertions, 2 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index 24fdf21..db9ca07 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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
diff --git a/README.md b/README.md
index c33a5f2..8067f83 100644
--- a/README.md
+++ b/README.md
@@ -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`.
diff --git a/mailctl.py b/mailctl.py
index be4cbcf..ea31286 100755
--- a/mailctl.py
+++ b/mailctl.py
@@ -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