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.handle() }),
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). Operators wire the actual method implementations — JMAP semantics are too varied (Mailbox / Email / Thread / SearchSnippet / Identity / EmailSubmission) to enshrine in v1.
## 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)
Enforced via b.guardJmap.validate — maxCallsInRequest, maxSizeRequest, maxObjectsInGet/Set, maxBackRefDepth. Per- account method-call concurrent cap via b.mail.server.rateLimit when wired.
## Error vocabulary (RFC 8620 §3.6.1)
Standard errors emitted as the methodResponse object:
urn:ietf:params:jmap:error:requestTooLargeurn:ietf:params:jmap:error:invalidArgumentsurn:ietf:params:jmap:error:invalidResultReferenceurn:ietf:params:jmap:error:unknownCapabilityurn:ietf:params:jmap:error:limit/urn:ietf:params:jmap:error:forbiddenurn:ietf:params:jmap:error:accountNotFoundurn:ietf:params:jmap:error:serverFail(opaque last-resort)
## 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
profile: "strict" | "balanced" | "permissive",
posture: "hipaa" | "pci-dss" | "gdpr" | "soc2",
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.handle() }),
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 },
});
Last updated 2026-08-08T16:39:49.652Z by seeder.