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.validatemaxCallsInRequest, 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:

## 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) #

stable0.9.50
{
  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) #

stable0.11.38gdprsoc2
{
  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.