@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
- 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. - Account Settings → Account Actions → Add Mail Account, then Configure manually.
- Incoming: IMAP, host
localhost, port993with SSL/TLS, or143with STARTTLS; authentication Normal password; usernamealice. - 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
| 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 |
maxSearchIndex | 10 000 | messages 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
LOGINDISABLEDand answersNO [PRIVACYREQUIRED]to LOGIN and AUTHENTICATE without reading the password.tlsis required for that reason; there is no option to log in in clear. - CONDSTORE and QRESYNC (RFC 7162):
ENABLE CONDSTORE,HIGHESTMODSEQ,FETCHwithMODSEQandCHANGEDSINCE,STOREwithUNCHANGEDSINCE(OK [MODIFIED …]names the messages it left alone) andSEARCH MODSEQ. A command that uses a modseq enables it, andMODSEQandUIDthen come in every FETCH that tells flags.ENABLE QRESYNCmakesVANISHEDreplace everyEXPUNGEfor the connection, andSELECT INBOX (QRESYNC (<uidvalidity> <modseq> [<known UIDs> [(<seq> <uids>)]]))andUID FETCH … (CHANGEDSINCE n VANISHED)answerVANISHED (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[…]andBINARY.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 isNO [UNKNOWN-CTE]).BINARYsets\Seen,BINARY.PEEKdoes not. An encoding the server cannot decode isNO [UNKNOWN-CTE]; a multipart or amessage/rfc822isNO [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 untaggedOKbefore itsEXPUNGEresponses (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.
BODYmatches the decoded text of the message's text parts (base64, quoted-printable and the charset undone, HTML read as text, attachments left out) andTEXTthat and every decoded header value of the message, both without case. Days (BEFORE,ON,SINCE,SENT*) are UTC days; a message with noDateis dated by its arrival forSENT*. A message stored before search existed is indexed by the first SEARCH that needs it,maxSearchIndexat a time; past that the command isNO [UNAVAILABLE]: retry, or runbumail store indexonce. 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
maxLiteralSizesays. A password longer than that goes through AUTHENTICATE PLAIN. - MOVE into the selected mailbox is OK and changes nothing: no
EXPUNGE, the same UIDs. trustedlists the proxies, nothing else. A peer listed there can claim any client address. Never list a range clients connect from, nor0.0.0.0/0on 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.
- 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