aboutsummaryrefslogtreecommitdiffstats

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, 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:

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
    }
  }
}
  • 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

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.