SMTP

Crate hopf-smtp: SMTP server and callback-driven client, a port of Gumdrop org.bluezoo.gumdrop.smtp. The server speaks RFC 5321 with a wide ESMTP extension set on the Hopf reactor. Authentication uses the full hopf-auth SASL mechanism set (PLAIN/LOGIN/CRAM-MD5/DIGEST-MD5/SCRAM-SHA-256/OAUTHBEARER/EXTERNAL) via a CredentialStore, same pattern as POP3/IMAP. TLS-from-accept, implicit TLS, and STARTTLS via SmtpConfig::with_tls / implicit_tls and SharedTlsAcceptor / SharedTlsConnector from hopf-core::tls. Stock services: accept-all, open-relay MX (hopf-dns), and local mailbox delivery (hopf-mailbox).

Local delivery lives in hopf_smtp::mailbox as LocalDeliveryService — the Gumdrop-shaped counterpart to SimpleRelayService.

Standards

Reference Role
RFC 5321 SMTP base, DATA, replies, multiline
RFC 5321 §4.5.2 Dot-stuffing / unstuffing (DotUnstuffer)
RFC 2034 Enhanced status codes
RFC 3030 BDAT / CHUNKING / BINARYMIME
RFC 6152 8BITMIME BODY=
RFC 3461 DSN RET / ENVID / NOTIFY / ORCPT
RFC 2852 DELIVERBY (BY=)
RFC 8689 REQUIRETLS
— SMTPUTF8, PIPELINING, SIZE, STARTTLS, AUTH (full SASL mechanism set), FUTURERELEASE, MT-PRIORITY

Architecture

Client ──TCP──► SmtpControlHandler
                   │ HELO/EHLO → HelloHandler
                   │ AUTH → CredentialStore, full SASL set (optional)
                   │ MAIL/RCPT → staged SPI
                   │ DATA/BDAT → MessageDataHandler (streamed)
                   │
                   └── optional SmtpPipeline per transaction

Protocol stages

Each connection advances through a staged SPI (Gumdrop-style), not a single monolithic callback:

  1. SmtpClientConnected — accept/reject on connect
  2. HelloHandler — HELO/EHLO, STARTTLS notice, AUTH result
  3. MailFromHandler — MAIL FROM (+ optional SmtpPipeline)
  4. RecipientHandler — RCPT TO, start DATA/BDAT
  5. MessageDataHandler — content chunks + complete/abort

Non-blocking DATA

Message content is streamed: DotUnstuffer handles RFC 5321 dot-unstuffing incrementally; BdatAccumulator handles CHUNKING. Handlers receive chunks without requiring the full message in one buffer (subject to max_message_size).

Commands

Command Behaviour
HELO / EHLO Greeting; EHLO lists extensions
MAIL Start transaction (+ params)
RCPT Add recipient (+ DSN params)
DATA Dot-stuffed body until CRLF.CRLF
BDAT Binary chunks (CHUNKING)
RSET Reset transaction
QUIT Close
NOOP Idle
HELP Help text
VRFY Always 252 (cannot verify)
EXPN 502 not implemented
ETRN 458 unsuitable
STARTTLS Upgrade when acceptor configured
AUTH Full SASL mechanism set when a store is configured, filtered by TLS requirement
XCLIENT Postfix extension; enabled only for CIDRs in SmtpConfig::xclient_allow (default empty = off). Overrides effective peer/local/HELO; LOGIN is informational only

EHLO advertisements

Always (capability-dependent):

Conditional:

MAIL / RCPT parameters

MAIL FROM parsed: SIZE, BODY, SMTPUTF8, REQUIRETLS, MT-PRIORITY, RET, ENVID, HOLDFOR, HOLDUNTIL, BY.

RCPT TO parsed: NOTIFY, ORCPT.

Parsed values populate DeliveryRequirements, DsnRecipientParams, etc., for handler code.

TLS

Mode How
Cleartext No acceptor
STARTTLS SmtpConfig::with_tls(acceptor) — listener uses starttls acceptor
Implicit SMTPS implicit_tls() / implicit_tls = true — TLS-from-accept

