@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:
JmapClientconnects 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.JmapServeris a JMAP server core: it implements the RFC 8620 request layer and the RFC 8621 mail method surface, and dispatches all storage into a pluggableIJmapMailBackend. 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.
License and Legal Information
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.