Skip to content

Jam is a tiny (~2kb gzipped), strongly-typed JMAP client with zero runtime dependencies. It has friendly, fluent APIs that make working with JMAP a breeze.

License

Notifications You must be signed in to change notification settings

htunnicliff/jmap-jam

Repository files navigation

Jam Illustration

Jam: A JMAP Client

bundle size JSR License

Jam is a tiny (~2kb gzipped), strongly-typed JMAP client with zero runtime dependencies. It has friendly, fluent APIs that make working with JMAP a breeze.

Jam is compatible with environments that support the Web Fetch API and ES Modules.

Jam adheres to the following IETF standards:

Table of Contents

Installation

Jam works in any environment that supports the Web Fetch API and ES Modules, including Node.js (>=18) and the browser.

Use as a package:

npm install jmap-jam

Use in the browser:

<script type="module">
  import JamClient from "https://your-preferred-cdn.com/jmap-jam@<version>";
</script>

Getting Started

To initialize a client, provide the session URL for a JMAP server to connect to, as well as a bearer token for authenticating requests.

import JamClient from "jmap-jam";

const jam = new JamClient({
  sessionUrl: "https://jmap.example.com/.well-known/jmap",
  bearerToken: "super-secret-token",
});

Making Requests

JMAP is a meta protocol that makes performing multiple, dependent operations on a server more efficient by accepting batches of them in a single HTTP request.

A request is made up of one or more invocations (also known as method calls) that each specify a method, arguments, and a method call ID (an arbitrary string chosen by the requester). Method calls can reference each other with this ID, allowing for complex requests to be made.

To learn more about requests in JMAP, see the following resources:

Individual Requests

Here's what a single request looks like with Jam:

const jam = new JamClient({ ... });

// Using convenience methods
const [mailboxes] = await jam.api.Mailbox.get({ accountId: "123" });

// Using a plain request
const [mailboxes] = await jam.request(["Mailbox/get",{ accountId: "123" }]);

Both of these methods output the same JMAP request:

{
  "using": ["urn:ietf:params:jmap:mail"],
  "methodCalls": [
    [
      "Mailbox/get", // <------------ Method name
      { "accountId": "123" }, // <--- Arguments
      "r1" // <------------- Method call ID (autogenerated)
    ]
  ]
}

Convenience methods for available JMAP entities (e.g. Email, Mailbox, Thread) are available through the api property.

Or, as seen in the example, requests can be made without convenience methods by using the request method directly.

Both methods of sending requests have strongly typed responses and can be used interchangeably.

Multiple Requests

Though JMAP examples often show multiple method calls being used in a single request, see the Notes on Concurrency section for information about why a single method call per request can sometimes be preferred.

To send multiple method calls in a single request, use requestMany.

const jam = new JamClient({ ... });

const accountId = '<account-id>';
const mailboxId = '<mailbox-id>';

const [{ emails }, meta] = await jam.requestMany((t) => {
  // Get the first 10 email IDs in the mailbox
  const emailIds = t.Email.query({
    accountId,
    filter: {
      inMailbox: mailboxId,
    },
    limit: 10,
  });

  // Get the emails with those IDs
  const emails = t.Email.get({
    accountId,
    ids: emailIds.$ref("/ids"), // Using a result reference
    properties: ["id", "htmlBody"],
  });

  return { emailIds, emails };
});

This produces the following JMAP request:

{
  "using": ["urn:ietf:params:jmap:mail"],
  "methodCalls": [
    [
      "Email/query",
      {
        "accountId": "<account-id>",
        "filter": {
          "inMailbox": "<mailbox-id>"
        }
      },
      "emailIds"
    ],
    [
      "Email/get",
      {
        "accountId": "<account-id>",
        "#ids": {
          "name": "Email/query",
          "resultOf": "emailIds",
          "path": "/ids"
        },
        "properties": ["id", "htmlBody"]
      },
      "emails"
    ]
  ]
}

The t argument used in the requestMany callback is a Proxy that lets "invocation drafts" be defined before they are assembled into an actual JMAP request sent to the server.

To create a result reference between invocations, use the $ref method on the invocation draft to be referenced.

Request Options

When making requests, you can pass an optional options object as the second argument to request, requestMany, or any of the convenience methods. This object accepts the following properties:

  • fetchInit - An object that will be passed to the Fetch API fetch method as the second argument. This can be used to set headers, change the HTTP method, etc.
  • createdIds - A object containing client-specified creation IDs mapped to IDs the server assigned when each record was successfully created.
  • using - An array of additional JMAP capabilities to include when making the request.

Response Metadata

Convenience methods, request, and requestMany all return a two-item tuple that contains the response data and metadata.

const [mailboxes, meta] = await jam.api.Mailbox.get({ accountId: "123" });
const { sessionState, createdIds, response } = meta;

The meta object contains the following properties:

  • sessionState - The current session state.
  • createdIds - A map of method call IDs to the IDs of any objects created by the server in response to the request.
  • response - The actual Fetch API Response.

Notes on Concurrency

RFC 8620 § 3.10: Method calls within a single request MUST be executed in order [by the server]. However, method calls from different concurrent API requests may be interleaved. This means that the data on the server may change between two method calls within a single API request.

