JMAP Proxy

May 11, 2026 · View on GitHub

Overview

The JMAP proxy bridges IMAP/CalDAV/CardDAV backends to the JMAP protocol (RFC 8620/8621, JMAP Calendars, JMAP Contacts). It also supports direct JMAP-to-JMAP passthrough for backends that already speak JMAP natively.

JMAP client  ──JMAP──►  jmap-proxy  ──IMAP/CalDAV/CardDAV──►  mail server
                                    ──JMAP──────────────────►  JMAP server

Quick Start (Docker)

docker run -d \
  --name jmap-proxy \
  --restart unless-stopped \
  -p 9000:9000 \
  -p 127.0.0.1:8080:8080 \
  -v /data/jmap-proxy:/data \
  -e BASEURL=https://jmap.example.com \
  ghcr.io/jmapio/jmap-proxy:latest
  • Port 9000: JMAP endpoint (public — put behind a TLS reverse proxy)
  • Port 8080: Management API (keep localhost-only)
  • /data: persistent volume for SQLite databases

Browse to http://localhost:8080 to open the management dashboard.


Prerequisites

Docker deployment

  • Docker Engine 20+
  • A domain name with DNS pointing at your server
  • A reverse proxy for TLS (Caddy or nginx — see TLS Termination)

Running from source

# Perl 5.20+, then:
cpanm --installdeps .
perl bin/jmap-proxy.pl

Environment Variables

All variables are optional unless marked required.

VariableDefaultDescription
BASEURLhttp://localhost:9000Required for production. Public URL the proxy is reachable at. Used in JMAP Session URLs and OAuth redirect URIs.
JMAP_PORT9000Port for the JMAP endpoint.
JMAP_MGMT_PORT8080Port for the management API and dashboard.
JMAP_MGMT_HOST127.0.0.1Interface the management port binds to. Set to 0.0.0.0 only if you have external access controls.
JMAP_DATADIR/dataDirectory for SQLite databases (accounts.sqlite3 + per-account files).
JMAP_HOME/home/jmap/jmap-perlDirectory containing the proxy source. Set automatically in Docker.
JMAP_IDLE_TIMEOUT300Seconds of inactivity before a per-account worker is killed. Set to 0 to disable.
JMAP_SYNC_INTERVAL30Seconds between background IMAP/CalDAV/CardDAV sync polls.
JMAP_DEBUG(unset)Set to any value to log full JMAP request/response bodies to stderr.
JMAP_SECRET_KEY(unset)64 hex chars (256-bit AES key) for credential encryption. Recommended for production.
JMAP_OPENBAO_ADDR(unset)OpenBao/Vault address for Transit-based credential encryption (e.g. http://vault:8200).
JMAP_OPENBAO_TOKEN(unset)Static OpenBao token (simpler; prefer AppRole for production).
JMAP_OPENBAO_ROLE_ID(unset)OpenBao AppRole role ID (for AppRole auth).
JMAP_OPENBAO_SECRET_ID(unset)OpenBao AppRole secret ID.
JMAP_OPENBAO_MOUNTtransitOpenBao secrets engine mount point.
JMAP_OPENBAO_KEYjmap-credentialsTransit key name inside the mount.
GOOGLE_CLIENT_ID(unset)OAuth2 client ID for Gmail accounts.
GOOGLE_CLIENT_SECRET(unset)OAuth2 client secret for Gmail accounts.
FASTMAIL_CLIENT_ID(unset)OAuth2 client ID for Fastmail accounts (uses built-in ID if unset).

TLS Termination

The proxy speaks plain HTTP. Put a reverse proxy in front.

jmap.example.com {
    reverse_proxy 127.0.0.1:9000
}

nginx

server {
    listen 443 ssl;
    server_name jmap.example.com;

    ssl_certificate     /etc/ssl/certs/jmap.pem;
    ssl_certificate_key /etc/ssl/private/jmap.key;

    location / {
        proxy_pass http://127.0.0.1:9000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        client_max_body_size 50m;
    }
}

Adding Accounts

Via the web UI

Browse to http://localhost:8080 and use the management dashboard. It supports adding IMAP, CalDAV/CardDAV, and JMAP passthrough accounts.

Via the management API

# IMAP account with CalDAV/CardDAV
curl -X POST http://localhost:8080/api/accounts \
  -H 'Content-Type: application/json' \
  -d '{
    "accountid":  "alice",
    "email":      "alice@example.com",
    "type":       "imap",
    "username":   "alice@example.com",
    "password":   "secret",
    "imapHost":   "imap.example.com",
    "imapPort":   993,
    "imapSSL":    2,
    "smtpHost":   "smtp.example.com",
    "smtpPort":   587,
    "smtpSSL":    3,
    "caldavURL":  "https://dav.example.com",
    "carddavURL": "https://dav.example.com"
  }'

# JMAP passthrough account
curl -X POST http://localhost:8080/api/accounts \
  -H 'Content-Type: application/json' \
  -d '{
    "accountid":  "bob",
    "email":      "bob@example.com",
    "sessionUrl": "https://api.example.com/jmap",
    "username":   "bob@example.com",
    "password":   "secret",
    "authType":   "basic"
  }'

