postfach-mcp
by unstko
README.md
# postfach-mcp
A self-hosted remote [MCP](https://modelcontextprotocol.io) server that gives
an AI assistant read-focused access to any IMAP mailbox: search, read, create
drafts, light triage. *Postfach* is German for mailbox.
**Deliberately no send, no delete — by design, not by configuration.**
E-mail is untrusted third-party input; an assistant that reads it can be
manipulated by it. This server keeps the blast radius small: the worst a
hijacked session can do is file a draft, create a folder or move a message —
all of it sits in your mailbox, in plain sight, reversible. A send tool may appear in a later
version, but only behind an explicit opt-in flag, unregistered by default.
Feature requests to weaken this stance will be declined.
## Status
v0.2.0 — in production use by the author against a real IMAP mailbox, with
three client paths verified: Claude Code, claude.ai on the web, and the
Claude Android app. Still a 0.x: the tool surface and configuration may
change between releases. Changes are tracked in [CHANGELOG.md](CHANGELOG.md).
## Tools
| Tool | Purpose |
|---|---|
| `list_folders` | List all folders, optionally with message and unseen counts |
| `folder_status` | Message and unseen counts for a folder |
| `list_messages` | Newest messages in a folder |
| `list_headers` | Page through the header data of a whole folder, oldest first |
| `search_messages` | Server-side IMAP search, including arbitrary header matches |
| `get_message` | Full message: headers, body, attachment metadata |
| `create_draft` | Build an RFC-822 message and file it in the drafts folder — never sends |
| `create_folder` | Create a folder — creating only, no deleting, no renaming |
| `mark_read` | Set or clear the seen flag |
| `mark_flagged` | Set or clear the flagged star |
| `move_messages` | Move messages to another folder, reporting the new uids when the server supports UIDPLUS |
Messages are addressed by `folder` + `uid`; UIDs are per-folder. Reading
never sets the seen flag — only `mark_read` does, when asked.
## Installation
Requires Python 3.11+.
```bash
pip install git+https://github.com/unstko/postfach-mcp
```
Or from a clone: `pip install .` — both install the `postfach-mcp` command.
## Configuration
Everything is environment variables prefixed `POSTFACH_MCP_`; a commented
template is in [.env.example](.env.example). Missing or invalid variables
are reported together, each by name.
| Variable | Default | Purpose |
|---|---|---|
| `IMAP_HOST` | *(required)* | IMAP server to connect to |
| `IMAP_USER` | *(required)* | Login name |
| `IMAP_PASSWORD` | *(required)* | Password — use an app password if your provider offers them |
| `IMAP_PORT` | `993` | IMAP over TLS port |
| `DRAFTS_FOLDER` | `Drafts` | Folder that receives created drafts; `postfach-mcp check` verifies it exists |
| `FROM_ADDRESS` | `IMAP_USER` | From header for drafts, e.g. `Your Name <you@example.org>` |
| `FROM_ADDRESSES` | — | Comma-separated additional sender identities `create_draft` may select via its `from_address` argument; anything not listed here or in `FROM_ADDRESS` is rejected |
| `DRAFT_FORMAT` | `text` | `text` writes plain-text drafts; `html` adds an HTML rendering of the same text as a `multipart/alternative` part — for clients whose HTML-based composer collapses plain-text line breaks (Spark, for example) |
| `TOKEN` | *(required for `serve`)* | Bearer token, at least 32 characters (`openssl rand -hex 32`) |
| `EXTRA_TOKENS` | — | Comma-separated additional bearer tokens, same length rule — give each client its own so one can be revoked without touching the others |
| `HOST` | `127.0.0.1` | Bind address of the HTTP server |
| `PORT` | `8000` | Port of the HTTP server |
| `ALLOWED_HOSTS` | `127.0.0.1,localhost` | Comma-separated Host header allowlist — add the public name your proxy or tunnel uses |
| `ENABLE_SEND` | — | Reserved for a future explicit opt-in; not implemented in v0.1 |
## Running
```bash
postfach-mcp check # probe the IMAP login, list folders, verify the drafts folder
postfach-mcp serve # run the HTTP server (--host/--port override the environment)
```
`check` is the deploy diagnosis: it answers "are the credentials right, and
what is the drafts folder actually called on this server?" without starting
anything. `serve` exposes the MCP endpoint at `/mcp` and an unauthenticated
health probe at `/api/health`.
## Connecting clients
Any Streamable-HTTP MCP client that can send an `Authorization` header
works. The two most common ones:
### Claude Code
```bash
claude mcp add --transport http postfach https://mail.example.org/mcp \
-H "Authorization: Bearer <token>" --scope user
```
### claude.ai custom connector (web and mobile apps)
claude.ai can talk to this server as a custom connector, which also makes
it available in the Claude mobile apps — the connector is configured once
and appears there automatically.
Custom connectors normally require OAuth, which this server does not
offer. What it relies on instead is the **request-header option** in the
add-connector dialog (authentication "None" plus a static header). At the
time of writing Anthropic describes that option as available to a limited
set of organizations, so it may not appear for your account — check the
dialog before planning around it.
Configuration, and the three pitfalls that cost the author an afternoon:
- URL: your public `https://…/mcp` endpoint. Authentication: **None**.
Add a request header `Authorization` with the value
`Bearer <token>` — **including the scheme and the space**; the value is
sent verbatim, so a bare token produces nothing but 401s.
- A connector's authentication settings cannot be changed later. To
rotate a token, delete the connector and create it again. This pairs
well with `EXTRA_TOKENS`: give the connector its own token and it can
be revoked without touching your other clients.
- The connection test in the dialog can fail even when everything is
configured correctly (some of its probes are sent without the header).
If the server logs show 401s from the dialog but your header is right,
deleting and re-creating the connector is faster than debugging.
One consequence for your network setup: connector traffic originates from
Anthropic's backend, not from your browser — the server must be reachable
from the public internet, a VPN or tailnet is not enough for this path.
To keep the exposed surface small you can restrict it to
[Anthropic's published egress IP range](https://platform.claude.com/docs/en/api/ip-addresses)
at your proxy or firewall; the bearer token remains the actual
authentication either way.
## Security model
- **No send, no delete.** The server cannot transmit mail or destroy it;
those tools do not exist at runtime. Drafts are filed via IMAP APPEND
into your drafts folder and stay there until you act on them.
- **Mail content is untrusted.** Bodies and headers are returned in
structured fields, never interpreted; tool descriptions warn the model
that message content is third-party input. Header fields of drafts are
validated against CRLF injection.
- **Bearer token** on every MCP request (constant-time comparison), minimum
32 characters. The health endpoint is the only unauthenticated route.
- **Host header allowlist** (`ALLOWED_HOSTS`) rejects requests addressed
under any other name — DNS-rebinding protection. Behind a proxy or
tunnel you must add the public host name, or every request fails
with 421.
- **Transport security is your job.** The server speaks plain HTTP and
binds to localhost by default; put a TLS-terminating reverse proxy,
tunnel, or VPN in front of it. Do not expose the port directly.
- **Errors are terse.** IMAP failures reach the client as one English
sentence; credentials and tracebacks never do.
## Limitations
- One account per server instance.
- Drafts carry no formatting beyond line and paragraph breaks, and IMAP
cannot edit them in place — a changed draft means a new one.
- Some clients render plain-text drafts through an HTML composer and lose
all line breaks (observed in Spark on macOS and Android; webmail shows
the same draft correctly). `DRAFT_FORMAT=html` works around this by
adding an HTML alternative part.
- Attachments are reported as metadata only (name, type, size); their
content is not retrievable.
- Message bodies are capped at 50,000 characters, list/search results at
100 messages per call, `list_headers` at 500 headers per page; drafts at
500,000 characters.
- Lossless export of raw messages is out of scope — for backups, use a
dedicated tool such as `mbsync` or `offlineimap`.
- HTML-only messages are converted to text with a deliberately simple
converter — layout is lost, links are kept visible.
- `mark_read`, `mark_flagged` and `move_messages` trigger an expunge in
the source folder (imap-tools behavior; a move is IMAP-internally
copy + delete + expunge). Harmless for this server, which never sets
the deleted flag itself, but it also purges messages other clients
have marked deleted in that folder.
## Development
```bash
python -m venv .venv && .venv/bin/pip install -e ".[dev]"
.venv/bin/python -m pytest
.venv/bin/python -m ruff check . && .venv/bin/python -m mypy
```
Tests run entirely without network access — enforced by the test suite
itself, which fails any accidental socket connect.
## License
[MIT](LICENSE)
---
<sub>Built with assistance from <a href="https://claude.com/claude-code">Claude Code</a>.</sub>
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues