jkunz 30312bd0d3
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / metadata (push) Skipped
v3.2.0
2026-09-29 07:24:32 +00:00
…
2026-09-29 07:24:32 +00:00
2026-09-29 07:24:32 +00:00
…
2026-09-29 07:24:32 +00:00
…

@push.rocks/smartjmap

A TypeScript JMAP toolkit (RFC 8620/8621): an event-driven mail client for reading, sending, and watching mail, plus an embeddable JMAP server core with pluggable storage for exposing your own mail store to JMAP clients.

Install

Install @push.rocks/smartjmap with pnpm:

pnpm add @push.rocks/smartjmap

The package has zero runtime dependencies — it uses the global fetch, Request/Response, and ReadableStream available in Node.js 18+ (and in Deno/Bun for the server core).

Usage

@push.rocks/smartjmap ships two sides of the protocol:

  • JmapClient connects to any JMAP server (Fastmail, Stalwart, Cyrus, ...), watches a mailbox for new mail in an event-driven way, and exposes the common mail operations: querying, reading, flagging, sending, uploads, and blob downloads.
  • JmapServer is a JMAP server core: it implements the RFC 8620 request layer and the RFC 8621 mail method surface, and dispatches all storage into a pluggable IJmapMailBackend. With no options it runs on a bundled in-memory backend, which makes it an offline test server; with your own backend it exposes a real mail store to JMAP clients.

Importing the Required Modules

import { JmapClient, type IJmapClientConfig } from '@push.rocks/smartjmap';

Configuration Object

Point the client at the JMAP session resource — most servers expose it at the autodiscovery path https://host/.well-known/jmap. All URLs inside the session (API endpoint, upload/download URLs, event source) are resolved relative to this URL automatically.

const jmapConfig: IJmapClientConfig = {
  sessionUrl: 'https://jmap.example.com/.well-known/jmap',
  auth: {
    accessToken: 'my-api-token',
  },
  mailbox: 'INBOX', // default: resolved via Mailbox role 'inbox', falling back to a name match
  pollIntervalMs: 30000, // fallback polling cadence when the event stream is unavailable
  // optional:
  // fetch: guardedFetch,                               // every request goes through it; default globalThis.fetch
  // credentialOrigins: ['https://download.example.com'], // strict mode: only these origins besides the session's
  // timeoutMs: 30000,                                  // default deadline per request; none when unset
};

When the session is loaded, every URL it advertises (apiUrl, downloadUrl, uploadUrl, eventSourceUrl) is resolved against the session URL and checked; the same check runs again before each request that carries the credentials:

  • By default (RFC 8620 §2: the session belongs to the server you configured), the credentials go to the session URL's own origin and to any https origin the session advertises, so a provider with a separate download host works as is. Plain http is accepted only on the session URL's own origin.
  • With credentialOrigins set, strict mode applies: only the session URL's origin and the listed origins ([] means the session origin alone).

A URL outside the rule makes connect() report, and open() reject with, a JmapError of code untrustedUrl. Which addresses may be reached at all is the injected fetch's job, e.g. one that only connects to public addresses. Redirects follow the fetch in use: the standard fetch drops the Authorization header when a redirect leaves the origin.

Authentication: Bearer and Basic

The auth option accepts exactly one of two shapes:

import { JmapClient } from '@push.rocks/smartjmap';

// Bearer token (OAuth2 access token or server API token)
const bearerClient = new JmapClient({
  sessionUrl: 'https://jmap.example.com/.well-known/jmap',
  auth: {
    accessToken: 'my-api-token',
  },
});

// Basic auth (username + password)
const basicClient = new JmapClient({
  sessionUrl: 'https://jmap.example.com/.well-known/jmap',
  auth: {
    user: 'user@example.com',
    pass: 'password123',
  },
});

Passing both shapes at once (or neither) throws at construction time.

Connecting and Handling Events

