Skip to main content
oximail-smtp is the SMTP layer. It owns the wire protocol on both sides of the mail boundary: receiving mail from the outside world on port 25, accepting authenticated mail from a user’s client on port 587, delivering queued mail to remote servers, generating bounces when delivery fails, and running as a backup MX for a primary that is down. It is the only crate that speaks raw SMTP; everything it accepts converges on the single ingestion path in oximail-mail, and everything it sends comes off the persistent delivery queue.

Overview

A message reaches this crate one of two ways, and the two ways are deliberately distinct code paths. An external server connecting to port 25 to deliver mail to a local recipient runs process_inbound_data. An authenticated user’s client connecting to port 587 to send mail runs process_submission_data. These are not two branches of one function with a flag — they are separate methods with separate rules, because port 25 (inbound) and port 587 (submission) are fundamentally different protocols. Inbound mail is untrusted and must be authenticated, scored, and sealed before delivery; submission mail is already authenticated and must instead be validated against the sending user’s own identities before it is queued for the outside world. The crate sits above oximail-store and oximail-mail and depends on the in-house oximail-auth crate for every email-authentication primitive (DKIM, SPF, DMARC, ARC, MTA-STS, DANE, SRS). The content-scoring side of spam is orchestrated here but lives in the oximail-spam crate: this crate decides where the verdict sends the message, not how the message scores. Every accepted message obeys ADR-017: an email that received a 250 OK from SMTP is always delivered somewhere — the recipient’s Inbox or its Junk folder — and never silently dropped. Nothing accepted is held back from the recipient: the quarantine-hold tier once planned in ADR-080 was withdrawn, and a dangerous verdict is shown on the message, never withheld.

Features

  • Two separate code paths — process_inbound_data (port 25) and process_submission_data (port 587), routed by process_data on whether the session is an authenticated submission. Inbound verifies and scores; submission validates the sender’s identity and queues.
  • Inbound auth + spam pipeline — DKIM / SPF / DMARC / ARC verification (via oximail-auth), ARC sealing of the chain, an Authentication-Results header (RFC 8601), then the spam pipeline orchestrated from the oximail-spam scorer, then delivery through the ingestion path.
  • Submission validation — the authenticated user’s envelope MAIL FROM and the message body’s From: header are both checked against the account’s identities (own address plus JMAP aliases). A mismatch is refused; the server does not relay forged senders even for an authenticated user.
  • Rejection replies with a postmaster help URL — every synchronous reject and asynchronous bounce carries a stable https://oximail.ch/postmaster?p=<Reason> URL so the sending postmaster can self-diagnose.
  • Persistent delivery queue — a SQLite-backed outbound queue with exponential-backoff retry and an RFC 5321 give-up window, polled by a background worker that resolves MX, DKIM-signs at delivery time, and enforces transport policy (MTA-STS / DANE).
  • DSN bounce generation (RFC 3464) — a structured bounce to the original sender on permanent failure, partial delivery failure, or queue expiry. Multiline remote diagnostics are flattened before they reach a DSN header field, so a verbose remote reply can never break bounce generation (the full text still reaches the human-readable body). The third part carries the original headers by default; where the message itself cannot be returned (a relayed message, or one that expired in the queue, whose body the expiry path never re-reads), it is a text/rfc822-headers part holding the original Message-ID alone, so a bounce processor can still attribute the report. A sender whose domain is signed here but receives its mail elsewhere (a send-only domain) gets its bounce like any other; it used to be dropped as if the domain were local.
  • Backup-MX path (ADR-052) — store-and-forward queue mode (default) or synchronous proxy mode.
  • SMTP AUTH — SASL PLAIN and LOGIN on the submission port, with per-session brute-force tracking.
  • Transport extensions — BDAT chunking (RFC 3030), REQUIRETLS (RFC 8689), and SRS envelope rewriting on forward.
  • Outbound extension mediation — every outbound hop derives its ESMTP requirements from the message content (SMTPUTF8, 8BITMIME, SIZE) and checks them against the destination’s advertised capabilities. An unbridgeable gap bounces permanently instead of delivering a mis-encoded message.
  • Compliance journaling hooks — inbound and outbound mail can be silently copied to a journal mailbox or an external archiver per tenant rules; see compliance. Off by default.

How it works

Two paths, one routing point

Each TCP connection gets an SmtpSession that tracks the protocol state machine (Connected → Greeted → MailFrom → RcptTo → Data / Bdat) and whether the connection arrived on a submission port. When DATA completes, process_data makes the one decision that splits the two worlds:
The two handlers share nothing but the raw bytes. This is a hard design rule, not an implementation detail: an authenticated submission must never accidentally fall into the inbound spam pipeline, and untrusted inbound mail must never reach the outbound queue. The session loop beneath both handlers ends a message only at <CRLF>.<CRLF> (RFC 5321 §4.1.1.4), so a dot line after a bare LF can no longer smuggle a second transaction with a forged envelope into the first; its five-minute limit is an inactivity limit re-armed by every command and every body read, under a one-hour lifetime; and at the server’s stop it answers a session waiting for a command 421 4.3.2 while a transaction in progress finishes. The operator view of these rules is the SMTP session.

The inbound path

process_inbound_data runs, in order:
  1. Trust check (ADR-031). Loopback is always trusted; [security] trusted_ips extends the set. A trusted peer bypasses SPF / DNSBL / DMARC-policy reject — but content analysis still runs, because a compromised local app can still spam. A recognized relay (a host published as MX of one of the box’s domains, other than this server, ADR-166) is not a trusted peer: its message is judged on the client address and EHLO read from the topmost Received: line the backup wrote, as if that client had connected directly (backup MX).
  2. Authentication. oximail-auth verifies SPF, DKIM, DMARC, and the ARC chain. DKIM verification is bounded, since any unauthenticated peer can send a message: at most 8 signatures are evaluated (the rest are reported as one permerror naming their count), the body is canonicalized once, and the whole pass has 30 seconds, past which the message is evaluated with no DKIM result and the journal says so. DMARC judges the author, read with the RFC 5322 §3.4 grammar: the one mailbox of the one From: field. A display name or a comment can no longer hide a second address (From: "x <x@attacker.example>" <ceo@victim.example> passed DMARC on attacker.example while the client showed ceo@victim.example), and a header line outside the grammar no longer turns DMARC into none. A message without exactly one author (no From:, several fields, several mailboxes, a From: outside the grammar) is DMARC permerror without a DNS query and is refused before the 250, on the direct and the backup-MX path, with 550 5.7.1 and a fixed text saying it must name exactly one mailbox (RFC 5322 §3.6.2, RFC 7489 §6.6.1; postmaster slug AuthorAddress); trusted peers are exempt. The ARC exception to a failing DMARC verdict holds only when the first sealer of the chain (i=1) is listed in [spam] arc_trusted_sealers, empty by default (email authentication). A DKIM signature whose l= leaves part of the body unsigned is reported dkim=policy. The crate then ARC-seals the chain (RFC 8617) and prepends an Authentication-Results header (RFC 8601) so downstream consumers see the verdict without re-running verification; the ARC-Authentication-Results it seals now separates its methods with ; as RFC 8601 requires; without it, verifiers, this one included, read no dkim nor dmarc in it.
  3. Spam pipeline. The scored pipeline (orchestrated here, scored in oximail-spam) returns a decision. Reject → a 550 reject reply; Defer → a 451; the delivered verdicts (Junk / suspected / bulk / clean) route to folder placement and an X-Spam-Status tag through a single disposition function (ADR-080) — only Junk files to the Junk folder, suspected and bulk deliver to the Inbox with a tag and are never junked.
  4. Delivery. Each recipient is delivered through the ingestion path. If ingestion fails after the message is accepted, ADR-017 takes over: the message is still delivered (with the $junk keyword) rather than dropped. If every recipient fails, the reply is a 451 so the sender retries; a partial failure generates a DSN bounce for the failed recipients.
The spam content scoring is not implemented in this crate. oximail-smtp builds the FilterContext, calls the scorer, and acts on the returned SpamDecision — folder, tag, reject, or defer. The scoring model itself (heuristics, reputation, Bayesian, content analysis) lives in oximail-spam. This page documents the orchestration and the delivery consequences, not the scoring.

The submission path

process_submission_data requires an authenticated session and does no auth verification, no spam scoring, no ARC, and no Authentication-Results — that work belongs to inbound mail, not to mail the user is sending. Instead it enforces sender legitimacy twice:
  • The envelope MAIL FROM must match the authenticated account’s email or one of its JMAP identities (aliases, plus-tags). On a lookup error the check fails closed (ADR-027): a transient database hiccup rejects rather than relays.
  • The message body’s From: header is checked against the same identity set. An authenticated user who passes the envelope check can still forge a body From:; this second check refuses that, 550 5.7.1. Before it, the header section must meet RFC 5322 §3.6: one From:, at most one Sender: and one Date:, and a Sender: when From: names several mailboxes; otherwise, or when no From: can be read, the answer is 554 5.6.0. The cardinality rule applies to relay accounts too.
An envelope sender authorized by a DNS proof (a relay account’s declared domain, or an envelope-sender grant) is honoured only while the proof is current: a proof suspended, or not re-verified for 72 hours, defers the message 451 4.7.1, a reversible state the client’s queue retries; a domain never proven is refused 550 5.7.1. A relay account’s recipient-per-day quota defers 452 4.7.1. A disabled account is refused 550 5.7.1: a decided revocation, not a state to wait out. The submission server then completes the message as RFC 6409 §8 lets it: a missing Date: or Message-ID: is added (the Message-ID reuses the queue entry id), and a Received: line for the submission hop is prepended, with an IPv6 client written as [IPv6:…]. A relayed message gets its Received: line and nothing else. A submission that passes is stored as a blob (in the clear since ADR-148: at rest is the volume’s business, encryption at rest) and enqueued for outbound delivery; while the storage guard is on, the RCPT is deferred 452 4.3.1 rather than accepted into a full disk. DKIM signing happens later, in the outbound worker, at delivery time. When the recipient is local (a user sends to a colleague on the same server), the outbound worker delivers without a network hop, and that local delivery applies the recipient’s filtering Rules and vacation auto-reply, exactly as an externally received message would. ADR-117 ratifies the longer-term shape: one shared per-recipient delivery pipeline used by every ingestion entry point, so a path that skips recipient policy becomes unrepresentable.

Rejection replies and the postmaster convention

Every rejection and bounce carries a stable help URL — https://oximail.ch/postmaster?p=<Reason> — mirroring the de-facto industry standard (Google’s support.google.com/mail/?p=<Reason>). The ?p= value is a per-reason slug, not a per-message reference, so the URL is stable and cacheable and maps to a named anchor on the public /postmaster page. postmaster.rs is the single source of truth for the slugs; the SMTP layer and the help page share it. The enhanced status code is chosen by why the message was refused, not by convenience: The DMARC reject is honest about what it is: 5.7.26 (“multiple authentication checks failed”, RFC 7372) is an authentication decision, not a content score — it is the code Gmail and Microsoft return for a DMARC reject. The previous code cited an arbitrary content reason under a 5.7.1, producing a self-contradictory bounce; the three DMARC-reject sites (scored pipeline, backup-MX, no-pipeline fallback) now route through one shared smtp_dmarc_reject_reply helper so they stay in lockstep.

Outbound delivery

A submitted or relayed message lands in the SQLite delivery queue. A background worker polls for due entries and delivers each one: it resolves the destination MX, applies transport policy (MTA-STS, RFC 8461; DANE, RFC 7672), DKIM-signs the message at delivery time, and sends it over an opportunistic-or-enforced TLS connection. An MTA-STS policy pattern such as *.example.com covers exactly one leftmost label (RFC 8461 §4.1): the match requires a non-empty label followed by the suffix, like the DANE name check. It used to test only that the MX ended with the suffix, so a notexample.com an attacker registered, with a valid certificate, satisfied an enforce policy. A recipient that is a sub-address of a local account (name+tag@domain) is resolved as the arrival door resolves it (RFC 5233) and delivered locally, instead of looping through this very server until the queue gave up. A temporary failure schedules a retry on an exponential backoff (1 min → 5 min → 30 min → 2 h → 8 h → 24 h). A message that cannot be delivered before the give-up window expires — 5 days by default, per the RFC 5321 §4.5.4.1 guidance (the same default as Postfix) — generates an RFC 3464 DSN bounce to the original sender and leaves the queue. The delivery hop mediates ESMTP extensions instead of silently bridging a gap. The requirements are derived deterministically from the message itself, never from a parameter the submitter declared: a non-ASCII octet in an envelope address or the header section requires SMTPUTF8 (RFC 6531), a high-bit body octet requires 8BITMIME (RFC 6152), and the transmitted length is declared as SIZE= when the peer advertises it (RFC 1870). Supported requirements are emitted onward; a destination that cannot satisfy one bounces the message permanently (550 5.6.7 / 554 5.6.3 / 552 5.3.4) rather than delivering it mis-encoded. This runs at the single outbound chokepoint, so direct-MX delivery and the relay path behave identically. For the same honesty reason, BINARYMIME is not advertised on the inbound EHLO — the outbound client is line-oriented and could not carry binary content onward without corruption — and a BODY=BINARYMIME parameter is rejected 555 5.5.4. The worker picks its next message with two index lookups, the expired entries first (oldest expiry first), then the ordinary ones (oldest due first), instead of sorting every due entry under the write lock submissions wait on: measured at 50 000 due entries, a pick went from 163 ms to about 0.01 ms. When a local delivery succeeds, the sender’s copy gets the same per-recipient deliveryStatus entry as a remote one (250, 2.0.0, deliveredAt). When a report arrives later for a recipient a remote server had accepted with 250, the recipient’s entry is rewritten from the report’s Action (failed or delayed), with its status and diagnostic, and its deliveredAt is removed; a report can only amend a recipient already in the entry, never add one. A message is treated as a delivery report only when it arrives with the null envelope sender (RFC 3461 §6.2): anything else shaped like a report is delivered as an ordinary message and changes no status, so a co-recipient cannot fail the other recipients by replying with a report. DKIM signing at delivery time oversigns the headers a reader acts on: the signature’s h= list names From, Reply-To, To, Cc, Subject, the two List-Unsubscribe headers and X-OxiMail-Flow one extra time, so a downstream actor cannot inject a second copy of any of them without breaking the signature (RFC 6376 §8.15 header-injection replay). A message carrying more than one From: field is never signed nor sent: it fails permanently with 554 5.6.0, whichever producer queued it (a raw message imported over JMAP and then submitted included). The key is chosen by the From: domain compared in its ASCII form (A-label, RFC 8616), so an internationalized domain is signed by the key configured for it. A message redirected out by a user’s rule is signed with a hosted domain’s key only when this server authenticated its author (a local submission, a server notice, or external mail that passed DMARC on entry); otherwise it leaves unsigned, so a forged From: of a hosted domain cannot be laundered through a redirect.

The backup-MX path

When the server runs with [mode] role = "backup", the inbound handler branches at RCPT TO and DATA into one of two modes (ADR-052):
  • Queue mode (default) — store-and-forward. The backup accepts the mail with 250 OK, then a retry worker relays it to the primary in the background. If the primary refuses with a 5xx, a DSN bounce goes to the original sender. RFC-conformant, but it opens a window between sender acceptance and the bounce in which a forged sender can consider its spam delivered.
  • Proxy mode (opt-in, ADR-052) — the backup proxies the SMTP session to the primary in real time: MAIL FROM, RCPT TO, DATA, body, and replies all flow through synchronously, so a primary 5xx is propagated to the sender as a 5xx with no backscatter and no delayed DSN. If the primary is unreachable at session start, the backup transparently falls back to queue acceptance for that mail.
In both modes the backup prepends its own Received: line before it relays, naming the client’s EHLO, its address and whether TLS was used (RFC 5321 §4.4). The primary reads that line to judge the message on the client the backup served (ADR-166): the trust check above.

Invariants

  • Never fail silently. An accepted message (250 OK) is always delivered somewhere (ADR-017), and that 250 is sent only after the database’s write-ahead log is synced to disk, so it survives a power loss ([storage] durable_doors, RFC 5321 §6.1); a permanent delivery failure always produces a DSN to the sender; an identity-lookup error on submission fails closed, not open.
  • Inbound and submission are separate paths. Submission validates MAIL FROM and body From: against the authenticated identities; inbound does not validate sender identity but does authenticate, score, and seal. Neither path can fall into the other.
  • Nothing accepted into a full disk. While the storage guard is on ([storage] min_free_percent), a submission RCPT is deferred 452 4.3.1 rather than accepted (ADR-151); content itself is stored in the clear since ADR-148.
  • One reply per reason. Each reject reason maps to one enhanced status code and one postmaster help slug, defined once in postmaster.rs.

Configuration

oximail-smtp does not read configuration files directly; the binary resolves the values and constructs the server. The operator-facing knobs that shape the SMTP surface — the inbound and submission bind addresses and ports, the hostname used in Received: and SRS, the rate limits, the [security] trusted_ips and [server] trusted_proxies sets, the backup-MX [mode] block, and the outbound max-lifetime override — are documented in the configuration reference. At rest, queued blobs are files in the clear on a volume the operator encrypts (ADR-148, not end-to-end); that model is covered in encryption at rest.

SMTP listeners

oximail-smtp is not a JMAP crate; it serves no JMAP methods and no HTTP routes. Its surface is the SMTP wire protocol on these listeners: The submission port supports SASL PLAIN and LOGIN, STARTTLS, BDAT chunking (RFC 3030), and REQUIRETLS (RFC 8689). The JMAP-side send surface (Email/send, EmailSubmission/set) lives in oximail-mail; this crate implements the SubmissionSender contract that the delivery queue fulfils for it.

