mail-muncher

by craigjmidwinter

Not rated
GitHub

About

Strictly read-only mail for agents: ordered filter rules archive matching messages from any IMAP mailbox or the Gmail API to disk as .eml plus markdown, served back over MCP.

Details

Author
craigjmidwinter
Categories
Communication, Productivity

Setup

Install mail-muncher in your MCP client (Claude Desktop, Cursor, Windsurf, and others).

Repository: https://github.com/craigjmidwinter/mail-muncher

Follow the installation instructions in the repository README, then restart your MCP client.

Give a program its own read-only mailbox, filtered down to exactly the mail it asked for, delivered as files on disk.

mail-muncher pulls messages from a mail provider, evaluates each one against ordered rules, and writes the matches to a directory — byte-faithful.eml, and optionally a markdown rendering with the headers as YAML frontmatter, the body as text, and attachments extracted alongside. A rule can take its filter input from a plain text file thatsome other program owns, which mail-muncher re-reads at the start of every cycle. That other program changes one line in that file, and the very next cycle delivers different mail — no config edit, no restart, no redeploy.

It reads from any IMAP mailbox — Gmail, Fastmail, iCloud, Proton Bridge, a work account, your own server — or from Gmail's API with a read-only OAuth scope. It runs one-shot for cron, or as a polling daemon, or as a stdio MCP server an agent can query directly. Every mode emits the same machine-readable manifest of what it did, and no mode ever writes to your mailbox.

Pick one before you install anything. Both are supported, and everything downstream — rules, formats, filenames, the archive layout, the MCP tools — is identical either way.

The ~2 min / ~10 min / 7 days above are the same numbersmail-muncher initand the unconfigured-run guidance print, because they are the numbers that decide this.

The read-only guarantee is real on both paths, but it is not the same guarantee, and flattening the two would be dishonest.

- Gmail: enforced by Google.The only scope requested isgmail.readonly. The token that comes back isincapableof sending, deleting, labelling or modifying — not because mail-muncher declines to, but because Google will refuse the call. A bug in this program cannot reach your mailbox.
- IMAP: enforced by mail-muncher.IMAP has no read-only credential to ask for. An app password is a full mail credential; the protocol will happily let its holder delete a folder. What mail-muncher does instead is refuse to: every folder is opened withEXAMINEand neverSELECT, every body is fetched withBODY.PEEK[]and neverBODY[](so mail is never marked read), and there is no code path anywhere in the provider that issuesSTORE,APPENDorEXPUNGE. Both belts are worn because a server is not obliged to protect a client from itself. That is a strong guarantee and an auditable one — it is just this program's guarantee, not your mail provider's.

If you have no specific reason to want the Gmail API, start with IMAP. It works on a Gmail account too, and it is the path the quickstart takes.

An automated process needs some mail. A job-search tracker wants replies from companies you applied to. A support bot wants messages from one vendor's domain. A research agent wants every newsletter from three publishers, as text it can actually read.

The usual answers are all bad. Hand the process your inbox credentials and it can read (and send, and delete) everything. Give it a mail API integration and you now maintain an OAuth flow, a sync cursor, MIME parsing, and a dedup story inside every process that wants mail. Or hard-code the filter into a config file, and every change towhat it wantsis a config edit and a redeploy.

mail-muncher splits that in half. It owns the credentials, the incremental sync, the parsing, and the dedup. The consuming program owns a text file listing what it wants and a directory it reads results from — and, if it prefers to ask rather than watch, a handful of MCP tools over that same directory.

There are two supported shapes, and they compose. Pick by whether your agent runs on a loop of its own or waits to be asked.

Both read the same archive, and running both at once is normal: a daemon fills the directory while the MCP server answers questions about it.

The loop is fully decoupled: mail-muncher never calls the agent, and the agent need never call mail-muncher. They share two paths on disk.

1. The agent declares what it wants.Append to a file it owns:

mkdir -p ~/.local/share/agent cat >> ~/.local/share/agent/domains.txt <<'EOF' # domains this agent is currently interested in acme.com globex.io EOF
rules: - name: agent-inbox match: from_domains_file: ~/.local/share/agent/domains.txt dest: ~/mail/agent-inbox formats: [eml, markdown]

3. Every cycle re-reads the file.Run it from cron, or leave the daemon running:

mail-muncher run # one cycle — the cron entrypoint mail-muncher daemon --interval 5m # poll forever

4. Matched mail lands indestas files the agent reads.

~/mail/agent-inbox/ └── 2026/ └── 07/ ├── 1785230100-a00d5c5e383a1c08-re-your-application-for-senior-engineer.eml ├── 1785230100-a00d5c5e383a1c08-re-your-application-for-senior-engineer.md └── 1785230100-a00d5c5e383a1c08-re-your-application-for-senior-engineer.attachments/ └── offer.pdf

The.mdis the consumable rendering — parse the frontmatter, feed the body to a model, open the attachments from the sibling directory:

--- subject: 'Re: Your application for Senior Engineer' from: Jane Doe <jane@acme.com> from_address: jane@acme.com from_addresses: [jane@acme.com] to: [me@example.com] to_addresses: [me@example.com] date: 2026-07-28T09:15:00Z message_id: <abc123@acme.com> thread_id: 18fe9c0d1a2b3c4d thread_id_source: provider in_reply_to: <application-000@example.com> account: personal rule: job-search labels: [INBOX] attachments: [offer.pdf] --- Hi there, Thanks for applying. ## Attachments - offer.pdf

thread_idis on every message and is never empty, so grouping a directory into conversations is asorton one field — no reference chains to reassemble.

5. Optionally, take the manifest instead of walking the tree.--jsonwrites a machine-readable record of the cycle to stdout, one object per account, while every log line goes to stderr:

mail-muncher run --json 2>/dev/null | jq -r '.stored[].path'

Three properties make this safe to put in an autonomous loop:

- Read-only by construction.Nothing in mail-muncher writes to a mailbox. On Gmail that is Google's enforcement of thegmail.readonlyscope; on IMAP it isEXAMINEandBODY.PEEK[]and no write path at all. Either way, whatever consumes the output — and whatever bug it has — cannot send, delete, or modify mail. Seethe comparison abovefor which of those two guarantees you are getting.
- Idempotent delivery.A message's filename embeds a digest ofaccount + ":" + message id, so its destination path is a pure function of its identity. A file that is already there means "an earlier cycle stored this", and the sink writes nothing. Re-run, replay after losing state, crash mid-cycle, or overlap two cron invocations: the tree converges, and nothing is processed twice.
- Deterministic routing.Rules are ordered and first-match-wins, so each message is written by exactly one rule. Give each consumer its own rule and its owndest, and each gets a private mailbox nothing else writes into.

Delivery is files on disk, and nothing here listens on a network. The contract is the directory, with the manifest as an optional, machine-readable account of what changed.

mail-muncher mcpis a stdio MCP server over the mail already archived. The agent asks; nothing is scheduled.

{ "mcpServers": { "mail-muncher": { "command": "/usr/local/bin/mail-muncher", "args": ["mcp", "--config", "/Users/you/.config/mail-muncher/config.yml"] } } }

It is read-only over mail: no tool sends, deletes, or modifies anything, andsync— the only tool that changes anything at all — can only add files. Filesystem access is jailed to the configured ruledestroots, so the config, any stored credential, and the state directory are unreachable and unnamed.

An unconfiguredmcpserver starts anyway, and that is deliberate.If a client launchesmail-muncher mcpbefore there is a config, the server doesnotexit — it completes the handshake, registers the same five tool names, and answers every call with the setup guidance as a tool error, so the agent has something to relay instead of "server failed to start". If you are wiring this up for an operator, that is expected behaviour and not a bug to file. The guidance also goes to stderr at startup, where clients tee the server log.

Full reference, client wiring, and every argument and return field:docs/mcp.md.

list_rulesis the one that closes the loop. The agent writes a domain to its own file, then askslist_rulesand sees its own subscription reflected back — the same list the next cycle will match against.

Read this before adopting. Several tools do the fetch-filter-deliver shape well, and some of them are a better fit than this one.

What none of them do, and what this tool exists for: take filter input from a file another program owns and re-read it every cycle, and emit a rendering built for a program to consume rather than for a mail client to display. If you do not need both of those, one of the tools above will serve you better and has years more mileage.

No Go toolchain required for the first two options.

brew install craigjmidwinter/tap/mail-muncher

