imap-mcp-server
Enables querying and retrieving email messages from an IMAP mailbox via MCP tools, with an indexed storage backend on Cloudflare.
README
imap-mcp
Source-available, not a product. This is a generic IMAP → MCP server that runs on Cloudflare Workers. It is published under MIT so the code can be read, copied and learned from — but it is built for, and run against, exactly one mailbox: a personal iCloud account. There is no support commitment: issues are not triaged, pull requests are not solicited, there are no releases, and nothing here is versioned for anyone else's use. If it is useful to you, fork it.
It is generic by design rather than by ambition — host, port and credentials are configuration, not constants — so it should work against any IMAP server. Only iCloud is actually exercised.
Status: early. The mailbox interface (#3), the D1 schema (#4), the tracer sync (#5), the queue fan-out (#6) incremental sync (#8) and the MCP server (#7) are implemented and tested: the sync worker enumerates folders on a cron, resumes from where the last run got to, and indexes them into D1 through a queue, and the MCP server serves
search_messagesover that index. Nothing is authenticated yet, so the MCP worker has no route until Access lands (#10). See Roadmap.
What it does
Indexes a mailbox into Cloudflare's storage, then serves it to an MCP client as search and retrieval tools — so a model can answer questions against fifteen years of mail without the mailbox itself being in the loop on every query.
┌────────────────────┐ IMAP over TLS ┌──────────────┐
│ imap-mcp-sync │ ───────────────▶ │ mailbox │
│ packages/sync │ ◀─────────────── │ (iCloud) │
│ cron, owns creds │ └──────────────┘
└─────────┬──────────┘
│ writes ▲ writes proxied over
▼ │ a service binding
┌────────────────────┐ │
│ D1 (+FTS5) and R2 │ │
└─────────┬──────────┘ │
│ reads │
▼ │
┌────────────────────┐────────────────┘ ┌──────────────┐
│ imap-mcp-server │ Streamable HTTP │ MCP client │
│ packages/mcp │ ◀───────────────── │ behind CF │
│ stateless, no creds │ Access │
└────────────────────┘ └──────────────┘
Two workers, and the split between them is the security design rather than a packaging choice:
packages/sync(imap-mcp-sync) is the only part of the system that speaks IMAP. It holds the app-specific password — which on iCloud grants full mailbox access including SMTP send — so that credential exists in exactly one place.packages/mcp(imap-mcp-server) is a stateless reader. It queries the index, never the mailbox, and proxies the few write operations back to the sync worker over a service binding rather than opening a connection of its own.
A third package, packages/imap (@imap-mcp/imap), is a library rather than a worker: the internal mailbox interface, and the only place the IMAP client library is imported. Only packages/sync depends on it.
Quickstart
pnpm install # installs both packages
pnpm run lint # biome check (lint + format)
pnpm run typecheck # wrangler types + tsc --noEmit
pnpm run test # vitest, inside workerd
pnpm run dead-code # knip
pnpm run build # wrangler deploy --dry-run, both workers
pnpm run db:migrate:local # apply migrations/ to the local D1
pnpm run db:migrate:remote # ... and to the deployed one
Per package, from packages/sync or packages/mcp: pnpm run dev, pnpm run test:watch, pnpm run deploy.
Configuration
This repository is public, so no deployment-specific values are committed — no Cloudflare account ID, no zone tag, no Cloudflare Access team domain or application audience (AUD), no mailbox address. Neither wrangler.jsonc contains an account_id, and the MCP worker declares no route at all.
Everything you would need to supply is named and explained in .env.example. Copy it to .env and fill it in. In short:
| What | Where it goes |
|---|---|
CLOUDFLARE_ACCOUNT_ID |
your environment; wrangler reads it directly |
| Route / zone for the MCP worker | a routes entry you add to packages/mcp/wrangler.jsonc |
ACCESS_TEAM_DOMAIN, ACCESS_AUD |
vars in packages/mcp/wrangler.jsonc |
IMAP_HOST, IMAP_PORT, IMAP_USER |
vars in packages/sync/wrangler.jsonc |
SYNC_FOLDERS and the four sizing vars |
vars in packages/sync/wrangler.jsonc; all optional |
IMAP_PASSWORD |
wrangler secret put, sync worker only — never a vars entry |
| The D1 database | provisioned on first deploy; see Storage |
| The two queues | created by wrangler deploy; Queues needs a Workers Paid plan |
Locally, secrets go in a gitignored .dev.vars per package; each has a .dev.vars.example to copy.
Both workers are unreachable by default
workers_dev and preview_urls are false in both wrangler.jsonc files, and the MCP worker declares no route. A fresh deploy is therefore not reachable from the internet. This is deliberate: with no folder fence, the MCP endpoint is functionally read access to an entire mailbox, so it must not become reachable before Cloudflare Access is in front of it. Without workers_dev: false a worker is live at <name>.<account>.workers.dev no matter what routes or Access policies exist.
A full deploy-from-scratch guide — secrets, Access setup, bindings, migrations and the backfill — is not written yet; it lands with the rest of the system.
Storage
One D1 database, written by the sync worker and read by the MCP server. The
schema is at migrations/ in the repo root — shared, rather
than owned by either worker — and both wrangler.jsonc files point their
migrations_dir at it.
folders |
one row per mailbox, carrying uidvalidity and the sync watermark |
messages |
envelope fields plus the normalised plain-text body |
attachments |
metadata and the R2 key; the bytes live in R2 |
write_log |
every mailbox write, successful or not |
messages_fts |
FTS5 over subject and body, BM25-ranked |
Two things in that schema are load-bearing rather than incidental:
- The plain-text body is a real column, not just FTS index content. That is the seam that lets semantic search be added later by reading this database, instead of re-pulling fifteen years of mail from iCloud.
- Messages are keyed on
(folder, uidvalidity, uid), so every write is an upsert. Queue delivery is at-least-once and consumers have to be safe to re-run.UIDPLUSis available on iCloud and everyAPPENDreturns an APPENDUID, so that key comes back from the server on each write rather than needing a re-fetch to discover.
messages_fts is an FTS5 external content table: it indexes messages
rather than holding a second copy of every body, and three triggers keep it in
step so no write path can forget to reindex. Its tokenizer is
porter unicode61 remove_diacritics 2 — stemmed, so "meeting" finds
"meetings", and diacritic-folded, so "cafe" finds "café". One known limitation,
pinned by a test rather than left to be rediscovered: unicode61 does not
word-segment CJK, so a run like 会議は月曜日です indexes as a single token. It
stores and reads back exactly; it is keyword search over it that is coarse, and
a prefix query (会議*) is the way through.
There is no database export
wrangler d1 export refuses to run against any database containing an FTS5
virtual table, and this one has messages_fts. So there is no working export
of this database, and no backup taken that way. Re-running the backfill is the
recovery path — which is affordable precisely because the mailbox, not D1, is
the source of truth: everything here is derived and can be rebuilt from IMAP.
First deploy
Neither wrangler.jsonc commits a database_id. A database id identifies one
Cloudflare account, and this repository commits no account-specific values — so
the binding declares database_name and nothing else, and wrangler
provisions it:
wrangler dev creates it locally, wrangler deploy creates it on your account,
binds it, and writes the id back into your copy of the config. It is also what a
"Deploy to Cloudflare" button would do.
Two things follow. Both workers must end up on the same database — deploy
imap-mcp-sync first, then point imap-mcp-server at the database that deploy
created rather than letting it provision a second one. And the id wrangler
writes back lands in a committed file; it is yours, so don't push it upstream.
Then apply the schema with pnpm run db:migrate:remote. Re-running it is a
no-op — applied migrations are recorded in a d1_migrations table.
What the sync worker does
Once an hour, imap-mcp-sync connects and enumerates: it opens each
configured folder read-only, lists UIDs — identifiers only, no bodies — and
posts them to a Cloudflare Queue in ranges of about a hundred. A consumer
then takes one range per invocation, fetches it over a single IMAP connection,
reduces each message to a row and upserts it into D1.
Three numbers in that shape are load-bearing:
- A queue message is a UID range, never a single email. One message per
email would mean one TCP + TLS +
LOGIN+SELECTper email — tens of thousands of logins for a backfill, which Apple will throttle or lock long before it finishes. Ranges of ~100 turn that into a few hundred. - Consumer concurrency is capped at 4. Queues will autoscale to hundreds of parallel consumers, but D1 is a single Durable Object and single-threaded, so high fan-out only relocates the bottleneck — while opening hundreds of connections to one Apple account at the same time.
- A cron tick queues at most 50 ranges. That is the throttle on a backfill: roughly five thousand messages an hour, and a large folder therefore completes over several ticks rather than all at once.
What gets queued is decided by looking for gaps rather than by advancing a cursor: one query asks D1 how many messages are already indexed in each UID bucket, and only the buckets that come up short are enqueued. A folder converges — each run queues what is still missing and then goes quiet — and a range that runs out of retries is picked up again on the next tick instead of being stepped over for good.
A run resumes rather than restarts. Each folder carries a watermark: the
highest UID below the first gap, which is the most that can honestly be claimed
when ranges complete out of order under fan-out. The next run walks from above
it, and asks D1 about the buckets above it too — matching the two is a
correctness requirement rather than a saving, because the bucket straddling the
watermark also holds rows below it. When the watermark reaches the top of a
folder's UID space the folder is skipped without a single SEARCH, which is
what makes a quiet hourly tick cheap rather than merely convergent. Against the
real mailbox that is the difference between about eight seconds and about two.
Two discontinuities are handled rather than assumed away. A changed
UIDVALIDITY means every UID recorded for that folder now identifies a
different message, so the watermark is dropped and the folder re-indexes from
UID 1 — the old rows stay addressable under their own uidvalidity while that
happens. And a folder deleted or renamed upstream is skipped with a warning
instead of failing the run: one LIST per run tells the difference, because a
tagged NO on SELECT looks the same either way, and ranges already in flight
for that folder are dropped rather than spending three retries on their way to
the dead-letter queue.
CONDSTORE is enabled for the session, which has to happen in the
authenticated state before the first SELECT — RFC 5161 requires that ordering
and getting it wrong is silent, since the only symptom is that HIGHESTMODSEQ
never appears. So support is detected by that value arriving, never by the
ENABLE reply, which iCloud returns empty while plainly having enabled it.
Nothing reads a mod-sequence yet; recording it per folder is what
#24 starts from.
Enumeration uses UID ranges and dates, and nothing else. A spike ran sixteen
SEARCH criteria against a real iCloud folder: ALL, SINCE/BEFORE and the
flag criteria are exact, but LARGER matches everything, SMALLER matches
nothing whatever argument they are given, and every string criterion —
SUBJECT, TEXT, HEADER Subject, even FROM "@" — returns zero hits.
Whether that is iCloud or the client was never isolated, and the design does not
depend on the answer.
Five properties of a run are deliberate, and each is pinned by a test rather than left as an intention:
- Nothing it does can change the mailbox. Folders are opened with
EXAMINE, every fetchPEEKs — the internalMailboxinterface has no way to fetch without it — and indexing therefore cannot mark mail as read. - Redelivering a range writes no duplicate rows. Every message write is an
upsert on
(folder_id, uidvalidity, uid), so the same range can be covered again after a failure, a redeploy, or the at-least-once delivery a queue guarantees. - An authentication failure aborts loudly and does not retry. A revoked
app-specific password retried on every tick — or, worse, across every
consumer at once — is how an Apple ID gets locked, so that failure and a
missing setting stop the run. On the cron path that means
noRetry(); on the queue path the batch is acked rather than retried, and the next tick re-enumerates whatever it did not store. Ordinary failures retry. - A range that exhausts its retries lands on a dead-letter queue, which is read and logged with the folder and UID range it was carrying — so what was missed is a line you can look at rather than a silent hole.
- The credential never reaches a log line. Every line this worker logs is scrubbed of the password in all the forms it could come back off the wire — plaintext, quoted, and SASL base64 — including error paths.
Bodies are normalised on the way in, because that is what gets indexed and,
eventually, read by a model. HTML is reduced to plain text with a real parser
(HTMLRewriter), <script> and <style> go with it, and so does anything a
reader could not have seen: hidden, aria-hidden, display: none,
font-size: 0. Then the characters that exist to hide text from a human —
zero-width spaces, bidi overrides, the Unicode tag block — are stripped, after
character references are decoded rather than before, so that a zero-width space
written as ​ is caught too.
Roadmap
Tracked as issues on this repo:
| #2 | Repo scaffold — this |
| #3 | The IMAP client, behind an internal interface |
| #4 | D1 schema and migrations |
| #5 | Tracer: sync one folder into D1 — done |
| #6 | Queue fan-out for the sync path — done |
| #7 | MCP server and search_messages — done |
| #8 | Incremental sync: watermarks and UIDVALIDITY — done |
| #9 | Attachments to R2, with text extraction |
| #10 | Gate the MCP endpoint with Access Managed OAuth |
| #11 | get_message and get_thread |
| #12 | Write tools over a service binding, with an audit log |
| #13 | Full backfill and setup guide |
| #24 | Flag reconciliation over CONDSTORE |
A spike settled the one question the whole architecture was contingent on — can a Cloudflare Worker speak IMAP to iCloud at all? It can: TLS and LOGIN on port 993 in 755 ms, folders listed, messages fetched and MIME-decoded, a draft appended and flagged. So the sync path is a Worker and nothing moves to a Container. The findings that constrain the design — CONDSTORE ordering, no MOVE on iCloud, SEARCH being unusable for content — are written into the tickets they affect.
Licence
MIT — see LICENSE.
The IMAP client
The protocol client is cf-imap by Exerra
(npm), MIT licensed — Copyright (c)
2024 Exerra, LICENSE in the published tarball. It has zero runtime
dependencies.
Note for anyone running a licence scanner over this repo: no published version
of cf-imap sets a license field in its package.json, so scanners
report it as unlicensed. The MIT text does ship inside the tarball — it is a
metadata gap, not an absent licence.
Issue #3 weighed vendoring the
source against depending on the package and settled on depending. The
generic-by-design requirement is met by the interface instead:
packages/imap/src/types.ts is what the rest of the repo is written against,
and cf-imap is imported in exactly one file
(packages/imap/src/cf-imap-mailbox.ts), so swapping the client — or the
provider — is a change to that file rather than a refactor.
What depending rather than vendoring costs is that four behaviours of the pinned version are worked around or pinned by tests rather than fixed at the source. None is reported upstream yet:
| Behaviour | Effect here |
|---|---|
storeFlags cannot parse the MODSEQ (n) RFC 7162 §3.1.3 requires on untagged FETCH once CONDSTORE is enabled |
a flag write that lands reports zero rows, so setFlags discards the STORE response and verifies every write with an independent UID FETCH |
every iso-8859-* charset is decoded as ISO-8859-1, ahead of the TextDecoder fallback |
ISO-8859-15's euro sign arrives as a currency sign; pinned in packages/imap/test/protocol/mime.test.ts |
a FETCH literal is decoded as UTF-8 before the part's charset is known |
bodies sent as raw 8-bit (Content-Transfer-Encoding: 8bit) lose their non-ASCII characters; anything quoted-printable or base64 is unaffected. Pinned in the same file |
| the published ESM uses extensionless relative imports | bundlers (workerd, wrangler deploy) resolve them; Node's ESM resolver does not, so the Node-side test project has Vite process the package instead |
The tests that pin these are contract tests over a pinned dependency: they are what turns an upgrade, or a swap to another client, into a red build rather than a quiet change in what fifteen years of mail decodes to.
推荐服务器
Baidu Map
百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。
Playwright MCP Server
一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。
Magic Component Platform (MCP)
一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。
Audiense Insights MCP Server
通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。
VeyraX
一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。
graphlit-mcp-server
模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。
Kagi MCP Server
一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。
e2b-mcp-server
使用 MCP 通过 e2b 运行代码。
Neon MCP Server
用于与 Neon 管理 API 和数据库交互的 MCP 服务器
Exa MCP Server
模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。