AUTH is only advertised once TLS is up for mechanisms that require it (PLAIN, LOGIN, OAUTHBEARER, EXTERNAL); TLS-independent mechanisms (CRAM-MD5, DIGEST-MD5, SCRAM-SHA-256) are advertised as soon as a store is configured, even pre-TLS.

Authentication

use hopf_auth::PasswordStore;
use hopf_smtp::{SmtpConfig, SmtpService};

let store = Arc::new(PasswordStore::new().with_user("alice", "secret"));
let config = SmtpConfig::new(addr, "mail.example".into())
    .with_tls(acceptor)
    .with_store(store)
    .auth_required(true);

Handler SPI

pub trait SmtpHandlerFactory: Send + Sync {
    fn create(&self) -> Box<dyn SmtpClientConnected>;
}

pub trait SmtpClientConnected: Send {
    fn connected(&mut self, state: &mut dyn ConnectedState, meta: &SmtpConnectionMetadata);
    fn disconnected(&mut self);
}

pub trait HelloHandler: Send {
    fn hello(&mut self, state: &mut dyn HelloState, extended: bool, hostname: &str);
    fn tls_established(&mut self, info: &hopf_core::SecurityInfo);
    fn authenticated(&mut self, state: &mut dyn AuthenticateState, user: &str);
    fn quit(&mut self);
}

pub trait MailFromHandler: Send {
    fn pipeline(&mut self) -> Option<Box<dyn SmtpPipeline>> { None }
    fn mail_from(
        &mut self,
        state: &mut dyn MailFromState,
        sender: Option<&EmailAddress>,
        smtputf8: bool,
        delivery: &DeliveryRequirements,
    );
    fn reset(&mut self, state: &mut dyn ResetState);
    fn quit(&mut self);
}

pub trait RecipientHandler: Send {
    fn rcpt_to(
        &mut self,
        state: &mut dyn RecipientState,
        recipient: &EmailAddress,
        dsn: &DsnRecipientParams,
    );
    fn start_message(&mut self, state: &mut dyn MessageStartState);
    fn reset(&mut self, state: &mut dyn ResetState);
    fn quit(&mut self);
}

pub trait MessageDataHandler: Send {
    fn message_content(&mut self, chunk: &[u8]);
    fn message_complete(&mut self, state: &mut dyn MessageEndState);
    fn message_aborted(&mut self);
}

Stock handlers

Type Role
AcceptAllSmtpHandler (+Factory) Accept and discard (optional capture)
SimpleRelayHandler (+Factory) Open MX relay via DNS + SmtpClient (dev only)
LocalDeliveryHandler (+Factory) APPEND to local INBOX via hopf-mailbox

SmtpConnectionMetadata: peer, local, tls, authenticated_user, smtputf8, control_handle, security_info. The last is a hopf_core::SecurityInfo snapshot (cipher suite, protocol version, and the peer’s certificate chain when mTLS is in use) taken at connect time and refreshed after STARTTLS — also passed directly to HelloHandler::tls_established.

State traits (ConnectedState, HelloState, MailFromState, …) expose accept/reject and deferred-delivery hooks (DeferredDelivery).

Pipelines

Optional per-transaction SmtpPipeline:

Type Role
SmtpPipeline Trait for transaction hooks
NullPipeline No-op
DiscardPipeline Discard-oriented
MessageBufferPipeline General-purpose in-memory buffer, for apps that want the whole message at once
AuthPipeline SPF/DKIM/DMARC — see Email authentication

Return a pipeline from MailFromHandler::pipeline(). The stock LocalDeliveryHandler and SimpleRelayHandler (below) use an internal streaming pipeline rather than MessageBufferPipeline, so neither one ever holds a whole message in memory.

Email authentication (SPF/DKIM/DMARC/ARC)

hopf_smtp::auth is a from-scratch port of Gumdrop's org.bluezoo.gumdrop.smtp.auth package: SPF (RFC 7208), DKIM verify + sign (RFC 6376, Ed25519 per RFC 8463), and DMARC (RFC 7489), plus a Public Suffix List for organizational-domain resolution and DMARC aggregate/forensic report rendering. See the conformance audit for the full RFC-by-RFC breakdown, including the couple of deliberately-deferred DMARCbis extras.