That tapscraigjmidwinter/homebrew-tapand installs a prebuilt binary.brew upgrade mail-munchertracks new releases.

Everyreleaseships archives for macOS and Linux on both amd64 and arm64, plus achecksums.txtand a signature over it.

# Latest release, without the leading v. Set this by hand to pin a version. VERSION=$(curl -fsSL https://api.github.com/repos/craigjmidwinter/mail-muncher/releases/latest \ | sed -n 's/."tag_name": "v\{0,1\}\([^"]\)"./\1/p') OS=$(uname -s | tr '[:upper:]' '[:lower:]') # darwin | linux ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') curl -fsSLO "https://github.com/craigjmidwinter/mail-muncher/releases/download/v${VERSION}/mail-muncher_${VERSION}_${OS}_${ARCH}.tar.gz" tar xzf "mail-muncher_${VERSION}_${OS}_${ARCH}.tar.gz" mail-muncher sudo install -m 0755 mail-muncher /usr/local/bin/mail-muncher

If the binary then refuses to run at all —

bash: mail-muncher: cannot execute binary file: Exec format error

— you have an archive for the wrong architecture. That message comes from the kernel and says nothing about mail-muncher, so it is worth knowing the shape of it. Compareuname -magainst the_amd64/_arm64in the filename you downloaded; theARCH=line above computes the right one for you, so this only bites if you set the name by hand.

No root?/usr/local/binneeds it;~/.local/bindoes not. Drop thesudoand install there instead — nothing about mail-muncher wants a system-wide location:

install -d ~/.local/bin install -m 0755 mail-muncher ~/.local/bin/mail-muncher

Ifmail-muncheris then "command not found",~/.local/binis not on yourPATH; add it in your shell profile.

Skipping thesudowithout changing the destination fails with aPermission deniedfrominstallitself — on macOS naming a scratch file rather thanmail-muncher, which is confusing the first time you see it:

install: /usr/local/bin/INS@LPh1Hz: Permission denied # macOS install: cannot create regular file '/usr/local/bin/mail-muncher': Permission denied # GNU

Either message means the same thing: pick the~/.local/binroute above, or put thesudoback.

On macOS, a binary you downloaded yourself is quarantined by Gatekeeper. Clear it withxattr -d com.apple.quarantine /usr/local/bin/mail-muncher, or use the Homebrew install above, which does this for you.

This tool reads your mail. Check that the archive is the one the release workflow built. First the checksum:

curl -fsSLO "https://github.com/craigjmidwinter/mail-muncher/releases/download/v${VERSION}/checksums.txt" # Linux sha256sum --check --ignore-missing checksums.txt # macOS shasum -a 256 --check --ignore-missing checksums.txt

Then the signature overchecksums.txt. Releases are signed keylessly withcosign— there is no public key to fetch and no private key anyone has to guard. The signing certificate is issued to the release workflow's own GitHub OIDC identity and recorded in the public Rekor transparency log, so what you are checking is "this was built byrelease.ymlin this repo, from a tag":

cosignis not installed by default on any platform and is not in the usual distro repositories, socosign: command not foundhere means "not installed yet", not "verification failed". Get it first —brew install cosign, orgo install github.com/sigstore/cosign/v2/cmd/cosign@latest, or a release binary fromthe install docs.

curl -fsSLO "https://github.com/craigjmidwinter/mail-muncher/releases/download/v${VERSION}/checksums.txt.sig" curl -fsSLO "https://github.com/craigjmidwinter/mail-muncher/releases/download/v${VERSION}/checksums.txt.pem" cosign verify-blob \ --certificate checksums.txt.pem \ --signature checksums.txt.sig \ --certificate-identity-regexp '^https://github\.com/craigjmidwinter/mail-muncher/\.github/workflows/release\.yml@refs/tags/' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ checksums.txt

Verified OKmeans the checksum file is authentic; thesha256sumstep then ties your archive to it. cosign 3 prints a deprecation notice for--certificateand--signature— the check still runs, and these detached files are what cosign 2 understands too.

The right path if you already have Go 1.25 or newer:

go install github.com/craigjmidwinter/mail-muncher/cmd/mail-muncher@latest

Note thatgo installbuilds reportdevfor--version, because the version is stamped at link time and thegotool does not do it. Released binaries andmake buildreport the real tag. If you file a bug from ago installbuild, say which commit you installed.

