@push.rocks/smartjmap
A TypeScript JMAP client for reading, sending, and watching mail via the JSON Meta Application Protocol (RFC 8620/8621), with a bundled test server for offline testing.
Install
To install @push.rocks/smartjmap, use pnpm (or npm):
pnpm install @push.rocks/smartjmap
The package has zero runtime dependencies — it uses the global fetch available in Node.js 18+.
Usage
@push.rocks/smartjmap 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, and blob downloads.
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, download URL, 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);
// Replace the full keywords object
await jmapClient.setKeywords(message.id, { $seen: true, $flagged: true });
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.
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.
The Bundled Test Server
JmapServer is a minimal in-memory JMAP server for offline tests. It serves the session resource at /.well-known/jmap (and /jmap/session), the API at /jmap/api, blob downloads, and StateChange pushes via SSE at /jmap/eventsource. It validates Bearer and Basic auth and answers unauthenticated requests with 401 + WWW-Authenticate.
import { JmapClient, JmapServer } from '@push.rocks/smartjmap';
const jmapServer = new JmapServer();
jmapServer.addUser('testuser', 'testpass');
jmapServer.addBearerToken('testuser', 'test-token');
jmapServer.createMailbox('testuser', 'INBOX', 'inbox');
jmapServer.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); // 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();
// Emails added while a client is connected are pushed via SSE:
jmapServer.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 the server and destroys open connections
The dispatched methods are Core/echo, Mailbox/get, Mailbox/query, Email/get, Email/query, Email/changes, Email/set, Identity/get, and EmailSubmission/set — enough for every public JmapClient method to run offline.
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.