IMAP

hopf-imap is an IMAP4rev2 / IMAPS server and callback-driven client. The server stores messages through hopf-mailbox (Maildir++ / mbox) and advertises only implemented capabilities. The client supports multiple outstanding tagged commands (pipelining), asynchronous DNS, STARTTLS/IMAPS, and a production IDLE state machine. Use ImapFetch / ImapIdle for auto-pilot sessions or implement ImapClientDriver for custom pipelines.

Features

Area Support
Base protocol IMAP4rev2 (RFC 9051): LOGIN, AUTHENTICATE PLAIN, SELECT/EXAMINE, LIST/LSUB, STATUS, CREATE/DELETE/RENAME, SUBSCRIBE, APPEND, FETCH/UID FETCH, SEARCH/UID SEARCH, STORE, COPY, EXPUNGE, CLOSE, NOOP, LOGOUT
FETCH data items FLAGS, UID, MODSEQ, RFC822[.SIZE/.HEADER/.TEXT], BODY[section] / BODY.PEEK[section] with <start.count> partial-fetch ranges, ENVELOPE, and BODYSTRUCTURE (recursive MIME structure via rmimeparser's streaming parser — see Conformance for the exact scope of what BODYSTRUCTURE's extension data omits)
IDLE RFC 2177 — continuation + DONE; mailbox polled off-reactor for EXISTS / EXPUNGE / FETCH FLAGS (Maildir refresh); auto-completes after idle_max_duration (default 29 minutes)
UIDPLUS RFC 4315 — APPENDUID, COPYUID, UID EXPUNGE
MOVE RFC 6851 — copy + \Deleted + expunge with COPYUID
NAMESPACE RFC 2342 — personal namespace from the mailbox store; optional other-users / shared namespaces via ImapConfig
ENABLE / CONDSTORE / QRESYNC RFC 5161 / RFC 7162 — HIGHESTMODSEQ, CHANGEDSINCE, MODSEQ when the backend provides modseqs; QRESYNC degrades safely (no fabricated VANISHED (EARLIER) history)
UNSELECT / ID RFC 3691 / RFC 2971
LIST-EXTENDED / LIST-STATUS / CHILDREN Selection/return options; RETURN (STATUS (…))
QUOTA RFC 9208 — GETQUOTA / GETQUOTAROOT / SETQUOTA via pluggable QuotaManager (default unlimited). The stock MemoryQuotaManager is an adapter over a shared hopf_core::QuotaManager — the same backend an hopf-ftp service can enforce against, so a user's mailbox and FTP storage can share one real quota instead of two independent trackers.
COMPRESS=DEFLATE / UTF8=ACCEPT RFC 4978 — raw DEFLATE (RFC 1951, no zlib/gzip wrapper) compression activated by COMPRESS DEFLATE and kept active for the rest of the connection (both directions); advertised only once authenticated and only while not already compressed. RFC 6855 UTF8=ACCEPT is tracked via ENABLE — no wire-format change is needed, since IMAP4rev2 responses are already raw UTF-8 rather than modified UTF-7.
SORT / THREAD / STATUS=SIZE RFC 5256 SORT (ARRIVAL/CC/DATE/FROM/SIZE/SUBJECT/TO keys, per-key REVERSE) and THREAD (THREAD=ORDEREDSUBJECT and THREAD=REFERENCES, both advertised); RFC 8438 STATUS=SIZE. SORT/THREAD's charset argument accepts only UTF-8/US-ASCII (anything else is rejected NO [BADCHARSET (UTF-8 US-ASCII)]), since header/body matching is UTF-8 throughout regardless of what the client declares.
OBJECTID / METADATA / NOTIFY RFC 8474 OBJECTID — MAILBOXID generated once per mailbox and persisted (survives RENAME); EMAILID derived deterministically from MAILBOXID + UID. RFC 5464 METADATA — GETMETADATA/SETMETADATA with DEPTH/MAXSIZE, backed by a directory tree per mailbox (and a separate one for server-level annotations). RFC 5465 NOTIFY — a documented subset: SET/NONE for the currently selected mailbox's MessageNew/MessageExpunge/FlagChange events only, reusing IDLE's poll-and-diff machinery so an event is never reported twice regardless of which mechanism observes it first (see Limitations).
Auth CredentialStore — LOGIN and AUTHENTICATE (all 7 hopf-auth SASL mechanisms: PLAIN, LOGIN, CRAM-MD5, DIGEST-MD5, SCRAM-SHA-256, OAUTHBEARER, EXTERNAL — SASL-IR, capability advertisement via store.supported_mechanisms() filtered by TLS requirement); LOGINDISABLED before STARTTLS
TLS STARTTLS and implicit IMAPS
Storage MailboxFactory → store opened after auth; open/read/append/expunge on the storage pool

