@bumail/imap

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

An IMAP4rev2 server on Bun.listen (RFC 9051): STARTTLS, LOGIN and AUTHENTICATE PLAIN only once encrypted, IDLE, MOVE, UIDPLUS, CONDSTORE, QRESYNC, BINARY, SPECIAL-USE, serving any @bumail/store

此软件包已被标记为弃用

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

原始使用文档
文档目录9 节
  1. Serving a store on 143 and 993
  2. Trying it with Thunderbird
  3. Limits
  4. Renew the certificate without a restart
  5. Behind a TCP proxy
  6. Traps
  7. API
  8. Documentation
  9. License

@bumail/imap

An IMAP server for Bun, on Bun.listen: IMAP4rev2 (RFC 9051), with IMAP4rev1 clients served too; STARTTLS or implicit TLS; LOGIN and AUTHENTICATE PLAIN, only once encrypted; IDLE, MOVE, UIDPLUS, CONDSTORE, QRESYNC, BINARY, SPECIAL-USE, LIST-EXTENDED, ESEARCH and LITERAL+. It serves the mail of any @bumail/store MailStore, and reads messages with @bumail/mime. Command-line clients (curl, Python's imaplib, imapclient) run against it in a repeatable script; Thunderbird and Apple Mail are checked by hand. What ran, and what did not, is on the interop page.

bun add @bumail/imap @bumail/store @bumail/mime

@bumail/store and @bumail/mime are peers: install the versions your app uses. typescript is an optional peer, for the types.

Serving a store on 143 and 993

The authenticate hook is how users log in: it gets the credentials and answers the id of the store account to serve, or null to refuse. It is asked only over TLS.

import { createImapServer } from '@bumail/imap';
import type { ImapServerOptions } from '@bumail/imap';
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' });
await store.createMailbox(alice.id, { name: 'Sent', role: 'sent' });
await store.createMailbox(alice.id, { name: 'Archive', role: 'archive' });

const passwords = new Map([['alice', await Bun.password.hash('correct horse')]]);
const accounts = new Map([['alice', alice.id]]);

const options: ImapServerOptions = {
    hostname: 'imap.example.com',
    store,
    tls: {
        key: await Bun.file('/etc/ssl/imap.example.com.key').text(),
        cert: await Bun.file('/etc/ssl/imap.example.com.crt').text(),
    },
    async authenticate({ username, password }) {
        const hash = passwords.get(username);
        if (!hash || !(await Bun.password.verify(password, hash))) return null;
        return accounts.get(username) ?? null;
    },
};

// 143: clear at first, STARTTLS before logging in.
await createImapServer(options).listen({ port: 143 });
// 993: TLS from the first byte (RFC 8314), what most clients pick.
await createImapServer({ ...options, implicitTls: true }).listen({ port: 993 });

Mail delivered into the store (by @bumail/smtp's onData, say) reaches a client in IDLE within idleInterval seconds (10 by default). Call server.notify(accountId) after a delivery for it to arrive at once.

Trying it with Thunderbird

  1. Run the server above with a certificate Thunderbird trusts — or, for a local try, a self-signed one for localhost, accepting Thunderbird's security exception when it asks.
  2. Account Settings → Account Actions → Add Mail Account, then Configure manually.
  3. Incoming: IMAP, host localhost, port 993 with SSL/TLS, or 143 with STARTTLS; authentication Normal password; username alice.
  4. Check that it lists INBOX, Sent and Archive with their special-use roles, reads the messages, marks them read, and moves them to Archive.

Apple Mail is the same: IMAP, port 993, Use TLS/SSL, Password authentication. Neither was run for 0.5.0; see the interop page for what to check.

Limits

optiondefault
maxConnections1000one more gets * BYE [UNAVAILABLE] Too many connections, try later
maxMessageSize25 MiBthe largest APPEND, announced as APPENDLIMIT; streamed into the store, never held
maxLiteralSize64 KiBthe largest literal of any other command
timeout1800 sidle seconds before the server hangs up; at least 1800 (RFC 9051 §5.4)
loginTimeout60 sfrom the greeting to logged in, however much the client sends
handshakeTimeout10 son implicit TLS, from the TCP connection to the end of the TLS handshake; with proxyProtocol, also for a trusted proxy's header; past it the socket is closed
idleInterval10 sbetween two looks at the store during IDLE
hookTimeout60 sfor authenticate to settle
maxSearchIndex10 000messages one SEARCH indexes from their bytes when the store holds some stored before search existed

Every timer is at most 2 147 483 seconds, what setTimeout can wait. A command line is at most 64 KiB, with at most 32 literals and lists or search keys nested at most 32 deep; before login, at most 2 literals of 1 KiB each. A mailbox name has at most 32 levels and 1024 characters (255 a level), a LIST at most 16 patterns, a SEARCH at most 32 TEXT or BODY keys. A sequence set such as 1:4294967295 is resolved from its ranges, never expanded. A client that stops reading is still hung up on at loginTimeout or timeout, and gives back its place under maxConnections at once; with nothing queued for it, it still reads the BYE, then a clean end, whenever it reads again.

On implicit TLS a socket holds its place under maxConnections from the TCP connection on, before its handshake, and is closed, without a word, handshakeTimeout seconds after connecting if it has not completed it; the greeting, or the BYE of a full server, waits for the handshake. So sockets that connect and never send a ClientHello cannot fill the server:

await createImapServer({ ...options, implicitTls: true, handshakeTimeout: 10 }).listen({ port: 993 });

Renew the certificate without a restart

setTls takes a renewed key and certificate: the next STARTTLS upgrade or implicit TLS connection uses them, and the sessions already open keep the TLS they have. The pair is read and checked first; one that cannot be used rejects with INVALID_OPTION and the old pair stays in use.

// After certbot, Traefik or an ACME client wrote new files:
await server.setTls({
    key: Bun.file('/etc/ssl/imap.example.com.key'),
    cert: Bun.file('/etc/ssl/imap.example.com.crt'),
});

Call it on each server that holds the pair (143 and 993 are two). The guide says how it works on each kind of port.

Behind a TCP proxy

A TCP proxy, such as a Traefik TCP router or HAProxy, hides the client: the server sees the proxy's address. With proxyProtocol, the proxies listed in trusted send a PROXY protocol header (version 1 or 2) first, and session.remoteAddress, for authenticate and onError, is the client's address again. Off by default.

// The proxy at 10.0.0.5 sends a PROXY header; anyone else is served as before.
const proxyProtocol = { trusted: ['10.0.0.5'] };

await createImapServer({ ...options, proxyProtocol }).listen({ port: 143 });
await createImapServer({ ...options, implicitTls: true, proxyProtocol }).listen({ port: 993 });

A trusted proxy must send a valid header within handshakeTimeout, or its connection is reset without a word. The proxy passes TLS through: on 993 the server still holds the certificate. The guide has the Traefik configuration and every case.

Traps

  • LOGIN only after TLS. On a clear connection the server advertises LOGINDISABLED and answers NO [PRIVACYREQUIRED] to LOGIN and AUTHENTICATE without reading the password. tls is required for that reason; there is no option to log in in clear.
  • CONDSTORE and QRESYNC (RFC 7162): ENABLE CONDSTORE, HIGHESTMODSEQ, FETCH with MODSEQ and CHANGEDSINCE, STORE with UNCHANGEDSINCE (OK [MODIFIED …] names the messages it left alone) and SEARCH MODSEQ. A command that uses a modseq enables it, and MODSEQ and UID then come in every FETCH that tells flags. ENABLE QRESYNC makes VANISHED replace every EXPUNGE for the connection, and SELECT INBOX (QRESYNC (<uidvalidity> <modseq> [<known UIDs> [(<seq> <uids>)]])) and UID FETCH … (CHANGEDSINCE n VANISHED) answer VANISHED (EARLIER): what left since, from the store's remembered expunges, or, when it no longer remembers that far, every known UID the mailbox lacks. Nothing is told that was not asked for first.
  • BINARY (RFC 3516): FETCH BINARY[1.2]<0.1024>, BINARY.PEEK[…] and BINARY.SIZE[…] give a leaf part with its base64 or quoted-printable decoded by the server, the partial counting decoded bytes; data with a NUL goes as a literal8, ~{n}, and APPEND takes ~{n} or ~{n+} for 8-bit content (a NUL is NO [UNKNOWN-CTE]). BINARY sets \Seen, BINARY.PEEK does not. An encoding the server cannot decode is NO [UNKNOWN-CTE]; a multipart or a message/rfc822 is NO [CANNOT]. Decoding is streamed, never held.
  • APPEND threads its message: the header (up to 64 KiB) is read as the literal streams in, so a reply joins its parent's thread; a longer header starts a thread of its own.
  • UIDPLUS names the new UIDs (RFC 4315): APPEND answers [APPENDUID <uidvalidity> <uid>], COPY and UID COPY answer [COPYUID <uidvalidity> <source UIDs> <new UIDs>], and MOVE says the same in an untagged OK before its EXPUNGE responses (RFC 6851 §4.3). A message another client removed meanwhile is left out of both sets, which stay in step.
  • SEARCH is the store's, on decoded text. BODY matches the decoded text of the message's text parts (base64, quoted-printable and the charset undone, HTML read as text, attachments left out) and TEXT that and every decoded header value of the message, both without case. Days (BEFORE, ON, SINCE, SENT*) are UTC days; a message with no Date is dated by its arrival for SENT*. A message stored before search existed is indexed by the first SEARCH that needs it, maxSearchIndex at a time; past that the command is NO [UNAVAILABLE]: retry, or run bumail store index once. APPEND indexes its message.
  • A sequence number past the last message is BAD, as RFC 9051 §6.4.4 says; a UID set that names no message is not.
  • Before login, a literal is at most 1 KiB, whatever maxLiteralSize says. A password longer than that goes through AUTHENTICATE PLAIN.
  • MOVE into the selected mailbox is OK and changes nothing: no EXPUNGE, the same UIDs.
  • trusted lists the proxies, nothing else. A peer listed there can claim any client address. Never list a range clients connect from, nor 0.0.0.0/0 on a public port: trusted: ['10.0.0.5'].

API

export
createImapServer(options)the server; throws an ImapError (INVALID_OPTION) on a bad option
ImapServerlisten({ port, hostname? }), which resolves to the bound { port, hostname } (once; again, even before the first resolved, throws ALREADY_LISTENING; on implicit TLS, a key or certificate it cannot read or use throws INVALID_OPTION), setTls({ key, cert }) (a renewed pair for new STARTTLS upgrades and new implicit TLS connections; open sessions keep theirs; a pair that cannot be used throws INVALID_OPTION and the old one stays), stop(closeConnections?) (true hangs up on every client, after STARTTLS too), notify(accountId) to wake the account's IDLE sessions, connections, the number of open connections
ImapServerOptionshostname, store, tls, implicitTls, authenticate, the limits above, proxyProtocol, onError
ImapSessionwhat authenticate and onError receive: id, remoteAddress, secure, user, accountId, and data for your own state
AuthResultwhat authenticate answers: an account id, or null / undefined to refuse
Credentialswhat authenticate receives: mechanism (LOGIN or PLAIN), username, password, authorizationId?
TlsOptionskey and cert, as Bun.listen takes them
ProxyProtocolOptionstrusted: the IPv4 and IPv6 addresses and CIDRs of the proxies whose PROXY header is read
ImapError, ImapErrorCodecode: INVALID_OPTION, ALREADY_LISTENING, STOPPED (a stop() came before listen resolved), and HOOK_TIMEOUT, which onError gets
encodeUtf7(name), decodeUtf7(name)mailbox names to and from modified UTF-7 (RFC 3501 §5.1.3), as IMAP4rev1 clients write them

Documentation

  • Index: the pages, and when to read each.
  • Guide: the session, every command, how the store maps to IMAP, IDLE and notify, running behind a TCP proxy, the RFCs followed and what is not.
  • Troubleshooting: every error, and the responses a client reports, by their exact text, split into configuration, logging in and connections (a proxy's included), syntax, mailboxes and messages.
  • Interop: which clients were run against the server (curl, imaplib, imapclient), what they checked, and that Thunderbird and Apple Mail were not run.
  • Roadmap: what is coming and what is not planned.

License

MIT

版本与发布记录

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

版本发布日期距今
0.6.0latest2026/11/053 天前
0.5.62026/11/054 天前
0.5.52026/11/044 天前
0.5.42026/11/045 天前
0.5.32026/11/045 天前
0.5.22026/11/036 天前

关于 @bumail/imap 的常见问题

@bumail/imap 是什么?

An IMAP4rev2 server on Bun.listen (RFC 9051): STARTTLS, LOGIN and AUTHENTICATE PLAIN only once encrypted, IDLE, MOVE, UIDPLUS, CONDSTORE, QRESYNC, BINARY, SPECIAL-USE, serving any @bumail/store注意:该包已被作者标记为弃用——moved to @softistx/bumail-imap (private, GitHub Packages)

如何安装 @bumail/imap?

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

@bumail/imap 支持 TypeScript 吗?

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

@bumail/imap 支持 ESM 还是 CommonJS?

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

@bumail/imap 可以商用吗?

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

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

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

@bumail/imap 还在维护吗?

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