connect() fetches and validates the JMAP session (the urn:ietf:params:jmap:core and urn:ietf:params:jmap:mail capabilities are required), resolves the target mailbox, and emits connected. It then emits a message event for every email currently in the mailbox and keeps watching for new mail: it prefers the JMAP event source (Server-Sent Events, RFC 8620 §7.3) and falls back to polling Email/changes on pollIntervalMs when the event stream is unavailable.

import { JmapClient, type ISmartJmapMessage } from '@push.rocks/smartjmap';

const jmapClient = new JmapClient({
  sessionUrl: 'https://jmap.example.com/.well-known/jmap',
  auth: { accessToken: 'my-api-token' },
});

jmapClient.on('connected', () => {
  console.log('Connected to the JMAP server');
});

jmapClient.on('message', (message: ISmartJmapMessage) => {
  console.log('From:', message.from[0]?.email);
  console.log('Subject:', message.subject);
  console.log('Text:', message.textBody);
});

jmapClient.on('error', (error: Error) => {
  console.error('JMAP error:', error);
});

jmapClient.on('disconnected', () => {
  console.log('Disconnected');
});

await jmapClient.connect();

The Message Shape

Every message event (and the query/get helpers) delivers an ISmartJmapMessage with resolved bodies:

jmapClient.on('message', (message: ISmartJmapMessage) => {
  message.id;          // JMAP Email id
  message.blobId;      // blob id of the raw RFC 5322 message
  message.threadId;    // JMAP Thread id
  message.mailboxIds;  // { [mailboxId]: true }
  message.keywords;    // { '$seen': true, ... }
  message.from;        // IJmapEmailAddress[]
  message.to;          // IJmapEmailAddress[] (cc, bcc, replyTo likewise)
  message.subject;     // string | undefined
  message.receivedAt;  // ISO date string
  message.textBody;    // plain-text body, resolved from bodyValues
  message.htmlBody;    // HTML body, undefined when the email has no text/html part
  message.attachments; // IJmapAttachment[]: { blobId, name, type, size }
  message.raw;         // the full raw JMAP Email object
});

Querying and Reading Mail

// All mailboxes of the account
const mailboxes = await jmapClient.getMailboxes();

// Emails in the watched mailbox, newest first (default filter)
const messages = await jmapClient.queryEmails();

// Custom JMAP filter (RFC 8621 §4.4.1) and limit
const invoices = await jmapClient.queryEmails({ subject: 'Invoice' }, 10);

// Single email with all body values fetched (fetchAllBodyValues)
const message = await jmapClient.getEmail(messages[0].id);

Flags and Keywords

// Convenience: set the $seen keyword
await jmapClient.markSeen(message.id);

// Mark unread, toggle flagged, move, or permanently destroy
await jmapClient.markUnseen(message.id);
await jmapClient.setFlagged(message.id, true);
await jmapClient.setMailboxes(message.id, ['archive-mailbox-id']);

// Replace the full keywords object
await jmapClient.setKeywords(message.id, { $seen: true, $flagged: true });

// Permanently destroy the email after any other mutations are complete
await jmapClient.destroyEmail(message.id);

Sending Mail

sendEmail looks up the account's identities via Identity/get, creates the email via Email/set (stored in the mailbox with role sent, falling back to drafts, then the watched mailbox), and submits it via EmailSubmission/set:

const result = await jmapClient.sendEmail({
  to: [{ email: 'friend@example.com' }],
  subject: 'Hello from JMAP',
  textBody: 'Plain-text content',
  htmlBody: '<p>HTML content</p>', // optional
});
console.log(result.emailId, result.submissionId);

When from is omitted, the first identity of the account is used.

Uploads and Attachments

uploadBlob POSTs raw bytes to the session's upload URL (RFC 8620 §6.1) and returns the stored blob's id, type, and size. Reference the blob id in sendEmail to attach it:

const upload = await jmapClient.uploadBlob(
  new TextEncoder().encode('quarterly report data'),
  'text/plain'
);
console.log(upload.blobId, upload.size);

