Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,17 @@ jobs:
env:
HOMEBREW_TAP_GITHUB_TOKEN: ""

# The snapshot below builds the container image too, and its linux/arm64
# layer really executes `apk add` and `adduser` on an amd64 runner. That
# needs binfmt emulation registered first, or the build dies with
# "exec /bin/sh: exec format error" -- which is exactly how this job
# first failed.
- name: Set up QEMU
uses: docker/setup-qemu-action@v3

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Build a snapshot release
uses: goreleaser/goreleaser-action@v7
with:
Expand Down
25 changes: 25 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ jobs:
# Mint the short-lived OIDC token cosign exchanges for a signing
# certificate. Without this, keyless signing cannot work.
id-token: write
# Push the container image to ghcr.io/craigjmidwinter/mail-muncher.
packages: write
steps:
- name: Check out
uses: actions/checkout@v4
Expand All @@ -42,6 +44,29 @@ jobs:
- name: Install cosign
uses: sigstore/cosign-installer@v3

# The image is Alpine and runs `apk add` and `adduser` at build time, so
# the linux/arm64 layer is genuinely executed rather than just assembled.
# On an amd64 runner that needs binfmt emulation; without QEMU the arm64
# half of the manifest fails to build.
- name: Set up QEMU
uses: docker/setup-qemu-action@v3

# dockers_v2 builds through buildx, which needs a builder instance that
# can target more than the host platform.
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

# Skipped on a dry run: --skip=publish means nothing is pushed, so there
# is nothing to authenticate for, and a login there would only be a way
# for the validation run to fail on credentials it never uses.
- name: Log in to GHCR
if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.dry_run) }}
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

# A missing tap token must be loud, not silent. The release still ships
# binaries without it; only the Homebrew formula update is skipped.
- name: Check Homebrew tap token
Expand Down
43 changes: 43 additions & 0 deletions .goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,49 @@ checksum:
name_template: checksums.txt
algorithm: sha256

# A container image on GHCR, built from the binaries above rather than from
# source. Two reasons it exists: it is the install path that needs nothing on
# the host but a container runtime, and the official MCP Registry indexes
# servers by distributable package, of which an OCI image is one. The registry
# does not accept a bare GitHub release of Go binaries, so without this there
# is no listing.
#
# `dockers_v2` is goreleaser's buildx-based image builder: one multi-arch
# manifest assembled from the already-compiled per-arch binaries. It is marked
# experimental upstream; the classic `dockers` + `docker_manifests` pair is the
# fallback if it ever regresses.
dockers_v2:
- id: mail-muncher
ids:
- mail-muncher
images:
- ghcr.io/craigjmidwinter/mail-muncher
dockerfile: Dockerfile
# Matches the linux half of the build matrix above. darwin has no container
# equivalent, and Docker Desktop on a Mac runs the linux image anyway.
platforms:
- linux/amd64
- linux/arm64
tags:
- "{{ .Version }}"
# Only a full release moves `latest`. A prerelease that grabbed it would
# silently upgrade everyone who pulls the unqualified tag.
- "{{ if not .Prerelease }}latest{{ end }}"
labels:
org.opencontainers.image.title: "{{ .ProjectName }}"
org.opencontainers.image.version: "{{ .Version }}"
org.opencontainers.image.revision: "{{ .FullCommit }}"
org.opencontainers.image.source: "https://github.com/craigjmidwinter/mail-muncher"
org.opencontainers.image.licenses: MIT
# Set on the manifest, not just the per-arch image configs. The MCP
# Registry's ownership check reads the manifest, so the server name has to
# survive here; it is also declared as a LABEL in the Dockerfile, and the
# two must agree with `name` in server.json.
annotations:
io.modelcontextprotocol.server.name: "io.github.craigjmidwinter/mail-muncher"
org.opencontainers.image.source: "https://github.com/craigjmidwinter/mail-muncher"
org.opencontainers.image.description: "An email client for AI agents: filtered, read-only mail on disk, served over MCP."

# Keyless signing with cosign + GitHub OIDC. No long-lived key to manage: the
# signature is bound to this workflow's identity and logged in Rekor.
signs:
Expand Down
46 changes: 46 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# The container image, built by goreleaser around an already-compiled binary.
# `dockers_v2` drops the right-architecture `mail-muncher` into the build
# context, so there is no Go toolchain here and nothing to compile: this file
# only assembles a runtime around a binary that already exists.
#
# Alpine rather than scratch or distroless, for one concrete reason:
# `imap.password_cmd` is mandatory on the IMAP path and internal/provider/imap
# runs it as `/bin/sh -c <command>`. An image with no shell cannot authenticate
# to IMAP at all -- not "less conveniently", it fails outright. Inside a
# container the command that yields the password is usually `printenv
# IMAP_PASSWORD` or `cat /run/secrets/imap-password` rather than a call into
# your password manager on the host, and both still need a shell to exist.
FROM alpine:3.22

