Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

sithbitd: SMTP & submission settings

Part of the configuration reference, continuing sithbitd’s core settings: the [smtp] (inbound MX) and [submission] (authenticated outbound) listeners, sender authentication (SPF/DMARC), outbound quotas, and the self-service refusal links. See sithbitd: IMAP, POP & security settings for the other two listener protocols and the shared TLS/rate-limit machinery, and sithbitd: spooler settings for what happens to mail after a listener accepts it.

[smtp], [submission] — the two SMTP listeners

[smtp] is the MX listener (enabled by default); [submission] is the authenticated-submission listener (disabled by default). Both share the same shape:

Row markers follow the going-public legend.

KeyDefaultMeaning
enabledtrue / falseREQUIRED (public) for [submission]. MX on, submission off by default — a deployment whose own users send mail turns submission on, and gives it a bind address and TLS of its own
hostname(discovered)REQUIRED (public). EHLO greeting name. Unset, the daemon adopts the first local_domains entry (the first configured one, else the alphabetically-first discovered domain), then the machine’s /etc/hostname, then "localhost" — see Identity defaults from the chain below
greeting"SithBit ESMTP service ready"Free text appended after the hostname in the 220 connection banner, which the defaults render as 220 localhost SithBit ESMTP service ready. Cosmetic — nothing in the protocol reads it — but it is the first line every connecting MTA logs, so keep the conventional ESMTP token in whatever you replace it with
mode(unset — [smtp] runs as "mx", [submission] as "submission")Listener role. "mx" is inbound MX — no AUTH, sender policy active, relaying refused. "submission" requires AUTH, allows relaying, and holds the envelope sender to the authenticated user. A [submission] section runs as submission without saying so: the daemon fills the role in when the table names no mode. Writing mode = "mx" under [submission] is still honoured — it runs a second MX listener on its own port — which is why the value is filled in rather than forced, and why an unset mode is distinguishable from an explicit "mx"
sender_auth"spf"RECOMMENDED (public). MX only: "spf", "dmarc-lite", "dmarc", or "none" — see Sender authentication below
local_domains(discovered)REQUIRED (public) unless discovery supplies them. Domains accepted for local delivery. Unset, the daemon adopts every domain the gateway’s signing key is authoritative for on-chain; without a chain connection the empty list falls back to the hostname itself. One deployment may list several — see the startup check below
dnsbl_zone(unset)RECOMMENDED (public). DNSBL zone to check the connecting peer’s IP against at connect, e.g. "zen.spamhaus.org"
dbl_zone(unset)RECOMMENDED (public). DBL zone to check the sender domain against at EHLO and MAIL FROM. A listed domain (or EHLO host) is refused 554 5.7.1. Unset = off. See Domain block list below for the zone string and DQS key
client_cert_authfalseRECOMMENDED (public). Request a TLS client certificate and offer SASL EXTERNAL on this listener — meaningful on [submission] (the MX listener does no SASL AUTH). Inert without [submission.tls]. Client auth stays optional, so password clients keep working on the same listener
self_service_base_url(unset)RECOMMENDED (public). Public base URL of the operator’s self-service pages. Set, the postage refusals link the funding page and (under sithbitd) the do-not-disturb refusal links the schedule page — see Self-service refusal links below. Unset keeps every refusal byte-identical to the linkless text
postmaster_wallet(unset)RECOMMENDED (public). Wallet delivered mail for bare postmaster / postmaster@<local-domain> (RFC 5321 §4.5.1), bypassing alias resolution and the frombox/postage gate so external senders — notably DMARC reporters targeting the [spooler.dmarc_rua_ingest] mailbox — can reach it without stamps. Unset keeps postmaster on the normal postage path, byte-identical refusals
accept_wallet_literalsfalseChain-less/dev instances only (no [grpc] gateway): accept a syntactically valid 32-byte base58 wallet address as the recipient local part, mirroring the account API’s chain-less compose route. No postage check applies without a chain — which is why the default is off: unset keeps the postage gate and every refusal byte-identical. Inert with a gateway configured, since that path already resolves wallet literals and keeps the postage gate
smtp.max_message_size, submission.max_message_size26214400 (25 MiB)RECOMMENDED (public). Spelled with their sections, unlike their neighbours, because the shared max_message_size is a top-level key of the same name and a bare cell here would read as documenting that one too. The SIZE ceiling in octets, both advertised at EHLO (RFC 1870) and enforced: an oversize SIZE= declaration on MAIL FROM, or a message that outgrows the ceiling mid-DATA/BDAT, is refused 552 5.3.4 and the transaction is aborted. 0 advertises SIZE with no fixed limit. This is a wire size — see [spooler.offload] for how it converts to a decoded attachment size
max_recipients50RECOMMENDED (public). Recipients accepted per transaction; the next RCPT TO gets 452 4.5.3 while the recipients already accepted stand, so a sending MTA can split its list across transactions. RFC 5321 §4.5.3.1.10 sets 100 as the floor an MTA should tolerate, so raise it rather than lower it if legacy peers stumble
max_messages10RECOMMENDED (public). Messages delivered per connection; the next MAIL FROM is refused 421 4.4.2 and the session closes (RFC 5321 §3.8), so a peer with more to send simply reconnects. Only successful deliveries count, so a refused transaction costs a sender nothing
max_recipient_errors16RECOMMENDED (public). Refused recipients tolerated per session — the budget that ends a dictionary attack on your address space. The next RCPT TO gets 550 5.7.0 and the connection is dropped. Over-quota relay refusals are deliberately excluded from the count: “try again later” should never escalate into a dropped connection
[health], [observability](the shared defaults)RECOMMENDED (public). The two shared sections; this binary’s health port is in the Monitoring table

