Ferroma
Protocols · Documentation

SMTP in Ferroma

Who should read this: anyone implementing, testing or troubleshooting ferroma-smtp, and any operator trying to work out why a remote server refused their mail.

This document covers both directions of SMTP. Inbound is the listener that accepts mail from other MTAs, and the submission listener that accepting mail from your own users' mail clients. Outbound is the queue worker that resolves MX records and delivers your users' mail to remote hosts. It specifies the command set, the session state machine, the reply codes for every failure, the limits and where each one is enforced, the Received: header Ferroma prepends, the retry schedule, the 4xx-versus-5xx classification rule, and bounce generation.

Status: design specification. The ferroma-smtp crate is implemented against this document; sections marked (planned) describe behaviour that is specified but not yet shipped. Everything in this file is (planned) as of now: crates/ferroma-smtp/src/lib.rs is a skeleton declaring the server/client/MX/policy module boundaries. The configuration keys, limit values and error variants it refers to do exist — [smtp] and [limits] in config/ferroma.toml, Limits in crates/ferroma-core/src/limits.rs, FerromaError in crates/ferroma-core/src/error.rs, and the mail_queue / delivery_attempts tables in migrations/0001_initial.sql.


1. Ports and listener roles

PortConfig keyRoleTLS
25smtp.portInbound MX. Accepts mail from other MTAs. Unauthenticated peers may deliver only to local domains.plaintext, STARTTLS offered
587smtp.submission_portSubmission. For your users' mail clients. Authentication required before MAIL FROM.plaintext, STARTTLS required by policy
465smtp.smtps_portImplicit TLS. Same policy as 587; the handshake comes first. 0 disables the listener.TLS from the first byte

Config::validate() refuses to start when smtp.smtps_port != 0 and tls.enabled = false, when two active SMTP ports collide, or when smtp.port is 0. See crates/ferroma-core/src/config.rs.

Which port a connection arrived on is part of the session, not a separate code path: it selects the policy profile (whether authentication is mandatory, and whether an unauthenticated transaction may address a non-local domain).


2. Command set (§9)

Specification §9.1 splits the command set into two phases.

PhaseCommandsStatus
MVPEHLO, HELO, MAIL FROM, RCPT TO, DATA, RSET, NOOP, QUITfirst release
Second phaseAUTH, STARTTLSfirst release for submission; STARTTLS on port 25 as soon as tls.enabled

Additional commands the implementation accepts, with the RFC that defines them:

CommandRFCNotes
VRFY5321 §4.1.1.6(planned) answered 252 2.5.2 Cannot VRFY user — never confirms whether an address exists
HELP5321 §4.1.1.4(planned) 214 2.0.0 plus a one-line summary
EXPN5321 §4.1.1.7(planned) 502 5.5.1 Command not implemented
STARTTLS3207available only when tls.enabled and not already encrypted
AUTH4954PLAIN and LOGIN only
BDAT3030(planned) 502CHUNKING is not advertised

Anything else is 500 5.5.2 Command unrecognized.

Command lines


3. The state machine

Specification §9.2 gives the enum; the shipped implementation uses it verbatim, extended with the encrypted flag that STARTTLS needs.

/// crates/ferroma-smtp/src/server/state.rs
pub enum SmtpState {
    Connected,      // greeting sent, nothing received yet
    Greeted,        // EHLO/HELO accepted
    MailFrom,       // MAIL FROM accepted, no recipient yet
    RcptTo,         // at least one RCPT TO accepted
    Data,           // 354 sent, reading the dot-terminated body
    Authenticated,  // SASL succeeded (orthogonal to the five above)
}

Transitions:

                    ┌──────────────┐
   connect ────────►│  Connected   │ 220 <smtp.banner>
                    └──────┬───────┘
        EHLO/HELO ─────────┤          250-… / 250 SIZE n
                           ▼
                    ┌──────────────┐
                    │   Greeted    │◄──── RSET (from any state)
                    └──────┬───────┘
      MAIL FROM:<…> ───────┤          250 2.1.0
                           ▼
                    ┌──────────────┐
                    │   MailFrom   │
                    └──────┬───────┘
      RCPT TO:<…> ─────────┤          250 2.1.5  (repeatable)
                           ▼
                    ┌──────────────┐
                    │    RcptTo    │
                    └──────┬───────┘
         DATA ─────────────┤          354 End data with <CR><LF>.<CR><LF>
                           ▼
                    ┌──────────────┐
                    │     Data     │  (body with limits.data_timeout_secs)
                    └──────┬───────┘
        end-of-data ───────┴─────────► 250 2.0.0 Ok: queued as <id>  → Greeted
                                   └──► 4xx / 5xx                    → Greeted

