A compact email alias & forwarding server written in C11 — give out disposable
alias@domain addresses that forward to your real inbox, and reply through them too. The whole
service — daemon and alias store — fits in a single small, portable APE
binary, configured in typechecked Dhall. The same C core also builds to
WebAssembly and runs fully client-side in your browser.
▶ Try it live — https://jmars.github.io/visage/ (edit a Dhall config and resolve aliases in the browser, powered by the real C pipeline compiled to wasm)
A single self-contained SMTP daemon that accepts mail for the domains it serves, resolves each
recipient against a DAFSA alias store, and forwards accepted mail to your mailbox provider. You
never give out your real address — only disposable aliases, and replies are routed back through a
reply+<token>@yourdomain reverse alias.
Two things stay small as you grow.
The server. One C codebase → one visage.com APE binary (≈2.6 MB) that runs on many OSes
with no VM, runtime, or database process. No framework, no interpreter for the hot path — just the
mail router and its store.
The data store. Aliases live in a datalog-dafsa store
built on a minimal acyclic DAFSA — a graph that shares common prefixes and suffixes across every
key, so the store stays tiny even as you add thousands of aliases. Strings are interned to 32-bit
symbol ids; each relation is one on-disk DAFSA that is mmap'd for read-only serving and durably
WAL'd (single-writer, flock).
Measured (make bench): on-disk store size is ~480 B/alias at 1,000 aliases and ~542 B/alias at
1,000,000 — roughly constant across three decades, with no per-alias index bloat. The footprint
is dominated by the string interner (every distinct address stored once), not per-alias metadata.
See docs/bench-size.svg.
Email routing needs exactly two lookups, and both are native DAFSA primitives — so visage carries no separate index, and lookup cost scales with the address length, not the alias count:
- Every fact is one fixed-width big-endian key; bind the leading columns to constants and enumerate the rest is exactly a byte-prefix walk on the DAFSA.
- Forward routing — resolving
alias@domain→ its destinations is a prefix walk binding the(domain, local)columns of thealiasrelation. - Reverse reply routing —
reply+<token>@domain→ (original sender, alias) is a prefix walk binding thetokencolumn of therevmaprelation.
Prefix enumeration is the DAFSA's second-strongest primitive (after exact-key lookup), so alias resolution and reply-token routing are O(prefix length) regardless of how many aliases exist.
Measured (make bench): warm store_resolve/store_revmap_resolve latency is ~0.6 µs at 1,000
aliases and ~3 µs at 1,000,000 — sub-linear (≈5× over three decades of scale), i.e. the cost
tracks address length, not alias count. See docs/bench-latency.svg.
The wire path is hand-rolled C11, so the hardening is explicit. visage was given a deep wire-path security review before public launch (3 HIGH + 4 MEDIUM findings, all fixed, no CRITICAL). Where it stands:
- Wire memory safety — bounded per-connection buffers on the SMTP and admin paths; a
reply-backlog cap (
SMTP_IN_MAX_OUT256 KB) + TCP backpressure so a never-reading client can't exhaust memory; hard command-length limits. - Reply tokens — 32 hex chars from
/dev/urandom, no weak fallback (fail-closed), expiring after 30 days. Tokens are the reply feature's only credential. - Admin API — constant-time bearer-token comparison;
config-checkrejects tokens that can never authenticate (>500 chars) and warns on weak defaults. - Anti-injection — MAIL FROM and RCPT are validated (printable ASCII, no quote/angle chars) so
forwarded headers can't be broken; no SMTP-envelope CRLF injection to the relay; mail containing
NUL/control bytes is rejected (
554) rather than silently forwarded. - Relay integrity —
starttls-verifydoes mandatory STARTTLS with CA + hostname verification and never falls back to plaintext or sends AUTH over it;starttlsis documented as anti-passive-snooping only. - Availability — inbound SMTP is rate-bounded (512 conns global / 16 per-IP,
421on excess); queue-driven relay sends are batched (8/tick) so a slow relay can't stall the event loop; null reverse-path mail is preserved end-to-end (MAIL FROM:<>) so DSN bounce loops can't ping-pong. - Browser demo — remote
http://Dhall imports are compiled out of the wasm build, so a pasted config can't make your browser probe URLs.
| Piece | Role |
|---|---|
| cosmocc | one small visage.com APE binary, many OSes |
| dhall-c | typechecked Dhall config, evaluated at startup (vendored submodule) |
| datalog-dafsa | compact minimal-DAFSA store: prefix-search lookups, WAL/flock + mmap reads (vendored submodule) |
SMTP-in-C (src/smtp_in.c, src/smtp_out.c) |
RFC 5321 state machine, receiver + relay |
STARTTLS / STARTTLS-verify (vendor/mbedtls) |
relay to your mailbox provider, optionally cert-verified |
DKIM (src/dkim.c) |
sign outbound mail from C |
| durable outbound retry queue | spooled to disk, bounded retries, no lost mail |
| emscripten | the same C core → docs/visage.wasm (client-side demo) |
Requires cosmocc. dhall-c and datalog-dafsa are vendored as git submodules; mbedTLS is
vendored under vendor/.
git submodule update --init --recursive # first checkout: fetch vendor/dhall-c + vendor/datalog-dafsa
make # builds visage.com (APE) + visage.com.dbg (ELF) + all *_check toolsTo use sibling dhall-c/datalog-dafsa checkouts instead of the submodules, build with
make DHALL_C=../dhall-c DATALOG=../datalog-dafsa (likewise scripts/build-wasm.sh honors DHALL_C).
For the browser build (needs emscripten clang lld llvm nodejs):
make wasm # → docs/visage.js + docs/visage.wasm
node tests/wasm-smoke.js # headless smoke test of the wasm module./visage.com daemon -c config.example.dhall
./visage.com config-check -c FILE # validate config and exit
./visage.com add-alias -c FILE --alias A@D --dest X@Y
./visage.com rm-alias -c FILE --alias A@D --dest X@Y
./visage.com log -c FILE [-n N] # recent log entries via the daemonThe config is a Dhall record — aliases, domains, relay, limits, storage, DKIM keys — typechecked
against its schema before the daemon binds a port. See config.example.dhall for a complete example.
{ hostname = "mx.example.com"
, domains = [ "example.com" ]
, listen = { address = "0.0.0.0", port = 2525 }
, relay = { host = "127.0.0.1", port = 2526, tls = "none", … }
, catch_all = ""
, aliases = [ { alias = "jane@example.com", destinations = [ "jane@realmail.example" ] }
, { alias = "shopping@example.com", destinations = [ "jane@realmail.example", "bob@realmail.example" ] }
]
, …
} : Configrelay.tls is none, starttls, or starttls-verify (an empty tls_ca uses the embedded Mozilla
CA bundle).