|
Production-grade IMAP / POP3 / LMTP / ManageSieve / Submission mail server written in Go. Multi-binary architecture — each protocol component is a separate process. Kubernetes-native via Helm. |
Yarilo is a multi-binary server. Each protocol and infrastructure role is a separate compiled binary — no mode flags, no combined processes. Deployment topology is configured purely through Helm values; the same binaries serve standalone and clustered installations.
Internet
|
| IMAPS :993 / IMAP :143 / POP3S :995 / POP3 :110
| LMTP :24 / ManageSieve :4190 / SASL :4190
v
+------------------------+ +------------------------+
| yarilo-imap-login | | yarilo-pop3-login | login pods (TLS termination,
| yarilo-lmtp-login | | yarilo-managesieve- | HAProxy / XCLIENT, passdb,
| yarilo-submission- | | login | anvil rate-limit, fd-passing)
| login / sasl-login | +------------------------+
+------------------------+
| Unix fd-passing (SCM_RIGHTS) after auth
v
+------------------------+ +------------------------+
| yarilo-imap | | yarilo-pop3 | session pods (mailbox,
| yarilo-lmtp | | yarilo-managesieve | index, Sieve execution,
| yarilo-submission | | yarilo-backend-api | quota, ACL)
+------------------------+ +------------------------+
|
| cross-process write coordination (TCP mTLS)
v
+------------------------+ +------------------------+
| yarilo-locks | | yarilo-auth | shared services
| yarilo-anvil | | Redis (dict / locks) |
+------------------------+ +------------------------+
|
| NFS PV (RWX) — shared by all session pods in a tag
v
[ Mailbox + Index files ]
Login pods — terminate TLS (SNI per-domain), authenticate via passdb, enforce per-user connection limits (anvil), pass the raw fd to session pods via SCM_RIGHTS. Stateless; scale independently.
Session pods — full mail processing: IMAP / POP3 / LMTP / Sieve / Submission / ManageSieve. Each protocol scales as a separate StatefulSet so IMAP can scale independently of LMTP.
yarilo-auth — shared passdb chain (MySQL / Postgres / SQLite / passwd-file / static), auth-token cache, SASL dispatch, master protocol for userdb lookups.
yarilo-locks — cross-process write coordination. TCP mTLS :9104, Redis-backed state. All Kubernetes deployments (standalone and backend) use remote mode — embedded Unix-socket mode is reserved for unit tests and single-process CLI runs.
yarilo-anvil — connection rate limiting and penalty tracking. Shared across all login pods.
| Protocol | Standard | Extensions | Status |
|---|---|---|---|
| IMAP4rev2 | RFC 9051 | IDLE, MOVE, CONDSTORE, QRESYNC, UIDPLUS, UNSELECT, NAMESPACE, QUOTA, ACL, BINARY, SORT, THREAD, ESEARCH, NOTIFY, URLAUTH, SPECIAL-USE, ID, OBJECTID, METADATA | ✅ |
| POP3 | RFC 1939 | STLS, UIDL, CAPA, XCLIENT | ✅ |
| LMTP | RFC 2033 | per-recipient status, HAProxy, XCLIENT, STARTTLS, Delivered-To, Sieve delivery |
✅ |
| ManageSieve | RFC 5804 | full script management | ✅ |
| Sieve | RFC 5228 | fileinto, reject, ereject, envelope, encoded-character, variables, relational, copy, subaddress, environment, body, vacation, vacation-seconds, regex, date, index, editheader, mailbox, mailboxid, duplicate, ihave, special-use, imap4flags, fcc, include, enotify, spamtest, spamtestplus, virustest, foreverypart, mime, extracttext, replace, enclose, mboxmetadata, servermetadata, imapsieve, vnd.yarilo.debug, vnd.yarilo.environment, vnd.yarilo.pipe, vnd.yarilo.filter, vnd.yarilo.report, vnd.yarilo.execute | ✅ |
| Submission | RFC 6409 | STARTTLS, SASL PLAIN, SIZE, PIPELINING, relay to upstream MTA | ✅ |
| SASL | — | PLAIN, LOGIN, SCRAM-SHA-256, XOAUTH2, OAUTHBEARER | ✅ |
| JMAP | RFC 8620/8621 | — | planned |
| Layer | Backend | Status |
|---|---|---|
| Mailbox | Maildir | ✅ |
| Mailbox | sdbox (single-file dbox with GUID metadata) | ✅ |
| Mailbox | mdbox (multi-message dbox, higher density) | ✅ |
| Mailbox | obox (S3-compatible object storage) | planned |
| Index | FileIndex (binary mail-index v7.3 wire format, .index / .index.log / .index.names) |
✅ |
All index mutations go through the cross-process mailbox lock (yarilo-locks). Sessions sharing a pod serialise on an in-process sync.RWMutex — the Redis lock is only ever contested across pods, not within a single pod.
Maildir sync-on-open (storage.maildir_sync_on_select, default true): on SELECT/EXAMINE, STATUS and IDLE the Maildir index is reconciled against cur/ and new/, so a message delivered by an external MDA or moved/renamed by a second MUA appears without an operator rebuild — files in new/ are migrated to cur/, new files gain a UID, vanished files are expunged, and out-of-band flag renames keep their UID. It is gated on a cheap cur/+new/ mtime token, so a quiescent folder costs a single stat. Only Maildir honours it; dbox stays index-authoritative.
dbox reactive rebuild (storage.dbox_reactive_rebuild, default true): dbox is index-authoritative and does not scan storage on every access, but it self-heals reactively. When an sdbox read hits a missing or corrupt message file the folder is flagged (a persisted FSCKD marker in the index header); the next SELECT, STATUS or IDLE poll then heals the index under the mailbox lock — every record whose file has vanished is expunged (with a QRESYNC tombstone), surviving messages keep their UID. Transient I/O errors (EIO, timeouts) do not trigger a heal.
mdbox now self-heals reactively too, the same way as sdbox: a read that trips over a missing/corrupt message flags the folder FSCKD, and the next SELECT/STATUS/IDLE poll (or POP3 login) runs a targeted per-folder heal under the mailbox lock — every index record whose message is no longer present in storage is expunged (QRESYNC tombstone), the rest keep their UID. This is per-folder and does not touch the shared map's refcounts, so it needs none of the quiescence the storage-wide rebuild does. Because it goes through the shared box.Scan, a concurrent purge/altmove (both write the new m.<N> before unlinking the old) makes the scan incomplete and the heal abort-and-retry rather than mistake a just-compacted message for a vanished one — so if a folder stays FSCKD, check whether a purge is running. A structurally corrupt m.<N> record also aborts the heal until an operator moves the bad file aside, and the vanished message's map refcount is not decremented by the heal (a leak the next operator rebuild + purge reclaims).
For the operator side, because mdbox messages are shared across folders through the map, a per-folder rebuild would import unrelated messages — so the per-folder endpoint (/api/backend/index/rebuild) returns 501 for mdbox and there is a dedicated storage-wide rebuild: POST /api/backend/index/rebuild-storage {user} (or yarilo-admin backend index rebuild-storage <user>). It reconciles the shared map against the physical m.<N> files under the storage lock, resets every folder index to the messages that still exist, recomputes each map record's refcount from the actual folder references (Dovecot rebuild_apply_map parity), drops map records whose message vanished, and bumps a persisted rebuild_count generation counter in the map header. It refuses to run when the scan is incomplete (a half-corrupt m.<N> or transient I/O — move/repair the named file and re-run) or when a configured alt tier is unmounted (would mass-expunge alt-resident mail). It is an operator repair tool (Dovecot force-resync parity) and should run with the user's mailboxes quiesced — no concurrent delivery and no concurrent folder operations (CREATE/DELETE/RENAME); neither the delivery folder-append nor the restore is serialised on the storage lock, so a concurrent message or folder change can race the rebuild.
A message present on disk but referenced by no folder becomes zero-ref (reported as unreferenced_zeroref and logged) so the next purge reclaims it — by default it is not re-filed into a mailbox.
Each mdbox message records the mailbox it was saved into (an ORIG_MAILBOX key in the dbox trailer, appended after the GUID/size fields — an older reader ignores the unknown key). With restore_orphans=true (--restore-orphans on the CLI) the rebuild re-files an unreferenced message that carries this tag back into its home folder (created if missing), via the normal append path so vsize/modseq/quota stay correct; untagged records are still left zero-ref. A restored orphan comes back with default flags (unseen, no keywords) — flags live in the fileindex and an orphan has no index record to recover them from (an accepted gap, like the VANISHED-tombstone gap of the reset path). Restore is off by default and per-request, because the tag proves only "was once in this folder", not "is lost" — a delete-with-refcount-leak carries the same tag, so the resurrection decision stays the operator's. A default run (restore_orphans=false) writes and reads the tag but takes no action, so its numbers are identical to a rebuild without the feature.
The rebuild preserves each surviving record's own modseq across the reset (no QRESYNC modseq storm on an operator rebuild); highest_modseq advances to the greatest value carried in, and only a record with no modseq (a freshly assigned UID) is stamped fresh. It also notifies FTS to expunge every UID it drops, per folder — the storage-wide rebuild, the per-folder rebuild, and the reactive heal all return the dropped UIDs so their FTS documents are invalidated immediately instead of lingering as ghost entries until the next fts rescan (the POP3-only reactive heal has no FTS client wired in, so those still rely on the next rescan). One accepted gap remains: the reset still loses per-UID VANISHED tombstone fidelity for dropped records, since it rewrites the whole set.
On-disk m.<N> files follow the dbox v2 layout: the ASCII file-header line (version M<hdr-size> C<create-stamp>) is written once per physical file, before its first message, then each message is [32-byte header][body][trailer]. The reader is self-describing — at each record it tells a file-header line (starts with the ASCII version digit) apart from a raw message header (starts with the \x01\x02 magic) by the first byte, so a real Dovecot instance parses past the first message in a multi-message file, and legacy yarilo stores that stamped the header before every record still read back unchanged (no migration).
Three Dovecot-parity knobs tune when a new m.<N> is rolled and how it is allocated (all under storage:, mdbox only):
| Key | Default | Effect |
|---|---|---|
mdbox_rotate_size |
10485760 (10 MiB) |
Max bytes per m.<N> before the next save rolls to a fresh file. 0 selects the 10 MiB default. |
mdbox_rotate_interval |
0 (disabled) |
Seconds; roll the append file once it is older than this, regardless of size. |
mdbox_preallocate_space |
false |
fallocate() the new file to mdbox_rotate_size up front (Linux only; a no-op elsewhere). |
The age check reads a persisted per-file create-time stored in the map header (not a filesystem btime, which is unreliable over NFS), so it survives restarts. Unlike Dovecot it uses a rolling window (now − createTime > interval) rather than a clock-boundary snap, so "rotate every interval" means the file actually lived at least that long. Preallocation uses FALLOC_FL_KEEP_SIZE so the file's logical size still grows from zero as records are appended — reserving blocks without breaking the offset model — and any failure is a non-fatal hint.
The reactive heal is retry-bounded per folder per session on the IMAP path: a near-continuous purge/altmove keeps every scan incomplete (the heal aborts rather than mistake a compacted message for a vanished one), so after a few consecutive aborts the session stops auto-retrying that folder — each attempt costs a full storage scan — and logs once, pointing the operator at a rebuild. The counter resets on a successful heal or when another session clears the marker. The POP3 path carries no such bound: a POP3 session heals at most once, at login, not in a command loop, so it cannot spin; a rapidly reconnecting client during a purge could still reproduce the storm across logins, but POP3 sessions are short and a cross-login bound would need persistent state — an accepted gap.
| Binary | Role | Status |
|---|---|---|
| yarilo-imap | IMAP4rev2 session server | ✅ |
| yarilo-imap-login | IMAPS / IMAP login proxy — TLS termination, passdb, fd-passing | ✅ |
| yarilo-pop3 | POP3 session server | ✅ |
| yarilo-pop3-login | POP3S / POP3 login proxy | ✅ |
| yarilo-lmtp | LMTP delivery server (Sieve, quota) | ✅ |
| yarilo-lmtp-login | LMTP login proxy — HAProxy, XCLIENT, preamble strip | ✅ |
| yarilo-managesieve | ManageSieve script management server | ✅ |
| yarilo-managesieve-login | ManageSieve login proxy — STARTTLS, HAProxy | ✅ |
| yarilo-submission | Submission relay server | ✅ |
| yarilo-submission-login | Submission login proxy | ✅ |
| yarilo-sasl-login | SASL auth socket for Postfix / Exim relay | ✅ |
| yarilo-auth | Passdb chain, auth cache, SASL dispatch, master userdb | ✅ |
| yarilo-anvil | Connection rate limiting + penalty | ✅ |
| yarilo-locks | Cross-process write coordination — Redis-backed, TCP mTLS | ✅ |
| yarilo-quota-status | Quota policy socket (Postfix quota check) | ✅ |
| yarilo-backend-api | HTTP admin API (dict, ACL, folder, quota, rebuild) | ✅ |
| yarilo-admin | CLI control tool — director and backend planes |
✅ |
| yarilo-monitor | Backend health sidecar for director ring | ✅ |
| yarilo-migrate | Offline mailbox migration (Maildir ↔ dbox ↔ mdbox) | ✅ |
| yarilo-director | Consistent-hashing ring, sticky sessions, failover | planned |
All intra-cluster protocols are TAB-delimited text with LF termination and a version handshake. Full wire-format specification: INTERNALS.md.
# Add the chart (local checkout)
helm upgrade --install yarilo ./helm \
-f helm_values/values-sandbox.yaml \
-n yarilo --create-namespaceSee INSTALL.md for a full Kubernetes walkthrough with cert-manager, Let's Encrypt, and an external MySQL passdb.
Minimal yarilo.yaml for bare-metal single-node:
hostname: mail.example.com
auth:
passdb:
- driver: mysql
dsn: "yarilo:secret@tcp(127.0.0.1:3306)/yarilo"
storage:
persistence:
enabled: true
size: 50Gi
locks:
mode: embedded # single-node only; use remote in k8sLOG_LEVEL=debug yarilo-imap -config yarilo.yamlLOG_LEVEL=debug is per-service — set it on the process whose code path you are tracing (a delivery breadcrumb lives in yarilo-lmtp, a search one in yarilo-imap). At debug level every write path emits an explicit "wrote UID=N file=Q" line (lmtp: delivered, imap: append saved, imapsieve: fileinto saved, sieve/pipe: invoked, sieve/sender: notification sent), and the read side logs what it actually scanned when a lookup comes back empty (imap: search matched no messages with the folder's record count and UID range, imap: fetch skipped uid absent from client view, fileindex: reload applied / reset folder with record counts before/after) — so a delivery→visibility mismatch is diagnosable from the log alone. The full-text search pipeline is instrumented the same way: yarilo-fts logs every request it handles (fts: indexed / expunged / lookup / rescanned / status) and the indexing worker reports each run (fts: index run start / done with checkpoint, indexed/skipped counts and duration, plus per-message fts: message indexed), while the yarilo-imap/yarilo-lmtp sides log the handoff (imap: fts notify sent, lmtp: fts autoindex queued) and how many candidates a search got back (imap: fts search candidates). FTS lines log result and term COUNTS, never the query terms (private mail content).
These lines carry only metadata (user, folder, UID, filename, counts); passwords, tokens, SASL response data, and search query terms are never logged.
Single-host, no Kubernetes — the standalone topology (login proxies + session backends + auth/anvil/locks + Redis) on one host, SQLite userdb, one image:
cd deploy/compose
cp .env.example .env
./gen-certs.sh mail.example.test # self-signed TLS for local use
docker compose up -dFull walkthrough — creating users, TLS, MTA (Postfix) integration, verifying, backups — in docs/DOCKER-COMPOSE.md.
yarilo-migrate \
--from /var/mail/vhosts \
--to /var/mail/dbox \
--format dbox # or mdbox; --dry-run to preview| Document | Contents |
|---|---|
| PLAN.md | Implementation plan, phases, timelines |
| INTERNALS.md | Wire-format specs for all internal protocols |
| ARCHITECTURE.md | Code-level architecture, process model, storage contract |
| docs/DEPLOYMENT.md | K8s topology, sizing, HA strategy, sharding via tags |
| docs/GENERAL.md | general: SSL, HAProxy, XCLIENT, connection limits |
| docs/SERVICES.md | services: per-listener config |
| docs/IMAP.md | protocol.imap: IDLE, line length, ACL, NAMESPACE, NOTIFY, METADATA, OBJECTID |
| docs/NAMESPACE.md | IMAP namespaces (RFC 2342 / 9051): personal / shared / other_users |
| docs/SUBMISSION.md | protocol.submission: hostname, size, relay |
| docs/LMTP.md | protocol.lmtp: delivery, HAProxy, XCLIENT, TLS, headers |
| docs/POP3.md | protocol.pop3: UIDL, soft-delete, migration |
| docs/SIEVE.md | sieve: filtering, ManageSieve, imapsieve, vacation, extensions |
| docs/AUTH.md | auth.passdb: SQL / passwd-file / static backends, password schemes, userdb extra fields |
| docs/QUOTA.md | quota: count-authoritative engine, grace, warnings, mail_size, clone mirror, over-status |
| docs/SMOKE.md | End-to-end smoke test |
| docs/DIRECTOR.md | director_service: ring, peers, HAProxy, XCLIENT, mTLS |
| docs/MONITOR.md | yarilo-monitor: health probes, Prometheus metrics |
| docs/DIRECTOR-API.md | Director HTTP admin API |
| docs/BACKEND-API.md | Backend HTTP admin API |
| docs/YARILO-ADMIN.md | yarilo-admin CLI reference |
| docs/DICT.md | pkg/dict KV-store abstraction: drivers, YAML schema |
GNU Affero General Public License v3.0 — see LICENSE.