Authenticated is not a position in that sequence; it is a flag that survives RSET and changes what the session is allowed to do. AUTH is legal in Greeted and later, never in Connected (503 5.5.1 Send HELO/EHLO first) and never during Data.

Guards, and the reply when a guard fires:

GuardFires whenReply
helo_requiredMAIL FROM arrives without EHLO/HELO503 5.5.1 Send HELO/EHLO first
SequenceRCPT TO before MAIL FROM503 5.5.1 Need MAIL FROM before RCPT TO
SequenceDATA with no accepted recipient503 5.5.1 Need RCPT TO before DATA
require_auth_on_submissionMAIL FROM on the submission port without AUTH530 5.7.0 Authentication required
require_tls_for_authAUTH on an unencrypted connection538 5.7.11 Encryption required for requested authentication mechanism
Nested MAILa second MAIL FROM during a transaction503 5.5.1 Sender already specified
Nested DATADATA while already in Dataimpossible: the body reader consumes to the terminator

4. The session struct

Specification §9.3:

/// crates/ferroma-smtp/src/server/session.rs
pub struct SmtpSession {
    /// Unique per accepted connection; also the `connection_id` log field.
    pub connection_id: Uuid,
    /// TCP peer address. Taken from `X-Forwarded-For` only when
    /// `api.trust_proxy_headers` is set and the peer is a trusted proxy.
    pub remote_addr: SocketAddr,
    /// The handler the connection is talking to.
    pub port: SmtpPort,          // Mx | Submission | Smtps
    pub state: SmtpState,
    /// The name the peer announced. Used in `Received:`, never trusted.
    pub helo: Option<String>,
    /// `true` once STARTTLS completed. Gates `require_tls_for_auth`.
    pub encrypted: bool,
    /// Set by a successful `AUTH`; the account whose quota and rate limits apply.
    pub authenticated_user: Option<UserId>,
    /// The session row created by `AuthService::open_session(SessionKind::Smtp)`.
    pub session_id: Option<SessionId>,
    /// `MAIL FROM` reverse-path, empty for a null sender (`<>`, a bounce).
    pub envelope_from: Option<String>,
    /// Accepted recipients, in order, after aliases and catch-all expansion.
    pub recipients: Vec<String>,
    /// `SIZE=` announced on `MAIL FROM`, when the client sent one.
    pub declared_size: Option<u64>,
    /// `BODY=` / `SMTPUTF8` parameters seen on `MAIL FROM`.
    pub body_8bit: bool,
    pub smtp_utf8: bool,
}

Every field that can be logged appears in the structured fields listed in specification §40 (connection_id, remote_ip, helo, authenticated_user, sender, recipient, message_id, result, duration). ferroma-core's logging module installs the subscriber that carries them.

The session is per connection and lives on one Tokio task. Nothing about it is shared, so the state machine needs no locking; the counters that are shared (connection totals, per-IP rate windows) live in the listener.


5. EHLO extensions advertised

EHLO is answered with one line per capability, and the whole set is only sent when smtp.advertise_extensions = true; when it is false the session replies with 250-<hostname> and a bare 250 OK, which is what an ancient client that chokes on extensions needs.

LineAdvertised whenMeaning
250-<server.hostname>alwaysgreeting line
250-PIPELININGsmtp.advertise_extensionsclient may batch commands without waiting for replies
250-SIZE <limits.max_message_size>smtp.advertise_sizemaximum DATA size accepted, in octets
250-8BITMIMEsmtp.advertise_extensionsBODY=8BITMIME is accepted
250-ENHANCEDSTATUSCODESsmtp.advertise_extensionsreplies carry x.y.z status codes (see §11)
250-SMTPUTF8smtp.advertise_extensionsUTF-8 local parts are accepted
250-DSN(planned) policyRET/ENVID parameters honoured on MAIL FROM and RCPT TO
250-STARTTLStls.enabled and not yet encryptedthe client may upgrade in place
250-AUTH PLAIN LOGINtls.enabled or smtp.require_tls_for_auth = falseSASL mechanisms
250-HELP(planned)HELP is implemented
250 CHUNKINGneverBDAT is not implemented; do not advertise it

AUTH is withheld entirely on a connection that is not encrypted when smtp.require_tls_for_auth = true. Advertising a mechanism the session will refuse is worse than not advertising it: it makes a client fail after sending credentials it should never have sent in the clear.


6. Open-relay policy, and why it is the default

Specification §9.4 and §54:

recipient domain is a local domain   →  accept (subject to quota and limits)
recipient domain is anything else    →  require a successful AUTH first

Concretely, in RCPT TO handling:

ConnectionRecipient domainOutcome
unauthenticated, port 25local (domains.name matches, domains.enabled)accepted, delivers locally
unauthenticated, port 25not local550 5.7.1 Relaying denied
authenticated, any portlocalaccepted
authenticated, port 587/465not localaccepted, queued to mail_queue
authenticated, port 25not localaccepted, queued — the account is authenticated, so this is submission, not relaying

Why the default is what it is. An open relay is not a configuration inconvenience; it is a machine for laundering spam that will be blacklisted within hours and will take the operator's legitimate mail down with it. Every mail administrator has seen it happen, and the damage is measured in months of deliverability, not minutes of downtime. Making the safe behaviour the default and the unsafe behaviour unreachable-by-typo is therefore worth the small amount of extra configuration: there is no allow_relay = true key to find and set by accident, and [../AGENTS.md](../AGENTS.md) §4.6 states the rule as non-negotiable.

Two adjacent policy decisions that fall out of the same reasoning:


7. Limits and where each one is enforced

Each limit is enforced at the protocol edge and re-checked in the mail core, so talking to the API instead of SMTP cannot bypass it. crates/ferroma-core/src/limits.rs is the single definition; [limits] in config/ferroma.toml is the single source of values.

LimitConfig keyDefaultEnforced atReply or error
Message sizelimits.max_message_size26214400 (25 MiB)DATA body reader, from the SIZE extension and by counting octets; re-checked by the mail core before the Maildir write552 5.3.4 Message size exceeds fixed maximum message size
Announced size, earlyMAIL FROM SIZE=MAIL FROM, before 354552 5.3.4 Message size exceeds fixed maximum message size
Recipients per transactionlimits.max_recipients100RCPT TO counter452 4.5.3 Too many recipients
Simultaneous connections, globallimits.max_connections100listener accept loop421 4.3.2 Too many connections, try again later, then close
Simultaneous connections per IPlimits.max_connections_per_ip10listener accept loop, per source address421 4.3.2 Too many connections from your address
Inbound commands per minute per IPlimits.smtp_rate_limit100command loop, sliding window per IP421 4.7.0 Too many commands, slow down
Submission messages per hour per accountlimits.submission_rate_limit50on acceptance of an authenticated transaction452 4.7.0 Submission rate limit exceeded
Messages per account per daylimits.daily_send_limit500on acceptance, counted from mail_queue, not from memory452 4.7.0 Daily send limit exceeded
Mailbox quotausers.quota_bytes, mailboxes.quota_bytes, limits.mailbox_quota1 GiBMailboxesRepository::check_quota(mailbox_id, needed) before the Maildir write452 4.2.2 Mailbox full — temporary, so the sender retries after the user frees space
Command timeoutsmtp.command_timeout_secs300command loop, per read421 4.4.2 Timeout waiting for command, then close
DATA timeoutsmtp.data_timeout_secs600body reader421 4.4.2 Timeout waiting for data, then close
MIME nesting depthlimits.max_mime_depth20MIME parser (ParseLimits in ferroma-mail)message accepted into Junk rather than rejected (planned)
Attachments per messagelimits.max_attachments50attachment extraction552 5.3.4 Too many message parts (planned)
Single attachment sizelimits.max_attachment_size26214400attachment extraction552 5.3.4 Attachment too large (planned)

Limits::validate() refuses to boot when max_connections_per_ip > max_connections, when max_attachment_size > max_message_size, when max_message_size is 0, or when max_mime_depth is outside 1–100. A typo in [limits] therefore stops the server at boot instead of silently disabling a limit.

The per-account limits are the ones that matter for abuse: an authenticated account that has been compromised can otherwise be used as a spam cannon at whatever rate the host's uplink allows. submission_rate_limit and daily_send_limit are checked on acceptance, and the daily count is derived from mail_queue rows rather than an in-memory counter, so a restart does not reset it.


8. AUTH PLAIN and AUTH LOGIN

Specification §15 specifies Argon2id for password storage and AUTH PLAIN / AUTH LOGIN for SMTP.

MechanismWire formNotes
PLAINAUTH PLAIN <base64(\0authcid\0passwd)> or AUTH PLAIN then a 334 continuation carrying the same blobone round trip; the continuation form is what a client uses after a 334
LOGINAUTH LOGIN334 VXNlcm5hbWU6 → base64 username → 334 UGFzc3dvcmQ6 → base64 passwordtwo round trips; the prompts are the conventional base64 of Username: and Password:

An initial-response = (RFC 4954 §4) means "empty", which is a parse error for both mechanisms: 501 5.5.4 Invalid base64 data.

