How it is built
Architecture overview
A mail store keeps every message a person has received, lets any mail client list, read, flag and move them, and finds a word in ten years of mail in under a second. A message is written once and almost never changed; what changes constantly is the small state around it (flags, folder membership, read position), and what is asked of it most is "what changed since I last looked". The design that follows from this is a split in three: an immutable blob store for message bodies, a mutable metadata store for per-message state, and a derived search index that can always be rebuilt from the other two.
The idea that makes it scale is the cursor: the server numbers every change inside a mailbox, and a client keeps one token telling it where it stopped, so a sync costs what changed, not what exists. IMAP's unique identifiers and modification sequences are that cursor (RFC 9051 and RFC 7162), and JMAP makes it explicit with a state string and a changes method (RFC 8620). Everything else is a choice about where the blobs live (one file per message, packed files, or object storage), how the index is kept, and how copies survive a failed machine. Hosted providers' backends (Gmail is the usual example) are proprietary, so this page describes the design space and the open-source systems that occupy it, such as Dovecot, Cyrus and Stalwart.
Parts:
- Delivery and parsing — accept a message, split headers from the body, decode parts, normalise text —
mime-parsing(no page yet), Text normalisation, Unicode text tokenisation - Blob store — the raw message, immutable, deduplicated and compressed — Content-addressed storage, Zstandard, Dictionary compression, LZ77, SHA-2 and BLAKE3 hashes,
erasure-coding(no page yet) - Metadata store — folders, flags, UIDs, thread links, per message — LSM tree, Write-ahead log, Roaring bitmaps, Fast hash functions
- Search index — full-text over headers and bodies — Inverted index, Delta encoding, Bit-packing and PFOR, Varints, Posting-list intersection, BM25 ranking, Top-k selection
- Sync — tell a client exactly what changed — Incremental sync cursors, Message sequence numbers,
cursor-pagination(no page yet) - Replication and placement — keep copies on several machines and repair divergence — Quorum replication, Consistent hashing, Vector clocks, Hinted handoff, Anti-entropy repair, Range partitioning,
merkle-tree(no page yet), Token bucket - Durability and integrity — survive crashes and bit rot — fsync and direct I/O, CRC32C checksums,
atomic-file-replacement(no page yet) - Confidentiality — encrypt at rest, shred on delete — Envelope encryption, AEAD ciphers,
aes-key-wrap(no page yet),csprng(no page yet)
SMTP / LMTP in ─► parse (MIME) ─► spam / rules ─► deliver
│
┌─────────────────────┼──────────────────────┐
▼ ▼ ▼
blob store (immutable) metadata store (UID, flags) indexer (tokenise)
file / packed / object WAL + sorted files inverted index segments
▲ │ change log: modseq / state
│ ▼
IMAP / JMAP front end ◄── sync cursor ── client (phone, laptop, web)
│ search ► index ► candidate UIDs ► fetch from blob store
└── replication: quorum writes, hints, anti-entropy between nodes
How the parts fit together
Blob store: the message is the unit that never changes
A message is bytes in the RFC 5322 format, with MIME structure from RFC 2045. Nothing in the server needs to rewrite them after delivery, so the cheapest store is an append-only one. Three layouts are in use, and Dovecot documents all of them:
- One file per message (Maildir). D. J. Bernstein's specification calls the point "no locks": each message is a separate file written first to
tmpand then moved tonew, so a reader never sees a partly delivered message and the format works even over NFS (source: Maildir specification). Dovecot adds adovecot-uidlistfile that maps message file names to IMAP UIDs, and notes that it must be replaced only withrename()(source: Dovecot Maildir docs). The price is one inode and one directory entry per message, which hurts at tens of millions of small files. - Packed files (dbox). Dovecot's single-dbox keeps one message per file and multi-dbox packs many messages into a file that is rotated at a size; the default rotate size is 10 MB, and the docs say new files are preallocated at that size on some Linux filesystems (source: Dovecot dbox docs). Fewer files make backup and delete cheaper, at the cost of a compaction step when messages are expunged.
- Object storage. For large installations Dovecot's documentation recommends object storage and names its
oboxformat for it as part of Dovecot Pro, the commercial edition (source: Dovecot mailbox formats overview). Stalwart lists RocksDB, FoundationDB, PostgreSQL, MySQL, SQLite, S3-compatible and Azure storage backends (source: Stalwart README).
On top of the layout, Content-addressed storage stores each blob under the hash of its bytes (SHA-2 and BLAKE3 hashes), so the same attachment sent to a hundred recipients is stored once and every read can verify itself. Deletion then needs reference counts. Compression is Zstandard with a trained dictionary (Dictionary compression) for the small, repetitive headers, and the plain LZ77 family underneath it. Compression must run before encryption, because ciphertext does not compress.
erasure-coding (no page yet) offers durability at lower overhead than three replicas: split a blob into k data and m parity shards and survive m losses, using Reed-Solomon codes over finite-field-arithmetic (no page yet) with SIMD programming for speed. It suits large, cold blobs; replicas suit small hot metadata, where a read should touch one machine.
Metadata store: small, hot, and the source of truth for sync
Flags, folder membership, thread links and the UID sequence are small records that change often. A LSM tree is a good fit because it turns random flag updates into sequential writes, with a Write-ahead log in front for crash safety; the alternative is a B+ tree, which reads faster but writes in place. A set of messages matching a flag (unread, flagged, in this folder) is a set of integers, which Roaring bitmaps store compactly and intersect quickly, so "unread in Inbox" is an AND of two bitmaps. Fast hash functions build in-memory lookup tables from message ID to location.
Many systems keep this state in separate index files next to the messages rather than in a database; Dovecot stores them in the Maildir by default and lets the index path move to other storage (source: Dovecot Maildir docs). Whatever form, the contract is the same: the store assigns a UID in strictly ascending order within a mailbox, and keeps a validity value that changes if UIDs are ever reassigned (source: RFC 9051, section 2.3.1.1).
Search index: derived, rebuildable, and allowed to lag
The index is an Inverted index: for each term, a sorted list of message identifiers. Lists are stored as Delta encoding of the gaps, then Bit-packing and PFOR or Varints for the small numbers; a multi-term query is a Posting-list intersection that starts with the shortest list. Terms come from text that went through Text normalisation and Unicode text tokenisation; the same pipeline must run on the query or "résumé" and "resume" never meet. BM25 ranking orders results by relevance when the client asks for it, and Top-k selection keeps only the best page of hits.
Two design points matter. First, the index is derived: it can be wrong or empty without losing mail, so it can be built lazily. Dovecot's full-text plugin can index mail on delivery, skip mailboxes such as Junk, and still index a skipped mailbox when a search hits it (source: Dovecot fts docs). Second, attachments need extraction before indexing; the same plugin can use an external decoder service for this. Dovecot's plugin supports Apache Solr as a backend (source: Dovecot documentation), Stalwart ships a built-in engine covering 17 languages and can also use Meilisearch, ElasticSearch, OpenSearch, PostgreSQL or MySQL (source: Stalwart README), and notmuch builds on Xapian (source: notmuch repository).
Sync: the cursor is the interface
Incremental sync cursors are the contract between the server and every client. IMAP gives two of them. The UID plus a UIDVALIDITY value identify a message forever within a mailbox (source: RFC 9051, section 2.3.1.1). CONDSTORE adds a modification sequence per message that is increased whenever a flag changes, so a client can ask only for flags changed since a known value (source: RFC 7162, sections 1 and 3.1); QRESYNC lets a reconnecting client resynchronise a mailbox in one step (same RFC). Message sequence numbers in general give loss and duplicate detection. JMAP puts it in the open: every object type has a state string and a /changes method that returns what was created, updated or destroyed since a given state (source: RFC 8620, section 5.2), and JMAP for Mail defines Email, Thread and the matching changes and query-changes methods (source: RFC 8621). cursor-pagination (no page yet) is the same idea for long lists: an opaque keyset token rather than an offset, so inserts during paging do not shift the window.
Replication and placement: where machines fail
One mailbox belongs to one user, so the natural shard key is the user: Consistent hashing or Range partitioning assign users to nodes, and moving a user is a bounded copy. A user's mailbox then needs copies. Quorum replication writes to W of N replicas and reads from R with R + W > N, which tolerates a failed node without a leader; Hinted handoff parks writes for a node that is down and replays them later, and Anti-entropy repair compares merkle-tree (no page yet) hashes of ranges to find silent divergence. Vector clocks tell whether two updates were ordered or concurrent, which is what conflict detection needs. The alternative is a single primary per user with a standby (primary-backup replication), which is simpler and gives the strong ordering that UIDs need; for mail it is often the better choice, because UID assignment must be a single sequence per mailbox.
Token bucket limits per-user rates for delivery and IMAP commands so one runaway client does not saturate a shard.
Durability and encryption
A message should be acknowledged to the sending server only after it is durably stored; fsync and direct I/O says exactly what a fsync guarantees, and Dovecot exposes a mail_fsync setting with an "optimized" mode and a way to disable it for POP3 (source: Dovecot mailbox formats overview). CRC32C checksums detect corruption in blocks and logs, and atomic-file-replacement (no page yet) (write a temporary file, sync it, rename it over the old one, sync the directory) updates small whole files such as an index safely.
For privacy, Envelope encryption encrypts each message with its own data key and wraps that key with a user key held elsewhere, using AEAD ciphers for the data and aes-key-wrap (no page yet) for the key, with randomness from a csprng (no page yet). Deleting the user's key then shreds everything. Server-side search then needs an index that is itself protected. Dovecot ships a mail-crypt plugin for encrypting stored mail (source: Dovecot documentation).
Data and control flow
One incoming message:
- The SMTP or LMTP server hands over the raw message and the recipient.
- The parser reads headers and MIME parts; spam and rule filters run; the result may be a folder choice.
- The blob is compressed, checksummed and written; the write is synced before acknowledgement.
- The metadata store assigns the next UID in the target mailbox and a new modification sequence, and appends to the log.
- The indexer is told about the new message, tokenises headers and text, and adds postings in a small in-memory or on-disk segment.
- Connected clients get a notification; sleeping clients find the change at their next sync.
One search from a phone:
- The client sends a query ("from:alice invoice"), scoped to a mailbox.
- The server tokenises the terms exactly as at index time and looks up each posting list.
- Lists are intersected, shortest first; the sender filter comes from the metadata bitmap.
- The top results are ranked, their UIDs returned and headers fetched from the blob store.
One reconnect after a day offline:
- The client sends its last known UIDVALIDITY and modification sequence.
- If UIDVALIDITY changed, the client discards its cache; otherwise the server returns new UIDs, changed flags and expunged UIDs.
- The client applies the delta and stores the new token.
Scaling
In the order you hit them:
- Metadata I/O. Many small flag updates; keep index and metadata on fast disks and blobs on cheaper ones.
- File count. One file per message becomes the limit; pack messages (dbox) or use object storage.
- Index size and build time. Index lazily, skip junk, shard the index with the user.
- Storage cost. Dedupe attachments, compress, move cold blobs to erasure-coded storage.
- Node failure. Replicas and repair; test them by failing nodes on purpose.
- Noisy users. Per-user rate and quota limits.
Failure modes
| Failure | Symptom | Cause | Fix |
|---|---|---|---|
| UID reuse | client shows wrong or duplicate messages | UIDVALIDITY not changed after UIDs were reassigned | bump UIDVALIDITY; clients then rebuild their cache (RFC 9051) |
| Lost flags | read mail turns unread | concurrent writers overwrote each other | modification sequences and conditional stores (RFC 7162) |
| Search finds nothing new | recent mail missing from results | indexer lag or crashed | rebuild or catch up; index is derived |
| Slow listing | folders with 100k files hang | one file per message, directory scans | packed format, index files, avoid stat on every entry |
| Silent corruption | unreadable old message | bit rot, no checksum | CRC32C on blocks, background scrub, repair from replica |
| Duplicate delivery | same mail twice | retry after timeout without idempotency | key deliveries by message ID and recipient |
| Unreadable after key loss | data permanently gone | wrapped key deleted or lost | back up key material separately from the data |
| Replication split | two nodes accept writes for one user | failover without fencing | single writer per mailbox, fencing, or quorum |
| Index poisoning | search shows other users' hits | shared index without tenant isolation | per-user or per-tenant index |
| Full disk | delivery rejects mail | quota or index growth | quota accounting, alert before full, move cold blobs |
Real implementations
| name | what it is | repo | licence | notes |
|---|---|---|---|---|
| Dovecot | IMAP and POP3 server with Maildir, dbox and index files | dovecot/core | MIT for some libraries, otherwise LGPL-2.1 (COPYING) | object storage format is in the commercial Pro edition |
| Cyrus IMAP | IMAP server (per its README) with its own mailbox store | cyrusimap/cyrus-imapd | BSD-style, Carnegie Mellon (COPYING) | project site cyrusimap.org |
| Stalwart | mail and collaboration server in Rust: JMAP, IMAP4, SMTP | stalwartlabs/stalwart | AGPL-3.0 (README badge); an enterprise licence is also sold | pluggable stores incl. RocksDB, FoundationDB, S3 |
| Apache James | Java mail server: IMAP, SMTP, JMAP, POP3 | apache/james-project | Apache-2.0 (LICENSE) | distributed server uses Cassandra, S3, OpenSearch, RabbitMQ (README) |
| notmuch | tag-based mail indexer on top of Xapian | notmuch/notmuch | GPL-3.0-or-later (COPYING) | an index over Maildir, not a server |
| Xapian | search engine library | xapian/xapian | GPL-2.0 (COPYING) | used by notmuch |
The alternative: use a hosted mail provider. Running your own mail store only pays when data location, control or cost demand it; the delivery side (reputation, spam, deliverability) is a separate, larger job than the storage.
Sources
- M. Crispin, Internet Message Access Protocol (IMAP) - Version 4rev2, RFC 9051. RFC; IMAP4rev1, RFC 3501: RFC
- A. Melnikov, D. Cridland, C. Wilson, IMAP Extensions: Quick Flag Changes Resynchronization (CONDSTORE) and Quick Mailbox Resynchronization (QRESYNC), RFC 7162. RFC
- N. Jenkins, C. Newman, The JSON Meta Application Protocol (JMAP), RFC 8620, and JMAP for Mail, RFC 8621. RFC 8620, RFC 8621
- Internet Message Format, RFC 5322. RFC; MIME Part One, RFC 2045. RFC
- D. J. Bernstein, Using maildir format. spec
- Dovecot CE 2.4 documentation: mailbox formats, Maildir, dbox, fts plugin, mail-crypt
- Stalwart: README; Cyrus IMAP: repo