await jmapClient.sendEmail({
  to: [{ email: 'friend@example.com' }],
  subject: 'Report attached',
  textBody: 'Please find the report attached.',
  attachments: [{ blobId: upload.blobId, type: 'text/plain', name: 'report.txt' }],
});

Downloading Blobs

Raw messages and attachments are blobs; download them via the session's download URL template:

// the raw RFC 5322 message
const rawBytes = await jmapClient.downloadBlob(message.blobId, 'message/rfc822', 'message.eml');

// an attachment
for (const attachment of message.attachments) {
  const bytes = await jmapClient.downloadBlob(attachment.blobId, attachment.type, attachment.name);
  console.log(attachment.name, bytes.length);
}

Raw JMAP Method Calls

For anything not covered by the helpers, request sends raw JMAP method calls (RFC 8620 §3.2) and returns the method responses:

const methodResponses = await jmapClient.request([
  ['Mailbox/query', { accountId: 'account-id', filter: { role: 'archive' } }, 'q0'],
]);

Disconnecting

await jmapClient.disconnect();

disconnect() tears down the event stream and any polling timer — no handles are left open — and emits disconnected.

Pull-Based Reading

For scanning and searching a mailbox, open() replaces connect(): it loads and validates the session and does nothing else. No mailbox is resolved, no mail is read, no event stream or timer is started, and no events are emitted. It rejects on failure (a JmapError with httpStatus, or a code such as missingCapability, noPrimaryAccount or untrustedUrl) and then holds nothing open. Every call below takes an optional signal and timeoutMs; a deadline rejects with code timeout, an abort rejects with the signal's reason, and disconnect() aborts whatever is in flight.

const reader = new JmapClient({
  sessionUrl: 'https://jmap.example.com/jmap/session',
  auth: { user: 'books@example.com', pass: 'app-password' },
  fetch: guardedFetch, // e.g. a fetch that only connects to public addresses
});
const { accountId } = await reader.open({ timeoutMs: 15000 });

const mailboxes = await reader.getMailboxes();
const inbox = mailboxes.find((mailbox) => mailbox.role === 'inbox')!;

// one bounded page of ids, newest first
const page = await reader.queryEmailIds(
  { inMailbox: inbox.id, from: 'billing@', hasAttachment: true, after: '2026-09-01T00:00:00Z' },
  { limit: 50, position: 0 }
);

// only the chosen properties; no body values unless asked for (and then bounded)
const { list, state } = await reader.getEmails(page.ids, {
  properties: ['messageId', 'from', 'subject', 'receivedAt', 'size', 'attachments', 'bodyStructure'],
});

// attachments with a hard byte limit: fails with code 'tooLarge', never truncates
const bytes = await reader.downloadBlob(blobId, 'application/pdf', 'invoice.pdf', {
  maxBytes: 50 * 1024 * 1024,
});
const { stream } = await reader.openBlob(blobId, { maxBytes: 50 * 1024 * 1024 });

await reader.disconnect();

getEmailChanges(sinceState, { maxChanges }) runs one Email/changes call and returns { status: 'changes', oldState, newState, hasMoreChanges, created, updated, destroyed }. A message moved into a mailbox arrives in updated, so check mailboxIds of both created and updated. Continue from newState while hasMoreChanges is true. An unknown or expired state yields { status: 'cannotCalculateChanges', sinceState }, which means: re-read from a fresh state. Persisting the state is the caller's job; getEmails([], { properties: ['id'] }) returns the current one.

The limits and deadlines:

  • queryEmailIds requires a positive limit; getEmailChanges requires a positive maxChanges.
  • getEmails fetches body values only with fetchTextBodyValues/fetchHTMLBodyValues and a positive maxBodyValueBytes.
  • downloadBlob with maxBytes refuses a larger declared Content-Length on an unencoded response before reading, and otherwise counts the bytes as they arrive. When the response has a Content-Encoding other than identity (for example gzip), the header counts the compressed bytes while fetch delivers the decoded body, so the header is not used: the running count of decoded bytes is the only limit, and openBlob omits contentLength. openBlob does the same and errors the stream past the limit; its deadline covers the whole transfer, and cancelling the stream releases it.

Running a JMAP server

JmapServer handles the protocol; storage is behind the IJmapMailBackend interface. Endpoints served: the session resource (/.well-known/jmap and /jmap/session), the API (/jmap/api), uploads (/jmap/upload/{accountId}), downloads (/jmap/download/{accountId}/{blobId}/{name}?type=), and StateChange pushes via SSE (/jmap/eventsource, RFC 8620 §7.3 with types, closeafter=state, and ping keepalive support).

Production consumers that rely on bounded streaming uploads can verify the server surface before starting a listener:

import { JMAP_SERVER_STREAMING_API_VERSION } from '@push.rocks/smartjmap';

if (JMAP_SERVER_STREAMING_API_VERSION !== 1) {
  throw new Error('This application requires smartjmap streaming server API v1.');
}

The Offline Test Server

With no options, JmapServer uses a fresh MemoryMailBackend plus a static credential registry — an offline JMAP server for tests. Seed users on the server (auth) and mail on the backend (storage):

import { JmapClient, JmapServer, MemoryMailBackend } from '@push.rocks/smartjmap';

const backend = new MemoryMailBackend();
const jmapServer = new JmapServer({ backend });
jmapServer.addUser('testuser', 'testpass');
jmapServer.addBearerToken('testuser', 'test-token');
backend.createMailbox('testuser', 'INBOX', 'inbox');
backend.addEmail('testuser', 'INBOX', {
  from: { email: 'alice@example.com' },
  to: { email: 'testuser@example.com' },
  subject: 'Welcome',
  textBody: 'Hello from the test server!',
});

const port = await jmapServer.start(0); // node:http wrapper; resolves with the bound port

const client = new JmapClient({
  sessionUrl: `http://127.0.0.1:${port}/.well-known/jmap`,
  auth: { accessToken: 'test-token' },
});
client.on('message', (message) => console.log(message.subject));
await client.connect();

// Backend mutations outside a JMAP request (e.g. seeding, an IMAP bridge)
// flow through the change feed and are pushed to clients via SSE:
backend.addEmail('testuser', 'INBOX', {
  from: { email: 'bob@example.com' },
  to: { email: 'testuser@example.com' },
  subject: 'Live push',
  textBody: 'Delivered through the event stream.',
});

await client.disconnect();
await jmapServer.stop(); // closes event streams, timers, and open connections

An attachment seeded through addEmail is an attachment part by default. Give it disposition: 'inline' for a picture pasted into the mail, and cid (the Content-ID without angle brackets) for a part the HTML body refers to as cid:; both appear in the body parts and in the raw message:

backend.addEmail('testuser', 'INBOX', {
  from: { email: 'billing@example.com' },
  to: { email: 'testuser@example.com' },
  subject: 'Invoice with logo',
  htmlBody: '<p>See the invoice.</p><img src="cid:logo-1@example.com">',
  attachments: [
    { name: 'logo.png', type: 'image/png', content: logoBytes, disposition: 'inline', cid: 'logo-1@example.com' },
    { name: 'invoice.pdf', type: 'application/pdf', content: pdfBytes },
  ],
});

To test a client that insists on https:, start the wrapper as a node:https server with a private key and certificate chain in PEM, for example issued by a test-only CA the client is told to trust:

const securePort = await jmapServer.start(0, {
  tls: { key: fs.readFileSync('server-key.pem', 'utf8'), cert: fs.readFileSync('server-cert.pem', 'utf8') },
});
// the session is at https://127.0.0.1:${securePort}/.well-known/jmap

Mounting fetchHandler (Deno, Bun, anywhere)

fetchHandler(request: Request): Promise<Response> is the primary API and handles every endpoint, including the SSE event source (served as a ReadableStream response). start(port) is only a node:http (or, with { tls }, node:https) adapter around it. In a Deno app:

// deno run --allow-net server.ts
import { JmapServer, MemoryMailBackend } from '@push.rocks/smartjmap';

