Self-hosted remote MCP server that lets an AI (Claude, etc.) read, search and send email through multiple IMAP/SMTP accounts, and read/write the user's calendar through CalDAV — authenticated via Clerk.
MCP clients (Claude Desktop, Claude.ai, …) can talk to remote servers, but none of them ship with a way to plug your own IMAP/SMTP and CalDAV accounts securely. Shoving raw credentials into a client config or shipping them to a third-party SaaS is a non-starter for anything serious.
imap-mcp is a tiny, self-hosted Next.js app that:
- authenticates humans with Clerk (you get a real sign-in UI, MFA, SSO, whatever Clerk supports),
- authenticates MCP clients with OAuth 2.1 (PKCE + Dynamic Client Registration),
- stores an arbitrary number of IMAP/SMTP and CalDAV accounts per user, encrypted at rest (AES-256-GCM),
- exposes those accounts to any MCP client through a clean set of tools — emails and calendar.
One container, one domain, your server, your keys.
- 🔐 Clerk for user auth — you manage users, not us
- 🔑 OAuth 2.1 (Authorization Code + PKCE) with Dynamic Client Registration (RFC 7591)
- 📬 Unlimited IMAP/SMTP accounts per user, each with its own HTML signature (Tiptap editor, DOMPurify-sanitized)
- 🗓️ Unlimited CalDAV calendar accounts per user — list/read/create/update/delete events and find free slots, fully timezone-aware (TZID + DST-correct recurrences)
- 🔒 Credentials encrypted with AES-256-GCM; OAuth tokens stored as SHA-256 hashes only
- 🧰 27 MCP tools: 19 email tools (list/read/search/send/reply/flag/move/folder ops) + 8 calendar tools (
list_calendar_accounts,list_calendars,list_events,get_event,create_event,update_event,delete_event,find_free_slots) - 🧪 Test connection from the list (IMAP
NOOP+ SMTPVERIFY, CalDAV principal-discovery) with per-account status badges and actionable error hints - ⚡ Provider presets on account creation: Gmail, Outlook / Microsoft 365, iCloud, Yahoo, Fastmail, OVH for email — iCloud, Fastmail, Nextcloud, OVH, Baïkal/generic for calendars
⚠️ Live port/SSL consistency warnings — catches thewrong version numbertrap before it happens- 🎯
/connectguide with tabbed, copy-to-clipboard setup instructions for Claude.ai (web), Claude Desktop and Claude Code - 🎨 Polished UI: light/dark, hero homepage, status badges, shadows, focus rings
- 🐳 Ships as a 2-service
docker-compose(Postgres + app)
┌─────────────────┐ OAuth 2.1 (PKCE + DCR) ┌──────────────────────────┐
│ MCP client │ ◀────────────────────────▶ │ /api/oauth/* │
│ (Claude, …) │ Bearer-auth'd JSON-RPC │ /api/mcp ← tools │
└─────────────────┘ │ │
│ Next.js 15 (App Router)│
┌─────────────────┐ Clerk session │ /accounts ← web UI │
│ Browser │ ─────────────────────────▶ │ │
└─────────────────┘ └──────────────┬───────────┘
│ Drizzle
┌─────▼─────┐
│ Postgres │
└───────────┘
The Next.js app is simultaneously:
- the OAuth Authorization Server (issues codes and tokens),
- the OAuth Resource Server (validates Bearer tokens at
/api/mcp), - the web UI for users to manage their accounts.
Human auth at the /authorize endpoint is delegated to the active Clerk session.
| Concern | Choice |
|---|---|
| Framework | Next.js 15 (App Router), React 19 |
| Language | TypeScript (strict) |
| Human auth | @clerk/nextjs |
| Database | PostgreSQL 16 |
| ORM | drizzle-orm |
| IMAP client | imapflow |
| SMTP client | nodemailer |
| CalDAV client | tsdav |
| iCalendar | ical.js (Mozilla) |
| MCP SDK | @modelcontextprotocol/sdk |
| HTML editor | Tiptap |
| Sanitizer | isomorphic-dompurify |
| Transport | Streamable HTTP (MCP spec 2025-06-18) |
git clone <your-fork> imap-mcp
cd imap-mcp
cp .env.example .envFill in .env:
# Generate a fresh 32-byte master key
openssl rand -base64 32Paste it as MCP_MASTER_KEY. Add your Clerk keys (pk_test_… / sk_test_…) and set NEXT_PUBLIC_APP_URL to the public URL of your deployment (e.g. https://mcp.example.com or http://localhost:3000 for local).
MCP_MASTER_KEY means losing every stored IMAP/SMTP password. Back it up.
docker compose up --build
# In another terminal, apply the schema on first install:
docker compose exec app npx drizzle-kit pushApp is now available at http://localhost:3000.
- Open the app, sign up with Clerk.
- Go to
/accounts/new. - Pick a provider preset (Gmail, Outlook, iCloud, Yahoo, Fastmail, OVH) to auto-fill hosts, ports and SSL flags — or fill them by hand.
- Gmail / Google Workspace: use an app password (
https://myaccount.google.com/apppasswords). - Optionally paste/edit an HTML signature.
- Save, then click Test on the account card. Both IMAP and SMTP must come back green.
- Go to
/calendars/new. - Pick a preset (iCloud, Fastmail, Nextcloud, OVH, Baïkal/generic) to fill the CalDAV base URL —
tsdavauto-discovers the principal and home calendars from there. - Use a provider app password (Apple, Fastmail, Nextcloud all expose one). Google Calendar is not supported in v1 because Google requires OAuth 2.0 for CalDAV — see Out of scope below.
- Save, then click Test connection: the badge shows the number of calendars discovered.
Every signed-in user has a built-in guide at /connect with tabbed setup instructions
and copy-to-clipboard snippets for the three major Claude surfaces:
- Open Settings → Connectors → Add custom connector.
- Paste
https://<your-domain>/api/mcp. - Sign in with Clerk in the popup, approve.
Merge into ~/Library/Application Support/Claude/claude_desktop_config.json
(or the equivalent on Windows/Linux):
{
"mcpServers": {
"email-mcp": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://<your-domain>/api/mcp"]
}
}
}Restart the app; the OAuth browser flow launches on first use. Requires Node ≥ 18.
claude mcp add --transport http email-mcp https://<your-domain>/api/mcpThen type /mcp in a session — the first tool call triggers OAuth.
The client will:
GET /.well-known/oauth-protected-resource— discover the auth server,POST /api/oauth/register— auto-register itself,- open
/api/oauth/authorizein a browser — you sign in with Clerk and approve, POST /api/oauth/token— exchange the code for an access token,- call
/api/mcpwithAuthorization: Bearer ….
All of this is handled transparently by conformant MCP clients.
Port/SSL mismatch. The form now warns about this live, and the accounts list surfaces a hint when the test fails. Use one of:
| Port | SSL/TLS checkbox | Meaning |
|---|---|---|
| 465 | ✅ on | Implicit TLS |
| 587 | ❌ off | STARTTLS upgrade |
| 25 | ❌ off | Plain (discouraged) |
The builder needs a placeholder at build-time; this repo's Dockerfile already sets a
dummy DATABASE_URL and MCP_MASTER_KEY for the build stage, and receives
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY / NEXT_PUBLIC_APP_URL as build args from
docker-compose.yml. Make sure your .env defines them before docker compose up --build.
docker compose exec app node node_modules/drizzle-kit/bin.cjs push(The drizzle-kit push command needs esbuild at runtime to load the TS config. If
missing, install it in the container: docker compose exec app npm i esbuild --no-save.)
| Tool | Purpose |
|---|---|
list_accounts |
List the current user's configured accounts |
list_folders |
IMAP LIST — all mailboxes for a given account |
list_messages |
Headers of the N most recent messages in a folder (with filters) |
get_message |
Full message: headers, text, HTML, attachments metadata (with indexes) |
get_thread |
Full conversation around a message; Gmail X-GM-THRID fast-path + RFC 5322 References fallback. cross_folder=true scans every mailbox. |
search_messages |
IMAP SEARCH by from, to, subject, body, date ranges, unread |
get_attachment |
Return a signed 15-minute HTTPS download URL for an attachment (file never persisted on the MCP server). Images are also previewed inline. inline_blob=true opts into returning the raw base64 as an embedded MCP resource. |
| Tool | Purpose |
|---|---|
send_message |
Send via the account's SMTP, appending the HTML signature and base64 attachments; the sent copy is IMAP-appended to the Sent folder (skipped on Gmail, which saves it automatically). |
reply_message |
Reply preserving In-Reply-To / References; same Sent-folder behavior as send_message. |
| Tool | Purpose |
|---|---|
mark_read |
Add \Seen to one or more UIDs |
mark_unread |
Remove \Seen |
flag_messages |
Add \Flagged (the star/favorite) |
unflag_messages |
Remove \Flagged |
set_flags |
Add and/or remove arbitrary IMAP flags (\Answered, $Important, labels…) |
| Tool | Purpose |
|---|---|
move_messages |
Move UIDs from one folder to another |
copy_messages |
Copy UIDs to another folder without removing the original |
delete_messages |
Move to Trash by default; permanent: true expunges |
create_folder |
Create a new IMAP mailbox (supports hierarchical paths) |
rename_folder |
Rename or reparent a mailbox |
delete_folder |
Delete a mailbox (INBOX is rejected) |
| Tool | Purpose |
|---|---|
list_calendar_accounts |
List the user's CalDAV accounts (independent of email accounts) |
list_calendars |
Discover the calendar collections of an account (returns url for each) |
list_events |
Events in a window. Returns etag for optimistic concurrency, tz, startLocal/endLocal and the raw RRULE. Pass expand_recurring=true to also receive expanded occurrences[]. |
get_event |
Fetch a single event (parsed + raw iCal) |
create_event |
Create an event. Optional tz (IANA name) anchors it to a real timezone — recurrences then survive DST correctly. |
update_event |
Patch an event. Requires the etag from list/get; a stale etag returns 412. tz=null re-anchors to UTC. |
delete_event |
Delete an event by URL. Pass etag for safe optimistic deletion. |
find_free_slots |
Find free intervals across one or more calendars, with optional working-hours filter (DST-correct in the working-hours tz). |
The authenticated user's ID is always injected from the OAuth token — tools never accept it as an argument, so a client cannot impersonate another user.
Every event response carries:
start/end— UTC ISO 8601 (always present).startLocal/endLocal— wall-clock string when the event has aTZID, otherwisenull.tz— IANA timezone name (e.g.Europe/Paris) when the event has aTZID, otherwisenull(UTC-stored event).
When creating or updating an event you can:
- Omit
tz→ the event is stored in UTC (Z). Simplest, correct for one-off events whose moment-in-time is what matters. - Pass
tz(IANA name) → the event is stored withDTSTART;TZID=<iana>:<local-time>.start/endmay be either a floating local time ("2026-05-01T10:00:00", interpreted intz) or a zoned/UTC ISO ("…Z"or"…+02:00", converted totzlocal time before storage). This is what you want for recurring events ("every Tuesday at 10:00 Paris time"): each occurrence is reconverted from local to UTC independently, so DST transitions stay correct.
Internals: src/lib/caldav-tz.ts uses Intl.DateTimeFormat to compute the offset of any IANA zone at any UTC instant — no tzdata bundle, no ical.js TimezoneService registration. The CalDAV write path emits TZID=<iana> without an inline VTIMEZONE block; iCloud, Fastmail, Nextcloud and Baïkal accept this. Strict servers that require VTIMEZONE will reject those events — fall back to omitting tz (UTC mode) until a follow-up adds VTIMEZONE generation.
npm install
# Start postgres however you want (docker, local, …) and export DATABASE_URL
npm run db:push # apply schema
npm run dev # Next.js dev server on :3000
npm run typecheck # strict TypeScript
npm run build # production buildusers(id, clerk_user_id UNIQUE)
mail_accounts(id, user_id, label, email,
imap_{host,port,secure,user,password_enc},
smtp_{host,port,secure,user,password_enc},
signature_html, writing_style, is_default)
calendar_accounts(id, user_id, label,
caldav_url, username, password_enc,
default_calendar_url, color, is_default)
oauth_clients(id, client_secret_hash, redirect_uris[], token_endpoint_auth_method)
oauth_auth_codes(code, client_id, user_id, redirect_uri,
code_challenge, code_challenge_method, expires_at, consumed_at)
oauth_tokens(id, access_token_hash UNIQUE, refresh_token_hash,
client_id, user_id, access_expires_at, refresh_expires_at, revoked_at)
- Master key: AES-256-GCM, IV per ciphertext, authenticated. Ciphertext =
base64(iv(12) ‖ ct ‖ tag(16)). - Passwords are never returned from the REST API — only their encrypted blob is stored.
- Access tokens are opaque random strings; DB stores only their SHA-256.
- Refresh tokens rotate on every use (old one is revoked).
- Signatures pass through DOMPurify server-side before storage and before being injected into outgoing mail.
- Attachment download URLs are HMAC-SHA256-signed (separate key derived from
MCP_MASTER_KEY) and expire in 15 minutes. They encode{userId, accountId, folder, uid, index, exp}— tampering is rejected in constant time, expired tokens are refused. Files are never written to disk on the MCP server; each request streams directly from IMAP and is garbage-collected after the response. - The
/api/mcpendpoint always returnsWWW-Authenticate: Bearer resource_metadata="…"on 401, per RFC 9728.
- XOAUTH2 for Gmail / Outlook IMAP (password/app-password only for now)
- OAuth 2.0 for Google Calendar (CalDAV) — Google deprecated basic auth; v1 only supports CalDAV providers that accept app passwords (iCloud, Fastmail, Nextcloud, OVH, Baïkal, …)
- Inline
VTIMEZONEblock generation — events are emitted withTZID=<iana>only; strict servers requiringVTIMEZONEare unsupported - IMAP IDLE / push notifications
- Master-key rotation flow (schema supports it, tool not written yet)
- Per-user rate limiting on MCP tools
PRs welcome for any of the above.
Issues and PRs are welcome. Before opening a PR, please:
npm run typecheckmust pass.npm run buildmust pass.- Keep the monolith mindset: one Next.js app, one container, boring dependencies.
MIT — do whatever you want, no warranty.
