August 10, 2026·13 min read·rustrustbackendprotocols

How Rust's Semantics Shape Mailwright

Mailwright uses Rust's type system, exhaustive matching, borrowing, and async boundaries to encode protocol states and operational lifecycles. Examining the relevant code shows what these language features guarantee inside a mail server.

Mailwright is a mail server organized as a Cargo workspace of 25 crates. Those crates cover SMTP, IMAP, POP3, JMAP, WebDAV with its calendar and contact extensions, ManageSieve, Sieve execution, spam analysis, message delivery, queue management, and several storage roles. This range provides repeated examples of the same implementation problem: protocol and infrastructure state must remain consistent while commands, storage operations, and background tasks proceed asynchronously.

The root Cargo.toml sets missing_docs = "warn" once under [workspace.lints.rust], and every member crate inherits that policy through [lints] workspace = true. More consequentially, the source uses sum types, exhaustive matches, borrowed dependencies, trait bounds, and owned task handles to describe conditions that would otherwise have to be maintained through flags, comments, or runtime conventions.

A sum type, represented by an enum in Rust, says that a value is exactly one of a fixed set of variants. Exhaustive matching requires code that examines such a value to address every variant. Borrowing connects the lifetime of one value to another without transferring ownership. These are general language features, but their practical value becomes clearer when they are attached to mail protocol phases and resource lifecycles.

Protocol state belongs to the state value

A POP3 maildrop is the collection of messages made available to a client after authentication. During the POP3 transaction phase, the client refers to messages by numbers from a snapshot and may mark them for deletion. Mailwright also holds an exclusive guard while this view is active so that two POP3 sessions on the same node do not independently manipulate the same maildrop.

In crates/mailwright-pop3/src/session.rs, the account, inbox identifier, message snapshot, and lock guard are collected into Maildrop. That value can exist inside the session only through the Transaction variant of State.

struct Maildrop {
    account: String,
    inbox_id: u64,
    messages: Vec<SnapshotEntry>,
    _lock: MaildropGuard,
}

enum State {
    Authorization { user: Option<String> },
    Transaction(Maildrop),
    Closed,
}

Before authentication, the session is in Authorization. Its optional user name records the intermediate state produced by USER, which a subsequent PASS command consumes. After successful authentication, the session moves to Transaction and owns a complete Maildrop. Once the connection is finished, it moves to Closed. The protocol's update work is performed when leaving the transaction phase, so Closed does not need to retain a maildrop for further commands.

This representation connects the resource lifetime to the protocol lifetime. Moving from Transaction to another variant drops the Maildrop, and dropping the maildrop also drops its MaildropGuard. Without that connection, the session would need separate fields for authentication, a possibly initialized snapshot, a possibly held lock, and the current command phase. Every transition would then have to update those fields together and preserve their permitted combinations by convention.

The main dispatcher in the same file demonstrates how the state value controls command handling.

pub async fn receive(&mut self, command: &Command) -> Outcome {
    match &self.state {
        State::Authorization { .. } => self.in_authorization(command).await,
        State::Transaction(_) => self.in_transaction(command).await,
        State::Closed => Outcome {
            response: Vec::new(),
            next: Next::Close,
        },
    }
}

Each arm delegates to a handler written for one state. Authorization commands cannot accidentally receive a Maildrop, while transaction commands are reached only when one exists. The match has no fallback arm, so adding another State variant makes this function fail to compile until the new phase has explicit behavior.

POP3's smaller maildrop() accessors contain an unreachable! fallback. Their callers are transaction-state handlers selected by the exhaustive dispatcher, which narrows the condition those methods must handle. The enum establishes the partition at the outer boundary, and the inner methods operate under the state selected there.

The IMAP session in crates/mailwright-imap/src/session.rs applies the same structure to a more detailed state machine. An IMAP client first authenticates and may then issue SELECT or EXAMINE to choose a mailbox for subsequent message commands. Mailwright represents those phases as NotAuthenticated, Authenticated, and Selected.

State::Selected contains the authenticated account, a SelectedView of the mailbox, and an optional SEARCHRES saved result. SEARCHRES lets a client save the UIDs produced by a search for later commands. NotAuthenticated contains none of this data, while Authenticated retains the account but has no selected mailbox. Closing or replacing the selection therefore drops the associated view and saved result together. The type prevents a saved search result from remaining attached to a session that no longer has the mailbox against which it was computed.

Sans-I/O keeps transport decisions explicit

A sans-I/O protocol implementation contains the rules of a protocol without owning the socket that carries it. It consumes parsed input and returns instructions to an outer driver. This separates questions such as whether a command is legal from questions such as how many bytes to read or when to flush a network stream.

