# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## What this is `mailctl.py` is a deliberately narrow, agent-safe CLI wrapper around `notmuch`. It exists so an agent can search and organize local mail **without any ability to send**. There is no SMTP, reply, or compose code path in the tool. Adding one violates the core design; do not add send capability. `mailsync.sh` is the separate sync driver (mbsync + `notmuch new`), meant for cron/systemd, not called by `mailctl.py`. ## Installation No installer, just copy files into place: ```bash cp mailctl.py mailsync.sh ~/bin/ # executables (skill calls ~/bin/mailctl.py) cp -r mail-organize ~/.claude/skills/ # skill in its own directory ``` The skill's `allowed-tools` expects the tool at `~/bin/mailctl.py`; keep that path. ## Running ```bash ./mailctl.py search "from:foo and tag:inbox" # global read ./mailctl.py search QUERY --account NAME # scoped read ./mailctl.py count QUERY [--account NAME] ./mailctl.py show thread:0000... | id:... ./mailctl.py tags # list all tags in use ./mailctl.py senders QUERY [--account NAME] [--top N] # senders ranked by count ./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 ``` 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. ## Safety model (the reason the code is shaped this way) These invariants are the point of the tool. Preserve them when editing: - **Reads are free, mutations are gated.** `search`/`show`/`tags`/`count`/`senders`/`subjects` take an optional `--account` (default = global across all mailboxes). `tag` refuses to run without either `--account NAME` or the explicit `--all-accounts`, so a cross-account mutation is never accidental. - **Tag changes are dry-run by default.** `tag` prints what would change and only touches the index with `--apply`. - **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`. ## Key structures - `ACCOUNTS` / `DRAFTS_SUBDIR`: built at import time by `load_accounts()` from `~/.config/mailctl/accounts.json` (`MAILCTL_CONFIG` overrides the path, `MAILCTL_MAIL_ROOT` the maildir root). **The real addresses and maildir names are not in the repo**, which is what lets the source be published. Same shapes as before: account key → (`~/Mail` subdir, From address), and account key → Drafts folder name or `None`. - `validate_accounts()` is the gate that replaces hardcoding. It checks schema (unknown/missing fields, key charset, address form, no absolute or `..` maildir, no duplicate maildirs) **and** the filesystem (the maildir and any Drafts dir must exist), then exits 2 with a message naming the account and field. It runs at import, before `build_parser()` reads `ACCOUNTS` for its `choices=`, so a bad config can never reach a query or a draft. Do not make this lazy or non-fatal. - `scoped_query` turns an account key into a `path:"subdir/**" and (query)` filter. - `cmd_draft` writes a **local-only** draft into the account's Drafts maildir using the atomic tmp/→rename→new/ pattern. It never sends; the draft reaches the server only on the next mbsync run. (Note: `draft` isn't listed in "Running" above because it's an outbound-adjacent path; it still writes nothing to the network.) Adding an account is a config edit, not a code edit: add an entry to `accounts.json` and run any command; validation reports a wrong maildir or Drafts name immediately. `test_mailctl.py` builds a throwaway maildir tree and config in a tempdir and sets `MAILCTL_CONFIG`/`MAILCTL_MAIL_ROOT` **before** importing `mailctl`, because the import validates. Keep that ordering when editing the tests. ## Skill: mail-organize `mail-organize/SKILL.md` is a project skill governing **all** agent interaction with the user's mail. When a task involves searching, reviewing, tagging, archiving, or drafting the user's email, invoke this skill and follow it; it is the only sanctioned interface (via `~/bin/mailctl.py`), not raw `notmuch`/`mbsync`. Its hard rules mirror and tighten the tool's own gates: - Never send/deliver mail; draft instead (a draft is not a send). - Never sync (`mbsync`/`mailsync.sh`/`notmuch new`) — that's the user's hourly cron; ask them if the index seems stale. - Scope `tag` mutations to one `--account`; don't use `--all-accounts` unless the user explicitly asked this turn. - Always dry-run and show the preview before `--apply`; never pass `--confirm-destructive` on your own judgment. - Global search (no `--account`) is fine — it's read-only. ## Known gaps (from the module docstring) - Audit log doesn't distinguish agent-run vs user-run (no `--actor`).