Commands are lexed incrementally with the shared push-parser style (tag / verb / arguments / literals) — synchronizing literals emit + continuations, and LITERAL- non-synchronizing literals are accepted. The APPEND message literal is never buffered whole: the codec emits it as a series of chunk events as wire bytes arrive, and the control layer streams each chunk straight to a bounded temp file, which is then streamed into the mailbox on proceed and removed — memory use stays flat regardless of message size.

Handler SPI

Gumdrop-style staged traits:

State traits constrain which replies are legal at each stage. The default handler (DefaultImapHandlerFactory) accepts everything the config allows and delegates storage I/O to the protocol via proceed. Storage work always runs on the Runtime storage pool, never on the reactor; while an operation is in flight the connection queues pipelined commands and answers them in order.

Quick start

use std::sync::Arc;
use hopf_auth::PasswordStore;
use hopf_core::{Runtime, RuntimeConfig};
use hopf_imap::{ImapConfig, ImapService};
use hopf_mailbox::MaildirFactory;

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 = ImapConfig::new("127.0.0.1:1143".parse()?, "localhost", store, factory);
let svc = ImapService::new(config, Arc::clone(&rt));
svc.start()?;

ImapConfig knobs: with_tls(acceptor) (+ implicit_tls() for IMAPS), with_greeting, with_quota_manager, with_server_id, and per-extension enable_* flags (idle, namespace, quota, move, condstore, qresync, enable, compress, utf8_accept, sort, thread_ordered_subject, thread_references, status_size, objectid, metadata, notify) — all default to enabled.

Example binary: cargo run -p imap -- 127.0.0.1:1143 localhost ./mail.

Client

The high-level ImapFetch auto-pilot drives greeting → CAPABILITY → (STARTTLS → CAPABILITY) → LOGIN / AUTHENTICATE PLAIN → SELECT → FETCH → LOGOUT. ImapIdle drives the same preamble then IDLE, delivering unsolicited EXISTS/EXPUNGE/FLAGS through a MailboxEventListener until DONE.

use std::sync::Arc;
use hopf_core::{Runtime, RuntimeConfig};
use hopf_imap::{ImapClient, ImapFetch};

let rt = Arc::new(Runtime::start(RuntimeConfig::default())?);

ImapClient::new("mail.example.com", 143)
    .connect(
        &rt,
        Arc::new(
            ImapFetch::new()
                .credentials("alice", "secret")
                .mailbox("INBOX")
                .on_message(Box::new(|seq, uid, body| {
                    println!("message {seq} uid={uid:?} ({} bytes)", body.len());
                }))
                .on_complete(Box::new(|ok| {
                    println!("fetch complete: {ok}");
                })),
        ),
    )?;

For full control implement ImapClientDriver: every server event surfaces as a callback (on_greeting, on_capability, on_authenticated, on_selected, on_fetch_data, on_status_data, on_list_entry, on_idle_started, …), and each callback receives the staged state reference (ImapClientNotAuthenticated → ImapClientAuthenticated → ImapClientSelected / ImapClientIdle) whose methods issue the commands that are legal in that state.

on_fetch_data delivers an ImapFetchData with FLAGS, UID, RFC822.SIZE, MODSEQ, EMAILID/THREADID parsed, and ENVELOPE, BODYSTRUCTURE (or BODY) and INTERNALDATE captured verbatim — any literal the server used inside them (a non-ASCII subject, say) is re-encoded as a quoted string, so each is one line of IMAP syntax. Parse them with ImapEnvelope::parse and ImapBodyStructure::parse; ImapBodyStructure::parts() lists every leaf with the section number BODY[section] would use for it (RFC 3501 §6.4.5, encapsulated message/rfc822 parts included), and display_candidates() picks the first non-attachment text/plain and text/html parts, which is what a mail client fetches instead of the whole message. Literal-bearing items (BODY[section], RFC822*) are streamed through on_fetch_literal* as before.

list_extended(reference, pattern, &ImapListOptions) issues an RFC 5258 LIST with selection options (SUBSCRIBED, REMOTE, RECURSIVEMATCH, SPECIAL-USE) and RETURN items (SUBSCRIBED, CHILDREN, SPECIAL-USE, and STATUS (…) per RFC 5819); the * STATUS replies a RETURN (STATUS …) provokes are routed to on_status_data interleaved with the on_list_entry calls, which is the one round trip a mail client needs for a folder list with unread counts. Extended data items after a LIST name (CHILDINFO, OLDNAME) are accepted and skipped. ImapCapabilities reports list_extended, list_status, special_use and children.