AuthPipeline implements SmtpPipeline and can be returned from MailFromHandler::pipeline(). SPF starts at MAIL FROM; DKIM verification and DMARC evaluation start once the message finishes at end-of-DATA. All three depend on DNS, so none of them complete synchronously — observe results via the on_spf/on_dkim/on_dmarc callbacks and/or a cloneable, pollable AuthVerdictHandle:

use hopf_smtp::{AuthPipeline, MessageEndState};

// MailFromHandler::pipeline():
fn pipeline(&mut self) -> Option<Box<dyn SmtpPipeline>> {
    let p = AuthPipeline::builder(dns.clone(), client_ip, helo_domain.clone())
        .on_dmarc(|outcome| log::info!("dmarc: {:?}", outcome.verdict))
        .build();
    self.verdict = Some(p.verdict()); // stash the handle for message_complete
    Some(Box::new(p))
}

// MessageDataHandler::message_complete(): AuthPipeline owns all message
// content when it's the registered pipeline, so this handler's own
// message_content() is never called.
fn message_complete(&mut self, state: &mut dyn MessageEndState) {
    let verdict = self.verdict.take().unwrap();
    match verdict.poll() {
        Some(AuthVerdict::Reject) => state.reject_message_policy("5.7.1 DMARC", Box::new(self.clone())),
        Some(_) => state.accept_message_delivery(None, Box::new(self.clone())),
        None => {
            // DNS hasn't resolved yet (the common case) — defer the reply.
            let deferred = state.defer(Box::new(self.clone()));
            verdict.on_ready(move |v| match v {
                AuthVerdict::Reject => deferred.reject(550, "5.7.1 DMARC"),
                _ => deferred.accept(None),
            });
        }
    }
}

This deferred-reply path — proven in hopf-smtp's integration tests against a real loopback DNS stub and the real control-handler state machine — is the normal case, not an edge case: message_complete() always runs synchronously right after end_data() fires off DNS lookups, so the verdict is essentially never ready yet when the app has to decide.

DkimSigner (outbound) and the standalone SpfResult/DkimResult/DmarcResult validators are also usable directly, without the pipeline, for apps that want to check or sign a message themselves. DmarcAggregateReport/DmarcForensicReport render the RFC 7489 Appendix C XML and RFC 5965 ARF documents respectively — sending/scheduling them is left to the app, matching Gumdrop.

AuthPipeline memory model

Only message headers are ever buffered in full (typically a few KB, bounded regardless of message size). The body — what actually dominates memory for large mail — is never retained: each message_content chunk streams straight into one incremental SHA-256 canonicalizer per distinct DKIM body canonicalization the message's signature(s) actually use (almost always 0 or 1), so peak AuthPipeline memory is O(headers), not O(message size) — matching the streaming design used throughout hopf-mailbox/hopf-smtp delivery (see Mailbox).

Authentication-Results header

AuthPipelineBuilder::authentication_results(authserv_id) opts in to synthesizing an RFC 8601 Authentication-Results header field once SPF/DKIM/DMARC finish; default is no synthesis (unchanged behaviour). Fetch the rendered field via AuthPipeline::authentication_results() — a cloneable, pollable AuthResultsHandle with the same poll()/on_ready() shape as AuthVerdictHandle:

let p = AuthPipeline::builder(dns.clone(), client_ip, helo_domain.clone())
    .authentication_results("mail.example.com")
    .build();
self.auth_results = p.authentication_results(); // stash alongside p.verdict()

AuthPipeline never inserts the header into the message itself. It doesn't rewrite bytes flowing through message_handler's inner tee (e.g. a spool file) — and by the time the header is ready (after end-of-DATA, DNS-bound) that tee has typically already streamed the whole message onward. Apply the rendered field yourself wherever your own message_complete/delivery logic already has a chance to touch the stored message — e.g. prepend it to a spool file before streaming that file onward for delivery — the same place you'd already be consulting AuthVerdictHandle. This also keeps the header out of DKIM's own signed-header set, since a receiver-added Authentication-Results header must never be part of what a signature covers.

ARC (RFC 8617)

