Configuration reference
Every SithBit binary follows the same contract: an empty or missing config file is a runnable dev instance. Every setting has an in-code default — loopback binds, unprivileged ports, a local SQLite store — so configuration is only ever overriding a default, never satisfying a required field. The one exception is called out below (TLS certificate paths, which have no sensible default).
The annotated example files are the canonical per-key documentation and ship with every default shown commented out:
mail_spooler/sithbitd.example.tomlaccount_api/account_api.tomldomain_sithbit/domain_sithbit.example.tomlipfs_daemon/sithbit_ipfsd.example.tomlipfs_gateway/ipfs_gateway.example.tomlmail_grpc/mail_grpc.example.toml
How a setting resolves
The TOML-config binaries (sithbitd, account-api, domain-sithbit,
mail-grpc) layer each setting, lowest to highest precedence:
- the in-code default,
- the TOML file (
sithbitd.toml/account_api.toml/domain_sithbit.toml/mail_grpc.tomlin the working directory, or the path inSITHBITD_CONFIG/ACCOUNT_API_CONFIG/DOMAIN_SITHBIT_CONFIG/MAIL_GRPC_CONFIG), - an optional cloud app-config source — AWS AppConfig or Azure App Configuration, opted into per binary by a bootstrap env var; skipped entirely when neither var is set,
./.env,./.env.$APP_ENV(APP_ENVcomes from the environment or./.env),- the real environment.
Environment variables address individual settings as
{PREFIX}_{PATH}, with __ descending one TOML nesting level:
SITHBITD_STORE__KIND=aws # [store] kind
SITHBITD_SMTP__SERVER__BIND_ADDR=0.0.0.0:2525 # [smtp.server] bind_addr
SITHBITD_STORE__BLOBS__KIND=azure # [store.blobs] kind (tagged enum)
The prefixes are SITHBITD, ACCOUNT_API, DOMAIN_SITHBIT, and
MAIL_GRPC. Env files are read from the working directory only, and
their values are exported to the process environment without
overriding variables that are already set.
Key sources: files or cloud secret managers
Six file-loaded secrets take a key source rather than a bare path:
the account-api JWT signing key (jwt.key_file), the
DKIM signing key(s) ([spooler.dkim] /
[mail.dkim] key_file), the credential-sealing
key (credential_key_file), every
server’s TLS certificate and key
(certs / key, or the account-api’s tls.cert_file / tls.key_file),
domain-sithbit’s delegate signing key
(delegate_key_file), and mail-grpc’s signing/fee-payer
keypair (keypair).
A key source is either a local file (the default) or a cloud secret
manager’s secret — Azure Key Vault (akv), AWS Secrets Manager (asm),
or Google Secret Manager (gsm) — in one of these TOML forms:
key_file = "jwt.key" # 1. bare path string -> a local file
key_file = { path = "jwt.key" } # 2. table, kind omitted -> a local file
key_file = { kind = "akv", # 3. an Azure Key Vault secret
vault_uri = "https://<vault>.vault.azure.net/",
secret_name = "jwt-signing-key" }
key_file = { kind = "asm", # 4. an AWS Secrets Manager secret
secret_id = "sithbit/jwt-signing-key" }
key_file = { kind = "gsm", # 5. a Google Secret Manager secret
project = "my-project",
secret = "jwt-signing-key" }
The representation defaults to a file, so a config that names a plain
path — or omits kind — keeps the historical, zero-config file behaviour
byte-for-byte; only an explicit cloud kind opts into a secret manager.
This holds for all six fields whatever the field is named (key_file,
credential_key_file, delegate_key_file, keypair, certs / key,
cert_file / key_file).
A cloud secret’s value holds exactly what the file would have — the raw
key bytes, or the PEM. The TLS pair therefore fetches two secrets —
the certificate-chain PEM and the private-key PEM — one per certs/key
(or cert_file/key_file) entry. No secret material lives in the config
file itself; it carries only the secret’s coordinates. Per cloud:
akvnames thevault_uriandsecret_name. Authentication reuses the same managed-identity credential chain as the Azure storage backend (azure_identity’s ManagedIdentity): no new auth to configure.asmnames thesecret_id— a secret name or a full ARN — with optionalregion(defaults to the ambient AWS configuration’s) andendpoint_url(an emulator such as LocalStack, mirroring the AWS storage backend’s setting). Authentication is the ambient AWS credential chain (environment, profile, IAM role). A string secret’s UTF-8 bytes are used; a binary secret’s raw bytes serve as the fallback when no string value is present.gsmnames theprojectandsecret, with optionalversion(default"latest"). Authentication is Application Default Credentials (workload identity,GOOGLE_APPLICATION_CREDENTIALS, or a gcloud user login).
Every kind parses in every build. The clouds themselves are cargo
features of the key-source crate (akv / asm / gsm, all on by
default); a build that compiles one out still accepts the config but
fails at load with an error naming the feature to enable, so a slimmed
operator build can drop the SDKs it never uses (see
Slim-build features for per-binary
slim build commands).
(Cloudflare has no equivalent backend by design: its Secrets Store / Workers secrets are write-only over the API — only a deployed Worker binding can read a value — so a fetch-style key source cannot exist.)
There is no local Key Vault emulator, so the live AKV fetch is
exercised only by an #[ignore]d probe gated on the
SITHBIT_TEST_KEYVAULT_URI environment variable (pointed at a real
vault). The ASM and GSM twins gate on SITHBIT_TEST_ASM_SECRET_ID (plus
optional SITHBIT_TEST_ASM_ENDPOINT/SITHBIT_TEST_ASM_REGION — an ASM
probe can target LocalStack) and SITHBIT_TEST_GSM_PROJECT /
SITHBIT_TEST_GSM_SECRET. The per-kind dispatch is unit-tested against
a fake fetcher, so CI covers the dispatch and a real cloud covers the
round trip.
Cloud app-config sources: AWS AppConfig or Azure App Configuration
When mounting a TOML file into every container or VM is the awkward part
of a deployment — orchestrated fleets, serverless units, config that
several instances must share — a binary can pull its settings from a
managed configuration store instead: AWS AppConfig or Azure App
Configuration. The cloud tier slots into the resolution chain
above directly after the TOML file: it
overrides the file, and is itself overridden by ./.env,
./.env.$APP_ENV, and the real environment — so a {PREFIX}_{PATH}
override still beats a cloud value, exactly as it beats the file. This
tier carries settings, not secrets: key material keeps going through
key sources, and a cloud
config value holds at most a secret’s coordinates, never the secret.
Opting in is pure environment — no TOML key, no code change. Each binary
derives six bootstrap variables from its prefix (SITHBITD,
ACCOUNT_API, DOMAIN_SITHBIT, MAIL_GRPC, SITHBIT_IPFSD,
IPFS_GATEWAY):
| Variable | Meaning |
|---|---|
{PREFIX}_AWSAPPCONFIG | Select AWS AppConfig: application/environment/profile (exactly three non-empty segments) |
{PREFIX}_AWSAPPCONFIG_REGION | Optional region override (default: the ambient AWS configuration’s) |
{PREFIX}_AWSAPPCONFIG_ENDPOINT | Optional endpoint URL, for emulators/local fakes |
{PREFIX}_AZAPPCONFIG | Select Azure App Configuration: the store’s https://<name>.azconfig.io endpoint |
{PREFIX}_AZAPPCONFIG_LABEL | Optional label filter; unset reads the NULL label only, never every label |
{PREFIX}_AZAPPCONFIG_PREFIX | Optional key prefix, server-filtered and stripped before mapping (e.g. sithbitd:) |
With neither primary variable set the tier is skipped entirely — the
zero-config contract is untouched. Setting both is a load error
(pick one provider per binary). The bootstrap variables are control
inputs, not settings, and may themselves come from a .env file.
The payload idiom differs per provider:
- AWS AppConfig holds one whole TOML document in a freeform
configuration profile. It deep-merges over the config file: nested
tables merge key-wise, so one cloud document can override a section
without erasing its siblings; scalars and arrays replace wholesale.
Each load opens a fresh AppConfigData session and makes a single
GetLatestConfigurationcall — configuration is read once at startup, so picking up a new deployment means restarting the binary. - Azure App Configuration holds per-key values:
:in a key descends one TOML nesting level (store:kindsetsstore.kind), and values get the same TOML-scalar parsing as env overrides. Keys are case-sensitive — spell them exactly like the TOML keys. A scalar-vs-table collision between keys fails the load rather than letting listing order decide.
Both providers authenticate ambiently — the AWS credential chain, the
Azure managed identity — the same posture as the key sources above. The
backends are cargo features of the app-config crate (awsconf /
azconf, both on by default); a build that compiles one out fails at
load with an error naming the feature to rebuild with when its provider
is selected, so a slimmed operator build can drop the SDK it never uses
(see Slim-build features for
per-binary slim build commands).
The mapping logic (bootstrap parsing, TOML merge, key nesting) is
unit-tested against injected fake fetches, so CI covers it without
credentials. The live round trips are #[ignore]d probes gated on
environment variables: SITHBIT_TEST_AWSAPPCONFIG_APPLICATION /
_ENVIRONMENT / _PROFILE (plus optional _REGION / _ENDPOINT — an
AWS probe can target an emulator) with ambient AWS credentials, and
SITHBIT_TEST_AZAPPCONFIG_ENDPOINT (plus optional _LABEL / _PREFIX)
with an ambient managed identity.
You do not have to author the store content from scratch: the
repository ships ready-to-import production documents for both
providers — a complete six-service deployment shape with dummy secret
coordinates — under iac/appconfig/, together with the az appconfig kv import / aws appconfig runbooks and the generator that keeps the
two flavors in lock-step. See iac/README.md and the
deployment chapter.
[health] and [observability] — every binary
Two sections shared by every TOML binary — mail-grpc included, since
it moved onto the same layering:
| Key | Default | Meaning |
|---|---|---|
health.bind_addr | 127.0.0.1:<per-binary port> | The /healthz + /readyz listener; each binary defaults its own port (8190–8198, table in Monitoring) |
health.enabled | true | Disable to serve no health endpoints (--health-probe then exits 1) |
observability.otlp | (absent — no export) | Presence of the section enables OTLP push of traces + metrics |
observability.otlp.endpoint | "http://127.0.0.1:4317" | Collector gRPC endpoint. The standard OTEL_EXPORTER_OTLP_*ENDPOINT env vars silently override this — leave them unset |
observability.otlp.metrics_interval_seconds | 60 | Metric push cadence |
Every binary also accepts a --health-probe argument: load the same
config, GET the health listener’s /readyz, exit 0/1 — this is what
the compose healthcheck: entries run inside the distroless images.
See Monitoring and backups for the endpoints, the
metric list, and the docker-compose.otel.yml collector overlay.
sithbitd
The combined mail daemon: SMTP MX + submission, IMAP, POP, and the
spooler workers in one process. Run exactly one sithbitd per store
while [store] kind = "sqlite" — IMAP IDLE push and per-wallet
SendMail ordering are in-process. The postgres and cloud stores lift
that limit; see Scaling out.
[store] — shared with account-api
| Key | Default | Meaning |
|---|---|---|
kind | "sqlite" | Tables/queues/leases backend: "sqlite", "postgres" (PostgreSQL), "aws" (DynamoDB + SQS), "azure" (Tables + Queue Storage), "turso" (libSQL local file / embedded replica), or "cloudflare" (D1 + Queues + Workers KV + R2) |
database | "sithbit.db" | SQLite database path (kind = "sqlite") |
credential_key_file | "credential.key" | Seal key for stored mail passwords — unrecoverable if lost; back it up. A key source: a file path (default) or a cloud secret-manager secret |
[store.blobs] selects the mail-body blob store: kind = "local"
(default, path = "blobs"), "s3" (endpoint, bucket, region,
access_key, secret_key), or "azure" (endpoint, container,
account, access_key).
[store.postgres] (for kind = "postgres"): url (default
"postgres://postgres:[email protected]:5432/sithbit"). The schema is
migrated idempotently at startup; the database itself must already
exist. The URL’s password is redacted from logs and Debug output.
[store.aws] (for kind = "aws"): region, table (one DynamoDB
table, default "sithbit"), queue_prefix (SQS queues named
<prefix>-*, default "sithbit"), endpoint_url /
sqs_endpoint_url (emulators), sqs_wait_time_seconds (SQS receive
long-poll seconds, default 10; 0 = short polling), access_key /
secret_key — omit the
keys to use the ambient AWS credential chain (env, profile, IAM role) —
and kms_master_key_id (a KMS key ID, alias, or ARN for at-rest
encryption of the table and queues; unset, the default, keeps
provider-managed SSE). The table and queues are created idempotently at
startup.
[store.azure] (for kind = "azure"): account, table,
queue_prefix, table_endpoint, queue_endpoint, access_key. The
defaults target a local Azurite emulator (run it with
--skipApiVersionCheck); a real account derives its endpoints and
needs access_key. Note the Azure blob store has no emulator key
fallback: Azurite blob use needs the published well-known key passed
explicitly.
[store.turso] (for kind = "turso"): database (local
libSQL file,
default "sithbit.db"), sync_url (libsql://… / https://… — set it
to run an embedded replica against a remote Turso/libSQL primary;
unset = a pure-local file, on-disk-identical to kind = "sqlite"),
auth_token (bearer secret for the remote; redacted from logs — unset
for local dev or an unauthenticated self-hosted sqld), and
sync_interval_secs (seconds between background replica→remote pulls;
unset defaults to 60s for a replica). Blobs still come from [store.blobs]
(local/s3) — libSQL has no object store, exactly like SQLite. The schema
is migrated idempotently at startup.
[store.cloudflare] (for kind = "cloudflare"): the daemon reaches
Cloudflare’s edge primitives over plain HTTP with one bearer API token —
it is not hosted on Workers. Three native services back the five
store traits: D1 (SQLite-over-HTTP) for accounts + mail + keyed
leases, Cloudflare Queues for the job queue, and R2
(S3-compatible) for blobs.
| Key | Meaning |
|---|---|
account_id | Cloudflare account id (the /accounts/<id>/… REST path segment, and the subdomain of the derived R2 endpoint) |
api_token | Bearer token authorizing the D1/Queues calls; redacted from logs and Debug output |
d1_database_id | D1 database id (the /d1/database/<id>/query segment) |
kv_namespace_id | Retired 2026-07-19 — leases now ride D1’s leases table. Accepted so existing configs keep parsing, but ignored |
api_base | REST base URL override (mock servers / proxies); unset = https://api.cloudflare.com/client/v4 |
[store.cloudflare.queues] | The five queue ids — chain, relay, chain_delete, dsn, dead (Cloudflare assigns each queue its own id, so there is no name-prefix derivation as with SQS) |
[store.cloudflare.r2] | R2 blob store — an s3-shaped BlobConfig (endpoint, bucket, region, access_key, secret_key). Unlike the other backends, blobs come from this section, not [store.blobs], because R2’s endpoint is account-derived (https://<account_id>.r2.cloudflarestorage.com) unless you set endpoint explicitly |
Provisioning — auto vs prerequisite. The D1 schema (leases table
included) is provisioned automatically at startup: the shared
migration set is replayed over D1’s HTTP query API, idempotently (a
_d1_migrations tracking table makes a restart a no-op). The D1
database and the five Cloudflare Queues themselves are a manual
prerequisite — creating them is a Cloudflare management-API/dashboard
step with no local emulator, so the backend does not auto-create them. Selecting kind = "cloudflare"
without those ids configured fails fast at startup with a clear config
error rather than an opaque runtime 404.
Local-first testing. The full shared store-conformance suite — D1
(accounts, mail, DMARC, keyed leases) and Cloudflare Queues (the job
queue) — now runs with no Cloudflare account over the same
production wire path the daemon uses in the field: the real
ReqwestTransport and the http.rs/queue.rs request-builders and
response-parsers, driven against a loopback fake that speaks Cloudflare’s
public REST envelopes (D1 /query, Queues push/pull/ack/info) over a
real TCP socket. Because D1’s dialect is SQLite, the fake replays the
identical SQL against an in-process SQLite; queues run over stateful
in-memory state behind the REST surface. R2 blobs ride the same
minio-backed S3 path the other S3-shaped backends use.
This exercises the request-building and response-parsing that the earlier
in-process Transport fakes bypassed, and proves the trait semantics
survive a real socket. Be clear about what it does not prove: a
self-written fake only shows that http.rs/queue.rs are internally
consistent with the envelopes we assumed Cloudflare speaks — it cannot
confirm those assumptions against the real service. A live
real-Cloudflare conformance run therefore remains deferred: it needs a
paid account (R2 requires dashboard enablement, and Queues requires a
Workers Paid plan) and is gated behind real credentials.
[grpc] and [ipfs] — the chain pipeline
The two sections are independent halves of chain access. [grpc]
alone enables everything gateway-backed — SMTP recipient verification
at RCPT time (alias resolution + postage checks),
at-rest sealing, alias logins
— with the chain pipeline off: delivered copies stay in state
received, and boot logs the verification-only posture at info. This
is the MX posture: an edge that verifies postage but never writes the
chain needs no [ipfs] provider. Adding [ipfs] as well enables the
full pipeline (the encrypt → pin → SendMail workers), so mail
actually reaches the chain. [ipfs] without [grpc] does nothing —
no gateway means no chain to announce pins to — and logs a warning at
boot. With neither section, the chain pipeline is disabled entirely
(the dev default).
| Key | Default | Meaning |
|---|---|---|
grpc.endpoint | (unset) | The mail-grpc gateway, e.g. "http://127.0.0.1:50051" |
ipfs.kind | (inferred) | "embedded", "remote", "filebase", or "pinata" (the pinning provider). Unset: a configured [ipfs.remote]/[ipfs.filebase]/[ipfs.pinata] section implies its kind (pre-selector configs keep working), otherwise the embedded node |
ipfs.blobs | local ipfs/ dir | Embedded-node block/pin storage; same shape as [store.blobs] (local, s3, azure). Use one shared S3 bucket for the cluster model |
ipfs.remote.endpoint | "http://127.0.0.1:8182" | A shared sithbit-ipfsd daemon’s pin API (kind = "remote") — the multi-instance shape: the fleet pins through one node instead of each embedding its own |
ipfs.remote.auth_token | (unset) | Bearer token, when the daemon’s auth_token is set |
ipfs.swarm | (unset) | Embedded-node libp2p swarm; omit the section and no swarm runs. An empty [ipfs.swarm] is an isolated swarm (loopback listeners, no bootstrap, no announcements) |
ipfs.swarm.listen | loopback TCP + QUIC, ephemeral ports | Multiaddrs to listen on; public participation needs e.g. "/ip4/0.0.0.0/tcp/4001", "/ip4/0.0.0.0/udp/4001/quic-v1" |
ipfs.swarm.bootstrap | [] | Bootstrap peers (/…/p2p/<PeerId> multiaddrs) that seed the DHT routing table, keyed by PeerId |
ipfs.swarm.provide | false | Announce pinned mail-blob roots as DHT provider records (re-announced every reprovide_interval_secs, default 22 h) |
ipfs.swarm.kad_protocol | "/ipfs/kad/1.0.0" | The DHT protocol id. On a private network use "/ipfs/lan/kad/1.0.0" — Kubo keeps private-address peers out of the public DHT and discovers them via its LAN DHT instead |
ipfs.swarm.identity_file | (unset) | Persisted ed25519 identity (32-byte JSON seed, created if missing). Without it the PeerId — and every provider record naming it — goes stale each restart |
ipfs.cluster | (unset — solo) | Shared-bucket clustering for the embedded node (embedded kind only — a remote daemon clusters via its own [cluster]). An empty [ipfs.cluster] enables membership + partitioned reprovide (needs [ipfs.swarm] with provide = true) + the GC sweep. See Scaling out |
ipfs.cluster.heartbeat_interval_secs | 15 | Membership renewal + roster check cadence; a roster change triggers an immediate reprovide sweep, so this bounds how fast a dead node’s share reassigns |
ipfs.cluster.member_ttl_secs | 60 | Missed renewals this long mark a member dead; its share of the keyspace reassigns to the survivors |
ipfs.cluster.gc_interval_secs | 3600 | Cadence of the shared-bucket GC sweep (delete blocks no pin manifest references); 0 disables it. Concurrent sweeps from several nodes are safe, just redundant |
ipfs.cluster.gc_grace_secs | 3600 | Minimum age before an unreferenced block is deleted — must comfortably exceed the longest plausible pin upload (a pin writes blocks before its manifest) |
ipfs.repin.from | (unset — no migration) | "filebase" or "pinata": migrate legacy pins from that service (its credential section stays configured) onto the active provider, which must be explicitly "embedded" or "remote". See below |
ipfs.filebase.access_key / secret_key | (unset) | Filebase S3 credentials |
ipfs.filebase.bucket | (unset) | Pinning bucket, e.g. "sithbit-mail" |
ipfs.filebase.endpoint | "https://s3.filebase.com" | Override for testing |
An empty [ipfs] section (plus [grpc]) is a complete chain setup: the
embedded node imports mail blobs with the fixed CID
profile (CIDv1,
sha2-256, raw leaves, 256 KiB balanced dag-pb — byte-identical to
Kubo) and stores blocks/pin manifests in ipfs.blobs.
[ipfs.repin] runs the repin-and-verify migration: an hourly sweep
enumerates every fully chained copy and, per message, fetches the sealed
bytes back from the legacy service, re-pins them on the active
embedded/remote node, and releases the legacy pin only when the
re-imported root CID matches the recorded on-chain one. On a mismatch
(the legacy service imported with a different profile) the legacy pin is
kept — the on-chain CID is immutable and must stay resolvable — and the
sithbit.repin.outcomes{kind="mismatch"} counter grows. Watch that
counter; once it stabilizes the migration is done: remove [ipfs.repin]
(and, if nothing mismatched, the legacy credentials). Requires a store
backend with candidate enumeration (SQLite today); sithbitd refuses the
section otherwise at startup.
kind = "remote" delegates pin/unpin/fetch to a shared
sithbit-ipfsd daemon over HTTP instead of running a
node in-process — the multi-instance shape: the fleet pins through one
node (one swarm identity, one block store) rather than each daemon
embedding its own. [ipfs.blobs] and [ipfs.swarm] are ignored in
this mode; they are the daemon’s to configure.
With [ipfs.swarm] configured the node also joins the IPFS DHT: it
learns peers via identify, and with provide = true announces every
pinned root so stock Kubo peers can discover this node as the content’s
provider (pin manifests are the source of truth — a reprovide sweep
reconciles the DHT records against them on every tick, immediately at
startup). While the swarm runs, the node serves its pinned blocks over
bitswap (/ipfs/bitswap/1.2.0): any connected peer — or one that
found us via a provider record — can ipfs get the content straight
out of the configured blob store. Serving is one-way: the node answers
wantlists but never fetches foreign CIDs. For public retrievability the
listen addresses must be reachable from the internet (an open inbound
port); behind a closed firewall the DHT records carry addresses nobody
can dial.
[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:
| Key | Default | Meaning |
|---|---|---|
enabled | true / false | MX on, submission off by default |
hostname | (discovered) | 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 |
mode | "mx" / "submission" | Listener role |
sender_auth | "spf" | MX only: "spf", "dmarc-lite", "dmarc", or "none" — see Sender authentication below |
local_domains | (discovered) | 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) | DNSBL zone to check the connecting peer’s IP against at connect, e.g. "zen.spamhaus.org" |
dbl_zone | (unset) | 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 | 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) | 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) | 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 |
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 the account
API’s [static] root), 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 7489) 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, andpctsampling is not applied — 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 (550 5.7.1), andp=quarantineaccepts the message but files the recipient’s copy into theirJunkfolder (auto-created on first delivery — no operator setup). It also honorspct: a deterministic per-message roll drops enforcement one step (reject→quarantine, quarantine→none) for the unsampled fraction, per RFC 7489 §6.6.4, sopct=100always enforces andpct=0never does. A"dmarc"MX can also emit RFC 7489 aggregate (rua) reports back to the domains it evaluates — off unless you enable[spooler.dmarc_report]— and per-failure 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.
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 | 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 | Hourly external-recipient allowance for a brand-new account |
base_daily | 200 | Daily external-recipient allowance for a brand-new account |
max_hourly | 500 | Ceiling the hourly allowance ramps up to |
max_daily | 2000 | 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.
[imap], [pop]
| Key | Default | Meaning |
|---|---|---|
enabled | true | Per-protocol toggle |
imap.watch_poll_seconds | 0 | 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 | Request a TLS client certificate and offer SASL EXTERNAL. Inert without [imap.tls]; client auth stays optional |
pop.client_cert_auth | false | Same, for POP — but EXTERNAL is offered on implicit-TLS (POP3S/995) listeners only (see the caveat below). Inert without [pop.tls] |
[*.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 | 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 | Wrap the socket in TLS at accept instead of STARTTLS/STLS |
proxy_protocol | false | 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 |
proxy_trusted | [] | 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. Empty (the default) trusts any peer — suitable when only the balancer can reach the port. Ignored unless proxy_protocol is on; a malformed entry fails startup naming it |
limits.max_connections | 1024 | Concurrent connections across the listener |
limits.max_per_peer | 16 | Concurrent connections per peer IP |
limits.idle_timeout_secs | 600 | Session idle timeout |
[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.
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 and inert unless the matching […tls]
section is present, since client-cert auth exists only over TLS. When it
is on the listener merely requests the client certificate (optional TLS
client auth), so password clients keep connecting on the same port;
EXTERNAL is advertised and accepted only on a connection actually
running under client-auth TLS. One caveat for POP: a POP listener fixes
its mechanism list at connection start, so EXTERNAL is offered on
implicit-TLS listeners (POP3S, port 995) only — a plaintext socket
upgraded with STLS will not retroactively advertise it. SMTP submission
and IMAP have no such restriction (STARTTLS or implicit TLS both work).
[spooler] — outbound workers
| Key | Default | Meaning |
|---|---|---|
enabled | true | Run the background workers — relay, DSN, chain pin/send + delete, the auto-settle sweeper, the reconciler, and the repin migration — as one unit. false makes a listeners-only role instance: mail is still accepted and spooled, and a worker-enabled sibling over the same shared store drains the queues. The DMARC RUA/RUF workers keep their own switches, and the embedded IPFS swarm is unaffected. See Role-split topologies |
hostname | "localhost" | EHLO name, Reporting-MTA, and the MAILER-DAEMON domain |
local_domains | [] | Domains the DSN builder treats as locally deliverable |
delay_dsn | false | Emit “delayed” DSNs on retry schedules |
mta_sts | true | Honor recipient domains’ MTA-STS policies (RFC 8461) on direct-to-MX delivery. Ignored when [spooler.smarthost] is configured |
dane | true | Honor recipient MX hosts’ DANE TLSA records (RFC 7672) on direct-to-MX delivery, DNSSEC-validated; preferred over MTA-STS where both exist. Ignored when [spooler.smarthost] is configured |
dead_retention_days | 30 | Hourly prune of dead-lettered jobs buried longer ago than this many days; 0 = never prune. Entries with no readable bury date count as older than any cutoff — see Monitoring |
report_retention_days | 0 | Hourly prune of stored report blobs older than this many days — ingested DMARC aggregate reports under dmarc_rua/ and pending TLS-RPT result rows under tlsrpt/pending/; 0 (the default) = never prune, keep forever. dmarc_rua/ is the data GET /v1/admin/dmarc-reports serves — enabling retention removes reports from that admin surface once they age past the window, which is exactly why the default never does so un-asked. The worker only runs with enabled = true above |
spooler.settle.enabled | true | Run the auto-settle sweeper (only when the chain pipeline is enabled) |
spooler.settle.after_days | 30 | Post-delivery grace window before a delivered copy is settled |
spooler.settle.keep_pin | false | Keep the IPFS pin at settlement instead of releasing it |
[spooler.smarthost] routes all outbound mail through a fixed
relay — a
smarthost — instead of MX resolution:
host, port, user, password,
require_tls, implicit_tls. implicit_tls = true (default false)
dials the smarthost with TLS from the first byte — the port-465 “SMTPS”
style, named after the
listeners’ switch — instead of the
default in-band
STARTTLS; the
port is not auto-switched to 465, it stays whatever you set.
Certificate verification stays the smarthost path’s strict webpki check,
and because implicit TLS is TLS the conversation is encrypted even
with require_tls = false. [spooler.dkim] signs authenticated submissions with
DKIM:
domain, selector, key_file (see DNS setup for the
matching DNS record). The key_file is a key
source — a file path (default) or
a cloud secret-manager secret. A multi-domain server writes one entry per
sending domain with the [[spooler.dkim]] array form (the single-table
form keeps working); the signer is selected by the sender’s domain so
each domain’s signature aligns for DMARC, and a sender domain with no
entry spools unsigned. A domain listed twice refuses to start.
With no smarthost, the relay resolves each recipient domain’s published
MTA-STS policy
(RFC 8461)
before dialing its MXes — on by default via mta_sts. An enforce
policy restricts delivery to the MX hosts matching the policy’s mx
patterns, each contacted over TLS with a verified certificate; any TLS
failure — STARTTLS missing or refused, a handshake or certificate
error, or zero matching MXes — defers the mail on the normal retry
schedule rather than falling back to plaintext. A testing policy
delivers opportunistically and logs each MX target that would fail
under enforce (with [spooler.tlsrpt]
enabled, TLS-RPT reports cover these attempts too); a none policy, no
policy, or a transient DNS failure with nothing cached keeps today’s
opportunistic TLS (RFC 7435). Policies are cached in memory per domain
for their max_age (clamped to one year), so a cached enforce policy
keeps applying even if the DNS record is stripped. mta_sts = false is
a deliverability-debugging escape hatch only; the switch is not consulted
when a smarthost is configured, since that path never resolves MXes.
DANE
(RFC 7672)
rides the same direct-to-MX path — on by default via dane. When an MX host publishes a DNSSEC-validated TLSA record
set at _25._tcp.<mx-host>, the STARTTLS handshake must match the
published certificate data (the handshake is pinned to the records, not
to the webpki roots), and any mismatch or TLS failure defers the
mail rather than falling back — for that host DANE outranks an MTA-STS
policy, including its mx pattern filter. A validated TLSA set whose
records are all unusable for SMTP still demands TLS (unauthenticated —
unless an MTA-STS enforce policy applies, which then stays the stricter
floor). Hosts whose TLSA lookup fails DNSSEC validation are not dialed
at all; domains without DNSSEC or without TLSA records keep today’s
opportunistic TLS, so the switch only ever tightens delivery to domains
that opted in. The switch also turns on DNSSEC validation for MX
resolution itself: DANE requires a validated MX answer — or, for a
domain with no MX record, a validated denial. When the negative
answer’s SOA proves Secure, the implicit-A fallback is DANE-eligible
and TLSA records at _25._tcp.<domain> apply (the domain itself is
the connect host); a denial that cannot be validated keeps the
fallback on opportunistic TLS as before. Like
mta_sts, dane = false is a deliverability-debugging escape hatch
only, and the switch is not consulted when a smarthost is configured.
[spooler.settle] runs the auto-settle
sweeper: an hourly scan
reclaims the on-chain stamp value of every delivered copy older than
after_days (via DeleteMail) while keeping the local IMAP/POP
copy — so the recipient keeps reading their mail, but its stamp value
stops being locked on-chain. It is on by default at a 30-day window,
and by default it also unpins the sealed IPFS copy at settlement,
since settling removes the on-chain message account and the reclaimed
copy no longer needs serving. That interplays with trustless web-client
retrieval: once a message settles past the window, its decentralized
copy is no longer fetchable — the on-chain CID is gone and the pin is
released. Set keep_pin = true to leave the pin
in place so the sealed copy stays fetchable after settlement, or
enabled = false to never auto-settle. The sweeper only runs when the
chain pipeline is enabled ([grpc] + [ipfs]); with either absent
there is nothing on-chain to settle.
Pinning leases override
the unpin leg per-CID, with no configuration: before releasing a pin the
sweeper asks the gateway whether the copy’s CID carries a live on-chain
lease. A leased copy still settles — DeleteMail reclaims the stamp on
schedule — but keeps its pin for as long as the lease account exists.
The check fails closed: if the gateway cannot answer (chain
unreachable, or a gateway predating the GetPinLease RPC), the whole
copy is left unsettled and retried next sweep, because a settled copy is
never re-examined and a wrongly released pin cannot be won back. One
accepted asymmetry follows: closing a lease after its copy settled does
not retroactively release the pin — that storage is reclaimed by
ordinary operator garbage collection, not by the sweeper.
[spooler.dmarc_report] — aggregate (RUA) reporting
[spooler.dmarc_report] emits DMARC
aggregate (rua) reports — the RFC 7489 §7.2.1 gzip XML feedback a
receiver sends back to each domain whose mail it evaluated. It is off
by default: with the section absent (or enabled = false) the MX
records no aggregation data and no reporting worker runs, so an
empty/commented config stays a complete dev stack. Recording only
happens on an MX running sender_auth = "dmarc"
and only while this section is enabled.
| Key | Default | Meaning |
|---|---|---|
spooler.dmarc_report.enabled | false | Master switch. false (or section absent) records nothing and runs no worker |
spooler.dmarc_report.org_name | (required when enabled) | Your reporter identity, written to the report’s org_name |
spooler.dmarc_report.email | (required when enabled) | The report From: and outbound relay origin. Should be a local, DKIM-signable address so reports pass your own alignment |
spooler.dmarc_report.submitter | (the email domain) | The reporting-MTA domain used in the report filename/subject |
spooler.dmarc_report.extra_contact_info | (none) | Optional contact URI/text carried in the report |
spooler.dmarc_report.window_hours | 24 | Aggregation window length reported in the report metadata |
spooler.dmarc_report.interval_hours | 24 | How often the worker drains and sends |
Each interval the worker drains the DMARC evaluations recorded since the
last tick and emails one aggregate report per policy domain to the
rua addresses that domain publishes in its DMARC record. Before
sending to any address outside the policy domain it enforces the RFC
7489 §7.1 external-destination check — the target must publish a
<policy-domain>._report._dmarc.<target> authorization record — so a
report is only delivered where the receiving domain has opted in.
Reports go out through the normal outbound relay path from email and
are DKIM-signed like any other outbound mail (configure a matching
[spooler.dkim] entry for that domain).
Scope and failure behavior, stated honestly:
- A policy domain that publishes no
ruaaddress gets no report. - A
ruatarget that fails the external-destination check is skipped (definitively discarded for that window). - A transient DNS or spool failure defers rather than drops: the affected rows are re-recorded for the next tick instead of being lost.
- The report’s
policy_publishedpolicy fields (p/sp/adkim/aspf) are not populated — the store does not retain the reported domain’s published policy, only the per-message evaluation results.
Commented block from sithbitd.example.toml (defaults shown):
# [spooler.dmarc_report]
# enabled = false
# org_name = "Example Mail"
# email = "[email protected]"
# submitter = "example.com"
# extra_contact_info = "https://example.com/dmarc"
# window_hours = 24
# interval_hours = 24
[spooler.dmarc_ruf] — forensic (ruf) reporting
[spooler.dmarc_ruf] emits DMARC
failure/forensic (ruf) reports — the RFC 7489 §7.3 per-message
feedback a receiver sends back the instant a message fails DMARC, wrapping
the offending message in an RFC 5965 ARF message/feedback-report. Like
aggregate reporting it is off by default: with the section absent (or
enabled = false) the MX builds no forensic reports, so an
empty/commented config stays a complete dev stack. Reporting only happens
on an MX running sender_auth = "dmarc"
and only while this section is enabled.
| Key | Default | Meaning |
|---|---|---|
spooler.dmarc_ruf.enabled | false | Master switch. false (or section absent) builds and sends nothing |
spooler.dmarc_ruf.include_body | false | Attach the full offending message (message/rfc822) instead of the headers-only default — see the privacy note below |
spooler.dmarc_ruf.org_name | (empty — set when enabled) | Your reporter identity, the report From: display name |
spooler.dmarc_ruf.email | (empty — set when enabled) | The report From: and outbound relay origin. Should be a local, DKIM-signable address so reports pass your own alignment |
spooler.dmarc_ruf.subject | "DMARC Forensic Failure Report" | The report Subject:; an empty value derives this default |
spooler.dmarc_ruf.extra_contact_info | (none) | Optional operator contact URI. Accepted for parity with [spooler.dmarc_report], but the ARF forensic format carries no such field — informational only |
Unlike aggregate reporting there are no window or interval settings:
forensic reports are per-message, not batched. On a DMARC failure whose
published policy carries a ruf= URI and whose fo= failure-options
match, the server builds one ARF report and relays it directly and
best-effort — no store, no worker, no retry queue. Before sending to any
ruf= target it enforces the RFC 7489 §7.1 external-destination check (the
target must publish a <policy-domain>._report._dmarc.<target>
authorization record), exactly as aggregate reporting does, so a report is
only ever delivered where the receiving domain has opted in. Reports relay
from email through the normal outbound path and are DKIM-signed like any
other outbound mail (configure a matching
[spooler.dkim] entry for that domain). Each
report also carries the offending message’s envelope identifiers — the RFC 5965
Original-Mail-From, Original-Rcpt-To, and Original-Envelope-Id fields — so
the receiving operator can correlate the failure to the delivery attempt.
Privacy — headers-only by default. A forensic report carries the
offending message itself to whoever the sender domain’s ruf= URI names,
so it is a content-exposure surface aggregate reports never are. SithBit
follows the RFC 7489 §7.3 content-minimization default: only the offending
message’s headers are attached (text/rfc822-headers). Setting
include_body = true attaches the full message/rfc822 — leaking the
message’s entire content to the ruf= operator. Leave it off unless you
specifically need full-body forensics and trust every domain you evaluate.
Scope and failure behavior, stated honestly:
- A policy domain that publishes no
rufaddress (or whosefo=does not select the failure) gets no report. - A
ruftarget that fails the §7.1 external-destination check receives nothing — it is dropped, not retried. - A transient resolver or spool failure also drops the report: there is no persistence and no retry queue. A forensic report lost to a transient failure is acceptable by design (unlike aggregate reporting, which defers and re-records affected rows for the next tick).
Commented block from sithbitd.example.toml (defaults shown):
# [spooler.dmarc_ruf]
# enabled = false
# include_body = false
# org_name = "Example Mail"
# email = "[email protected]"
# subject = "DMARC Forensic Failure Report"
# extra_contact_info = "mailto:[email protected]"
[spooler.dmarc_rua_ingest] — DMARC report ingestion
The receiving side of DMARC aggregate reporting: when another operator’s
receiver mails an RFC 7489 aggregate (rua) report to one of your
operated domains, this section makes the delivery path parse it and store
the result as JSON for the account API’s
GET /v1/admin/dmarc-reports surface. Off by default — with the
section absent (or enabled = false) inbound reports are ordinary
delivered mail. Pair it with
postmaster_wallet above so
external reporters (who never hold a prefunded frombox) can reach the
mailbox at all.
| Key | Default | Meaning |
|---|---|---|
spooler.dmarc_rua_ingest.enabled | false | Master switch. false (or section absent) delivers reports as ordinary mail, parsing nothing |
spooler.dmarc_rua_ingest.recipients | ["postmaster"] | Delivered recipients that trigger parse+store; each entry is a bare local-part (matched at any local domain) or a full address. The raw message still lands in the mailbox either way — parsing is additive, never a diversion. Parsed reports are stored under the fixed dmarc_rua/ blob prefix (deliberately not configurable — it is the admin surface’s read contract) |
[spooler.tlsrpt] — SMTP TLS reporting (TLS-RPT)
[spooler.tlsrpt] records SMTP TLS Reporting (TLS-RPT, RFC 8460)
results for outbound mail: one row per relay attempt against a recipient
MX host — including hosts a policy excluded before dialing — noting
whether the TLS session succeeded and, on failure, the derived RFC 8460
result code plus the policy (MTA-STS, DANE TLSA, or none) that governed
the attempt. It is off by default: with the
section absent (or enabled = false) the relay records nothing, so an
empty/commented config stays a complete dev stack. Recording happens on
the direct-to-MX path only — a configured
smarthost is not the recipient domain’s
TLS posture, so nothing is recorded when one routes everything.
| Key | Default | Meaning |
|---|---|---|
spooler.tlsrpt.enabled | false | Master switch. false (or section absent) records nothing |
spooler.tlsrpt.org_name | (required when enabled) | Your reporter identity, written to the report’s organization-name |
spooler.tlsrpt.email | (required when enabled) | The report From: and outbound relay origin. Should be a local, DKIM-signable address (RFC 8460 §3 requires reports to pass DKIM) |
spooler.tlsrpt.contact_info | (mailto: the email) | The report’s contact-info URI |
spooler.tlsrpt.window_hours | 24 | Aggregation window length reported in the report metadata |
spooler.tlsrpt.interval_hours | 24 | How often the drain worker runs |
Recorded rows accumulate as JSON blobs under the fixed tlsrpt/pending/
blob prefix (deliberately not configurable). The one enabled switch also
starts the report drain worker: at each interval_hours tick it
folds the pending rows into one RFC 8460 report per recipient domain,
discovers the domain’s rua= targets from its _smtp._tls TLSRPT
record, and delivers over both channels — mailto: targets ride the
normal outbound relay, DKIM-signed on spool entry (hence the local,
DKIM-signable email, whose domain doubles as the report’s submitter
identity), https: targets receive the gzip-compressed JSON directly.
A domain that publishes no TLSRPT record gets no report and that
window’s rows are dropped; rows are deleted only after delivery to
every target, so a crash between send and delete can re-deliver a
window — the deterministic report-id lets receivers de-duplicate. See
the conformance
appendix
for the full recording and reporting semantics.
Scope and failure behavior, stated honestly:
- Recording is strictly observational: a failed row write logs a warning and never changes the delivery outcome, and a recorded TLS failure still defers/retries exactly as before.
- Success rows are flag-truthful: a success is recorded only when
the completed conversation actually ended on TLS. A completed
plaintext opportunistic delivery — TLS never negotiated, including
a declined STARTTLS offer that continued in the clear — records no
row at all: under RFC 8460 it is neither a TLS session nor a failed
attempt. The seam cannot tell “STARTTLS never offered” apart from
“offered but declined” — both go unrecorded;
starttls-not-supportedfailure rows are reserved for enforced postures that abort the delivery. - Handshake failures are recorded with the general
validation-failurecode and the TLS error detail infailure-reason-code; the specific certificate codes (certificate-expired,certificate-host-mismatch, …) cannot be distinguished at this seam. - Hosts a policy excludes before dialing record never-dialed failure
rows: an unusable DANE TLSA set records
dnssec-invalid, an MX target outside an enforce-mode MTA-STS policy recordssts-policy-invalid, each with the planner’s diagnostic infailure-reason-code. Unreachable or timed-out hosts still produce no row, and MTA-STStesting-mode mismatches stay warn-log only.
Commented block from sithbitd.example.toml (defaults shown):
# [spooler.tlsrpt]
# enabled = false
# org_name = "Example Mail"
# email = "[email protected]"
# contact_info = "mailto:[email protected]"
# window_hours = 24
# interval_hours = 24
account-api
| Key | Default | Meaning |
|---|---|---|
bind_addr | "127.0.0.1:8180" | HTTP listen address |
admin_wallets | [] | Wallets allowed on the /v1/admin routes (account/queue inspection, dead-letter requeue — see Monitoring). Empty disables the admin surface: every admin call is 403 |
[store] | (same as sithbitd) | Point it at the same store so one database serves both |
jwt.issuer / jwt.audience | "sithbit" | Token claims |
jwt.key_file | "jwt.key" | 32-byte signing key — auto-generated if missing, then unrecoverable; back it up (losing it invalidates all sessions). A key source: a file path (default) or a cloud secret-manager secret (auto-generation applies to the file form only) |
jwt.ttl_hours | 24 | Token lifetime |
[chain] | (absent) | Enables the authenticated /v1/chain on-chain read proxy + signed-transaction relay (the Thunderbird extension’s surface). Absent = those routes answer 503; an empty section uses the two defaults below |
chain.grpc_endpoint | "http://127.0.0.1:50051" | The mail-grpc gateway serving mailbox/key/alias/ frombox/tx-status reads |
chain.rpc_url | "http://127.0.0.1:8899" | Solana JSON-RPC node for SOL balances and relaying client-signed transactions |
mail.local_domains | [] | Domains whose recipients live in this store: compose (POST /v1/mail/send) delivers them locally, resolving aliases and prechecking stamps over the [chain] gateway. Mirror sithbitd’s local_domains. Empty = every recipient rides a relay job |
[quota] | (enabled, sithbitd’s defaults) | Outbound-relay quotas + suspension on compose — the same keys, defaults, and ramp as sithbitd’s [smtp.quota] / [submission.quota]; the two are twins by design, keep them in step. Over-quota external recipients at compose answer 429, a suspended composer 403 |
[mail.dkim] | (absent) | DKIM keys for composed mail — the same one-or-many shape as sithbitd’s [spooler.dkim], selected by the sender’s domain. Absent = composed relay mail goes unsigned |
[static] | (absent) | Serves a static directory on the API’s own origin (browser pages reach /v1/… without CORS) — the Outlook add-in’s built bundle is the intended occupant. Absent = no static routes; a missing root 404s per request |
static.route | "/addin" | Route prefix the files appear under |
static.root | "wwwroot" | Directory to serve (point it at webclients/outlook/staging) |
[tls] | (absent) | Terminates TLS on the API listener itself (dev sideloads, small deployments — production guidance is still a reverse proxy). Both keys are required when present; a missing/invalid file fails at startup, never per-connection |
tls.cert_file | (required) | PEM certificate chain — a key source: a file path (default) or a cloud secret-manager secret holding the PEM |
tls.key_file | (required) | PEM private key — same file-or-cloud key source as cert_file |
sithbit-console
The management TUI over the account API’s /v1/admin routes: accounts,
mailboxes, messages with chain states, queue depths, and dead-letter
requeue/discard (see Monitoring; full tutorial and key
reference in the sithbit-console appendix). It
never touches the store directly — every read and action rides the API.
Config file sithbit_console.toml (or SITHBIT_CONSOLE_CONFIG), env
prefix SITHBIT_CONSOLE.
| Key | Default | Meaning |
|---|---|---|
api_url | "http://127.0.0.1:8180" | Base URL of the account API |
keypair_file | ~/.config/solana/id.json | Solana keypair that signs the wallet-challenge login — its wallet must be on the API’s admin_wallets allowlist |
gateway_endpoint | "http://127.0.0.1:50051" | mail-grpc gateway the balances pane reads on-chain mailbox/frombox state through — a SolanaMail gRPC endpoint (GetMailbox default stamp price + mail count, GetFrombox per-sender stamps) |
rpc_url | "http://127.0.0.1:8899" | Solana JSON-RPC node the balances pane fetches native SOL balances from (getBalance) |
The balances pane is the one console view that reads chain state directly
rather than through the account API: press b on a wallet to see its
native SOL, its mailbox’s default stamp price and mail count, and the
prepaid stamps each other loaded wallet holds toward it. Those figures come
straight from mail-grpc and the JSON-RPC node above — so a console used
only for the admin panes can leave both endpoints at their loopback
defaults (or unset the whole file). Every entry defaults, so an empty or
absent sithbit_console.toml targets the loopback dev stack:
# api_url = "http://127.0.0.1:8180"
# keypair_file = "~/.config/solana/id.json"
# gateway_endpoint = "http://127.0.0.1:50051"
# rpc_url = "http://127.0.0.1:8899"
domain-sithbit
| Key | Default | Meaning |
|---|---|---|
bind_addr | "127.0.0.1:8181" | HTTP listen address |
wwwroot | "wwwroot" | Static files (the verification front-end) |
delegate_key_file | (unset) | Solana keypair of the postoffice’s standing delegate — the key that signs on-chain domain authorizations. A key source: a file path (default) or a cloud secret-manager secret. Re-loaded from the configured source on every POST /domain (for a cloud kind, a fresh fetch per request), so a delegate rotation is picked up by swapping the file or cloud secret, no restart. Boot-validated: an unreadable or malformed key fails startup, not the first request. Unset, POST /domain replies 503 (DNS lookups still work). A hot key by design — rotate it on a schedule |
Its on-chain calls resolve the
RPC endpoint the way the sithbit CLI
does: the config file at ~/.config/solana/cli/config.yml, managed with
sithbit config get/set,1 overridden by a JSON_RPC_URL
environment variable.
[mail_hosts] — client autoconfiguration
The public IMAP/POP/SMTP coordinates domain-sithbit advertises to
unmodified mail clients through its autoconfig/autodiscover routes (see
domain-sithbit: client autoconfiguration).
Every sub-server is dev-defaulted to a loopback stack on the standard
implicit-TLS mail ports, so an empty config still serves a valid document;
point the hosts at your real, publicly reachable servers before
advertising them.
| Key | Default | Meaning |
|---|---|---|
[mail_hosts.imap] host | "127.0.0.1" | Public IMAP hostname a client connects to |
[mail_hosts.imap] port | 993 | IMAP port |
[mail_hosts.imap] socket_type | "SSL" | Transport security (see below) |
[mail_hosts.pop] host | "127.0.0.1" | Public POP3 hostname |
[mail_hosts.pop] port | 995 | POP3 port |
[mail_hosts.pop] socket_type | "SSL" | Transport security |
[mail_hosts.smtp] host | "127.0.0.1" | Public submission hostname |
[mail_hosts.smtp] port | 465 | Submission port — implicit-TLS SMTPS by default (RFC 8314); set 587 with socket_type = "STARTTLS" to advertise the opt-in secondary instead |
[mail_hosts.smtp] socket_type | "SSL" | Transport security |
socket_type takes the Thunderbird tokens "SSL" (implicit TLS from
connect), "STARTTLS" (opportunistic upgrade), or "plain" (no
encryption, dev only); lowercase aliases ("ssl", "starttls") are also
accepted, and the Outlook POX <SSL>/<Encryption> flags are derived
from it. A present [mail_hosts.<server>] sub-table must spell out
all three fields (a partial table fails startup loudly); an omitted
whole sub-server falls back to its defaults. Env overrides use the usual
__ descent, e.g. DOMAIN_SITHBIT_MAIL_HOSTS__SMTP__HOST=mail.example.com.
[mta_sts] — MTA-STS policy publication
The
MTA-STS
policy (RFC 8461) this instance publishes at GET /.well-known/mta-sts.txt
for the domains it fronts (see domain-sithbit: publishing the MTA-STS
policy). The whole
section is opt-in: absent, the endpoint replies 404. Senders fetch the
policy as https://mta-sts.<domain>/.well-known/mta-sts.txt, so front the
service with a TLS proxy holding a certificate for that hostname.
| Key | Default | Meaning |
|---|---|---|
mode | "testing" | What the policy demands of senders: "enforce" (MX mismatch or TLS failure = do not deliver), "testing" (failures are reported, delivery proceeds), or "none" (the domain withdraws its policy). The default reports without blocking mail |
mx | [] | MX identity patterns senders match delivery targets against — exact hostnames (mx.example.com) or a *. wildcard covering exactly one leftmost label (*.example.com). Must cover every host your MX records name. Required — at least one — unless mode = "none" |
max_age | 604800 (one week) | Policy lifetime in seconds — how long senders cache it. RFC 8461 recommends weeks. Values above one year (31557600, the §3.2 ceiling senders clamp to anyway) are rejected |
Validation is fail-fast at startup, never per request: a mode outside
the RFC vocabulary, an enforce/testing policy with an empty mx
list, or an over-ceiling max_age all refuse to boot rather than serve
a broken policy. Remember to bump the _mta-sts.<domain> TXT record’s
id whenever you edit this section — senders re-fetch the policy only
when that id changes (see DNS
setup).
sithbit-ipfsd
The self-hosted IPFS node as its own daemon: the same embedded
repo/swarm sithbitd can run in-process, behind a small HTTP pin API
(POST/DELETE /pins/{name}, GET /ipfs/{cid}) for fleets that share
one node via [ipfs] kind = "remote". Config file sithbit_ipfsd.toml
(or SITHBIT_IPFSD_CONFIG), env prefix SITHBIT_IPFSD_.
| Key | Default | Meaning |
|---|---|---|
bind_addr | "127.0.0.1:8182" | HTTP listen address |
auth_token | (unset — open) | Bearer token required on every request when set. Bind beyond loopback only with a token or network isolation — the pin API is a write surface |
max_pin_bytes | 33554432 (32 MiB) | POST body limit; larger uploads are refused with 413 |
[blobs] | local ipfs/ dir | Block/pin storage — the same shape and semantics as sithbitd’s [ipfs.blobs] |
[swarm] | (unset — no swarm) | Identical to sithbitd’s [ipfs.swarm] (listen/bootstrap/provide/kad_protocol/identity_file); every pin announces to it, and pinned blocks serve over bitswap |
[cluster] | (unset — solo, no GC) | Identical to sithbitd’s [ipfs.cluster] (heartbeat/TTL/GC settings): N daemons over one [blobs] bucket heartbeat a membership roster in the bucket, partition the DHT reprovide keyspace by rendezvous hashing, and GC unreferenced blocks. See Scaling out |
[swarm] — service-record freshness
When a node advertises itself for decentralized service
discovery — publishing a signed
service record on the DHT so clients
can find its POP/IMAP endpoints without DNS SRV — two settings in the swarm
section govern how fresh that advertisement stays. They live under
[ipfs.swarm] for sithbitd and under [swarm] for sithbit-ipfsd (the same
section that carries listen/bootstrap/provide/identity_file), and both
have in-code defaults, so an empty swarm section is still valid — a plain node
that never advertises a service simply ignores them.
| Key | Default | Meaning |
|---|---|---|
service_record_ttl_secs | 900 (15 min) | How long an advertised record stays fresh from its created_at stamp. Deliberately minutes-scale — service liveness wants minutes, unlike the ~22 h content-reprovide cadence — so a node that stops heartbeating ages out of discovery quickly. Validated on the client’s DHT get, so a lapsed record is dropped before it is ever trusted |
service_heartbeat_interval_secs | 300 (5 min) | How often the node re-stamps created_at and re-publishes its records. Keep it comfortably below service_record_ttl_secs so a record never lapses between heartbeats (the default 5 min ≪ 15 min TTL leaves two missed beats of slack) |
These settings affect only advertisement freshness. Authority — who may
serve the domain — is proved separately by the node’s
delegation chaining to the
on-chain MailDomain.authority, checked by the client both on the DHT record
and in the self-authenticating TLS
handshake,
never by these timers. A public DHT advertising POP/IMAP endpoints is
enumerable, so the usual [*.server] connection limits and DNSBL/DBL still
apply to the listeners those records point at.
sithbit-gateway
The read-only IPFS HTTP path gateway: GET/HEAD /ipfs/{cid}
(deserialized, plus trustless ?format=raw|car) over the same
block/pin bucket the node writes; locally-absent CIDs answer 404 — it
never fetches from the IPFS network. Config file ipfs_gateway.toml
(or IPFS_GATEWAY_CONFIG), env prefix IPFS_GATEWAY_.
| Key | Default | Meaning |
|---|---|---|
bind_addr | "127.0.0.1:8183" | HTTP listen address. The surface is read-only, so no auth gate exists; bind it wherever readers live |
public_host | (unset — path gateway only) | Base domain for the subdomain (Host-based) gateway: set it to also serve <base32-cidv1>.ipfs.<public_host> browser-origin-isolated requests. Unset keeps path routing only |
[blobs] | local ipfs/ dir | Block/pin storage — the same shape as sithbit-ipfsd’s [blobs]; point it at the shared bucket the node/cluster pins into. The gateway only reads it |
mail-grpc
Config file mail_grpc.toml (or MAIL_GRPC_CONFIG), env prefix
MAIL_GRPC. An empty or missing file is a runnable dev gateway: the
chain endpoint and the signing keypair fall back to the operator’s
Solana CLI config (~/.config/solana/cli/config.yml),1 exactly like
the sithbit CLI — a missing CLI config means the stock CLI defaults
(mainnet-beta). This replaced the legacy environment-only configuration
as a clean break: GRPC_SERVER_ADDRESS, DEFAULT_KEYPAIR, and the other
old names are no longer read — see the mail-grpc chapter
for the migration note. The one legacy name still honored is bare
JSON_RPC_URL, which overrides the endpoint for parity with the CLI (see
the json_rpc_url row below).
| Key | Default | Meaning |
|---|---|---|
bind_addr | "127.0.0.1:50051" | gRPC listen address. Loopback by default — the gateway is private-network-only by design; expose it deliberately, never by default |
json_rpc_url | (unset — Solana CLI config) | Solana RPC endpoint; unset falls back to the CLI config’s json_rpc_url. A bare JSON_RPC_URL environment variable overrides this (env > this key > CLI config), matching the sithbit CLI |
keypair | (unset — Solana CLI config) | The fee-payer/signing keypair, a key source: a keypair-file path (not the keypair content) or a cloud secret-manager secret holding the keypair JSON. Unset falls back to the CLI config’s keypair_path (~/.config/solana/id.json by default) |
alias_cache_seconds | 30 | TTL for the ResolveAlias cache (0 disables caching). Kept short because sold/transferred aliases must not resolve stale; when the alias indexer runs, marketplace events evict entries within its poll interval anyway |
[alias_index] database | "alias_index.db" | SQLite path for the alias-enumeration index. Set explicitly empty to disable the indexer (ListAliases/ListSales then answer UNAVAILABLE) |
[alias_index] poll_seconds | 5 | How often the indexer polls for new alias transactions |
[health], [observability] | health on 127.0.0.1:8193 | The two shared sections above |
The alias index is derived state: it backfills from chain history on an empty database, so the file needs no backup (see Monitoring and backups).
Which services get a .env
mail_spooler, account_api, domain_sithbit, ipfs_daemon, and
ipfs_gateway each ship a sample .env and .env.development in their
crate directory — commented-out templates for every environment-variable
override, using the precedence chain above. Copy the ones you need and
uncomment.
Other crates in the workspace deliberately don’t have one:
mail-grpcuses the same layered.env/.env.$APP_ENVmechanism as the binaries above (it reads them from the working directory, withMAIL_GRPC_*overrides), but ships no sample: its whole surface is the short table above, andmail_grpc/mail_grpc.example.tomlalready shows every key. The workspace-root.envthat used to configure it is retired — its legacy names (GRPC_SERVER_ADDRESS,DEFAULT_KEYPAIR, …) are no longer read by anything, and the file survives only as commented-out devnet fixture history.- The
sithbitCLI (mail_client) reads no.envat all; its only configuration inputs are the config filesithbit configmanages1 and theJSON_RPC_URLenvironment variable. mail_program/alias_program/domain_programare on-chain SBF programs with no runtime environment.- Every other workspace crate is a library with no binary target, so there’s nothing to configure at runtime.
-
This is the same file (
~/.config/solana/cli/config.yml, same location on Windows too) the Solana CLI’s ownsolana configcommand reads and writes, if you already have it installed — see CLI Quickstart. ↩ ↩2 ↩3