Skip to main content
Glama
wildsurfer

your-mail-mcp

your-mail-mcp

Your mail already holds the answers: booking references, gate codes, invoices, warranty periods, promises people made in writing. This server lets your AI assistant find them.

Ask it things like:

  • "Find the booking reference for the June ferry."

  • "What was the wifi password the hotel sent last summer?"

  • "What did the accountant answer about VAT, and when?"

  • "Collect everything between me and the builder about the roof, in order, and summarize who promised what."

  • "What arrived this morning, across all my accounts, that actually needs me?"

Use it for:

  • Search that understands questions. Full-text search over your entire history, every account in one index, phrased the way you think instead of the way search syntax works.

  • Triage from your phone. A morning summary of what came in overnight, with junk already filtered out, from wherever you are.

  • Mail as context for other work. Pull the client's requirements out of the thread and into your coding or writing session, instead of retyping them.

  • Agents you can leave running. The server can only read. A malicious email that reaches your assistant gets read and nothing more, because sending, deleting and moving do not exist here. That makes scheduled digests and always-on agents a calm thing to run.

Setup is two files and docker compose up -d — see Running it.

A self-hosted MCP server that gives an MCP client (Claude, or any other client that speaks streamable HTTP MCP with OAuth) read access to your mail. It mirrors one or more IMAP accounts into a local maildir with mbsync, indexes them with notmuch, and answers tool calls from that index.

How your-mail-mcp works: mail is pulled from IMAP providers into a local mirror, indexed by notmuch, and served to an MCP client through an OAuth gate, with no write path back to the providers

Mail only ever moves left to right in that picture. The one arrow the server makes back toward a provider is a single IMAP LIST at startup, to find out what that server calls its junk and trash folders; it never selects a mailbox and never fetches a message. The diagram source is docs/diagrams/how-it-works.html.

What it cannot do

The read-only property is built into the architecture.

The mirror is pull-only. The generated mbsync configuration for every account carries Sync Pull, Create Near, Remove None, Expunge None — nothing in that configuration can push a change back to the server, delete a message, or expunge one.

The only IMAP operation anywhere in the Go code is LIST, issued once per account at startup to find each account's junk and trash folders (see Provider notes and Troubleshooting). That connection logs in, lists mailboxes, and logs out. It never selects a mailbox and never fetches a message.

There is no send, no delete, no move, and no tag. Attachments are listed in show and thread and served read-only by the attachment tool, one part at a time, capped at 5MB. Bigger parts are served raw at GET /attachment/{id}/{part}, authenticated by a bearer token or by the short-lived signed link the tool returns when it refuses an oversized part. Nothing in the process holds write access to any account.

Ten tools, all read-only:

Tool

What it does

search

Search mail. Returns thread summaries as JSON.

ids

Return the message ids matching a query.

files

Return the maildir file paths matching a query.

count

Count the messages matching a query.

show

Show one message: headers and decoded body, as JSON.

thread

Show the whole thread containing a message. Excludes junk/trash replies by default; set include_excluded to include them.

text

Return the plain-text body of one message, converting HTML.

folders

List accounts, their folders, index tags, and each account's last sync and last error.

refresh

Sync INBOX now and report how many messages arrived.

attachment

One attachment or MIME part of a message, by part number from show. Images and binaries as typed content, text as a marked block. Parts over 5MB get a signed download link instead.

search, ids, files and count take a notmuch query (from:, to:, subject:, tag:, folder:, date:2026-01-01..2026-06-30, combined with and/or/not), an optional account to scope to one account, and can include junk/trash with include_excluded.

Related MCP server: notmuchproxy

Running it

Three ways to run this. They differ in one thing: who can reach the server. Start at case 1 and move up only when you need to. None of them is hardened beyond the defaults — that is Hardening, further down, and it is deliberately separate so you can get the thing working first.

Where it runs

Who can reach it

Your mail is stored on

1

your machine

that machine only

your machine

2

your machine

you, from anywhere

your machine

3

a VPS

you, from anywhere

a rented disk

