imap-mcp
by xercn
README.md
# imap-mcp: your mail for Claude and other AI clients
A small, self-hosted [MCP](https://modelcontextprotocol.io) server that reads several IMAP mailboxes the way an
email client does and gives Claude (and other AI clients you allow) search-and-read access to them as a custom
connector. Reading never changes your mail. Where you switch it on, a client can also put a reply draft into a
mailbox's Drafts folder, and ask to send one of its drafts; nothing is sent until you read the frozen message and
press Send on the approval page yourself.
You run it on your own server behind HTTPS, add your mailboxes, then add one URL in Claude. Mailbox passwords stay
on your server, encrypted.
```
claude.ai ──HTTPS + OAuth──▶ Caddy / Cloudflare Tunnel ──▶ imap-mcp (Docker) ──IMAPS / SMTPS──▶ your mail servers
```
> **A personal project, shared as is.** It is used by its author every day, but there is no promise of support,
> fixes or compatibility. Read the [security model](#security-model) before you point it at real mail, and
> report security problems privately (see [SECURITY.md](SECURITY.md)).
**中文简介:** imap-mcp 是一个自己部署的 MCP 服务器,让 Claude 等 AI 客户端以只读方式搜索、阅读你的多个 IMAP
邮箱(读信绝不改变已读/未读状态)。可以按邮箱打开“回复草稿”;也可以打开“发送”,但每一封都要你在确认页上用
主人密码 + TOTP 看过并放行才会发出。支持多个 AI 客户端各自的邮箱白名单和权限,所有写操作都记审计日志。
对中文、日文邮件(GBK、Big5、Shift_JIS、ISO-2022-JP 等编码和中文文件夹名)做了专门处理。个人项目,不承诺支持。
## Core guarantees
- **Reading never changes a message.** Folders are opened only with `EXAMINE`, bodies are read only with
`BODY.PEEK[...]`, and every IMAP command passes an allowlist before it reaches the socket. Unread mail stays
unread. The tests compare the flags of every message before and after every operation.
- **Drafts and sending are separate.** Reply drafts are off unless you switch them on, per server and per mailbox,
for a client you granted `mail:draft`. Sending is a separate switch per mailbox that needs SMTP settings of its
own. Each path uses its own IMAP connection with its own, narrower allowlist.
- **Every message sent is approved by a human.** A client can only *ask* to send a draft it wrote. The server
freezes the exact bytes, pushes you a link, and sends only after you sign in (owner password + TOTP), read the
whole message on `/approve/<id>` and press Send. Requests expire after 30 minutes; pressing twice sends once; a
draft changed after the request is never sent.
- **Per-client mailbox allowlists.** Several AI clients can share one server, each with its own credentials,
scopes (`mail:read`, `mail:draft`) and list of mailboxes it may see. Credentials are revocable one by one.
- **An append-only audit log.** Every draft, send request, approval, rejection, SMTP result and admin change is
recorded (SQLite plus an append-only JSONL file). Bodies are never stored, only their SHA-256; subjects and
recipients are blanked after 90 days by default.
## What a client can do with it
| Tool | What it does |
| --- | --- |
| `list_accounts` | The mailboxes this client may see: id, name, address |
| `list_folders` | One mailbox's folders, with Chinese and Japanese names decoded (已发送, 送信済み), optional counts |
| `search_messages` | Search one, several or all mailboxes by sender, recipient, subject, text, date range, unread; newest first |
| `get_message` | Headers, plain-text body (HTML converted), attachment list |
| `get_attachment` | Text out of PDF, Word, Excel, PowerPoint, text, CSV, HTML and .eml attachments; images up to 3 MB as images |
| `urgent_mail` | Recent unread, unanswered mail that looks urgent, with a score and the reasons |
| `get_conversation` | A whole conversation across mailboxes, oldest first |
| `find_contacts` | People by part of a name or address, with how much mail went each way (needs the local archive) |
| `sync_status` | How fresh the local archive is, per mailbox and folder (needs the local archive) |
| `mail_draft_reply` | Only where drafts are on and the client holds `mail:draft`: a plain-text reply in the Drafts folder, with risk hints |
| `mail_list_drafts` | The reply drafts this server wrote |
| `mail_audit_log` | The audit log of drafts and send requests |
| `mail_request_send` | Only where sending is on: ask to send one of this client's drafts. Freezes it and notifies you; sends nothing by itself |
| `mail_send_status` | Where this client's send requests stand (waiting, sent, failed, unknown) |
| `mail_cancel_send` | Withdraw a send request that hasn't been sent |
Mail in GB2312/GBK/GB18030, Big5, Shift_JIS, ISO-2022-JP and UTF-8 is decoded, including the usual mislabelled
cases (GBK characters inside "gb2312", raw 8-bit GBK subjects, encoded words in quotes).
## Quick start (Docker)
You need a server with Docker and the compose plugin, and a (sub)domain pointing at it, for example
`mail-mcp.example.com`. Full guide, including roles, TOTP, sending, upgrades and backups:
[docs/DEPLOY.md](docs/DEPLOY.md).
```
git clone https://github.com/xercn/imap-mcp.git && cd imap-mcp
cp .env.example .env
docker compose build
docker compose run --rm imap-mcp gen-secret-key # -> IMAP_MCP_SECRET_KEY in .env (keep a copy)
docker compose run --rm imap-mcp hash-password # -> IMAP_MCP_ADMIN_PASSWORD_HASH in .env
# set IMAP_MCP_PUBLIC_URL=https://mail-mcp.example.com in .env
sudo chown -R 10001 config # the container runs as uid 10001 and writes config/
docker compose run --rm imap-mcp account add --preset gmail # asks for address, id, password; tests a read-only login
docker compose run --rm imap-mcp config check
# put your domain in deploy/Caddyfile, then:
docker compose --profile caddy up -d
curl https://mail-mcp.example.com/healthz # -> ok
```
Then in claude.ai: Customize → Connectors → Add custom connector, URL `https://mail-mcp.example.com/mcp`, keep
OAuth sign-in (register automatically, no client ID or secret), Connect, and sign in with the owner password on the
page that opens. Claude Code:
```
claude mcp add --transport http mail https://mail-mcp.example.com/mcp
```
The reading tools are marked read-only, so you can set them to "always allow".
## Configuration
Everything about your mailboxes and clients lives in the `config/` folder and in `.env`; nothing is in the code.
Changes to the TOML files apply to the next request, without a restart.
| File | What it holds |
| --- | --- |
| `.env` ([example](.env.example)) | Public URL, owner password hash, secret key, roles, global switches (`IMAP_MCP_WRITE=off`, `IMAP_MCP_SEND=off`), Bark key |
| `config/accounts.toml` ([example](config/accounts.example.toml)) | One `[accounts.<id>]` table per mailbox; `[write]` and `[accounts.<id>.write]` for drafts and sending; `[accounts.<id>.smtp]` |
| `config/clients.toml` ([example](config/clients.example.toml)) | AI clients: auth (`oauth` or `token`), scopes, mailbox allowlist, limits |
| `config/extract.toml`, `config/vip.toml` | Urgent-mail rules and VIP senders (`imap-mcp rules init`, `imap-mcp vip add`) |
No file holds a password or token. Mailbox and SMTP passwords are encrypted with `IMAP_MCP_SECRET_KEY` in the data
volume (or come from `password_env` / `password_file`); tokens and sessions are stored only as SHA-256 hashes.
Common commands (run as `docker compose run --rm imap-mcp ...`, or `docker compose exec imap-mcp imap-mcp ...`):
```
imap-mcp presets # known providers
imap-mcp account add [--preset NAME] | list | test --all | set-password ID | edit ID --set key=value
imap-mcp account drafts ID on|off # reply drafts for one mailbox (no restart)
imap-mcp account smtp ID --sent-copy append # SMTP settings (asks for the password; tests the login, sends nothing)
imap-mcp account send ID on|off # sending for one mailbox
imap-mcp client list | add NAME | grant NAME mail:draft | accounts NAME ID... | token NAME | revoke NAME
imap-mcp vip add boss@example.com @client.example
imap-mcp admin totp-setup # binds TOTP; turns on /admin
imap-mcp send list | send show s_... # send requests (approve them on the web page)
imap-mcp config check # checks every file, offline
```
The `/admin` page (after `admin totp-setup`) does the same for mailboxes, clients, SMTP and the send switch, shows
connection status and the audit log, and lists send requests.
Providers (presets only fill in server, port and security; check them against your provider):
- **Gmail**: IMAP with an app password. Add `exclude_folders = ["[Gmail]/All Mail"]`. For sending,
`sent_copy = "server"` (Gmail files sent mail itself).
- **iCloud**: `imap.mail.me.com`, an app-specific password. Some servers answer EXAMINE as READ-WRITE; the command
guard still allows only `BODY.PEEK`.
- **QQ / 163 / 126**: the authorization code from the web mail settings; 163 and 126 need `send_id = true`.
- **Alibaba enterprise mail (阿里企业邮箱)**: the administrator must allow IMAP for third-party clients. See
[known limitations](#known-limitations). For sending, `sent_copy = "append"`.
- **China Telecom mail (189, enterprise editions)**: presets `189`, `ct-qiye`, `ct-qiye-hk`, `ct-bnet`, `ct-xinchuang`.
- **Microsoft 365 / Outlook.com** no longer accept passwords over IMAP and are not supported.
## Security model
- **Who can call it.** claude.ai connects with OAuth 2.1 against this server's own sign-in page: dynamic client
registration, PKCE, redirects only to Claude's callbacks, one-hour access tokens and rotating refresh tokens. No
token is issued without the owner password (plus TOTP once bound). Failed sign-ins are rate-limited per IP and
globally. Other clients use bearer tokens minted from the CLI or admin page, which expire (365 days by default).
Optionally, `/mcp` can be limited to Anthropic's published outbound IP range (`IMAP_MCP_ALLOWED_CLIENT_IPS`;
see the comment in `.env.example` and check the official list).
- **What a caller can do.** Scopes and mailbox allowlists per client, checked on every request; revocation applies
to the next request. Rate limits per client and per mailbox; a connection cap per mailbox.
- **Three IMAP channels, three allowlists.** Read: `EXAMINE`, `SEARCH`, `FETCH` with `PEEK` only. Drafts: `APPEND`
only to the drafts folder and only with `(\Draft \Seen)`; no `STORE` or `EXPUNGE` at all. Send: `APPEND` only to
Sent with `(\Seen)`, and one optional `+FLAGS.SILENT (\Answered)` on the original. Anything else raises before it
reaches the server.
- **Sending.** Plain text only, no attachments, at most 10 recipients, From one of the mailbox's identities,
per-mailbox and global hourly/daily limits. The approval page shows the whole frozen message; Send is a POST with
the session, a CSRF token and the hash of what you saw. If the connection breaks mid-send the message is never
resent automatically: Sent is checked, or you are asked. `IMAP_MCP_SEND=off` stops all sending at once.
- **The admin page.** Owner password + TOTP, 30-minute sessions, CSRF and same-origin checks on every POST, no
OAuth or bearer token opens it, and the admin cookie doesn't open `/mcp`. Every change goes to the audit log.
Passwords and tokens are never shown back.
- **Logs.** One line per MCP call (tool, client, mailbox, time, result); never arguments, queries or mail.
- **Prompt injection.** Mail is written by other people. A crafted message can try to steer the model. With
drafts and sending off, the worst it can do is show the model mail it can already read. With drafts on, the
worst is a draft you don't send. With sending on, nothing leaves without your approval: read the whole message
on the approval page, including the recipients, before you press Send.
## Known limitations
- **Alibaba enterprise mail refuses server-side text search.** It answers `BAD` to every SUBJECT/FROM/TO/TEXT
search (in any charset, English too) and drops the connection after four. imap-mcp switches such a mailbox to
local filtering after the first refusal; set `search = "local"` to skip even that. Subject, sender, recipient and
date filters then read the headers of every message in the folder (in memory, read-only); full-text search
checks only the newest 200 messages and says so in the result.
- Sending is plain text only, without attachments.
- OAuth-only providers (Microsoft 365, Outlook.com) are not supported.
- Push notifications for send approvals use [Bark](https://github.com/Finb/Bark) (iOS). Without a Bark key, send
requests wait in the admin page's list and expire after 30 minutes unless you open them there.
- The `serve` role keeps no archive, so `find_contacts` and `sync_status` are not available there, and
`urgent_mail` reads the inbox live with fewer signals.
## Development
```
python -m venv .venv && . .venv/bin/activate
pip install -e '.[test,browser]'
python -m playwright install chromium
pytest
```
The tests start throwaway local IMAP servers (Dovecot when installed, otherwise pymap) and a local SMTP receiver,
with Chinese, Japanese, HTML-only and attachment-heavy test mail, and use only test passwords; nothing touches a
real mailbox. Tests that need Dovecot (`apt install dovecot-imapd`) or a browser are skipped without them.
Before a release, run the full suite with nothing skipped, plus the Docker end-to-end flows:
```
scripts/release-check.sh # pytest with IMAP_MCP_REQUIRE_BROWSER=1 (browser tests fail instead of skipping)
python tests/e2e_drafts_docker.py # drafts against the built image; ends with "FLAGS UNCHANGED: True"
python tests/e2e_send_docker.py # draft -> send request -> approval page -> exactly one message received
```
The end-to-end scripts need Docker and Dovecot on the host; set `E2E_SKIP_BUILD=1` to use an image you built
yourself. Design notes for the local archive: [docs/L1_SCHEMA.md](docs/L1_SCHEMA.md) (Chinese). Release history:
[CHANGELOG.md](CHANGELOG.md).
Built on the official MCP Python SDK (`mcp` 2.3.0, Streamable HTTP).
## License
[Apache License 2.0](LICENSE). Copyright 2026 Martin Liu.