Successful authentication:

  1. The session decodes the credentials and calls
    AuthService::login(email, password, SessionKind::Smtp, ip, user_agent, None).
  2. AuthService verifies against the stored Argon2id PHC string
    (PasswordHasher::verify), transparently re-hashes when
    PasswordHasher::needs_rehash says the stored parameters are stale, and
    records the attempt through LoginAttemptsRepository.
  3. On success the session gets an authenticated_user and a SessionId from
    sessions with kind = 'smtp'.
  4. The reply is 235 2.7.0 Authentication successful.

Failure replies — deliberately indistinguishable between "no such user" and "wrong password", exactly as AuthService::login makes them:

ConditionReply
Wrong password, unknown account, disabled account535 5.7.8 Authentication credentials invalid
limits.max_failed_logins reached, or the account is locked454 4.7.0 Temporary authentication failure
More than 3 × limits.max_failed_logins failures from one source IP in the window454 4.7.0 Temporary authentication failure
Malformed base64, unknown mechanism501 5.5.4 Invalid base64 data / 504 5.5.4 Unrecognized authentication type
AUTH after a successful AUTH503 5.5.1 Already authenticated
AUTH on cleartext with smtp.require_tls_for_auth = true538 5.7.11 Encryption required for requested authentication mechanism

require_tls_for_auth

Default false in config/ferroma.toml so a bare cargo run works without certificates. docker-compose.prod.yml sets FERROMA__SMTP__REQUIRE_TLS_FOR_AUTH: 'true', and a production MX should keep it there: AUTH PLAIN and AUTH LOGIN both send the password in base64, which is encoding, not encryption. Over plaintext port 587 a passive observer reads the password in the clear. With the flag on, AUTH is neither advertised nor accepted until STARTTLS completes.

Config::allows_plaintext_auth() exists so callers can ask the question without re-deriving it from the TLS flags.


9. STARTTLS, SMTPS and the submission role

PortWhat happensPolicy
25EHLO advertises STARTTLS; the client sends STARTTLS, gets 220 2.0.0 Ready to start TLS, and both sides renegotiate. The session returns to Connected and the client must send a fresh EHLO.Mail from other MTAs is accepted opportunistically. Refusing plaintext inbound would lose mail from every host that does not do TLS.
587Same upgrade path, but submission policy applies: require_auth_on_submission means MAIL FROM is refused until AUTH succeeds, and require_tls_for_auth means AUTH is refused until TLS succeeds.Submission. A client that cannot do STARTTLS cannot send.
465TLS from the first octet (SMTPS). No STARTTLS is advertised — there is nothing to upgrade.Submission.

Details that are easy to get wrong and are therefore specified:


10. The Received: header

Inbound mail gets one Received: header prepended when smtp.add_received_header = true (the default). It is prepended, not appended: Received: headers accumulate newest-first, and the top one is the first hop that Ferroma knows about.

Received: from <helo> (<reverse-dns> [<remote-ip>])
        by <server.hostname> (Ferroma <version>)
        with ESMTPS id <connection_id>
        for <recipient>
        ; <date>

Rendered example (illustrative output):

Received: from mail.example.net (mail.example.net [203.0.113.25])
        by mail.example.com (Ferroma 0.1.0)
        with ESMTPS id 0f4c9a12-6b1e-4d3f-9a77-2c1f0e5b8d41
        for <[email protected]>
        ; Tue, 16 Sep 2026 09:12:31 +0000

Field-by-field rules:

FieldSourceRule
from <helo>SmtpSession::helothe name the peer announced — untrusted, displayed, never used for a decision
(<reverse-dns> [<remote-ip>])PTR lookup of remote_addromitted entirely when the lookup fails; brackets always carry the literal IP
by <host>server.hostnamemust be a valid DNS name or Config::validate() refuses to boot
with <protocol>sessionSMTP (plaintext), ESMTP (EHLO, plaintext), ESMTPS (EHLO + TLS), ESMTPSA (EHLO + TLS + AUTH), ESMTPA (EHLO + AUTH, no TLS)
id <connection_id>SmtpSession::connection_idthe same UUID that appears in the logs, so a Received: line and a log line can be joined
for <recipient>first accepted recipientomitted when there are several recipients (it would leak the other recipients), which is RFC 5321 §4.4 practice for multi-recipient mail
; <date>clock at acceptanceRFC 5322 date-time, always UTC with a +0000 zone

Two things the header never contains: the peer's IP is taken from the socket, not from any header the peer sent, and no header the peer sent is ever copied into the generated line.

Authentication-Results is added separately when policy.add_auth_results or policy.add_auth_results is set; see security.md §8.


11. Outbound delivery

