mailctl
A deliberately narrow, agent-safe CLI wrapper around notmuch, 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, removinginbox) need--applyand--confirm-destructive. - Every applied mutation is audited to
~/.local/state/mailctl/audit.log. - Drafting is possible, sending is not.
mailctl draftwrites a local-only message into the account's Drafts folder for you to review and send yourself in neomutt.
Requirements
notmuchonPATH, 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:
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):
{
"accounts": {
"work": {
"maildir": "work-mbsync-dir",
"address": "you@example.org",
"drafts": "Drafts"
},
"personal": {
"maildir": "personal-mbsync-dir",
"address": "you@example.net",
"drafts": null
}
}
}
maildiris the account's subdirectory under~/Mail, exactly as mbsync created it.addressis the real From address, used only when writing a draft.draftsis the Drafts folder name inside that maildir, matching the account's mbsyncPatterns(Drafts,[Gmail]/Bozze, ...). Usenull, or omit it, if that account has no synced Drafts folder;mailctl draftthen 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
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
# 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. <danix@danix.xyz>
Released under the GNU General Public License, version 2 (GPLv2-only). See
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.