The SMTP receive session in crates/mailwright-smtp/src/session.rs follows this design. It has no socket. Its result consists of an optional SMTP reply, a directive describing the driver's next operation, and a delay to apply before sending the reply.

pub enum Next {
    Command,
    Sasl,
    Data,
    Chunk {
        size: u64,
        keep: bool,
    },
    StartTls,
    Close,
}

pub struct Outcome {
    pub reply: Option<Reply>,
    pub next: Next,
    pub delay: Duration,
}

Each Next variant gives the driver the information required for one transport operation. Command requests another command line. Sasl requests a continuation line for an authentication exchange. Data tells the driver to collect a traditional SMTP message body. StartTls requires the driver to flush the successful reply and transfer the stream to the TLS upgrade code. Close ends the connection.

BDAT is SMTP's chunked alternative to the traditional DATA transfer. A BDAT command declares how many octets follow, and successive chunks form the message. Next::Chunk carries that declared size so the driver can read exactly the right number of bytes. Its keep field tells the driver whether accepted bytes should be buffered or refused bytes should merely be drained from the stream. The protocol session decides which disposition applies, while the driver performs the byte-level work.

The delay field uses the same division. Authentication failures and bad-recipient responses may be throttled, but the session does not start a timer. It declares a duration, and crates/mailwright-smtp/src/server.rs sleeps before writing the reply. This leaves timing and socket ownership in the I/O layer while keeping the policy decision in the protocol layer.

The server driver writes any reply and then matches on outcome.next to continue reading, collect a body, process a chunk, upgrade TLS, or close the connection. The session can still make asynchronous directory and delivery calls, but it does not also acquire responsibility for buffering, transport timeouts, or stream ownership.

Shutdown follows this command boundary. The driver races its next input operation against a tokio::sync::watch receiver. A command that has already been parsed completes normally. If shutdown arrives while the driver is waiting for more input, it obtains a best-effort 421 response from the protocol object, writes it, and closes. The protocol layer supplies the valid SMTP response, while the I/O layer determines when the stream can be interrupted.

Trait contracts describe backend behavior

Mailwright can select among storage implementations, but higher layers need one stable account of what storage operations mean. In Rust, a trait provides that contract. Send + Sync on the trait means a store can be transferred between tasks and referenced concurrently, subject to the guarantees of its methods.

The central interface in crates/mailwright-store/src/lib.rs is DataStore, an asynchronous trait over opaque byte keys and values. The first four methods are required primitives. scan_range has a default implementation built from a full prefix scan.

#[async_trait]
pub trait DataStore: Send + Sync {
    async fn get(&self, key: &[u8]) -> Result<Option<Vec<u8>>>;

    async fn put(&self, key: &[u8], value: &[u8]) -> Result<()>;

    async fn delete(&self, key: &[u8]) -> Result<()>;

    async fn scan_prefix(&self, prefix: &[u8]) -> Result<Vec<(Vec<u8>, Vec<u8>)>>;

