From b5eec783a208f835e8899312ee014cc00b07c0c6 Mon Sep 17 00:00:00 2001 From: craigjmidwinter Date: Thu, 30 Jul 2026 15:14:23 -0500 Subject: [PATCH 1/3] docs: the bundled skill knows IMAP, so stop saying it does not The caveat under "As a Claude Code skill" told readers the skill only walks the Gmail path and has not caught up with `provider: imap` or `mail-muncher init`. Both halves stopped being true at 2944723, which reordered the skill to lead with IMAP; `init` is documented across SKILL.md and references/setup.md as well. Left alone it was actively steering people away from the two-minute route the rest of the README spends its opening arguing for. --- README.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d2c0d49..603794c 100644 --- a/README.md +++ b/README.md @@ -400,11 +400,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 From ca4606e2bb622dc926f3e812ae8c662e511045e3 Mon Sep 17 00:00:00 2001 From: craigjmidwinter Date: Thu, 30 Jul 2026 15:14:48 -0500 Subject: [PATCH 2/3] release: ship a container image, and list on the MCP Registry The official MCP Registry indexes servers by distributable package and accepts npm, PyPI, NuGet, cargo, OCI and MCPB. A GitHub release of Go binaries is none of those, so mail-muncher could not be listed at all. OCI is the path that fits. MCPB looked closer -- prebuilt binaries off a GitHub release, no toolchain -- but it is one server out of 1200 in the live registry, and it has no way to say which of four os/arch artifacts a client should take. A container manifest resolves the architecture itself, and 51 registry entries already go this way. The image is Alpine rather than scratch or distroless, which is not a size oversight: `imap.password_cmd` is mandatory on the IMAP path and internal/provider/imap runs it as `/bin/sh -c`. Without a shell, IMAP auth cannot work at all. The trade is real and README says so -- inside a container `password_cmd` reaches the container's environment, not the password manager on your host. `dockers_v2` hands the Dockerfile a build context laid out as //, so the COPY resolves through buildx's TARGETOS and TARGETARCH rather than a bare path. CI grows QEMU because the arm64 layer genuinely executes `apk add` and `adduser`. The registry proves ownership by matching an `io.modelcontextprotocol.server.name` annotation on the pushed manifest against `name` in server.json, so those two strings move together. server.json names 0.4.0, not the current 0.3.0: the image it points at does not exist until a release builds it, so publishing to the registry follows the tag rather than leading it. Verified by a snapshot build: image builds for both platforms, runs as non-root with $HOME resolving the default config path, carries the annotation, and answers an MCP initialize + tools/list with all five tools over stdio. --- .github/workflows/release.yml | 25 ++++++++++++++++++ .goreleaser.yml | 43 +++++++++++++++++++++++++++++++ Dockerfile | 46 +++++++++++++++++++++++++++++++++ README.md | 48 +++++++++++++++++++++++++++++++++++ server.json | 29 +++++++++++++++++++++ 5 files changed, 191 insertions(+) create mode 100644 Dockerfile create mode 100644 server.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fad4f84..b9e7bc8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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 diff --git a/.goreleaser.yml b/.goreleaser.yml index 4497634..b0e7ff6 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -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: diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b6042e2 --- /dev/null +++ b/Dockerfile @@ -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 `. 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 //, 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"] diff --git a/README.md b/README.md index 603794c..f74a91e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/server.json b/server.json new file mode 100644 index 0000000..2bd0825 --- /dev/null +++ b/server.json @@ -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." + } + ] + } + ] +} From 87dcb6c352a2e3a006665bb6ef6f9817305cc577 Mon Sep 17 00:00:00 2001 From: craigjmidwinter Date: Thu, 30 Jul 2026 15:18:32 -0500 Subject: [PATCH 3/3] ci: give the snapshot job the emulation its arm64 layer needs The release dry run builds the container image now, and the linux/arm64 half genuinely runs `apk add` and `adduser` on an amd64 runner. Without binfmt registered that is "exec /bin/sh: exec format error", which is how this job failed the moment the image landed. release.yml got the same two steps when the image was added; this job lives in ci.yml and was missed. --- .github/workflows/ci.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 79108cf..730cfd9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: