jkunz dab3b19ec0
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
v2.1.0
2026-07-15 23:37:27 +00:00
2026-07-15 23:37:27 +00:00
2026-07-15 23:37:27 +00:00
2026-07-15 23:37:27 +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
};

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.

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

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 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 and body-value fetch flags)
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
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, fetchTextBodyValues/fetchHTMLBodyValues/fetchAllBodyValues, maxBodyValueBytes truncation), Email/query (filters: inMailbox, text, subject, from, to, before, after, hasKeyword, notKeyword; 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. HTTP problem details (RFC 7807) and JMAP method-level errors are surfaced as JmapError with httpStatus, problemType, problemDetail, jmapErrorType, and jmapErrorDescription fields.

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
201 KiB
Languages
TypeScript 100%