Protocol conformance for custom mail servers
Looking up a mailbox notes that a domain’s MX
servers must “support the email program protocol.” This page spells out
exactly what that means for anyone weighing a from-scratch server, or an
existing open-source MTA, against simply running
sithbitd — and draws a line the rest of the docs
don’t draw explicitly: decentralized storage is an optional layer on top
of the protocol, not a requirement of it.
What conformance actually requires
Nothing on-chain checks which software sent a transaction — only whether it’s a valid one. A server “supports the protocol” if it does all of the following, regardless of implementation language or storage choice:
- Builds and submits valid
MailInstructions. The account layouts, PDA seeds, and borsh encoding are defined once inmail_modelandsolana_common/program_common(see the Program & PDA reference) — any server deriving the same PDAs and encoding the same instructions participates in the economic model identically to the reference implementation. - Checks postage before accepting mail. A sender’s frombox stamp balance
must be checked (and decremented on accept) via the
mail_apigRPC service or direct RPC — this is what prices out spam; see Fromboxes. - Seals the body to the recipient before it ever leaves the accepting
server. Mail is encrypted with
crypto_box_sealto the recipient’s wallet or their published delegated key — see How sealed-box encryption works. - Stores the sealed body somewhere reachable by the CID recorded
on-chain. The
Emailaccount’scidfield is an opaque byte string as far asmail_programis concerned — the program never touches IPFS and never validates that acidcorresponds to real, distributed content (see the next section). - Authenticates IMAP/ POP/SMTP sessions. Either wallet-signature SASL PLAIN (verified against the connecting address, no stored secret) or a stored/sealed mail password for clients limited to CRAM-MD5/APOP — see The mail password for why both paths exist.
- Signs outbound mail with DKIM and checks SPF/DMARC on relayed inbound
mail. The chain trusts a domain’s active authority completely for
relayed mail; verifying the real sender is the operator’s job — see
Trust assumptions and threat model.
The reference server implements full DMARC (RFC 9989, which obsoletes
RFC 7489) evaluation and disposition — alignment folded to the
organizational domain by the §4.10 DNS tree walk,
p=rejectbounces,p=quarantine→ the recipient’sJunkfolder, and the subdomain policiessp=/np=— selectable viasender_auth = "dmarc". Enforcement is all-or-nothing: 9989 retired thepct=sampling tag, so a publishedpct=is inert and no fraction of failing mail escapes the policy. It also emits RFC 9990 §3.1 aggregate (rua) reports (gzip XML in thedmarc-2.0namespace, one per policy domain per interval, with the §4 external-destination check enforced on everyruatarget) when aggregate reporting is enabled. It also emits RFC 9991 §2 failure/forensic (ruf) reports when forensic reporting is enabled — one RFC 5965 ARFmessage/feedback-reportper DMARC failure, attributing the failing connection withSource-IPand, whenever the peer’s TCP source port is known, the RFC 6692Source-Portfield (an unknown port omits the field; a fakeSource-Port: 0is never emitted), sent direct and best-effort to the domain’srufaddresses, headers-only by default (text/rfc822-headers) with the full message opt-in, and 9991’s own §5 external-destination gate applied to everyruftarget (a target that fails the live-DNS authorization check declines the report). That publish-then-gate path is also what satisfies RFC 6650’s applicability statement: reports go only where the receiving party has asked for them. The receiving side — ingesting other operators’ aggregate reports about your own domains — is an optional operator feature, not a conformance requirement; see aggregate-report ingestion below.
None of this requires joining a peer-to-peer network. It requires chain-ABI conformance, the sealed-box crypto, and some addressable place to put ciphertext.
Decentralization is optional, not required
The cid field’s name suggests IPFS, and the reference implementation does
use real content identifiers — but two things are worth being precise about:
- The trustless read path doesn’t re-verify the hash either. The
client-side reader (
webclients/shared/trustless.js) fetches{gateway}/ipfs/{cid}and unseals whatever comes back; it does not recompute the CID’s multihash and compare it to the fetched bytes. The tamper-evidence in practice comes from the sealed-box authenticated encryption, not from CID verification: swap or corrupt the bytes behind a CID and the recipient’s decrypt fails loudly, whether or not anything ever checked the hash. A CID-shaped identifier resolved through any addressable store — not necessarily a distributed IPFS swarm — gives the recipient the same cryptographic guarantee. - The reference implementation itself defaults to no swarm.
sithbitd’s embedded IPFS node (ipfs_daemon/ipfs_swarm) ships withswarm = None— no libp2p, no Kademlia DHT, no bitswap — unless an operator explicitly configures[swarm]with public listen addresses andprovide = true(see sithbit-ipfsd). Out of the box,sithbitdalready runs in exactly the mode this page is describing: chain economics fully live, bodies content-addressed and servable over a private HTTP gateway, with zero participation in the public IPFS network.
So a server that stores sealed bodies in a conventional store (a database, a
filesystem, S3) behind its own GET /ipfs/{cid}-shaped endpoint, without
ever joining the public swarm, is not a lesser or non-conformant
implementation — it’s the same posture the reference implementation
defaults to. Real distribution (pinning to the public network, or running a
shared-bucket cluster) is an
enhancement you opt into, layered on top of a protocol that doesn’t require
it.
What you give up by skipping real distribution, honestly stated:
- Availability beyond your own infrastructure. If your server or its storage goes down, there is no other peer or pinning service holding a copy — unlike content actually pinned onto the public network, or handed to a pinning service (see IPFS storage: benefits to users).
- No public discoverability. A stranger running a generic IPFS client
against
<cid>won’t find your privately-stored bytes — only your own gateway resolves them. - Integrity is unaffected. Recipients still get the same tamper-evidence either way, since it comes from the encryption, not the network.
RFC 8314: cleartext is obsolete — TLS before credentials
RFC 8314 (“Cleartext Considered
Obsolete: Use of Transport Layer Security for Email Submission and Access”)
requires mail submission and mail access to run over TLS, and requires a
server to refuse authentication on an unprotected connection rather than
inviting credentials into the clear. The reference servers enforce this by
default in production posture — require_tls is on out of the box for the SMTP
submission edge, IMAP, and POP — declining credentials until the connection is
protected, each in its protocol-appropriate shape:
- SMTP submission refuses
AUTH(andMAIL FROM) before STARTTLS with a530 5.7.0 Must issue a STARTTLS command first, and hides theAUTHcapability from the EHLO response so clients aren’t invited to authenticate in the clear. The gate is therequire_tls && !tls_activecheck insmtp_session/src/session/ready.rs(theauthandmailhandlers), and the server default isrequire_tls.unwrap_or(mode == Submission)insmtp_server/src/config.rs— on for the submission edge. - IMAP advertises
LOGINDISABLEDin the pre-TLS CAPABILITY and refusesLOGIN/AUTHENTICATEwithNO [PRIVACYREQUIRED](RFC 5530) until STARTTLS completes. The gate iscredentials_refusalinimap_session/src/session/not_authenticated.rs;require_tlsdefaults totrueinimap_server/src/config.rs. - POP3 refuses
USER/PASSbeforeSTLSwith-ERR Must issue STLS command first, and discards any pre-TLSUSERafter the upgrade (a STARTTLS injection defense). The gate istls_gateinpop3_proto/src/session/authorization.rs;require_tlsdefaults totrueinpop_server/src/config.rs.
On the client side of submission, the spooler’s [spooler.smarthost]
relay path can dial with implicit TLS (implicit_tls = true — the
transport §3.3 prefers for submission) instead of STARTTLS — see the
[spooler] reference.
These are runtime policy gates, not compile-time ones: the legacy server compiled its TLS gate out of Debug builds, and the reference servers deliberately do not. The zero-config developer stack (loopback plaintext binds) is a separate, explicitly opt-in convenience, outside this production conformance claim.
The version floor underneath these gates. Per
RFC 8996 (TLS 1.0/1.1 deprecated) and
RFC 8997 (which updates RFC 8314 with
a TLS ≥ 1.2 floor for email), no TLS surface in the stack can negotiate below
TLS 1.2: the shared acceptors in server_common, the outbound relay connector,
and the spooler’s two HTTPS report fetchers (MTA-STS policy fetch, TLS-RPT
submission) all ride rustls, whose supported set is TLS 1.3 and 1.2 only. The
floor is asserted rather than inherited — test fences hold it at both the
acceptors and the outbound connector, and the report fetchers pin the rustls
backend in code — so a custom server matching this conformance claim should
refuse the deprecated versions too.
RFC 7162: CONDSTORE — quick flag-change resynchronization
RFC 7162 (“IMAP Extensions: Quick
Flag Changes Resynchronization (CONDSTORE) and Quick Mailbox
Resynchronization (QRESYNC)”) lets a returning IMAP client fetch only what
changed since it last looked, instead of re-reading every flag. This is a
mail-access feature of the reference server, not a requirement of the
economic protocol — a custom server without it is fully conformant — but a
custom server that does advertise CONDSTORE should match this posture:
- The CONDSTORE half is implemented in full. The capability is
advertised once the connection may carry credentials at all — live
TLS, or an instance configured with
require_tls = false— which on a TLS connection is before login rather than after it, the same gate the other post-credential extensions ride. Every §3.1 surface is there:ENABLE CONDSTORE,SELECT/EXAMINE (CONDSTORE), the unconditionalHIGHESTMODSEQ/NOMODSEQresponse code on select,STATUS (HIGHESTMODSEQ), theSEARCH MODSEQkey (with the untagged(MODSEQ n)search tail, and §3.1.5’s entry-name/entry-type prefix accepted and ignored), theMODSEQfetch item andCHANGEDSINCEfetch modifier (which addsMODSEQimplicitly), andSTORE UNCHANGEDSINCEansweringOK [MODIFIED …]with the messages it left untouched — sequence numbers forSTORE, UIDs forUID STORE. Flag echoes suppressed by.SILENTstill carry their MODSEQ-only updates. - Mod-sequences are real, per message, on every store backend. Each
mailbox carries a monotonic highest-mod-sequence counter and each message
its own mod-sequence, bumped once per flag-changing operation, across all
six
mail_storebackends. A production mailbox is always tracked (the counter is born at 1), soNOMODSEQnever appears in production; a mailbox genuinely without tracking answersNOMODSEQand refuses CONDSTORE parameters with a taggedBAD, the §3.1.2.2 MUST. - The §3.1.4.1 follow-up FETCH after an implicit
\Seenis sent. When a non-PEEK body fetch implicitly sets\Seenand the flag persist succeeds, the session emits one untaggedFETCHper changed message before the taggedOK— the same code path as theSTOREflag echo, so the two shapes can never drift:FLAGSalways (FETCH has no.SILENTform),MODSEQwhen the session is CONDSTORE-enabled (folded in after the persist, so it reports the new mod-sequence), andUIDforUID FETCH. A client fetching the body of an unseen message therefore sees its flags twice — once inline in the FETCH data, with\Seenforce-added before the persist, and again in the post-persist echo — which is exactly what the RFC mandates. On a failed persist there is no echo (the FETCH data itself already went out) and the taggedOKstill follows. The echo’sMODSEQrides the same codec as every other FETCH response, so the wire deviation below applies to it too. - The QRESYNC half is not implemented. It is queued as its own
follow-up wave (expunged-UID tombstones and SELECT-time resync are the
bulk of it); until then
SELECT … (QRESYNC …)parameters andVANISHEDare refused with a taggedBADrather than half-honored. - One known wire deviation, stated honestly — fixed upstream, not yet
released. The adopted
imap-codec(pinned at 2.0.0-alpha.9) serializes the FETCH data item asMODSEQ 4where RFC 7162’sfetch-mod-respgrammar requiresMODSEQ (4)— the typed value the session emits is correct; the codec owns the missing parentheses. Its own parser, by contrast, demands them, so the crate cannot read back what it writes. Confirmed on the wire; interop risk with strict clients is low but nonzero.HIGHESTMODSEQ,SEARCH’s(MODSEQ n)tail, and[MODIFIED …]are unaffected. - Fix history: filed 2026-08-06, fixed and merged upstream 2026-08-18,
awaiting a crates.io release. Probed 2026-08-05: the pins were
already at the then-current tips (
imap-codec2.0.0-alpha.9,imap-types2.0.0-alpha.7,imap-next0.3.4), the encoder’sMODSEQ {value}arm was byte-identical on upstreammain, and no existing issue or pull request named the missing parentheses — so one was filed: imap-codec#722. imap-codec#723 fixed it (MODSEQ {value}→MODSEQ ({value}), plus two regression tests gated onext_condstore_qresync) and merged intomainon 2026-08-18 — but as of that date crates.io’s newest publishedimap-codecversion is still 2.0.0-alpha.9 (2026-07-19), so there is no release carrying the fix to pin yet. The QRESYNC wave stays queued behind the release rather than the merge: a local patch or fork of the codec would put this project’s servers on a private wire encoder for one FETCH item, which is a worse conformance position than one documented deviation, and QRESYNC would multiply that item, since its resync responses are MODSEQ-carrying FETCHes. Unblock signal: a publishedimap-codecrelease at or after the #723 merge — check crates.io’s version list first thing before re-probing the repository.
RFC 7889: APPENDLIMIT — advertising the APPEND ceiling
RFC 7889 (“The IMAP APPENDLIMIT
Extension”) lets a server publish the largest message APPEND will take, so
a client can size an upload instead of learning the limit by having a
finished upload refused. Like CONDSTORE above this is a mail-access
convenience of the reference server, not a requirement of the economic
protocol — a custom server that advertises nothing is fully conformant — but
one that does advertise APPENDLIMIT should match this posture:
- Form (a) only: one ceiling for the whole server. §2 defines two
shapes and the reference server implements the first, where the
capability carries the value in its own name (
APPENDLIMIT=<n>) — the form the RFC reserves for a limit that is the same in every mailbox. The other shape, a bareAPPENDLIMITatom announcing that limits vary and must be read one mailbox at a time, is not implemented, and neither is theSTATUS (APPENDLIMIT)item that shape depends on. - The advertised number is the enforced one, structurally.
<n>isimap.max_message_size(25 MiB by default) — the very setting the driver hands theimap-nextflow layer as its literal ceiling. One setting feeds both, so an advertised limit that differed from the enforced one is unrepresentable rather than merely unlikely. The boundary matches the RFC’s wording too: a literal is refused only when it is larger than the ceiling, so<n>is the largest size that will be accepted, not the first one refused. - Advertised before authentication as well as after, which §2 permits
explicitly and which is most of the point: a client reads the ceiling out
of the greeting’s
[CAPABILITY …]code and can size its very firstAPPEND. - The §4
[TOOBIG]response code is met in prose, not as a code — gap one, on the synchronizing literal. §4 asks that an oversizeAPPENDbe refused with a tagged response carrying the[TOOBIG]code that RFC 4469 defines. An oversize synchronizing literal is refused here with exactly one line, a taggedBADwhose text readsTOOBIG: message exceeds the APPENDLIMIT of <n> bytes— the code’s name, and the very number the greeting advertised, sent in place of the continuation request and so before any of the payload is buffered. What it is not is a resp-text-code: the name rides unbracketed, as the first word of free text, so a client matching on[TOOBIG]still reads a genericBADand only a human reads the reason. That is a property of the pinnedimap-nextrather than a shortcut taken here. It pre-builds the rejection itself with the response code hardcoded toNone, and the one seam it exposes is the reject text, which it validates as continuation-request text — whose constructor refuses a leading[outright (imap-types’ “Ambiguity #1”, guarding an ambiguity that cannot arise for aBAD). A trueNO [TOOBIG]is therefore unbuildable on the pinned version without forking the dependency, which would put this project’s servers on a private flow layer for one response code — the same trade the CONDSTORE deviation above declines, and declined the same way. - Gap two, the wider one: a non-synchronizing (
LITERAL-) literal is never told anything. There is no continuation request to refuse in, so the pinnedimap-nextpoisons the message, keeps reading the payload it cannot stop the client sending, and surfaces the result afterwards as a generic malformed-message error — indistinguishable from a syntax error, so the server answers with its ordinary untagged* BAD could not parse command. NoTOOBIG, no ceiling, and theAPPEND’s own tag is never completed at all, which a client waiting on its tagged reply experiences as a hang. Upstream does this deliberately: discarding the literal early would risk reading the payload as commands. The connection itself survives — the next command is answered normally — and the whole observed shape is pinned by a driver test written as a tripwire for the day animap-nextbump improves it, asserting what a client sees today rather than what the RFC wants. Both gaps are queued behind that bump. Advertising the limit is what makes them visible, and also what makes them rarely reached: a client that readsAPPENDLIMITnever sends the upload that would hit it.
RFC 9208: QUOTA — reading the wallet’s storage cap
RFC 9208 (“IMAP QUOTA Extension”,
obsoleting RFC 2087) lets a client read how full its account is instead of
learning by having an upload refused. Like APPENDLIMIT above this is a
mail-access convenience of the reference server, not a requirement of the
economic protocol — a custom server that advertises nothing is fully
conformant — but one that does advertise QUOTA should match this
posture:
- GETQUOTA and GETQUOTAROOT are served; SETQUOTA is parsed but always
refused. The storage ceiling is operator configuration —
max_wallet_bytes— never client-settable, soSETQUOTAanswers a taggedNO(quota limits are set by the operator). The capability advertisement matches:QUOTAplus the mandatory per-resourceQUOTA=RES-STORAGE, and neverQUOTASET, which would claim the refusal away. The pair rides the same gate asENABLE/IDLE— absent before the connection is protected, present whereverAUTH=is — and the three commands themselves are authenticated-state, per §4.1. - One quota root, existing exactly while a cap applies. With a cap
set, every mailbox belongs to the conventional default root
""— the quota is per wallet, not per mailbox, so one root carries the account’s singleSTORAGEpair, andGETQUOTAon any other root name is refused without a storage read. With the cap unset (0, the default) no root exists at all:GETQUOTAROOTanswers an empty roots list with noQUOTAline following, andGETQUOTA ""answersNO no such quota root. An unbounded account has no quota, rather than an invented huge one. - GETQUOTAROOT answers for any mailbox name, by design. Because the quota is per-wallet, the answer is the same whatever the name, so only the name’s shape is checked — the mailbox need not exist, which the RFC permits (asking about a name before creating it is legal).
- STORAGE usage is the enforced stored-bytes meter, not per-copy
message-size arithmetic — the deliberate deviation. Usage is read from
the very meter the backend refuses writes against: the wallet’s
aggregate stored bytes, in which a blob is counted once however many
of the wallet’s mailboxes reference it. A client’s own reconciliation
arithmetic — summing each copy’s
RFC822.SIZEacross its folder listings, the model RFC 9208’sSTORAGEestimate describes — counts a message copied into two folders twice, so such a client will see less usage inQUOTAresponses than it computes whenever copies exist. That is the honest number: it is the quantity the quota actually enforces, so the* QUOTAline, the[OVERQUOTA]refusal, and the operator’s configured ceiling can never disagree — advertising the per-copy sum instead would overstate fullness, showing an account as full while the enforced meter still admits writes. - Units round conservatively in both directions.
STORAGEcounts 1024-octet blocks; the byte meter converts by rounding usage up, so a partially used block is never reported as free, and the limit down, so the advertised allowance never exceeds what the enforced byte ceiling admits. The edge case is deliberate: a cap under 1024 bytes advertises a limit of0blocks — wire-legal (the RFC’s quota grammar admits zero) and honest, since such a ceiling admits no full block.
RFC 2177: IDLE — the push watch, and its own deadline
RFC 2177 (“IMAP4 IDLE command”)
lets a client park a connection inside IDLE and be told when its
mailbox changes, instead of polling NOOP on a timer. Like CONDSTORE and
APPENDLIMIT above this is a mail-access feature of the reference server
rather than a requirement of the economic protocol — a custom server
without it is fully conformant — but one that does advertise IDLE
should match this posture, and should think hardest about the deadline
the command runs under:
- IDLE is implemented, and advertised with the other post-credential
extensions.
IDLErides alongsideENABLE,MOVE,UNSELECT,NAMESPACE,UIDPLUSandCONDSTOREin the capability set offered once the connection may carry credentials at all (live TLS, or an instance configured withrequire_tls = false) — unlikeAPPENDLIMIT, which is advertised before that too. The command is accepted in the authenticated and selected states, answered with a+ idlingcontinuation, and ended byDONEwith a taggedOK IDLE terminated. With a mailbox selected the session pushes an untaggedEXISTSon every change it learns of, from two sources at once: the in-process watch signal (this process’s own deliveries,APPENDs andCOPYs) and the store’s cross-process change wait, which is how a delivery handled by a sibling process reaches this idler. AnIDLEwith no mailbox selected is accepted as well — legal, and simply with nothing to push. - An accepted IDLE runs under a deadline of its own, not the
listener’s. A client inside
IDLEis deliberately silent for far longer than a transport read deadline tolerates, so while the exchange is live the connection’s read deadline isidle_command_timeout_secsrather than theidle_timeout_secsthe listener applies to every other read, from its shared limits. At shipped defaults the two coincide at 1800 s (30 minutes), so the substitution raises nothing: the shared deadline already clears RFC 3501 §5.4’s requirement that an inactivity autologout timer be at least 30 minutes, and it is that base default — not this seam — that buys §5.4 conformance out of the box. The seam is real anyway, because the two settings move independently: an operator who lowers the shared deadline to keep a tighter grip on every other command does not thereby hang up on a conformant idler, which is exactly what the driver test that drops the shared deadline to five seconds pins. The saved value is restored the moment IDLE ends by any path (DONE, or the IDLE deadline itself firing), through the one funnel every exit takes, so the IDLE deadline cannot outlive the command. The setting sits at the top level of the IMAP configuration, besidemax_login_attempts, rather than in the shared limits table, because it bounds one command and not the listener. A server that runsIDLEunder one shared read deadline instead is safe only for as long as that single deadline stays generous — the day it is tuned down for ordinary commands it acquires the bug this seam exists to prevent: the protocol asks the client for silence and the transport hangs up on it for complying. - 1800 s is chosen against the RFC’s advice to clients, and clears
it by a minute. RFC 2177 requires nothing of the server here. It
permits a server to consider an idling client inactive and to log it
off at the end of its inactivity timeout, and on exactly that basis it
advises clients to terminate and re-issue
IDLEat least every 29 minutes. The default is that interval plus a minute of slack, so a client following the advice re-issues before this server’s deadline and never reaches it. Stated precisely, because the two are easy to swap: 29 minutes is guidance to clients, not a floor the RFC imposes on servers — an operator lowering this setting under it is choosing to cut conformant idlers off, and one raising it is choosing to hold connection slots open longer. - When the deadline does fire, the client is told. A client that
idles past the allowance without
DONEor a re-issue gets an untagged* BYE IDLE timed out, flushed, and then a clean close — where the non-IDLE read deadline’s expiry is, and deliberately remains, a silent EOF. Both shapes are pinned to the tick by driver tests on a paused clock: survival well past the shared deadline (with anEXISTSpush and a completedDONEproving liveness, not merely absence of EOF), silence one tick short of the IDLE allowance, and BYE-then-close on the tick that completes it. A custom server can reasonably choose a different allowance; sending something before closing an idler is the part worth copying.
RFC 8461: MTA-STS — downgrade-resistant outbound TLS
RFC 8461 (“SMTP MTA Strict Transport
Security (MTA-STS)”) lets a receiving domain declare that its MX hosts support
TLS with a valid certificate, so a sending MTA can refuse to deliver over an
unauthenticated or plaintext channel — closing the STARTTLS-stripping downgrade
that opportunistic TLS (RFC 7435) leaves open. The relay implements the sending
side, on by default (the mta_sts switch in the
[spooler] reference);
domain-sithbit implements the publishing side (the last bullet):
- Policy discovery and parsing live in
mail_spooler/src/mta_sts.rs: the_mta-sts.<domain>TXT probe (§3.1, with a transient/definitive split so a resolver hiccup is never mistaken for policy withdrawal), the HTTPS fetch ofhttps://mta-sts.<domain>/.well-known/mta-sts.txt(§3.3 — 10-second timeout, redirects refused, 64 KiB body cap), the §3.2 policy parser, and the §4.1 MX-pattern matcher (exact match or a*.wildcard covering exactly one leftmost label). - Enforce mode is the relay’s
attempt_enforcedbranch inmail_spooler/src/pipeline/relay.rs: only MX targets matching the policy’smxpatterns are dialed, each demanding STARTTLS with a certificate verified against the webpki roots for the MX hostname (TlsVerify::Strictinmail_spooler/src/smtp_out.rs— the same strict verifier the smarthost path uses). Any TLS failure — STARTTLS missing or refused, a handshake or certificate error — and zero matching MX targets defer the mail on the normal retry schedule (§5’s transient handling): enforce never bounces on a TLS failure and never falls back to plaintext. - The policy cache honors §5.1: policies are cached per domain for their
max_age(clamped to one year) and keyed to the TXT record’sidfor rollover, and an unexpired cached policy keeps being applied through a DNS strip or transient resolver failure — which is what defeats record-removal attacks against senders that have already seen the policy. - Testing mode delivers opportunistically and logs (
tracing::warn!) each MX target that would fail under enforce. With[spooler.tlsrpt]enabled, each dialed attempt also records an RFC 8460 result row under its STS policy context — see TLS-RPT below — which is the feedback loop testing mode is designed to be watched through. - The publish side lives in
domain-sithbit(domain_sithbit/src/mta_sts.rs), servingGET /.well-known/mta-sts.txtfor the domains the instance fronts. The §3.2 serializer renders the policy body once at startup from the optional[mta_sts]config section —version/mode/mx/max_age, every line CRLF-terminated — and validation is fail-fast at boot: an unknownmodename, anenforce/testingpolicy with nomxpattern (§3.2 requires one), or amax_ageabove the one-year ceiling refuses to start rather than serving a policy senders would reject or silently shorten (the ceiling is fenced equal to the consume side’s clamp by a round-trip test through the spooler’s parser). With no[mta_sts]section the route answers 404 — publication is opt-in per instance, and there is no per-domain policy map: one instance serves one policy. HTTPS is the fronting proxy’s job (senders fetchhttps://mta-sts.<domain>/.well-known/mta-sts.txt, so the proxy needs a certificate for that hostname), no TLS-RPT (RFC 8460) reports are consumed on this side either, and the_mta-sts.<domain>discovery TXT record — with itsidbump on every policy change — stays operator-managed DNS: see DNS setup.
RFC 7672: DANE — DNSSEC-pinned outbound TLS
RFC 7672 (“SMTP Security via
Opportunistic DANE TLS”) lets a receiving domain pin its MX hosts’ TLS
certificates in DNSSEC-signed TLSA records, closing the same
STARTTLS-stripping downgrade as MTA-STS — but with DNSSEC as the trust base,
so it has neither the trust-on-first-use nor the cache-lifetime residual. The
relay implements the sending side, on by default (the dane switch in the
[spooler] reference),
preferring DANE over MTA-STS wherever both apply:
- TLSA discovery and classification live in
mail_spooler/src/dane.rs: the_25._tcp.<mx-host>lookup rides a DNSSEC-validating resolver, and every answer record’s validation proof is checked — one bogus or unsigned hop taints the whole chain (§2.2.2). The §3.1.3 usability rules apply: DANE-EE(3) and DANE-TA(2) with known selectors/matching types are usable; PKIX-TA(0)/PKIX-EE(1) and unknown registry values are not. The per-host verdict is one of: verify (usable records — pin the handshake), mandatory unauthenticated TLS (a validated RRset that is all-unusable, §2.2/§3.1.3), not applicable (no TLSA, unsigned zone, or a validated denial — the host stays on the MTA-STS/opportunistic path), or unusable (bogus validation or a failed lookup — the host is never dialed). - The DANE verifier (
TlsVerify::Daneinmail_spooler/src/smtp_out.rs) matches the presented chain against the records: DANE-EE matches the end-entity certificate alone, with name, expiry, and chain checks all skipped (§3.1.1 — the DNSSEC-signed record is the trust statement); DANE-TA requires some presented chain certificate to match a TLSA record AND the end entity to path-validate (rustls-webpki) with that certificate as the trust anchor, expiry and the §3.2.3 server-name check enforced. Full-certificate and SPKI selectors, exact/SHA-256/SHA-512 matching. - Relay composition is the per-host planner in
mail_spooler/src/pipeline/relay.rs(plan_hosts): DANE applies to an MX host only when the MX RRset itself validated (§2.2.1) AND that host’s TLSA chain validated with usable records — and then it outranks an MTA-STS policy, including itsmxpattern filter (RFC 8461 §2). An all-unusable TLSA set demands TLS without authentication, except under an MTA-STS enforce policy, which stays the stricter floor. Any DANE failure — a handshake that matches no record, a bogus TLSA chain, every host excluded — defers the mail on the normal retry schedule, never a plaintext fallback and never a bounce. - MX-less domains (§2.2.1). A domain with no MX record — the implicit-A
fallback, where the connect host is the domain itself — is not excluded
from DANE: when the denial of MX existence is DNSSEC-proven (a Secure
validation proof on the negative answer’s SOA), the fallback counts as a
validated answer, and TLSA records published at
_25._tcp.<domain>are consulted and enforced exactly as for an MX host. The honest subset that remains is narrower: a denial that arrives without a validatable SOA — or whose proof is insecure, indeterminate, or bogus — stays insecure and skips DANE, and unsigned MX-less zones behave exactly as before (opportunistic TLS). - Documented subsets. The TLSA base domain is the MX hostname as published: CNAME chains are followed (each hop must validate Secure), but the §2.2.3 alternate base-domain derivation from A/AAAA-expansion is not performed — a subset that only ever loosens toward today’s opportunistic posture, never past a published policy.
- Observability is warn-level logging on unusable TLSA chains and DANE
handshake failures — and, with
[spooler.tlsrpt]enabled, every dialed attempt (and every host DANE excludes before dialing) records an RFC 8460 result row under its TLSA policy context; see TLS-RPT below. - Scope: sending side only. SithBit does not generate TLSA records for its own domains; an operator who wants inbound protection publishes them in their DNSSEC-signed zone — see DNS setup.
RFC 8460: TLS-RPT — SMTP TLS reporting
RFC 8460 (“SMTP TLS Reporting”)
is the feedback loop for the two mechanisms above: a sending MTA records how
its outbound TLS sessions actually went — per recipient domain, per
governing policy — and delivers a daily aggregate report to whatever
addresses that domain names in its _smtp._tls TLSRPT record, so the
domain’s operator sees downgrade attempts and misconfigured MX hosts from
the senders’ vantage point. The reference server implements the sending
side — recording and reporting — behind the single [spooler.tlsrpt]
switch, off by default (the
configuration reference):
- Result recording happens in the relay’s per-host attempt loop
(
mail_spooler/src/pipeline/tlsrpt.rs), direct-to-MX path only: each dialed attempt that produced TLS evidence lands one row carrying the RFC 8460 §4.2 policy context that governed it —tlsa(the verified TLSA records rendered as policy strings),sts(the MTA-STS policy body), orno-policy-found— and either a success tally or a §4.4failure-detailsblock. Hosts a policy excludes before dialing land never-dialed failure rows too: a DANE-unusable host (excluded at the resolver/proof level, no TLSA records assessed) recordsdnssec-invalidwith a baretlsapolicy block, and an MX target outside an enforce-mode MTA-STS policy recordssts-policy-invalidrendering the enforce policy body — the planner’s diagnostic ridesfailure-reason-code. Retries re-record, and identical rows aggregate byfailed-session-countat fold time. Recording is strictly observational: a recorded failure still defers/retries exactly as the enforce sections above describe, and a failed row write warns without ever changing a delivery outcome. Smarthost mode records nothing — a smarthost’s TLS posture is not the recipient domain’s. - The report worker (
mail_spooler/src/pipeline/tlsrpt_report.rs) runs on the same switch: every 24 hours (a fixed period, not a setting) it folds the pending rows into one RFC 8460 report per recipient domain, discovers the domain’srua=targets from its_smtp._tls.<domain>TXT record, and delivers over both channels —mailto:targets ride the normal outbound relay, DKIM-signed on spool entry (so the configuredemailmust be a local, DKIM-signable address; its domain doubles as the report’s submitter identity), andhttps:targets receive the gzip-compressed JSON directly as anapplication/tlsrpt+gzipPOST (10-second timeout, redirects refused — the MTA-STS fetcher’s settings). Unlike DMARC aggregate reporting there is no §7.1-style external-destination authorization gate: RFC 8460 defines none, so a report goes wherever the published record points. - Delivery bookkeeping, stated honestly. Rows are deleted only after a
domain’s report reached every target, so a crash between send and
delete — or a partial multi-target failure, which defers the whole
domain — can re-deliver the window; the deterministic report-id
(
<end-time>_<domain>) lets receivers de-duplicate. A domain that definitively publishes no TLSRPT record (or one naming norua=target) has its rows dropped rather than pinned forever; only transient DNS or delivery failures keep rows pending for the next tick. An unparseable pending row is poison: warned and deleted. - Recording gaps, on record. The rustls seam collapses the RFC 8460
certificate result taxonomy (
certificate-expired,certificate-host-mismatch, …) into the generalvalidation-failurecode, with the raw TLS error detail preserved infailure-reason-code. Success rows are flag-truthful — recorded only when the outcome says the conversation actually ended on TLS — so a completed plaintext opportunistic session (TLS never negotiated, including a declined STARTTLS offer that continued in the clear) records no row at all: neither a §4.1 TLS session nor a failed attempt. The honest limit that remains: the seam cannot distinguish “STARTTLS never offered” from “offered but declined, continued plaintext” — both go unrecorded, withstarttls-not-supportedfailure rows reserved for enforced postures that abort. Unreachable or timed-out hosts still record nothing, and MTA-STStesting-mode mismatches are warn-logged, never recorded. - No receiving side. Ingesting other operators’ TLS reports about your
own domains is not implemented — inbound reports are ordinary delivered
mail (there is no TLS-RPT sibling of the
DMARC
ruaingestion below). To request reports about a domain you operate, publish the TLSRPT record — see DNS setup.
RFCs 7372, 8301, and 8463: SPF and DKIM updates — and the RFC 9989 From-extraction disposition
The SPF and DKIM rows in the standards page carry three update RFCs, and the DMARC row one disposition, whose behavior deserves the same honest spelling-out as the sections around this one. None of this is a requirement of the economic protocol — it is what the reference server does, and the posture a custom server should match if it claims the same rows:
- RFC 7372: the SPF hardfail rejection code. A published SPF hardfail
(
v=spf1 -all) rejects atMAIL FROMwith554carrying enhanced status5.7.23— the “SPF validation failed” code RFC 7372 §3.2 registers for exactly this outcome. Anything short of a publishedFail— softfail, neutral, none, resolver errors — never rejects there; those verdicts land in theAuthentication-Resultsheader instead. The other 7372 codes with an emission site: the DMARC bounce rejects with554carrying5.7.26— the “multiple authentication checks failed” code RFC 7372 §3.3 registers for a DMARC rejection — still naming the offending From domain. The remaining 7372 codes have no emission site by policy design, not omission: SPF temperror/permerror never reject, and DKIM failures never reject standalone in anysender_authmode. - RFC 8301: the DKIM algorithm floor, held on both sides. The signer is
rsa-sha256-only by construction — no configuration can produce an
rsa-sha1signature, satisfying RFC 8301’s signer MUST NOT. On the verify side, the adoptedmail-auth(0.11.1) still verifiesrsa-sha1, so the server post-filters every DKIM verification result: a verifiedrsa-sha1signature is downgraded to failure before it reachesAuthentication-Results, aggregate/forensic reporting, or DMARC alignment input — report and disposition both see it as failed, with the signature evidence kept so reporting still names the signing domain. The filter is fenced in both directions (the downgrade is load-bearing for DMARC; rsa-sha256 flows through untouched), and one fence deliberately asserts thatmail-authstill verifiesrsa-sha1: a future dependency version that stops doing so turns that assertion red, which is the signal that the filter and its fences can retire. - RFC 8463: ed25519-sha256, verified and optionally dual-signed. Inbound
RFC 8463
ed25519-sha256signatures verify topass(fenced with the RFC’s own appendix-A test key). Outbound, each[spooler.dkim]entry can opt into dual-signing via theed25519_selector/ed25519_key_filepair — the message then carries twoDKIM-Signatureheaders, rsa-sha256 and ed25519-sha256, each signing the same headers and body independently, so verifiers honor whichever algorithm they support; with the pair absent (the default) the entry signs rsa-only, unchanged. The ed25519 public key needs its own selector because one_domainkeyDNS name publishes one key record, and that record’sp=is the raw 32-byte public key base64 (v=DKIM1; k=ed25519; p=…) — not a DER SubjectPublicKeyInfo like RSA’s. The key format and config shape are in the configuration reference. - RFC 9989 §5.3.1: DMARC terminates without a verdict on unextractable
From. When RFC5322.From yields zero author domains (an absent From, or
RFC 6854 group syntax such as
undisclosed-recipients:;) or several differing ones, DMARC evaluation terminates without producing a verdict — exactly what §5.3.1 prescribes (“DMARC validation is not possible and the process terminates”) and nothing more: no reject and no quarantine, even when an apparent sending domain publishesp=reject, and the message proceeds still subject to every other gate (the SPF hardfail rejection above, DNSBL, recipient postage). §5.3.1’s MAY — a receiver may still evaluate the multiple-domain case — is deliberately not taken, and §4.4 places handling of malformed, absent, or repeated From fields outside the spec’s scope. Both termination shapes are fenced as deliberate dispositions, not incidental fallthrough.
RFC 9990: ingesting DMARC aggregate reports
The emitting side of DMARC reporting is covered above; the reference
server also implements the receiving side of
RFC 9990 — what happens when
another operator’s receiver mails an aggregate (rua) report to a domain
you operate. The posture is deliberately
ingest, store, and surface — nothing more:
- Reaching the mailbox at all. An external reporter never holds a
prefunded frombox, so the postage gate would refuse it like any other
stranger. The
[smtp] postmaster_walletsetting implements the RFC 5321 §4.5.1 postmaster exemption in the SMTP driver (smtp_server/src/driver.rs):RCPTto barepostmasterorpostmaster@<local-domain>(case-insensitive, per §4.5.1) skips alias resolution and the frombox/postage check entirely and delivers to the configured wallet — a foreign-domain postmaster stays a relay request. Unset (the default), refusals are byte-identical to the unconfigured behavior. - Parse and store (
mail_spooler/src/adapters/dmarc_rua.rs). With[spooler.dmarc_rua_ingest]enabled, delivery to a matched local recipient (a bare local-part entry matches at any local domain, a full address exactly; default["postmaster"]) parses the raw message with mail-auth’s RFC 9990 parser — MIME wrapping and gzip/zip report bodies handled, and both thedmarc-2.0namespace and RFC 7489-era report bodies accepted — and stores the parsed report, serialized verbatim as JSON, at blob keydmarc_rua/<id>.json. Decoding is bounded: because a compressed attachment says nothing about what expanding it costs, the parser stops reading at 25 MiB decompressed — the same ceiling as[smtp] max_message_size, deliberately one size vocabulary rather than two. A report that exceeds it is simply not parsed, which by the best-effort rule below means it still delivers as ordinary mail. The id isorg_name!report_id!begin!end, modelled on the §3.5.2 report filename convention rather than copied from it, sanitized to[A-Za-z0-9._-](every other byte, including the!separators, becomes_; 200-byte cap). Ingestion is strictly additive: the message still delivers to the mailbox normally, a malformed report warns and delivers, redelivery overwrites the same key idempotently, and relay recipients never trigger it. - Surface. The account API’s admin routes
(
GET /v1/admin/dmarc-reportslist,GET /v1/admin/dmarc-reports/{id}fetch) read the stored JSON back — see account-api, and DNS setup for wiring therua=record to your own deployment.
What is deliberately not implemented, so an operator knows what this feature is not:
- No automated disposition. Auto-disabling or suspending accounts from RUA data was considered and rejected as a category error: aggregate-report rows carry no join key back to local wallets, and the rows failing your policy are almost always third-party spoofers, not your users. The reports exist for a human operator to read.
- No
rufingestion. Failure/forensic reports are emitted (above) but not consumed — inbound ARF messages are ordinary mail. - No pruning. Nothing deletes stored reports yet; they accumulate
under the
dmarc_rua/blob prefix until an operator clears them — a documented limitation, like the TLS-RPT gaps above.
Self-authenticating TLS (optional, for DHT discovery)
Everything above is what a server must do to participate in the economic
protocol. A server that additionally wants to be discovered over the DHT
(rather than published in DNS SRV/A records) opts into one more behavior —
self-authenticating TLS, the connection half of decentralized service
discovery:
- The node presents a self-signed certificate whose key is its delegated node
key. The certificate is not chained to a public CA. It carries the node’s
authority-signed
SignedDelegationin a custom X.509 v3 extension under OID1.3.6.1.4.1.58888.1.1(an unregistered placeholder enterprise number — not IANA-registered; an independent implementation must match the constant. Registering a real PEN is a tracked external prerequisite that MUST replace this arc before mainnet; the mainnet deploy preflight enforces it). The delegation binds{domain, proto, node_pubkey, expiry}and chains to the domain’s on-chainMailDomain.authority. - The client verifies against the chain, not a CA or the hostname. A
conforming client (SithBit’s
NodeDelegationVerifier, a rustlsServerCertVerifier) extracts the delegation from the leaf certificate, resolves the domain’s authority from chain, verifies the delegation’s signature against it, and enforces that the certificate’s public key equals the delegatednode_pubkey. Intermediates, the SNI/server name, and OCSP are deliberately ignored — trust flows only from the on-chain authority. This makes a node impersonation-proof even if the DHT record that pointed the client at it was poisoned: a bad address just fails the handshake’s chain check.
This is the inverse of the client-certificate SASL EXTERNAL mechanism (there
the client proves a wallet identity to the server; here the server proves a
delegated domain identity to the client). It is entirely optional: a server
published the classic way in DNS, presenting an ordinary CA-issued certificate,
is fully conformant — self-auth TLS matters only if you want the server found
and trusted through the DHT with no DNS and no public CA.
Should you graft this onto an existing MTA?
Given the above, the SMTP-accept edge — envelope validation, TLS, the
postage check — is the one piece with a plausible plugin surface in a
mature MTA: a Postfix Milter or an Exim ACL could call the mail_api gRPC
service to check and decrement stamps before accepting a message, in the
same shape as existing greylisting or reputation Milters.
Everything downstream of “accepted” does not have a comparable plugin surface:
- Local delivery must seal the body to the recipient’s key and land it in CID-addressed storage instead of a Maildir/mbox — no standard local delivery agent does this.
- IMAP/POP retrieval must decrypt from that store on fetch — Dovecot’s storage backends assume a conventional mailbox format.
- Wallet-signature SASL PLAIN, and the CRAM-MD5/APOP fallback that needs a server-held secret, has no drop-in mechanism in stock Cyrus SASL or Dovecot auth without custom code either way.
- DKIM signing on spool entry, the relay retry schedule, RFC 3464 DSNs, and
on-chain settlement bookkeeping are all
mail_spooler’s job regardless of which SMTP edge accepted the message.
So grafting SithBit support onto Postfix/Exim/Dovecot buys you a mature
MTA’s SMTP-edge tooling (anti-abuse, TLS hardening, operational familiarity)
at the acceptance step, but the storage, crypto, wallet auth, and settlement
layers still have to be written from scratch — which is what
mail_spooler already is. sithbitd is the reference implementation and
the recommended path for standing up a domain; a bespoke server is worth
building primarily if keeping an existing MTA’s edge tooling matters more
to you than the extra integration work, not because it’s meaningfully less
total effort.
Conformance checklist
- Build and submit valid
MailInstruction/AliasInstructiontransactions (mail_model,solana_common). - Check and decrement frombox stamps before accepting mail (
mail_apigRPC). - Seal bodies with
crypto_box_sealto the recipient’s wallet or published delegated key. - Store sealed bodies under a content address reachable at the URL you publish on-chain — real IPFS or a conventional store, your choice.
- (optional) Pin or announce that content on the public IPFS network for availability beyond your own infrastructure.
- Require TLS for submission and mail access, refusing credentials before
the connection is protected (RFC 8314) — SMTP
AUTH, IMAPLOGIN/AUTHENTICATE, POPUSER/PASS. - Accept wallet-signature SASL PLAIN and/or a stored mail password for clients limited to legacy SASL mechanisms.
- Sign outbound mail with DKIM; verify SPF/DMARC on relayed inbound mail.