hopf_smtp::auth::arc covers both roles a deployment can play in an Authenticated Received Chain. All three builder options below imply chain validation at end-of-DATA; results are available from AuthPipeline::arc_result() (an ArcResultHandle) and, when authentication_results(..) is enabled, as an arc= result.

Relay

use hopf_smtp::SimpleRelayService;

let relay = SimpleRelayService::new(config, Arc::clone(&rt))?;
// optional: .with_dns_timeout(Duration::from_secs(5))
// optional: .with_resolver(resolver, outbound_port)  // default outbound 25
let bound = relay.start(rt)?;

Resolves recipient MX with hopf-dns and forwards with SmtpClient. Intended for development — open relay, incomplete STARTTLS to arbitrary MX (may bounce).

Streaming, not buffering: since RCPT TO is always fully known before DATA/BDAT starts, SimpleRelayHandler resolves every recipient domain's MX up front and opens one outbound SmtpClient connection per domain before the inbound message body arrives. Inbound message bytes are written to a small bounded per-transaction temp file as they stream in (never a growing in-memory buffer), and each outbound connection streams its DATA directly from that file via SmtpSend::message_file — the file is deleted once every domain has finished. If any destination domain fails, the whole inbound transaction is rejected with a 4xx even if other domains already succeeded: there is no cross-domain retry bookkeeping, so a rejected client retry may cause duplicate delivery to the domains that already succeeded. This trades a small chance of duplicate delivery for not having to build spool/custody infrastructure — a deliberate simplification for this example relay, not something a production MTA would do.

Local delivery

Gumdrop LocalDeliveryService / LocalDeliveryHandler: accept mail only for one local domain; APPEND each message to the recipient's INBOX on the Runtime storage pool.

use hopf_mailbox::MaildirFactory;
use hopf_smtp::{LocalDeliveryService, SmtpConfig};

let factory = Arc::new(MaildirFactory::new("/var/mail"));
let config = SmtpConfig::new(listen, "mail.example.com");
let svc = LocalDeliveryService::new(
    config, Arc::clone(&rt), factory, "example.com",
);
let bound = svc.start(rt)?;
Property Type Required Semantics
local_domain String yes Only RCPT TO in this domain (case-insensitive); else 551 relay denied
hostname from SmtpConfig yes Greeting / EHLO banner (… ESMTP Service ready)
mailbox_factory Arc<dyn MailboxFactory> yes MaildirFactory or MboxFactory
max_message_size / max_recipients / AUTH / TLS SmtpConfig no Same as other SMTP services

Username is the RCPT local-part. Delivery opens INBOX, APPENDs on StorageExecutor, and replies 250 or 450. See Mailbox and examples/smtp-local.

Inbound message bytes are streamed to a small bounded per-transaction temp file as they arrive rather than an in-memory buffer; at message_complete that file is streamed into each recipient's INBOX in fixed-size chunks via the Mailbox streaming append API and then removed — memory use stays flat regardless of message size.

Configuration

SmtpConfig

Property Type Default Notes
listen SocketAddr caller Typical :25 / demo :2525
hostname String caller Used in greetings
max_message_size u64 35 * 1024 * 1024 EHLO SIZE; DATA/BDAT cap
max_recipients usize 100 Per transaction
auth_required bool false MAIL needs AUTH
tls_acceptor Option<SharedTlsAcceptor> None STARTTLS / implicit
implicit_tls bool false SMTPS from first byte
store Option<Arc<dyn CredentialStore>> None AUTH — full SASL mechanism set

Constants: DEFAULT_MAX_MESSAGE_SIZE = 35 MiB; DEFAULT_MAX_RECIPIENTS = 100.

Builders: SmtpConfig::new(listen, hostname), .with_tls, .implicit_tls(), .auth_required(bool), .with_store.

SmtpService

Method Notes
new(config) Accept-all factory
with_handler_factory(config, factory) Custom SPI
control_listener(runtime) TcpListenerConfig
start(runtime) Bind + register
metrics() SmtpServerMetrics

Client

Callback-driven SmtpClient on an Arc<Runtime>. DNS (when needed) and the SMTP session run on worker reactors; connect returns immediately. Stock auto-pilot pipeline: SmtpSend (greeting → EHLO → optional STARTTLS/AUTH → MAIL → RCPT → DATA → QUIT).