Specification §10 gives the pipeline; specification §11 the queue states and the retry schedule.

 SMTP submission / Webmail / Client API
                │
                ▼
            Mail Core                renders RFC 5322, signs DKIM,
                │                    writes the Sent copy
                ▼
            mail_queue                one row per recipient
                │
                ▼
        QueueRepository::claim_due()  status pending|retry → delivering
                │
                ▼
            DNS MX                    per recipient domain
                │
                ▼
       remote MX hosts, in preference order, :25
                │
                ▼
        SMTP client (EHLO → STARTTLS if offered → MAIL → RCPT → DATA)
                │
                ├── success  ──► delivered   + delivery_attempts row
                └── failure  ──► retry | failed, per §12

11.1 Queue states and the columns that hold them

mail_queue.status carries a CHECK constraint listing exactly these values: pending, delivering, delivered, retry, failed, cancelled.

  pending ──► delivering ──┬──► delivered
     ▲                     │
     │                     ├──► retry ──► delivering ──► …
     └─────────────────────┘
                           └──► failed   (attempts exhausted, or a 5xx)
ColumnMeaning
mail_queue.message_idthe stored copy the delivery is for; ON DELETE CASCADE
mail_queue.senderenvelope reverse-path
mail_queue.recipientenvelope forward-path — one row per recipient, so one bad recipient does not delay the others
mail_queue.attempts / max_attemptsattempts so far / the cap (queue.max_attempts, 12)
mail_queue.next_attempt_atwhen the dispatcher may pick the row up; mail_queue_due_idx indexes exactly WHERE status IN ('pending','retry')
mail_queue.last_error, last_status_code, last_status_textthe most recent failure, for the Admin queue screen
mail_queue.remote_mxthe host that was tried
mail_queue.delivered_atwhen it finally worked

Every attempt also writes a delivery_attempts row (queue_id, attempt, remote_mx, status_code, status_text, error, duration_ms), which is what the Admin "Delivery Logs" screen reads. Attempts are history; the queue row is state.

11.2 MX resolution

(planned) ferroma-smtp::mx::MxResolver uses hickory-resolver (configured from [dns]).

  1. Look up MX for the recipient domain.
  2. Sort by preference ascending, with a stable random tie-break inside equal
    preferences so that repeated attempts do not always hit the same host first —
    which is also what RFC 5321 §5.1 recommends and what stops one dead MX from
    absorbing every retry.
  3. Try hosts in order. A connection failure, a 4xx greeting, or a TLS
    failure moves to the next host in the same attempt. Only when every host has
    failed is the attempt recorded as failed.
  4. Null MX. A single MX 0 . means the domain accepts no mail. That is a
    permanent failure (failed, and a bounce): 556 5.1.10 equivalent locally,
    FerromaError::Invalid.
  5. No MX, but an A/AAAA record. Fall back to the address itself, per RFC
    5321 §5.1. Only when the fallback also fails is the domain undeliverable.
  6. No MX and no address. FerromaError::Dns — classified per §12.5, which
    makes it temporary: a domain can be mid-registration.
  7. CNAME chains are followed by the resolver, bounded by dns.attempts.
  8. Timeouts come from [dns] (timeout_secs, attempts, tcp_fallback).

Connection concurrency to one host is capped by queue.max_connections_per_host (4): hammering a single remote MX with four hundred parallel connections is how a mail server gets itself blocked.

11.3 The retry schedule (§11)

queue.retry_schedule_secs = [60, 300, 900, 3600, 21600, 86400] — one minute, five minutes, fifteen minutes, one hour, six hours, twenty-four hours. The last entry repeats until attempts reaches queue.max_attempts (12).

QueueConfig::backoff_for_attempt(attempt) is the implementation of that rule: it clamps the index to the end of the schedule, so attempt 7, 8, … all wait 86 400 seconds, and returns 60 when the schedule is empty.

AttemptDelay before itCumulative elapsed (approximately)
1immediate (on acceptance)0
260 s1 min
3300 s6 min
4900 s21 min
53600 s1 h 21 min
621600 s7 h 21 min
786400 s31 h 21 min
8–1286400 s eachup to ~5 days 7 h

Four and a half days of trying is the conventional MTA posture: long enough that a remote server's weekend outage does not bounce your user's mail, short enough that the sender eventually learns the message did not arrive. queue.retention_days (30) governs how long a delivered row is kept afterwards; it is independent of the retry window.

The dispatcher polls with queue.poll_interval_secs (10) and runs queue.workers (4) deliveries concurrently.


12. 4xx versus 5xx, and FerromaError::is_temporary()