const backend = new MemoryMailBackend();
const jmapServer = new JmapServer({ backend, baseUrl: 'http://localhost:8080' });
jmapServer.addUser('demo', 'demopass');
backend.createMailbox('demo', 'INBOX', 'inbox');

Deno.serve({ port: 8080 }, (request) => jmapServer.fetchHandler(request));

baseUrl makes the URLs advertised in the session object absolute; without it they are relative and JMAP clients resolve them against the session URL.

Custom Authentication

The authenticate option replaces the built-in Basic/Bearer registry entirely. It receives the raw Request and returns a principal (or null for 401); the backend then maps the principal to an account via resolveAccount:

import { JmapServer } from '@push.rocks/smartjmap';

const jmapServer = new JmapServer({
  authenticationChallenges: ['Bearer realm="jmap"'],
  authenticate: async (request: Request) => {
    const match = request.headers.get('authorization')?.match(/^Bearer\s+(.+)$/i);
    const token = match?.[1];
    return token === 'sesame' ? { username: 'appuser' } : null;
  },
});

Set authenticationChallenges to the exact schemes accepted by a custom authenticator. The built-in registry advertises both Bearer and Basic by default.

Implementing a Real Backend

Implement IJmapMailBackend against your own store to serve real mail. All state strings are backend-owned opaque strings. The contract (all methods async except subscribeToChanges, which returns its unsubscribe function synchronously):

Method Serves
resolveAccount(principal) principal → account mapping for the session
getMailboxes(accountId, ids) / getMailboxChanges(accountId, sinceState, maxChanges?) Mailbox/get, Mailbox/query, Mailbox/changes
getEmails(accountId, ids, properties?) Email/get (the server applies projection, bodyProperties and body-value fetch flags; give each body part its headers, name and Raw value in message order, for the headers and header: body properties)
queryEmails(accountId, options) Email/query (filter subset, receivedAt sort, position/limit/total)
getEmailChanges(accountId, sinceState, maxChanges?) Email/changes (return null for cannotCalculateChanges)
setEmails(accountId, request) Email/set (creates, normalized keyword/mailbox updates, destroys)
getThreads(accountId, ids) Thread/get
uploadBlob(accountId, data, type) / optional uploadBlobStream(accountId, upload) / getBlob(accountId, blobId) buffered or streaming upload, download endpoints, and raw-message access
getIdentities(accountId) Identity/get
submitEmail(accountId, submission) EmailSubmission/set — receives the resolved envelope plus the raw RFC 5322 payload; your backend performs the actual sending
subscribeToChanges(listener) StateChange pushes — the server's only push feed, so notify on all mutations, including those made through JMAP requests, not just out-of-band changes
optional features declares emailFilters: ['hasAttachment'] and emailProperties: ['bodyStructure'] when the backend implements them; undeclared ones are answered with unsupportedFilter / invalidArguments
import { JmapServer, type IJmapMailBackend } from '@push.rocks/smartjmap';

declare const myBackend: IJmapMailBackend; // your implementation

const jmapServer = new JmapServer({
  backend: myBackend,
  baseUrl: 'https://mail.example.com',
});

MemoryMailBackend is the reference implementation — a readable starting point for the expected semantics.

When uploadBlobStream is implemented, the server authenticates and admits the request before reading, requires Content-Length, and passes stream, exact size, media type, and an abort signal. The backend must consume exactly size bytes, reserve quota before reading the stream, reject short or oversized input, honor cancellation, and remove partial storage on failure.