With the chain pipeline enabled, sithbitd checks every configured local domain against the chain at startup (via the gateway’s GetMailDomain): a domain that is unregistered, deactivated, or whose on-chain authority is not the gateway’s signing key gets one loud warning in the log — mail to it would otherwise fail silently per-message at SendMail. The check never blocks or fails the boot, and the chain-disabled dev stack skips it.

Identity defaults from the chain

With the chain pipeline enabled, the chain itself is the best source for these two identity fields: at startup sithbitd asks the gateway once (ListAuthoritativeDomains) for every active domain whose on-chain authority is the gateway’s own signing key, and any field you left unset adopts the answer — local_domains takes the whole list, hostname the first local domain (alphabetically-first, when discovered). Explicitly configured values are never overridden, the lookup never blocks or fails the boot (trouble degrades to the static defaults with one warning), and the boot log states each adopted value and its source (configured / discovered / system / fallback). The chain-disabled dev stack skips the lookup and falls through to the machine’s /etc/hostname, then "localhost".

One caveat: on-chain authority proves protocol authority, not DNS plumbing. The discovered name becomes the EHLO greeting, and legacy relays may compare that greeting against forward and reverse DNS — a domain apex with no matching A/PTR records can cost you deliverability even though every SithBit-side check passes. If this server fronts legacy SMTP peers, set hostname explicitly to the listener’s real FQDN (the one its PTR record names).

Wallet submission envelopes (local_domains)

local_domains has a second job beyond local delivery: it bounds the envelope sender a wallet-authenticated submission session may use. A session that logged in as a bare wallet address — the mail password or a client certificate, both of which authenticate the wallet itself — may present exactly

<its own wallet base58>@<a domain this listener is authoritative for>

and nothing else. Another wallet’s address, its own address at a domain this server does not serve, a case-variant of its own base58, and the null sender MAIL FROM:<> are each refused 553 5.7.1. Base58 is case-sensitive — Alice and alice decode to different keys — so the local part is compared exactly. Submission by alias with a password is unaffected: the rule is consulted only for wallet-literal identities.

The domain leg has two scopes that stack. local_domains is always the server’s authority — “is this one of the domains I serve?” — and it alone gates a chain-disabled listener. But when the listener has a chain gateway ([grpc] configured), a second, per-wallet check rides on top: the envelope domain must also be one the authenticated wallet owns on-chain — i.e. the wallet is that domain’s recorded authority, looked up through the gateway’s GetMailDomain RPC (an exact base58 authority match). So local_domains scopes which domains the listener will serve at all, and the on-chain authority check scopes which of those the authenticated wallet may actually send as. On a multi-domain instance a wallet may therefore send as itself only at the domains it owns on-chain, not at every domain the instance serves.

When the listener has no chain gateway (a chain-disabled or empty-config dev MX, e.g. MemoryVerifier / an empty [grpc]), the per-wallet lookup is unavailable and the check falls back to local_domains alone, exactly as before — so an empty-config dev stack still sends. The daemon (sithbitd) submission path, which wraps the same driver behind its away-schedule handling, enforces the tightened rule identically.

The dev-stack trap. When local_domains is empty the check falls back to hostname alone, and an empty config’s hostname is "localhost" — the chain pipeline is disabled there, so discovery never fills the list. Submitting as <wallet>@sithbit.net against that stack is refused 553 5.7.1 Sender address does not match authenticated user, whose text names the sender and never hints that the domain was what failed. The fix is one line — list the domain explicitly on the submission listener, which carries its own list; the MX section’s copy does not carry over:

[submission]
local_domains = ["sithbit.net"]