The classification is not decided in the SMTP layer. It is FerromaError::is_temporary() — one function, in crates/ferroma-core/src/error.rs — and it is the single source of truth behind both SMTP reply classes and the queue's retry-versus-fail decision.

pub fn is_temporary(&self) -> bool {
    matches!(
        self,
        FerromaError::Io(_)
            | FerromaError::Network(_)
            | FerromaError::Dns(_)
            | FerromaError::RateLimited
            | FerromaError::Timeout(_)
            | FerromaError::Storage(_)
            | FerromaError::Internal(_)
    )
}
FerromaError variantcode()Temporary?Meaning for a delivery
Ioio_erroryeslocal filesystem/socket failure — try again
Networknetwork_erroryesoutbound connection failed
Dnsdns_erroryesMX/A lookup failed or timed out
RateLimitedrate_limitedyesthrottle, back off
Timeouttimeoutyesdeadline exceeded
Storagestorage_erroryesdatabase or mail store failure — the message is not at fault
Internalinternal_erroryesa bug; retrying is the conservative choice, and the failure is logged loudly
Configconfig_errornounusable configuration — never a per-message condition
Parseparse_errornomalformed input
NotFoundnot_foundnoreferenced entity does not exist
Conflictconflictnouniqueness or state violation
Invalidinvalid_inputnovalidation failed
Unauthorizedunauthorizednocredentials missing or wrong
Forbiddenforbiddennopolicy refusal, e.g. relaying denied
LimitExceededlimit_exceedednosize, recipients or quota
Protocolprotocol_errornopeer violated the protocol
Tlstls_errornocertificate validation failed
Unsupportedunsupportednospecified but not implemented

The rule in one sentence: is_temporary() == true4xx and requeue; is_temporary() == false5xx and fail. A protocol layer that classifies errors itself by string-matching a message is a bug; add a variant or fix is_temporary().

12.1 Inbound: what Ferroma replies to a peer

ConditionReplyClass
Message stored250 2.0.0 Ok: queued as <id>2xx
DATA body exceeds limits.max_message_size552 5.3.4 Message size exceeds fixed maximum message size5xx, permanent — retrying will not shrink it
Recipient count over limits.max_recipients452 4.5.3 Too many recipients4xx — RFC 5321 §4.5.3.1.10 makes this a transient reply
Unknown local domain550 5.1.2 Relay access denied5xx
Unknown local address550 5.1.1 No such user here5xx
Relay to a non-local domain, unauthenticated550 5.7.1 Relaying denied5xx
Mailbox over quota452 4.2.2 Mailbox full4xx — the user can free space
Database or Maildir write failure451 4.3.0 Temporary local problem4xx — FerromaError::Storage, retry
Disk full on the mail root452 4.3.1 Insufficient system storage4xx
Internal error while storing451 4.3.0 Temporary local problem4xx — FerromaError::Internal
Rate limit exceeded421 4.7.0 Too many commands, slow down4xx, then close
SPF hard fail (-all) (planned)550 5.7.23 SPF validation failed5xx
DMARC failure with p=reject (planned)550 5.7.1 DMARC policy violation5xx
DKIM verification failure with dkim.verify_inbound (planned)550 5.7.20 DKIM signature validation failed5xx

Two corrections to the intuition above, both deliberate:

12.2 Outbound: what a remote reply means for the queue

Remote replyQueue decisionRationale
2xx after the final dotdelivered, write delivered_at, delivery_attemptsdone
4xx at any pointretry, next_attempt_at = now + backoff_for_attempt(attempts), record last_status_code/last_status_textthe remote asked us to come back
5xx at any pointfailed immediately, no further attempts, bounce if queue.bounce_on_failurethe remote refused permanently; retrying is abuse
Connection refused / timeout / resetretry (FerromaError::Network / Timeout)try the next MX, then back off
TLS handshake failureretryoften a certificate rotation on the far side
MX lookup failureretry (FerromaError::Dns)DNS glitches are transient
All MX hosts tried and all failed with 4xxretryone attempt covers every host; the backoff applies to the whole domain
attempts reaches max_attemptsfailed, bounce if queue.bounce_on_failurethe retry window has elapsed
Remote 5xx on one recipient of a multi-recipient messagethat mail_queue row fails; the others are unaffectedone row per recipient exists precisely so this is per-recipient

12.3 Enhanced status codes

ENHANCEDSTATUSCODES (RFC 3463) is advertised, so every reply carries a x.y.z class after the basic code. The classes Ferroma emits:

