aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore4
-rw-r--r--CLAUDE.md71
-rw-r--r--LICENSE338
-rw-r--r--README.md111
-rw-r--r--TODO.md46
-rw-r--r--mail-organize/SKILL.md136
-rwxr-xr-xmailctl.py563
-rwxr-xr-xmailsync.sh67
-rwxr-xr-xtest_mailctl.py252
9 files changed, 1588 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..5a2009b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+__pycache__/
+
+# Local-only: real sender addresses and mail history, not for the repo.
+HOOK-RULES.md
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..1cf9b0a
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,71 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## What this is
+
+`mailctl.py` is a deliberately narrow, agent-safe CLI wrapper around `notmuch`. It exists so an agent can search and organize local mail **without any ability to send**. There is no SMTP, reply, or compose code path in the tool. Adding one violates the core design; do not add send capability.
+
+`mailsync.sh` is the separate sync driver (mbsync + `notmuch new`), meant for cron/systemd, not called by `mailctl.py`.
+
+## Installation
+
+No installer, just copy files into place:
+
+```bash
+cp mailctl.py mailsync.sh ~/bin/ # executables (skill calls ~/bin/mailctl.py)
+cp -r mail-organize ~/.claude/skills/ # skill in its own directory
+```
+
+The skill's `allowed-tools` expects the tool at `~/bin/mailctl.py`; keep that path.
+
+## Running
+
+```bash
+./mailctl.py search "from:foo and tag:inbox" # global read
+./mailctl.py search QUERY --account NAME # scoped read
+./mailctl.py count QUERY [--account NAME]
+./mailctl.py show thread:0000... | id:...
+./mailctl.py tags # list all tags in use
+./mailctl.py senders QUERY [--account NAME] [--top N] # senders ranked by count
+./mailctl.py subjects QUERY [--account NAME] [--top N] # subject terms ranked by thread count
+./mailctl.py tag QUERY --account NAME --add work --remove inbox # dry-run
+./mailctl.py tag QUERY --account NAME --add work --apply # commit
+```
+
+Requires `notmuch` on PATH and a synced Maildir at `~/Mail`. No build, no deps beyond the stdlib and the `notmuch` binary. The only test is `./test_mailctl.py` (plain asserts, no framework), covering the `senders` address-merge and `subjects` term-counting logic.
+
+## Safety model (the reason the code is shaped this way)
+
+These invariants are the point of the tool. Preserve them when editing:
+
+- **Reads are free, mutations are gated.** `search`/`show`/`tags`/`count`/`senders`/`subjects` take an optional `--account` (default = global across all mailboxes). `tag` refuses to run without either `--account NAME` or the explicit `--all-accounts`, so a cross-account mutation is never accidental.
+- **Tag changes are dry-run by default.** `tag` prints what would change and only touches the index with `--apply`.
+- **Destructive changes need a second gate.** Adding a tag in `DESTRUCTIVE_TAGS` (`deleted`/`trash`/`spam`) or removing one in `PROTECTED_REMOVALS` (`inbox`) requires `--apply` AND `--confirm-destructive`.
+- **Bulk mutations are capped.** `tag --apply` aborts if the match count exceeds `--max-messages` (default `DEFAULT_MAX_MESSAGES`, 5000). Raise the flag to override for a deliberate large batch.
+- **Every applied mutation is audited** to `~/.local/state/mailctl/audit.log` (tab-separated, timestamped) via `log_mutation`.
+
+## Key structures
+
+- `ACCOUNTS` / `DRAFTS_SUBDIR`: built at import time by `load_accounts()` from `~/.config/mailctl/accounts.json` (`MAILCTL_CONFIG` overrides the path, `MAILCTL_MAIL_ROOT` the maildir root). **The real addresses and maildir names are not in the repo**, which is what lets the source be published. Same shapes as before: account key → (`~/Mail` subdir, From address), and account key → Drafts folder name or `None`.
+- `validate_accounts()` is the gate that replaces hardcoding. It checks schema (unknown/missing fields, key charset, address form, no absolute or `..` maildir, no duplicate maildirs) **and** the filesystem (the maildir and any Drafts dir must exist), then exits 2 with a message naming the account and field. It runs at import, before `build_parser()` reads `ACCOUNTS` for its `choices=`, so a bad config can never reach a query or a draft. Do not make this lazy or non-fatal.
+- `scoped_query` turns an account key into a `path:"subdir/**" and (query)` filter.
+- `cmd_draft` writes a **local-only** draft into the account's Drafts maildir using the atomic tmp/→rename→new/ pattern. It never sends; the draft reaches the server only on the next mbsync run. (Note: `draft` isn't listed in "Running" above because it's an outbound-adjacent path; it still writes nothing to the network.)
+
+Adding an account is a config edit, not a code edit: add an entry to `accounts.json` and run any command; validation reports a wrong maildir or Drafts name immediately.
+
+`test_mailctl.py` builds a throwaway maildir tree and config in a tempdir and sets `MAILCTL_CONFIG`/`MAILCTL_MAIL_ROOT` **before** importing `mailctl`, because the import validates. Keep that ordering when editing the tests.
+
+## Skill: mail-organize
+
+`mail-organize/SKILL.md` is a project skill governing **all** agent interaction with the user's mail. When a task involves searching, reviewing, tagging, archiving, or drafting the user's email, invoke this skill and follow it; it is the only sanctioned interface (via `~/bin/mailctl.py`), not raw `notmuch`/`mbsync`. Its hard rules mirror and tighten the tool's own gates:
+
+- Never send/deliver mail; draft instead (a draft is not a send).
+- Never sync (`mbsync`/`mailsync.sh`/`notmuch new`) — that's the user's hourly cron; ask them if the index seems stale.
+- Scope `tag` mutations to one `--account`; don't use `--all-accounts` unless the user explicitly asked this turn.
+- Always dry-run and show the preview before `--apply`; never pass `--confirm-destructive` on your own judgment.
+- Global search (no `--account`) is fine — it's read-only.
+
+## Known gaps (from the module docstring)
+
+- Audit log doesn't distinguish agent-run vs user-run (no `--actor`).
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..9efa6fb
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,338 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 2, June 1991
+
+ Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
+ <https://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The licenses for most software are designed to take away your
+freedom to share and change it. By contrast, the GNU General Public
+License is intended to guarantee your freedom to share and change free
+software--to make sure the software is free for all its users. This
+General Public License applies to most of the Free Software
+Foundation's software and to any other program whose authors commit to
+using it. (Some other Free Software Foundation software is covered by
+the GNU Lesser General Public License instead.) You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+this service if you wish), that you receive source code or can get it
+if you want it, that you can change the software or use pieces of it
+in new free programs; and that you know you can do these things.
+
+ To protect your rights, we need to make restrictions that forbid
+anyone to deny you these rights or to ask you to surrender the rights.
+These restrictions translate to certain responsibilities for you if you
+distribute copies of the software, or if you modify it.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must give the recipients all the rights that
+you have. You must make sure that they, too, receive or can get the
+source code. And you must show them these terms so they know their
+rights.
+
+ We protect your rights with two steps: (1) copyright the software, and
+(2) offer you this license which gives you legal permission to copy,
+distribute and/or modify the software.
+
+ Also, for each author's protection and ours, we want to make certain
+that everyone understands that there is no warranty for this free
+software. If the software is modified by someone else and passed on, we
+want its recipients to know that what they have is not the original, so
+that any problems introduced by others will not reflect on the original
+authors' reputations.
+
+ Finally, any free program is threatened constantly by software
+patents. We wish to avoid the danger that redistributors of a free
+program will individually obtain patent licenses, in effect making the
+program proprietary. To prevent this, we have made it clear that any
+patent must be licensed for everyone's free use or not licensed at all.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ GNU GENERAL PUBLIC LICENSE
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+ 0. This License applies to any program or other work which contains
+a notice placed by the copyright holder saying it may be distributed
+under the terms of this General Public License. The "Program", below,
+refers to any such program or work, and a "work based on the Program"
+means either the Program or any derivative work under copyright law:
+that is to say, a work containing the Program or a portion of it,
+either verbatim or with modifications and/or translated into another
+language. (Hereinafter, translation is included without limitation in
+the term "modification".) Each licensee is addressed as "you".
+
+Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope. The act of
+running the Program is not restricted, and the output from the Program
+is covered only if its contents constitute a work based on the
+Program (independent of having been made by running the Program).
+Whether that is true depends on what the Program does.
+
+ 1. You may copy and distribute verbatim copies of the Program's
+source code as you receive it, in any medium, provided that you
+conspicuously and appropriately publish on each copy an appropriate
+copyright notice and disclaimer of warranty; keep intact all the
+notices that refer to this License and to the absence of any warranty;
+and give any other recipients of the Program a copy of this License
+along with the Program.
+
+You may charge a fee for the physical act of transferring a copy, and
+you may at your option offer warranty protection in exchange for a fee.
+
+ 2. You may modify your copy or copies of the Program or any portion
+of it, thus forming a work based on the Program, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+ a) You must cause the modified files to carry prominent notices
+ stating that you changed the files and the date of any change.
+
+ b) You must cause any work that you distribute or publish, that in
+ whole or in part contains or is derived from the Program or any
+ part thereof, to be licensed as a whole at no charge to all third
+ parties under the terms of this License.
+
+ c) If the modified program normally reads commands interactively
+ when run, you must cause it, when started running for such
+ interactive use in the most ordinary way, to print or display an
+ announcement including an appropriate copyright notice and a
+ notice that there is no warranty (or else, saying that you provide
+ a warranty) and that users may redistribute the program under
+ these conditions, and telling the user how to view a copy of this
+ License. (Exception: if the Program itself is interactive but
+ does not normally print such an announcement, your work based on
+ the Program is not required to print an announcement.)
+
+These requirements apply to the modified work as a whole. If
+identifiable sections of that work are not derived from the Program,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works. But when you
+distribute the same sections as part of a whole which is a work based
+on the Program, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Program.
+
+In addition, mere aggregation of another work not based on the Program
+with the Program (or with a work based on the Program) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+ 3. You may copy and distribute the Program (or a work based on it,
+under Section 2) in object code or executable form under the terms of
+Sections 1 and 2 above provided that you also do one of the following:
+
+ a) Accompany it with the complete corresponding machine-readable
+ source code, which must be distributed under the terms of Sections
+ 1 and 2 above on a medium customarily used for software interchange; or,
+
+ b) Accompany it with a written offer, valid for at least three
+ years, to give any third party, for a charge no more than your
+ cost of physically performing source distribution, a complete
+ machine-readable copy of the corresponding source code, to be
+ distributed under the terms of Sections 1 and 2 above on a medium
+ customarily used for software interchange; or,
+
+ c) Accompany it with the information you received as to the offer
+ to distribute corresponding source code. (This alternative is
+ allowed only for noncommercial distribution and only if you
+ received the program in object code or executable form with such
+ an offer, in accord with Subsection b above.)
+
+The source code for a work means the preferred form of the work for
+making modifications to it. For an executable work, complete source
+code means all the source code for all modules it contains, plus any
+associated interface definition files, plus the scripts used to
+control compilation and installation of the executable. However, as a
+special exception, the source code distributed need not include
+anything that is normally distributed (in either source or binary
+form) with the major components (compiler, kernel, and so on) of the
+operating system on which the executable runs, unless that component
+itself accompanies the executable.
+
+If distribution of executable or object code is made by offering
+access to copy from a designated place, then offering equivalent
+access to copy the source code from the same place counts as
+distribution of the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+ 4. You may not copy, modify, sublicense, or distribute the Program
+except as expressly provided under this License. Any attempt
+otherwise to copy, modify, sublicense or distribute the Program is
+void, and will automatically terminate your rights under this License.
+However, parties who have received copies, or rights, from you under
+this License will not have their licenses terminated so long as such
+parties remain in full compliance.
+
+ 5. You are not required to accept this License, since you have not
+signed it. However, nothing else grants you permission to modify or
+distribute the Program or its derivative works. These actions are
+prohibited by law if you do not accept this License. Therefore, by
+modifying or distributing the Program (or any work based on the
+Program), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Program or works based on it.
+
+ 6. Each time you redistribute the Program (or any work based on the
+Program), the recipient automatically receives a license from the
+original licensor to copy, distribute or modify the Program subject to
+these terms and conditions. You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties to
+this License.
+
+ 7. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Program at all. For example, if a patent
+license would not permit royalty-free redistribution of the Program by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Program.
+
+If any portion of this section is held invalid or unenforceable under
+any particular circumstance, the balance of the section is intended to
+apply and the section as a whole is intended to apply in other
+circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system, which is
+implemented by public license practices. Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+ 8. If the distribution and/or use of the Program is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Program under this License
+may add an explicit geographical distribution limitation excluding
+those countries, so that distribution is permitted only in or among
+countries not thus excluded. In such case, this License incorporates
+the limitation as if written in the body of this License.
+
+ 9. The Free Software Foundation may publish revised and/or new versions
+of the General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Program
+specifies a version number of this License which applies to it and "any
+later version", you have the option of following the terms and conditions
+either of that version or of any later version published by the Free
+Software Foundation. If the Program does not specify a version number of
+this License, you may choose any version ever published by the Free Software
+Foundation.
+
+ 10. If you wish to incorporate parts of the Program into other free
+programs whose distribution conditions are different, write to the author
+to ask for permission. For software which is copyrighted by the Free
+Software Foundation, write to the Free Software Foundation; we sometimes
+make exceptions for this. Our decision will be guided by the two goals
+of preserving the free status of all derivatives of our free software and
+of promoting the sharing and reuse of software generally.
+
+ NO WARRANTY
+
+ 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
+FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
+OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
+PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
+OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
+TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
+PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
+REPAIR OR CORRECTION.
+
+ 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
+REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
+INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
+OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
+TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
+YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
+PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGES.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>
+
+ This program is free software; you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation; either version 2 of the License, or
+ (at your option) any later version.
+
+ 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. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along
+ with this program; if not, see <https://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+If the program is interactive, make it output a short notice like this
+when it starts in an interactive mode:
+
+ Gnomovision version 69, Copyright (C) year name of author
+ Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, the commands you use may
+be called something other than `show w' and `show c'; they could even be
+mouse-clicks or menu items--whatever suits your program.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the program, if
+necessary. Here is a sample; alter the names:
+
+ Yoyodyne, Inc., hereby disclaims all copyright interest in the program
+ `Gnomovision' (which makes passes at compilers) written by James Hacker.
+
+ <signature of Moe Ghoul>, 1 April 1989
+ Moe Ghoul, President of Vice
+
+This General Public License does not permit incorporating your program into
+proprietary programs. If your program is a subroutine library, you may
+consider it more useful to permit linking proprietary applications with the
+library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..3e1ef04
--- /dev/null
+++ b/README.md
@@ -0,0 +1,111 @@
+# 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 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):
+
+```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]
+```
+
+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` 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. &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.
diff --git a/TODO.md b/TODO.md
new file mode 100644
index 0000000..5d59385
--- /dev/null
+++ b/TODO.md
@@ -0,0 +1,46 @@
+# TODO
+
+Open items for mailctl. The three original feature gaps (`--max-messages`,
+`senders`, `subjects`) are implemented and were removed from this list on
+2026-08-01.
+
+The list of concrete senders that warrant a `post-new` hook rule is personal
+data, so it lives in a local-only `HOOK-RULES.md` rather than in the repo. The
+method for deriving it is below.
+
+## 1. `--actor` audit tag (not urgent)
+
+Already noted in the module docstring. Distinguish agent-run from user-run
+mutations in `~/.local/state/mailctl/audit.log`. Matters for trust; has not
+slowed any session's work so far.
+
+## Method notes for writing hook rules
+
+These are the traps found while sweeping a large backlog. They cost real
+mis-tagging when skipped, so they are worth keeping even though the senders
+that produced them are not recorded here.
+
+- **Verify every predicate with `mailctl count` before acting on it.** `from:`
+ matches whole tokens, and local parts split on punctuation, so per-sender
+ probes silently undercount: one bucket of 24 messages returned 16 across ten
+ probed addresses.
+- **Scope vendor rules to local parts, not domains.** A domain that sends
+ marketing usually also sends order confirmations and password resets from
+ other local parts on the same domain. A `from:<domain>` rule sweeps those
+ into promo.
+- **Check for transactional terms before any bulk promo tag.** Marketing
+ senders hide real order, shipping, and billing mail in the same bucket. This
+ caught genuine transactional messages in five separate batches; the one batch
+ where it was skipped produced the only mis-tagging that reached the index.
+- **A sender is not always one rule.** Forum and mailing-list senders mix
+ bulletins, automated notices, and real replies to threads you posted in.
+ Split by subject, or a blanket rule buries correspondence.
+- **Subject substring tests overmatch.** A common word in a subject test can
+ pull in unrelated threads that merely discuss the topic.
+- **Dead senders need no rule.** Most backlog volume comes from senders that
+ stopped mailing years ago. A rule for a dead sender is dead code that whoever
+ edits the hook next still has to read and trust. Check last-seen dates first.
+- **Join multiple `id:` terms in Python, not the shell.** `paste -sd' or '`
+ cycles the two delimiter characters between lines and glues message-ids
+ together, failing silently with a plausible-looking match count. Always
+ assert the match count equals the number of ids.
diff --git a/mail-organize/SKILL.md b/mail-organize/SKILL.md
new file mode 100644
index 0000000..5e30a49
--- /dev/null
+++ b/mail-organize/SKILL.md
@@ -0,0 +1,136 @@
+---
+name: mail-organize
+description: Use this skill whenever the user asks to search, review, tag, label, archive, or otherwise organize their email. Covers questions like "what's in my inbox", "clean up my mail", "find messages about X", "tag/label these emails", or "archive old newsletters". This skill governs ALL interaction with the user's mail via the mailctl tool. Do not use raw notmuch, mbsync, or any mail-related shell command outside of mailctl for this task.
+allowed-tools: Bash(python3 ~/bin/mailctl.py:*)
+---
+
+# Mail organization via mailctl
+
+The user has several mail accounts synced locally via mbsync/notmuch. The
+`mailctl` CLI (`~/bin/mailctl.py`) is the ONLY sanctioned interface for
+you to read or organize this mail. Read the hard rules below before
+running anything.
+
+## Hard rules, no exceptions
+
+1. **Never send or actually deliver mail, under any framing.** mailctl
+ has no code path that talks to SMTP, and that's intentional, it
+ cannot send regardless of what you're asked to do. Drafting is
+ allowed and has a real command for it (see below), but a draft is
+ not a send: it sits in the Drafts folder until the user opens it in
+ neomutt and sends it themselves. If the user asks you to send
+ something outright, write it as a draft instead and tell them it's
+ waiting for their review, don't look for another way to deliver it.
+
+2. **Never sync mail.** Do not run `mbsync`, `mailsync.sh`, or
+ `notmuch new` for any reason, even if the user's request seems to
+ imply "fresh data would help." Mail sync runs on the user's own
+ hourly cron job. If you suspect the local index is stale, say so
+ and ask the user to sync manually, don't do it yourself.
+
+3. **Always scope mutations to a single account**, using
+ `--account <name>`. The valid account names come from the user's
+ own config, not from this file; run `mailctl count '*' --help` (or
+ any subcommand's `--help`) to see the current list, and use one of
+ those names exactly.
+
+ Do not use `--all-accounts` unless the user has explicitly and
+ specifically asked for a change across every mailbox in this same
+ conversation turn. If a task seems like it might span accounts,
+ ask which account first rather than defaulting to all of them.
+
+4. **Always dry-run before applying.** Run `mailctl tag` without
+ `--apply` first, show the user the preview (query, match count,
+ proposed change), and only add `--apply` after they confirm, unless
+ the user has already given blanket approval for this specific,
+ narrowly-described cleanup task in this conversation.
+
+5. **Never pass `--confirm-destructive` on your own judgment.** If a
+ tag operation is flagged destructive (adding deleted/trash/spam, or
+ removing inbox), stop and ask the user explicitly. Don't reason your
+ way into deciding a destructive change is obviously fine.
+
+6. **Never raise `--max-messages` on your own judgment.** `tag --apply`
+ aborts if the match exceeds the cap (default 5000). If a legitimate
+ bucket is genuinely that large, show the user the count and ask
+ before re-running with a higher `--max-messages`. Don't silently bump
+ the cap to push a big batch through.
+
+7. **Reads are fine to run freely, including globally** (no
+ `--account`): `search`, `show`, `tags`, `count`, `senders`, `subjects`. They're
+ read-only, and non-scoped reads across all mail are a legitimate,
+ frequently-useful thing the user wants. The scoping requirement in
+ rule 3 applies to `tag` only.
+
+## Typical workflow
+
+1. Understand what the user wants organized (a sender, a topic, a date
+ range, a specific account). To find *which* senders are clogging a
+ mailbox, use `mailctl senders "<query>" [--account NAME] [--top N]`
+ rather than guessing addresses and probing them one at a time with
+ `count`. When one sender needs splitting (receipts vs marketing),
+ use `mailctl subjects "<query>" --account NAME` to see which terms
+ actually cover the bucket instead of guessing subject keywords.
+2. `mailctl search "<query>" [--account NAME]` to see what matches
+ and confirm the query is catching the right messages, and only
+ those.
+3. `mailctl tag "<query>" --account NAME --add X --remove Y` (no
+ `--apply`) to preview the change.
+4. Show the user the preview verbatim. Wait for confirmation.
+5. Re-run with `--apply` only after confirmation.
+
+## Command reference
+
+```
+mailctl search "<notmuch query>" [--account NAME] [--json]
+mailctl show "<thread:id or id:msgid>"
+mailctl tags
+mailctl count "<query>" [--account NAME]
+mailctl senders "<query>" [--account NAME] [--top N] [--json]
+mailctl subjects "<query>" [--account NAME] [--top N] [--json]
+mailctl tag "<query>" --account NAME [--add TAG]... [--remove TAG]...
+ [--apply] [--confirm-destructive] [--max-messages N]
+mailctl draft --account NAME --to ADDR --subject TEXT
+ [--cc ADDR] [--body TEXT | --body-file PATH]
+```
+
+## Drafting mail
+
+`mailctl draft` writes a real message into the account's local Drafts
+maildir. It does not send anything, ever, there is no `--apply` step
+because a draft has no side effect worth confirming beyond writing it.
+
+Rules specific to drafting:
+
+- `--account` is always required here too, same reasoning as `tag`:
+ the draft's `From` address is derived from the account, so an
+ ambiguous or wrong account means a draft that would send from the
+ wrong identity.
+- After writing a draft, tell the user plainly: it's local until the
+ next mbsync run, it is not sent, and they need to open it in
+ neomutt (that account's Drafts folder, whatever it is named there,
+ or the postponed message list if configured) to review, edit, and
+ send it.
+- If the user's request is vague about recipient or content ("draft
+ something to my accountant about the invoice"), ask for the missing
+ specifics rather than guessing a plausible-sounding email address or
+ inventing content they didn't ask for.
+- Never draft and then take any further action on that draft, no
+ tagging it, no re-reading it back to "double check", the task ends
+ when the draft is written and reported.
+
+Notmuch query syntax reference: `from:`, `to:`, `subject:`, `tag:`,
+`date:`, boolean `and`/`or`/`not`. When in doubt about whether a query
+is too broad, run `count` first before `tag`.
+
+## What NOT to do, even if it seems helpful
+
+- Don't infer that a broad cleanup request ("archive my old newsletters")
+ means every account, ask which account or offer to do them one at a
+ time.
+- Don't chain multiple `--apply` tag operations without showing the
+ user each preview individually, batch confirmation of unreviewed
+ changes defeats the point of the dry-run step.
+- Don't try to work around the no-sync rule by suggesting the user run
+ a sync "so I can help better" unless they ask whether fresher data
+ would help, that's information, not you initiating an action.
diff --git a/mailctl.py b/mailctl.py
new file mode 100755
index 0000000..be4cbcf
--- /dev/null
+++ b/mailctl.py
@@ -0,0 +1,563 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# 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. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+"""
+mailctl - a deliberately narrow CLI wrapper around notmuch, meant for
+agent use (Claude Code, opencode, etc.) to search and organize mail.
+
+Design goals:
+ - No SMTP, no send, no reply, no compose. Not "discouraged", not present
+ in the code at all. An agent can't do what the tool has no code path for.
+ - Read operations (search/show/tags/count/senders/subjects) run freely, --account is
+ optional there, defaulting to a global search across all mailboxes.
+ - Mutating operations (tag) are scoped to a single account by default.
+ Touching more than one account requires the explicit --all-accounts
+ flag, there's no accidental global mutation.
+ - Tag mutations default to dry-run: they print what WOULD change and
+ require --apply to actually touch the index.
+ - Anything that looks destructive (removing 'inbox', adding 'deleted' or
+ 'trash'/'spam') requires --apply AND --confirm-destructive, and gets
+ logged regardless of account scope.
+ - Every applied mutation is appended to ~/.local/state/mailctl/audit.log
+ with a timestamp, so there's a plain-text trail of what an agent changed.
+
+ - The account map (maildir names and real From addresses) is NOT in this
+ file. It loads from ~/.config/mailctl/accounts.json, override with
+ MAILCTL_CONFIG. The config is validated against its schema and against
+ the actual maildirs on disk at import time; any problem is a hard exit
+ before argparse runs, so a typo can't reach a query or a draft.
+
+This is a starting skeleton, not a finished tool. In particular:
+ - The audit log doesn't distinguish "run by agent" vs "run by you",
+ worth adding an --actor tag if that distinction matters to you.
+"""
+
+import argparse
+import json
+import os
+import re
+import socket
+import subprocess
+import sys
+import time
+from collections import Counter
+from datetime import datetime, timezone
+from email.message import EmailMessage
+from email.utils import make_msgid, formatdate
+from pathlib import Path
+
+AUDIT_LOG = Path.home() / ".local" / "state" / "mailctl" / "audit.log"
+MAIL_ROOT = Path(os.environ.get("MAILCTL_MAIL_ROOT", Path.home() / "Mail"))
+
+CONFIG_PATH = Path(
+ os.environ.get("MAILCTL_CONFIG",
+ Path.home() / ".config" / "mailctl" / "accounts.json")
+)
+
+# The account map is real addresses and maildir names, so it lives outside the
+# repo. Same closed-set guarantee as when it was hardcoded: it is validated
+# against the schema AND against the disk at import time, and mailctl refuses
+# to run at all if anything is off. A typo can't silently produce a path
+# filter that matches nothing, or a draft sent from the wrong identity.
+ACCOUNT_KEY_RE = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
+ADDRESS_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
+ACCOUNT_FIELDS = {"maildir", "address", "drafts"}
+
+
+def config_error(problem, *hints):
+ """Refuse to run. Config problems are typos in the user's own file, so the
+ message names the file, the problem, and how to fix it."""
+ print(f"mailctl: bad config at {CONFIG_PATH}\n {problem}", file=sys.stderr)
+ for h in hints:
+ print(f" {h}", file=sys.stderr)
+ sys.exit(2)
+
+
+def validate_accounts(raw, mail_root):
+ """Check a parsed config against the schema and the filesystem.
+
+ Returns (accounts, drafts_subdir) in the same shape the rest of the tool
+ used when these were module constants. Calls config_error (exits) on the
+ first problem found.
+ """
+ if not isinstance(raw, dict):
+ config_error(f"top level must be a JSON object, got {type(raw).__name__}",
+ 'example: {"accounts": {"work": {...}}}')
+ accounts_raw = raw.get("accounts")
+ if accounts_raw is None:
+ config_error('missing top-level "accounts" key',
+ f"found instead: {', '.join(sorted(raw)) or '(empty file)'}")
+ if not isinstance(accounts_raw, dict) or not accounts_raw:
+ config_error('"accounts" must be a non-empty JSON object')
+
+ accounts, drafts = {}, {}
+ for key, spec in accounts_raw.items():
+ where = f'account "{key}"'
+ if not ACCOUNT_KEY_RE.match(key):
+ config_error(f"{where}: invalid name",
+ "names are lowercase letters, digits, . _ - and must "
+ "not start with a separator")
+ if not isinstance(spec, dict):
+ config_error(f"{where}: must be an object, got {type(spec).__name__}")
+
+ unknown = set(spec) - ACCOUNT_FIELDS
+ if unknown:
+ config_error(f"{where}: unknown field(s) {', '.join(sorted(unknown))}",
+ f"valid fields: {', '.join(sorted(ACCOUNT_FIELDS))}")
+ for required in ("maildir", "address"):
+ if required not in spec:
+ config_error(f"{where}: missing required field \"{required}\"")
+
+ maildir = spec["maildir"]
+ if not isinstance(maildir, str) or not maildir:
+ config_error(f"{where}: \"maildir\" must be a non-empty string")
+ if maildir.startswith("/") or ".." in Path(maildir).parts:
+ config_error(f"{where}: \"maildir\" must be a plain subdirectory "
+ f"of {mail_root}, got {maildir!r}")
+ if not (mail_root / maildir).is_dir():
+ config_error(f"{where}: maildir {mail_root / maildir} does not exist",
+ "check the spelling against what mbsync actually synced")
+
+ address = spec["address"]
+ if not isinstance(address, str) or not ADDRESS_RE.match(address):
+ config_error(f"{where}: \"address\" is not a valid email address: "
+ f"{address!r}")
+
+ drafts_dir = spec.get("drafts")
+ if drafts_dir is not None:
+ if not isinstance(drafts_dir, str) or not drafts_dir:
+ config_error(f"{where}: \"drafts\" must be a non-empty string "
+ "or null (null = account has no synced Drafts)")
+ if not (mail_root / maildir / drafts_dir).is_dir():
+ config_error(
+ f"{where}: Drafts maildir "
+ f"{mail_root / maildir / drafts_dir} does not exist",
+ "it must match the mbsync Patterns for this account "
+ '(e.g. "[Gmail]/Bozze" vs "Drafts"), or set it to null')
+
+ accounts[key] = (maildir, address)
+ drafts[key] = drafts_dir
+
+ dupes = Counter(a for a, _ in accounts.values())
+ for maildir, n in dupes.items():
+ if n > 1:
+ config_error(f"maildir {maildir!r} is used by {n} accounts",
+ "each account needs its own maildir, or scoping "
+ "silently matches the wrong mail")
+ return accounts, drafts
+
+
+def load_accounts(path=None, mail_root=None):
+ path = Path(path) if path else CONFIG_PATH
+ mail_root = mail_root or MAIL_ROOT
+ try:
+ text = path.read_text()
+ except FileNotFoundError:
+ print(f"mailctl: no config at {path}\n"
+ " Create it with one entry per account:\n"
+ ' {"accounts": {"work": {"maildir": "work-mbsync-dir",\n'
+ ' "address": "you@example.org",\n'
+ ' "drafts": "Drafts"}}}\n'
+ ' "drafts" may be null if that account has no synced Drafts '
+ "folder.\n"
+ " Override the location with MAILCTL_CONFIG=/path/to/file.",
+ file=sys.stderr)
+ sys.exit(2)
+ except OSError as e:
+ config_error(f"cannot read: {e}")
+ try:
+ raw = json.loads(text)
+ except json.JSONDecodeError as e:
+ config_error(f"not valid JSON: {e}")
+ return validate_accounts(raw, mail_root)
+
+ACCOUNTS, DRAFTS_SUBDIR = load_accounts()
+
+DESTRUCTIVE_TAGS = {"deleted", "trash", "spam"}
+PROTECTED_REMOVALS = {"inbox"}
+DEFAULT_MAX_MESSAGES = 5000 # abort a tag --apply touching more than this unless --max-messages raises it
+
+# Function words filtered out of `subjects` term counts. Italian and English
+# only, because that's what this mail is; not a general-purpose stopword list.
+# ponytail: a hand-rolled set, no NLP dependency for what is word counting.
+# Add words here when a useless term keeps topping a real query.
+STOPWORDS = {
+ # Italian
+ "di", "il", "la", "le", "lo", "gli", "un", "una", "uno", "del", "della",
+ "dei", "delle", "dello", "al", "alla", "ai", "alle", "allo", "da", "dal",
+ "dalla", "in", "nel", "nella", "con", "su", "sul", "sulla", "per", "tra",
+ "fra", "che", "chi", "cui", "non", "come", "piu", "più", "anche", "sono",
+ "sei", "hai", "ha", "ho", "essere", "questo", "questa", "questi",
+ "queste", "quello", "quella", "tuo", "tua", "tuoi", "tue", "mio", "mia",
+ "ti", "si", "ci", "ne", "se", "ma", "così", "cosa", "tutto", "tutti",
+ "già", "ora", "oggi", "solo", "ed", "od", "sta", "fa", "qui", "te",
+ "lì", "là", "dove", "quando", "molto", "ancora", "sempre", "poi",
+ # English (words under 2 chars are dropped by length, not listed here)
+ "the", "an", "of", "to", "on", "at", "for", "and", "or", "but",
+ "is", "are", "was", "were", "be", "been", "your", "you", "my", "it", "its",
+ "this", "that", "these", "those", "with", "from", "by", "as", "we", "our",
+ "has", "have", "had", "will", "can", "not", "new", "now", "all", "more",
+ "re", "fwd",
+}
+
+
+def run_notmuch(args, capture=True):
+ cmd = ["notmuch"] + args
+ result = subprocess.run(cmd, capture_output=capture, text=True)
+ if result.returncode != 0:
+ print(f"notmuch error: {result.stderr.strip()}", file=sys.stderr)
+ sys.exit(result.returncode)
+ return result.stdout if capture else None
+
+
+def scoped_query(query, account):
+ """Wrap a user query with a path: filter for the given account key.
+ Returns the query unchanged if account is None (global scope)."""
+ if account is None:
+ return query
+ if account not in ACCOUNTS:
+ print(f"Unknown account '{account}'. Valid accounts: "
+ f"{', '.join(ACCOUNTS)}", file=sys.stderr)
+ sys.exit(1)
+ subdir, _address = ACCOUNTS[account]
+ return f'path:"{subdir}/**" and ({query})'
+
+
+def account_choices_help():
+ return "one of: " + ", ".join(ACCOUNTS)
+
+
+def cmd_search(args):
+ q = scoped_query(args.query, args.account)
+ out = run_notmuch(["search", "--format=json", "--output=summary", q])
+ results = json.loads(out)
+ if args.json:
+ print(json.dumps(results, indent=2))
+ return
+ for r in results:
+ date = r.get("date_relative", "")
+ frm = r.get("authors", "")
+ subj = r.get("subject", "(no subject)")
+ tags = ",".join(r.get("tags", []))
+ print(f"{date:>12} {frm:<30.30} {subj:<60.60} [{tags}]")
+ scope = args.account or "all accounts"
+ print(f"\n{len(results)} thread(s) [scope: {scope}]", file=sys.stderr)
+
+
+def cmd_show(args):
+ out = run_notmuch(["show", "--format=json", args.query])
+ print(out)
+
+
+def cmd_tags(args):
+ out = run_notmuch(["search", "--output=tags", "*"])
+ print(out.strip())
+
+
+def cmd_count(args):
+ q = scoped_query(args.query, args.account)
+ out = run_notmuch(["count", q])
+ scope = args.account or "all accounts"
+ print(f"{out.strip()} [scope: {scope}]")
+
+
+def cmd_senders(args):
+ q = scoped_query(args.query, args.account)
+ out = run_notmuch(["address", "--output=sender", "--output=count",
+ "--format=json", q])
+ # notmuch dedupes on name-addr, so one address shows up once per display
+ # name it ever used. Merge on the address, keeping the longest name seen.
+ merged = {}
+ for a in json.loads(out):
+ e = merged.setdefault(a["address"], {"address": a["address"],
+ "name": "", "count": 0})
+ e["count"] += a["count"]
+ if len(a["name"]) > len(e["name"]):
+ e["name"] = a["name"]
+ results = sorted(merged.values(), key=lambda a: a["count"], reverse=True)
+ if args.top:
+ results = results[:args.top]
+ if args.json:
+ print(json.dumps(results, indent=2))
+ return
+ for a in results:
+ print(f"{a['count']:>7} {a['address']:<45.45} {a['name']:.35}")
+ scope = args.account or "all accounts"
+ print(f"\n{len(results)} sender(s) [scope: {scope}]", file=sys.stderr)
+
+
+def subject_terms(subjects):
+ """Count word frequency across subject lines, ignoring stopwords.
+
+ Returns a Counter. Mail here is mixed Italian/English and heavy on emoji
+ and marketing punctuation, so tokens are lowercased word characters only
+ (emoji and '...' fall out), and single characters plus pure digits are
+ dropped as noise.
+ """
+ counts = Counter()
+ for subj in subjects:
+ seen = set()
+ for word in re.findall(r"\w+", subj.lower()):
+ if len(word) < 2 or word.isdigit() or word in STOPWORDS:
+ continue
+ seen.add(word)
+ # count each term once per subject, so one shouty repeated word in a
+ # single subject can't outrank a term used across many messages
+ counts.update(seen)
+ return counts
+
+
+def cmd_subjects(args):
+ q = scoped_query(args.query, args.account)
+ out = run_notmuch(["search", "--format=json", "--output=summary", q])
+ results = json.loads(out)
+ subjects = [r.get("subject") or "" for r in results]
+ terms = subject_terms(subjects).most_common(args.top)
+
+ if args.json:
+ print(json.dumps([{"term": t, "threads": n} for t, n in terms],
+ indent=2))
+ return
+ total = len(subjects)
+ for term, n in terms:
+ pct = 100 * n / total if total else 0
+ print(f"{n:>7} {pct:>5.1f}% {term}")
+ scope = args.account or "all accounts"
+ print(f"\n{len(terms)} term(s) across {total} thread(s) "
+ f"[scope: {scope}]", file=sys.stderr)
+
+
+def cmd_tag(args):
+ adds = args.add or []
+ removes = args.remove or []
+
+ if not adds and not removes:
+ print("Nothing to do: specify --add and/or --remove", file=sys.stderr)
+ sys.exit(1)
+
+ # --- account scoping gate, applies before anything else ---
+ if not args.account and not args.all_accounts:
+ print("Refusing: 'tag' needs either --account NAME (recommended) "
+ "or --all-accounts (explicit, for a deliberate cross-account "
+ f"change). {account_choices_help()}", file=sys.stderr)
+ sys.exit(1)
+
+ if args.account and args.account not in ACCOUNTS:
+ print(f"Unknown account '{args.account}'. {account_choices_help()}",
+ file=sys.stderr)
+ sys.exit(1)
+
+ q = scoped_query(args.query, args.account) # None if --all-accounts
+
+ is_destructive = bool(
+ set(adds) & DESTRUCTIVE_TAGS or set(removes) & PROTECTED_REMOVALS
+ )
+
+ count = run_notmuch(["count", q]).strip()
+ tag_expr = [f"+{t}" for t in adds] + [f"-{t}" for t in removes]
+ scope = args.account or "ALL ACCOUNTS"
+
+ print(f"Scope: {scope}")
+ print(f"Query: {args.query}")
+ print(f"Effective query: {q}")
+ print(f"Matches: {count} message(s)")
+ print(f"Change: {' '.join(tag_expr)}")
+
+ if is_destructive:
+ print("\n[!] This includes a destructive tag change "
+ f"({DESTRUCTIVE_TAGS | PROTECTED_REMOVALS} related).")
+
+ n = int(count)
+ if n > args.max_messages:
+ print(f"\n[cap] {n} matches exceeds --max-messages "
+ f"({args.max_messages}).")
+
+ if not args.apply:
+ print("\nDry run only. Re-run with --apply to actually change tags.")
+ return
+
+ if n > args.max_messages:
+ print(f"\nRefusing to apply: {n} messages exceeds the "
+ f"--max-messages cap ({args.max_messages}). Re-run with a "
+ "higher --max-messages if this is intended.", file=sys.stderr)
+ sys.exit(1)
+
+ if is_destructive and not args.confirm_destructive:
+ print("\nRefusing to apply: destructive change needs "
+ "--apply AND --confirm-destructive.", file=sys.stderr)
+ sys.exit(1)
+
+ run_notmuch(["tag"] + tag_expr + ["--", q], capture=False)
+ log_mutation(scope, args.query, tag_expr, int(count))
+ print(f"\nApplied. {count} message(s) affected.")
+ print("Note: if synchronize_flags is on, this will also update Maildir "
+ "flags and may propagate to the IMAP server on next mbsync run.")
+
+
+def cmd_draft(args):
+ if args.account not in ACCOUNTS:
+ print(f"Unknown account '{args.account}'. {account_choices_help()}",
+ file=sys.stderr)
+ sys.exit(1)
+
+ subdir, from_addr = ACCOUNTS[args.account]
+ drafts_subdir = DRAFTS_SUBDIR.get(args.account)
+ if drafts_subdir is None:
+ print(f"Account '{args.account}' has no synced Drafts folder "
+ "(not in its mbsync Patterns). Pick a different account "
+ "or add Drafts to that account's sync config first.",
+ file=sys.stderr)
+ sys.exit(1)
+
+ drafts_path = MAIL_ROOT / subdir / drafts_subdir
+ if not drafts_path.is_dir():
+ print(f"Expected Drafts maildir not found at {drafts_path}. "
+ "Has this account been synced yet?", file=sys.stderr)
+ sys.exit(1)
+
+ body = args.body
+ if args.body_file:
+ body = Path(args.body_file).read_text()
+ if body is None:
+ body = sys.stdin.read()
+
+ msg = EmailMessage()
+ msg["From"] = from_addr
+ msg["To"] = args.to
+ if args.cc:
+ msg["Cc"] = args.cc
+ msg["Subject"] = args.subject
+ msg["Date"] = formatdate(localtime=True)
+ msg["Message-ID"] = make_msgid()
+ msg.set_content(body)
+
+ # Maildir atomic write: build the full file in tmp/, then rename
+ # (not copy) into new/. Any reader (neomutt, notmuch, mbsync) only
+ # ever sees either "not there yet" or "fully written", never a
+ # partial file.
+ unique = f"{int(time.time())}.M{os.getpid()}P{id(msg) % 100000}.{socket.gethostname()}"
+ tmp_path = drafts_path / "tmp" / unique
+ new_path = drafts_path / "new" / unique
+
+ tmp_path.write_bytes(msg.as_bytes())
+ os.rename(tmp_path, new_path)
+
+ print(f"Draft written: {new_path}")
+ print(f"From: {from_addr}")
+ print(f"To: {args.to}")
+ print(f"Subject: {args.subject}")
+ print("\nThis is a LOCAL draft only. It will appear on the server "
+ "(and other devices) after the next mbsync run. Nothing has "
+ "been sent, review and send it yourself in neomutt.")
+
+
+def log_mutation(scope, query, tag_expr, count):
+ AUDIT_LOG.parent.mkdir(parents=True, exist_ok=True)
+ ts = datetime.now(timezone.utc).isoformat(timespec="seconds")
+ with open(AUDIT_LOG, "a") as f:
+ f.write(f"{ts}\tscope={scope}\tquery={query!r}\t"
+ f"change={' '.join(tag_expr)}\tcount={count}\n")
+
+
+def build_parser():
+ p = argparse.ArgumentParser(
+ prog="mailctl",
+ description="Read-heavy, agent-safe wrapper around notmuch. "
+ "No send/reply/compose capability exists in this tool.",
+ )
+ sub = p.add_subparsers(dest="command", required=True)
+
+ sp = sub.add_parser("search", help="search mail, notmuch query syntax")
+ sp.add_argument("query")
+ sp.add_argument("--account", choices=list(ACCOUNTS),
+ help="scope to one account, default is global")
+ sp.add_argument("--json", action="store_true")
+ sp.set_defaults(func=cmd_search)
+
+ sp = sub.add_parser("show", help="show a full thread/message")
+ sp.add_argument("query", help="e.g. thread:0000... or id:...")
+ sp.set_defaults(func=cmd_show)
+
+ sp = sub.add_parser("tags", help="list all tags currently in use")
+ sp.set_defaults(func=cmd_tags)
+
+ sp = sub.add_parser("count", help="count messages matching a query")
+ sp.add_argument("query")
+ sp.add_argument("--account", choices=list(ACCOUNTS),
+ help="scope to one account, default is global")
+ sp.set_defaults(func=cmd_count)
+
+ sp = sub.add_parser("senders", help="rank senders by message count")
+ sp.add_argument("query")
+ sp.add_argument("--account", choices=list(ACCOUNTS),
+ help="scope to one account, default is global")
+ sp.add_argument("--top", type=int, metavar="N",
+ help="show only the top N senders")
+ sp.add_argument("--json", action="store_true")
+ sp.set_defaults(func=cmd_senders)
+
+ sp = sub.add_parser("subjects", help="rank subject terms by how many "
+ "threads use them")
+ sp.add_argument("query")
+ sp.add_argument("--account", choices=list(ACCOUNTS),
+ help="scope to one account, default is global")
+ sp.add_argument("--top", type=int, default=25, metavar="N",
+ help="show only the top N terms (default 25)")
+ sp.add_argument("--json", action="store_true")
+ sp.set_defaults(func=cmd_subjects)
+
+ sp = sub.add_parser("tag", help="add/remove tags (dry-run unless --apply)")
+ sp.add_argument("query")
+ sp.add_argument("--account", choices=list(ACCOUNTS),
+ help="required unless --all-accounts is given")
+ sp.add_argument("--all-accounts", action="store_true",
+ help="explicit opt-in to a cross-account mutation")
+ sp.add_argument("--add", action="append", metavar="TAG")
+ sp.add_argument("--remove", action="append", metavar="TAG")
+ sp.add_argument("--apply", action="store_true",
+ help="actually apply the change, default is dry-run")
+ sp.add_argument("--confirm-destructive", action="store_true",
+ help="required in addition to --apply for "
+ "deleted/trash/spam or removing 'inbox'")
+ sp.add_argument("--max-messages", type=int, default=DEFAULT_MAX_MESSAGES,
+ metavar="N",
+ help=f"abort --apply if the match count exceeds N "
+ f"(default {DEFAULT_MAX_MESSAGES})")
+ sp.set_defaults(func=cmd_tag)
+
+ sp = sub.add_parser("draft", help="write a draft to the account's Drafts "
+ "folder for later review/sending in "
+ "neomutt. Never sends anything.")
+ sp.add_argument("--account", required=True, choices=list(ACCOUNTS),
+ help="which identity/mailbox to draft into")
+ sp.add_argument("--to", required=True)
+ sp.add_argument("--cc")
+ sp.add_argument("--subject", required=True)
+ sp.add_argument("--body", help="draft body text")
+ sp.add_argument("--body-file", help="read body from a file instead")
+ sp.set_defaults(func=cmd_draft)
+
+ return p
+
+
+def main():
+ parser = build_parser()
+ args = parser.parse_args()
+ args.func(args)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/mailsync.sh b/mailsync.sh
new file mode 100755
index 0000000..1ea2aa2
--- /dev/null
+++ b/mailsync.sh
@@ -0,0 +1,67 @@
+#!/bin/bash
+# ~/bin/mailsync.sh
+#
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# 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. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+# Defensive: don't rely on cron/systemd/whatever invokes this to have
+# set these correctly. Explicit beats inferred, especially after the
+# HOME-not-set failure we hit once already. Fall back to the invoking
+# user's home from passwd rather than a hardcoded path.
+export HOME="${HOME:-$(getent passwd "$(id -u)" | cut -d: -f6)}"
+export GNUPGHOME="${GNUPGHOME:-$HOME/.gnupg}"
+
+LOCKFILE="/tmp/mbsync.lock"
+LOGFILE="$HOME/.local/state/mailsync.log"
+MAX_LOG_BYTES=$((10 * 1024 * 1024)) # rotate past 10MB, see note below
+
+exec 200>"$LOCKFILE"
+if ! flock -n 200; then
+ echo "$(date -Iseconds) === SKIPPED: previous run still in progress ===" >> "$LOGFILE"
+ exit 1
+fi
+
+# Simple rotation: if the log's gotten big, keep the last run's worth
+# and move the rest aside rather than letting it grow forever.
+if [ -f "$LOGFILE" ] && [ "$(stat -c%s "$LOGFILE" 2>/dev/null || echo 0)" -gt "$MAX_LOG_BYTES" ]; then
+ mv "$LOGFILE" "${LOGFILE}.1"
+fi
+
+START_TS="$(date -Iseconds)"
+{
+ echo "===== RUN START: $START_TS ====="
+
+ # Timestamp every line of mbsync/notmuch output as it streams,
+ # rather than only marking run boundaries, this is what actually
+ # lets you tell which errors are from which run at a glance.
+ mbsync -a 2>&1 | while IFS= read -r line; do
+ echo "$(date '+%H:%M:%S') $line"
+ done
+ MBSYNC_STATUS=${PIPESTATUS[0]}
+
+ notmuch new 2>&1 | while IFS= read -r line; do
+ echo "$(date '+%H:%M:%S') $line"
+ done
+ NOTMUCH_STATUS=${PIPESTATUS[0]}
+
+ END_TS="$(date -Iseconds)"
+ if [ "$MBSYNC_STATUS" -eq 0 ] && [ "$NOTMUCH_STATUS" -eq 0 ]; then
+ echo "===== RUN END: $END_TS status=OK ====="
+ else
+ echo "===== RUN END: $END_TS status=FAILED mbsync=$MBSYNC_STATUS notmuch=$NOTMUCH_STATUS ====="
+ fi
+} >> "$LOGFILE"
+
+exit 0
diff --git a/test_mailctl.py b/test_mailctl.py
new file mode 100755
index 0000000..c6a50b9
--- /dev/null
+++ b/test_mailctl.py
@@ -0,0 +1,252 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# 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. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+"""Self-checks for the bits of mailctl with real logic in them:
+
+ - the senders address merge: notmuch dedupes on name-addr, so one address
+ appears once per display name it ever used, and we merge on the address.
+ - subject_terms: tokenizing/stopword-filtering subject lines, counting each
+ term once per subject.
+ - validate_accounts: the config gate. Every rejection case here is a typo
+ that would otherwise silently produce a query matching nothing, or a
+ draft written under the wrong identity.
+
+Run: ./test_mailctl.py
+"""
+
+import io
+import json
+import os
+import tempfile
+from contextlib import redirect_stdout
+from pathlib import Path
+from unittest.mock import patch
+
+# mailctl validates its account config at import time, so a valid one has to
+# exist before the import below. Point it at a throwaway maildir tree.
+_tmp = tempfile.TemporaryDirectory()
+FIXTURE_ROOT = Path(_tmp.name)
+(FIXTURE_ROOT / "mail" / "acct-a" / "Drafts").mkdir(parents=True)
+(FIXTURE_ROOT / "mail" / "acct-b").mkdir(parents=True)
+FIXTURE_CONFIG = FIXTURE_ROOT / "accounts.json"
+FIXTURE_CONFIG.write_text(json.dumps({"accounts": {
+ "acct-a": {"maildir": "acct-a", "address": "a@example.org",
+ "drafts": "Drafts"},
+ "acct-b": {"maildir": "acct-b", "address": "b@example.org",
+ "drafts": None},
+}}))
+os.environ["MAILCTL_CONFIG"] = str(FIXTURE_CONFIG)
+os.environ["MAILCTL_MAIL_ROOT"] = str(FIXTURE_ROOT / "mail")
+
+import mailctl # noqa: E402 (must follow the env vars above)
+
+MAIL = FIXTURE_ROOT / "mail"
+
+
+def senders_json(raw, **overrides):
+ """Run cmd_senders against a canned notmuch reply, return parsed JSON."""
+ opts = {"query": "*", "account": None, "top": None, "json": True}
+ opts.update(overrides)
+ args = type("Args", (), opts)()
+ buf = io.StringIO()
+ with patch.object(mailctl, "run_notmuch", return_value=json.dumps(raw)), \
+ redirect_stdout(buf):
+ mailctl.cmd_senders(args)
+ return json.loads(buf.getvalue())
+
+
+def test_merges_on_address_keeping_longest_name():
+ got = senders_json([
+ {"name": "", "address": "a@x", "count": 112},
+ {"name": "DPReview", "address": "a@x", "count": 353},
+ {"name": "B", "address": "b@x", "count": 5},
+ ])
+ assert got == [
+ {"address": "a@x", "name": "DPReview", "count": 465},
+ {"address": "b@x", "name": "B", "count": 5},
+ ], got
+
+
+def test_top_truncates_after_sorting():
+ got = senders_json([
+ {"name": "small", "address": "s@x", "count": 1},
+ {"name": "big", "address": "b@x", "count": 99},
+ ], top=1)
+ assert [a["address"] for a in got] == ["b@x"], got
+
+
+def test_empty():
+ assert senders_json([]) == []
+
+
+def test_subject_terms_counts_each_term_once_per_subject():
+ # "promo" three times in one subject must not outrank "sconto" in two
+ got = mailctl.subject_terms(["Promo promo PROMO", "sconto", "Sconto!"])
+ assert got["promo"] == 1, got
+ assert got["sconto"] == 2, got
+
+
+def test_subject_terms_drops_stopwords_digits_and_emoji():
+ got = mailctl.subject_terms(["🔥 Le offerte di oggi for you 2024 ⏳"])
+ assert set(got) == {"offerte"}, got
+
+
+def test_subject_terms_keeps_accented_words():
+ got = mailctl.subject_terms(["Località e novità"])
+ assert set(got) == {"località", "novità"}, got
+
+
+def test_subject_terms_empty():
+ assert mailctl.subject_terms([]) == {}
+ assert mailctl.subject_terms(["", "🔥"]) == {}
+
+
+def rejects(accounts_value, expect_in_message):
+ """Assert a config is refused, and that the message names the problem.
+
+ Checking the message matters as much as the exit: these fire on the user's
+ own typo, and a rejection that doesn't say which account and which field
+ is barely better than a silent wrong answer.
+ """
+ buf = io.StringIO()
+ try:
+ with patch("sys.stderr", buf):
+ mailctl.validate_accounts(accounts_value, MAIL)
+ except SystemExit as e:
+ assert e.code == 2, f"expected exit 2, got {e.code}"
+ msg = buf.getvalue()
+ assert expect_in_message in msg, f"want {expect_in_message!r} in:\n{msg}"
+ return
+ raise AssertionError(f"config was accepted but should not be: {accounts_value}")
+
+
+def good(**overrides):
+ spec = {"maildir": "acct-a", "address": "a@example.org", "drafts": "Drafts"}
+ spec.update(overrides)
+ return {"accounts": {"acct-a": spec}}
+
+
+def test_valid_config_returns_both_maps():
+ accounts, drafts = mailctl.validate_accounts(json.loads(
+ FIXTURE_CONFIG.read_text()), MAIL)
+ assert accounts == {"acct-a": ("acct-a", "a@example.org"),
+ "acct-b": ("acct-b", "b@example.org")}, accounts
+ assert drafts == {"acct-a": "Drafts", "acct-b": None}, drafts
+
+
+def test_rejects_misspelled_maildir():
+ # the typo this whole gate exists for: scoping would match zero mail
+ rejects(good(maildir="acct-A"), "does not exist")
+
+
+def test_rejects_misspelled_drafts_dir():
+ # would write a draft into a folder mbsync never syncs back
+ rejects(good(drafts="Bozze"), "does not exist")
+
+
+def test_rejects_absolute_and_traversing_maildir():
+ rejects(good(maildir="/etc"), "plain subdirectory")
+ rejects(good(maildir="../../etc"), "plain subdirectory")
+
+
+def test_rejects_bad_address():
+ rejects(good(address="a@example"), "not a valid email address")
+ rejects(good(address="not-an-address"), "not a valid email address")
+
+
+def test_rejects_unknown_field():
+ # catches "addresss"/"maildirs" style typos instead of ignoring them
+ rejects(good(adress="a@example.org"), "unknown field")
+
+
+def test_rejects_missing_required_field():
+ spec = good()
+ del spec["accounts"]["acct-a"]["address"]
+ rejects(spec, 'missing required field "address"')
+
+
+def test_rejects_duplicate_maildir():
+ rejects({"accounts": {
+ "one": {"maildir": "acct-a", "address": "a@example.org", "drafts": None},
+ "two": {"maildir": "acct-a", "address": "b@example.org", "drafts": None},
+ }}, "is used by 2 accounts")
+
+
+def test_rejects_structural_problems():
+ rejects([], "must be a JSON object")
+ rejects({}, 'missing top-level "accounts" key')
+ rejects({"accounts": {}}, "non-empty")
+ rejects({"accounts": {"acct-a": "acct-a"}}, "must be an object")
+ rejects({"accounts": {"Acct A": good()["accounts"]["acct-a"]}},
+ "invalid name")
+
+
+def test_message_names_the_offending_account():
+ buf = io.StringIO()
+ try:
+ with patch("sys.stderr", buf):
+ mailctl.validate_accounts({"accounts": {
+ "fine": {"maildir": "acct-b", "address": "b@example.org"},
+ "broken": {"maildir": "nope", "address": "c@example.org"},
+ }}, MAIL)
+ except SystemExit:
+ pass
+ assert '"broken"' in buf.getvalue(), buf.getvalue()
+
+
+def test_drafts_null_is_allowed():
+ accounts, drafts = mailctl.validate_accounts(good(drafts=None), MAIL)
+ assert drafts == {"acct-a": None}, drafts
+
+
+def test_drafts_field_is_optional():
+ spec = good()
+ del spec["accounts"]["acct-a"]["drafts"]
+ _, drafts = mailctl.validate_accounts(spec, MAIL)
+ assert drafts == {"acct-a": None}, drafts
+
+
+def test_load_accounts_rejects_bad_json():
+ bad = FIXTURE_ROOT / "bad.json"
+ bad.write_text("{not json")
+ buf = io.StringIO()
+ try:
+ with patch("sys.stderr", buf):
+ mailctl.load_accounts(bad, MAIL)
+ except SystemExit as e:
+ assert e.code == 2
+ assert "not valid JSON" in buf.getvalue(), buf.getvalue()
+ return
+ raise AssertionError("bad JSON was accepted")
+
+
+def test_load_accounts_missing_file_explains_how_to_create_it():
+ buf = io.StringIO()
+ try:
+ with patch("sys.stderr", buf):
+ mailctl.load_accounts(FIXTURE_ROOT / "absent.json", MAIL)
+ except SystemExit as e:
+ assert e.code == 2
+ assert "no config at" in buf.getvalue(), buf.getvalue()
+ return
+ raise AssertionError("missing config was accepted")
+
+
+if __name__ == "__main__":
+ for name, fn in sorted(globals().items()):
+ if name.startswith("test_") and callable(fn):
+ fn()
+ print("ok")