SSL/TLS values for imapSSL / smtpSSL:

ValueMeaning
0Plain (no encryption)
1Plain (alias for 0)
2SSL/TLS from the start (IMAPS, port 993)
3STARTTLS upgrade (IMAP STARTTLS, port 143 or 587)

Via the sign-up form

The web UI at $BASEURL/ includes a sign-up form for self-service account registration. Users enter their email address; the proxy performs SRV DNS auto-discovery for IMAP/SMTP and PACC (draft-ietf-mailmaint-pacc) for OAuth2 providers, then redirects to OAuth or a password form as appropriate.


OAuth2 Setup

Gmail

  1. Create a project in Google Cloud Console.
  2. Enable the Gmail API and People API.
  3. Create an OAuth2 credential (web application type).
  4. Add $BASEURL/cb/oauth as an authorised redirect URI.
  5. Set the environment variables:
GOOGLE_CLIENT_ID=<your-client-id>.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=<your-client-secret>

Fastmail

Fastmail uses PKCE OAuth2 — no client secret needed.

FASTMAIL_CLIENT_ID=<your-client-id>

If FASTMAIL_CLIENT_ID is not set, the proxy uses a built-in app registration that works for testing but may have rate limits.


Credential Encryption

IMAP/SMTP passwords and OAuth tokens are stored in SQLite. Without encryption they are stored in plaintext — acceptable for personal use but not for multi-user deployments.

AES-256-GCM (single-server)

# Generate a key
openssl rand -hex 32
JMAP_SECRET_KEY=<64-hex-chars>

Keep the key separate from the data volume. Losing the key means losing access to all stored credentials.

OpenBao / Vault Transit (multi-server / production)

Keys never leave OpenBao; the proxy only ever holds ciphertexts.

# Dev mode — use proper storage in production
docker run -d --name openbao \
  -e VAULT_DEV_ROOT_TOKEN_ID=mytoken \
  -p 8200:8200 quay.io/openbao/openbao:latest

export VAULT_ADDR=http://localhost:8200 VAULT_TOKEN=mytoken
bao secrets enable transit
bao write transit/keys/jmap-credentials type=aes256-gcm96
# Option A: static token
JMAP_OPENBAO_ADDR=http://openbao:8200
JMAP_OPENBAO_TOKEN=mytoken

# Option B: AppRole (recommended)
JMAP_OPENBAO_ADDR=http://openbao:8200
JMAP_OPENBAO_ROLE_ID=<role-id>
JMAP_OPENBAO_SECRET_ID=<secret-id>

Migration

Encrypted and plaintext credentials can coexist. To migrate:

  1. Set JMAP_SECRET_KEY (or JMAP_OPENBAO_*) and restart.
  2. Credentials are re-encrypted the next time each account syncs.
  3. To force all accounts immediately, trigger a settings update via the management API.

When migrating from AES to OpenBao, keep JMAP_SECRET_KEY set until all accounts have been re-encrypted — old enc1: values are still readable.


Connecting JMAP Clients

After setup, point any RFC 8620-compliant JMAP client at:

GET $BASEURL/.well-known/jmap

This redirects to the Session object at $BASEURL/session.

Authentication: Basic (email:password), Bearer token, or session cookie. Clients that follow RFC 8620 auto-discovery will find everything from the session URL.

Recommended clients:


Monitoring

Health check

curl http://localhost:8080/healthz
# {"status":"ok","uptime":3600,"children":2,"pid":12345}

Prometheus metrics

curl http://localhost:8080/metrics

Key metrics:

MetricTypeDescription
jmap_uptime_secondsgaugeSeconds since the proxy started
jmap_backend_workers_activegaugeLive per-account worker processes
jmap_backend_queue_depthgaugePending backend requests
jmap_sse_connections_activegaugeOpen Server-Sent Events connections
jmap_http_requests_totalcounterJMAP port requests
jmap_method_calls_totalcounterIndividual JMAP method calls
jmap_method_errors_totalcounterMethod calls returning an error
jmap_account_last_sync_age_secondsgaugePer-account sync lag (labelled by accountid)

Troubleshooting

Proxy not responding
Check docker logs jmap-proxy (or /tmp/jmap-proxy.log when running from source). The proxy logs fatal errors and per-account sync warnings to stderr.

Account sync failing
Trigger a manual sync and watch for errors:

curl -X POST http://localhost:8080/api/accounts/ACCOUNTID/sync
docker logs -f jmap-proxy

JMAP requests returning wrong URLs
BASEURL must match the public URL the client reaches. If it defaults to http://localhost:9000, all session URLs will be wrong.

Debug mode
Set JMAP_DEBUG=1 to log full request/response JSON to stderr. Do not leave this enabled in production — it logs credentials in OAuth responses.

SSE push not working behind nginx
Add proxy_buffering off and proxy_read_timeout 3600 to the nginx location block, and ensure X-Accel-Buffering: no is passed through.