mailrs-inbound
Composable SMTP receive pipeline framework for Rust mail servers. Define
your checks as Stage implementations, compose them with the
Pipeline builder, and let early-rejection short-circuit the rest of
the chain when one stage decides. The pure make_delivery_decision
policy combines accumulated signals into a final DeliveryDecision.
Extracted from mailrs so any Rust SMTP server can lean on the same multi-stage receive pattern that fronts a production-tested mail infrastructure — without inheriting opinions about which DKIM verifier, which greylist backend, which virus scanner, or which scoring model to use.
This is, at the time of writing, the only standalone SMTP receive pipeline framework on crates.io.
Highlights
- Backend-free — the crate ships zero protocol code. No SPF / DKIM
verifier, no DNSBL lookup, no ClamAV protocol, no LLM provider. Wrap
whichever crate you prefer behind your own
Stageimpl. - Single-method trait — every stage exposes one
async fn evaluate(&self, ctx: &mut ReceiveContext) -> StageOutcome. Trait objects work, composition is cheap, testing is trivial. - Early reject — first
StageOutcome::Decide(_)short-circuits the pipeline. No wasted virus-scan on a greylist-deferred message. - Pure decision combiner —
make_delivery_decisiontakes aPipelineInput(struct of signals) and returns aDeliveryDecision. Pure function, no async, no I/O — call it directly if you have your own orchestration. - RFC 8601 auth-header helpers —
build_auth_header/format_auth_results/AuthResultbuild theAuthentication-Results:header used by every modern mail server.
How it slots together
┌──── stages run in order, mutating ReceiveContext ─────┐
│ │
ReceiveContext ──> [GreylistStage] ──> [DkimStage] ──> [ClamavStage] ──> ...
│ │ │ │ │
│ Continue Continue Decide(...) │
│ (writes signal) (writes signal) (terminal) │
▼ ▼
DeliveryDecision
(Accept / Junk /
Reject / Greylist)
If every stage returns Continue, [Pipeline::run] calls
make_delivery_decision over the accumulated signals to produce the
final decision.
Quick start
use async_trait;
use ;
use ;
// Your own check — wraps whatever backend you like.
# async
Example stage shapes
These are examples, not bundled implementations — you write them in your own server crate.
// SPF / DKIM / DMARC via mail-auth — mailrs's own production stage:
// ClamAV TCP virus scan:
Default decision policy
If every stage returns Continue, the final decision comes from
make_delivery_decision which evaluates signals in this precedence
order (high → low):
- Greylist (highest precedence — defer before any other work).
- Virus found → hard 550 reject.
- DMARC
p=reject→ hard 550 reject. - DMARC
p=quarantine→ Junk. content_score + ptr_score + ai_score >= spam_threshold→ Junk.- Default: Accept.
The function is pure — same input always produces the same output. Use it directly if you'd rather orchestrate the pipeline yourself:
use ;
let input = PipelineInput ;
let decision = make_delivery_decision;
What's NOT in the crate (intentionally)
- No SPF / DKIM / ARC / DMARC verifier — pick your favorite Rust crate.
- No DNS resolver type — stages get whichever resolver they want.
- No virus scanner — ClamAV, rspamd, etc.
- No greylist backend — Redis / Memcached / Postgres / in-memory.
- No LLM / ML scoring provider.
- No DMARC reporting — separate concern.
- No
Authentication-Results:parser (consumer-side reading of headers from prior hops) — the crate emits, doesn't ingest.
These all live as concrete Stage implementations in your own crate.
This keeps the framework dependency-light and free of opinion.
Tested
1.0.0 ships 37 unit tests across 5 modules covering every method:
| Module | Tests | Surface |
|---|---|---|
auth_header |
11 | RFC 8601 formatting — single result, multi-result folding, reasons, temperror / permerror passthrough, build_auth_header quadruple |
decision |
12 | Precedence ordering, every variant, score thresholds, auth-header carried through |
context |
4 | ReceiveContext::new initialization, to_pipeline_input round-trip |
stage |
3 | Trait object shape, NoopStage / AlwaysRejectStage |
pipeline |
7 | Empty pipeline, sequential execution, early-reject short-circuit, signal mutation, custom threshold |
Run with cargo test -p mailrs-inbound.
Performance
benches/pipeline.rs covers the four hot paths the SMTP DATA → response handler hits per message: final-decision policy, Authentication-Results building, ReceiveContext materialization, and Pipeline::run dispatch overhead with no-op stages (real stages dominate any production pipeline; this measures the framework floor).
Measured with criterion 0.8 on Apple Silicon (M-series), cargo bench, release profile.
| Operation | Median | Notes |
|---|---|---|
make_delivery_decision (Accept) |
~360 ns | builds + carries the auth header |
make_delivery_decision (Junk) |
~850 ns | extra format! for the reason string with score breakdown |
make_delivery_decision (DMARC reject) |
~465 ns | reject path still builds auth header for logging |
make_delivery_decision (Greylist) |
~3 ns | early-exit short-circuit |
build_auth_header(no reason) |
~380 ns | one allocation, four short string interpolations |
build_auth_header(with reason) |
~425 ns | + reason parenthetical |
format_auth_results_header(quadruple) |
~280 ns | flatten 4 AuthResults into one header line |
ReceiveContext::to_pipeline_input(5.0) |
~200 ns | clones AuthResults + matched_rules + hostname |
Pipeline::run (4 no-op stages + decide) |
~600 ns | full async dispatch path |
Pipeline::run (4 noop + 2 scoring + decide) |
~635 ns | realistic stage mix |
Pipeline::run (early-reject after 2 stages) |
~185 ns | short-circuit on StageOutcome::Decide |
Run with cargo bench -p mailrs-inbound. See tests/perf_gate.rs for the regression budgets — Pipeline::run dispatch is gated at 100 µs, with plenty of headroom over the ~600 ns measurement above.
Versioning
1.x follows semver. The stable public surface:
Stagetrait method signaturesPipeline+PipelineBuildermethod signaturesReceiveContext(marked#[non_exhaustive]so we can add signal fields in minor versions without breaking destructure patterns)DeliveryDecision,AuthResults,DmarcPolicy,StageOutcomeenum variantsPipelineInputstruct shape +make_delivery_decisionsignature- All public functions in
auth_header::*
The default policy in make_delivery_decision may evolve within 1.x if
the precedence rules need tightening; consumers who want to lock it in
should compute their own final decision from the signals.
License
Licensed under either Apache License, Version 2.0 or MIT license at your option.