JMAP supports passing multiple method calls in a single request, but it is important to remember that each method call will be executed in sequence, not concurrently.

TypeScript

Jam provides types for JMAP methods, arguments, and responses as described in the JMAP and JMAP Mail RFCs.

All convenience methods, request, and requestMany will reveal autosuggested types for method names (e.g. Email/get), the arguments for that method, and the appropriate response.

Many response types will infer from arguments. For example, when using an argument field such as properties to filter fields in a response, the response type will be narrowed to exclude fields that were not included.

Capabilities

Jam has strongly-typed support for the following JMAP capabilities:

Entity Capability Identifier
Core urn:ietf:params:jmap:core
Mailbox urn:ietf:params:jmap:mail
Thread urn:ietf:params:jmap:mail
Email urn:ietf:params:jmap:mail
SearchSnippet urn:ietf:params:jmap:mail
Identity urn:ietf:params:jmap:submission
EmailSubmission urn:ietf:params:jmap:submission
VacationResponse urn:ietf:params:jmap:vacationresponse

API Reference

JamClient

JamClient is Jam's primary entrypoint. To use Jam, import and construct an instance.

The class can be imported by name or using default import syntax.

import JamClient from "jmap-jam";

const jam = new JamClient({
  bearerToken: "<bearer-token>",
  sessionUrl: "<server-session-url>",
});

A client instance requires both a bearerToken and sessionUrl in order to make authenticated requests.

Upon constructing a client, Jam will immediately dispatch a request for a session from the server. This session will be used for all subsequent requests.

api.<entity>.<operation>()

A convenience pattern for making individual JMAP requests that uses the request method under the hood.

const [mailboxes] = await jam.api.Mailbox.get({
  accountId,
});

const [emails] = await jam.api.Email.get({
  accountId,
  ids: ["email-123"],
  properties: ["subject"],
});

request()

Send a standard JMAP request.

const [mailboxes] = await jam.request(["Mailbox/get", { accountId }]);

const [emails] = await jam.request([
  "Email/get",
  {
    accountId,
    ids: ["email-123"],
    properties: ["subject"],
  },
]);

requestMany()

Send a JMAP request with multiple method calls.

const [{ emailIds, emails }] = await jam.requestMany((r) => {
  const emailIds = r.Email.query({
    accountId,
    filter: {
      inMailbox: mailboxId,
    },
  });

  const emails = r.Email.get({
    accountId,
    ids: emailIds.$ref("/ids"),
    properties: ["id", "htmlBody"],
  });

  return { emailIds, emails };
});

$ref()

Each item created within a requestMany callback is an instance of InvocationDraft. Internally, it keeps track of the invocation that was defined for use when the request is finalized and sent.

The important part of InvocationDraft is that each draft exposes a method $ref that can be used to create a result reference between invocations.

To create a result reference, call $ref with a JSON pointer at the field that will receive the reference.

The emailIds.$ref("/ids") call in the previous code block will be transformed into this valid JMAP result reference before the request is sent:

{
  "using": ["urn:ietf:params:jmap:mail"],
  "methodCalls": [
    [
      "Email/query",
      {
        "accountId": "<account-id>",
        "filter": {
          "inMailbox": "<mailbox-id>"
        }
      },
      "emailIds"
    ],
    [
      "Email/get",
      {
        "accountId": "<account-id>",
        // Result reference created here
        "#ids": {
          "name": "Email/query",
          "resultOf": "emailIds",
          "path": "/ids"
        },
        "properties": ["id", "htmlBody"]
      },
      "emails"
    ]
  ]
}

session

Get the client's current session.

const session = await jam.session;

getPrimaryAccount()

Get the ID of the primary mail account for the current session.

const accountId = await jam.getPrimaryAccount();

uploadBlob()

Initiate a fetch request to upload a blob.

const data = await jam.uploadBlob(
  accountId,
  new Blob(["hello world"], { type: "text/plain" })
);
console.log(data); // =>
// {
//   accountId: "account-abcd",
//   blobId: "blob-123",
//   type: "text/plain",
//   size: 152,
// }

downloadBlob()

Intiate a fetch request to download a specific blob. Downloading a blob requires both a MIME type and file name, since JMAP server implementations are not required to store this information.

If the JMAP server sets a Content-Type header in its response, it will use the value provided in mimeType.

If the JMAP server sets a Content-Disposition header in its response, it will use the value provided in fileName.

const response = await jam.downloadBlob({
  accountId,
  blobId: 'blob-123'
  mimeType: 'image/png'
  fileName: 'photo.png'
});

const blob = await response.blob();
// or response.arrayBuffer()
// or response.text()
// ...etc

connectEventSource()

Connect to a JMAP event source using Server-Sent Events.

const sse = await jam.connectEventSource({
  types: "*", // or ["Mailbox", "Email", ...]
  ping: 5000, // ping interval in milliseconds
  closeafter: "no", // or "state"
});

sse.addEventListener("message", (event) => ...));
sse.addEventListener("error", (event) => ...));
sse.addEventListener("close", (event) => ...));

About

Jam is a tiny (~2kb gzipped), strongly-typed JMAP client with zero runtime dependencies. It has friendly, fluent APIs that make working with JMAP a breeze.

Topics

Resources

License

Stars

Watchers

Forks

Sponsor this project