What the Server Advertises (and What It Doesn't)

The session object advertises urn:ietf:params:jmap:core, urn:ietf:params:jmap:mail, and urn:ietf:params:jmap:submission. The core limits are enforced, not just advertised (exported as JMAP_SERVER_LIMITS): maxSizeRequest 10 MB, maxCallsInRequest 16, maxObjectsInGet/maxObjectsInSet 500, maxSizeUpload 50 MB, maxConcurrentRequests 4, and per-principal maxConcurrentUpload 4 — violations produce request-level limit errors, requestTooLarge method errors, or upload problem details. Constructor limits override advertised limits; maxConcurrentUploadServer adds a process-wide upload ceiling, and mapUploadError maps storage failures to application-specific RFC 7807 responses. maxDelayedSend is 0 (no delayed send) and mayCreateTopLevelMailbox is false (no Mailbox/set).

The request layer implements using validation (unknownCapability), #-prefixed back-references with full ResultReference { resultOf, name, path } JSON-pointer resolution including /* array expansion, client-provided createdIds maps, and unknownMethod/invalidArguments/serverFail error responses.

Dispatched methods: Core/echo, Mailbox/get, Mailbox/query, Mailbox/changes, Thread/get, Email/get (property projection, bodyProperties for the parts in bodyStructure/textBody/htmlBody/attachments with RFC 8621's default list when omitted, bodyStructure always the full tree with its parts projected alike, subParts: null on a non-multipart part when asked for, headers and the header:{name}[:as{form}][:all] properties in every RFC 8621 §4.1.2 form (from the part's headers as the backend holds them, null without), and unknown body properties or forbidden forms refused with invalidArguments, bodyStructure when the backend declares it, fetchTextBodyValues/fetchHTMLBodyValues/fetchAllBodyValues, maxBodyValueBytes truncation), Email/query (filters: inMailbox, text, subject, from, to, before, after, hasKeyword, notKeyword, and hasAttachment when the backend declares it; receivedAt sort; position/limit/calculateTotal), Email/changes, Email/set (create/update/destroy with RFC 8620 §5.3 JSON-pointer patches for keywords/* and mailboxIds/*, plus ifInState), Identity/get, and EmailSubmission/set (with onSuccessUpdateEmail/onSuccessDestroyEmail and the implicit Email/set response).

Not implemented in this phase: Email/queryChanges (answered with an explicit cannotCalculateChanges error), Mailbox/set, Email/copy/Email/import/Email/parse, SearchSnippet/*, VacationResponse/*, PushSubscription (RFC 8620 §7.2 — the §7.3 event source is the push channel), FilterOperator trees (AND/OR/NOT), and anchor pagination.

Error Handling

connect() never throws; failures (unreachable host, wrong credentials, missing capabilities) are emitted as error events, and the instance holds no open sockets or timers afterwards. open() rejects instead. HTTP problem details (RFC 7807) and JMAP method-level errors are surfaced as JmapError with httpStatus, problemType, problemDetail, jmapErrorType, and jmapErrorDescription fields. Client-side failures carry code: timeout, tooLarge, untrustedUrl, missingCapability, noPrimaryAccount or invalidResponse.

Because JMAP is stateless HTTP, the same client instance can reconnect — call connect() again after a failure. Already-emitted messages are deduplicated, so a reconnect does not re-emit mail the client has already seen. While connected, the client recovers on its own: if the event stream drops, it silently falls back to Email/changes polling, and a cannotCalculateChanges response triggers a full re-query.

import { JmapClient, JmapError } from '@push.rocks/smartjmap';

const client = new JmapClient({
  sessionUrl: 'https://jmap.example.com/.well-known/jmap',
  auth: { accessToken: 'my-api-token' },
});

client.on('error', (error: Error) => {
  if (error instanceof JmapError && error.httpStatus === 401) {
    console.error('Credentials rejected — refresh the token before reconnecting.');
    return;
  }
  console.error('JMAP error, retrying in 10s:', error.message);
  setTimeout(() => {
    client.connect().catch(console.error);
  }, 10000);
});

await client.connect();

Issue Reporting and Security

For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.

This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the license file within this repository.

Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.

Trademarks

This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.

Company Information

Task Venture Capital GmbH
Registered at District court Bremen HRB 35230 HB, Germany

For any legal inquiries or if you require further information, please contact us via email at hello@task.vc.

By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

S
Description
No description provided
Readme
740 KiB
Languages
TypeScript 100%