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: 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.

KeyDefaultMeaning
enabledtruePer-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_seconds0RECOMMENDED (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_authfalseRECOMMENDED (public). Request a TLS client certificate and offer SASL EXTERNAL. Inert without [imap.tls]; client auth stays optional
pop.client_cert_authfalseRECOMMENDED (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_size26214400 (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_attempts3RECOMMENDED (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_attempts3RECOMMENDED (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_secs1800 (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_apopfalseAdvertise 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_passwordstrueRECOMMENDED (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
KeyDefaultMeaning
max_failures10RECOMMENDED (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_secs900 (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_tracked10000RECOMMENDED (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. sithbitd shares 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 standalone pop-server / imap-server / smtp-server dev 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 channel
    

    454 4.7.0 is RFC 4954 §6’s temporary authentication failure — the completion the AUTH command is owed, since a silently dropped socket reads to a mail client as a server fault it should retry — and 421 is RFC 5321 §3.8’s announced close. That same 454 4.7.0 is 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 later and IMAP a tagged NO [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_protocol load-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_secs after 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 sithbitd replicas over one cloud store each keep their own table and a guesser reaching both gets two budgets. Size max_failures per 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

KeyDefaultMeaning
login_requires_mailboxfalseRECOMMENDED (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 later and 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 a GetMailbox against 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 = true on 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_attempts and 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

KeyDefaultMeaning
max_wallet_bytes0RECOMMENDED (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 answers NO [OVERQUOTA], and account-api’s compose answers 413 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 default 0 no quota is advertised at all: GETQUOTAROOT reports 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 sithbitd holds 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:

KeyDefaultMeaning
bind_addr127.0.0.1:2525 / :1430 / :1100REQUIRED (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_tlsfalseREQUIRED (public). The production posture for submission, IMAP and POP (below). Wrap the socket in TLS at accept instead of STARTTLS/STLS
proxy_protocolfalseRECOMMENDED (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_connections1024RECOMMENDED (public). Concurrent connections across the listener
limits.max_per_peer16RECOMMENDED (public). Concurrent connections per peer IP
limits.idle_timeout_secs1800 (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_secs30Deadline 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_secs60Deadline 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:

KeyDefaultMeaning
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 = true on 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 lower SRV preference (DNS setup).
  • require_tls refuses AUTH / LOGIN / USER until 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. sithbitd reads 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 = true with 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.