@bumail/jmap

中文文档
0.12.0发布于 2026/11/05MITTypeScriptES Module零依赖近 30 天下载 3439

A JMAP server (RFC 8620, RFC 8621) on alxia: the session, the API with back-references, Mailbox, Email and Thread, blob download and upload, serving any @bumail/store

此软件包已被标记为弃用

moved to @softistx/bumail-jmap (private, GitHub Packages)

原始使用文档
文档目录11 节
  1. Mounting it in an alxia app
  2. Authentication
  3. What it answers
  4. Uploading and downloading
  5. Limits
  6. OpenAPI document
  7. Traps
  8. Push
  9. API
  10. Documentation
  11. License

@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/echoits arguments
Mailbox/get, Mailbox/changesevery property; myRights grants all but maySubmit, which is true with the submission option
Mailbox/queryfilter by parentId, role, name, hasAnyRole, isSubscribed; sort by sortOrder and name
Mailbox/queryChangesthe filter and sort of Mailbox/query, with sinceQueryState, maxChanges, upToId, calculateTotal
Mailbox/setcreate, update name, parentId and isSubscribed, destroy with onDestroyRemoveEmails
Email/getthe metadata, headers, every header: form, the convenience headers, bodyStructure, textBody, htmlBody, attachments, bodyValues, preview, hasAttachment
Email/queryfilter 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/queryChangesthe filter, sort and collapseThreads of Email/query, with sinceQueryState, maxChanges, upToId, calculateTotal; removed may be a superset
Email/changescreated, updated and destroyed since a state
Email/setupdate keywords and mailboxIds, whole or patched; destroy; create from an uploaded blobId
Email/importemails from uploaded blobs
Thread/getthe emails of a thread, oldest first, through the store's thread index
Thread/changescreated, updated and destroyed threads since a state
Identity/get, Identity/changes, Identity/setwith 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, /setwith 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

limitdefault
maxSizeRequest10 MBthe API body: a Content-Length past it refused unread, a chunked body cut once past it
maxCallsInRequest16method calls in one request
maxConcurrentRequests4API requests in flight, per account
maxObjectsInGet, maxObjectsInSet500ids in a /get; creates, updates and destroys in a /set; and the page of a query
maxSizeUpload25 MiBone upload, held like maxSizeRequest
maxConcurrentUpload4uploads in flight, per account
uploadQuota100 MiBbytes of in-memory uploads held per account (with uploads: the store's quotaBytes)
uploadTtl3600 show long an in-memory upload is kept (with uploads: the store's ttlMs)
maxEventSourceStreams4event streams open, per account (429 limit past it)
maxJsonDepth, maxJsonTokens64, 100 000checked before the body is parsed
maxReferenceItems5000values one back-reference expands to
maxReferenceBytes4 MiBbytes of JSON all the back-references of one request resolve to
maxSizeResponse64 MiBbytes of JSON of one API response
maxQueryScan10 000emails one Email/query indexes from their bytes, matches Email/queryChanges holds, and emails a Mailbox/get count reads (past it, the store's counts)
maxBodyValueBytes1 MiBone body value, whatever the client asks
maxBodyValuesTotal16 MiBbody 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/query is answered by the store's queryMessages, from the search fields an email is added with (Email/import and Email/set pass them). An email without them is read from its bytes, at most maxQueryScan a call, and given its fields (setSearchFields, which no client sees); the call is serverUnavailable while some remain, and each retry indexes more (never a partial result). Email/queryChanges holds the whole result and answers requestTooLarge past maxQueryScan matches. Thread/get and Thread/changes do not scan: the store answers them. Mailbox/get counts unreadEmails, totalThreads and unreadThreads as RFC 8621 §2 asks ($seen in any spelling is read, $draft is never unread) within the same budget, all its mailboxes together (properties left out, null, asks for them): past it, a mailbox's counts are the store's IMAP counts, unseen for both unread counts and messages for totalThreads, 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, unless uploads gives an UploadStore: a restart forgets them, and two processes do not share them.
  • sortOrder is always 0, and a mailbox's role cannot change: the store keeps neither.
  • ifInState is 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 pushInterval and when notify is 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/jmap of the app that mounts it: mount jmap() on an app with no prefix, or that path moves too.
  • asRaw header 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
JmapServernotify(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
JmapOptionsstore, authenticate, origin, basePath, limits, pushInterval, hookTimeout, allowInsecureBasic, secure, onError, submission
JmapSubmission, IdentityInfothe 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, SendDeliverywhat send is given and answers
JmapLimitsthe limits above
JmapCredentialswhat authenticate gets: { scheme: 'basic', username, password } or { scheme: 'bearer', token }
AuthResultwhat authenticate answers: an account id, or null / undefined
JmapClientwho a request is from, as authenticate, secure and onError are told: ip (ctx.ip) and url (originalUrl(ctx))
ErrorContextwhat onError gets beside the error: request, client, accountId, method
JmapError, JmapErrorCodecode: INVALID_OPTION, and HOOK_TIMEOUT, which onError gets when a hook (authenticate, submission.identities, submission.send) does not settle within hookTimeout
@bumail/jmap/openapi.jsonthe 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 (EmailSubmission and the send hook), 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

版本与发布记录

@bumail/jmap 共发布 21 个版本,首次发布于 2026/10/04;过去 12 个月发布了 21 个版本。

版本发布日期距今
0.12.0latest2026/11/053 天前
0.11.12026/11/054 天前
0.11.02026/11/054 天前
0.10.42026/11/044 天前
0.10.32026/11/045 天前
0.10.22026/11/045 天前

关于 @bumail/jmap 的常见问题

@bumail/jmap 是什么?

A JMAP server (RFC 8620, RFC 8621) on alxia: the session, the API with back-references, Mailbox, Email and Thread, blob download and upload, serving any @bumail/store注意:该包已被作者标记为弃用——moved to @softistx/bumail-jmap (private, GitHub Packages)

如何安装 @bumail/jmap?

在项目目录运行 npm install @bumail/jmap,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:@alxia/core(^0.19.0)、@bumail/mime(^0.4.0)、@bumail/store(^0.9.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@bumail/jmap 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@bumail/jmap 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@bumail/jmap 可以商用吗?

@bumail/jmap 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@bumail/jmap 的下载量有多少?

根据 npm 官方下载统计,@bumail/jmap 在 2026/10/09 – 2026/11/07 这 30 天内被下载 3,439 次,日均约 115 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@bumail/jmap 还在维护吗?

最新版本 0.12.0 发布于 2026/11/05(3 天前);至今共发布 21 个版本,其中过去 12 个月发布了 21 个;首次发布于 2026/10/04。作者已将其标记为弃用,新项目建议寻找替代方案。