aboutsummaryrefslogtreecommitdiffstats
path: root/CLAUDE.md
blob: eb473228f6891765735529a7db4e3e5525d57aad (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
# 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.

**This tool has a sibling, and one file couples them.** `qtmaildir`
(`../qtmaildir`) is a Qt6 GUI over the same notmuch index. The two are
independent except for `~/.config/mailrules/rules.json`, the auto-tagging rule
store: `mailrules.py` here and `src/tagrules.cpp` there are **two independent
implementations of one format**, agreeing by test rather than by shared code.
Both preserve fields they do not understand, which is what keeps the file owned
by neither.

Consequences for work in this repo:

- **A change to the rule format is a two-repo change.** A field added here and
  not there is silently dropped the next time the other tool saves. The full
  procedure is under "Changing the shared rule format" in
  `../qtmaildir/CLAUDE.md`, and the design is
  `../qtmaildir/docs/superpowers/specs/2026-08-12-tagging-rules-design.md`.
- **`post-new` is live.** It is installed at `<database.path>/.notmuch/hooks/`
  and runs on every sync, so a change here reaches real mail on a ten-minute
  timer, not when someone next runs a command. `post-new.shell-backup` beside it
  is the original hand-written hook, kept as the escape hatch.
- **Two hook properties are safety-critical.** It refuses to remove `unread` or
  `inbox`, and it does not consume the `tag:new` marker when the rules fail to
  load. The reasoning is in the hook's own comments; weaken neither without
  reading them.
- **Rule editing stays out of this tool**, deliberately. `mailrules.save()`
  exists because both tools must agree on the write semantics, but no CLI
  surface reaches it: a rule edit is a mutation whose blast radius is every
  future sync, and the gate for that is not designed. qtmaildir has the editor.

Nothing else is shared. This tool never imports from qtmaildir, and qtmaildir
never invokes it.

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