# 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` no longer lives here.** It moved to the companion project
`qtmaildir`, at `assets/mailsync.sh`. It never belonged in this repo:
`mailctl.py` does not call it, while qtmaildir runs it as a subprocess and
depends on how it behaves, so the constraints on the script are all over
there. Sync and organization stay separate here exactly as before; there is
simply nothing to sync with in this tree.

## Installation

No installer, just copy files into place:

```bash
cp mailctl.py mailrules.py ~/bin/          # executable + module (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
./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.

## 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`.
- **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

- `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`).
