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 mailsync.sh ~/bin/ # executables
cp -r mail-organize ~/.claude/skills/ # Claude Code skill, in its own dir
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]
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 runs mbsync -a followed by notmuch new, with a flock guard and timestamped, rotated logging. It is meant for a cron/systemd timer and is not called by mailctl.py; sync and organization stay separate on purpose.
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.
