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).
Returns the current process status.
Response 200
{
"status": "ok",
"uptime": 3600,
"children": 2,
"pid": 12345
}| Field | Description |
|---|---|
status |
Always "ok" if the process is alive. |
uptime |
Seconds since the proxy started. |
children |
Number of live per-account worker processes (excludes __accounts__). |
pid |
OS process ID of the parent. |
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.
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
}
]| Field | Description |
|---|---|
accountid |
Unique identifier for the account. |
email |
Email address. |
type |
"imap" or "jmap". |
imapHost |
IMAP hostname (IMAP accounts only). |
imapPort |
IMAP port (IMAP accounts only). |
folders |
Number of synced folders. |
messages |
Number of synced messages. |
Returns details for a single account.
Response 200 — same shape as one element of the GET /api/accounts list.
Response 404
{ "error": "not found" }Creates and initialises a new 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"
}| Field | Required | Description |
|---|---|---|
accountid |
yes | Unique identifier. Alphanumeric, no spaces. |
email |
yes | Email address shown to JMAP clients. |
type |
no | "imap" (default). |
username |
no | IMAP login name. Defaults to email. |
password |
no | IMAP password or app-specific password. |
imapHost |
no | IMAP server hostname. If omitted, SRV DNS discovery is attempted. |
imapPort |
no | IMAP port. Default 993. |
imapSSL |
no | 0=plain, 2=TLS, 3=STARTTLS. Default 2. |
smtpHost |
no | SMTP server hostname. |
smtpPort |
no | SMTP port. Default 587. |
smtpSSL |
no | 0=plain, 2=TLS, 3=STARTTLS. Default 3. |
caldavURL |
no | CalDAV base URL for calendar sync. |
carddavURL |
no | CardDAV 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.
{
"accountid": "bob",
"email": "bob@example.com",
"sessionUrl": "https://jmap.example.com/session",
"username": "bob@example.com",
"password": "secret",
"authType": "basic"
}| Field | Required | Description |
|---|---|---|
accountid |
yes | Unique identifier. |
email |
yes | Email address. |
sessionUrl |
yes | URL of the upstream JMAP Session object (/.well-known/jmap or direct). |
username |
no | Username for authenticating to the upstream. |
password |
no | Password or Bearer token. |
authType |
no | "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
| Status | Body | Meaning |
|---|---|---|
| 400 | {"error":"invalid JSON"} |
Request body was not valid JSON. |
| 400 | {"error":"accountid required"} |
accountid field missing. |
| 500 | {"error":"<message>"} |
Backend error during setup. |
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).
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" }The following endpoints are on JMAP_PORT (default 9000) and require
authentication (Basic, Bearer, or cookie).
Redirects (301) to $BASEURL/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
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.
Blob upload (RFC 8620 §6.1). Content-Type header sets the blob MIME type.
Returns:
{
"accountId": "...",
"blobId": "f-<uuid>",
"type": "image/jpeg",
"size": 12345
}Blob download (RFC 8620 §6.2). name is used as the Content-Disposition
filename. Requires authentication.
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).
Exports the authenticated account's data as a zip archive.
Response 200 — Content-Type: application/zip,
Content-Disposition: attachment; filename="pdpa-<accountid>.zip"
The zip contains:
| Path | Contents |
|---|---|
archive.json |
Metadata: generator, timestamp, version |
mail/{folder}/folder.json |
Folder metadata with a list of messages |
mail/{folder}/{n}.eml |
Raw RFC-822 message (one file per message) |
contacts/{ab}/folder.json |
Address book metadata with a list of cards |
contacts/{ab}/{uid}.json |
JSContact card |
calendars/{cal}/folder.json |
Calendar metadata with a list of events |
calendars/{cal}/{uid}.json |
JSCalendar 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.zipImports a PDPA zip archive into the authenticated account.
Request — Content-Type: application/zip, body is the zip file.
Query parameter
| Parameter | Description |
|---|---|
prefix |
If 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
.emlis registered as a blob (store_blob) and imported viaEmail/import.\Recentis stripped (server-managed). IMAP flags are converted to JMAP keywords (\Seen→$seenetc.). - Contacts: a new address book is created for each source address book;
cards are imported via
ContactCard/set.idandaddressBookIdsfrom 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.idandcalendarIdsfrom 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.zipServer-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:stateto close after firstStateChangeevent, orno(default)ping: client-requested ping interval in seconds (minimum 30, default 300)
Events:
state—StateChangeobject (RFC 8620 §7.1) when any watched type changesping— keepalive with{"interval": N}
These endpoints handle the web-based sign-up and OAuth2 flows. They live on
JMAP_PORT and serve the user-facing UI.
| Path | Description |
|---|---|
GET / |
Landing page and self-service sign-up form |
GET /accounts |
Authenticated account management page (add, edit, detach, delete accounts; manage tokens) |
GET /cb/oauth |
OAuth2 callback — receives code from Google/Fastmail after authorisation |
GET /.well-known/oauth-authorization-server |
RFC 8414 OAuth2 server metadata (for OIDC clients) |
GET /oauth/jwks |
OIDC JSON Web Key Set (RS256 public key) |
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": "..."
}