No description
  • Rust 99.7%
  • Dockerfile 0.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Julian Lindner 56ca99a19b
All checks were successful
CI / tag-ancestry (push) Successful in 2s
CI / cargo (push) Successful in 1m16s
CI / docker (push) Successful in 26s
Merge pull request 'Release0.3.1 with patched official Debian13 runtime' (#48) from fix/runtime-base-20261029 into main
2026-10-28 18:39:31 +00:00
.forgejo/workflows ci: re-pin rust-toolchain to a master commit the mirror can resolve 2026-10-24 10:59:03 +08:00
.github/workflows fix(ci): refuse a release tag that is not an ancestor of main 2026-08-27 10:17:17 +08:00
src chore(release): prepare v0.3.0 guarded draft API 2026-10-29 01:41:18 +08:00
.dockerignore feat(release): prepare secure public v0.2.10 2026-08-19 15:12:41 +08:00
.env.example docs(env): use a placeholder in the example config 2026-08-23 00:02:18 +08:00
.gitignore chore: stop tracking Playwright MCP session snapshots 2026-07-07 12:17:25 +08:00
AGENTS.md Clarify the currently active Forge release path 2026-10-29 02:31:45 +08:00
Cargo.lock Release 0.3.1 with patched Debian 13 runtime 2026-10-29 02:21:29 +08:00
Cargo.toml Release 0.3.1 with patched Debian 13 runtime 2026-10-29 02:21:29 +08:00
CHANGELOG.md Release 0.3.1 with patched Debian 13 runtime 2026-10-29 02:21:29 +08:00
CLAUDE.md docs: fold CLAUDE.md into AGENTS.md and date the mirror pitfall 2026-08-26 12:44:40 +08:00
CODE_OF_CONDUCT.md feat(release): prepare secure public v0.2.10 2026-08-19 15:12:41 +08:00
compose.yaml Release 0.3.1 with patched Debian 13 runtime 2026-10-29 02:21:29 +08:00
CONTRIBUTING.md feat(release): prepare secure public v0.2.10 2026-08-19 15:12:41 +08:00
deny.toml feat: jmap-mcp — remote MCP server bridging claude.ai to a Stalwart JMAP mailbox 2026-06-02 09:53:12 +08:00
Dockerfile Release 0.3.1 with patched Debian 13 runtime 2026-10-29 02:21:29 +08:00
LICENSE feat: jmap-mcp — remote MCP server bridging claude.ai to a Stalwart JMAP mailbox 2026-06-02 09:53:12 +08:00
README.md Release 0.3.1 with patched Debian 13 runtime 2026-10-29 02:21:29 +08:00
rust-toolchain.toml feat(release): prepare secure public v0.2.10 2026-08-19 15:12:41 +08:00
rustfmt.toml feat: jmap-mcp — remote MCP server bridging claude.ai to a Stalwart JMAP mailbox 2026-06-02 09:53:12 +08:00
SECURITY.md Release 0.3.1 with patched Debian 13 runtime 2026-10-29 02:21:29 +08:00

jmap-mcp

A remote Model Context Protocol server that lets Claude and other streamable-HTTP MCP clients read, search, compose, and organise mail in a Stalwart JMAP mailbox using the user's existing Logto identity.

jmap-mcp validates each inbound OAuth access token against Logto's JWKS and forwards the same token to Stalwart. It stores no mailbox passwords and has no per-user database or persistent volume.

Important

The current authentication bridge is specifically tested with Logto and Stalwart. Other JMAP or OIDC implementations may work only when their issuer, token, audience, claim, and dynamic-registration behaviour matches this deployment model.

Features

The server exposes 49 tools with MCP read-only, destructive, and idempotency annotations:

  • Identity: whoami, get_identities, create_identity
  • Read: mailbox listing and metadata, recent mail and activity, search, message and thread reading, unread summaries, headers, and attachments
  • Enumerate: query_email_ids, bounded id-only paging over one mailbox (below)
  • State: read/unread, flags, keywords, move, and copy
  • Compose: send, reply, forward, save/update drafts, and attachments
  • Delete: trash, permanent deletion, empty trash, and empty spam
  • Mailbox management: create, rename, delete, subscribe, and unsubscribe
  • Profile: profile, account information, vacation response, and session verification
  • Self-audit: append envelope-only audit notes to a designated mailbox

Sender-controlled bodies, subjects, snippets, addresses, structured headers, and attachment filenames are treated as untrusted. Returned bodies are wrapped in <email:message trust="external"> delimiters, prompt-injection markers are escaped, and suspicious content is flagged.

Saving and replacing drafts safely

save_draft creates a plain-text draft and verifies it before returning its email_id, account Email state, and draft revision. get_draft_version returns the current state/revision for an owned draft without returning content. Neither tool submits mail. Existing send tools are separate; clients implementing a draft-only workflow must not expose them.

update_draft now requires expected_state and expected_revision as well as the old email_id. An old call without both preconditions fails closed. JMAP Email content is immutable (RFC 8621 §4.1), so an update creates a replacement and returns a new email_id; it never patches the old subject, addresses or body. After verifying the replacement and unchanged original, it conditionally moves the original to Trash and verifies both again. It never permanently deletes either copy. Keep the returned new ID, state and revision for the next generation. Original copies in Trash remain subject to the mailbox's normal retention policy.

Only an owned From address, $draft: true and exclusive Drafts membership are accepted. HTML, attachments, multipart bodies, truncated/undecodable bodies, unsupported or duplicate headers, and part metadata that cannot be preserved are refused before writing. The computed htmlBody display list may contain only the same plain part as textBody (RFC 8621 §4.1.4); that is not HTML content. The supported plain-text content, recipients, threading fields and keywords are retained; a replacement receives a new server-generated Message-ID. There is no new credential, privilege or server storage.

The account state is an optimistic concurrency guard (RFC 8620 §5.3). Unrelated mail activity can change it too. A client may refresh the state only when the draft revision still equals the generation it previously accepted and no earlier call consumed that generation with retry_safe: false. Never automatically adopt a changed revision: that may be a human edit or send. The revision binds the account, Email ID, immutable blob, mailbox memberships and keywords. Ownership means any draft owned by that account; it does not prove an agent authored it. Autonomous clients must separately retain only IDs they created and the unchanged generation revision. Serialize operations per owned draft generation: hold a workflow lock from the first version read through outcome recording. A concurrent caller must not refresh state while another replacement is in flight. Neither state nor revision is a server-side retry or lock ledger. The replacement receives a new receivedAt arrival time, which can change client sorting; sentAt/Date is preserved.

Failures after creation or uncertain writes return an error with stage, original_email_id, replacement_email_id when known, and retry_safe: false. This permanently consumes that automation generation: record the known replacement ID and stop automatic updates. Refreshed state never makes this generation retryable, even when its old revision is unchanged. A human must reconcile Drafts and Trash and explicitly accept a new generation. A lost creation response can leave an additional draft whose ID is not yet known; there is no automatic retry or claim of atomic multi-step replacement. A concurrent edit after cleanup is reported as a conflict even though the old copy is safely in Trash. Live fixture acceptance remains a release gate; the source tests use a synthetic backend and never write production mail.

Configured audit mailboxes create audit Emails asynchronously after write tools. Their writes can immediately stale the returned account state, or cause a conditional retirement conflict after replacement creation. Treat these like other account changes; never bypass preconditions or retry a consumed generation. After both writes complete, unrelated account activity is allowed only when both per-email revisions still match. Bodies over 512 KiB are refused before creation. Input CRLF body lines normalize to LF per RFC 8621; lone CR and other body controls (except LF/tab) are refused. Subjects strip leading spaces and reject controls; empty/missing subject text is equivalent at readback. A single bracketed reply Message-ID is normalized to its bare value. Address arguments support bare addresses only; whitespace/display-name/group forms are refused before creation. Blank stored From fields never fall back to the session identity. The disposable live acceptance matrix must cover server normalization of multiline/non-ASCII/long-line bodies, empty subjects, bracketed threading IDs, real MIME headers/part IDs, and audit-mailbox races before deployment.

Enumerating a mailbox with query_email_ids

query_email_ids walks one mailbox and returns only JMAP Email ids plus the metadata needed to ask for the next page. It issues a single Email/query and never Email/get, so no body, subject, address or header passes through it, and it writes nothing (readOnlyHint: true, destructiveHint: false). Use it to size or enumerate a mailbox; use list_recent_emails or read_email when you want content.

Arguments

argument required meaning
mailbox_id yes the mailbox to enumerate, as an opaque JMAP Mailbox id
received_after no UTCDate; matches mail received at or after this instant
received_before no UTCDate; matches mail received strictly before this instant
position no zero-based index of the first id (default 0)
limit no page size, 1–100 (default 50)
expected_query_state no drift check; requires expected_query_fingerprint
expected_query_fingerprint no drift check; requires expected_query_state

The schema is closed: any other argument is refused rather than ignored. There is no raw JMAP filter or sort passthrough — the sort is always receivedAt ascending, because position-based paging is only coherent while every page asks the same question.

Dates are RFC 8620 §1.4 UTCDate: 2026-01-31T09:30:00Z, uppercase T, literal Z, optional fractional second of 1–9 digits. An all-zero fraction (.000Z), a numeric offset, a naive local time and a leap second are each refused with a reason. The value you send is forwarded to the backend verbatim.

Result

field meaning
ids this page's ids, in the server's order
position the index the page actually starts at
total total matching mail, or null if the backend declined to count
has_more whether another page may follow
next_position the position for the next call, or null when terminal
query_state the backend's queryState for this query
query_fingerprint identifies the account, filter and sort this state belongs to

Continue from next_position, never from position + limit. A JMAP server may enforce a smaller limit than you asked for (RFC 8620 §5.5), and the difference between those two numbers is a silently skipped id.

When total is null, a short page is not the end: keep going until a page comes back empty. With a total, the walk ends on the last full page without spending an extra round-trip.

The drift checkpoint

If both checkpoint fields are omitted, pagination is best-effort: it cannot certify complete, exactly-once coverage while the mailbox changes. For checked continuation, repeat the same mailbox and date arguments on every page and carry both returned checkpoint fields forward; page size may change.

A mailbox can change under an enumeration. Pass the previous page's query_state and query_fingerprint back on the next call and the tool refuses to continue if the ordered set has moved, rather than splicing pages from two different orderings into a result that looks complete:

// page 2, checked against page 1
{ "mailbox_id": "mb-1", "position": 50, "limit": 50,
  "expected_query_state": "<page 1's query_state>",
  "expected_query_fingerprint": "<page 1's query_fingerprint>" }

The two are inseparable, and sending one without the other is refused. A queryState is only meaningful against the same account, filter and sort (RFC 8620 §5.5), so a state compared without the fingerprint that identifies which query it came from can match by coincidence and report a coverage guarantee it never made. The fingerprint is a sha256 of the account id, the canonical filter and the fixed sort; it carries no credential or session material and is safe to store and log.

Both failures answer with JSON-RPC code -32026 and no ids at all:

  • query_fingerprint_mismatch — the checkpoint belongs to a different account, mailbox, date window or sort. Refused before the query is issued.
  • query_state_changed — the same query, but the mailbox's ordering moved. The message names both states.

Either way the walk must restart from position: 0; nothing partial is returned, and the tool never claims exact coverage across a drift.

A malformed backend response — a missing queryState, position or ids, a page longer than the limit in force, duplicate or empty ids, a total behind the window, or an empty page while total says results remain — is refused with upstream_response_invalid rather than answered with a page. Each of those shapes has a lenient reading that looks exactly like the end of a mailbox, which is the one thing an enumerator must never be told by mistake.

When an empty page is allowed to mean "finished"

ids: [] is the only thing that ends an enumeration, so the tool is strict about which empty pages it will accept as one. An empty page ends the walk when it answers the position that was requested, and in exactly one other case: the request was at or past a known total and the server clamped to the end of the result set. Everything else is refused:

  • an empty page at a position nobody asked for, with no total — there is nothing that could make the shift consistent;
  • an empty page at a shifted position while total says results exist at the position that was requested;
  • an empty page under a server-reported enforced limit of 0, which is empty because of the cap rather than because of the mailbox.

A reported enforced limit above the requested one is fine: RFC 8620 §5.5 has the server report the maximum it enforces, and the requested limit still bounds the page.

Every page of a walk asks an identical Email/query apart from position: the same accountId and filter, sort fixed to receivedAt ascending, collapseThreads: false (sent explicitly — true would return one Email per Thread, a different ordered set), and calculateTotal: true.

Requirements

  • A public HTTPS hostname for jmap-mcp
  • A Logto tenant with a public application and API resource
  • A Stalwart server with JMAP enabled and an OIDC directory that trusts Logto
  • An MCP client supporting streamable HTTP and OAuth 2.1

Quick start

Copy the environment template, fill in your deployment-specific values, and start the published Linux/amd64 image:

cp .env.example .env
docker compose up -d
curl http://127.0.0.1:3000/health

The release pipeline publishes to:

forge.oddie.app/jlxq0/jmap-mcp:v0.3.1

Require successful tag CI, final-image vulnerability scanning and the immutable index digest before deploying. The image is published before its final scan, so registry presence alone is not a passing gate (0.3.0 failed that scan).

As of 2026-10-29 the Forge push-mirror list is empty. The GitHub release workflow is retained, but GHCR publishing and keyless signing are not available by inference: verify a real mirrored tag and successful GitHub release before using GHCR.

linux/amd64 only. No arm64 variant is published, so on Apple Silicon, AWS Graviton, or any other ARM host docker pull fails with no matching manifest for linux/arm64/v8 in the manifest list entries. Add --platform linux/amd64 to run it there under emulation:

docker pull --platform linux/amd64 forge.oddie.app/jlxq0/jmap-mcp:v0.3.1

For a direct container invocation:

docker run --rm -p 3000:3000 --env-file .env \
  forge.oddie.app/jlxq0/jmap-mcp:v0.3.1

Expose port 3000 through HTTPS and configure the MCP client with:

https://jmap-mcp.your-domain.example/mcp

GET /health is public and returns the running package version:

{"status":"healthy","version":"0.3.1"}

Logto and Stalwart setup

The following identifiers must remain aligned:

  1. Create a Logto API resource whose indicator is the bare jmap-mcp origin, such as https://jmap-mcp.your-domain.example.

  2. Create a public SPA/native Logto application. Put its client ID in JMAP_MCP_DCR_CLIENT_ID; no client secret is used by the DCR flow.

  3. Register https://jmap-mcp.your-domain.example/oauth/callback as the Logto application's redirect URI.

  4. Put every MCP client's callback URI in JMAP_MCP_OAUTH_REDIRECT_URIS. HTTPS and native private-use callbacks match exactly. Loopback HTTP callbacks match the configured host, path, and query while allowing the client to choose an ephemeral local port, as required by RFC 8252.

  5. Configure Stalwart's OIDC directory to trust the same Logto issuer, accept the jmap-mcp origin as the token audience, and map the token's username, email, or sub claim to the mailbox principal.

  6. Confirm that a Logto access token for the resource can discover a JMAP session directly:

    curl -H "Authorization: Bearer $ACCESS_TOKEN" \
      https://mail.your-domain.example/.well-known/jmap
    

The JWT aud value accepted by jmap-mcp and Stalwart must contain the exact bare origin. JMAP_MCP_RESOURCE_URL accepts either the origin or an origin ending in /mcp; jmap-mcp canonicalises it to the bare origin and advertises <origin>/mcp through RFC 9728 metadata.

Configuration

Variable Required Default Purpose
JMAP_MCP_RESOURCE_URL yes — Public origin and required JWT audience; a trailing /mcp is accepted and removed
JMAP_MCP_AUTHORIZATION_SERVER yes — Logto OIDC issuer, normally ending in /oidc
JMAP_MCP_STALWART_JMAP_BASE_URL yes — Stalwart base used for /.well-known/jmap discovery
JMAP_MCP_DCR_CLIENT_ID for DCR clients disabled Pre-provisioned public Logto client returned by /register
JMAP_MCP_OAUTH_REDIRECT_URIS for OAuth proxy empty Comma-separated MCP client callback allowlist; only loopback HTTP ports may vary
JMAP_MCP_BIND_ADDR no 0.0.0.0:3000 Public HTTP listener
JMAP_MCP_METRICS_BIND_ADDR no 127.0.0.1:9090 Internal Prometheus listener; never publish it directly
POD_IP no — Derives the metrics listener as <pod-ip>:9090 in Kubernetes
JMAP_MCP_RATE_LIMIT_READS_PER_MIN no 60 Per-identity read quota
JMAP_MCP_RATE_LIMIT_WRITES_PER_MIN no 30 Per-identity write quota
JMAP_MCP_DOWNLOAD_MAX_BYTES no 5242880 Maximum attachment download response
JMAP_MCP_UPLOAD_MAX_BYTES no 10485760 Maximum remote URL attachment fetch
JMAP_MCP_TRUSTED_PROXY_HOPS no 1 Trusted rightmost X-Forwarded-For proxy count; use 0 when directly exposed
JMAP_MCP_EXTRA_FROM_ADDRESSES for mailbox aliases empty Extra sendable addresses, comma- or whitespace-separated. Stalwart exposes principal aliases only through x:Account/*, which need sysAccountGet/sysAccountQuery permissions an ordinary user token lacks, so aliases must be named here. Role addresses are refused
JMAP_MCP_STALWART_CONNECT_IP no DNS Optional fixed Stalwart socket address for private routing while preserving TLS host validation
JMAP_MCP_LOGTO_CLIENT_ID no — Opaque-token introspection client ID; must be paired with the secret
JMAP_MCP_LOGTO_CLIENT_SECRET no — Opaque-token introspection client secret
JMAP_MCP_LOG_FORMAT no compact Set to json for structured logs
RUST_LOG no application defaults Standard tracing filter
OTEL_EXPORTER_OTLP_ENDPOINT no disabled Enables OTLP trace export
OTEL_SERVICE_NAME no jmap-mcp OpenTelemetry service name

The app fails startup when required values are missing or malformed. When DCR is configured, an empty redirect allowlist fails authorization closed.

Security and privacy

jmap-mcp grants an MCP client access to a user's mailbox. Operators and users must trust the MCP client, model provider, Logto, Stalwart, and the jmap-mcp operator.

  • Access tokens are held in memory and forwarded to Stalwart; raw tokens are not logged or persisted.
  • Audit logs include the account identity, MCP method, mailbox/message resource identifiers, outcome, latency, result count, error class, and the first 16 hexadecimal characters of a SHA-256 token hash. They exclude message bodies, subjects, recipients, attachment contents, and free-form tool arguments.
  • Remote attachment fetches require HTTPS, reject private/reserved addresses, pin the validated socket address, bypass environment proxies, and enforce byte limits.
  • In-memory caches, pending OAuth state, rate-limit key maps, MCP sessions, and request bodies have hard bounds.
  • Release CI runs formatting, Clippy, tests, independent RustSec and OSV dependency audits, cargo-deny, container vulnerability scanning, SBOM generation, provenance attestation, and keyless image signing.

See SECURITY.md for reporting and supported-version policy.

Building and testing

Rust 1.93 is pinned in rust-toolchain.toml.

cargo fmt --all --check
cargo clippy --all-targets --all-features --locked -- -D warnings
cargo test --all-features --locked
cargo audit
cargo deny check bans licenses sources
docker build --platform linux/amd64 -t jmap-mcp:local .

Contributing

See CONTRIBUTING.md and CODE_OF_CONDUCT.md. Release history is maintained in CHANGELOG.md.

License

MIT.