Enhanced codeMeaningWhere it comes from
2.0.0other/undefined status, successmessage accepted
2.1.0sender okMAIL FROM accepted
2.1.5recipient okRCPT TO accepted
2.5.2cannot VRFY userVRFY
2.7.0security policy okAUTH succeeded
4.2.2mailbox fullquota exceeded
4.3.0other mail system statuslocal storage/database failure
4.3.1mail system fulldisk full
4.3.2system not accepting network messagesconnection caps
4.4.2bad connectiontimeout
4.5.3too many recipientslimits.max_recipients
4.7.0security policy, temporaryrate limits, throttled AUTH
5.1.1bad destination mailbox addressunknown local address
5.1.2bad destination system addressunknown local domain
5.3.4message too big for systemsize limit
5.5.1invalid commandsequence violation
5.5.2syntax errorunparseable command line
5.5.4invalid command argumentsbad base64, bad parameter
5.7.0security policy, permanentauthentication required
5.7.1delivery not authorisedrelaying denied, sender not owned by user
5.7.8authentication credentials invalidbad password
5.7.11encryption requiredcleartext AUTH refused
5.7.20DKIM signature validation failed (planned)inbound DKIM
5.7.23SPF validation failed (planned)inbound SPF

When smtp.advertise_extensions = false the enhanced code is omitted and the reply is the bare three-digit code plus the class-less text.

12.4 DSN

DSN (RFC 3461) is advertised (planned) and the parameters on MAIL FROM (RET=FULL|HDRS, ENVID=) and RCPT TO (NOTIFY=, ORCPT=) are recorded on the queue row. NOTIFY=NEVER on a recipient suppresses the bounce for that recipient. Until it is implemented, DSN is not advertised and those parameters are ignored, which is the behaviour RFC 5321 §4.1.1.11 requires of a server that does not support them.

12.5 The complete inbound reply table

ReplyTextTrigger
220<smtp.banner>connection accepted
2202.0.0 Ready to start TLSSTARTTLS
2212.0.0 ByeQUIT
2352.7.0 Authentication successfulAUTH
2502.0.0 OkEHLO/HELO final line, NOOP, RSET
2502.1.0 OkMAIL FROM
2502.1.5 OkRCPT TO
2502.0.0 Ok: queued as <id>end of DATA
2522.5.2 Cannot VRFY user, but will accept message and attempt deliveryVRFY
334<base64 prompt>AUTH continuation
354End data with <CR><LF>.<CR><LF>DATA
4214.3.2 Too many connections, try again laterlimits.max_connections
4214.3.2 Too many connections from your addresslimits.max_connections_per_ip
4214.4.2 Timeout waiting for commandsmtp.command_timeout_secs
4214.4.2 Timeout waiting for datasmtp.data_timeout_secs
4214.7.0 Too many commands, slow downlimits.smtp_rate_limit
4504.2.0 Mailbox busy, try again latertransient lock contention (planned)
4514.3.0 Temporary local problemFerromaError::Storage / Internal
4524.2.2 Mailbox fullquota
4524.3.1 Insufficient system storagedisk full
4524.5.3 Too many recipientslimits.max_recipients
4524.7.0 Submission rate limit exceededlimits.submission_rate_limit
4524.7.0 Daily send limit exceededlimits.daily_send_limit
4544.7.0 Temporary authentication failurelockout, or per-IP failure flood
5005.5.2 Command unrecognizedunknown verb
5005.5.2 Line too longcommand over 512 octets
5015.5.4 Invalid base64 datamalformed SASL blob
5015.5.4 Syntax error in parametersunparseable MAIL/RCPT
5025.5.1 Command not implementedEXPN, BDAT, STARTTLS without TLS
5035.5.1 Send HELO/EHLO firsthelo_required
5035.5.1 Need MAIL FROM before RCPT TOsequence
5035.5.1 Need RCPT TO before DATAsequence
5035.5.1 Sender already specifiedsecond MAIL FROM
5035.5.1 Already authenticatedsecond AUTH
5035.5.1 TLS already activeSTARTTLS twice
5045.5.4 Unrecognized authentication typeAUTH CRAM-MD5
5305.7.0 Authentication requiredrequire_auth_on_submission
5355.7.8 Authentication credentials invalidbad credentials
5385.7.11 Encryption required for requested authentication mechanismrequire_tls_for_auth
5505.1.1 No such user hereunknown local address
5505.1.2 Relay access deniedunknown local domain
5505.7.1 Relaying deniedunauthenticated relay attempt
5505.7.1 Sender address rejected: not owned by userforeign MAIL FROM
5505.7.23 SPF validation failed (planned)SPF
5505.7.1 DMARC policy violation (planned)DMARC
5505.7.20 DKIM signature validation failed (planned)DKIM
5525.3.4 Message size exceeds fixed maximum message sizesize limit
5525.3.4 Too many message parts (planned)limits.max_attachments
5545.5.1 Pipelining violatedcommand pipelined across STARTTLS
5545.7.1 Message rejectedcatch-all policy refusal (planned)

