OpenIMAPD
a free IMAP4rev2 server, built on OpenBSD, for OpenBSD
Status: pre-release, actively developed
Not an OpenBSD port yet. Source is publicly available (see Source below), and a signed 0.2.3 pre-release tarball has been published. imapd needs OpenBSD -current and will not build on -release. See Status. If you’re looking at this page from the ports@ posting, thanks for coming by.
Overview
OpenIMAPD is an IMAP4rev2 (RFC 9051) server written from scratch in strict C99 plus POSIX, following the same privilege-separation design as smtpd, httpd, and ntpd: a root parent that reads a configuration and binds sockets, an unprivileged listener that terminates TLS and speaks the wire protocol, a separate auth process, a keymgr process that holds the TLS private key so the process terminating TLS never has it in memory, a parser child that a store spins up on demand and retires after a spell idle to do every parse of attacker-reachable content – SEARCH’s grammar and FETCH’s ENVELOPE/BODYSTRUCTURE/header-field parsing alike – confined to pledge(2) “stdio recvfd”, reading each message over a descriptor the store passes it already open rather than opening anything itself, and a store process forked per authenticated account, shared by all of that account’s sessions, that chroots and drops privileges before ever touching a mailbox. pledge(2), unveil(2), and chroot(2) are used to enforce boundaries: the parent is the only process that can pass a file descriptor at all.
The goal is a small, honest, single-binary IMAP server, not as a replacement for Dovecot. Scope has been kept purposefully minimal. Features that belong to large multi-user or shared-mailbox deployments, such as ACLs, are left out.
Source
The repository is hosted with Game of Trees (got), read-only anonymous access over SSH. It’s also git-compatible, a plain git clone against the same URL works too.
got clone ssh://anonymous@got.openimapd.dev/imapd
git clone ssh://anonymous@got.openimapd.dev/imapd
Prefer to browse without cloning? The repository is also viewable online via gotwebd.
Latest release: 0.2.3
A signed pre-release tarball is available below. Verify it with signify(1) and the published public key.
- imapd-0.2.3.tar.gz
- imapd-0.2.3.SHA256
- imapd-0.2.3.sig
- imapd.pub (signing public key)
signify -C -p imapd.pub -x imapd-0.2.3.sig
Status
So far, imapd has implemented STARTTLS and implicit TLS (RFC 8314) via libtls, SELECT/EXAMINE, multi-mailbox CREATE/DELETE/RENAME/LIST, SUBSCRIBE/UNSUBSCRIBE (including LIST’s SUBSCRIBED selection option, RFC 9051 section 6.3.9.1), FETCH (including ENVELOPE, BODYSTRUCTURE, and MIME-part-addressed BODY[<part>]), STORE, SEARCH, APPEND, COPY/MOVE to any mailbox, EXPUNGE, UID forms of every command that supports it, CONDSTORE/QRESYNC (RFC 7162), and IDLE. A hand-written imapd.conf grammar and an rc.d(8) script are both in place.
0.1.3 added mailbox names validated as UTF-8 (RFC 3629 well-formedness, with C1 controls and U+FEFF refused, per RFC 5198 Net-Unicode as far as it can be enforced without Unicode tables); an sshd_config(5)-style startups throttle on concurrent unauthenticated connections; and a configurable idle poll interval. A listen directive now also selects which listeners run, so a configuration naming only tls port 993 binds nothing on 143, the deployment RFC 8314 asks for.
IDLE is a poll, not a kernel-driven push: an idling session rechecks its selected mailbox every idle poll seconds (5 by default), so new mail, whether delivered by an external MTA or by another IMAP session, is reported within one interval rather than instantly. Most polls cost two stat(2) calls and no lock. Earlier versions of this page described this as “real cross-session push”, which was not accurate.
For 0.1.4, imapd has added runtime checks that assert the three libtls invariants the keymgr private-key isolation depends on, closed a rare RFC 7162 compliance gap where a failed allocation could send an incomplete MODIFIED set instead of refusing the STORE outright, hardened the credentials-file permission check the auth process applies before trusting it, and deduplicated several client/store-side helpers (mailbox-name validation, header-field walking, and more) that had previously drifted out of sync with each other.
For 0.1.5, imapd has added SUBSCRIBE/UNSUBSCRIBE and LIST’s SUBSCRIBED selection option (RFC 9051 section 6.3.9.1); three new imapd.conf directives, login grace (a pre-authentication timeout of the kind RFC 9051 section 5.4 explicitly permits), lock timeout (bounds how long a command waits on another session’s mailbox lock before answering NO [INUSE], RFC 9051 section 7.1), and append max (a configurable ceiling on APPEND uploads, independent of attachment max); and switched its syslog facility from LOG_DAEMON to LOG_MAIL, matching smtpd(8), so a site’s mail log rules catch both.
For 0.1.6, the daemon that parses SEARCH and FETCH is isolated: a parser child, spun up by a store on demand and retired after a spell idle, now does every parse of attacker-reachable content – SEARCH’s grammar and FETCH’s ENVELOPE/BODYSTRUCTURE/header-field parsing alike – confined to pledge(2) “stdio recvfd”, reading each message over a descriptor the store passes it already open rather than opening anything itself. A store child is now also shared across one account’s sessions rather than forked per session, which two new imapd.conf directives govern: account sessions (the most sessions one account may hold open at once, answered NO [LIMIT] past it, RFC 9051 section 7.1) and connections max (the most connections held open at once from all clients, answered a BYE [UNAVAILABLE] greeting on the cleartext port, or simply closed on the implicit-TLS one, past it).
For 0.1.7, FETCH’s ENVELOPE and BODYSTRUCTURE gained a per- account cache of already-parsed results, keyed by mailbox name, UIDVALIDITY (RFC 9051 section 2.3.1.1), and UID: a repeat FETCH of the same message skips the parser child entirely rather than asking it to parse the message again. Capped at 4 MiB per account, oldest entries evicted first. Anything that could make a cached result wrong invalidates it: EXPUNGE, COPY/MOVE (a new UID), mailbox DELETE/RENAME, and a UIDVALIDITY change all drop the affected entries.
For 0.1.8, NOOP now reports the same mailbox changes IDLE does – EXISTS, EXPUNGE, and flag FETCHes, with MODSEQ once CONDSTORE is enabled (RFC 9051 sections 5.2 and 6.1.2) – so a client that polls with NOOP rather than idling now learns of another session’s changes too. Sequence numbers are now read against what a session was last told rather than the mailbox’s current state, so a FETCH, STORE, or SEARCH naming a message another session has since expunged answers NO [EXPUNGEISSUED] instead of acting on the wrong message (RFC 9051 section 7.5.1, RFC 2180 section 4); STORE .SILENT and COPY/MOVE still succeed on the rest, as those sections require. BODYSTRUCTURE now describes a forwarded message – a MESSAGE/RFC822 or MESSAGE/GLOBAL part – by its own envelope, body structure, and line count (RFC 9051 section 7.5.2) instead of refusing the whole FETCH, and BODY[<part>] addressing reaches through such a part the same way. A fresh install missing any of the three daemon accounts now fails at startup with an explicit “unknown user” message rather than failing unpredictably later, and plain BODY[<part>] (as distinct from BODY.PEEK[<part>]) is now answered and sets (RFC 9051 section 6.4.5); both had shipped incompletely. keymgr’s private-key operations are now bounded by a timeout on the listener’s side, so a keymgr that stops answering can no longer stall every TLS handshake indefinitely.
For 0.1.9, a FETCH or SEARCH walk that pauses to let an account’s other sessions run now re-reads cur/ if a message it held onto turns out to have been renamed meanwhile, rather than reporting it missing; RFC 9051 section 6.4.4 and RFC 2180 section 4.3 both expect a still-present message to be found. SEARCH now yields every 1,000 messages walked, not only every 64 parser requests, so a SEARCH the index alone can answer no longer holds an account’s other sessions for its entire walk. STORE, EXPUNGE, COPY, and MOVE still run to completion without yielding; imapd(8) now says so plainly, since one of them over a very large mailbox delays every other session of the same account until it finishes. STATUS now writes the mailbox index only when new mail has actually arrived, instead of on every call, cutting its cost on a large mailbox and no longer disturbing IDLE’s own view of the directory on every poll. FETCH’s ENVELOPE now joins every occurrence of a repeated From, Sender, Reply-To, To, Cc, or Bcc header field into one address list, as RFC 5322 section 4.5.3 asks for To/Cc/Bcc and as SEARCH already read them, instead of showing only the first; SEARCH SUBJECT now matches only the first Subject field, the one ENVELOPE shows.
For 0.2.0, imapd supports SEARCH RETURN (SAVE) and the “$” result- set marker (RFC 5182, folded into RFC 9051), so a client can save a SEARCH’s matches and reuse them in a later FETCH, STORE, COPY, MOVE, or UID EXPUNGE without resending the sequence set. A FETCH naming a HEADER.FIELDS list with a space inside its brackets, or an unparenthesized item followed by a fetch-modifier (RFC 4466 section 3), is now parsed correctly instead of answered BAD. A command queued behind one whose literal was refused – for a stray NUL or for exceeding the command-length cap – is no longer stuck waiting for a reply that was never coming. A failed login now costs the same whether or not the user name exists (RFC 9051 section 11.7), closing a timing side channel. A client whose socket stays full no longer stalls its account’s other sessions, and the write timeout that disconnects it is now 5 seconds before login and the RFC 9051 section 5.4 floor of 30 minutes after. imapd.conf’s directives now have their own imapd.conf(5) page, split out of imapd(8), which a second pass checked line by line against the code and corrected where it had drifted.
For 0.2.1, imapd.conf(5) has a “Mail delivery” section saying how to have smtpd(8) deliver into an imapd account’s maildir: a userbase table giving each account the uid, gid and maildir of its credentials line, and a maildir "%{user.directory}" action. imapduser -a now prints that userbase line, so imapduser -a joe >> table works. Mail smtpd delivers is stored with CRLF line endings (RFC 5322), converted once when first indexed and before it has a UID, so no UID’s text ever changes (RFC 9051 section 2.3.1.1). A message over attachment max is now refused on the size the file reports, before any of it is read, so it no longer holds an account’s other sessions while it is read and discarded. A broken channel to one child process no longer ends imapd and every session with it; the parent drops that child instead. A login answered OK could be followed by “BAD Command not permitted in this state” when two internal messages arrived out of order; the listener now ignores the late one. imapd(8) was cut to the shape of smtpd(8), with the credentials file’s rules moved into imapd.conf(5).
For 0.2.2, APPEND, COPY, MOVE to another mailbox, and CREATE are refused with NO [OVERQUOTA] (RFC 5530 section 3) when less than 5% of the space or inodes of the filesystem holding the mailbox is free to non-root users, the same floor smtpd(8) keeps for its queue; flags can still be changed and messages expunged below it, and mail smtpd delivers is not refused. imapd.conf(5) describes the floor under spool. SELECT, EXAMINE and STATUS answer [NONEXISTENT] only when the mailbox directory is missing, NO [OVERQUOTA] when saving the index fails for lack of space or quota, and a plain NO (“SELECT failed”, “EXAMINE failed”, “STATUS failed”) for any other failure, instead of calling every failure a missing mailbox. APPEND and COPY now fail, and remove their temporary file, when fsync(2) or close(2) of the new message fails, instead of logging it and reporting the message stored.
For 0.2.3, imapd gives UIDs to message files it finds in a mailbox’s cur/ directory, not only new/, so a maildir brought from another server is no longer invisible, and a mailbox whose imapd.index was lost is rebuilt from its files under a new UIDVALIDITY instead of showing no messages. Each file is first rewritten with CRLF line endings, before it has a UID (RFC 9051 section 2.3.1.1); a file with no maildir info gets “:2,” added; and, in a mailbox that already has an index, a cur/ file flagged T is left out and logged, since it may be a message whose EXPUNGE could not unlink it (RFC 9051 section 6.4.3). A file put in cur/ while a session is in IDLE is noticed at the next refresh that runs for another reason. A login is now answered OK only once the account’s mailbox store is running in its maildir; a store that cannot start refuses each waiting login with NO [UNAVAILABLE] (RFC 9051 section 7.1), where before the client got OK and then a closed connection. A login refused for that reason, or for the account sessions limit, gets an untagged BYE with the same code before its tagged NO (RFC 9051 section 3.4). With no listen on directive imapd now listens on *, both IPv4 and IPv6, where it listened on 0.0.0.0 only. imapd -n checks the configuration file and exits, and repeating -v raises the verbosity.
imapd is currently achieving a warnings-clean build, OpenBSD malloc hardening, fuzzing passes against the command parser, and a regression suite that runs every release.
imapd builds on OpenBSD -current only. It will not build on -release. The daemon calls imsgbuf_set_close_callback() and imsgbuf_set_userdata(), added to libutil on 2026-09-04, after 7.9 was released on 2026-05-19; they are not in 7.9’s imsg_init(3). That is the entire incompatibility, two calls in one file, and everything else has been in base since the November 2024 imsg rework.
Security
If you find any security issues, please send an email to: security@openimapd.dev.
Feedback
For general feedback, please send an email to: feedback@openimapd.dev.