Interacting with a JMAP API - a rapid-start
IMAP is one of the predominant email protocols out there, but it's a) very old and b) somewhat painful to work with. To address some of these problems, JMAP was designed as IMAP-for-the-21st-ceentury, particularly for client usage.
Although it's an official IETF standard, it doesn't seem to have got that much traction - but is being used and supported by Fastmail, who also happen to be my long-term (nearly 20 years) email provider of choice.
That means I needed to get to grips with it in order to hack together my overly-complicated blog-content-from-markdown hack.
The problem with not being widely-adopted is the dearth of actual practical examples for copy-and-pasting inspiration. What follows is my get-things-up-and-running process, hopefully as a springboard for more complex usecases.
Basics
You interact with the API by sending HTTP POST requests with an authorisation header and a JSON payload. The payload contains the command you want to run along with any necessary criteria such as search terms.
- Fastmail's authorisation requires an Authorizationheader with a value in the formatBearer <API token>)
- The API token needs to be created on the Fastmail dashboard
- The method calls are sent as JSON text with an application/jsontype in thePOSTbody
Command format
The POST body has two parts:
- a usingarray, which doesn't change from method to method and defines the spec being used
- a methodCallsarray which contains the actual methods you want to call, along with their parameters
The method calls have a format in the shape of object/action - objects are Mailbox, Email, Thread and so on; actions are verbs like get, set, query etc.
The main commands we'll be using are Mailbox/get, Email/query and Email/get.
The overall shape of the body looks like this:
{
  "using": [
    "urn:ietf:params:jmap:core",
    "urn:ietf:params:jmap:mail"
  ],
  "methodCalls": [
    <calls go here>
  ]
}
Getting your account ID
The first step is to figure out your Fastmail account ID. This requires making an authenticated GET request to https://api.fastmail.com/.well-known/jmap, which returns a JSON response containing details of your account and what rights the API key allows.
Buried in middle somewhere is a primaryAccounts key, which should look something like this:
"primaryAccounts": {
    "urn:ietf:params:jmap:core": "u123456ab",
    "urn:ietf:params:jmap:mail": "u123456ab"
  },