Production instances are largely immune: sithbitd fills local_domains from chain discovery at startup (see Identity defaults from the chain above), so the domains the server serves are exactly the ones its wallets may send from.

Domain block list (dbl_zone)

Where dnsbl_zone scores the connecting IP at connect time, dbl_zone scores the sender domain: the MX listener queries the Spamhaus DBL at both EHLO (the greeting host) and MAIL FROM (the envelope-sender domain), and a listed domain is refused with a permanent 554 5.7.1 naming it. The value is the full zone string, and which zone you use is a Spamhaus registration question, not a syntax one:

  • DQS (recommended) — the current Spamhaus form is <key>.dbl.dq.spamhaus.net, where <key> is your 26-character per-customer Data Query Service code from a free registered DQS account. Example: dbl_zone = "abcdefgh1234567890ijklmnop.dbl.dq.spamhaus.net".
  • Public dbl.spamhaus.org (deprecated) — the legacy public zone still resolves, but Spamhaus deprecates it for anything beyond small non-commercial volumes and blocks it from the big public resolvers (Google 8.8.8.8, Cloudflare 1.1.1.1, Quad9): a query through one of those returns no useful answer. If you use it, point the host at your own recursive resolver, not a public one.

Default None = off: no domain lookups happen and no dbl_zone line is needed. Following the repo convention, leave the example commented out with its default when you do add it:

[smtp]
# dbl_zone = ""   # off; set to "<key>.dbl.dq.spamhaus.net" to enable

One setting, two pages. Set self_service_base_url to the public base URL where the self-service pages for refused senders are hosted (they ship in the onboarding web bundle, normally one of the account API’s [[static]] mounts), and the RCPT-time refusals start telling senders how to fix themselves:

  • the two postage refusals — 450 4.7.0 (frombox out of stamps) and 550 5.7.0 (no frombox) — append ; fund it at {base}/fund.html?to=<recipient>&from=<sender>;
  • under sithbitd, the do-not-disturb refusal — 450 4.2.1 (recipient away) — appends ; schedule at {base}/dnd.html?to=<recipient>.

Query values are percent-encoded and a trailing / on the base is trimmed. Unset (the default), every refusal stays byte-identical to the legacy linkless text — same code, same enhanced status, same line. The standalone smtp-server dev binary honors the same key but carries only the funding link: the DND gate (and so the schedule link) is sithbitd’s. What each page shows the sender is on Do not disturb.

Sender authentication (sender_auth)

The MX listener’s sender_auth selects how inbound relayed mail is authenticated, from lax to strict. It has no effect on [submission] (authenticated submission trusts the logged-in user). The four values:

  • "spf" (default) — SPF at MAIL FROM, rejecting only a published hardfail (RFC 7208 §8.4); DKIM is verified and recorded in the Authentication-Results header but never rejects.
  • "dmarc-lite" — full DMARC alignment (RFC 9989) at end-of-DATA, but it bounces only on p=reject with neither SPF nor DKIM aligned. A raw SPF hardfail no longer rejects on its own, so legitimately forwarded mail carrying an aligned DKIM signature survives. p=quarantine is recorded but not enforced — there is no junk folder at this layer — and enforcement is all-or-nothing.
  • "dmarc" — the full DMARC disposition. Alignment is evaluated as in dmarc-lite, but the domain’s entire published policy applies: p=reject bounces (554 5.7.26), and p=quarantine accepts the message but files the recipient’s copy into their Junk folder (auto-created on first delivery — no operator setup). Enforcement is all-or-nothing: there is no sampled fraction any more (see Why pct= no longer does anything). sp= applies to an existing subdomain of the Author Domain’s organizational domain, and np= to one that does not exist at all (see Subdomain policies and the np= existence probe below, which an operator behind a caching resolver should read). A "dmarc" MX can also emit RFC 9990 aggregate (rua) reports back to the domains it evaluates — off unless you enable [spooler.dmarc_report] — and per-failure RFC 9991 forensic (ruf) reports, off unless you enable [spooler.dmarc_ruf].
  • "none" — no sender authentication. Intended for tests, offline dev, and submission-only instances.

For an internet-facing MX, run "spf" or stricter; see the threat model for why a lax MX is worse than ordinary spam.

Why pct= no longer does anything

SithBit evaluates DMARC as RFC 9989 defines it, and 9989 retired the pct= tag. A domain that still publishes pct= is not sampled and not partially enforced: the tag is not even parsed, so it cannot reach the disposition. The one direction this moves is stricter, never laxer — a domain publishing p=reject; pct=0 used to have its unsampled mail downgraded to quarantine, and now has it rejected. If you operate a domain that was using pct=0 as a “publish the policy but don’t enforce it yet” switch, publish t=y (test mode) instead: it is 9989’s deliberate replacement for that use of pct, and receivers apply the policy one level below the published one (rejectquarantine, quarantinenone). SithBit honors t=y on inbound mail — the underlying library applies it while selecting the policy record, so it needs nothing from this configuration.

