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.
| Key | Default | Meaning |
|---|---|---|
enabled | true / false | REQUIRED (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_auth | false | RECOMMENDED (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_literals | false | Chain-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_size | 26214400 (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_recipients | 50 | RECOMMENDED (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_messages | 10 | RECOMMENDED (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_errors | 16 | RECOMMENDED (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 (Google8.8.8.8, Cloudflare1.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
Self-service refusal links (self_service_base_url)
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) and550 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 atMAIL FROM, rejecting only a published hardfail (RFC 7208 §8.4); DKIM is verified and recorded in theAuthentication-Resultsheader but never rejects."dmarc-lite"— full DMARC alignment (RFC 9989) at end-of-DATA, but it bounces only onp=rejectwith 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=quarantineis 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 indmarc-lite, but the domain’s entire published policy applies:p=rejectbounces (554 5.7.26), andp=quarantineaccepts the message but files the recipient’s copy into theirJunkfolder (auto-created on first delivery — no operator setup). Enforcement is all-or-nothing: there is no sampled fraction any more (see Whypct=no longer does anything).sp=applies to an existing subdomain of the Author Domain’s organizational domain, andnp=to one that does not exist at all (see Subdomain policies and thenp=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 (reject → quarantine, quarantine →
none). 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 at127.0.0.53among 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 takessp=. Usually that is harmless, becausenp=defaults tosp=; it is not harmless for a domain publishingp=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 undersp=. 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.
| Key | Default | Meaning |
|---|---|---|
enabled | true | RECOMMENDED (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_hourly | 50 | RECOMMENDED (public). Hourly external-recipient allowance for a brand-new account |
base_daily | 200 | RECOMMENDED (public). Daily external-recipient allowance for a brand-new account |
max_hourly | 500 | RECOMMENDED (public). Ceiling the hourly allowance ramps up to |
max_daily | 2000 | RECOMMENDED (public). Ceiling the daily allowance ramps up to |
The effective allowance is min(cap, base × 2^account_age_weeks) —
at the defaults:
| Account age | Hourly | Daily |
|---|---|---|
| 0 weeks | 50 | 200 |
| 1 week | 100 | 400 |
| 2 weeks | 200 | 800 |
| 3 weeks | 400 | 1,600 |
| 4+ weeks | 500 (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.