Skip to content

Using the JMAP API

Serverless Inbox implements JMAP (RFC 8620) and JMAP for Mail (RFC 8621). All JMAP traffic goes through a single HTTP endpoint.

The JMAP API is served at jmap.<domainName>. The Mailbox CDK construct provisions the ACM certificate and Route 53 record automatically.

If the optional prefix property is set, a label is inserted between the service name and the base domain:

CDK propsJMAP endpoint
domainName: "example.com"https://jmap.example.com
domainName: "example.com", prefix: "mailbox"https://jmap.mailbox.example.com

The JMAP API requires a Bearer JWT issued by the Cognito User Pool. The token must carry the jmapApi audience and at least the MAIL_READ scope for read operations; MAIL_WRITE and MAIL_SEND are required for mutation and send operations.

Terminal window
# The exact scope strings depend on your IDP plugin configuration.
# Check the CloudFormation outputs or ask your administrator for the configured scopes.
TOKEN=$(curl -s -X POST \
"https://<cognito-domain>.auth.<region>.amazoncognito.com/oauth2/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=<JmapHeadlessClientId>" \
-d "client_secret=<JmapHeadlessClientSecret>" \
-d "scope=<mail-read-scope> <mail-write-scope> <mail-send-scope>" \
| jq -r '.access_token')

The headless client credentials are available as CloudFormation outputs (JmapHeadlessClientId and JmapHeadlessClientSecret).

Before making method calls, fetch the JMAP session object. It describes the capabilities the server supports, the accounts the authenticated user has access to, and the API URL to use for calls.

Terminal window
curl -s "https://jmap.mail.example.com/.well-known/jmap" \
-H "Authorization: Bearer $TOKEN" | jq .

The response includes an apiUrl (the endpoint for method calls) and the primaryAccounts map — you need the account ID for almost every method call.

{
"capabilities": {
"urn:ietf:params:jmap:core": { "maxCallsInRequest": 32, "..." : "..." },
"urn:ietf:params:jmap:mail": { "mayCreateTopLevelMailbox": true, "...": "..." },
"https://serverlessinbox.com/jmap/websocket": {
"maxSubscriptions": 10
}
},
"accounts": {
"acc-abc123": {
"name": "alice@example.com",
"isPersonal": true,
"isReadOnly": false,
"accountCapabilities": {
"urn:ietf:params:jmap:core": {},
"urn:ietf:params:jmap:mail": {},
"urn:ietf:params:jmap:submission": {}
}
}
},
"primaryAccounts": {
"urn:ietf:params:jmap:core": "acc-abc123",
"urn:ietf:params:jmap:mail": "acc-abc123",
"urn:ietf:params:jmap:submission": "acc-abc123"
},
"apiUrl": "https://jmap.mail.example.com/jmap",
"uploadUrl": "https://jmap.mail.example.com/upload/{accountId}",
"downloadUrl": "https://cdn.mail.example.com?accountId={accountId}&blobId={blobId}&type={type}&name={name}",
"webSocketUrl": "wss://ws.mail.example.com"
}

All JMAP method calls are sent as a single POST to the apiUrl:

POST /jmap
Authorization: Bearer <token>
Content-Type: application/json
{
"using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
"methodCalls": [
["<MethodName>", { ...arguments... }, "<clientId>"]
]
}

Each element of methodCalls is a 3-tuple:

  1. Method name — e.g. Email/query, Mailbox/get
  2. Arguments — the method’s argument object
  3. Client ID — an arbitrary string you choose (e.g. "r1") to identify this call when referencing its result

The response is:

{
"methodResponses": [
["<MethodName>", { ...response... }, "<clientId>"]
]
}

A key JMAP feature is the ability to use the output of one method call as the input to a later call in the same request. This avoids a round-trip. To reference a result, prefix the argument key with # and provide a ResultReference object:

{
"resultOf": "<clientId of the earlier call>",
"name": "<method name of the earlier call>",
"path": "/path/to/value"
}

Example: list and fetch emails in one request

Section titled “Example: list and fetch emails in one request”

This example:

  1. Queries the 10 most recent emails in the Inbox
  2. Fetches the subject, from, and preview of those emails — in the same HTTP request, without needing the IDs from step 1 first
Terminal window
# Substitute your accountId from the session object
ACCOUNT_ID="acc-abc123"
INBOX_ID="mailbox-inbox-id" # obtain via Mailbox/get
curl -s -X POST "https://jmap.mail.example.com/jmap" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
"methodCalls": [
["Email/query", {
"accountId": "'"$ACCOUNT_ID"'",
"filter": { "inMailbox": "'"$INBOX_ID"'" },
"sort": [{ "property": "receivedAt", "isAscending": false }],
"limit": 10
}, "q1"],
["Email/get", {
"accountId": "'"$ACCOUNT_ID"'",
"#ids": {
"resultOf": "q1",
"name": "Email/query",
"path": "/ids"
},
"properties": ["subject", "from", "preview", "receivedAt"]
}, "g1"]
]
}' | jq '.methodResponses[1][1].list'
Terminal window
curl -s -X POST "https://jmap.mail.example.com/jmap" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
"methodCalls": [
["Mailbox/get", {
"accountId": "'"$ACCOUNT_ID"'",
"ids": null
}, "m1"]
]
}' | jq '.methodResponses[0][1].list[] | {id, name, role}'

Example: incremental sync with Email/changes

Section titled “Example: incremental sync with Email/changes”

After your initial sync (via Email/get with ids: null), use the state value from the response to poll for changes incrementally:

Terminal window
curl -s -X POST "https://jmap.mail.example.com/jmap" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
"methodCalls": [
["Email/changes", {
"accountId": "'"$ACCOUNT_ID"'",
"sinceState": "<state-from-previous-response>"
}, "c1"],
["Email/get", {
"accountId": "'"$ACCOUNT_ID"'",
"#ids": {
"resultOf": "c1",
"name": "Email/changes",
"path": "/created"
},
"properties": ["subject", "from", "mailboxIds", "receivedAt"]
}, "g1"]
]
}' | jq .

If hasMoreChanges is true in the changes response, call again with the returned newState until it is false.

Serverless Inbox supports JMAP push over WebSocket (RFC 8887) for real-time change notifications. The session object includes a webSocketUrl field (e.g. wss://ws.mail.example.com) pointing to the WebSocket endpoint — using the ws subdomain with the same prefix logic as the other endpoints. See the WebSocket Events reference for details.