mailrs-inbound 1.0.2

Composable SMTP receive pipeline framework — Stage trait + early-reject executor + pure decision logic + RFC 8601 Authentication-Results helpers. Framework-only; bring your own greylist / DKIM / virus-scan / scoring stages.
Documentation

mailrs-inbound

Crates.io docs.rs License Downloads

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 Stage impl.
  • 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 combinermake_delivery_decision takes a PipelineInput (struct of signals) and returns a DeliveryDecision. Pure function, no async, no I/O — call it directly if you have your own orchestration.
  • RFC 8601 auth-header helpersbuild_auth_header / format_auth_results / AuthResult build the Authentication-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::async_trait;
use mailrs_inbound::{Pipeline, ReceiveContext, Stage, StageOutcome};
use std::net::{IpAddr, Ipv4Addr};

// Your own check — wraps whatever backend you like.
struct GreylistStage { /* your greylist db, config, ... */ }

#[async_trait]
impl Stage for GreylistStage {
    fn name(&self) -> &str { "greylist" }
    async fn evaluate(&self, ctx: &mut ReceiveContext) -> StageOutcome {
        // ... look up the (ip, sender, recipient) triplet in your store ...
        // if first time: return StageOutcome::Decide(DeliveryDecision::Greylist);
        // otherwise:     mark ctx.greylisted = false and return Continue
        StageOutcome::Continue
    }
}

# async fn run() {
let pipeline = Pipeline::builder()
    .add(GreylistStage { /* ... */ })
    // ... more stages: SPF check, DKIM verify, ClamAV scan, content scoring, etc.
    .spam_threshold(8.0)
    .build();

let mut ctx = ReceiveContext::new(
    IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)),
    "client.example.com",
    "alice@example.com",
    "bob@example.com",
    b"From: alice...".to_vec(),
    "mx.example.com",
);

let decision = pipeline.run(&mut ctx).await;
// match decision { ... handle Accept / Junk / Reject / Greylist }
# let _ = decision;
# }

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:
struct MailAuthStage {
    authenticator: Arc<MessageAuthenticator>,
    hostname: String,
}

#[async_trait]
impl Stage for MailAuthStage {
    fn name(&self) -> &str { "mail_auth" }
    async fn evaluate(&self, ctx: &mut ReceiveContext) -> StageOutcome {
        let spf_params = SpfParameters::verify_mail_from(
            ctx.client_ip, &ctx.ehlo_domain, &self.hostname, &ctx.sender,
        );
        let spf_output = self.authenticator.verify_spf(spf_params).await;
        ctx.auth_results.spf = spf_token(spf_output.result());
        // ... DKIM + ARC + DMARC ...
        StageOutcome::Continue
    }
}

// ClamAV TCP virus scan:
struct ClamavStage { addr: String }
#[async_trait]
impl Stage for ClamavStage {
    fn name(&self) -> &str { "clamav" }
    async fn evaluate(&self, ctx: &mut ReceiveContext) -> StageOutcome {
        if let Some(sig) = scan(&self.addr, &ctx.message).await {
            ctx.virus_found = Some(sig);
            // Don't decide here — let make_delivery_decision turn it into a Reject.
        }
        StageOutcome::Continue
    }
}

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):

  1. Greylist (highest precedence — defer before any other work).
  2. Virus found → hard 550 reject.
  3. DMARC p=reject → hard 550 reject.
  4. DMARC p=quarantine → Junk.
  5. content_score + ptr_score + ai_score >= spam_threshold → Junk.
  6. 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 mailrs_inbound::{
    make_delivery_decision, AuthResults, DmarcPolicy, PipelineInput,
};

let input = PipelineInput {
    greylisted: false,
    auth: AuthResults {
        spf: "pass".into(),
        dkim: "pass".into(),
        arc: "none".into(),
        dmarc: "pass".into(),
        dmarc_policy: DmarcPolicy::Pass,
    },
    virus_found: None,
    content_score: 1.5,
    matched_rules: vec![],
    ptr_score: 0.0,
    ai_score: 0.5,
    spam_threshold: 5.0,
    hostname: "mx.example.com".into(),
};

let decision = make_delivery_decision(&input);

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.

Versioning

1.x follows semver. The stable public surface:

  • Stage trait method signatures
  • Pipeline + PipelineBuilder method signatures
  • ReceiveContext (marked #[non_exhaustive] so we can add signal fields in minor versions without breaking destructure patterns)
  • DeliveryDecision, AuthResults, DmarcPolicy, StageOutcome enum variants
  • PipelineInput struct shape + make_delivery_decision signature
  • 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.