imap-mcp-server

imap-mcp-server

Enables querying and retrieving email messages from an IMAP mailbox via MCP tools, with an indexed storage backend on Cloudflare.

Category
访问服务器

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_messages over 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. UIDPLUS is available on iCloud and every APPEND returns 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 + SELECT per 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 fetch PEEKs — the internal Mailbox interface 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 &#8203; 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_messagesdone
#8 Incremental sync: watermarks and UIDVALIDITYdone
#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

Baidu Map

百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。

官方
精选
JavaScript
Playwright MCP Server

Playwright MCP Server

一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。

官方
精选
TypeScript
Magic Component Platform (MCP)

Magic Component Platform (MCP)

一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。

官方
精选
本地
TypeScript
Audiense Insights MCP Server

Audiense Insights MCP Server

通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。

官方
精选
本地
TypeScript
VeyraX

VeyraX

一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。

官方
精选
本地
graphlit-mcp-server

graphlit-mcp-server

模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。

官方
精选
TypeScript
Kagi MCP Server

Kagi MCP Server

一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。

官方
精选
Python
e2b-mcp-server

e2b-mcp-server

使用 MCP 通过 e2b 运行代码。

官方
精选
Neon MCP Server

Neon MCP Server

用于与 Neon 管理 API 和数据库交互的 MCP 服务器

官方
精选
Exa MCP Server

Exa MCP Server

模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。

官方
精选