{
  "markdown": "<p align=\"center\">\n  <picture>\n    <source media=\"(prefers-color-scheme: dark)\" srcset=\"docs/assets/brand/mark-dark.svg\">\n    <img src=\"docs/assets/brand/mark.svg\" alt=\"The mail-muncher archive beast biting an envelope\" width=\"220\" height=\"220\">\n  </picture>\n  <br>\n  <picture>\n    <source media=\"(prefers-color-scheme: dark)\" srcset=\"docs/assets/brand/lockup-dark.svg\">\n    <img src=\"docs/assets/brand/lockup.svg\" alt=\"mail-muncher\" width=\"408\" height=\"64\">\n  </picture>\n</p>\n\n# mail-muncher\n\n[![CI](https://github.com/craigjmidwinter/mail-muncher/actions/workflows/ci.yml/badge.svg)](https://github.com/craigjmidwinter/mail-muncher/actions/workflows/ci.yml)\n[![Go Reference](https://pkg.go.dev/badge/github.com/craigjmidwinter/mail-muncher.svg)](https://pkg.go.dev/github.com/craigjmidwinter/mail-muncher)\n[![Go version](https://img.shields.io/github/go-mod/go-version/craigjmidwinter/mail-muncher)](go.mod)\n[![Release](https://img.shields.io/github/v/release/craigjmidwinter/mail-muncher?color=blue)](https://github.com/craigjmidwinter/mail-muncher/releases/latest)\n[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)\n\nGive a program its own read-only mailbox, filtered down to exactly the mail it\nasked for, delivered as files on disk.\n\nmail-muncher pulls messages from a mail provider, evaluates each one against\nordered rules, and writes the matches to a directory — byte-faithful `.eml`,\nand optionally a markdown rendering with the headers as YAML frontmatter, the\nbody as text, and attachments extracted alongside. A rule can take its filter\ninput from a plain text file that *some other program owns*, which mail-muncher\nre-reads at the start of every cycle. That other program changes one line in\nthat file, and the very next cycle delivers different mail — no config edit, no\nrestart, no redeploy.\n\nIt reads from any IMAP mailbox — Gmail, Fastmail, iCloud, Proton Bridge, a work\naccount, your own server — or from Gmail's API with a read-only OAuth scope. It\nruns one-shot for cron, or as a polling daemon, or as a stdio MCP server an\nagent can query directly. Every mode emits the same machine-readable manifest of\nwhat it did, and no mode ever writes to your mailbox.\n\n## Two ways to connect a mailbox\n\nPick one before you install anything. Both are supported, and everything\ndownstream — rules, formats, filenames, the archive layout, the MCP tools — is\nidentical either way.\n\n| | `provider: imap` | `provider: gmail` |\n| --- | --- | --- |\n| Setup time | **~2 min** | **~10 min** in the Google Cloud Console |\n| What you register | nothing | your own Google Cloud project and Desktop-app OAuth client |\n| Credential | an app password from your provider's own settings page | an OAuth token, scope `gmail.readonly` |\n| How wide that credential is | **a full mail credential.** An app password can send and delete | read-only, and nothing else |\n| Who enforces read-only | **mail-muncher's own code** | **Google** |\n| Expiry | none | **every 7 days** on a Testing-mode consent screen; `mail-muncher auth` has to be re-run weekly |\n| Where the secret lives | wherever your password manager already keeps it: `password_cmd` is run and its stdout is the password. There is deliberately no `password` key | `token.json`, mode 0600, written by `mail-muncher auth` |\n| Which mailboxes | the folders you list in `mailboxes:`; `[INBOX]` by default | the whole Gmail account, minus Spam and Trash unless you ask for them |\n| Works with | Gmail, Fastmail, iCloud, Proton Bridge, work accounts, self-hosted | Gmail only |\n| Extra steps | none. There is no `auth` command on this path | `mail-muncher auth`, after [docs/gmail-setup.md](docs/gmail-setup.md) |\n\nThe ~2 min / ~10 min / 7 days above are the same numbers `mail-muncher init`\nand the unconfigured-run guidance print, because they are the numbers that\ndecide this.\n\n**The read-only guarantee is real on both paths, but it is not the same\nguarantee, and flattening the two would be dishonest.**\n\n- **Gmail: enforced by Google.** The only scope requested is `gmail.readonly`.\n  The token that comes back is *incapable* of sending, deleting, labelling or\n  modifying — not because mail-muncher declines to, but because Google will\n  refuse the call. A bug in this program cannot reach your mailbox.\n- **IMAP: enforced by mail-muncher.** IMAP has no read-only credential to ask\n  for. An app password is a full mail credential; the protocol will happily let\n  its holder delete a folder. What mail-muncher does instead is refuse to: every\n  folder is opened with `EXAMINE` and never `SELECT`, every body is fetched with\n  `BODY.PEEK[]` and never `BODY[]` (so mail is never marked read), and there is\n  no code path anywhere in the provider that issues `STORE`, `APPEND` or\n  `EXPUNGE`. Both belts are worn because a server is not obliged to protect a\n  client from itself. That is a strong guarantee and an auditable one — it is\n  just this program's guarantee, not your mail provider's.\n\nIf you have no specific reason to want the Gmail API, start with IMAP. It works\non a Gmail account too, and it is the path the quickstart takes.\n\n## The problem\n\nAn automated process needs some mail. A job-search tracker wants replies from\ncompanies you applied to. A support bot wants messages from one vendor's\ndomain. A research agent wants every newsletter from three publishers, as text\nit can actually read.\n\nThe usual answers are all bad. Hand the process your inbox credentials and it\ncan read (and send, and delete) everything. Give it a mail API integration and\nyou now maintain an OAuth flow, a sync cursor, MIME parsing, and a dedup story\ninside every process that wants mail. Or hard-code the filter into a config\nfile, and every change to *what it wants* is a config edit and a redeploy.\n\nmail-muncher splits that in half. It owns the credentials, the incremental\nsync, the parsing, and the dedup. The consuming program owns a text file\nlisting what it wants and a directory it reads results from — and, if it prefers\nto ask rather than watch, a handful of MCP tools over that same directory.\n\n## The agent workflow\n\nThere are two supported shapes, and they compose. Pick by whether your agent\nruns on a loop of its own or waits to be asked.\n\n- **File drop** — mail-muncher runs on a schedule and writes files; the agent\n  reads the directory. Nothing calls anything. This is the shape below.\n- **Tool call** — the agent talks to `mail-muncher mcp` over MCP and asks\n  questions directly: what am I subscribed to, what arrived, what does this\n  thread say, fetch now. See [Shape 2: tool call](#shape-2-tool-call).\n\nBoth read the same archive, and running both at once is normal: a daemon fills\nthe directory while the MCP server answers questions about it.\n\n### Shape 1: file drop\n\nThe loop is fully decoupled: mail-muncher never calls the agent, and the agent\nneed never call mail-muncher. They share two paths on disk.\n\n**1. The agent declares what it wants.** Append to a file it owns:\n\n```bash\nmkdir -p ~/.local/share/agent\ncat >> ~/.local/share/agent/domains.txt <<'EOF'\n# domains this agent is currently interested in\nacme.com\nglobex.io\nEOF\n```\n\n**2. mail-muncher subscribes to that declaration.** One rule, pointed at the\nfile:\n\n```yaml\nrules:\n  - name: agent-inbox\n    match:\n      from_domains_file: ~/.local/share/agent/domains.txt\n    dest: ~/mail/agent-inbox\n    formats: [eml, markdown]\n```\n\n**3. Every cycle re-reads the file.** Run it from cron, or leave the daemon\nrunning:\n\n```bash\nmail-muncher run                 # one cycle — the cron entrypoint\nmail-muncher daemon --interval 5m  # poll forever\n```\n\n**4. Matched mail lands in `dest` as files the agent reads.**\n\n```\n~/mail/agent-inbox/\n└── 2026/\n    └── 07/\n        ├── 1785230100-a00d5c5e383a1c08-re-your-application-for-senior-engineer.eml\n        ├── 1785230100-a00d5c5e383a1c08-re-your-application-for-senior-engineer.md\n        └── 1785230100-a00d5c5e383a1c08-re-your-application-for-senior-engineer.attachments/\n            └── offer.pdf\n```\n\nThe `.md` is the consumable rendering — parse the frontmatter, feed the body\nto a model, open the attachments from the sibling directory:\n\n```markdown\n---\nsubject: 'Re: Your application for Senior Engineer'\nfrom: Jane Doe <jane@acme.com>\nfrom_address: jane@acme.com\nfrom_addresses: [jane@acme.com]\nto: [me@example.com]\nto_addresses: [me@example.com]\ndate: 2026-07-28T09:15:00Z\nmessage_id: <abc123@acme.com>\nthread_id: 18fe9c0d1a2b3c4d\nthread_id_source: provider\nin_reply_to: <application-000@example.com>\naccount: personal\nrule: job-search\nlabels: [INBOX]\nattachments: [offer.pdf]\n---\n\nHi there,\n\nThanks for applying.\n\n## Attachments\n\n- [offer.pdf](1785230100-a00d5c5e383a1c08-re-your-application-for-senior-engineer.attachments/offer.pdf)\n```\n\n`thread_id` is on every message and is never empty, so grouping a directory into\nconversations is a `sort` on one field — no reference chains to reassemble.\n\n**5. Optionally, take the manifest instead of walking the tree.** `--json`\nwrites a machine-readable record of the cycle to stdout, one object per account,\nwhile every log line goes to stderr:\n\n```bash\nmail-muncher run --json 2>/dev/null | jq -r '.stored[].path'\n```\n\nFull contract: [docs/manifest.md](docs/manifest.md).\n\nThree properties make this safe to put in an autonomous loop:\n\n- **Read-only by construction.** Nothing in mail-muncher writes to a mailbox.\n  On Gmail that is Google's enforcement of the `gmail.readonly` scope; on IMAP\n  it is `EXAMINE` and `BODY.PEEK[]` and no write path at all. Either way,\n  whatever consumes the output — and whatever bug it has — cannot send, delete,\n  or modify mail. See [the comparison above](#two-ways-to-connect-a-mailbox)\n  for which of those two guarantees you are getting.\n- **Idempotent delivery.** A message's filename embeds a digest of\n  `account + \":\" + message id`, so its destination path is a pure function of\n  its identity. A file that is already there means \"an earlier cycle stored\n  this\", and the sink writes nothing. Re-run, replay after losing state, crash\n  mid-cycle, or overlap two cron invocations: the tree converges, and nothing\n  is processed twice.\n- **Deterministic routing.** Rules are ordered and first-match-wins, so each\n  message is written by exactly one rule. Give each consumer its own rule and\n  its own `dest`, and each gets a private mailbox nothing else writes into.\n\nDelivery is files on disk, and nothing here listens on a network. The contract\nis the directory, with the manifest as an optional, machine-readable account of\nwhat changed.\n\n### Shape 2: tool call\n\n`mail-muncher mcp` is a stdio MCP server over the mail already archived. The\nagent asks; nothing is scheduled.\n\n```json\n{\n  \"mcpServers\": {\n    \"mail-muncher\": {\n      \"command\": \"/usr/local/bin/mail-muncher\",\n      \"args\": [\"mcp\", \"--config\", \"/Users/you/.config/mail-muncher/config.yml\"]\n    }\n  }\n}\n```\n\nFive tools:\n\n| Tool | What it answers |\n| --- | --- |\n| `list_rules` | What am I collecting, and which senders am I subscribed to *right now*? Each `from_domains_file` is re-read on every call. |\n| `list_messages` | What has arrived? Filter by rule, account, thread or date; optionally grouped into conversations. |\n| `search_messages` | Where is the message that mentions X? Substring search over subject, sender, recipients, labels, attachment names and body. |\n| `read_message` | One message in full — metadata, body, attachment names and sizes — and optionally its whole thread in order. |\n| `sync` | Fetch new mail once, returning the same manifest `run --json` writes. |\n\nIt is read-only over mail: no tool sends, deletes, or modifies anything, and\n`sync` — the only tool that changes anything at all — can only add files.\nFilesystem access is jailed to the configured rule `dest` roots, so the config,\nany stored credential, and the state directory are unreachable and unnamed.\n\n**An unconfigured `mcp` server starts anyway, and that is deliberate.** If a\nclient launches `mail-muncher mcp` before there is a config, the server does\n*not* exit — it completes the handshake, registers the same five tool names, and\nanswers every call with the setup guidance as a tool error, so the agent has\nsomething to relay instead of \"server failed to start\". If you are wiring this\nup for an operator, that is expected behaviour and not a bug to file. The\nguidance also goes to stderr at startup, where clients tee the server log.\n\nFull reference, client wiring, and every argument and return field:\n[docs/mcp.md](docs/mcp.md).\n\n`list_rules` is the one that closes the loop. The agent writes a domain to its\nown file, then asks `list_rules` and sees its own subscription reflected back —\nthe same list the next cycle will match against.\n\n## Alternatives\n\nRead this before adopting. Several tools do the fetch-filter-deliver shape\nwell, and some of them are a better fit than this one.\n\n| Tool | Use it instead when |\n| --- | --- |\n| [getmail6](https://github.com/getmail6/getmail6) | You want a mature, widely packaged fetcher. It does IMAP and Gmail OAuth2, delivers to Maildir/MDAs, and filters through external programs. If a human (or mutt, or notmuch) is the consumer, this is the stronger tool. |\n| [fdm](https://github.com/nicm/fdm) | You want per-rule Maildir destinations with a compact, well-tested config — exactly this tool's shape, minus the external filter source. Gmail access is app-password IMAP. |\n| [lieer](https://github.com/gauteh/lieer) | You want your whole Gmail mailbox synced bidirectionally into a local Maildir for notmuch, not a filtered subset pulled out of it. |\n| `gmail-archive` | It was almost exactly this — Gmail query to Maildir, incremental — and would be the obvious answer if it were still maintained. It has not been since 2018. |\n| `gmail-exporter` | You want a one-off, label-based, spreadsheet-shaped export rather than incremental sync. |\n| `mbsync` / `offlineimap` | You want full mailbox replication and will filter locally afterwards. |\n\nWhat none of them do, and what this tool exists for: take filter input from a\nfile another program owns and re-read it every cycle, and emit a rendering\nbuilt for a program to consume rather than for a mail client to display. If you\ndo not need both of those, one of the tools above will serve you better and has\nyears more mileage.\n\n## Install\n\nNo Go toolchain required for the first two options.\n\n### Homebrew\n\n```bash\nbrew install craigjmidwinter/tap/mail-muncher\n```\n\nThat taps [craigjmidwinter/homebrew-tap](https://github.com/craigjmidwinter/homebrew-tap)\nand installs a prebuilt binary. `brew upgrade mail-muncher` tracks new\nreleases.\n\n### Download a binary\n\nEvery [release](https://github.com/craigjmidwinter/mail-muncher/releases/latest)\nships archives for macOS and Linux on both amd64 and arm64, plus a\n`checksums.txt` and a signature over it.\n\n```bash\n# Latest release, without the leading v. Set this by hand to pin a version.\nVERSION=$(curl -fsSL https://api.github.com/repos/craigjmidwinter/mail-muncher/releases/latest \\\n  | sed -n 's/.*\"tag_name\": *\"v\\{0,1\\}\\([^\"]*\\)\".*/\\1/p')\n\nOS=$(uname -s | tr '[:upper:]' '[:lower:]')     # darwin | linux\nARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')\n\ncurl -fsSLO \"https://github.com/craigjmidwinter/mail-muncher/releases/download/v${VERSION}/mail-muncher_${VERSION}_${OS}_${ARCH}.tar.gz\"\ntar xzf \"mail-muncher_${VERSION}_${OS}_${ARCH}.tar.gz\" mail-muncher\nsudo install -m 0755 mail-muncher /usr/local/bin/mail-muncher\n```\n\nIf the binary then refuses to run at all —\n\n```\nbash: mail-muncher: cannot execute binary file: Exec format error\n```\n\n— you have an archive for the wrong architecture. That message comes from the\nkernel and says nothing about mail-muncher, so it is worth knowing the shape of\nit. Compare `uname -m` against the `_amd64` / `_arm64` in the filename you\ndownloaded; the `ARCH=` line above computes the right one for you, so this only\nbites if you set the name by hand.\n\n**No root?** `/usr/local/bin` needs it; `~/.local/bin` does not. Drop the\n`sudo` and install there instead — nothing about mail-muncher wants a\nsystem-wide location:\n\n```bash\ninstall -d ~/.local/bin\ninstall -m 0755 mail-muncher ~/.local/bin/mail-muncher\n```\n\nIf `mail-muncher` is then \"command not found\", `~/.local/bin` is not on your\n`PATH`; add it in your shell profile.\n\nSkipping the `sudo` without changing the destination fails with a `Permission\ndenied` from `install` itself — on macOS naming a scratch file rather than\n`mail-muncher`, which is confusing the first time you see it:\n\n```\ninstall: /usr/local/bin/INS@LPh1Hz: Permission denied     # macOS\ninstall: cannot create regular file '/usr/local/bin/mail-muncher': Permission denied   # GNU\n```\n\nEither message means the same thing: pick the `~/.local/bin` route above, or\nput the `sudo` back.\n\nOn macOS, a binary you downloaded yourself is quarantined by Gatekeeper. Clear\nit with `xattr -d com.apple.quarantine /usr/local/bin/mail-muncher`, or use the\nHomebrew install above, which does this for you.\n\n#### Verify what you downloaded\n\nThis tool reads your mail. Check that the archive is the one the release\nworkflow built. First the checksum:\n\n```bash\ncurl -fsSLO \"https://github.com/craigjmidwinter/mail-muncher/releases/download/v${VERSION}/checksums.txt\"\n\n# Linux\nsha256sum --check --ignore-missing checksums.txt\n# macOS\nshasum -a 256 --check --ignore-missing checksums.txt\n```\n\nThen the signature over `checksums.txt`. Releases are signed keylessly with\n[cosign](https://docs.sigstore.dev/cosign/system_config/installation/) — there\nis no public key to fetch and no private key anyone has to guard. The\nsigning certificate is issued to the release workflow's own GitHub OIDC\nidentity and recorded in the public Rekor transparency log, so what you are\nchecking is \"this was built by `release.yml` in this repo, from a tag\":\n\n`cosign` is not installed by default on any platform and is not in the usual\ndistro repositories, so `cosign: command not found` here means \"not installed\nyet\", not \"verification failed\". Get it first — `brew install cosign`, or\n`go install github.com/sigstore/cosign/v2/cmd/cosign@latest`, or a release\nbinary from [the install\ndocs](https://docs.sigstore.dev/cosign/system_config/installation/).\n\n```bash\ncurl -fsSLO \"https://github.com/craigjmidwinter/mail-muncher/releases/download/v${VERSION}/checksums.txt.sig\"\ncurl -fsSLO \"https://github.com/craigjmidwinter/mail-muncher/releases/download/v${VERSION}/checksums.txt.pem\"\n\ncosign verify-blob \\\n  --certificate checksums.txt.pem \\\n  --signature checksums.txt.sig \\\n  --certificate-identity-regexp '^https://github\\.com/craigjmidwinter/mail-muncher/\\.github/workflows/release\\.yml@refs/tags/' \\\n  --certificate-oidc-issuer https://token.actions.githubusercontent.com \\\n  checksums.txt\n```\n\n`Verified OK` means the checksum file is authentic; the `sha256sum` step then\nties your archive to it. cosign 3 prints a deprecation notice for\n`--certificate` and `--signature` — the check still runs, and these detached\nfiles are what cosign 2 understands too.\n\n### `go install`\n\nThe right path if you already have Go 1.25 or newer:\n\n```bash\ngo install github.com/craigjmidwinter/mail-muncher/cmd/mail-muncher@latest\n```\n\nNote that `go install` builds report `dev` for `--version`, because the version\nis stamped at link time and the `go` tool does not do it. Released binaries and\n`make build` report the real tag. If you file a bug from a `go install` build,\nsay which commit you installed.\n\n### Build from source\n\n```bash\ngit clone https://github.com/craigjmidwinter/mail-muncher\ncd mail-muncher\nmake build          # -> ./mail-muncher, version stamped from git describe\n```\n\n`make snapshot` builds the full set of release archives locally (requires\n[goreleaser](https://goreleaser.com)) if you want to check what a release would\ncontain.\n\nThe example configs referenced below live in [`examples/`](examples/) —\n[`imap.yml`](examples/imap.yml), [`minimal.yml`](examples/minimal.yml) and\n[`job-search.yml`](examples/job-search.yml). They are also bundled inside every\nrelease archive, so a binary download has them too. You do not need them to get\nstarted, though: `mail-muncher init` writes a config from scratch.\n\n### Container image\n\n```bash\ndocker pull ghcr.io/craigjmidwinter/mail-muncher:latest\n```\n\n`linux/amd64` and `linux/arm64`, built from the same binaries the release\narchives carry. The image's default command is `mcp`, because serving the\narchive over stdio is the mode a container suits: a client starts it, talks to\nit, and stops it. `run` and `daemon` work too — override the command — but on a\nhost those are a cron line and a launchd/systemd unit, which fit better.\n\nTwo mounts, and both matter:\n\n```bash\n# -e IMAP_PASSWORD forwards the variable, it does not invent it: export it\n# first, from wherever you actually keep the secret.\nexport IMAP_PASSWORD=\"$(security find-generic-password -s mail-muncher -w)\"\n\ndocker run -i --rm \\\n  -e IMAP_PASSWORD \\\n  -v ~/.config/mail-muncher:/home/muncher/.config/mail-muncher:ro \\\n  -v ~/.local/share/mail-muncher:/home/muncher/archive \\\n  ghcr.io/craigjmidwinter/mail-muncher:latest mcp\n```\n\nThat `export` pairs with `password_cmd: printenv IMAP_PASSWORD` in the config —\nsee the note below on why your host password manager is not reachable from\ninside the container.\n\n**Every path inside `config.yml` has to be a path the container can see.** A\n`dest:` of `~/Mail/receipts` resolves against the container's home directory,\nnot yours, so mail lands on a layer that disappears when the container exits.\nPoint `dest:` at the mounted directory — `/home/muncher/archive/receipts` for\nthe mount above — or you will archive into the void and the manifest will\ncheerfully tell you it worked.\n\n**`password_cmd` runs inside the container**, under `/bin/sh`, which means your\nhost password manager is not there. `pass show mail/fastmail` cannot work. Use\nthe secret material the container does have:\n\n```yaml\npassword_cmd: printenv IMAP_PASSWORD          # -e IMAP_PASSWORD\npassword_cmd: cat /run/secrets/imap-password  # docker secret or a mounted file\n```\n\nThis is the one place the container path is genuinely worse than a host\ninstall: it moves the credential out of your password manager and into the\ncontainer's environment. If that trade is not worth it to you, install the\nbinary — `password_cmd` is designed for the host case, and this is the\ncompromise, not the intent.\n\nThe image is also what backs the [MCP Registry](https://registry.modelcontextprotocol.io)\nlisting; [`server.json`](server.json) is that entry, and its `name` has to match\nthe `io.modelcontextprotocol.server.name` label baked into the image.\n\nPublishing that entry is automatic. Tagging a release builds and pushes the\nimage, and then a second job rewrites `version` and the image tag in\n`server.json` from the git tag and publishes to the registry, authenticating\nwith the workflow's own OIDC identity rather than a stored token.\n\n**So the `version` committed in `server.json` is last release's, and lags by\none tag on purpose.** The tag is the source of truth; the file is a template\nthat CI stamps. Bumping it by hand achieves nothing.\n\n### As a Claude Code skill\n\nThe repo ships a skill and plugin package under [`skills/`](skills/), which\ninstalls mail-muncher as something an agent can set up and drive for you —\nwriting the config, running `auth`, and wiring the MCP server into your client.\nIf that is how you want to adopt it, start there instead of the quickstart\nbelow.\n\nThe skill leads with `provider: imap` and drives `mail-muncher init`, so it\ntakes the same two-minute route this README does rather than sending you to the\nGoogle Cloud Console.\n\n### Windows\n\nThere is no Windows build, and none of the options above quietly work around\nthat. Homebrew does not run on Windows. The release archives are `darwin` and\n`linux` only, and the download snippet above is a POSIX shell script built on\n`uname`, which PowerShell and `cmd` cannot run at all.\n\n`go install` is the one path that produces something, and that is the problem\nworth stating plainly. Go cross-compiles this module cleanly — no cgo, no\nplatform build tags outside a test file — so you get a `mail-muncher.exe` that\nstarts, and `mail-muncher init` that writes a config without complaint. It\nstops at the first `run`. The IMAP provider, the ~2 min path this README leads\nwith, executes `imap.password_cmd` by handing it to `/bin/sh -c`\n([`internal/provider/imap/password.go`](internal/provider/imap/password.go)),\nand a stock Windows machine has no `/bin/sh`. `init` is careful enough not to\nseed a Windows config with a macOS or Linux secret tool, but the command it\ndoes seed still goes to a shell that is not there, so the failure arrives late\nand blames the wrong thing.\n\nThe Gmail provider has no such dependency — its OAuth flow already picks\n`rundll32` on Windows — so it may work end to end. It is untested there and\nunsupported.\n\nWhat does work on Windows: the [container image](#container-image) under\nDocker Desktop, or either install path inside WSL2, where a Linux binary and\n`/bin/sh` both exist.\n\n### Upgrade\n\n```bash\nbrew upgrade mail-muncher            # Homebrew\ngo install github.com/craigjmidwinter/mail-muncher/cmd/mail-muncher@latest\n```\n\nFor a downloaded binary, repeat the download steps above — the `install` step\noverwrites in place. Nothing else has to change: the config schema and the\non-disk layout are stable within 0.x, and sync cursors in `state_dir` are read\nby any newer version. [CHANGELOG.md](CHANGELOG.md) records anything that would\nmake that untrue, and there is nothing there yet.\n\nCheck what you landed on with `mail-muncher --version`. A build without `make`\nreports `dev` — that is the `go install` version-stamp gotcha, not a broken\ninstall.\n\n### Uninstall\n\nRemoving the binary leaves everything else behind, so this is in the order\nthat removes the most sensitive material first. Nothing here is done for you:\n\n```bash\n# 1. Stop it, if you scheduled it.\nlaunchctl unload ~/Library/LaunchAgents/com.craigjmidwinter.mail-muncher.plist\nrm ~/Library/LaunchAgents/com.craigjmidwinter.mail-muncher.plist\nrm -f ~/Library/Logs/mail-muncher.out.log ~/Library/Logs/mail-muncher.err.log\n# or, if you used cron: crontab -e and delete the line.\n\n# 2. The credential. This is the part nothing else will clean up.\nsecurity delete-generic-password -s mail-muncher      # macOS Keychain, IMAP\n# Gmail instead: revoke the app at https://myaccount.google.com/permissions\n\n# 3. Config, credentials and the OAuth token.\nrm -rf ~/.config/mail-muncher\n\n# 4. Sync cursors, both lockfiles, and the quarantine directory.\nrm -rf ~/.local/state/mail-muncher\n\n# 5. The binary.\nbrew uninstall mail-muncher          # Homebrew\nrm -f /usr/local/bin/mail-muncher    # downloaded binary\nrm -f \"$(go env GOPATH)/bin/mail-muncher\"   # go install\n```\n\n**Your archived mail is deliberately not on that list.** It lives at whatever\n`dest` your rules named — `~/Mail/mail-muncher` if you took the `init` default\n— and those are ordinary files that outlive the tool, which is the whole point\nof the format. `grep -n 'dest:' ~/.config/mail-muncher/config.yml` before step\n3 if you want the paths, and delete them yourself if you want the mail gone.\n\nThe Homebrew cask carries no `zap` stanza, so `brew uninstall` removes the\nbinary and nothing under your home directory. That is on purpose — mail this\ntool has already written is yours, and an uninstaller is a bad place to\ndiscover otherwise.\n\n## Quickstart\n\nAbout five minutes, no browser, no clone. This is the IMAP path; for the Gmail\nAPI instead, read [Quickstart: Gmail](#quickstart-gmail) below **before** you\nstart, because it costs about ten minutes in the Google Cloud Console and the\ntoken expires weekly.\n\n**0. Check the install.** Right after installing, before there is any config:\n\n```bash\nmail-muncher run\n```\n\nThat is a genuinely useful smoke test rather than a mistake. It exits 1 and\ntells you exactly where it looked, what to run next, and what each provider\ncosts:\n\n```\nmail-muncher is not configured.\n  missing config file: /Users/you/.config/mail-muncher/config.yml\n  next command:        mail-muncher init\n  then:                mail-muncher validate && mail-muncher run --dry-run\n\ninit asks which provider to use. Both are supported; the costs differ.\n  provider: imap   ~2 min. Gmail, Fastmail, Proton Bridge, work accounts,\n    self-hosted. Needs an app password, which is a broader credential than a\n    read-only OAuth token; mail-muncher only ever issues BODY.PEEK.\n  provider: gmail  ~10 min in the Google Cloud Console: gmail.readonly is a\n    Google restricted scope, so mail-muncher ships no OAuth client and you\n    register your own. Google enforces read-only, but on a Testing-mode\n    consent screen the refresh token expires every 7 days, so\n    \"mail-muncher auth\" must be re-run weekly. Read docs/gmail-setup.md.\nDocs: https://craigjmidwinter.github.io/mail-muncher/\n```\n\nEvery command that needs a config says this, so a broken install and an\nunconfigured one never look alike.\n\n**1. Get an app password.** From your mail provider's own settings page —\nGmail, Fastmail, iCloud, Proton, your work account. It is scoped to this one\nuse and you can revoke it without touching anything else. Put it wherever you\nalready keep secrets:\n\n```bash\n# macOS Keychain\nsecurity add-generic-password -s mail-muncher -a \"$USER\" -w\n\n# or pass, 1Password, secret-tool, gpg — anything that prints it on stdout\n```\n\nmail-muncher never stores this. It runs a command you name and reads the\npassword off that command's stdout, so the secret stays in your password\nmanager. There is deliberately no `password:` key in the config schema.\n\n**2. Write a config.**\n\n```bash\nmail-muncher init --provider imap\n```\n\n```\nAccount name [personal]:\nWrite matched mail to [~/Mail/mail-muncher]:\nIMAP host: imap.fastmail.com\nIMAP username: you@fastmail.com\nPassword command [security find-generic-password -s mail-muncher -w]:\nWrote /Users/you/.config/mail-muncher/config.yml\n\nNext, for provider imap:\n  1. Run that password_cmd in a shell and check it prints the password and\n     nothing else, for example:\n       security find-generic-password -s mail-muncher -w | cat -A\n     Anything else on stdout - a prompt, a warning, a trailing blank line -\n     becomes part of the password and the login fails. If it is not there\n     yet, create an app password with your mail provider first and store it\n     where password_cmd can read it.\n  2. mail-muncher validate\n  3. mail-muncher run --dry-run     then     mail-muncher run\nMatched mail lands in ~/Mail/mail-muncher\nDocs: https://craigjmidwinter.github.io/mail-muncher/\n```\n\n**There is no editing step.** `init` asks for everything the IMAP path needs\nand writes a config that validates on the first try. The password command is\noffered with your platform's default already filled in — Keychain on macOS,\n`secret-tool` on Linux, `pass` elsewhere — so pressing Enter through it is a\nreal answer, not a placeholder.\n\nEvery question has a flag, for answering up front or scripting the whole\nthing:\n\n```bash\nmail-muncher init --provider imap --yes \\\n  --host imap.fastmail.com --username you@fastmail.com\n```\n\n`--yes` takes the default for everything that has an honest one, which is why\nit still requires `--provider`, `--host` and `--username`. Those three have no\ndefault worth guessing, and it says so rather than writing a placeholder:\n\n```\nerror: --host and --username required with --yes --provider imap; host and\nusername have no honest default to take. Run `mail-muncher init --provider\nimap` without --yes to be prompted instead\n```\n\nAdd `--account NAME`, `--dest DIR` and `--password-cmd CMD` to answer the rest.\nAn existing config is never overwritten without `--force`.\n\n`~/.config/mail-muncher/config.yml` is the default path; `--config` overrides\nit everywhere, including for `init`.\n\n**3. Check the password command prints the password, and nothing else.**\n\n```bash\nsecurity find-generic-password -s mail-muncher -w | cat -A\n```\n\n`| cat -A` makes a stray prompt, warning, or trailing blank line visible.\nAnything extra on stdout becomes part of the password and the login fails —\nthis is the single most common reason a first run cannot authenticate.\n\nHere is what `init` wrote, for reference; it is commented throughout, and\n[`examples/imap.yml`](examples/imap.yml) is a fuller worked version:\n\n```yaml\naccounts:\n  - name: personal\n    provider: imap\n    imap:\n      host: imap.fastmail.com\n      username: you@fastmail.com\n      password_cmd: security find-generic-password -s mail-muncher -w\n      mailboxes: [INBOX]\n```\n\n**4. Check the config.**\n\n```bash\nmail-muncher validate\n```\n\n```\nconfig: /Users/you/.config/mail-muncher/config.yml\n1 account(s), 1 rule(s), state_dir /Users/you/.local/state/mail-muncher\nOK\n```\n\nAn IMAP account validates clean: no credential file to find, no token to have\nwritten yet, nothing on disk at all. `OK` with no warnings is the expected\nresult. `validate` parses the config, compiles every rule's match tree, and\nchecks the files it references. Missing files that another program owns — a\n`from_domains_file`, or on the Gmail path the OAuth credentials and token — are\nwarnings, not errors:\n\n```\nwarning: rules[0].match.any[0].from_domains_file: file does not exist yet: /Users/you/.local/share/jobsearch/domains.txt (it is maintained by another program; the rule matches nothing until it appears)\nOK with 1 warning\n```\n\n**5. See what a real run would do.**\n\n```bash\nmail-muncher run --dry-run\n```\n\nA dry run connects, fetches and evaluates exactly as a real run does, and\nreports the path each match *would* be written to. It writes no files and does\nnot save sync state, so you can run it as many times as you like. This is also\nwhere a wrong host, username or `password_cmd` surfaces, named exactly:\n\n```\nerror: account \"personal\": imap: password_cmd \"security find-generic-password -s mail-muncher -w\" failed: exit status 44: security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain.\n```\n\n**6. Run it.**\n\n```bash\nmail-muncher run\n```\n\nThe config `init` wrote carries one starter rule matching everything newer than\n72h, so this first run is guaranteed to store something — a run that stores\nnothing is indistinguishable from a broken install. Then run it again:\neverything already on disk reports as `skipped`, and the incremental cursor\nmeans the second run barely talks to the server at all.\n\nOnce that works, narrow the starter rule into what you actually want\n([docs/filters.md](docs/filters.md)), then put it on a schedule (see\n[Scheduling](#scheduling)). [docs/configuration.md](docs/configuration.md) has\nevery key.\n\n### Quickstart: Gmail\n\nTake this path if you specifically want the Gmail API and a read-only guarantee\nenforced by Google rather than by this program. **Know the two costs before you\nbegin**, because both are structural and neither goes away:\n\n- **About ten minutes in the Google Cloud Console, up front.** `gmail.readonly`\n  is a Google *restricted* scope, so mail-muncher ships no OAuth client and\n  never will — you register your own project and Desktop-app client and\n  download its JSON.\n- **The token expires every 7 days.** Google applies that to every consent\n  screen still in Testing mode, which yours will be. `mail-muncher auth` has to\n  be re-run weekly. There is no setting that removes it;\n  [docs/gmail-setup.md](docs/gmail-setup.md#the-seven-day-refresh-token-expiry)\n  explains why and what the alternatives cost.\n\nIf neither is worth it to you, the IMAP path above works on a Gmail account.\n\n```bash\nmail-muncher init --provider gmail        # prints the cost warning, then writes the config\n# → follow docs/gmail-setup.md: project, Gmail API, consent screen,\n#   Desktop app OAuth client, save its JSON as\n#   ~/.config/mail-muncher/credentials.json\nmail-muncher auth --account personal      # browser consent; writes token.json 0600\nmail-muncher validate\nmail-muncher run --dry-run\nmail-muncher run\n```\n\n`auth` prints a consent URL (and tries to open a browser), listens on a\nloopback port for the redirect, and writes the token to the account's\n`token_file` with mode 0600. It is a Gmail-only command — on an IMAP account it\nrefuses, because there is nothing to authorize. Steps 4 through 6 of the IMAP\nquickstart above then apply unchanged; `validate` will report two warnings until\nthe credentials and token files exist.\n\n## Externally-managed filter files\n\nThis is the feature the tool is built around, so it is worth being precise\nabout the semantics.\n\n`from_domains_file` names a file that mail-muncher does not own, does not\ncreate, and never writes:\n\n```yaml\nmatch:\n  from_domains_file: ~/.local/share/jobsearch/domains.txt\n```\n\n```\n# ~/.local/share/jobsearch/domains.txt\n# written by the job-search tracker\n\nacme.com\nglobex.io          # inline comments are fine\n@initech.com       # a leading @ is stripped\nMAIL.Umbrella.COM  # case is irrelevant\n```\n\n- **Read once per cycle, on first use.** Not once per process, and not once per\n  message. `run` re-reads it; every daemon tick re-reads it. A file referenced\n  by several rules is read once and shared.\n- **Missing or unreadable is never fatal.** The predicate simply matches\n  nothing and one warning is logged for that file for that cycle. The owning\n  program may not have created it yet, and mail-muncher must not fail because\n  of that.\n- **Liberal parsing.** One entry per line; `#` starts a comment; blank lines are\n  skipped; surrounding whitespace is trimmed; a leading `@` and a trailing `.`\n  are stripped; everything is lowercased; duplicates collapse. An entry with no\n  dot in it is kept and logged as suspicious rather than dropped, because the\n  file belongs to someone else and guessing wrong should not silently discard\n  an entry.\n- **Equality or subdomain.** `acme.com` matches `acme.com` and\n  `careers.acme.com`, but not `notacme.com`.\n\nThe same matching rules apply to the inline `from_domains:` predicate; the only\ndifference is who owns the list.\n\n### When a domain list cannot express it\n\nSome senders cannot be enumerated in advance. One company's mail might arrive\nfrom `wagepoint.teamtailor.com`, `mail.wagepoint.com` and\n`notifications@wagepoint-hr.example` — a domain list can only name hosts you\nalready know about. `from_regex_file` is the same idea for patterns:\n\n```yaml\nmatch:\n  from_regex_file: ~/.local/share/jobsearch/companies.txt\n```\n\n```\n# one RE2 pattern per line, unanchored\nwagepoint\n(?i)^careers@acme\\.io$\nteamtailor\\.com$\n```\n\nLifecycle is identical — read once per cycle, missing is never fatal,\n`on_degraded_filter` governs the cursor. Two deliberate differences from the\ndomain format:\n\n- **Nothing is lowercased**, because a regex is case-sensitive by construction.\n  Write `(?i)` when you want otherwise.\n- **`#` only starts a comment at the start of a line.** Truncating a pattern at\n  a mid-line `#` would silently change what it matches.\n\n**The failure modes are opposites, and that is why the guards differ.** A typo\nin a domain list matches *nothing* — the cost is silence. A typo in a pattern\nlist can match *everything*: `.*` or a stray blank claims the entire mailbox.\nSo an empty pattern, or any pattern that matches the empty string, is refused\noutright; a pattern that fails to compile is refused **by itself** while the\nrest of the file stays in force; and the count of patterns loaded is logged\nevery cycle, so a file that fell from twelve patterns to one catch-all is a\nnumber in your run output rather than a discovery by way of a full disk.\n\n## Configuration\n\nFull reference: [docs/configuration.md](docs/configuration.md). Runnable files:\n[`examples/imap.yml`](examples/imap.yml),\n[`examples/minimal.yml`](examples/minimal.yml),\n[`examples/job-search.yml`](examples/job-search.yml).\n\nThe account block is the only part that differs by provider. IMAP:\n\n```yaml\naccounts:\n  - name: personal\n    provider: imap\n    imap:\n      host: imap.fastmail.com\n      port: 993                  # default\n      tls: true                  # default\n      username: you@fastmail.com\n      password_cmd: pass show mail/fastmail   # stdout is the password\n      mailboxes: [INBOX, Archive]             # default [INBOX]\n      initial_lookback: 720h                  # default\n```\n\nGmail:\n\n```yaml\nstate_dir: ~/.local/state/mail-muncher\n\non_message_failure: quarantine   # or: abort\non_degraded_filter: hold         # or: fail, proceed\n\naccounts:\n  - name: personal\n    provider: gmail\n    gmail:\n      credentials_file: ~/.config/mail-muncher/credentials.json\n      token_file: ~/.config/mail-muncher/token.json\n      query: \"-in:chats\"\n      initial_lookback: 2160h\n\nrules:\n  - name: job-search\n    account: personal\n    match:\n      any:\n        - from_domains_file: ~/.local/share/jobsearch/domains.txt\n        - subject_regex: \"(?i)your application\"\n    dest: ~/Mail/job-search\n    formats: [eml, markdown]\n```\n\n| Key | Type | Default | Description |\n| --- | --- | --- | --- |\n| `state_dir` | path | `~/.local/state/mail-muncher` | Sync cursors (one JSON file per account), the cycle lock, the instance lock, and the quarantine directory. |\n| `on_message_failure` | `quarantine`, `abort` | `quarantine` | What to do with a message that will not parse or that a sink failed on. See below. |\n| `on_degraded_filter` | `hold`, `fail`, `proceed` | `hold` | What to do when a rule's `from_domains_file` or `from_regex_file` cannot be read. See below. |\n| `quarantine_dir` | path | `<state_dir>/quarantine` | Where quarantined messages are parked. |\n| `accounts` | list | — | Mailboxes to pull from. At least one is required. |\n| `accounts[].name` | string | — | Required, unique. Names the state file and is what `rules[].account` refers to. |\n| `accounts[].provider` | `imap`, `gmail` | — | **Required**; there is no default. Which backend fetches. See [Two ways to connect a mailbox](#two-ways-to-connect-a-mailbox). |\n| `accounts[].imap` | mapping | — | Required — and only permitted — when the provider is `imap`. |\n| `accounts[].imap.host` | string | — | Required. `imap.fastmail.com`, `imap.gmail.com`, `127.0.0.1` for the Proton Bridge. |\n| `accounts[].imap.port` | integer | `993` | 993 is implicit TLS (IMAPS) and pairs with the `tls: true` default. |\n| `accounts[].imap.username` | string | — | Required. Usually the full address; some providers want the bare local part. |\n| `accounts[].imap.password_cmd` | shell command | — | Required. Run under `/bin/sh -c`; its stdout is the password. **There is deliberately no `password` key** — the secret stays in your password manager. |\n| `accounts[].imap.mailboxes` | list of strings | `[INBOX]` | Folders to fetch, each with its own cursor. A name doubles as the `label` predicate value. A folder the server does not have is an error, not an empty folder. |\n| `accounts[].imap.tls` | boolean | `true` | Implicit TLS on connect. `false` sends the password and every body in the clear; `validate` warns. Legitimate only on loopback or behind an stunnel. |\n| `accounts[].imap.initial_lookback` | Go duration | `720h` | How far back a first-ever sync of each mailbox reaches, and again after any UIDVALIDITY change. Must be positive. |\n| `accounts[].gmail` | mapping | — | Required — and only permitted — when the provider is `gmail`. |\n| `accounts[].gmail.credentials_file` | path | — | Required. The OAuth **client** JSON downloaded from Google Cloud. |\n| `accounts[].gmail.token_file` | path | — | Required. Where `auth` caches the OAuth token, mode 0600. |\n| `accounts[].gmail.query` | string | none | Optional Gmail search expression. A cost optimization for the **first-ever** scan only — see below. |\n| `accounts[].gmail.initial_lookback` | Go duration | `720h` | How far back the first-ever scan reaches. Must be positive. See [Backfill](#backfill-the-first-run). |\n| `accounts[].gmail.include_spam_trash` | boolean | `false` | Fetch messages in Spam and Trash. Honoured identically by both Gmail sync paths. `validate` warns when true. See [Spam and Trash](#spam-and-trash-gmail). |\n| `rules` | list | — | Evaluated in order against every message; first match wins. |\n| `rules[].name` | string | — | Required, unique. Appears in logs and in markdown frontmatter. |\n| `rules[].account` | string | all accounts | Restricts the rule to one account. |\n| `rules[].match` | match node | — | Required. See [Filters](#filters). |\n| `rules[].dest` | path | — | Required. Destination directory; created on demand. |\n| `rules[].formats` | list of `eml`, `markdown` | `[eml]` | Renderings to write. |\n\nNotes that bite people:\n\n- **Unknown keys are a hard error.** A typo fails the load rather than being\n  ignored, so `validate` catches `initial_lookbak` before a run does.\n- **`~` and `$VAR` are expanded** in every path-valued field, including\n  `from_domains_file` and `from_regex_file` values inside a match tree. `~user` forms are not\n  supported. An undefined variable expands to the empty string, as in a shell.\n- **`gmail.query` does not filter what gets kept, and applies to less than you\n  think.** It is sent to Gmail on the **first-ever** scan of an account and\n  nowhere else — not on incremental cycles, and not on a recovery scan after the\n  history cursor expires. It is never re-applied locally. Your rules are the\n  only authority on what is stored. Keep the query broad, or omit it.\n- **Spam and Trash are not fetched by default (Gmail).** Both Gmail sync paths\n  agree on this: full scans pass `includeSpamTrash=false`, and the incremental\n  path drops messages labelled `SPAM` or `TRASH` before they reach the pipeline.\n  Set `gmail.include_spam_trash: true` to fetch them anyway — see\n  [Spam and Trash](#spam-and-trash-gmail). On IMAP there is no equivalent key: you\n  fetch exactly the folders you list in `mailboxes:`, so simply not listing the\n  junk folder is the whole mechanism.\n- **The `gmail:` and `imap:` blocks are mutually exclusive.** Setting the one\n  that does not match `provider:` is a hard error rather than a silently ignored\n  block, so an `imap:` block under a Gmail account cannot leave you believing\n  you are fetching over IMAP when you are not.\n\n### Policies for the two things that can go wrong\n\nBoth keys sit at the top level, beside `state_dir`. The defaults are the safe\nchoices; you only change them if you have decided which failure you prefer.\n\n**`on_message_failure`** — a message that will not parse, or where every\nrendering its rule asked for failed to write.\n\n| Value | Behavior |\n| --- | --- |\n| `quarantine` (default) | Write the raw bytes to `<quarantine_dir>/<account>/<id>.eml` with a `.json` sidecar naming the failure, then let the cursor advance past the message. Nothing is lost, and one poison message cannot wedge the pipeline. Counted as `quarantined` in the summary and manifest; the run still exits 0. |\n| `abort` | Return the failure, so the cursor does **not** advance and the message is re-fetched next cycle. The trade-off is explicit: a permanently unparseable message wedges the account until a human deals with it. |\n\nA quarantine write that itself fails falls back to `abort` semantics for that\nmessage — refusing to advance is recoverable, losing the message is not.\n\n**`on_degraded_filter`** — a rule's `from_domains_file` or `from_regex_file` is missing, unreadable,\nor truncated partway through. Such a file matches nothing, so without a policy\nevery message that cycle would be evaluated against an empty list, found not to\nmatch, and consumed.\n\n| Value | Behavior |\n| --- | --- |\n| `hold` (default) | Run the cycle and store everything that did match, log the degradation at error level, but do **not** save the advanced cursor — so the same mail is re-evaluated once the file returns. The manifest reports `degraded` and `state_held`. Exit 0. |\n| `fail` | End the cycle before anything is fetched. Nothing stored, nothing advanced, non-zero exit. |\n| `proceed` | Treat an unreadable list as an empty one and advance anyway. The old behavior, and the only option that accepts silent loss of wanted mail — `validate` warns about it. |\n\nFiles already stored under `hold` stay stored: the sinks are idempotent, so the\nre-run skips them.\n\n## Filters\n\nFull reference and cookbook: [docs/filters.md](docs/filters.md).\n\nA `match:` value is a mapping with **exactly one key** — a combinator or a\npredicate. Two keys in one mapping is a compile error that tells you to combine\nthem with `all:` or `any:`. Regexes and durations are compiled when the config\nloads, so a bad pattern is a `validate` failure, not a surprise at 3am.\n\n### Combinators\n\n| Key | Value | Matches when |\n| --- | --- | --- |\n| `all` | list of nodes | every child matches (at least one child required) |\n| `any` | list of nodes | at least one child matches (at least one child required) |\n| `not` | a single node | the child does not match |\n\n```yaml\nmatch:\n  all:\n    - any:\n        - from_domains: [acme.com]\n        - from_domains_file: ~/.local/share/agent/domains.txt\n    - not:\n        subject_regex: \"(?i)^\\\\[newsletter\\\\]\"\n```\n\n### Predicates\n\n| Key | Value | Matches when |\n| --- | --- | --- |\n| `from_domains` | list of domains | any `From` address's domain equals or is a subdomain of a listed domain |\n| `from_domains_file` | path | same, with the list read from an externally-owned file each cycle |\n| `from_regex` | RE2 pattern | the pattern matches any `From` addr-spec (no display name) |\n| `from_regex_file` | path | same, with the patterns read from an externally-owned file each cycle |\n| `to_regex` | RE2 pattern | the pattern matches any `To` or `Cc` addr-spec |\n| `subject_regex` | RE2 pattern | the pattern matches the decoded `Subject` |\n| `header` | `{name: X-Foo, regex: ...}` | the pattern matches any value of that header |\n| `has_attachment` | `true` / `false` | the message does (or does not) carry a real attachment |\n| `label` | label name | the message carries that provider label, compared exactly. On Gmail that is a Gmail label; on IMAP it is the name of the mailbox the message came from |\n| `older_than` | Go duration | the message `Date` is further in the past than the duration |\n| `newer_than` | Go duration | the message `Date` is more recent than the duration |\n\nOne worked example each:\n\n```yaml\n# Mail from a company or any of its subdomains.\n- from_domains: [acme.com, globex.io]\n\n# The same list, owned and updated by another program.\n- from_domains_file: ~/.local/share/jobsearch/domains.txt\n\n# A specific sender, however they capitalize it.\n- from_regex: \"(?i)^no-?reply@acme\\\\.com$\"\n\n# Patterns owned and updated by another program, for senders whose host cannot\n# be enumerated in advance (wagepoint.teamtailor.com, mail.wagepoint.com).\n- from_regex_file: ~/.local/share/jobsearch/companies.txt\n\n# Anything addressed to a plus-alias you hand out to vendors.\n- to_regex: \"(?i)^me\\\\+vendors@example\\\\.com$\"\n\n# Application acknowledgements, case-insensitively.\n- subject_regex: \"(?i)(your application|application received)\"\n\n# Everything a mailing list tags for you.\n- header: {name: List-Id, regex: \"golang-nuts\"}\n\n# Only messages that actually carry a file.\n- has_attachment: true\n\n# On Gmail: labels exactly as shown in the UI. Nested labels use \"Parent/Child\";\n# system labels are upper case (INBOX, SENT, UNREAD, STARRED).\n# On IMAP: the mailbox the message came from, verbatim as the server names it,\n# including its hierarchy separator (\"Lists/golang\", \"Lists.golang\").\n- label: INBOX\n\n# Message Date older than 90 days / newer than a day.\n- older_than: 2160h\n- newer_than: 24h\n```\n\nDetails worth knowing:\n\n- `from_regex` and `to_regex` test the bare address (`jane@acme.com`), never the\n  display name. Use `header: {name: From, regex: ...}` to test the raw header\n  including the display name.\n- `has_attachment` counts parts marked `Content-Disposition: attachment`.\n  Inline images referenced by `cid:` are not attachments.\n- `label` is case-sensitive and exact — `label: inbox` does not match `INBOX`.\n  On an IMAP account the values are the mailbox names you listed under\n  `imap.mailboxes`, so a message can carry only the one it was fetched from.\n- `older_than` / `newer_than` compare against the message `Date` header, falling\n  back to the provider's internal date when the header is missing or\n  unparseable. A message with no usable date matches neither.\n- Patterns are Go [RE2](https://github.com/google/re2/wiki/Syntax): no\n  backreferences and no lookaround. Prefix with `(?i)` for case-insensitivity.\n  In YAML, prefer double quotes and escape backslashes (`\"\\\\.\"`), or use single\n  quotes where no escaping is needed.\n- Use `true` / `false` for `has_attachment`. YAML 1.2 treats `yes` and `no` as\n  strings, and mail-muncher rejects them.\n\n### Spam and Trash (Gmail)\n\n**Spam and Trash are not fetched by default.** Nothing in those folders reaches\nyour rules, and nothing lands on disk. Spam is the likeliest source of hostile,\nattacker-authored text in a pipeline that ends in a model's context window, so\nthe default is to leave it where Gmail put it.\n\nThis whole section is about the Gmail provider. IMAP has no equivalent key\nbecause it needs none: an IMAP account fetches exactly the folders named in\n`imap.mailboxes`, so junk arrives only if you ask for it by name.\n\nIf you want it anyway — a legitimate message misfiled as spam, or an archive\nthat is genuinely complete — set the key per account:\n\n```yaml\naccounts:\n  - name: personal\n    gmail:\n      include_spam_trash: true   # validate warns; that is deliberate\n```\n\nThe two settings do different jobs, and you may want both:\n\n| | Decides |\n| --- | --- |\n| `gmail.include_spam_trash` | whether those messages are **fetched at all** |\n| A rule on the `SPAM` / `TRASH` labels | what happens to them **once fetched** |\n\n`gmail.query` cannot do either job. It is sent only on the first-ever scan, so\n`-in:spam` there does nothing for any later cycle. With\n`include_spam_trash: true` set, discriminate with a rule — the filter engine is\nthe only thing that sees every fetched message:\n\n```yaml\nrules:\n  - name: job-search\n    match:\n      all:\n        - from_domains_file: ~/.local/share/jobsearch/domains.txt\n        - not:\n            any:\n              - label: SPAM\n              - label: TRASH\n    dest: ~/Mail/job-search\n```\n\nGmail's system labels are exact and upper case. If you want Spam and Trash out\nof every rule, put the `not:` in each one — there is no global exclusion, by\ndesign: rules are the single authority on what is stored.\n\n## On-disk layout\n\n**The files mail-muncher writes are its public API.** This section is the tour;\n[docs/output-format.md](docs/output-format.md) is the contract — every\nfrontmatter key, why the frontmatter needs a real YAML parser, and the rules\nfor enumerating a delivery tree safely. Read it before you write a consumer,\nand see [examples/read_delivered.py](examples/read_delivered.py) for a short\ncorrect one.\n\nEvery sink files a message under the rule's `dest` by the message date, in UTC:\n\n```\n~/Mail/job-search/\n└── 2026/\n    └── 07/\n        ├── 1785230100-a00d5c5e383a1c08-re-your-application-for-senior-engineer.eml\n        ├── 1785230100-a00d5c5e383a1c08-re-your-application-for-senior-engineer.md\n        └── 1785230100-a00d5c5e383a1c08-re-your-application-for-senior-engineer.attachments/\n            ├── offer.pdf\n            └── R-sum-2026.docx\n```\n\nThe basename is shared by every format, so a message's renderings sort together:\n\n```\n<unix-seconds>-<sha256(account + \":\" + message-id)[:16]>-<subject-slug>\n```\n\nThe digest fragment is 16 hex characters — 64 bits. Two messages colliding on it\nis not reachable at any volume a mailbox produces, and readers of the archive\nparse it back out as a message id, so treat the width as part of the layout.\n\n- The **timestamp** sorts a directory chronologically.\n- The **digest** is the idempotency key. It depends only on the account name and\n  the provider message id, so the path is a pure function of message identity.\n- The **slug** is the subject lowercased, with every character outside `[a-z0-9]`\n  collapsed to a single `-`, trimmed, and truncated to 40 characters.\n\nTwo caveats about the slug, both deliberate:\n\n- **It is ASCII-only.** A subject written entirely in a non-Latin script, or\n  entirely in emoji, slugs to `no-subject`. Non-ASCII filenames would be subject\n  to filesystem Unicode normalization (HFS+ stores NFD), which can make the name\n  written differ from the name the next cycle checks for — and that existence\n  check is the entire idempotency story. The digest still keeps such messages\n  apart.\n- **It is cosmetic.** Only the digest carries identity. Two messages with the\n  same subject never collide.\n\n### How files are written\n\nA message file is written to a temp file in its destination directory, fsynced,\nand then **hard-linked** into place with `link(2)`. Three consequences worth\nrelying on:\n\n- **A partial file is never published.** The temp file is complete before the\n  name exists.\n- **An existing file is never overwritten.** `link(2)` fails with `EEXIST`\n  rather than clobbering, unlike `rename(2)`. That failure *is* the idempotency\n  check — the kernel decides whether the name is free at the instant it is\n  claimed, so there is no window in which another writer can slip a file in and\n  have it silently replaced. \"Already there\" is reported as `skipped`.\n- **Symlinks are refused, not followed.** A symlink at a message's final path,\n  or standing in for the `<YYYY>` or `<MM>` directory below `dest`, is an error\n  the message is counted and logged for. Nothing in the layout is legitimately a\n  link, so one means something else is placing them there. The rule's own\n  `dest:` is exempt — pointing that at another volume is ordinary.\n\nOn a filesystem without hard links (FAT, some network mounts) the fallback is an\n`O_CREAT|O_EXCL` write in place: still atomically no-clobber and still\nsymlink-proof, at the cost of the no-partial-file guarantee.\n\nAttachments are the one exception: they are written with a temp file and\n`rename(2)`, because their names are not the idempotency marker — the `.md`\nabove them decides that.\n\n**Directories are created 0700 and files 0600.** Archived mail is private\ncorrespondence and decoded attachments, so it gets the same treatment as the\nsync cursors and the OAuth token: nothing here is readable by other local users.\nA tool you run as yourself is unaffected.\n\n### The `.eml` file\n\n`model.Message.Raw`, byte for byte, exactly as the provider delivered it.\nNothing is re-encoded, re-wrapped, or normalized, so it round-trips through any\nmail tool and still verifies against DKIM signatures. This is the fidelity copy.\n\n### The `.md` file\n\nYAML frontmatter, then the body, then links to any attachments.\n\n- **Body selection**: the `text/plain` part if there is one; otherwise the\n  `text/html` part converted to markdown; otherwise the literal `*(no body)*`.\n  Line endings are normalized to LF, trailing whitespace is stripped per line,\n  and leading and trailing blank lines are trimmed.\n- **Frontmatter** always carries `subject`, `from`, `from_address`,\n  `from_addresses`, `to`, `to_addresses`, `date`, `message_id`, `thread_id`,\n  `thread_id_source`, `account`, `rule`. `cc`, `cc_addresses`, `in_reply_to`,\n  `labels` and `attachments` are omitted when empty. **Parse addresses from the\n  `*_address` / `*_addresses` fields, never from `from`/`to`/`cc`** — those are\n  display strings, and a sender-chosen display name containing `<`, `>` or `,`\n  makes them ambiguous. The machine-readable fields carry bare addr-specs and\n  cannot be spoofed that way. It is produced with a YAML\n  encoder, not string formatting, so a subject full of quotes and colons cannot\n  break the parse — which also means **you need a real YAML parser to read it**.\n  An emoji subject arrives double-quoted with a `\\U0001F389` escape, and a\n  subject containing a newline arrives as a `|-` block scalar. A `key: value`\n  splitter gets both wrong. See\n  [docs/output-format.md](do",
  "bytes": 60000,
  "sha": "28c1d10240fdcba5f119c0a49eda15810a87dc7a6b85bac54d82d8580bd9a1ab",
  "repo_slug": "craigjmidwinter/mail-muncher",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_craigjmidwinter_mail_muncher_72f1d706/readme"
}