Make a note of the value of the <xxx>:mail key, you'll need it for all further requests.
Request a list of folders
Most processes are going to involve getting a list of available messages in a specific folder, and the prerequisite for that is a folder ID.
The request
Let's start by getting a list of folders (aka mailboxes in JMAP-speak). Send this JSON body in a POST request:
{
  "using": [
    "urn:ietf:params:jmap:core",
    "urn:ietf:params:jmap:mail"
  ],
  "methodCalls": [
    [
      "Mailbox/get",
      {
        "accountId": "u123456ab",
        "ids": null
      },
      "abc"
    ]
  ]
}
Breaking down the methodCalls section, there's three parts:
- the command we're sending, in this case Mailbox/get
- the parameters for this command, in this case the accountIdand the foldersidswe want to match. By sendingnull, the server will respond with a list of all folders/mailboxes
- the trailing abcis effectively a variable name - the results of thisMailbox/getcommand will be available to subsquent commands by referencing theabcvariable, which allows you to create composite commands that are chained together
The response
The respose is a blob of JSON containing the methodResponse array. That in turn contains a list of folders and their various properties - the keys we're interested in look like this:
{
  "methodResponses": [
    [
      "Mailbox/get",
      {
        "list": [
          {
            ...
            "name": "Inbox",
            "id": "abcdef1234",
            ...
          },
        ...
      },
      "abc"
    ]
  ],
  "latestClientVersion": "",
  "sessionState": "cyrus-0;p-e21c23889a;s-6606885adc92256b"
}
The name key contains the human-readable folder name, and the id is the corresponding folder ID
Getting a list of emails
Retrieving a specific email is a two-stage process: finding its ID, then retrieving the email itself:
Retrieving a list of emails in a specific folder
This uses the Email/query command with a filter (this example also throws in a sort order for good measure). It's the same process - make a POST request with the auth headers, and send over the JSON payload in the body.
This is the full payload:
{
  "using": [
    "urn:ietf:params:jmap:core",
    "urn:ietf:params:jmap:mail"
  ],
  "methodCalls": [
    [
      "Email/query",
      {
        "accountId": "u123456ab",
        "filter": {
          "inMailbox": "abcdef1234"
        },
        "sort": [
          {
            "property": "receivedAt",
            "isAscending": false
          }
        ],
        "limit": 500
      },
      "xzy"
    ]
  ]
}
Breaking this down, we have:
- a filter, which searches in a specific mailbox with ID abcdef1234
- a sort order, which in this case is by property receivedAtand most-recent first
- a limit, in case there's a lot of results. If that happens, you can start using paging, but that's beyond the scope of this simple example.
The result is a JSON response which looks like this:
{
  "methodResponses": [
    [
      "Email/query",
      {
        "ids": [
          "Mcae60028353b175d88882abe",
          "M999a6d73062302c1592daebe",
          "M38493717a69686cbb4fc4d6f",
        ],
        "sort": [
          {
            "isAscending": false,
            "property": "receivedAt"
          }
        ],
        "filter": {
          "inMailbox": "abcdef1234"
        },
        "position": 0,
        "collapseThreads": false,
        "queryState": "1871939:0",
        "canCalculateChanges": true,
        "accountId": "u123456ab",
        "total": 3
      },
      "xyz"
    ]
  ],
  "latestClientVersion": "",
  "sessionState": "cyrus-0;p-e21c23889a;s-6606885adc92256b"
}
The first and most useful part is the ids array, which contains a list of IDs for actual emails. You also get a confirmation of the sort order and filter that was used, and some metadata about the query itself.
position relates to the paging if that took place; collapseThreads relates to how message threads are handled, and the total value is the number of records returned which can be useful when processing the results.
Retrieving a specific email
Now that we've got message IDs, we can finish off by retrieving a specific one.  This uses the Email/get method, to which you pass a specific message ID(s):
{
  "using": [
    "urn:ietf:params:jmap:core",
    "urn:ietf:params:jmap:mail"
  ],
  "methodCalls": [
    [
      "Email/get",
        {
          "accountId": "abcdef1234",
          "properties": null,
          "ids": [
            "Mcae60028353b175d88882abe"
          ]
        },
      "pqr"
    ]
  ]
}
The result (assuming the message exists) should be a lump of JSON that contains the message itself along with a pile of metadata:
{
  "methodResponses": [
    [
      "Email/get",
      {
        "accountId": "abcdef1234",
        "state": "1872326",
        "notFound": [],
        "list": [
          {
            "attachments": [],
            "mailboxIds": {
              "abcdef1234": true
            },
            "size": 91829,
            "htmlBody": [
              {
                "language": null,
                "location": null,
                "blobId": "G67f2b026af5a35d77985d0dd57edb93364d2736",
                "name": null,
                "type": "text/html",
                "cid": null,
                "size": 61902,
                "charset": "utf-8",
                "partId": "2",
                "disposition": null
              }
            ],
            "blobId": "G438f25a1713b0b3445b3ecd76a2d33d43337856",
            "from": [
              {
                "email": "sz-magazin@newsletter.sueddeutsche.de",
                "name": "SZ-Magazin Das Beste"
              }
            ],
            "textBody": [
              {
                "disposition": null,
                "size": 13300,
                "charset": "utf-8",
                "partId": "1",
                "cid": null,
                "type": "text/plain",
                "name": null,
                "language": null,
                "location": null,
                "blobId": "G05569b6bd4104d589a7efdece3d2f5d7dcbfe1e"
              }
            ],
            "inReplyTo": null,
            "id": "M438f25a1713b0b4451b3ecd",
            "sentAt": "2024-03-30T07:59:53+01:00",
            "to": [
              {
                "name": null,
                "email": "tim@duckett.de"
              }
            ],
            "receivedAt": "2024-03-30T06:59:59Z",
            "sender": null,
            "hasAttachment": false,
            "threadId": "Td27cfc407a520026",
            "keywords": {
              "$x-me-annot-2": true,
              "$ismailinglist": true,
              "$canunsubscribe": true
            },
            "preview": "Außerdem: Warum das Wort »später« so gefährlich ist Sollte der Newsletter nicht korrekt angezeigt werden, klicken Sie bitte hier Illustration: iStock / by Malte Mueller",
            "subject": "Lust auf ein neues Leben?",
            "cc": null,
            "bcc": null,
            "messageId": [
              "0.0.1A.354.1DA826FDE08E6A0.0@uspmta120023.emarsys.net"
            ],
            "replyTo": null,
            "bodyValues": {},
            "references": null
          }
        ]
      },
      "a"
    ]
  ],
  "latestClientVersion": "",
  "sessionState": "cyrus-0;p-e21c23889a;s-6607b80d3ff0a0fe"
}
This is an example of an HTML-formatted message - there's a text preview which is contained in the preview key, but there's one more step required to get the content of the message itself. This is available in two formats:
- HTML, which is referred to by the blobIdcontained in thehtmlBodysection
- text, which is referred to by the blobIdcontained in thetextBodysection
Confusingly, there's a third blobId in the list section which refers to the message itself in case you want retrieve that as a block of text.
Retrieving the contents of a specific email
This final step is different to the others, because it's a straight-forward GET request:
GET https://www.fastmailusercontent.com/jmap/download/<accountId>/<blobId>/<filename>?type=<type>
The parameters here are:
- accountId- the account ID as used before
- blobId- the ID of the specific blob you want, either HTML or text
- filename- this appears to have no effect as far as I can tell, but needs to be present; you can send any string
- type- the MIME type in which you want the content encoded. This makes no practical difference in terms of the content returned, but does show up in the- content-typeresponse header in case your code needs this. In this example, it makes most sense to send- application/html
Getting email attachments
Although that final GET request is the end point if what you want is the message, my usecase was slightly different and I wanted any attachments.
The good news here is that the process of getting attachments is the same as the process of getting email contents - you send a GET request with a blob ID.
Attachment blob IDs will show up in the attachments array at the top of the email payload:
{
  "methodResponses": [
    [
      "Email/get",
      {
        "accountId": "abcdef1234",
        "state": "1872326",
        "notFound": [],
        "list": [
          {
            "attachments": [
              "G67f2b026af5a35d77985d0dd45edb93364d2736"
            ],
            "mailboxIds": {
              "abcdef1234": true
            },
            <SNIP>
}
Grab the blob ID(s) from the attachments array, feed these into the GET requests, and you can download and process attachments as needed.
Finishing up
This is only the minimal basics needed to get messages and attachments; there's a lot more that the protocol can do. The docs are here, and you can use that information to extend the examples above to build a completely-functional email client if that's what you need.
There's a Postman collection of the relevant API calls here.