sithbitd: IMAP, POP & security settings
Part of the configuration reference, continuing
sithbitd’s core settings: the [imap] and [pop]
listeners, the cross-connection login-attempt budget, the on-chain
mailbox-login gate, the per-wallet storage cap, the shared [*.server]
listener shape and its production TLS posture, and client-certificate
(SASL EXTERNAL) authentication — all of which the SMTP listeners on
sithbitd: SMTP & submission settings share too.
[imap], [pop]
Row markers follow the going-public legend.
| Key | Default | Meaning |
|---|---|---|
enabled | true | Per-protocol toggle |
imap.hostname | "localhost" | REQUIRED (public). Named in the greeting and in CRAM-MD5 challenges. Unlike the SMTP listeners’ hostname it is never discovered from the chain — set it to the public IMAP name your clients, and domain-sithbit’s autoconfiguration records, use |
pop.hostname | "localhost" | REQUIRED (public). Named in the +OK greeting and in the CRAM-MD5/APOP challenges — the POP twin of the row above, and equally undiscovered from the chain |
imap.watch_poll_seconds | 0 | RECOMMENDED (public). Split deployments only: poll for mailbox changes every N seconds to feed IDLE when deliveries happen in another process. 0 trusts in-process push — except on an IMAP-only instance (both SMTP roles disabled), where nothing delivers in-process, so a 0 auto-adopts 5 with an info log; an explicit value is always honored. See Role-split topologies |
imap.client_cert_auth | false | RECOMMENDED (public). Request a TLS client certificate and offer SASL EXTERNAL. Inert without [imap.tls]; client auth stays optional |
pop.client_cert_auth | false | RECOMMENDED (public). Same, for POP: EXTERNAL joins the CAPA SASL line once TLS is live — on POP3S from the greeting, and equally on a plaintext listener the moment it upgrades with STLS, which the offer re-reads mid-connection. Inert without [pop.tls]; client auth stays optional |
imap.max_message_size | 26214400 (25 MiB) | RECOMMENDED (public). Largest APPEND literal accepted, in octets — deliberately the same ceiling as the SMTP listeners’ max_message_size, so a message that arrived can always be uploaded back. An oversize literal is refused with a tagged BAD and its bytes discarded; the session carries on. This number is not enforcement-only: it is also what the server advertises as its APPENDLIMIT capability (RFC 7889) and what a synchronizing literal’s refusal names in its text, so a client can read the ceiling before uploading and is told it again when refused. The command line wrapped around the literal gets 64 KiB of headroom on top |
imap.max_login_attempts | 3 | RECOMMENDED (public). Failed logins tolerated before the session ends with BYE. Each failure also tarpits its own refusal (2s, then 4s, doubling), so this is the backstop rather than the primary rate limit; a cancelled AUTHENTICATE costs no attempt. 0 is not unlimited — the session counts the failure before comparing, so it behaves exactly like 1: one strike, hang up on the first failure, no tarpit. Per CONNECTION: it is spent and forgotten when the socket closes, so the budget that survives a reconnect is [auth_rate_limit] below |
pop.max_login_attempts | 3 | RECOMMENDED (public). Failed logins tolerated on one connection before the session hangs up. Each failure also tarpits its own refusal (2s, then 4s, doubling), so this is the backstop rather than the primary rate limit; POP3 allows one reply per command, so the final refusal is the goodbye. 0 is not unlimited — the session counts the failure before comparing, so it behaves exactly like 1: one strike, hang up on the first failure, no tarpit. Per CONNECTION, like IMAP’s: the budget that survives a reconnect is [auth_rate_limit] below |
imap.idle_command_timeout_secs | 1800 (30 min) | How long a client may sit silent inside an accepted IDLE before the session ends, in seconds. A client in IDLE is deliberately silent far longer than the shared read deadline tolerates, so while the exchange is active this deadline replaces the listener’s limits.idle_timeout_secs, restored the moment DONE (or expiry) ends it — RFC 2177 tells clients to re-issue IDLE at least every 29 minutes, and the default allows the full half hour, that interval plus a minute of slack. At shipped defaults the two deadlines coincide, since limits.idle_timeout_secs is 1800 too, so the substitution raises nothing; the seam earns its keep because the two settings move independently — an operator who tightens the shared read deadline for every other command does not thereby hang up on a conformant idler. Expiry here is a protocol goodbye rather than the shared deadline’s silent close: the server sends an untagged * BYE IDLE timed out and closes cleanly |
pop.enable_apop | false | Advertise APOP by putting a per-connection timestamp banner in the +OK greeting (RFC 1939 §7). Off by default because APOP digests the secret itself: it only works for accounts whose mail password the server can recover in plaintext — the same requirement CRAM-MD5 carries, and the same trade described under mail passwords |
smtp.enable_stored_passwords, submission.enable_stored_passwords, pop.enable_stored_passwords | true | RECOMMENDED (public). Advertise CRAM-MD5 on that listener. Set to false — together with account-api’s key of the same name, which stops new passwords being stored — to retire stored mail passwords fleet-wide, which is what keeps delivered mail sealed at rest for every account: a stored password is plaintext-recoverable by the server, and the challenge mechanisms it exists for could never unwrap a sealed body’s key. Passwords already stored keep verifying over PLAIN/LOGIN until each is cleared with DELETE /v1/account/password; pop.enable_apop is independent, so leave it off too. IMAP never offers CRAM-MD5 and has no such key |
[health], [observability] | (the shared defaults) | RECOMMENDED (public). The two shared sections; this binary’s health port is in the Monitoring table |
[auth_rate_limit] — the cross-connection login budget
max_login_attempts above is spent and forgotten when the socket closes,
so a guesser that reconnects after every third try keeps a steady rate
forever. This table is the budget that survives the reconnect: it
remembers the (client address, account) pair, and once that pair has
spent its failures inside the window every further attempt is refused
unchecked — no store lookup, no password comparison. It is on by
default; the defaults are complete, and the commented block reads:
# [auth_rate_limit]
# max_failures = 10
# window_secs = 900
# max_tracked = 10000
| Key | Default | Meaning |
|---|---|---|
max_failures | 10 | RECOMMENDED (public). Failures one (client address, account) pair may accumulate inside the window before further attempts are refused. 0 is not unlimited — the failure is counted before the comparison, so it behaves exactly like 1, deliberately the same spelling as max_login_attempts |
window_secs | 900 (15 min) | RECOMMENDED (public). How long a failure is remembered, in seconds. The window slides on every counted failure, so a pair that keeps guessing stays locked out; a refused attempt does not extend it, so a locked-out client always recovers window_secs after its last counted failure. 0 disables the limiter entirely, matching limits.handshake_timeout_secs’ spelling of “off” |
max_tracked | 10000 | RECOMMENDED (public). Ceiling on tracked pairs, so a spray across random logins cannot grow the table without bound. At the ceiling expired entries are swept first; if that frees nothing the new pair goes untracked (the limiter fails open for it, logged once) while pairs already counting keep counting — flooding the table cannot un-track an attack already in progress |
Nine behaviours decide whether the defaults suit your deployment, and none of them are guessable from the key names:
-
One table for the whole daemon.
sithbitdshares a single limiter across POP, IMAP, the MX listener and submission. A per-protocol[pop.auth_rate_limit]/[imap.auth_rate_limit]/[smtp.auth_rate_limit]/[submission.auth_rate_limit]still parses — the sections exist on the underlying configs — but is ignored. The daemon logs a startup warning naming the section it dropped, but only when that section was tuned away from the defaults above — a block carried across unchanged is dropped in silence, because dropping it changes nothing. So a quiet boot means no tuning was lost, not that no per-protocol section was present. Private per-protocol budgets would let a guesser rotate POP → IMAP → MX → submission against one account for four times the allowance. The standalonepop-server/imap-server/smtp-serverdev binaries are the exception: each reads its own[auth_rate_limit]section, because each is one protocol. Their shipped example files (pop_server.toml,imap_server.toml,smtp_server.toml) each carry the table commented out with these same defaults written into it, so the budget in force is visible in the file you are editing — and uncommenting the header alone changes nothing, since every key under it defaults to the value shown. -
SMTP has no per-connection sibling. POP and IMAP back this budget with
max_login_attempts; SMTP has nothing of the kind — the session counts no failures, tarpits nothing, and never hangs up on a guesser. On the submission listener this table is therefore the only authentication budget in the stack, which is why its refusal has to close the connection itself and why turning it off (window_secs = 0) costs more here than it does on the other two protocols. -
The SMTP refusal is two replies and a close, and the wire code is ambiguous by design:
454 4.7.0 Too many authentication failures; try again in 873 seconds 421 4.7.0 Too many authentication failures, closing transmission channel454 4.7.0is RFC 4954 §6’s temporary authentication failure — the completion theAUTHcommand is owed, since a silently dropped socket reads to a mail client as a server fault it should retry — and421is RFC 5321 §3.8’s announced close. That same454 4.7.0is what a credential-store outage answers (454 4.7.0 Temporary authentication failure), deliberately: a locked-out client must not be able to tell a rate limit from an outage. When you are reading logs, the message text is what distinguishes them, not the code. Both SMTP roles answer identically, byte for byte, retry hint included, so a guesser rotating between MX and submission cannot tell from the refusal which one it is on. POP answers-ERR [AUTH] too many authentication failures; try again laterand IMAP a taggedNO [UNAVAILABLE]plus* BYE; neither discloses the remaining seconds, because only on SMTP is a 4xx an instruction to retry. -
The refusal is not tarpitted. Unlike
max_login_attempts, whose every refusal is delayed on a doubling schedule (2s, then 4s, …), a cross-connection refusal is answered immediately and the connection slot comes straight back. Half of what this budget buys is that slot: a limiter that made a locked-out client wait would spend the server’s own concurrency defending against it. -
The address is the effective client address — the one the accept loop resolved through any PROXY protocol preamble, i.e. the client behind a load balancer and not the balancer. That makes
proxy_protocolload-bearing for this control: left off on a listener that really is behind a balancer, every user of that balancer shares one bucket, and ten failures anywhere in the fleet lock all of them out of the account they were failing on. -
The key is the pair, not the address alone, so one hostile login from a shared NAT or a carrier-grade address cannot lock out a co-located neighbour’s account. The account half is normalized before keying: trimmed, ASCII-lowercased (varying the case of a login must not buy a fresh budget) and truncated to 128 bytes (an unauthenticated peer picks the string, so the untruncated form would let it grow the table by megabytes per entry). The domain part is kept — two logins that differ past the
@are two accounts, and merging them would let one tenant lock out another. A login the server could not parse at all normalizes to the empty string, which gives every such attempt from one address a single shared bucket. -
Only a failed credential comparison moves the count, and the two cases an operator will actually meet are the interesting ones. A credential-store outage moves it in neither direction: one store blip cannot lock a whole deployment out, and equally a client able to provoke store errors gets no free reset of the count it has already run up. A refusal raised after a credential verified — a suspended account, an unreachable postage gate, a POP maildrop another session already holds — clears the pair wherever the server can tell that is what happened, because whoever just proved they own the account is not the guesser this table is for. POP and SMTP can, and do; that matters in practice, since a second concurrent POP session is an everyday event and charging it would lock a legitimate user out for the whole window. IMAP deliberately does neither: the same refusals reach its driver from a pre-credential store lookup as well, and it cannot tell the two apart, so charging would let a store blip lock out a legitimate user while clearing would let the same blip erase a guesser’s accumulated count.
-
Recovery needs no restart and no admin action. The ban lapses on its own
window_secsafter the pair’s last counted failure — refusals in between do not push it out — and a successful login forgets the pair immediately. A user who waits, or who finally gets their password right from another address, is simply let back in. -
The counters are in-process and per-replica. Nothing is shared through the store, so two
sithbitdreplicas over one cloud store each keep their own table and a guesser reaching both gets two budgets. Sizemax_failuresper replica and expect the effective ceiling to scale with the instance count — the same caveat every in-memory control in the fleet carries (see Known seams).
Every refusal logs at WARN with the client address and the seconds
remaining — authentication refused: too many recent failures for this client and account — and a table that hits max_tracked logs
auth rate-limit table full; new client/account pairs go untracked
once, not once per login, so a saturated table is visible without
drowning the log.
A misspelled setting here fails startup naming the key, the same
deny_unknown_fields contract [*.server]’s limits carry below.
login_requires_mailbox — the on-chain mailbox login gate
| Key | Default | Meaning |
|---|---|---|
login_requires_mailbox | false | RECOMMENDED (public). Whether an IMAP or POP session may only open for a wallet that owns an on-chain mailbox. Top level, not under [imap] or [pop]: one posture for the whole daemon, so the two protocols cannot disagree about it. Written above the first section header in sithbitd.toml, or it reads as a key of whichever section precedes it. Env override SITHBITD_LOGIN_REQUIRES_MAILBOX, the standard spelling |
A SithBit login is self-proving: the credential is a wallet signature
(or a client certificate bound
to a wallet), so what it proves is a keypair, not an account. At the
shipped default the daemon takes that as enough — anyone who generates a
keypair can log in as that address, and the daemon creates storage for it
on the spot. Setting login_requires_mailbox = true makes it ask the
chain first: no mailbox account for that wallet, no session.
- The check runs before anything is written, which is the whole point. An IMAP session-open mints an INBOX row; a POP session-open mints the maildrop lease and the INBOX row. The gate answers ahead of all three, so a refused login leaves the store exactly as it found it — a gate consulted afterwards would refuse the session having already created the storage it exists to withhold. The suspension check is the one thing that still runs first, because it only reads.
- What the refused client is told. IMAP answers
<tag> NO [AUTHORIZATIONFAILED] no on-chain mailbox for this account; create one before logging in, and POP answers-ERR [SYS/PERM] no on-chain mailbox for this account; create one before logging in— one text, byte for byte, however the client arrived. The codes say authorization, not authentication: the credential proved out, so neither reply sends the holder back to re-enter a password that works. The remedy is in the text —sithbit mailbox create, and the login then works untouched. - A chain outage is a temporary failure, never that refusal. When the
gateway cannot be reached the daemon does not fall back on either posture:
IMAP answers
<tag> NO temporary authentication failure; try again laterand POP answers-ERR [SYS/TEMP] temporary server problem, both retryable. A passing outage must not lock a real account out, so the permanent refusal above is reserved for an answer the chain actually gave. - It costs one gateway round-trip per session open, and it needs
[grpc]. The lookup is aGetMailboxagainst the chain gateway; with the gate off no call is made at all, so the loopback dev stack’s login path is unchanged. The trap worth knowing before you set it:login_requires_mailbox = trueon a daemon with no[grpc]section has nothing to ask, and the gate admits everyone. The daemon says so at boot —login_requires_mailbox is set but no [grpc] gateway is configured: logins are NOT checked against the chain— but it starts anyway, because refusing to boot would break the zero-config dev stack. Read the warning as “the gate is off”, not as a nag. - What it does and does not cover. It gates the two session-opens that create storage — IMAP’s and POP’s. SMTP submission authentication is not gated, because it creates no account storage of its own, and neither is account-api’s wallet-challenge login, which is a separate binary with no switch of its own; that surface answers the same finding a different way, by provisioning nothing until a signature has verified.
- How a refusal moves the login budgets. Per connection it is an
ordinary failed attempt on both protocols: it spends one of
max_login_attemptsand is tarpitted like any other. Across connections the two differ, for the reason the cross-connection budget gives above — POP clears the (client address, account) pair, since the refusal can only be reached past a credential that verified for that very account, while IMAP neither charges nor clears it, since it cannot tell this refusal apart from one raised before any credential was looked at.
max_wallet_bytes — the aggregate per-wallet storage cap
| Key | Default | Meaning |
|---|---|---|
max_wallet_bytes | 0 | RECOMMENDED (public). Aggregate bytes ONE wallet may hold in this store — every mailbox it owns, and every stored blob those messages reference, counted once per wallet however many of its mailboxes point at the same blob. 0 (the default) is unbounded. Top level, beside login_requires_mailbox and for the same reason: one number for the whole daemon, so delivery and IMAP cannot disagree about how full a wallet is. Written above the first section header in sithbitd.toml, or it reads as a key of whichever section precedes it. Env override SITHBITD_MAX_WALLET_BYTES, the standard spelling |
The shipped default is no cap at all: the store grows until the disk does, which is how every SithBit deployment has run so far. Setting a byte count makes a write that would carry a wallet past it fail instead of succeeding — advisory rather than exact, since usage is read and the write happens separately, so writes in flight together can overshoot by their own combined size. Size a hard ceiling with that headroom.
- It is measured per wallet, not per mailbox. A wallet’s INBOX, Sent, Junk and every folder it has made share one budget, and a blob two of its mailboxes both reference is charged once. That is what makes the number an operator can reason about — the size of one account on disk — rather than a per-folder allowance that a client sidesteps by filing mail elsewhere.
- A refusal is transient on every path, and says so. An SMTP delivery
to a full recipient answers
452 4.2.2 Mailbox full, an IMAP APPEND answersNO [OVERQUOTA], and account-api’s compose answers413 Payload Too Large. None of them is a permanent rejection: a recipient who prunes receives the mail on the sending server’s next retry, with no bounce in between. - Mail clients can see the cap. IMAP’s
GETQUOTA/GETQUOTAROOT(RFC 9208) surface this ceiling — and the wallet’s current usage against it — to any connected mail client, so a user can watch fullness approach instead of meeting it as a refusal. With the default0no quota is advertised at all:GETQUOTAROOTreports no quota roots, which clients render as “no quota”. The usage shown is this setting’s own per-wallet, deduplicated meter, not per-folder arithmetic — see the conformance appendix for how that reads to a client that does its own sums. - Bounce reports are exempt, deliberately. A delivery-status
notification for a message this wallet sent is written straight to its
INBOX and is never refused, because the one report that must survive a
full mailbox is the one saying the mailbox is full — refuse it and the
sender never learns their mail is failing. The cost is a bounded
trickle: a sender can push their own stored bytes a little past the
ceiling by sending mail that bounces, bounded by their own outbound
volume, which
[quota]already limits. - The SMTP verdict answers for the whole envelope. A message with five recipients, one of them full, is refused to all five and re-sent to all five — the spool reports one outcome per transaction, not one per recipient. See the delivery sink’s own note before choosing a number tight enough for that to happen routinely.
- Set account-api’s key of the same name to the same
number. The two binaries write into one store, so a ceiling only
sithbitdholds is a ceiling any sender walks around by composing in webmail. The two configs are twins on purpose and are documented as such; nothing at startup can check that they agree, because neither process reads the other’s file.
[*.server] — the shared listener section
Every listener ([smtp.server], [submission.server],
[imap.server], [pop.server]) takes the same fields:
| Key | Default | Meaning |
|---|---|---|
bind_addr | 127.0.0.1:2525 / :1430 / :1100 | REQUIRED (public). The loopback defaults reach nobody off this host, and the address is enforced at startup the moment the table exists (see below). Listen address (the only per-listener default that differs: SMTP 2525, IMAP 1430, POP 1100; the submission listener has no distinct default — set it explicitly when enabling) |
implicit_tls | false | REQUIRED (public). The production posture for submission, IMAP and POP (below). Wrap the socket in TLS at accept instead of STARTTLS/STLS |
proxy_protocol | false | RECOMMENDED (public). Expect a PROXY protocol preamble and attribute sessions to the client it names. Only behind an L4 balancer — never on a directly reachable listener (the preamble is spoofable), and clients that don’t send one are dropped. It also decides what the cross-connection login budget keys on: that budget uses the effective client address, so leaving this off on a listener that really is behind a balancer puts every user of that balancer in one bucket |
proxy_trusted | [] | REQUIRED (public) when proxy_protocol is on, and that one is enforced at startup. CIDR allowlist of the socket peers permitted to speak PROXY protocol, e.g. ["10.0.0.0/8", "2001:db8::/32"] (bare addresses count as /32 or /128; a v4 entry also matches v4-mapped peers on dual-stack listeners). Untrusted peers are refused before a single header byte is read, so a stray direct client can’t spoof its address even if it reaches the port. Required whenever proxy_protocol is on: an empty list fails startup, because the allowlist is the only thing standing between a reachable port and a peer that asserts whatever client address it likes. To deliberately trust every peer — appropriate only when nothing but the balancer can reach the port — say so explicitly with ["0.0.0.0/0", "::/0"]. Ignored unless proxy_protocol is on; a malformed entry fails startup naming it |
limits.max_connections | 1024 | RECOMMENDED (public). Concurrent connections across the listener |
limits.max_per_peer | 16 | RECOMMENDED (public). Concurrent connections per peer IP |
limits.idle_timeout_secs | 1800 (30 min) | Session idle timeout. IMAP sets the value: RFC 3501 §5.4 requires an inactivity autologout of at least 30 minutes, so a shorter shared deadline broke that MUST every time an IMAP session fell quiet. POP3 (RFC 1939 §3) and SMTP (RFC 5321 §4.5.3.2) state only smaller minimums of their own, and a deadline clearing the largest minimum clears the rest — which is why one setting stays conformant on all three listeners. The cost is that a silent session holds its connection slot for half an hour; bound that with max_connections and max_per_peer rather than by lowering this under 1800 on a listener serving IMAP. On IMAP it does not bound an accepted IDLE: that exchange runs under imap.idle_command_timeout_secs instead, and this deadline is restored the moment the exchange ends |
limits.handshake_timeout_secs | 30 | Deadline for a client to finish the TLS handshake — one setting covering both paths: the implicit-TLS accept and the STARTTLS/STLS upgrade. A peer that connects and then never completes one would otherwise hold its connection slot until the kernel gave up on the socket. Expiry is a silent close, matching the other admission refusals, so the slot is freed. 0 disables the deadline |
limits.write_timeout_secs | 60 | Deadline for one write to the client to make progress. Reads are bounded by idle_timeout_secs; writes were not, so a peer that stops draining its socket could wedge the session task behind TCP backpressure. The clock restarts whenever the peer accepts any bytes, so it expires only on a peer taking none at all for the whole window — a dead or hostile reader, not a slow one. 0 disables the deadline |
bind_addr becomes required the moment the table exists. It is the
one field here with no default of its own — the listen addresses above
are defaults for the whole absent section, not for the field. So
uncommenting a single limit under [imap.server.limits] and nothing else
fails startup with missing field bind_addr in imap, which reads as a
puzzle when all you changed was a timeout. Uncomment the listener’s
address line as well — that is exactly why the example files write it
directly under every [*.server] header. The same holds for
[smtp.server], [submission.server] and [pop.server]: there is
deliberately no protocol-neutral fallback, since each listener’s port is
part of its identity.
A misspelled key here fails startup rather than being ignored. Both
this table and its limits sub-table reject names they do not recognise,
so writing idle_timeout_sec (no trailing s) stops the daemon with an
“unknown field” error naming the key and listing the ones it accepts,
instead of silently leaving the default in force while you believe you
overrode it. That listing is the authoritative key set for whichever
level the typo is on, so read it before reaching for this page.
REQUIRED (public). [smtp.tls] / [submission.tls] / [imap.tls] /
[pop.tls] each take certs and key (the PEM certificate chain and private key, e.g.
fullchain.pem / privkey.pem). Each is a key
source: a file path (default), or
a cloud secret-manager secret holding the PEM. With no TLS section the
listener runs plaintext — fine on loopback, not on the internet.
One certificate for every listener: the top-level [tls]
Four listeners on one host almost always terminate the same wildcard
or multi-SAN certificate — they must, since clients reach
mail.<domain>, imap.<domain> and pop.<domain>. Writing it four
times meant four places to miss on a renewal, so the daemon takes it
once at the top level:
| Key | Default | Meaning |
|---|---|---|
tls.certs | (absent — plaintext listeners) | REQUIRED (public). PEM certificate chain inherited by [smtp.tls], [submission.tls], [imap.tls] and [pop.tls]. A key source, same as the per-listener spelling |
tls.key | (absent — plaintext listeners) | REQUIRED (public). PEM private key for that chain — same key source forms |
A listener that writes its own [*.tls] keeps it, so a deployment
that really does present a different certificate on one port still can.
Inheritance only fills in the listeners that named none, which means an
existing config file that writes all four behaves exactly as it did.
[tls] is a table, so — unlike hostname and local_domains — it can
sit anywhere in the file.
Production posture: implicit TLS and require_tls (RFC 8314)
The dev defaults are loopback plaintext, but the production posture is RFC 8314: TLS on connect for submission and access, and no credentials offered before the connection is protected. Two settings carry it:
implicit_tls = trueon the[submission.server]/[imap.server]/[pop.server]listeners, bound to the standard secure ports — 465 (submission, SMTPS), 993 (IMAPS), 995 (POP3S) — so the socket is wrapped in TLS at accept, with no STARTTLS/STLS round trip. The MX listener on 25 stays plaintext-with-STARTTLS by nature (foreign MTAs reach it that way). The STARTTLS/STLS secondaries on 587/143/110 (implicit_tls = false) are an opt-in for legacy clients; advertise them at a lowerSRVpreference (DNS setup).require_tlsrefusesAUTH/LOGIN/USERuntil TLS is active. It is on by default for the SMTP submission edge (mode = "submission"), IMAP, and POP, so the production posture needs no config entry for it — the MX listener does no SASL AUTH and is unaffected. The three protocol-appropriate enforcement guards are detailed in the conformance reference.
mail_spooler/sithbitd.example.toml ships the full implicit-TLS stack as
commented [submission.server] / [imap.server] / [pop.server] +
[*.tls] blocks, and docker-compose.prod.example.yml publishes the
465/993/995 primaries (plus 25 MX) with the STARTTLS ports commented out —
see Running a mail server: Production.
Client-certificate auth (SASL EXTERNAL)
The client_cert_auth toggle on [submission], [imap], and [pop]
turns on the passwordless login path: a client presents a
self-signed Ed25519 TLS client certificate whose public key is the
wallet’s 32-byte signing key (the wallet address, and so the
mailbox/maildrop identity), and the server authenticates it as that
wallet via SASL EXTERNAL — the
completed TLS client-auth handshake is the proof of key possession, so
nothing is stored server-side. It is the alternative to the derived
mail password (sithbit mailbox credentials, SASL PLAIN);
sithbit mailbox create-cert mints the certificate (see
Thunderbird
/ Outlook).
The certificate’s subject/SAN are ignored — only the SPKI key binds — and
an empty or wallet-matching SASL authzid is accepted while a mismatching
one is rejected.
The toggle is off by default — an empty config file leaves it off on
all four of sithbitd’s listeners, and a test fences that — and it is
inert unless the matching […tls] section is present, since client-cert
auth exists only over TLS. Turning it off does not disable AUTH:
the password mechanisms (PLAIN, LOGIN, CRAM-MD5, APOP) are unaffected
either way, and only the one mechanism the transport cannot back is
withheld. Turning it on makes the listener merely request the client
certificate (optional TLS client auth), so password clients keep
connecting on the same port.
EXTERNAL is advertised if and only if the listener really sends a TLS
CertificateRequest. Each binary reads client_cert_auth in exactly one
function — listener_tls in sithbitd / pop-server / imap-server /
smtp-server, one shared helper rather than a copy per binary — and that
function returns the acceptor and the client-auth mode it was built with
as a single value; every handler gates the offer on that recorded mode,
never on the config boolean. There is no consistency here for you to
maintain, because there is one setting and one read of it — now literally
one, in one place, for every listener the project ships. The corollary: you can no
longer produce a listener that offers EXTERNAL but cannot complete it.
The old failure mode — clients see the offer, attempt it, fail, and on
IMAP burn a login attempt each time — is not reachable by configuration.
If EXTERNAL is missing from your CAPABILITY / CAPA / EHLO, the cause
is client_cert_auth = false, or no [*.tls] section at all, never a
wiring mismatch. (The guarantee runs acceptor → advertisement, so a
client-auth acceptor paired with client_cert_auth = false would still
advertise it — a pairing no binary’s config path can produce, since the
flag is what picks the acceptor, and constructible only in tests.)
Three things that invariant does not promise:
- It is per listener, not per daemon.
sithbitdreads its[submission],[imap]and[pop]copies of the flag independently — and[smtp]’s too, though the MX listener does no SASL AUTH — and nothing cross-checks them. On for IMAP and off for POP is valid and silently accepted, which makes it a plausible mistake: set it on every authenticated listener, or on none. - A silently absent offer is still possible.
client_cert_auth = truewith no matching[*.tls]section is inert and warns nothing, and so is a[*.tls]section on a listener whose clients never reach implicit TLS or STARTTLS. What is guaranteed is that no false offer is made; nothing is guaranteed about a missing one. - It is about advertisement and completability, not authorization. Whether the wallet a presented certificate proves is one this daemon will serve is the SASL EXTERNAL verify step’s business, and is unchanged by any of the above.
So advertising the mechanism and accepting it remain two different gates: the offer follows the listener and the live channel, never the individual peer — a client that sent no certificate is still offered EXTERNAL — while acceptance requires that the peer really did present a certificate whose Ed25519 key matches the wallet it authenticates as. A certificate-less client is refused at the SASL step, so nothing but a matching key ever logs in.
The second gate is why the offer can appear mid-connection. All three
protocols re-read the pair at every use — one mechanism list feeds the
EHLO / CAPABILITY / CAPA advertisement and the AUTH /
AUTHENTICATE accept check — so EXTERNAL is withheld while the socket is
still cleartext, however the listener is configured (the certificate that
proves the identity does not exist until TLS is up), and appears the
instant an in-band upgrade completes: STARTTLS on 587, STLS on 110. The
same connection that was refused AUTH EXTERNAL in the clear is offered
it, and logs in with it, after the upgrade. POP used to be the exception —
its mechanism list was fixed at connection start, so EXTERNAL reached
implicit-TLS listeners (POP3S, port 995) only — and is not any more; the
three listeners now behave identically, and a [pop.tls] section with
client_cert_auth = true is enough on port 110.