Mail JMAP Server
JMAP Core (RFC 8620) + JMAP Mail (RFC 8621) listener. Where IMAP is a TCP text-protocol with a connection state-machine, JMAP is HTTP-mounted JSON-RPC — operators mount the handler under their existing b.router / b.createApp and the JMAP semantics ride the HTTP request lifecycle (auth → body parse → handler → response).
## Public surface
var jmap = b.mail.server.jmap.create({
mailStore: b.mailStore.create({ backend: b.db }),
methods: {
"Mailbox/get": async function (actor, args) {...},
"Email/query": async function (actor, args) {...},
"Email/get": async function (actor, args) {...},
},
serverCapabilities: {
"urn:ietf:params:jmap:mail": { maxMailboxesPerEmail: null },
"urn:ietf:params:jmap:submission": null,
},
});
// Mount on the framework's router:
app.use("/.well-known/jmap", jmap.discoveryHandler);
app.use("/jmap/session", b.middleware.bearerAuth(...), jmap.sessionHandler);
app.use("/jmap/api", b.middleware.bearerAuth(...), jmap.apiHandler);
The listener owns the request envelope (b.guardJmap.validate), back-reference resolution (RFC 8620 §3.7), the per-call dispatch, and the standard error mapping (RFC 8620 §3.6.1). In a result reference path, * applies the tokens after it to every item of the array and flattens array results into one array, so /list/*/emailIds over a Thread/get response yields the message ids; a path no item satisfies fails the call with invalidResultReference, an array index follows the RFC 6901 grammar, and an argument given both directly and as #name is invalidArguments. Operators supply the method implementations themselves: Mailbox, Email, Thread, SearchSnippet, Identity and EmailSubmission are not implemented here.
## Capability discovery (RFC 8620 §2)
GET /.well-known/jmap redirects to the session resource per §2.2. GET /jmap/session returns the session object with the server's capabilities, account list (operator-supplied via opts.accountsFor(actor)), and endpoint URLs.
## Request shape (RFC 8620 §3.3)
POST /jmap/api with body:
{
"using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
"methodCalls": [
["Mailbox/get", { "accountId": "A1" }, "c0"],
["Email/query", { "filter": { "inMailbox": "#c0/list/0/id" } }, "c1"]
]
}
Response shape:
{
"methodResponses": [
["Mailbox/get", { ... }, "c0"],
["Email/query", { ... }, "c1"]
],
"sessionState": "<opaque-token>"
}
## Caps (RFC 8620 §3.6)
b.guardJmap.validate applies maxCallsInRequest, maxSizeRequest, maxUsingCapabilities and maxBackRefDepth to the request envelope. dispatch applies maxObjectsInGet to a /get call's ids and maxObjectsInSet to the combined create, update and destroy of a /set or /copy call, after result references resolve, answering an over-cap call with requestTooLarge before the handler runs. The session's urn:ietf:params:jmap:core capability publishes all of them. Per-account method-call concurrent cap via b.mail.server.rateLimit when wired.
## Error vocabulary (RFC 8620 §3.6)
A refused request (§3.6.1) is answered with an application/problem+json body carrying type, status and detail, and no methodResponses. The type is one of:
- urn:ietf:params:jmap:error:unknownCapability - urn:ietf:params:jmap:error:notJSON - urn:ietf:params:jmap:error:notRequest - urn:ietf:params:jmap:error:limit, with a limit member naming the cap reached (maxSizeRequest, maxCallsInRequest, maxUsingCapabilities, maxBackRefDepth) - urn:ietf:params:jmap:error:forbidden (401) - urn:ietf:params:jmap:error:serverFail (500, opaque last-resort)
A method error (§3.6.2) replaces that call's response with [ "error", { "type": ", where the type is the bare name: unknownMethod, invalidArguments, invalidResultReference, accountNotFound, serverFail, and the RFC 8621 set errors a handler returns or throws.
## Beyond Core + Mail, this also ships
- **Push channel (RFC 8887)** — eventSourceHandler (SSE) and webSocketHandler (WebSocket, with StateChange push). - **Blob upload/download (RFC 8620 §6)** — uploadHandler / downloadHandler, routing uploads through the guard-* family. - **EmailSubmission/set (RFC 8621 §7.5)** — emailSubmissionSetHandler, composing b.mail.send.deliver.
## What v1 does NOT ship
- **Calendars / Contacts (RFC 9610)**, **Sieve (RFC 9661)**, **MDN (RFC 9007)** — opt-in capabilities.
b.mail.server.jmap.create(opts) #
{
mailStore: b.mailStore handle (operator-supplied backend),
methods: { "/": async fn(actor, args, ctx) },
// operator-supplied JMAP method handlers
serverCapabilities: { "": },
// capabilities the server advertises beyond core
accountsFor: async function (actor) → { primaryAccounts, accounts },
// operator-supplied accountId enumeration
webSocket: boolean, // default true — false stops advertising the
// RFC 8887 WebSocket transport (capability and the
// top-level webSocketUrl alias), for a deployment
// that has not wired the upgrade handler
webSocketUrl: string, // default "/jmap/ws" — where the upgrade lives
webSocketMaxMessageBytes: number, // default: the profile's maxSizeRequest
profile: "strict" | "balanced" | "permissive",
posture: "hipaa" | "pci-dss" | "gdpr" | "soc2",
maxBlobBytes: number, // positive integer; default 50 MiB, published
// as maxSizeUpload
maxConcurrentUpload: number, // default: 4
maxConcurrentRequests: number, // default: 4
actorKey: function, // (actor) => string naming the principal, for a
// deployment whose actors carry none of id, userId,
// username, sub, principalId, email
collationAlgorithms: [string], // default: RFC 4790 i;ascii-numeric,
// i;ascii-casemap, i;octet
audit: b.audit // optional
}
Build a JMAP Core + JMAP Mail listener. Returns a handle exposing apiHandler / sessionHandler / discoveryHandler (Express-style (req, res, next) functions) and dispatch(actor, body) for operators with a non-Express transport.
var jmap = b.mail.server.jmap.create({
mailStore: b.mailStore.create({ backend: b.db }),
methods: {
"Mailbox/get": async function (actor, args) {
return { accountId: args.accountId, list: [], notFound: [] };
},
},
serverCapabilities: { "urn:ietf:params:jmap:mail": {} },
accountsFor: async function (actor) {
return {
primaryAccounts: { "urn:ietf:params:jmap:mail": "A1" },
accounts: { A1: { name: actor.username } },
};
},
});
app.post("/jmap/api", b.middleware.bearerAuth({ verify: verify }), jmap.apiHandler);
b.mail.server.jmap.emailSubmissionSetHandler(opts) #
{
deliver: async function (envelope), // b.mail.send.deliver instance (REQUIRED)
lookupEmail: async function (emailId, accountId, actor) → Buffer|null, (REQUIRED)
identities: function (accountId) → [ { id, email, mayDelegate } ], (REQUIRED)
onCreated: async function (subId, submission, accountId), (optional)
onDestroyed: async function (subId, accountId), (optional)
onCancel: async function (subId, accountId) → boolean, (optional — undo support)
maxRecipients: number, // default 1000
}
Reference implementation of JMAP EmailSubmission/set (RFC 8621 §7.5) that composes b.mail.send.deliver. Returns an async method-handler suitable for plumbing into b.mail.server.jmap.create({ methods: ... }).
The handler:
1. Walks args.create per RFC 8621 §7.5. For each EmailSubmission: - Refuses identityId not registered in opts.identities(accountId). - Refuses emailId absent — calls opts.lookupEmail(emailId, accountId, actor) to fetch the RFC 822 blob (refuses emailNotFound when null). - Refuses missing or oversize envelope.rcptTo (max 1000 per the same recipient cap b.mail.send.deliver enforces). - Validates envelope.mailFrom.email matches the identity's authorized addresses (forbiddenMailFrom per RFC 8621 §7.5.1.2 when not). 2. Hands the RFC 822 blob to the supplied opts.deliver(envelope) (a b.mail.send.deliver.create() instance). 3. Maps deliver's { delivered, deferred, failed } result into JMAP deliveryStatus (recipient → { smtpReply, delivered, displayed } per RFC 8621 §7.4). 4. Calls opts.onCreated(subId, submission, accountId) so the operator can persist the EmailSubmission record (state survives across JMAP requests via EmailSubmission/get).
args.destroy removes EmailSubmission records via opts.onDestroyed(subId, accountId) — the delivery itself cannot be unsent at this point; destroy only removes the JMAP-visible record.
args.update is honored only for the undoStatus: "canceled" transition per RFC 8621 §7.5.2 (operators with a queue-based deferred-send model wire opts.onCancel(subId, accountId); the reference handler refuses with cannotUnsend when no onCancel is configured).
var deliver = b.mail.send.deliver({ hostname: "mta.example.com" });
var emailSubSet = b.mail.server.jmap.emailSubmissionSetHandler({
deliver: deliver,
lookupEmail: async function (emailId, accountId) {
return mailStore.fetchBlob(accountId, emailId);
},
identities: function (accountId) {
return [{ id: "I1", email: "ops@example.com" }];
},
onCreated: async function (id, sub, accountId) { return; },
});
var jmap = b.mail.server.jmap.create({
mailStore: store,
accountsFor: async function () { return { primaryAccounts: {}, accounts: {} }; },
methods: { "EmailSubmission/set": emailSubSet },
});
b.mail.server.jmap.emailBodyProperties(tree, opts) #
{
blobIdPrefix: string, // required — the message's own id, scoping each part's blobId
}
Build the body-part properties RFC 8621 section 4.1.4 defines for an Email, from the tree b.safeMime.parse returns. Answers { bodyStructure, textBody, htmlBody, attachments, hasAttachment }, where every EmailBodyPart carries the partId and blobId a client needs to fetch one part through the download endpoint.
Email/get is an operator-supplied handler, so this is the piece the handler composes. Without these properties a client cannot see that a message carries a file: the response is well formed, the client reads the properties it knows, and a message with three attachments looks the same as one with none.
blobId is , and the prefix must satisfy the JMAP Id grammar of RFC 8620 section 1.2, because the download handler refuses anything else. That grammar admits - and _, so a separator also occurs inside a prefix: the length in front lets a reader take exactly the prefix and leave the path, and two messages whose ids differ never name one blob. A multipart carries no blobId of its own.
Throws mail-server-jmap/bad-opts for an opts that is not an object, mail-server-jmap/bad-blob-id-prefix for a blobIdPrefix that is not a JMAP Id and mail-server-jmap/blob-id-too-long for one that leaves no room for a part's own path within the id length, and mail-server-jmap/bad-mime-tree for a tree that is not one b.safeMime.parse produced. A tree parsed in structure-only mode carries no part bodies, so reading one raises safe-mime/structure-only.
var tree = b.safeMime.parse(rawMessageBytes);
var body = b.mail.server.jmap.emailBodyProperties(tree, { blobIdPrefix: message.objectid });
body.hasAttachment; // → true
body.attachments[0].name; // → "report.pdf"
body.attachments[0].blobId; // → "8-obj_1f3c-1"