    async fn scan_range(
        &self,
        start: &[u8],
        end_exclusive: &[u8],
    ) -> Result<Vec<(Vec<u8>, Vec<u8>)>> {
        Ok(self
            .scan_prefix(b&#34;&#34;)
            .await?
            .into_iter()
            .filter(|(key, _)| key.as_slice() >= start && key.as_slice() < end_exclusive)
            .collect())
    }
}

The default first requests every key by scanning the empty prefix. It then retains entries whose keys satisfy start <= key < end_exclusive. This is a half-open range, so the lower boundary is included and the upper boundary is excluded.

That implementation establishes correct behavior for a small test store without requiring it to reproduce a database query engine. It is not intended to erase backend capabilities. The memory, PostgreSQL, and RocksDB implementations override the method with native bounded scans, which preserve the same result contract without materializing rows outside the requested interval.

The distinction between a contract and an implementation matters here. Callers can depend on ordering and range boundaries through DataStore, while each backend remains free to use its own efficient mechanism. A default method gives simple implementations a correct baseline, and an override changes the execution strategy without changing what the method means.

crates/mailwright-store/src/conformance.rs specifies the behavioral side of this interface through generic asynchronous tests. The suite covers ordering, empty values, deletion, counters, compare-and-set conflicts, batch atomicity, range deletion, and concurrent updates. The in-memory and RocksDB modules instantiate it through a macro. PostgreSQL calls the same functions through a container-aware wrapper that can skip when Docker is unavailable. These tests ensure that code written against the trait does not acquire different semantics when the selected backend changes.

The composition root in crates/mailwright/src/instance.rs stores the configured backend as Arc<dyn DataStore>. dyn DataStore permits runtime selection through dynamic dispatch, while Arc supplies shared ownership. A blanket DataStore implementation for Arc<T> lets this shared handle pass through generic APIs as a store in its own right.

Other components can remain generic where runtime selection is unnecessary. BayesAnalyzer<S> in crates/mailwright-spam/src/bayes/analyzer.rs requires S: DataStore + Clone. Production can supply the dynamically selected shared store, while tests commonly use Arc<MemoryStore> directly. The analyzer depends on storage behavior rather than a concrete database type.

Smaller traits isolate narrower side effects. DnsSeam in crates/mailwright-mta/src/dns.rs distinguishes a negative MX answer from a lookup failure and accepts the address-family strategy as an argument. Delivery planning needs that distinction because an authoritative absence and an unsuccessful lookup produce different classifications. Scripted resolvers can exercise those cases without adding test-only branches to routing code.

Errors remain structured until the wire boundary

A text error message is useful to a human, but later code cannot reliably recover a protocol category from its wording. Mailwright therefore retains error categories as enum variants until the layer responsible for constructing a wire response.

SMTP parsing distinguishes an unknown command from malformed arguments to a recognized command. CommandError in crates/mailwright-smtp/src/command.rs has exactly those variants. The session maps them to separate SMTP replies in crates/mailwright-smtp/src/session.rs.

match Command::parse(line) {
    Ok(command) => self.command(command).await,
    Err(CommandError::Unknown) => Outcome::reply(500, &#34;5.5.1 Command not recognized&#34;),
    Err(CommandError::BadArguments) => {
        Outcome::reply(501, &#34;5.5.4 Syntax error in parameters&#34;)
    }
}

A successful parse proceeds to command handling. An unknown command produces code 500, while bad parameters produce 501. The parser communicates this distinction as data, so the session does not inspect or compare an error string. Because the match has no fallback arm, a new parse-error variant would require an explicit wire-level decision here.

JMAP, the JSON-based protocol surface in the repository, can execute multiple method calls in one request. This creates two error scopes. A request-level failure rejects the entire HTTP request, while a method-level failure replaces one method result and allows the remainder of the batch to retain its own outcomes.

crates/mailwright-jmap/src/error.rs represents those scopes separately. RequestError maps through type_uri(), http_status(), and detail() into a problem document. MethodError::type_name() exhaustively maps each method error to its registered JMAP spelling. Its handwritten Serialize implementation delegates to the same typed value construction used elsewhere. The two levels cannot accidentally share a general serialization path that would place an error in the wrong protocol envelope.

Storage errors retain a comparable distinction. StoreError separates Conflict from Backend(String) and derives Clone, PartialEq, and Eq. Downstream error enums can wrap it, and tests can assert an exact refusal category. Transaction code can retry a compare-and-set conflict while returning a backend failure immediately.

Ownership also applies to asynchronous shutdown

An SmtpSession<'a> borrows its configuration, directory, policy expressions, and optional side-effect seams. The lifetime parameter connects the session to those instance-scoped services. The compiler therefore prevents the session from outliving the dependencies it uses, including when references remain live across an .await.

Background tasks require an ownership decision of their own. Tokio returns a JoinHandle when a task is spawned. The handle can be awaited to establish that the task has finished, or aborted to request cancellation. Mailwright keeps handles for subscription loops so shutdown can account for their completion.

The drain_task helper in crates/mailwright/src/instance.rs receives &mut Option<JoinHandle<()>>. Its caller first raises the task's shutdown latch, after which the helper waits for normal completion. The timeout branch handles a task that has not returned within the grace period.

Err(_elapsed) => {
    tracing::warn!(
        &#34;a subscription loop did not return on its shutdown signal; aborting it&#34;
    );
    handle.abort();
    let _ = handle.await;
}

abort() schedules cancellation, and awaiting the handle establishes when cancellation has completed. That second operation matters because the task may still own cloned store handles until the runtime next polls and drops it. The option is cleared only after completion has been established.

Cancellation has a further boundary around spawn_blocking. RocksDB operations move cloned engine handles into blocking closures. Cancelling the async task that awaits such a closure does not stop a closure that is already queued or running. The normal shutdown path therefore signals the outer loop and allows its current operation to finish. Abortion remains an escalation for a loop that does not return during the grace period.

Borrowing the optional handle preserves its reachability during a second shutdown escalation. Instance::drain can be dropped if another termination signal requests an immediate stop. If drain_task removed the handle from the option before awaiting it, dropping the drain future would also drop its only route to the handle and detach the task. By borrowing the handle in place, later release code still finds it in the instance unless completion has already been established and the slot explicitly cleared.

Async closure bounds expose a current language boundary

An optimistic transaction reads current values, prepares a conditional batch, and attempts to commit it. If another writer changes a value involved in the transaction first, the compare-and-set assertion reports a conflict. A retry creates a new transaction, repeats the reads and closure, and tries again. The closure may consequently run more than once and must avoid side effects outside the transaction handle.

crates/mailwright-store/src/transact.rs expresses the ordinary entry point with an async closure borrowing a Txn.

pub async fn transact<S, F, T>(store: &S, f: F) -> Result<T>
where
    S: DataStore + ?Sized,
    F: AsyncFn(&mut Txn<'_, S>) -> Result<T>,

The transaction borrow is valid only for the closure invocation. Inside the function, a conflict causes a backoff followed by another invocation against fresh reads. Success returns the closure's value, a backend error returns immediately, and exhausting MAX_ATTEMPTS returns StoreError::Conflict.

Queue workers run transactions inside tokio::spawn, whose task future must be Send. In this context, the closure's returned future must be Send for every lifetime with which it may borrow a transaction. The direct AsyncFn bound cannot currently state that requirement in a form the compiler can prove sufficiently general, so the file provides a second entry point.

pub async fn transact_send<S, T, F>(store: &S, f: F) -> Result<T>
where
    S: DataStore + ?Sized,
    F: for<'t> Fn(
        &'t mut Txn<'_, S>,
    ) -> std::pin::Pin<Box<dyn Future<Output = Result<T>> + Send + 't>>,

for<'t> requires the function to work for any transaction-borrow lifetime. The boxed trait object explicitly states that the returned future is Send and cannot outlive that borrow. This supplies the evidence required by a spawned task.

Both functions contain the same conflict retry loop, use the same attempt budget, and call the same conflict_backoff. Their difference is confined to how the closure's future is represented. Ordinary callers use the direct async form, while spawned workers use the entry point whose type records the additional execution requirement.

Newtypes preserve identifier domains

A newtype is a tuple struct containing one underlying value. Although its representation may be small, Rust treats it as a distinct type. This prevents identifiers from different subsystems from becoming interchangeable merely because both happen to use the same primitive representation.

The single-instance blob layer defines ContentHash([u8; 32]) in crates/mailwright-store/src/single_instance.rs. It represents the BLAKE3 identity of raw content. Storage APIs can accept a ContentHash instead of an arbitrary 32-byte array, making the intended domain visible in function signatures.

JMAP adds a second boundary in crates/mailwright-jmap/src/blob.rs with BlobId(ContentHash). A JMAP blob identifier is the protocol representation of a content identity. Its parser accepts a G sigil followed by 64 hexadecimal characters.

pub fn parse(wire: &str) -> Result<Self, BlobIdError> {
    let body = wire
        .strip_prefix(BLOB_SIGIL)
        .ok_or(BlobIdError::NotABlobId)?;
    if body.len() != HASH_HEX_LEN {
        return Err(BlobIdError::Malformed);
    }
    let mut bytes = [0u8; 32];
    let hex = body.as_bytes();
    for (i, slot) in bytes.iter_mut().enumerate() {
        let hi = hex_value(hex[i * 2]).ok_or(BlobIdError::Malformed)?;
        let lo = hex_value(hex[i * 2 + 1]).ok_or(BlobIdError::Malformed)?;
        *slot = (hi << 4) | lo;
    }
    Ok(Self(ContentHash::from_bytes(bytes)))
}

The parser first checks the namespace sigil, then checks the encoded length. It decodes two hexadecimal characters for each of the 32 bytes and constructs a ContentHash, which is then wrapped as a BlobId. A missing sigil returns NotABlobId; an invalid length or hexadecimal digit returns Malformed.

The nested newtype preserves the distinction between a storage content identity and its JMAP wire identity while permitting a direct conversion. Serialization emits the wire form, and deserialization passes through the same parser. An arbitrary string therefore cannot become a BlobId through parsing or deserialization without the wire-format validation.

DAV tokens in crates/mailwright-dav/src/token.rs apply the same technique to stateful protocol values. LockToken wraps a random u128. SyncToken stores a collection identifier, synchronization epoch, change position, and optional truncation cursor. A DAV client presents a sync token to ask for changes since an earlier synchronization point. The token is not a bare counter because that counter would be ambiguous across collections or after retained history loses continuity. Its type carries the claimed binding among those four facts that validation checks against live storage.

The semantics with the largest effect

The strongest effects in this codebase come from representing protocol phases as sum types, retaining structured errors until their protocol mapping, and making resource ownership visible across asynchronous work. Trait bounds define storage and side-effect contracts, while newtypes distinguish identifiers that share an underlying representation.

These mechanisms do not remove runtime validation. Network input still requires parsing, storage operations still fail, and shutdown still needs timeouts. They reduce the additional state that the implementation must coordinate manually and give the compiler enough information to reject missing protocol branches, invalid lifetimes, and insufficient async execution bounds.