RunBSD

OpenIMAPD: A From-Scratch IMAP Server, OpenBSD-Style

18 August 2026

Update, 22 August 2026: this project has outgrown a single projects-page write-up. It now has its own home at openimapd.dev, with the real source, documentation, and ongoing development log. This page is left in place as the original announcement; head to openimapd.dev for anything current.

What it is

OpenIMAPD is an IMAP4rev2 (RFC 9051) server written from scratch in traditional C, built in the OpenBSD "small daemon" tradition — the same lineage as smtpd, httpd, and ntpd. No third-party IMAP library, no borrowed protocol engine. Just a privilege-separated daemon, a maildir-based storage layer, and a wire protocol implemented directly against the RFC text.

The long-term aspiration is genuine OpenBSD base inclusion, someday. The honest, near-term goal is smaller: a simple, secure, personal-use IMAP server that's actually good enough to run my own mail through — which, as of this week, it is.

Why build another IMAP server

Because the good ones (Dovecot, Cyrus) are enormous, and the OpenBSD base tree has a long history of proving that a mail-adjacent daemon doesn't have to be. smtpd replaced sendmail/postfix-class complexity with something an individual can actually read start to finish. There was never an equivalent on the IMAP side. OpenIMAPD is an attempt at one — deliberately trading completeness for a footprint small enough that every line of it can be justified against an RFC citation or an explicit, documented judgment call.

That "smaller feature set over completeness" principle — borrowed directly from smtpd's own design philosophy — has been the single biggest driver of every scoping decision in this project. Every extra command is attack surface. If a feature isn't load-bearing for a real personal mail workflow, it doesn't go in v1.

Architecture

Privilege separation, smtpd-style:

Nothing that speaks to the network ever touches the mail spool directly, and nothing that touches the mail spool ever speaks to the network. That boundary is enforced by pledge(2), unveil(2), and chroot(2), not just convention.

What actually works today

At last count the source is a little over 21,000 lines across listener.c, store.c, auth.c, parent.c, and the shared imapd.h protocol header — small by mail-server standards, and every one of those lines maps back to something in the RFC text or a documented reason it doesn't.

Storage: maildir, plus a small index

Messages live in stock maildir format (tmp/, new/, cur/, atomic delivery via rename(2)) — auditable, ls-and-grep-inspectable, and (this turned out to matter a lot) natively understood by smtpd itself. smtpd.conf's maildir delivery action writes straight into the same tree OpenIMAPD reads, with zero glue code. As of this week that's not just a design note — it's how my own test mail actually arrives, smtpd on one side, OpenIMAPD on the other, no LMTP, no MDA script in between.

IMAP needs more bookkeeping than a bare maildir gives you for free, though — UIDs, UIDVALIDITY, per-message mod-sequences, arbitrary keywords. That lives in a small, privately-owned, line-oriented index file per mailbox, rewritten under flock(2) on every mutation. Deliberately boring: colon-delimited text, not a database, not a binary format. If something ever goes wrong, I can read the index with cat.

Built and tested against real hardware

Every feature in this project gets the same treatment before I consider it done: implement it, compile-check it under the same WARNS=6-equivalent flags the real Makefile uses, deploy it to an actual OpenBSD 8.0 box, and verify it against a real IMAP exchange — not just "it compiles." A lot of that verification has been through Apple Mail directly, and a fair amount through small purpose-built Python test harnesses when I needed to control the exact bytes on the wire (raw openssl s_client turned out to have its own CRLF and pipelining gotchas that a real client's IMAP library quietly handles for you).

That discipline has caught real bugs a compiler never would have: a boot-handshake deadlock from reading an imsg before checking it had actually arrived, a busy-loop from missing EV_WRITE handling, a read-size regression in BODYSTRUCTURE parsing. Every one of those is documented, with the fix, in the project's own README.skeleton — which has become less a README and more an honest running log of what's been verified versus what's still a documented assumption.

Hardened, and now it boots like a real daemon

The most recent pass wasn't a new IMAP command — it was making sure the commands that already exist hold up under real pressure, and making the daemon behave the way the rest of the system expects a daemon to behave.

On the hardening side: OpenBSD malloc hardening enabled for every test run, a sanitizer build (ASan/UBSan) stood up against the real OpenBSD toolchain, a purpose-built fuzz harness thrown at the IMAP command parser, and a full manual review pass over the two highest-risk files — the MIME/BODYSTRUCTURE parser and the wire-protocol tokenizers. That turned up three real bugs: a size_t underflow, an algorithmic-complexity denial-of-service in LIST pattern matching, and a functional gap the first fix exposed once it was in place — all fixed and verified against the real daemon, not just a test harness. A second, independent review (a different model, working from a cold start) turned up another 14 findings across the privilege-separation trust boundary, buffer sizing, and input validation; I checked its patches against the live source myself rather than taking the summary on faith, and they held up.

On the deployment side: until this week, this project had only ever been run by hand — doas ./imapd -d -v in a foreground terminal. It now has a real install path (/usr/local/sbin, the same convention a port would use), a -V flag, and a proper rc.d(8) service script, so it starts, stops, and gets tracked by rcctl the same way every other daemon on the box does. It also picks up OpenBSD's boot-time re-link mechanism on its own terms: make install builds a re-link kit the same way libc/sshd do, but since nothing in base actually consumes a re-link kit for anything outside that fixed allowlist, the rc.d script carries its own small consumer that applies a pending relink before the daemon starts — no patching of base /etc/rc to teach it about a daemon that isn't in base.

The installed daemon itself also picked up a new name that week: imapd, not openimap. That matches OpenBSD's own convention for its "Open*" projects — the "Open" prefix comes off for the installed binary. At the time I assumed the project's own name stayed put unchanged, the same way OpenSSH ships ssh/sshd. Checked that against OpenBSD's own innovations page since, rather than leaving it assumed: OpenNTPD ships ntpd, OpenSMTPD ships smtpd, OpenBGPD ships bgpd, OpenIKED ships iked — and in every one of those, the "D" is already baked into the project name itself. OpenSSH is the exception, and only because it's a whole toolkit, not one daemon. This project is shaped like the single-daemon case, so the earlier assumption was the wrong analogy — the project itself is now OpenIMAPD, matching precedent instead of guessing at it.

Where it's going

Short-term: nothing urgent in the core command set. SUBSCRIBE/UNSUBSCRIBE and ACL support were looked at and deliberately left out rather than left unimplemented by accident — checked against real Apple Mail behavior, both exist mainly to manage large numbers of shared or public mailboxes, a scenario this single-user, no-shared-mailbox server doesn't have. Same reasoning smtpd itself uses to justify skipping VRFY/EXPN despite RFC recommendation: every extra command is attack surface, and neither currently earns its keep here.

Longer-term, the honest path to OpenBSD base inclusion still runs through becoming a real port first — httpd and smtpd both got into base by having a long-term champion build a track record on tech@, not by landing a finished daemon in one shot. A proper port package is still a distinct body of work — a tagged release, pkg/DESCR, pkg/PLIST, distinfo — but this week's install-path work means the port's own BINDIR convention is already what's on disk, which is one less thing to redo later. Same plan as before: keep the public, honest verification trail intact, and eventually post to the mailing list once it's something a stranger could stand up from a port and actually trust with their mail.

For now, it already does the one thing that matters most: it holds my real mail, over TLS, through a real client, on a real machine, started and supervised the same way every other daemon on the box is — built one RFC section at a time.