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.<your mail domain>. The ACM certificate and Route 53 record are created for you during deployment. The exact URL for your deployment is published as the jmapApiUrl output — open your stack’s Outputs tab in the CloudFormation console to read it.

Your mail domain is derived from the Route 53 hosted zone you selected when you launched the stack. If you set the optional subdomainNamespace parameter, that label is inserted between the service name and the base domain:

Hosted zone / subdomainNamespaceJMAP endpoint
example.com, namespace left emptyhttps://jmap.example.com
example.com, namespace mailboxhttps://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.

Machine-to-machine access requires a deployment built with the ServerlessInbox CDK constructs. A headless app client is not part of a template install — it exists only where the CDK app asks for one explicitly, by calling provisionHeadlessClient({ name, audiences, permissions }) on the MailboxUserPool construct. That call creates a dedicated client_credentials app client and registers it as an accepted audience on the APIs listed in audiences; it returns the client’s ID, its generated client secret as a SecretValue, and the scope strings the client may request.

Where those values go is the deployment’s own choice — the constructs do not store them anywhere. The recommended handling is for the CDK app to put the secret straight into AWS Secrets Manager and never surface it any other way:

const client = userPool.provisionHeadlessClient({
name: 'sync-worker',
audiences: ['jmapApi'],
permissions: [Permission.MAIL_READ, Permission.MAIL_WRITE],
});
// clientSecret is set for headless (client_credentials) clients.
new secretsmanager.Secret(this, 'SyncWorkerClientSecret', {
secretName: 'serverlessinbox/headless/sync-worker',
secretStringValue: client.clientSecret!,
});

Do not publish the secret as a CfnOutput and do not pass it into a construct property that lands verbatim in the synthesized template: a stack output and a plaintext template value are both readable by anyone with read access to the stack.

The client ID is not a secret and may be published as a stack output or hard-coded in the consumer’s configuration.

The flow itself is standard OAuth 2.0 client_credentials. The client must carry the jmapApi audience and the mail scopes the integration needs: MAIL_READ for reads, plus MAIL_WRITE and MAIL_SEND for mutations and sends.

Read the secret at run time from Secrets Manager rather than pasting it into the command, and authenticate to the token endpoint with HTTP Basic auth (client_secret_basic) so the secret is never in the POST body:

Terminal window
# 1 — Fetch the client secret at run time; never paste it into the command.
CLIENT_ID=<headless-client-id>
CLIENT_SECRET=$(aws secretsmanager get-secret-value \
--secret-id serverlessinbox/headless/sync-worker \
--query SecretString --output text)
# 2 — Obtain a token. --user sends Basic auth, keeping the secret out of the body.
# The exact scope strings depend on your IDP plugin configuration.
TOKEN=$(curl -s -X POST \
--user "$CLIENT_ID:$CLIENT_SECRET" \
"https://<cognito-domain>.auth.<region>.amazoncognito.com/oauth2/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "scope=<mail-read-scope> <mail-write-scope> <mail-send-scope>" \
| jq -r '.access_token')
unset CLIENT_SECRET

Never echo, log, or set -x over the secret or the token. Note that a process’s arguments are visible to other users on the host, so --user still exposes the secret on a shared machine — pass it on stdin with curl --config - there, or use an SDK that keeps it in memory.

The access token is short-lived (Cognito returns its lifetime in expires_in). Cache it in memory and reuse it until it is close to expiry rather than requesting a new token per call.

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://specs.serverlessinbox.com/websocket": {
"url": "wss://ws.mail.example.com",
"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}"
}

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 subdomainNamespace logic as the other endpoints. See the WebSocket Events reference for details.