13. Bounce generation

When a delivery ends as failed and queue.bounce_on_failure = true, Ferroma sends a Delivery Status Notification back to the envelope sender. Specification §11 does not spell out the format, so this section is the specification.

13.1 Who gets bounced to

Envelope senderBehaviour
A local address that existsbounce delivered into that mailbox's INBOX, with From: MAILER-DAEMON@<server.hostname>
A local address that no longer existsbounce discarded, logged at warn
A remote addressa new mail_queue row with a null sender (MAIL FROM:<>), subject to the same retry policy; a bounce that itself bounces is dropped
Empty (<>)never bounce — this is already a bounce, and bouncing it is how mail loops start

The null-sender rule matters: RFC 5321 §6.1 and RFC 3464 both require that a notification have a null reverse-path, and a server that bounces a bounce will happily generate an infinite loop between two misconfigured MTAs.

13.2 The bounce is a DSN

multipart/report; report-type=delivery-status (RFC 3462/3464), containing:

PartContent typeContent
1text/plain; charset=utf-8human-readable explanation: which recipient, why, and how long it was tried
2message/delivery-statusper-message fields (Reporting-MTA, Arrival-Date) and per-recipient fields (Final-Recipient, Action: failed, Status: 5.1.1, Diagnostic-Code: smtp; 550 5.1.1 No such user here)
3message/rfc822 (or text/rfc822-headers)the original message's headers, or the whole message when it is small

Built with ferroma_mail::MessageBuilder; the original bytes come from the Maildir by messages.storage_path. The Status: field carries the enhanced code from §12.3, so the sender's client can act on the class rather than the prose.

13.3 When a bounce is generated

ConditionBounce?
Remote 5xx on the final dotyes, immediately
Remote 5xx on RCPT TOyes, for that recipient
attempts reached queue.max_attemptsyes
Null MXyes
Recipient is a local address that does not existgenerated at RCPT TO time, not by the queue
queue.bounce_on_failure = falseno bounce; the failure is recorded in mail_queue and delivery_attempts and shown in Admin
The message came from a local submission and the sender is still connectedthe submission already returned 250; the bounce is the only feedback path

The bounce carries the original Message-ID in In-Reply-To and References, so a client can thread "Undelivered Mail Returned to Sender" with the message the user actually sent.


14. Diagnosing SMTP by hand

Specification §44 names the tools. All of these work against a locally running server; port 25 is the inbound listener, 587 the submission listener.

# Greeting, capabilities and a full transaction, unencrypted.
swaks --server 127.0.0.1 --port 25 --from [email protected] --to [email protected] --body "test"

# The same, forcing STARTTLS.
swaks --server 127.0.0.1 --port 587 --tls --auth PLAIN --auth-user [email protected] --auth-password '…'

# By hand: type EHLO, MAIL FROM, RCPT TO, DATA.
nc 127.0.0.1 25

# Which capabilities does the submission port advertise, and does it offer STARTTLS?
openssl s_client -starttls smtp -connect 127.0.0.1:587 -crlf

# Implicit TLS on 465.
openssl s_client -connect 127.0.0.1:465

# Is the MX record the one Ferroma will use?
dig +short MX example.com

Illustrative output of the greeting and capability exchange:

220 mail.example.com Ferroma ESMTP ready
EHLO client.example.net
250-mail.example.com
250-PIPELINING
250-SIZE 26214400
250-8BITMIME
250-ENHANCEDSTATUSCODES
250-SMTPUTF8
250-STARTTLS
250-AUTH PLAIN LOGIN
250 HELP
MAIL FROM:<[email protected]>
250 2.1.0 Ok
RCPT TO:<[email protected]>
250 2.1.5 Ok
DATA
354 End data with <CR><LF>.<CR><LF>
Subject: test

hello
.
250 2.0.0 Ok: queued as 4821
QUIT
221 2.0.0 Bye

For "mail is not arriving" and "the queue is growing", see the symptom-keyed command list in deployment.md §11.


TopicDocument
Ports, DNS records, TLS termination, first-run setupdeployment.md
SPF, DKIM, DMARC, HTML sanitisation, relay defence rationalesecurity.md
Where the bytes end up, and the messages / mail_queue schemastorage.md
Retry state machine from the client's point of view, Outboxsync.md, client.md
The API that queues mail: POST /api/v1/messagesapi.md §5.2
Crate layering and the request lifecyclearchitecture.md
Ferroma · MIT OR Apache-2.0 · built from docs/