JMAP Proxy

May 12, 2026 · View on GitHub

The management API runs on JMAP_MGMT_PORT (default 8080, bound to 127.0.0.1 by default). It is unauthenticated — keep it off the public internet. The management dashboard at http://localhost:8080/ uses this API.

All request and response bodies are JSON (Content-Type: application/json).


Health & Metrics

GET /healthz

Returns the current process status.

Response 200

{
  "status":   "ok",
  "uptime":   3600,
  "children": 2,
  "pid":      12345
}
FieldDescription
statusAlways "ok" if the process is alive.
uptimeSeconds since the proxy started.
childrenNumber of live per-account worker processes (excludes __accounts__).
pidOS process ID of the parent.

GET /metrics

Returns Prometheus-format metrics (text/plain; version=0.0.4).

# HELP jmap_uptime_seconds Seconds since proxy process started
# TYPE jmap_uptime_seconds gauge
jmap_uptime_seconds 3600

# HELP jmap_backend_workers_active Active per-account backend worker processes
# TYPE jmap_backend_workers_active gauge
jmap_backend_workers_active 2

# HELP jmap_http_requests HTTP requests received on the JMAP port
# TYPE jmap_http_requests counter
jmap_http_requests_total 4182

# HELP jmap_account_last_sync_age_seconds Seconds since last successful sync per account
# TYPE jmap_account_last_sync_age_seconds gauge
jmap_account_last_sync_age_seconds{accountid="alice"} 28
jmap_account_last_sync_age_seconds{accountid="bob"} 12

Full metric list: see the Monitoring section of SETUP.md.


Accounts

GET /api/accounts

Lists all registered accounts with basic statistics.

Response 200

[
  {
    "accountid": "alice",
    "email":     "alice@example.com",
    "type":      "imap",
    "imapHost":  "imap.example.com",
    "imapPort":  993,
    "folders":   42,
    "messages":  1830
  }
]
FieldDescription
accountidUnique identifier for the account.
emailEmail address.
type"imap" or "jmap".
imapHostIMAP hostname (IMAP accounts only).
imapPortIMAP port (IMAP accounts only).
foldersNumber of synced folders.
messagesNumber of synced messages.

GET /api/accounts/:accountid

Returns details for a single account.

Response 200 — same shape as one element of the GET /api/accounts list.

Response 404

{ "error": "not found" }

POST /api/accounts

Creates and initialises a new account.

IMAP account

{
  "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"
}
FieldRequiredDescription
accountidyesUnique identifier. Alphanumeric, no spaces.
emailyesEmail address shown to JMAP clients.
typeno"imap" (default).
usernamenoIMAP login name. Defaults to email.
passwordnoIMAP password or app-specific password.
imapHostnoIMAP server hostname. If omitted, SRV DNS discovery is attempted.
imapPortnoIMAP port. Default 993.
imapSSLno0=plain, 2=TLS, 3=STARTTLS. Default 2.
smtpHostnoSMTP server hostname.
smtpPortnoSMTP port. Default 587.
smtpSSLno0=plain, 2=TLS, 3=STARTTLS. Default 3.
caldavURLnoCalDAV base URL for calendar sync.
carddavURLnoCardDAV base URL for contacts sync.

Response 201

{ "accountid": "alice", "type": "imap" }

If the account was created but the initial IMAP sync failed:

{
  "accountid": "alice",
  "type":      "imap",
  "warning":   "setup failed: Connection refused"
}

The account still exists; you can trigger a sync manually once the backend is reachable.

JMAP passthrough account

{
  "accountid":  "bob",
  "email":      "bob@example.com",
  "sessionUrl": "https://jmap.example.com/session",
  "username":   "bob@example.com",
  "password":   "secret",
  "authType":   "basic"
}
FieldRequiredDescription
accountidyesUnique identifier.
emailyesEmail address.
sessionUrlyesURL of the upstream JMAP Session object (/.well-known/jmap or direct).
usernamenoUsername for authenticating to the upstream.
passwordnoPassword or Bearer token.
authTypeno"basic" (default) or "bearer".

Response 201

{ "accountid": "bob", "type": "jmap", "email": "bob@example.com" }

Note: for JMAP passthrough accounts the proxy fetches the upstream Session to discover the real accountId, which may differ from the accountid you provide. The response contains the canonical accountid to use in subsequent calls.

Error responses

StatusBodyMeaning
400{"error":"invalid JSON"}Request body was not valid JSON.
400{"error":"accountid required"}accountid field missing.
500{"error":"<message>"}Backend error during setup.

DELETE /api/accounts/:accountid

Deletes an account and all its local data.

This stops the per-account worker, removes the account from accounts.sqlite3, and deletes the per-account SQLite database file. Emails and calendar data on the remote backend are not affected.

Response 200

{ "deleted": true }

Response 404 — if the account does not exist (returned by the accounts child).


POST /api/accounts/:accountid/sync

Triggers an immediate IMAP/CalDAV/CardDAV sync for the account. The sync runs in the account's worker process; this call returns once the sync completes.

For JMAP passthrough accounts this is a no-op (no local sync state).

Response 200

{ "synced": "alice" }

Response 500

{ "error": "IMAP connection refused" }

JMAP Endpoint

