aboutsummaryrefslogtreecommitdiffstats
path: root/README.md
blob: 8067f833a1be88619da1520d90d811f2b87a5537 (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
117
118
119
120
121
122
123
124
125
126
127
128
129
130
# 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.