- Scheme 64.5%
- Shell 15.8%
- Rust 15.5%
- Makefile 2.7%
- Python 1.3%
- Other 0.2%
| .builds | ||
| .jerboa | ||
| bin | ||
| docs | ||
| examples | ||
| fuzz | ||
| lib/jerboa-smtp | ||
| rust | ||
| scripts | ||
| support | ||
| tests | ||
| wasm | ||
| .gitignore | ||
| .jerbuild | ||
| AGENTS.md | ||
| build.ss | ||
| jpkg.lock | ||
| jpkg.policy.sexp | ||
| jpkg.sexp | ||
| LICENSE | ||
| Makefile | ||
| plan.md | ||
| README.md | ||
| SECURITY.md | ||
jerboa-smtp
Security-first SMTP server in Jerboa.
This project is starting from the same core security idea as jerboa-dns:
Jerboa owns policy and orchestration, while hostile protocol bytes are parsed in
small Rust WebAssembly modules with fixed caps and fuel.
Current Status
The first slice is project skeleton plus a sandboxed SMTP command parser:
- Jerboa protocol representation for SMTP commands.
- no_std Rust/WASM command parser with envelope reverse-path/forward-path
parsing and normalization for
MAIL FROMandRCPT TO. - no_std Rust/WASM DATA full-block framer for CRLF, terminator, dot-stuffing, line-limit, and NUL rejection checks.
- Jerboa host wrapper that validates command and envelope metadata returned by the sandbox.
- Pure Jerboa SMTP session state machine for command sequencing, no-relay policy, and explicit local-recipient allowlisting.
- Strict JSON config loader with local-domain, local-recipient, IPv4, and resource-limit validation.
- Durable queue admission API with sharded spool layout, unguessable queue IDs, envelope validation, atomic reserved quota counters, and metadata-as-admission-marker semantics.
- Queue janitor with
janitor --config FILEfor safe temp-file cleanup and orphan message-file removal, including leftover delivery-marker temps. - no_std Rust native file publication helper linked into the standalone binary:
open validated directories, create temp with
openat(O_EXCL), write and fsync bytes,linkatinto the final directory without overwriting, fsync the destination directory, then unlink temp by basename. - DATA-aware session path:
DATAenters a pending state, the complete DATA block is framed in WASM, and accepted messages are queued before250. - Binary-port SMTP listener with
serve --config FILE [--once], configured IPv4 bind address, bounded concurrent workers, global/per-IP shedding, absolute command/DATA/connection deadlines, minimum DATA progress-rate enforcement, DATA collection, and queue handoff. - Local Maildir delivery worker with
deliver --config FILE, configured local-recipient Maildir layout, native final-file publication, delivered markers published through native no-overwrite file operations, and idempotent repeat runs that clean stale incoming files after a delivered marker exists. - Smoke tests for plain parsing, sandboxed command parsing, sandboxed DATA parsing, config, native publication, queue admission, Maildir delivery, server connection handling, and session behavior.
See plan.md for the full implementation plan.
Build And Test
make build
make test
Release evidence:
make release-evidence
The release bundle records SMTP load/slow-client status under
dist/release-evidence/soak/. By default this is a blocked, record-only status;
set JSMTP_RUN_RELEASE_SOAK=1 with production-scale JSMTP_SOAK_SESSIONS to
capture current SMTP session evidence plus local DATA queueing and slow-DATA
rejection smokes. Deployment confinement and STARTTLS/AUTH policy are documented
in docs/deployment-hardening.md and copied into
dist/release-evidence/deployment-policy.txt.
Bounded local coverage-guided fuzz evidence is recorded with:
JSMTP_RUN_COVERAGE_FUZZ=1 JSMTP_FUZZ_RUNS=2048 make fuzz-evidence
When JSMTP_RUN_COVERAGE_FUZZ=1 is set for make release-evidence, the release
bundle also records coverage_fuzz_status=local-smoke-recorded under
dist/release-evidence/fuzz-evidence/ after smtp_command and smtp_data
complete.
Build just the WASM parser:
make wasm
Run the CLI parser smoke:
make run ARGS='parse "EHLO mx.example"'
Run the pure session smoke:
make run ARGS='session "EHLO client.example" "MAIL FROM:<alice@example.net>" "RCPT TO:<bob@example.org>"'
The CLI session smoke uses the default config, which has no local domains, so that recipient is denied as relay by design.
Run the session smoke with validated local-recipient policy:
make run ARGS='session --config examples/jsmtp.json "EHLO client.example" "MAIL FROM:<alice@example.net>" "RCPT TO:<bob@local.test>"'
The command parser requires WASM by default. JSMTP_ALLOW_WASM_COMMAND_FALLBACK=1
enables the in-process Jerboa parser only for development fallback.
Run a single-connection listener:
make run ARGS='serve --config examples/jsmtp.json --once'
Deliver queued local messages once:
make run ARGS='deliver --config examples/jsmtp.json'
Clean queue temp files and orphan message files:
make run ARGS='janitor --config examples/jsmtp.json'
Security Defaults
Production deployments must run as an unprivileged service identity behind
service-manager confinement; see docs/deployment-hardening.md. Command parser
fallback is development-only and requires JSMTP_ALLOW_WASM_COMMAND_FALLBACK=1.
The (std wasm sandbox) loader binds registered static symbols first in the
standalone daemon. Development tests may still use jerbuild's trusted native
library cache explicitly.
DATA can now be framed by the WASM parser and admitted through the queue API, including from the SMTP session state machine and the binary-port listener. Queued messages can be delivered to local Maildir storage by running the delivery worker.
The current DATA parser is a full-block WASM parser with a 64 KiB hard cap.
max_message_bytes defaults to 65536 and larger configured values are
rejected until the DATA path is moved to a streaming WASM parser.
local_recipients is required in config. RCPT accepts only full normalized
addresses in that allowlist; unknown local users are rejected during the SMTP
transaction with 550 No such local recipient.
The listener accepts continuously into bounded workers. max_connections and
max_connections_per_ip shed excess clients with 421; the defaults are 64
and 8. connection_timeout_seconds, command_timeout_seconds,
idle_timeout_seconds, and data_timeout_seconds are absolute deadlines that
activity cannot extend. The default DATA rate floor is 1024 bytes/second after
the configured grace period.
tls_available must remain false until the native rustls STARTTLS helper is
implemented. Configurations that set it to true are rejected instead of
advertising a plaintext placeholder.
auth_available must remain false until authenticated submission is
implemented. Configurations that set it to true are rejected instead of
accepting a nonfunctional AUTH deployment.
Queue publication currently writes the message file before its metadata file; consumers must treat metadata as the admission marker and ignore unmatched message files. The file publication primitive is now native Rust and refuses overwrite; queue and Maildir publication use directory-relative native calls with validated basenames. Local delivery publishes the delivered marker before deleting the incoming message and metadata files, so restart cleanup treats the marker as authoritative.
Quota admission uses a durable, atomically replaced counter under a native
cross-process lock, so normal admission is O(1) and concurrent reservations
cannot exceed the configured cap. Startup and the offline janitor reconcile
the counter from disk. Queue IDs are distributed across 256 two-hex-digit
shards under tmp, incoming, and delivered to avoid single-directory
growth; legacy unsharded queue files remain readable and cleanable.