POP3
hopf-pop3 is a POP3 / POP3S server and async client (Gumdrop pop3 port). Use Pop3Fetch for auto-pilot message retrieval or implement Pop3ClientDriver for custom pipelines.
Features
| Area | Support |
|---|---|
| Commands | USER/PASS, APOP, AUTH (SASL), STAT, LIST, RETR, DELE, RSET, TOP, UIDL, CAPA, NOOP, QUIT, STLS, UTF8 |
| Auth | CredentialStore — USER/PASS, APOP, PLAIN, LOGIN, CRAM-MD5, DIGEST-MD5, SCRAM-SHA-256, OAUTHBEARER, EXTERNAL |
| TLS | STLS and implicit POP3S |
| Storage | MailboxFactory → open INBOX; open/read/close on the storage pool |
| Deletion | Session marks until QUIT close(true); RSET clears; disconnect discards |
Control commands are lexed incrementally by Pop3ServerLexer (KEYWORD [SP TEXT] CRLF) — a small self-contained state machine that consumes every byte it's given and owns its own bounded verb/arg scratch buffers, never requiring the caller to retain or re-supply anything. Not “buffer until CRLF then re-parse”.
Handler SPI
Gumdrop-style staged traits:
ClientConnected→ greeting (Pop3ConnectionMetadata, including optionaltraceparent)AuthorizationHandler→ post-credential policy (proceed_openopens INBOX)TransactionHandler→ STAT/LIST/RETR/DELE/RSET/TOP/UIDL/QUIT
State traits constrain which replies are legal at each stage. The default handler (DefaultPop3Handler) uses the mailbox API and delegates open/RETR/TOP/QUIT I/O to the protocol via proceed_*.
RETR and TOP both read the message via the Mailbox streaming read triad rather than pulling the whole message into memory first. TOP additionally stops reading as soon as it has collected the requested number of body lines, instead of reading (and discarding) the rest of a potentially large message.
Quick start
use std::sync::Arc;
use hopf_auth::PasswordStore;
use hopf_core::{Runtime, RuntimeConfig};
use hopf_mailbox::MaildirFactory;
use hopf_pop3::{Pop3Config, Pop3Service};
let rt = Arc::new(Runtime::start(RuntimeConfig::default())?);
let factory = Arc::new(MaildirFactory::new("./mail"));
let store = Arc::new(PasswordStore::new().with_user("alice", "secret"));
let config = Pop3Config::new("127.0.0.1:1110".parse()?, "localhost", store, factory);
let svc = Pop3Service::new(config, Arc::clone(&rt));
svc.start()?;
Example binary: cargo run -p pop3 -- 127.0.0.1:1110 localhost ./mail.
Composition
Register a "pop3" handler factory on CompositionRegistry that builds a Pop3ControlHandler (or use Pop3Service::start programmatically).
Client
The hopf-pop3 crate ships a full async client. The high-level Pop3Fetch auto-pilot handles CAPA negotiation, optional STLS upgrade, USER/PASS or APOP authentication, STAT + RETR for every message, optional DELE, and QUIT.
Pop3Fetch quick start
use std::sync::Arc;
use hopf_core::{Runtime, RuntimeConfig};
use hopf_pop3::client::{Pop3Client, Pop3Fetch};
let rt = Arc::new(Runtime::start(RuntimeConfig::default())?);
Pop3Client::new("mail.example.com", 110)
.connect(
&rt,
Arc::new(
Pop3Fetch::new()
.credentials("alice".into(), "secret".into())
.on_message(Box::new(|id, uid, body| {
println!("message {id} uid={uid:?} ({} bytes)", body.len());
}))
.on_complete(Box::new(|ok| {
println!("fetch complete: {ok}");
})),
),
)?;
For full control implement Pop3ClientDriver: each callback receives the staged state reference (Pop3ClientAuthorization → Pop3ClientPassword / Pop3ClientAuthExchange → Pop3ClientTransaction) whose methods issue the commands that are legal in that state. Commands that do not originate in a reply reach a live session through Pop3ClientDriver::on_wake: it runs at the start of every receive, so in particular after a hopf_core::ConnHandle::poke() from any thread, and is handed a Pop3ClientWakeState — Authorization or Transaction when no command is in flight, Busy otherwise (POP3 is strictly one command at a time). Whatever the driver issues is flushed when it returns. Queue work somewhere the driver can see it, poke the ConnHandle stashed from an earlier callback, and drain the queue in on_wake, putting back anything the current state does not allow.
Client options
A dial that never produces a connection — DNS failure, no addresses, a connect that cannot start — reaches Pop3ClientHandlerFactory::connect_failed(host, error), which prints to stderr unless overridden; Pop3Fetch overrides it to call its on_complete(false).
| Builder method | Default | Description |
|---|---|---|
Pop3Client::new(host, port) |
— | Plain TCP; DNS-resolved |
.implicit_tls(connector, name) |
off | POP3S (port 995) |
.stls(connector, name) |
off | Opportunistic STLS upgrade |
.timeouts(Pop3ClientTimeouts) |
dns=5s, connect=30s, stage=60s, msg=600s | Per-phase deadlines |
Pop3Fetch::credentials(user, pass) |
— | USER/PASS or APOP |
.prefer_apop(true) |
false | Use APOP when server offers timestamp |
.delete_after_fetch(true) |
false | DELE each message after RETR |
.require_stls(true) |
false | Abort if STLS unavailable |
Example binary: cargo run -p pop3-fetch -- mail.example.com alice secret.
Metrics
Process-local Pop3ServerMetrics (in hopf-pop3): connections, auth_ok / auth_fail, retr, dele, and stls via Pop3Service::metrics().
OTLP/JSONL export (when Pop3Service::with_telemetry(&pipeline) is used): hopf_otel::Pop3ServerMetrics emits pop3.server.connections, pop3.server.active_connections, pop3.server.auth, pop3.server.stls, pop3.server.dele, pop3.server.retrieves (kind/outcome), pop3.server.retrieve.duration (ms), and pop3.server.retrieve.size.
With traces enabled on the pipeline, Pop3ConnectionMetadata::traceparent carries the active W3C traceparent for the connection or current RETR/TOP retrieve. Handler SPI implementers can pass it to outbound HTTP with hopf_otel::with_traceparent.