Design decisions

  • ADR-017 (accepted email must deliver) — any message that got a 250 OK ends up in the Inbox or Junk, never held; ingestion failure delivers with $junk rather than dropping. The refusal has to happen before the 250: the storage guard defers at RCPT, never after acceptance.
  • ADR-052 (backup-MX proxy mode) — the backup MX runs in queue mode (store-and-forward, background relay, delayed DSN on primary 5xx) or proxy mode (synchronous session proxy that propagates the primary’s reply with no backscatter).
  • ADR-049 (MTA-STS coverage extension) — transport-security policy (MTA-STS, DANE) is honored on the backup-MX relay path, not only on direct outbound.
  • ADR-031 (trusted-network policy) — loopback and [security] trusted_ips bypass SPF / DNSBL / DMARC reject, but never the content pipeline. Its greylist clause is superseded by ADR-128, which retired RCPT-time greylisting altogether (anti-spam).
  • ADR-080 (spam disposition spine) — one disposition function maps every delivered spam verdict to folder placement plus the X-Spam-Status tag, so suspected and bulk deliver to the Inbox with a tag and only Junk files to Junk.
  • ADR-077 (unified spam classification model) — a message to a spamtrap recipient is captured as ground-truth spam corpus (forced $junk delivery) rather than rejected or deferred, so the trap collects exactly the bulk spam it exists to study.
  • ADR-027 (fail-silent audit) — the submission identity checks fail closed on a lookup error: a transient database failure rejects the send rather than relaying it.
  • ADR-076 (parser and auth stay AGPL, no crates.io) — the email-authentication primitives come from the in-house AGPL oximail-auth crate, a workspace path-dependency.
  • ADR-116 (outbound smarthost relay) — the authenticated relay routes, the ephemeral relay retention, and the §11 extension-mediation amendment documented above; the operator view is on the outbound relay page.
  • ADR-117 (unified local delivery pipeline) — locality is a transport detail, not a semantic one: a local recipient gets the same recipient policy (Rules, vacation, trace) as an external delivery. Recipient Rules already run at local delivery; the full shared-pipeline extraction is the engraved target shape.
ADRs live in the OxiMail server repository; see the ADR index.

Standards

  • RFC 5321 — Simple Mail Transfer Protocol. The wire protocol of both the inbound and submission paths, the Received: trace header (§4.4), and the give-up-window guidance (§4.5.4.1).
  • RFC 7372 — Email Authentication Status Codes. The 5.7.26 enhanced status used for a DMARC p=reject rejection (§3.3).
  • RFC 3464 — Delivery Status Notifications. The structured bounce generated on permanent or partial delivery failure and on queue expiry.
  • RFC 8601 — Authentication-Results header, prepended to inbound mail with the verdict.
  • RFC 3030 — BDAT / CHUNKING for the inbound data phase.
  • RFC 8689 — REQUIRETLS, propagated onto bounce and Sieve-generated outbound envelopes, and re-emitted on the outbound wire.
  • RFC 6531 (SMTPUTF8), RFC 6152 (8BITMIME), RFC 1870 (SIZE) — the extensions the outbound mediation derives from message content and negotiates per destination.
The email-authentication RFCs themselves — RFC 6376 (DKIM), RFC 7208 (SPF), RFC 7489 (DMARC), RFC 8617 (ARC), RFC 8461 (MTA-STS), RFC 7672 (DANE) — are implemented in the in-house oximail-auth crate (which will have its own reference page) and consumed by oximail-smtp.

Source map & tests

postmaster.rs carries a test asserting every reason’s help URL is well-formed and stable; the reject-reply helpers carry tests pinning the DMARC reject to 5.7.26 and the content reject to 5.7.1, both with the postmaster URL. The session state machine has its own transition test.

Status

Implemented and in production at v0.30.0: the two separate inbound and submission code paths, the full inbound auth + ARC-seal + spam-orchestration + delivery pipeline, the submission envelope-and-header identity validation, the persistent delivery queue with backoff and the RFC 5321 give-up window, RFC 3464 DSN generation, the postmaster help-URL reject convention, both backup-MX modes (queue and proxy, ADR-052), the outbound ESMTP extension mediation, DKIM oversigning of From/To/Subject, recipient Rules at local delivery, and the per-tenant compliance journaling hooks. The spam content scoring that the inbound path orchestrates is implemented in the oximail-spam crate; on the community (AGPL) build the scorer runs the heuristic and authentication signals, and the spamtrap-corpus capture (ADR-077) is live in production. The email-authentication primitives are provided by the in-house oximail-auth crate, which will have its own reference page.