SmtpClientTimeouts

Property Type Default Notes
dns duration 5s Hostname resolve budget (ignored for literal IPs / from_addr)
connect duration 30s Dial → greeting
stage duration 60s Per-reply idle after each command
message duration 600s Post-DATA-end budget

SmtpClient

Builder Notes
SmtpClient::new(host, port) Resolve hostname via hopf-dns, then dial
SmtpClient::from_addr(addr) Skip DNS; dial a resolved SocketAddr
.timeouts(SmtpClientTimeouts) Override defaults
.starttls(connector, server_name) Explicit STARTTLS after greeting
.implicit_tls(connector, server_name) SMTPS — TLS from first byte
.resolver(Arc<DnsResolver>) Reuse a resolver
.connect(&Arc<Runtime>, factory) Schedule DNS/dial; returns immediately

factory is an Arc<dyn SmtpClientHandlerFactory>. SmtpSend implements that trait for one-shot delivery.

A dial that never produces a connection — DNS failure, no addresses, a connect that cannot start — reaches SmtpClientHandlerFactory::connect_failed(host, error), which prints to stderr unless overridden; SmtpSend overrides it to report SmtpSendOutcome::Failed (retryable, no reply code) to on_result and false to on_complete.

use std::sync::Arc;
use std::time::Duration;
use hopf_core::{Runtime, RuntimeConfig};
use hopf_smtp::{SmtpClient, SmtpClientTimeouts, SmtpSend};

let rt = Arc::new(Runtime::start(RuntimeConfig::default())?);
let send = SmtpSend::new("smtp-send.local")
    .mail_from("alice@example.com")
    .rcpt_to("bob@example.com")
    .message(b"Subject: hi\r\n\r\nHello\r\n".to_vec())
    .on_complete(Box::new(|ok| eprintln!("delivery: {ok}")))
;
SmtpClient::from_addr(addr)
    .timeouts(SmtpClientTimeouts {
        connect: Duration::from_secs(10),
        stage: Duration::from_secs(10),
        message: Duration::from_secs(30),
        ..Default::default()
    })
    .connect(&rt, Arc::new(send))?;

SmtpSend builders: mail_from, rcpt_to / recipients, message (in-memory Vec<u8>) or message_file(path) (stream DATA straight from a file, dot-stuffing on the fly, without ever materializing the whole body in memory), require_starttls, auth_plain, on_complete. Custom drivers implement SmtpClientHandlerFactory / SmtpClientDriver. Helpers: dot_stuff (whole-buffer) and DotStuffer (streaming, chunk-at-a-time — used internally by message_file).

Metrics

Process-local SmtpServerMetrics (in hopf-smtp): connections, messages, bytes, auth_ok / auth_fail, starttls counts via SmtpService::metrics().

OTLP/JSONL export (when SmtpService::with_telemetry(&pipeline) is used): hopf_otel::SmtpServerMetrics emits smtp.server.connections, smtp.server.messages (outcome), smtp.server.transaction.duration (ms, MAIL→DATA/RSET), smtp.server.message.size, smtp.server.auth, smtp.server.starttls, and smtp.server.active_connections.

With traces enabled on the pipeline, SmtpConnectionMetadata::traceparent carries the active W3C traceparent for the connection or current transaction. Handler SPI implementers can pass it to outbound HTTP with hopf_otel::with_traceparent. Connection duration is not duplicated on metadata — use telemetry Accept→Close timestamps.

Examples

See Cookbook: SMTP.

# Accept-all cleartext
cargo run -p smtp-server -- 127.0.0.1:2525 localhost

# Send one message
SMTP_SUBJECT=Hi SMTP_BODY=Hello \
  cargo run -p smtp-send -- 127.0.0.1:2525 from@ex to@ex
Package Demonstrates Knobs
examples/smtp (smtp-server) Accept-all addr, hostname
examples/smtp-local Local delivery → Maildir++ addr, hostname, local_domain, mail_root
examples/smtp-send Async SmtpClient + SmtpSend addr, from, to; SMTP_SUBJECT / SMTP_BODY

Limitations