Subdomain policies and the np= existence probe

Three tags can name the policy applied to a failing message, and which one wins depends on where the tree walk found the record that applies. When the Author Domain publishes its own record, its p= applies. When it does not — the record came from the organizational domain above it — the subdomain tags decide: sp= for an existing subdomain, np= for one that does not exist. Both sp= and np= default to a copy of the tag above them, so an ordinary record publishing only p= behaves exactly as before.

“Does not exist” is decided by a live DNS lookup — a single A query for the Author Domain, performed by the DMARC library on the branch where the Author Domain published no record of its own, and only when the record publishes an np= that differs from its sp=. That is one extra query class on the inbound path, and it is worth knowing two limits of it:

  • A resolver that masks NXDOMAIN defeats np= entirely. Some stub and caching resolvers — systemd-resolved at 127.0.0.53 among them — answer a non-existent name with NODATA (rcode 0, no answers) rather than NXDOMAIN. The probe then reads “the domain exists” for every name, and every subdomain takes sp=. Usually that is harmless, because np= defaults to sp=; it is not harmless for a domain publishing p=none; sp=none; np=reject, which gets no enforcement at all on your MX. If you enforce DMARC, point the MX at a resolver that returns NXDOMAIN faithfully rather than at a NODATA-masking stub.
  • A failed probe falls back to sp=, silently. A SERVFAIL or a timeout is not a temporary-failure path: the existence answer is simply unknown, and the message is dispositioned under sp=. There is no deferral and no distinct log line for it.

Outbound quotas ([smtp.quota] / [submission.quota])

Both listener sections carry a [quota] sub-section: rolling per-account limits on the external recipients an authenticated sender may relay per hour and per day. Only relayed foreign-domain recipients count — local, on-chain-stamped mail never does, because stamps already price it. External sends never touch the chain, so no on-chain fee prices them; this quota is the off-chain counterpart, and its new-account ramp (below) is the operational form of “reputation reduces sender friction”: a week-old account earns double a new one’s allowance, doubling each week up to the cap.

KeyDefaultMeaning
enabledtrueRECOMMENDED (public). Master switch for the quota math only. Suspension (below) is independent — a suspended account is refused even with quotas off — and accepted external recipients are still counted while disabled, so the ledger is truthful if enforcement is enabled later
base_hourly50RECOMMENDED (public). Hourly external-recipient allowance for a brand-new account
base_daily200RECOMMENDED (public). Daily external-recipient allowance for a brand-new account
max_hourly500RECOMMENDED (public). Ceiling the hourly allowance ramps up to
max_daily2000RECOMMENDED (public). Ceiling the daily allowance ramps up to

The effective allowance is min(cap, base × 2^account_age_weeks) — at the defaults:

Account ageHourlyDaily
0 weeks50200
1 week100400
2 weeks200800
3 weeks4001,600
4+ weeks500 (cap)2,000 (cap)

How enforcement behaves on the wire:

  • Per external RCPT: an over-quota external recipient is refused 452 4.5.3 (transient — “try again later”); local recipients in the same transaction are unaffected, and quota refusals deliberately do not burn the session’s recipient-error budget, so a well-behaved client finishes the transaction for its accepted recipients and retries the refused one after the window rolls.
  • Recording happens on acceptance only — a refused or all-local message adds nothing to the counters.
  • Counters key on the wallet: an alias login resolves to its wallet first, so aliases share the wallet’s counters (and its suspend flag) rather than getting their own.
  • The counters live in the account store — hour-bucketed rolling windows, on every [store] backend alike.

The account API enforces the same policy on compose through its own twin [quota] section (below) — the two are twins by design and must be kept in step, so a sender meets one policy whichever submission surface they use. Following the zero-config rule, the defaults are complete; the commented block:

# [submission.quota]      # ([smtp.quota] takes the same keys)
# enabled = true
# base_hourly = 50
# base_daily = 200
# max_hourly = 500
# max_daily = 2000

Alongside the quotas rides the account suspension flag, set and cleared over the admin API and honored by every server (SMTP AUTH/MAIL, IMAP, POP, compose) regardless of enabled. The refusal codes per surface, the admin endpoints, and complaint handling are in Monitoring — outbound quotas and suspension.

One scope note: the standalone smtp-server dev binary parses the [quota] section (same config shape) but wires no account store, so the gate is inert there — enforcement is sithbitd’s (and the account API’s) job.