# mailctl

A deliberately narrow, agent-safe CLI wrapper around [notmuch](https://notmuchmail.org/), meant for use by AI coding agents (Claude Code, opencode, etc.) to **search and organize** local mail. It has no ability to send: there is no SMTP, reply, or compose code path in the tool at all.

## Why

An agent given raw `notmuch`/`mbsync` access can do anything, including mangle tags across every account or trigger a sync at the wrong moment. `mailctl` closes that surface:

- **Reads run freely**, mutations are gated.
- **Tag changes are dry-run by default**, and only touch the index with `--apply`.
- **Cross-account mutations require an explicit opt-in** (`--all-accounts`); the default forces a single `--account`.
- **Destructive changes** (adding `deleted`/`trash`/`spam`, removing `inbox`) need `--apply` **and** `--confirm-destructive`.
- **Every applied mutation is audited** to `~/.local/state/mailctl/audit.log`.
- **Drafting is possible, sending is not.** `mailctl draft` writes a local-only message into the account's Drafts folder for you to review and send yourself in neomutt.

## Requirements

- `notmuch` on `PATH`, with a synced Maildir at `~/Mail` (see Configuration).
- Python 3 (standard library only, no third-party packages).

## Installation

No installer, just copy files into place:

```bash
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

Your accounts are not in the source. `mailctl` reads them from `~/.config/mailctl/accounts.json` (override with the `MAILCTL_CONFIG` environment variable):

```json
{
  "accounts": {
    "work": {
      "maildir": "work-mbsync-dir",
      "address": "you@example.org",
      "drafts": "Drafts"
    },
    "personal": {
      "maildir": "personal-mbsync-dir",
      "address": "you@example.net",
      "drafts": null
    }
  }
}
```

- `maildir` is the account's subdirectory under `~/Mail`, exactly as mbsync created it.
- `address` is the real From address, used only when writing a draft.
- `drafts` is the Drafts folder name inside that maildir, matching the account's mbsync `Patterns` (`Drafts`, `[Gmail]/Bozze`, ...). Use `null`, or omit it, if that account has no synced Drafts folder; `mailctl draft` then refuses that account.

The account map stays a closed set. The config is checked against its schema **and** against the actual directories on disk when `mailctl` starts, and any problem is a hard exit before a query runs: a misspelled `maildir` can't silently produce a filter matching nothing, and a misspelled `drafts` can't put a draft somewhere mbsync never syncs. Set `MAILCTL_MAIL_ROOT` if your Maildir is not at `~/Mail`.

## Usage

```bash
mailctl search "<notmuch query>" [--account NAME] [--json]
mailctl show   "<thread:id or id:msgid>"
mailctl tags                                     # list all tags in use
mailctl count  "<query>" [--account NAME]
mailctl senders "<query>" [--account NAME] [--top N] [--json]   # ranked by count
mailctl subjects "<query>" [--account NAME] [--top N] [--json]  # subject terms
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`.

### Example

```bash
# Preview: what would tagging these as 'newsletter' touch?
mailctl tag "from:substack.com" --account personal --add newsletter

# Commit it after reviewing the preview
mailctl tag "from:substack.com" --account personal --add newsletter --apply
```

## Companion sync script

`mailsync.sh` used to live here. It now lives in the companion project
**qtmaildir**, at `assets/mailsync.sh`, which is where to look for it and where
to change it.

It runs `mbsync -a` followed by `notmuch new`, with a `flock` guard and
timestamped, rotated logging, and is meant for a cron/systemd timer. It is still
**not** called by `mailctl.py`: sync and organization stay separate on purpose,
which is precisely why it moved. qtmaildir runs it as a subprocess and depends
on how it behaves, so the script and the constraints on it belong together.

Two of those constraints are worth knowing if you edit it: it must print to
stdout as well as its log file, or qtmaildir's sync pane shows nothing, and it
must exit with the real status, or a failed sync is reported as a clean one.

## Claude Code skill

`mail-organize/` is a Claude Code skill that makes `mailctl` the only sanctioned interface for an agent to touch your mail, and tightens the tool's gates into workflow rules (always dry-run first, never sync, never send, single-account scope). See `mail-organize/SKILL.md`.

## License

Copyright (C) 2026 Danilo M. &lt;danix@danix.xyz&gt;

Released under the **GNU General Public License, version 2** (GPLv2-only). See
[`LICENSE`](LICENSE) for the full text.

This program is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE.

## Development Approach

This project is developed using AI-assisted tools. Code is generated with the help of AI based on human-provided specifications, design decisions, and iterative feedback.

All contributions are reviewed, tested, and curated by the maintainer before being included in the codebase. AI is used as a productivity and exploration tool, while human oversight remains central to all decisions.

The goal is to combine the flexibility of AI-assisted development with standard open-source practices such as transparency, review, and accountability.