git clone https://github.com/craigjmidwinter/mail-muncher cd mail-muncher make build # -> ./mail-muncher, version stamped from git describe

make snapshotbuilds the full set of release archives locally (requiresgoreleaser) if you want to check what a release would contain.

The example configs referenced below live inexamples/imap.yml,minimal.ymlandjob-search.yml. They are also bundled inside every release archive, so a binary download has them too. You do not need them to get started, though:mail-muncher initwrites a config from scratch.

docker pull ghcr.io/craigjmidwinter/mail-muncher:latest

linux/amd64andlinux/arm64, built from the same binaries the release archives carry. The image's default command ismcp, because serving the archive over stdio is the mode a container suits: a client starts it, talks to it, and stops it.runanddaemonwork too — override the command — but on a host those are a cron line and a launchd/systemd unit, which fit better.

# -e IMAP_PASSWORD forwards the variable, it does not invent it: export it # first, from wherever you actually keep the secret. export IMAP_PASSWORD="$(security find-generic-password -s mail-muncher -w)" docker run -i --rm \ -e IMAP_PASSWORD \ -v ~/.config/mail-muncher:/home/muncher/.config/mail-muncher:ro \ -v ~/.local/share/mail-muncher:/home/muncher/archive \ ghcr.io/craigjmidwinter/mail-muncher:latest mcp

Thatexportpairs withpassword_cmd: printenv IMAP_PASSWORDin the config — see the note below on why your host password manager is not reachable from inside the container.

Every path insideconfig.ymlhas to be a path the container can see.Adest:of~/Mail/receiptsresolves against the container's home directory, not yours, so mail lands on a layer that disappears when the container exits. Pointdest:at the mounted directory —/home/muncher/archive/receiptsfor the mount above — or you will archive into the void and the manifest will cheerfully tell you it worked.

password_cmdruns inside the container, under/bin/sh, which means your host password manager is not there.pass show mail/fastmailcannot work. Use the secret material the container does have:

password_cmd: printenv IMAP_PASSWORD # -e IMAP_PASSWORD password_cmd: cat /run/secrets/imap-password # docker secret or a mounted file

This is the one place the container path is genuinely worse than a host install: it moves the credential out of your password manager and into the container's environment. If that trade is not worth it to you, install the binary —password_cmdis designed for the host case, and this is the compromise, not the intent.

The image is also what backs theMCP Registrylisting;server.jsonis that entry, and itsnamehas to match theio.modelcontextprotocol.server.namelabel baked into the image.

Publishing that entry is automatic. Tagging a release builds and pushes the image, and then a second job rewritesversionand the image tag inserver.jsonfrom the git tag and publishes to the registry, authenticating with the workflow's own OIDC identity rather than a stored token.

So theversioncommitted inserver.jsonis last release's, and lags by one tag on purpose.The tag is the source of truth; the file is a template that CI stamps. Bumping it by hand achieves nothing.

The repo ships a skill and plugin package underskills/, which installs mail-muncher as something an agent can set up and drive for you — writing the config, runningauth, and wiring the MCP server into your client. If that is how you want to adopt it, start there instead of the quickstart below.

The skill leads withprovider: imapand drivesmail-muncher init, so it takes the same two-minute route this README does rather than sending you to the Google Cloud Console.

There is no Windows build, and none of the options above quietly work around that. Homebrew does not run on Windows. The release archives aredarwinandlinuxonly, and the download snippet above is a POSIX shell script built onuname, which PowerShell andcmdcannot run at all.

go installis the one path that produces something, and that is the problem worth stating plainly. Go cross-compiles this module cleanly — no cgo, no platform build tags outside a test file — so you get amail-muncher.exethat starts, andmail-muncher initthat writes a config without complaint. It stops at the firstrun. The IMAP provider, the ~2 min path this README leads with, executesimap.password_cmdby handing it to/bin/sh -c(internal/provider/imap/password.go), and a stock Windows machine has no/bin/sh.initis careful enough not to seed a Windows config with a macOS or Linux secret tool, but the command it does seed still goes to a shell that is not there, so the failure arrives late and blames the wrong thing.

No reviews yet — be the first

Sign in to leave a review

Use Google, GitHub, or an email account so ratings stay tied to real people.

Email sign in

No reviews posted yet.