The server ships as a container image at ghcr.io/wildsurfer/your-mail-mcp, built and published by CI for amd64 and arm64. Nothing needs compiling, and every case starts the same way — two files in an empty directory:

mkdir your-mail && cd your-mail
curl -fsSLO https://raw.githubusercontent.com/wildsurfer/your-mail-mcp/main/compose.yaml
curl -fsSL https://raw.githubusercontent.com/wildsurfer/your-mail-mcp/main/accounts.example.json -o accounts.json

Edit accounts.json with your accounts (see The accounts file), then put the secrets it references in a .env file next to compose.yaml:

# .env
OAUTH_PASSPHRASE=pick-a-long-one-you-can-type-on-a-smartphone
WORK_PASS=your-gmail-app-password
PERSONAL_PASS=your-icloud-app-specific-password

OAUTH_PASSPHRASE is the only credential between the internet and your mail in cases 2 and 3. Treat it accordingly.

These two files hold your mail passwords. If you ever put this directory under version control or into a backup that leaves the machine, treat them accordingly.


Case 1 — on your machine, for your machine only

The server binds to loopback. Nothing outside your machine can reach it, so there is no TLS to arrange and no hostname to own. Your CLI tools can use it. Your smartphone cannot.

Add one line to .env:

PUBLIC_URL=http://127.0.0.1:8080

Then start it:

docker compose up -d
docker compose logs -f          # watch the first sync

The first sync populates the maildir and takes a while on a large mailbox. It is slower than it could be on purpose, one IMAP command at a time, because providers throttle. There is no separate initialization step.

Claude Code

claude mcp add --transport http your-mail http://127.0.0.1:8080/mcp

Then run /mcp inside Claude Code, pick your-mail, and authenticate. A browser opens the consent page, which asks for one thing: your OAUTH_PASSPHRASE. Until you do this, claude mcp list shows Needs authentication.

Codex

codex mcp add your-mail --url http://127.0.0.1:8080/mcp
codex mcp login your-mail

