npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@bumail/imap

v0.4.0

Published

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

Readme

@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, SPECIAL-USE, LIST-EXTENDED, ESEARCH and LITERAL+. It serves the mail of any @bumail/store MailStore, and reads messages with @bumail/mime. Thunderbird and Apple Mail log in, list folders, read messages and set flags.

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. Thunderbird 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.

Limits

| option | default | | | --- | --- | --- | | maxConnections | 1000 | one more gets * BYE [UNAVAILABLE] Too many connections, try later | | maxMessageSize | 25 MiB | the largest APPEND, announced as APPENDLIMIT; streamed into the store, never held | | maxLiteralSize | 64 KiB | the largest literal of any other command | | timeout | 1800 s | idle seconds before the server hangs up; at least 1800 (RFC 9051 §5.4) | | loginTimeout | 60 s | from the greeting to logged in, however much the client sends | | handshakeTimeout | 10 s | on 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 | | idleInterval | 10 s | between two looks at the store during IDLE | | hookTimeout | 60 s | for authenticate to settle |

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.
  • No CONDSTORE or QRESYNC yet. A client resynchronises a mailbox by fetching its flags; ENABLE CONDSTORE is answered without it.
  • No UIDPLUS yet. APPEND, COPY and MOVE do not say the new UIDs; a client finds them with UID SEARCH or a FETCH.
  • SEARCH TEXT and BODY read the stored bytes. A base64 or quoted-printable body is not decoded before matching.
  • 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 | | ImapServer | listen({ 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 | | ImapServerOptions | hostname, store, tls, implicitTls, authenticate, the limits above, proxyProtocol, onError | | ImapSession | what authenticate and onError receive: id, remoteAddress, secure, user, accountId, and data for your own state | | AuthResult | what authenticate answers: an account id, or null / undefined to refuse | | Credentials | what authenticate receives: mechanism (LOGIN or PLAIN), username, password, authorizationId? | | TlsOptions | key and cert, as Bun.listen takes them | | ProxyProtocolOptions | trusted: the IPv4 and IPv6 addresses and CIDRs of the proxies whose PROXY header is read | | ImapError, ImapErrorCode | code: 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.
  • Roadmap: what is coming — CONDSTORE, QRESYNC, UIDPLUS, BINARY — and what is not planned.

License

MIT