# Verifying TLS to imap.fastmail.com, or to oauth2.googleapis.com on the Gmail
# path, needs a trust store. The base image is not guaranteed to carry one.
RUN apk add --no-cache ca-certificates

# How the MCP Registry proves you own this image: it reads this annotation back
# off the pushed manifest and requires it to equal the `name` in server.json.
# These two strings move together or the next publish is rejected.
LABEL io.modelcontextprotocol.server.name="io.github.craigjmidwinter/mail-muncher"

# mail-muncher never needs root. It also resolves config, state and archive
# paths under $HOME, so give it a real home directory to resolve them against
# rather than letting `~` expand to `/` and scattering dotfiles at the root.
RUN adduser -D -u 65532 -h /home/muncher muncher
USER muncher
ENV HOME=/home/muncher

# goreleaser lays the build context out as <os>/<arch>/<binary>, one subtree
# per platform it was asked to build, so there is no `mail-muncher` at the
# context root to copy. buildx sets TARGETOS and TARGETARCH per platform of the
# manifest it is assembling, which names the right subtree exactly. Declaring
# the ARGs is required -- they are only predefined, not automatically in scope.
ARG TARGETOS
ARG TARGETARCH
COPY ${TARGETOS}/${TARGETARCH}/mail-muncher /usr/local/bin/mail-muncher

ENTRYPOINT ["/usr/local/bin/mail-muncher"]

# `mcp` is the mode a container image is actually for: an MCP client starts the
# server, talks to it over stdin/stdout, and stops it. `run` and `daemon` work
# too -- override the command -- but on the host they are a cron line and a
# launchd/systemd unit, which is usually the better fit for those.
CMD ["mcp"]
56 changes: 51 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,54 @@ The example configs referenced below live in [`examples/`](examples/) —
release archive, so a binary download has them too. You do not need them to get
started, though: `mail-muncher init` writes a config from scratch.

### Container image

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

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

Two mounts, and both matter:

```bash
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
```

**Every path inside `config.yml` has to be a path the container can see.** A
`dest:` of `~/Mail/receipts` resolves against the container's home directory,
not yours, so mail lands on a layer that disappears when the container exits.
Point `dest:` at the mounted directory — `/home/muncher/archive/receipts` for
the mount above — or you will archive into the void and the manifest will
cheerfully tell you it worked.

**`password_cmd` runs inside the container**, under `/bin/sh`, which means your
host password manager is not there. `pass show mail/fastmail` cannot work. Use
the secret material the container does have:

```yaml
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_cmd` is designed for the host case, and this is the
compromise, not the intent.

The image is also what backs the [MCP Registry](https://registry.modelcontextprotocol.io)
listing; [`server.json`](server.json) is that entry, and its `name` has to match
the `io.modelcontextprotocol.server.name` label baked into the image.

### As a Claude Code skill

The repo ships a skill and plugin package under [`skills/`](skills/), which
Expand All @@ -400,11 +448,9 @@ writing the config, running `auth`, 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 bundled skill still only knows the Gmail path** and has not yet caught up
with `provider: imap` or `mail-muncher init`. It will walk you through Google
Cloud rather than offering the two-minute route. Until it is updated, follow the
[quickstart](#quickstart) here if you want IMAP; the binary itself supports it
fully.
The skill leads with `provider: imap` and drives `mail-muncher init`, so it
takes the same two-minute route this README does rather than sending you to the
Google Cloud Console.

## Quickstart

Expand Down
29 changes: 29 additions & 0 deletions server.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "io.github.craigjmidwinter/mail-muncher",
"title": "mail-muncher",
"description": "Filtered, read-only IMAP and Gmail archived to disk as .eml + markdown, served over MCP.",
"version": "0.4.0",
"websiteUrl": "https://craigjmidwinter.github.io/mail-muncher/",
"repository": {
"url": "https://github.com/craigjmidwinter/mail-muncher",
"source": "github"
},
"packages": [
{
"registryType": "oci",
"identifier": "ghcr.io/craigjmidwinter/mail-muncher:0.4.0",
"transport": {
"type": "stdio"
},
"packageArguments": [
{
"type": "positional",
"value": "mcp",
"valueHint": "mcp",
"description": "Serve the archive over stdio MCP. This is the image's default command; it is spelled out here so a client that appends its own arguments does not silently drop it."
}
]
}
]
}
Loading