@bumail/jmap
A JMAP server for Bun: the JMAP core (RFC 8620) and JMAP for Mail
(RFC 8621), as an alxia app
you mount in your own. It serves the mail of any
@bumail/store MailStore — the same store
@bumail/imap serves — and reads messages with
@bumail/mime: the session, the API with
back-references, Mailbox, Email and Thread, queryChanges, blob download and upload, and
EventSource push (RFC 8620 §7.3).
bun add @bumail/jmap @alxia/core @bumail/store @bumail/mime
@alxia/core (0.19.0 or later, below 0.20), @bumail/store and @bumail/mime are
peers: install the versions your app uses. typescript is an optional
peer, for the types.
Mounting it in an alxia app
jmap(options) returns an alxia app. Mount it in yours with plugin: it adds
GET /.well-known/jmap (the session) and, under basePath (/jmap by
default), POST /jmap/api, GET /jmap/download/:accountId/:blobId/:name
and POST /jmap/upload/:accountId. Each of its routes authenticates the
request first; your own routes are left alone.
import { alxia } from '@alxia/core';
import { jmap } from '@bumail/jmap';
import { MemoryMailStore } from '@bumail/store';
const store = new MemoryMailStore();
const alice = await store.createAccount('[email protected]');
await store.createMailbox(alice.id, { name: 'INBOX', role: 'inbox' });
const tokens = new Map([['alice-secret-token', alice.id]]);
const passwords = new Map([['alice', await Bun.password.hash('correct horse')]]);
const server = jmap({
store,
origin: 'https://mail.example.com', // the public URL the session's URLs start with
async authenticate(credentials) {
if (credentials.scheme === 'bearer') return tokens.get(credentials.token) ?? null;
const hash = passwords.get(credentials.username);
if (!hash || !(await Bun.password.verify(credentials.password, hash))) return null;
return alice.id;
},
});
const app = alxia()
.get('/health', ({ reply }) => reply(200, 'ok')) // not authenticated
.plugin(server);
app.listen(8080);
examples/serve.ts is
this example, runnable from a clone: bun install && bun run build, then
bun packages/jmap/examples/serve.ts, then
curl -H 'Authorization: Bearer alice-secret-token' http://localhost:8080/.well-known/jmap.
Authentication
authenticate gets { scheme: 'basic', username, password } or
{ scheme: 'bearer', token }, the Request, and the client: { ip, url }, the host app's ctx.ip and originalUrl(ctx) — the address a
login limiter counts. It answers the id of the
store account to serve, or null to refuse (a 401 with both challenges).
It runs under hookTimeout (30 s): one that throws, hangs or names an
account the store does not have is a 503, and onError is told.
Basic is taken only over HTTPS. On a clear request it is refused with a
403 before authenticate is called, as @bumail/imap and @bumail/smtp
refuse a login before TLS. Behind a proxy that ends TLS, declare the
proxies on the host app with trustProxy, and read what they said:
import { alxia, trustProxy } from '@alxia/core';
const app = alxia({
proxy: trustProxy({ trusted: ['10.0.0.0/8'], untrusted: 'refuse-all' }),
}).plugin(
jmap({
…,
// the scheme the outermost trusted proxy wrote, never the client's
secure: (_request, client) => client.url.protocol === 'https:',
// your own check, its limiter keyed by the forwarded client's address
authenticate: (credentials, _request, client) => check(credentials, client.ip),
}),
);
allowInsecureBasic: true lifts the refusal, for local tests only. Bearer
tokens are taken on any request.
What it answers
| method | |
|---|---|
Core/echo | its arguments |
Mailbox/get, Mailbox/changes | every property; myRights grants all but maySubmit, which is true with the submission option |
Mailbox/query | filter by parentId, role, name, hasAnyRole, isSubscribed; sort by sortOrder and name |
Mailbox/queryChanges | the filter and sort of Mailbox/query, with sinceQueryState, maxChanges, upToId, calculateTotal |
Mailbox/set | create, update name, parentId and isSubscribed, destroy with onDestroyRemoveEmails |
Email/get | the metadata, headers, every header: form, the convenience headers, bodyStructure, textBody, htmlBody, attachments, bodyValues, preview, hasAttachment |
Email/query | filter by inMailbox, inMailboxOtherThan, before, after, minSize, maxSize, hasKeyword, notKeyword, text, from, to, cc, bcc, subject, body, with AND, OR and NOT; sort by receivedAt, size, from, to, subject; collapseThreads, position, anchor, limit, calculateTotal |
Email/queryChanges | the filter, sort and collapseThreads of Email/query, with sinceQueryState, maxChanges, upToId, calculateTotal; removed may be a superset |
Email/changes | created, updated and destroyed since a state |
Email/set | update keywords and mailboxIds, whole or patched; destroy; create from an uploaded blobId |
Email/import | emails from uploaded blobs |
Thread/get | the emails of a thread, oldest first, through the store's thread index |
Thread/changes | created, updated and destroyed threads since a state |
Identity/get, Identity/changes, Identity/set | with the submission option and urn:ietf:params:jmap:submission in using: the addresses the host's directory says the account sends as, read-only; /changes from the current state is empty, from any other cannotCalculateChanges; /set answers forbidden |
EmailSubmission/get, /changes, /query, /queryChanges, /set | with the submission option: sends an email through the host's send hook (see Sending; submission: { identities, send }), which decides who may send; the envelope made from the email as RFC 8621 §7.5 says, Bcc never in what leaves, the record stored before the hook runs, a deliveryStatus per recipient, onSuccessUpdateEmail and onSuccessDestroyEmail answered by an Email/set after the call's own; the envelope's DSN parameters (RET, ENVID, NOTIFY, ORCPT, RFC 3461, values decoded as RFC 8621 §7 says) are checked and given to send, and the session lists DSN in submissionExtensions; no delayed send or undo (cannotUnsend) |
Back-references (#ids with resultOf, name and a path with *)
and #creationIds work across calls of one request.
Uploading and downloading
// accountId and inboxId come from the session and Mailbox/get; rawMessage is RFC 5322 bytes
const authorization = 'Bearer alice-secret-token';
const upload = await fetch(`https://mail.example.com/jmap/upload/${accountId}`, {
method: 'POST',
headers: { authorization, 'content-type': 'message/rfc822' },
body: rawMessage,
}).then((response) => response.json()); // { accountId, blobId, type, size }
// then: ["Email/import", { accountId, emails: { a: { blobId: upload.blobId, mailboxIds: { [inboxId]: true } } } }, "0"]
A download serves an email's bytes, one of its parts (each part's
blobId in Email/get), or an upload, with Range support:
GET /jmap/download/{accountId}/{blobId}/{name}?accept={type}.
By default uploads live in memory. To keep them durably, streamed and shared
by every instance, give jmap an UploadStore from @bumail/store:
import { SqliteUploadStore } from '@bumail/store/sqlite';
jmap({ store, origin, authenticate, uploads: SqliteUploadStore.open({ directory: '/var/lib/mail' }) });
Its ttlMs, maxSize and quotaBytes then replace uploadTtl and
uploadQuota. Its ids are random, so the same bytes uploaded twice are two
uploads, each counted against the quota; the in-memory uploads dedupe.
Limits
| limit | default | |
|---|---|---|
maxSizeRequest | 10 MB | the API body: a Content-Length past it refused unread, a chunked body cut once past it |
maxCallsInRequest | 16 | method calls in one request |
maxConcurrentRequests | 4 | API requests in flight, per account |
maxObjectsInGet, maxObjectsInSet | 500 | ids in a /get; creates, updates and destroys in a /set; and the page of a query |
maxSizeUpload | 25 MiB | one upload, held like maxSizeRequest |
maxConcurrentUpload | 4 | uploads in flight, per account |
uploadQuota | 100 MiB | bytes of in-memory uploads held per account (with uploads: the store's quotaBytes) |
uploadTtl | 3600 s | how long an in-memory upload is kept (with uploads: the store's ttlMs) |
maxEventSourceStreams | 4 | event streams open, per account (429 limit past it) |
maxJsonDepth, maxJsonTokens | 64, 100 000 | checked before the body is parsed |
maxReferenceItems | 5000 | values one back-reference expands to |
maxReferenceBytes | 4 MiB | bytes of JSON all the back-references of one request resolve to |
maxSizeResponse | 64 MiB | bytes of JSON of one API response |
maxQueryScan | 10 000 | emails one Email/query indexes from their bytes, matches Email/queryChanges holds, and emails a Mailbox/get count reads (past it, the store's counts) |
maxBodyValueBytes | 1 MiB | one body value, whatever the client asks |
maxBodyValuesTotal | 16 MiB | body values in one request |
Set them under limits. maxSizeRequest, maxCallsInRequest,
maxConcurrentRequests, maxObjectsInGet, maxObjectsInSet,
maxSizeUpload and maxConcurrentUpload are announced in the session's
urn:ietf:params:jmap:core capability. A limit broken by the request is a
urn:ietf:params:jmap:error:limit problem naming it (413 for a size it
sent, 429 for a concurrency, 400 for the rest, maxSizeResponse included); one broken by a call is a method
error. Ids are [A-Za-z0-9_-]{1,255}; an account never reaches another
account's data, whatever id it names; client text repeated in an error is
cut after 100 characters.
OpenAPI document
The package ships an OpenAPI 3.1 document of the routes it adds, as
@bumail/jmap/openapi.json (openapi/jmap.json in the package):
import document from '@bumail/jmap/openapi.json' with { type: 'json' };
document.paths['/api'].post.operationId; // 'api'
It covers the session and its Session object, POST {basePath}/api with
the Request and Response envelopes, the download (its URI template
parameters, Range and the 206) and the upload, the Basic and Bearer
schemes, and every status the server answers, the RFC 8620 problems
(notJSON, notRequest, unknownCapability, limit) as
application/problem+json. The API, download and upload carry a server
whose basePath variable defaults to /jmap: with another basePath, set
the variable; the paths stay as they are.
A method call is [name, arguments, callId], and its arguments are
described generically, as RFC 8620 frames them: an argument named #name
is a back-reference resolved from an earlier response, and a method that
fails answers an error invocation inside the 200, never an HTTP status.
RFC 8620 and RFC 8621 define each method's arguments. The document is
documentation and a contract: the server does not validate against it,
and a spec of the package fails when a route and the document disagree.
Its info.version is the version of the document, not of the package.
Traps
- Emails stored before search existed are indexed on first use.
Email/queryis answered by the store'squeryMessages, from the search fields an email is added with (Email/importandEmail/setpass them). An email without them is read from its bytes, at mostmaxQueryScana call, and given its fields (setSearchFields, which no client sees); the call isserverUnavailablewhile some remain, and each retry indexes more (never a partial result).Email/queryChangesholds the whole result and answersrequestTooLargepastmaxQueryScanmatches.Thread/getandThread/changesdo not scan: the store answers them.Mailbox/getcountsunreadEmails,totalThreadsandunreadThreadsas RFC 8621 §2 asks ($seenin any spelling is read,$draftis never unread) within the same budget, all its mailboxes together (propertiesleft out, null, asks for them): past it, a mailbox's counts are the store's IMAP counts,unseenfor both unread counts andmessagesfortotalThreads, which counts emails, not threads, since the store keeps no per-mailbox thread counts. - Uploads live in memory for
uploadTtl, in the process that took them, unlessuploadsgives anUploadStore: a restart forgets them, and two processes do not share them. sortOrderis always 0, and a mailbox'srolecannot change: the store keeps neither.ifInStateis checked, then the call runs: another writer between the two is not caught.- Push is EventSource only (RFC 8620 §7.3): no WebSocket (RFC 8887),
no push subscriptions (§7.2). Streams look at the store every
pushIntervaland whennotifyis called; they live in the process that holds them, so with several instances only the poll reaches a client whose change another instance made. - The session is at
/.well-known/jmapof the app that mounts it: mountjmap()on an app with no prefix, or that path moves too. asRawheader values are unfolded and lose their leading space.
Push
GET /jmap/eventsource (the session's eventSourceUrl) streams a
StateChange whenever an account's Email, Mailbox, Thread, EmailDelivery,
Identity or EmailSubmission
state moves. Tell the server about changes it did not make, and set how often
it looks for the rest (pushInterval, in seconds, default 10):
const server = jmap({ store, origin, authenticate, pushInterval: 5 });
// after a delivery into the store, or an IMAP change:
server.notify(accountId, { delivery: true });
// serving through your own Bun.serve? end the streams before you stop it:
server.close();
API
| export | |
|---|---|
jmap(options) | the server: an alxia app to mount with plugin, with notify(accountId, change?) and close(); throws a JmapError (INVALID_OPTION) on a bad option |
JmapServer | notify(accountId, { delivery? }): wakes the account's event streams to look at its state; delivery: true also moves EmailDelivery; close(): ends every open event stream and refuses new ones |
JmapOptions | store, authenticate, origin, basePath, limits, pushInterval, hookTimeout, allowInsecureBasic, secure, onError, submission |
JmapSubmission, IdentityInfo | the submission option: identities(accountId) answers the { email, name } an account sends as, send(request) sends a message and decides who may, maxRecipients (default 100); setting it announces urn:ietf:params:jmap:submission |
SendRequest, SendEnvelope (with ret, envid, recipients), SendRecipient, SendNotify, SendAnswer, SendRefusal, SendRefusalType, SendDelivery | what send is given and answers |
JmapLimits | the limits above |
JmapCredentials | what authenticate gets: { scheme: 'basic', username, password } or { scheme: 'bearer', token } |
AuthResult | what authenticate answers: an account id, or null / undefined |
JmapClient | who a request is from, as authenticate, secure and onError are told: ip (ctx.ip) and url (originalUrl(ctx)) |
ErrorContext | what onError gets beside the error: request, client, accountId, method |
JmapError, JmapErrorCode | code: INVALID_OPTION, and HOOK_TIMEOUT, which onError gets when a hook (authenticate, submission.identities, submission.send) does not settle within hookTimeout |
@bumail/jmap/openapi.json | the OpenAPI 3.1 document of the routes, as JSON |
Documentation
- Index: the pages, and when to read each.
- Guide: mounting, authenticating, the session, every method and how the store maps to it, sending (
EmailSubmissionand thesendhook), blobs, limits, the OpenAPI document, and the RFCs followed. - Troubleshooting: every error, problem and method error, by its exact text.
- Roadmap: what is coming — SearchSnippet, VacationResponse — and the store gaps.
License
MIT