Commands that do not originate in a reply — a mail client whose user just clicked a folder, say — reach a live session through ImapClientDriver::on_wake. It runs at the start of every receive, so in particular after a hopf_core::ConnHandle::poke() from any thread (which re-enters receive with no data), and it is handed an ImapClientWakeState: the staged state reference for the session's current state (NotAuthenticated / Authenticated / Selected / Idle), or Busy while nothing can be issued (connecting, mid-STARTTLS, IDLE sent but not yet acknowledged). Whatever the driver issues is flushed when it returns. The pattern is: stash the ConnHandle from ep.handle() in an early callback, queue work somewhere the driver can see it, poke, and drain the queue in on_wake — putting back anything the current state does not allow. During IDLE the only legal command is done(); issue the real work from on_idle_complete, which hands over ImapClientSelected.

fn on_wake(&mut self, state: ImapClientWakeState<'_>, _ep: &mut dyn Endpoint) {
    let Some(job) = self.queue.lock().unwrap().pop_front() else { return };
    match (job, state) {
        (Job::Fetch(set), ImapClientWakeState::Selected(s)) => s.uid_fetch(&set, "(FLAGS BODY.PEEK[HEADER])"),
        (Job::Fetch(_), ImapClientWakeState::Idle(i)) => { i.done(); /* re-queue; see on_idle_complete */ }
        (job, _) => self.queue.lock().unwrap().push_front(job), // not now
    }
}
// elsewhere, on the UI thread:
queue.lock().unwrap().push_back(Job::Fetch("1:*".into()));
conn_handle.poke();

Client options

Two things the options table below does not cover. max_net_out(bytes) (and max_net_in) set this connection's buffer limits; hopf-core's 4 MiB outbound default closes the connection on a send that would overflow it, and ImapClientAppend::send_literal writes a whole APPEND literal in one call, so a client that appends messages must raise it above its largest message. And a dial that never produces a connection — DNS failure, no addresses, a connect that cannot start — reaches ImapClientHandlerFactory::connect_failed(host, error), which prints to stderr unless overridden; ImapFetch and ImapIdle override it to call their on_complete(false).

Builder method Default Description
ImapClient::new(host, port) — Plain TCP; DNS-resolved off-reactor
ImapClient::from_addr(addr) — Skip DNS
.starttls(connector, name) off Explicit TLS after greeting/CAPABILITY
.implicit_tls(connector, name) off IMAPS (port 993)
.timeouts(ImapClientTimeouts) dns=5s, connect=30s, stage=60s, msg=600s Per-phase deadlines
.max_pipeline(n) 8 Cap outstanding tagged commands
ImapFetch::credentials(user, pass) — LOGIN or AUTHENTICATE PLAIN
.mailbox(name) INBOX Mailbox to SELECT
.sequence_set(set) / .fetch_items(items) 1:* / (RFC822) What to FETCH
.require_starttls(true) false Abort if STARTTLS unavailable
ImapIdle::done_on_event(true) false Send DONE after the first mailbox event

Example binary: cargo run -p imap-fetch -- 127.0.0.1 1143 alice secret.

Pipelining

The client keeps a tag-correlated PendingMap of outstanding commands (up to max_pipeline). Untagged replies are classified by prefix (STATUS, LIST, FETCH, SEARCH, …) and routed to the oldest compatible pending command, so tagged completions may arrive in any order. pipeline_status_and_list issues STATUS and LIST back-to-back as a demonstration; the Hopf server serialises storage-backed commands per connection and answers in order, but the client also handles out-of-order completion from other servers.

Timeouts

ImapClientTimeouts phases:

Phase Default Covers
dns 5 s Hostname resolution
connect 30 s Dial → greeting (and implicit-TLS handshake)
stage 60 s Each outstanding tagged command
message 600 s FETCH literal (body) transfer

On expiry the driver’s on_timeout fires and the connection closes.

Testing

Unit tests run with the workspace default (cargo test -p hopf-imap --lib). Loopback TCP / TLS / filesystem integration tests are opt-in:

cargo test -p hopf-imap --features integration

They cover raw-socket LOGIN/SELECT/FETCH/APPEND, pipelined STATUS+LIST (in-order against the Hopf server and synthetic out-of-order against a scripted server), STARTTLS and implicit-TLS fetch, hostname dial, and IDLE (server DONE handling plus client EXISTS→DONE).

Metrics

Process-local ImapServerMetrics (in hopf-imap): connections, auth_ok / auth_fail, commands, and starttls via ImapService::metrics().

OTLP/JSONL export (when ImapService::with_telemetry(&pipeline) is used): hopf_otel::ImapServerMetrics emits imap.server.connections, imap.server.active_connections, imap.server.auth, imap.server.starttls, imap.server.commands (verb/outcome), and imap.server.command.duration (ms).

With traces enabled on the pipeline, ImapConnectionMetadata::traceparent carries the active W3C traceparent for the connection or current tagged command. Handler SPI implementers can pass it to outbound HTTP with hopf_otel::with_traceparent.

Limitations