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.
Contents
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:
SmtpClientConnected— accept/reject on connectHelloHandler— HELO/EHLO, STARTTLS notice, AUTH resultMailFromHandler— MAIL FROM (+ optionalSmtpPipeline)RecipientHandler— RCPT TO, start DATA/BDATMessageDataHandler— 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 |
| 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):
- SIZE (value =
max_message_size) - PIPELINING
- 8BITMIME
- SMTPUTF8
- ENHANCEDSTATUSCODES
- CHUNKING
- BINARYMIME
- DSN
- HELP
Conditional:
- STARTTLS — when
tls_acceptoris set and the session is not yet TLS - AUTH <mechanisms> — when
storeis set; lists each mechanismstore.supported_mechanisms()returns, filtered to drop TLS-only mechanisms (PLAIN, LOGIN, OAUTHBEARER, EXTERNAL) until the session is TLS-protected
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);auth_required— reject MAIL until AUTH succeeds.- Mechanisms: whatever the store advertises via
supported_mechanisms()—PasswordStoreoffers PLAIN, LOGIN, SCRAM-SHA-256, OAUTHBEARER, EXTERNAL, plus DIGEST-MD5 when enrolled withwith_digest_realm(not CRAM-MD5). Same pattern as POP3/IMAP.
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.
- Validator -
arc_validation()/on_arc(cb), or callarc::validatedirectly. Groups theARC-*headers by instance, checkscv=sequencing, verifies the newestARC-Message-Signatureand everyARC-Sealusing the same DNS key lookup as DKIM. - Sealer -
arc_sealer(Arc<ArcSealer>).ArcSealer::new(key, domain, selector, authserv_id)takes the sameDkimPrivateKeyDKIM signing uses. Once SPF, DKIM, DMARC and validation are known,AuthPipeline::arc_seal()resolves to the three headers (ArcSetHeaders::to_prepend()); as withAuthentication-Results, applying them to the forwarded message is the caller's job. A malformed or already-failed incoming chain is never extended. - ARC-aware DMARC -
arc_dmarc_policy(Arc<dyn ArcDmarcPolicy>). After validation the policy is handed the chain and this hop's own results and may return anArcAuthSnapshotwhose SPF/DKIM results DMARC evaluates instead. hopf ships no trust list: deciding which sealers to believe is the policy's job, andArcSet::sealer_domain()/recorded_results()give it what it needs.
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
- Local delivery is final APPEND only (no outbound queue/retry); wrong-domain RCPT is relay-denied.
- Relay is open MX only; STARTTLS to arbitrary MX is incomplete.
- EXPN / ETRN not useful; VRFY always
252. - SPF's
%{p}macro always resolves to"unknown"rather than doing the discouraged PTR-then-forward-confirm lookup; DMARCbisnp=is parsed but not enforced,psd=isn't parsed (see conformance). - Client completion is callback-driven — callers wait on their own signalling (see
examples/smtp-send).