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, the Junk folder, or quarantine — and never silently dropped.

Features

  • Two separate code pathsprocess_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.
  • 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 extensionsBDAT chunking (RFC 3030), REQUIRETLS (RFC 8689), and SRS envelope rewriting on forward.

How it works

Two paths, one routing point

Each TCP connection gets an SmtpSession that tracks the protocol state machine (ConnectedGreetedMailFromRcptToData / Bdat) and whether the connection arrived on a submission port. When DATA completes, process_data makes the one decision that splits the two worlds:
if session.is_submission && session.authenticated.is_some()
    → process_submission_data   (port 587)
else
    → process_inbound_data      (port 25)
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 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 / greylist / DMARC-policy reject — but content analysis still runs, because a compromised local app can still spam.
  2. Authentication. oximail-auth verifies SPF, DKIM, DMARC, and the ARC chain. 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.
  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.
A submission that passes is stored as a tenant-encrypted blob (ADR-020 — if the tenant key is unavailable the submission is rejected with 451, never stored in cleartext) and enqueued for outbound delivery. DKIM signing happens later, in the outbound worker, at delivery time.

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:
ReasonReplyEnhanced statusStandard
DMARC p=reject (failing SPF + DKIM alignment)5505.7.26RFC 7372 §3.3
Refused by the spam content pipeline5505.7.1RFC 5321
Recipient mailbox does not exist5505.1.1RFC 5321
Backup MX does not relay for this domain5505.1.1RFC 5321
Permanent delivery failure (returned to sender)DSNRFC 3464
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. 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 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.

Invariants

  • Never fail silently. An accepted message (250 OK) is always delivered somewhere (ADR-017); 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.
  • No cleartext blobs. A submission whose tenant encryption key is unavailable is rejected with 451, never stored in cleartext (ADR-020).
  • 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. The at-rest encryption model behind the queued blobs (the LUKS-style scheme, not end-to-end) 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:
ListenerPortPathPurpose
Inbound MX25process_inbound_dataReceive mail from external servers for local recipients. Verifies SPF / DKIM / DMARC / ARC, runs the spam pipeline, delivers through ingestion. No sender-identity validation.
Submission587process_submission_dataAccept authenticated mail from a user’s client. Requires SMTP AUTH; validates MAIL FROM and body From: against the account’s identities; queues for outbound delivery.
Backup MX25backup branch (ADR-052)When role = "backup": store-and-forward (queue) or synchronous proxy to the primary.
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, Junk, or quarantine; ingestion failure delivers with $junk rather than dropping. For inbound, ADR-017 wins over encryption (deliver with $junk); for outbound submission, encryption wins (reject with 451 before any 250 OK).
  • 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 / greylist / DMARC reject, but never the content pipeline.
  • 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.
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 3030BDAT / CHUNKING for the inbound data phase.
  • RFC 8689REQUIRETLS, propagated onto bounce and Sieve-generated outbound envelopes.
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

File (within the crate)What it holds
src/inbound.rsThe SmtpServer, the command loop, process_data (the path split), process_inbound_data, process_submission_data, deliver_to_recipient, and the reject-reply helpers (smtp_dmarc_reject_reply, smtp_spam_reject_reply).
src/session.rsSmtpSession and the SessionState machine.
src/auth.rsThe oximail-auth verification wrapper (AuthResolver, verify_message, should_reject, should_flag_junk).
src/outbound.rsThe outbound worker: MX resolution, transport policy, DKIM signing at delivery, TLS.
src/queue.rsDeliveryQueue — the persistent outbound queue, retry backoff, and the give-up window.
src/dsn.rsRFC 3464 bounce generation.
src/postmaster.rsPostmasterReason — the single source of truth for the ?p= reject slugs and help URLs.
src/backup.rs, src/backup_proxy.rsThe backup-MX queue mode and synchronous proxy mode (ADR-052).
src/srs.rsSRS envelope rewriting on forward.
src/mta_sts.rs, src/dane.rsOutbound transport-policy checks (consume oximail-auth primitives).
src/greylist.rs, src/bayes.rs, src/spam_store.rsThe greylist filter and the local spam-corpus storage the scorer reads.
src/header_mutate.rs, src/sanitize.rs, src/transport.rs, src/rate_limit.rs, src/trusted.rsHeader mutation, content sanitization, transport helpers, rate limiting, and the trusted-peer set.
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, and both backup-MX modes (queue and proxy, ADR-052). 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.