codex mcp list shows the auth status. If the tools still do not appear in a session after a successful login, that is a known Codex bug where the OAuth credentials are obtained and then never used (openai/codex#20009). Use the bridge below until it is fixed.

mcp-remote does the OAuth dance itself and re-exposes the server over stdio, which every MCP client supports:

# ~/.codex/config.toml
[mcp_servers.your-mail]
command = "npx"
args = ["-y", "mcp-remote", "http://127.0.0.1:8080/mcp"]

It opens the same consent page on first run and caches the tokens.


Case 2 — on your machine, reachable from anywhere

Same server, plus something that gives it a public HTTPS address. Your mail stays on your machine, and nothing listens on your home network, because the tunnel dials out. You need this for the smartphone and desktop apps: a custom connector is fetched by the vendor's servers, so it cannot reach a private address.

With Tailscale (no domain needed)

One command, same on macOS and Linux, and you get an HTTPS hostname without owning a domain.

tailscale funnel --bg 8080

--bg keeps it running across reboots. It prints the public URL, which looks like https://your-machine.your-tailnet.ts.net. That is the hostname to use:

# .env
PUBLIC_URL=https://your-machine.your-tailnet.ts.net
docker compose up -d

Funnel needs HTTPS certificates and the Funnel node attribute enabled for your tailnet; the CLI offers to add the policy line the first time, and the rest is in your admin console. tailscale funnel status shows what is exposed, and tailscale funnel --https=443 off takes it down.

With Cloudflare (you own a domain, and it is on Cloudflare)

Use this if you want a hostname on your own domain rather than a .ts.net one. mail.example.com below is your domain, already added to your Cloudflare account — Cloudflare does not hand you a hostname for a named tunnel.

cloudflared tunnel login
cloudflared tunnel create your-mail

create prints the tunnel's UUID and the credentials file it just wrote:

Tunnel credentials written to /Users/you/.cloudflared/f9e2…-… .json
Created tunnel your-mail with id f9e2…-…

Use that exact path below; cloudflared tunnel list prints the UUID again if you lose it. Route the hostname, then write ~/.cloudflared/config.yml:

cloudflared tunnel route dns your-mail mail.example.com
tunnel: your-mail
credentials-file: /Users/you/.cloudflared/f9e2….json   # the path create printed
url: http://localhost:8080
cloudflared tunnel run your-mail

To keep it running: on Linux, sudo cloudflared service install. On macOS, install it through Homebrew and use brew services start cloudflared, because the sudo install path looks for its certificate under the root user's home and will not find the one cloudflared tunnel login wrote to yours.

Then set PUBLIC_URL=https://mail.example.com in .env and docker compose up -d.

Either way

PUBLIC_URL has to match what you type into the client exactly. The server publishes PUBLIC_URL + /mcp as the resource in its OAuth metadata, and a mismatch there is the most common reason a connector refuses to add.

One thing to know before you start on a smartphone: neither Claude nor ChatGPT lets you add a connector from the smartphone app. You add it once on the web (or in Claude's desktop app), and it then shows up on your smartphone. Trying to do the setup on the smartphone itself will waste your time.

Claude — add on web or desktop, then use on your smartphone

  1. On claude.ai or in Claude Desktop, go to Settings → Connectors, and click + next to Connectors, or Add custom connector.

  2. Give it a name and the URL <PUBLIC_URL>/mcp. Leave the advanced OAuth fields empty: this server registers clients dynamically.

  3. Claude opens the consent page. Enter your OAUTH_PASSPHRASE.

  4. Open the Claude app on your smartphone. The connector is already there, and the tools are available in a chat. Turn it on for a conversation from the tools or connectors menu in the composer.

ChatGPT — add on web, then use on your smartphone

Custom MCP connectors live behind developer mode, which needs a Pro, Plus, Business, Enterprise or Education account and is only available on the web.

  1. In ChatGPT on the web, open Settings → Security and login and turn on Developer mode. On Business and Enterprise workspaces an admin may have to allow it first.

  2. Add a connector for a remote MCP server and give it the URL <PUBLIC_URL>/mcp, with OAuth as the authentication. ChatGPT supports dynamic client registration, so there is nothing to paste.

  3. Approve the consent page with your OAUTH_PASSPHRASE.

  4. Open ChatGPT on your smartphone and enable the connector in a chat.

These menus move. If the names above do not match what you see, look for developer mode in settings, then for the place that adds a connector by URL.

ChatGPT disables some MCP write actions on mobile. That has no effect here, because this server has no write actions at all.

Claude Code

claude mcp add --transport http your-mail https://your-host/mcp

Codex

codex mcp add your-mail --url https://your-host/mcp
codex mcp login your-mail

Case 3 — on a VPS, reachable from anywhere

Pick this when you want the mirror to stay up whether or not your machine is on. It costs a few dollars a month and one real trade-off: a full plaintext copy of your mail moves onto a rented disk, with the app passwords in the same environment. Read Security before you choose it.

The install is case 1 plus a tunnel, on someone else's computer. No ports to open, no DNS to configure, no certificates to manage.

On a fresh Debian or Ubuntu box:

# 1. Docker
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker $USER && newgrp docker

# 2. The two files, and your accounts
mkdir your-mail && cd your-mail
curl -fsSLO https://raw.githubusercontent.com/wildsurfer/your-mail-mcp/main/compose.yaml
curl -fsSL https://raw.githubusercontent.com/wildsurfer/your-mail-mcp/main/accounts.example.json -o accounts.json
$EDITOR accounts.json             # your accounts
$EDITOR .env                      # OAUTH_PASSPHRASE and the account passwords

# 3. A public address, exactly as in case 2
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up
tailscale funnel --bg 8080        # prints your https://….ts.net hostname

# 4. Put that hostname in .env, then start
echo "PUBLIC_URL=https://your-machine.your-tailnet.ts.net" >> .env
docker compose up -d
docker compose logs -f

PUBLIC_URL comes last because you do not know the hostname until step 3 prints it.

Connecting a client is identical to case 2.

restart: unless-stopped in compose.yaml brings the containers back after a reboot. Check on it with the folders tool, which reports each account's last sync and its last error, or with docker compose logs --tail=50.

Now go and read Hardening. A VPS you can SSH into with a password, holding a copy of your mail, is worse than not running this at all.


Hardening

None of this is needed to make the server work, which is why it is not in the install steps. It is ordered by how much it buys you. Case 1 needs none of it.

Pick a real passphrase. OAUTH_PASSPHRASE is the whole door. A wrong guess costs the attacker one second, and guesses are serialised so running them in parallel does not help, but neither of those saves a short passphrase. Use a long one you can still type on a smartphone.

Lock down SSH (case 3). A rented box with password login and a copy of your mail on it is the worst combination in this document. As root, before anything else:

adduser mail && usermod -aG sudo mail
rsync --archive --chown=mail:mail ~/.ssh /home/mail
sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin no/; s/^#\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
systemctl restart ssh

Then do the install as mail, not as root.

Close the ports you are not using (case 3). With a tunnel you need no inbound ports at all, so:

sudo ufw allow OpenSSH && sudo ufw --force enable

Restrict who can reach the connector. If the only thing that talks to your server is a custom connector in a Claude app, that traffic arrives from Anthropic's published egress range, 160.79.104.0/21, and you can refuse everything else at the tunnel or firewall. Do not do this if you also use Claude Code or Codex from a laptop, since those connect from wherever you are.

Back up the volumes, or accept a re-sync. compose.yaml keeps the maildir and the index in named volumes. Nothing in them is unique — it is all still on your mail server — but re-downloading a large mailbox takes a while and annoys providers that throttle.

Know what the passphrase does not protect. It gates the MCP surface. It does not encrypt anything at rest. See Security.

If you would rather terminate TLS yourself on a domain you own, point an A record at the box and put Caddy in front. Add compose.override.yaml:

services:
  caddy:
    image: caddy:2
    restart: unless-stopped
    ports: ["80:80", "443:443"]
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
volumes:
  caddy_data:
# Caddyfile
mail.example.com {
    reverse_proxy your-mail-mcp:8080
}

Open both ports — 80 is not optional, Caddy uses it for the certificate challenge and the HTTPS redirect:

sudo ufw allow 80/tcp && sudo ufw allow 443/tcp

Caddy obtains and renews the certificate itself. Set PUBLIC_URL to the hostname and docker compose up -d.

The accounts file

Mounted read-only at /config/accounts.json (see compose.yaml). JSON, parsed with encoding/json, expanded against the process environment before parsing so ${VAR} in any string value is replaced with the environment variable of that name. This is how secrets stay out of the file:

{
  "accounts": [
    {
      "name": "work",
      "host": "imap.gmail.com",
      "user": "you@example.com",
      "password": "${WORK_PASS}"
    }
  ]
}

Per-account keys:

Key

Default

Notes

name

Required. No spaces, quotes, or slashes (forward or back). Becomes the top-level maildir directory for the account and the account argument in tool calls.

host

Required. IMAP server hostname.

port

993 (imaps) or 143 (otherwise)

user

Required. See Provider notes: iCloud wants the short name, not the full email address.

password

Required. ${VAR} expands from the environment; a literal password also works but is not recommended.

tls

imaps

imaps, starttls, or none.

patterns

["*"]

mbsync folder patterns — which folders to mirror.

exclude_folders

discovered automatically

Folder names to exclude from search by default (see SPECIAL-USE discovery). Setting this overrides discovery entirely for that account.

An account name must be unique. At least one account is required; an empty accounts array is a startup error.

Environment variables

Variable

Required

Default

Meaning

CONFIG

yes

Path to the accounts file.

MAILDIR

yes

Maildir root; each account gets a subdirectory.

INDEX

yes

notmuch/Xapian index directory.

PUBLIC_URL

yes

The external URL the server is reached at, exactly as a client will use it (a trailing slash, if any, is stripped). Used in OAuth metadata and must match what you type into the client.

OAUTH_PASSPHRASE

yes

The one passphrase that gates the consent screen.

SYNC_INTERVAL

no

5m

Full-sync period, as a Go duration (5m, 1h).

SYNC_TIMEOUT

no

1h

Per-account deadline for one mbsync run, as a Go duration. Raise it if a large first mirror is still running when it hits this and gets cut off — a mailbox in the tens of thousands of messages can take well over the default.

LISTEN_ADDR

no

:8080

Address the HTTP server binds.

INIT_MIRROR

no

unset

Set to 1 to sync into an empty directory that is not a mount point. Not needed with compose, where /mail is a volume.

CONFIG, MAILDIR and INDEX are required; the process refuses to start without them. PUBLIC_URL and OAUTH_PASSPHRASE are required by the OAuth layer and the process also fails to start without them.

The container image already sets four of these (Dockerfile): MAILDIR=/mail, INDEX=/index, CONFIG=/config/accounts.json, LISTEN_ADDR=:8080. compose.yaml doesn't override any of them. Leave them alone unless you're also changing the matching volume mount or config mount in compose.yaml — an override that doesn't move the mount with it points the server at an empty or missing path.

Without Docker

Release binaries for Linux and macOS, amd64 and arm64, are on the releases page, with checksums. The binary shells out to mbsync, notmuch and w3m, so install those first — brew install isync notmuch w3m on macOS, apt install isync notmuch w3m on Debian and Ubuntu. isync 1.4.4 or newer works.

Then the same configuration as the container, with paths of your choosing. The container's volumes start out as mount points, which the empty-maildir guard reads as a genuine first run; a plain directory you create yourself looks exactly like a missing volume to that same guard, so it needs INIT_MIRROR=1 to say it really is meant to be a first run here:

mkdir -p mail index
CONFIG=./accounts.json MAILDIR=./mail INDEX=./index INIT_MIRROR=1 \
PUBLIC_URL=http://127.0.0.1:8080 OAUTH_PASSPHRASE=... \
WORK_PASS=... ./your-mail-mcp

Windows is not supported: the maildir handling leans on Unix filesystem semantics, and there is no mbsync to shell out to.

Building it yourself

CI builds, tests and publishes every image, so nobody has to — but it is one command if you want to: docker build -t your-mail-mcp . for the container, or go build for the binary (Go 1.27, with the three tools above on PATH for the tests).

Provider notes

The iCloud notes come from long-running operation of a real iCloud mirror that predates this server. The Gmail and Dovecot notes come from provider documentation and the project's research, and have not all been re-verified through this server yet.

  • iCloud (imap.mail.me.com): the IMAP user is the short name — the part before @icloud.com — not the full email address. iCloud throttles concurrent IMAP connections; this is why the generated mbsync configuration pins PipelineDepth 1 for every account, and it is not configurable.

  • Gmail (imap.gmail.com): requires an App Password, which requires 2-step verification to be enabled on the account first — Gmail does not accept the account password directly over IMAP. Gmail also keeps a copy of essentially everything in [Gmail]/All Mail, so a Gmail account's mirror is roughly double the size of what the folder list suggests, since most messages exist both under their folder and under All Mail. The first mirror of a large Gmail account takes hours, and Google also enforces a daily IMAP download quota (about 2.5GB per day), so a multi-gigabyte mailbox spreads its first mirror over several days. This is normal: the server keeps retrying on its schedule and mbsync resumes where it stopped. Set SYNC_TIMEOUT to something like 8h for the first mirror so a long run is not cut off by the default one-hour deadline.

  • Dovecot servers (many self-hosted and smaller providers) commonly prefix folder names with INBOX. (e.g. INBOX.Sent). If folders shows folder names you didn't expect, this is usually why.

Security

Account passwords are supplied through the process environment (${VAR} in accounts.json, or literal values). At startup, the server writes them into a generated mbsync configuration file on disk inside the container, at file mode 0600. That file is not encrypted. Anything that can read the container's environment, or that file, can read the passwords in plain text.

Protection at rest — disk encryption, restricting who can exec into the container, access to the host — is the operator's responsibility. This server makes no claim of encrypting credentials at rest, and does not attempt to.

The OAuth passphrase is checked in constant time and gates the whole server with a single shared secret; it is not a per-user credential system. Treat OAUTH_PASSPHRASE and the mail account passwords with the same care.

search's thread summaries include a display name for every message in a matching thread, which a sender controls. A message in a folder excluded by default (junk, trash) can still put its own attacker-chosen name in front of you this way, even though its body never does — search does not fetch or show the body of an excluded message. thread and show are read paths, not subject to this: thread excludes junk/trash replies by default (see the tools table above), and show reads a single message you already have the id for. This display-name leak in search is not fixed in this release.

Troubleshooting

"maildir ... is an empty plain directory, not a mount point: refusing to sync" — the server checks whether your maildir is a mounted filesystem. A mounted volume that happens to be empty is a first run and syncs without any opt-in, which is why compose needs no extra step. An empty plain directory is ambiguous: a fresh maildir looks exactly like a path whose volume was never mounted, and syncing into the second one re-downloads every account into a directory that disappears the moment you fix the mount. Either mount the storage where MAILDIR points, or set INIT_MIRROR=1 if it really is meant to be an ordinary directory on this filesystem.

"maildir ...: no such file or directory" — the path does not exist at all. With compose that means the volume or bind mount is missing from compose.yaml; running the binary directly, it means MAILDIR is wrong.

Check per-account sync status with the folders tool. It lists every configured account, its last successful sync time, its last error if any, its folders, and the tags in the index. A single account with a bad password or an expired app-specific password does not stop the others — sync failures are isolated per account — but it will show up here as a last error line, not as silence.

Junk/trash exclusion, two different failure shapes:

  • "special-use discovery: account NAME: ..." in the container logs means the startup connect, login, or LIST for that account failed outright. On that failure there are no folder names to fall back to matching against, so that account gets nothing excluded at all — not even by the built-in English name list — until the connection problem is fixed or exclude_folders is set for it by hand.

  • No error line, but folders still shows nothing excluded means the LIST succeeded — the server just doesn't advertise \Junk/\Trash attributes (no RFC 6154 SPECIAL-USE support) and its folder names don't match the built-in English list (junk, spam, trash, deleted messages, deleted items, bulk mail). This is the localised-mailbox case — a German or French mailbox, for instance — and the fix is the same: set exclude_folders by hand.

exclude_folders in accounts.json, e.g. "exclude_folders": ["Papierkorb"], takes priority over both SPECIAL-USE and the built-in list in every case.

A
license - permissive license
Not graded
quality - not tested
A
maintenance

Maintenance

Maintainers
Response time
Release cycle
1Releases (12mo)
Commit activity

Related MCP Servers

  • A
    license
    A
    quality
    B
    maintenance
    Local MCP server for multi-account IMAP/SMTP email (iCloud + Gmail via app-specific passwords). Never marks mail read. Cross-folder search, idempotent sends, TLS verified.
    8
    MIT
  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables LLMs to search and read email from a notmuch archive, providing tools for searching threads, retrieving messages, and listing tags through an MCP endpoint.
    MIT
  • F
    license
    Not graded
    quality
    C
    maintenance
    Local IMAP/SMTP MCP server that lets Claude read, search, draft, send, flag, and move mail across multiple IMAP mailboxes. Credentials stay on your machine.
  • F
    license
    Not graded
    quality
    B
    maintenance
    A private, single-user MCP server that unifies Gmail, Microsoft 365/Outlook, and IMAP mailboxes for LLMs to search and read emails live, without storing or caching mailbox contents.

View all related MCP servers

Related MCP Connectors

  • Search your AI chat history (ChatGPT, Claude, Codex) from any MCP client. Remote, private, read-only

  • Read, search, send, organize, draft and schedule email across your inboxes from any MCP client.

  • Agent-native MCP server over the public saagarpatel.dev corpus. Read-only, stateless.

View all MCP Connectors

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/wildsurfer/your-mail-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server