The following endpoints are on JMAP_PORT (default 9000) and require authentication (Basic, Bearer, or cookie).

GET /.well-known/jmap

Redirects (301) to $BASEURL/session.

GET /session

Returns the JMAP Session object (RFC 8620 §2). The response includes:

  • All accounts in the authenticated user's pool
  • Capability declarations for core, mail, calendars, contacts, quota, principals
  • URLs for API, upload, download, event source

POST /jmap

Main JMAP API endpoint (RFC 8620 §3). Accepts a Request object:

{
  "using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
  "methodCalls": [["Email/get", {"ids": ["abc"]}, "r1"]]
}

Cross-account /copy methods (Blob/copy, Email/copy, CalendarEvent/copy, ContactCard/copy) are handled at the parent level and can address any two accounts within the same account pool.

POST /upload/:accountid

Blob upload (RFC 8620 §6.1). Content-Type header sets the blob MIME type. Returns:

{
  "accountId": "...",
  "blobId":    "f-<uuid>",
  "type":      "image/jpeg",
  "size":      12345
}

GET /raw/:accountid/:blobId/:name

Blob download (RFC 8620 §6.2). name is used as the Content-Disposition filename. Requires authentication.


PDPA (Personal Data Portability Archive)

These endpoints implement personal data export and import per draft-ietf-mailmaint-pdparchive. Both live on JMAP_PORT and require the same authentication as the JMAP endpoint (Basic, Bearer, or cookie).

GET /pdpa

Exports the authenticated account's data as a zip archive.

Response 200Content-Type: application/zip, Content-Disposition: attachment; filename="pdpa-<accountid>.zip"

The zip contains:

PathContents
archive.jsonMetadata: generator, timestamp, version
mail/{folder}/folder.jsonFolder metadata with a list of messages
mail/{folder}/{n}.emlRaw RFC-822 message (one file per message)
contacts/{ab}/folder.jsonAddress book metadata with a list of cards
contacts/{ab}/{uid}.jsonJSContact card
calendars/{cal}/folder.jsonCalendar metadata with a list of events
calendars/{cal}/{uid}.jsonJSCalendar event (jscalendarbis-15)

Messages are exported from fetch_blobs (raw RFC-822 from IMAP), keywords are converted to IMAP flags ($seen\Seen etc.). Each email is placed in its first listed mailbox; emails that appear in multiple mailboxes are exported once.

Example

curl -u alice:password http://localhost:9000/pdpa -o alice.zip

POST /pdpa

POST /pdpa?prefix=FolderName

Imports a PDPA zip archive into the authenticated account.

RequestContent-Type: application/zip, body is the zip file.

Query parameter

ParameterDescription
prefixIf set, all imported mail goes under prefix/original-folder, the address book name becomes prefix/original-name, and the calendar name becomes prefix/original-name. If absent, data is merged directly into the existing hierarchy.

Behaviour

  • Mail: missing mailboxes are created (parent folders first), then each .eml is registered as a blob (store_blob) and imported via Email/import. \Recent is stripped (server-managed). IMAP flags are converted to JMAP keywords (\Seen$seen etc.).
  • Contacts: a new address book is created for each source address book; cards are imported via ContactCard/set. id and addressBookIds from the archive are ignored; the server assigns new values.
  • Calendars: a new calendar is created for each source calendar; events are imported via CalendarEvent/set. id and calendarIds from the archive are ignored; the server assigns new values.

Existing data is not replaced — imported data is added alongside it.

Response 200

{ "ok": true }

Response 400 — if the request body is not a valid zip.

Examples

# Merge into existing folder structure
curl -u alice:password -X POST http://localhost:9000/pdpa \
  -H 'Content-Type: application/zip' --data-binary @alice.zip

# Import under an "Imported" subtree
curl -u alice:password -X POST 'http://localhost:9000/pdpa?prefix=Imported' \
  -H 'Content-Type: application/zip' --data-binary @alice.zip

GET /eventsource

Server-Sent Events push channel (RFC 8620 §7.3).

Query parameters:

  • types: comma-separated list of data-type names to watch (e.g. Email,Mailbox)
  • closeafter: state to close after first StateChange event, or no (default)
  • ping: client-requested ping interval in seconds (minimum 30, default 300)

Events:

  • stateStateChange object (RFC 8620 §7.1) when any watched type changes
  • ping — keepalive with {"interval": N}

OAuth2 Endpoints

These endpoints handle the web-based sign-up and OAuth2 flows. They live on JMAP_PORT and serve the user-facing UI.

PathDescription
GET /Landing page and self-service sign-up form
GET /accountsAuthenticated account management page (add, edit, detach, delete accounts; manage tokens)
GET /cb/oauthOAuth2 callback — receives code from Google/Fastmail after authorisation
GET /.well-known/oauth-authorization-serverRFC 8414 OAuth2 server metadata (for OIDC clients)
GET /oauth/jwksOIDC JSON Web Key Set (RS256 public key)

Error Responses

All management API errors return JSON:

{ "error": "human-readable message" }

Standard HTTP status codes apply: 400 bad request, 404 not found, 500 internal error.

JMAP-level errors (on POST /jmap) follow RFC 8620 §3.6:

{
  "type":        "urn:ietf:params:jmap:error:notJSON",
  "status":      400,
  "detail":      "..."
}