sithbitd: spooler settings
Part of the configuration reference, continuing
sithbitd’s core settings: the [spooler] background
workers — relay, DSNs, the smarthost and DKIM signing, MTA-STS and DANE,
the auto-settle sweeper — plus DMARC aggregate (RUA) and forensic (RUF)
reporting, inbound DMARC report ingestion, SMTP TLS reporting (TLS-RPT),
and large-attachment IPFS offload. This is what turns mail a listener
accepted (see the SMTP / IMAP, POP &
security pages) into delivered mail.
[spooler] — outbound workers
Row markers follow the going-public legend.
| 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 |
spooler.hostname | "localhost" | REQUIRED (public). EHLO name, Reporting-MTA, and the MAILER-DAEMON domain. Inherits the shared top-level hostname while still on the built-in value |
spooler.local_domains | [] | REQUIRED (public). Domains the DSN builder treats as locally deliverable. Inherits the shared top-level local_domains while empty |
delay_dsn | false | Emit “delayed” DSNs on retry schedules |
mta_sts | true | RECOMMENDED (public). Honor recipient domains’ MTA-STS policies (RFC 8461) on direct-to-MX delivery. Ignored when [spooler.smarthost] is configured |
dane | true | RECOMMENDED (public). 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 | RECOMMENDED (public). 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 | RECOMMENDED (public). Keep the IPFS pin at settlement instead of releasing it |
[health], [observability] | (the shared defaults) | RECOMMENDED (public). The two shared sections; this binary’s health port is in the Monitoring table |
One more hourly prune runs alongside the two retention prunes above and
carries no setting at all: the daemon deletes every expired
login-challenge row (the account API’s auth_nonces, written by
POST /v1/auth/nonce) each hour, unconditionally — even with
enabled = false above, since a listeners-only instance shares the
store those rows live in. There is nothing to configure because there is
no retention policy to pick: each row carries its own ~300-second expiry
set at issuance, so an expired challenge is garbage by definition, unlike
a dead-lettered job an operator may want to inspect. A deployment running
the account API with no daemon over its store keeps expired rows between
logins — see account-api.
RECOMMENDED (public). [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.
REQUIRED (public). [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.
Each entry can additionally opt into RFC 8463 dual-signing with the
ed25519_selector / ed25519_key_file pair — set both or neither (half
a pair refuses to start; with both absent, the default, the entry signs
rsa-sha256 only, unchanged). When configured, every signed message
carries a second, ed25519-sha256 DKIM-Signature header alongside the
rsa-sha256 one, each signing the same headers and body independently, so
verifiers honor whichever algorithm they support. ed25519_key_file is
the same key source shape
as key_file and holds a PKCS#8 PRIVATE KEY PEM — what
openssl genpkey -algorithm ed25519 writes. The second selector is
DNS-mechanical, not decorative: one _domainkey name publishes one key
record, so the ed25519 public key needs its own record at
<ed25519_selector>._domainkey.<domain>, shaped
v=DKIM1; k=ed25519; p=<key> where p= is the raw 32-byte public key
base64 — not a DER-wrapped SubjectPublicKeyInfo like RSA’s.
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 9990 §3.5.2 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 | RECOMMENDED (public). Master switch. false (or section absent) records nothing and runs no worker |
spooler.dmarc_report.org_name | (empty) | REQUIRED (public) when enabled. Your reporter identity, written to the report’s org_name. sithbitd refuses to start when the section is enabled and this is empty — set both org_name and email, or disable the section. |
spooler.dmarc_report.email | (empty) | REQUIRED (public) when enabled. The report From: and outbound relay origin. Should be a local, DKIM-signable address so reports pass your own alignment. sithbitd refuses to start when the section is enabled and this is empty — set both org_name and email, or disable the section. |
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 | Retired — still accepted so an existing config boots, but ignored. The period is fixed at 24 h |
spooler.dmarc_report.interval_hours | 24 | Retired — as above; a daily report’s drain cadence is its window |
Every 24 hours 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
9990 §4 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_publishedfields carryp/sp/np/adkim/aspf, read from the record that governed each evaluation.npis echoed only when the domain actually published annp=tag: the DMARC library fills an absent one in with a copy ofspwhile parsing, and reporting that would attribute a policy the domain never stated, so the element is simply omitted instead — as it also is for annp=published equal tosp=, which RFC 9091 makes the default reading of an absent one anyway. One element is not emitted at all:discovery_method, deliberately omitted because this MX folds the report’s grouping domain with the public suffix list while its alignment verdicts come from the tree walk — claiming either method would be a false statement rather than a missing one. - Reports are emitted in the RFC 9990
urn:ietf:params:xml:ns:dmarc-2.0namespace, and that is the intended behavior rather than an accident of a library version. RFC 9990 is Standards Track and obsoletes RFC 7489, so emitting the current schema is precisely what running a DMARCbis receiver means; andnp— the element the bullet above discusses — exists only indmarc-2.0, so downgrading the namespace would foreclose ever reporting it. The tradeoff, stated rather than hidden: a consumer that still validates strictly against the RFC 7489dmarc-1.0schema will refuse our reports, and there is no switch to emit the older form. That is an accepted consequence of adopting DMARCbis. In the receiving direction we stay lenient — the optional report ingest path accepts bothdmarc-1.0anddmarc-2.0bodies. The emitted namespace is pinned by a test, so a future library bump that moves it again fails loudly instead of silently.
Commented block from sithbitd.example.toml (defaults shown):
# [spooler.dmarc_report]
# enabled = false
# org_name = "Example Mail"
# email = "dmarc-reports@example.com"
# submitter = "example.com"
# extra_contact_info = "https://example.com/dmarc"
# # Retired: both keys still parse, but the period is fixed at 24 h.
# window_hours = 24
# interval_hours = 24
[spooler.dmarc_ruf] — forensic (ruf) reporting
[spooler.dmarc_ruf] emits DMARC
failure/forensic (ruf) reports — the RFC 9991 §2 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) | REQUIRED (public) when enabled. Your reporter identity, the report From: display name. sithbitd refuses to start when the section is enabled and this is empty — set both org_name and email, or disable the section. |
spooler.dmarc_ruf.email | (empty) | REQUIRED (public) when enabled. The report From: and outbound relay origin. Should be a local, DKIM-signable address so reports pass your own alignment. sithbitd refuses to start when the section is enabled and this is empty — set both org_name and email, or disable the section. |
spooler.dmarc_ruf.subject | "DMARC Forensic Failure Report" | The report Subject:; an empty value derives this default |
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 9991 §5 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 9991 §7.1 content-minimization guidance: 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 §5 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 = "dmarc-reports@example.com"
# subject = "DMARC Forensic Failure Report"
There is no extra_contact_info here, unlike
[spooler.dmarc_report]: the ARF
forensic format has no field to carry one, so the key never reached a
report and was removed.
[spooler.dmarc_rua_ingest] — DMARC report ingestion
The receiving side of DMARC aggregate reporting: when another operator’s
receiver mails an RFC 9990 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 | RECOMMENDED (public). 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.chain_budget] — on-chain publication rate limit
Bounds how fast delivered mail is published to Solana, and with it how fast this daemon can spend from the gateway’s fee-payer wallet.
Why this exists. Recipients pay for postage, but the transaction fee
for every SendMail comes out of the operator’s own gateway wallet. Most
paths price the sender before anything reaches the chain — except
postmaster_wallet,
which RFC 5321 §4.5.1 requires to accept mail from anyone and which
therefore skips the postage check by design. Without a budget, an
unauthenticated sender can drive fee-payer spend at whatever rate the
connection limits allow. Set this whenever postmaster_wallet is set.
Over-budget mail is not refused. It is accepted, stored, and readable over IMAP/POP immediately — only its on-chain publication is paced, by enqueueing the chain job invisible until its turn. Nothing bounces and no sender sees an error; the copy simply reaches the chain later. A sustained flood therefore grows the chain queue rather than being shed, which is the deliberate trade: mail is never destroyed to protect the wallet.
max_per_window is the setting that actually holds. On the
postmaster_wallet path the envelope sender is unauthenticated and free to
vary, so a per-sender budget alone is walked past by changing MAIL FROM
on every message. max_per_sender_per_window is fairness on top — it stops
one heavy sender consuming the whole shared allowance — never a substitute.
Both budgets count publications per window, not lamports: a SendMail
fee is near-constant, so a publication count stands in faithfully for the
money spent. The window is fixed rather than sliding, so a burst straddling
a boundary can reach up to twice the budget — the same trade the
account API’s rate limits make.
| Key | Default | Meaning |
|---|---|---|
spooler.chain_budget.max_per_window | 0 | REQUIRED (public) when postmaster_wallet is set. On-chain publications admitted per window across every sender together. 0 = unlimited. This is the budget that bounds fee-payer spend; set it whenever postmaster_wallet is set |
spooler.chain_budget.max_per_sender_per_window | 0 | RECOMMENDED (public). Publications admitted per window for any one envelope sender, so a single heavy sender cannot consume the whole shared allowance. 0 = unlimited. Fairness only — an unauthenticated sender can vary MAIL FROM, so this never bounds total spend on its own |
spooler.chain_budget.window_secs | 60 | RECOMMENDED (public). Seconds both budgets reset on. When this daemon shares a store with account_api, keep this at or below that service’s longest rate-limit window — the store’s window sweep takes a cutoff rather than a key prefix, so a shorter-windowed sweeper can retire a live window early, which makes the budget under-enforce for one window |
The budgets are charged against the same store-backed counter the account
API’s durable rate limits use, so every sithbitd
replica over one store shares one budget rather than getting its own. A
store that cannot answer fails open — the publication goes out
unpaced, and a warning is logged — because a counter outage must not stop
mail reaching the chain.
Report traffic this daemon generates itself (DSNs, DMARC forensic reports, TLS-RPT reports) is deliberately not budgeted: it is the operator’s own mail, exactly as it is already exempt from the per-wallet storage cap.
[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 | RECOMMENDED (public). Master switch. false (or section absent) records nothing |
spooler.tlsrpt.org_name | (empty) | REQUIRED (public) when enabled. Your reporter identity, written to the report’s organization-name. sithbitd refuses to start when the section is enabled and this is empty — set both org_name and email, or disable the section. |
spooler.tlsrpt.email | (empty) | REQUIRED (public) when enabled. The report From: and outbound relay origin. Should be a local, DKIM-signable address (RFC 8460 §3 requires reports to pass DKIM). sithbitd refuses to start when the section is enabled and this is empty — set both org_name and email, or disable the section. |
spooler.tlsrpt.contact_info | (mailto: the email) | The report’s contact-info URI |
spooler.tlsrpt.window_hours | 24 | Retired — still accepted so an existing config boots, but ignored. The period is fixed at 24 h |
spooler.tlsrpt.interval_hours | 24 | Retired — as above; a daily report’s drain cadence is its window |
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: every 24 hours 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 = "tlsrpt@example.com"
# contact_info = "mailto:tlsrpt@example.com"
# # Retired: both keys still parse, but the period is fixed at 24 h.
# window_hours = 24
# interval_hours = 24
[spooler.offload] — large-attachment IPFS offload
[spooler.offload] keeps oversized attachments out of the stored and
pinned message. An attachment larger than threshold_bytes decoded
bytes is sealed under its own freshly generated key, pinned through the
same [ipfs] provider the chain workers use, and replaced in the
delivered message by a placeholder part whose link points at
<gateway_url>/ipfs/<cid> — with the key riding in the URL’s
#fragment, the one part of a URL a client never puts on the wire. The
gateway therefore serves ciphertext it cannot read, and only a recipient
holding the whole link can open the attachment.
Two size rules decide which parts leave, and they compose as a union
rather than one overriding the other. threshold_bytes takes any part
individually over it. aggregate_bytes then caps what one message may
leave inline in total, offloading the largest eligible parts until
the rest fits — the case a per-part threshold structurally cannot catch,
because it is compared per part and only per part, so twenty 1 MB parts
under a 5 MiB threshold ride inline as a 20 MB message.
It is off by default, and opting in is deliberate: with the section
absent — or present but leaving both threshold_bytes and
aggregate_bytes at 0 — no message is ever rewritten and delivered
bytes are byte-identical to a daemon without the feature. Naming a
gateway_url alone does not arm it. Either size rule arms it on its
own, so aggregate_bytes with no threshold set is a valid “cap the
total, ignore part size” configuration. Offload also requires the chain
pipeline ([grpc] + [ipfs]): without a pinning provider there is
nothing to build a fetchable link from, so the settings stay inert.
| Key | Default | Meaning |
|---|---|---|
spooler.offload.gateway_url | "http://127.0.0.1:8183" | REQUIRED (public) when enabled. Public base URL of the read-only IPFS gateway (sithbit-gateway) serving the pinned ciphertext. Recipients’ clients fetch this, so it must be the address they can reach — never an internal one; the default is that gateway’s own loopback dev bind so the zero-config stack stays coherent. A trailing / is ignored and a path prefix (https://example.com/gw) is honored |
spooler.offload.threshold_bytes | 0 | RECOMMENDED (public). Decoded attachment size, in bytes, above which a part is offloaded (strictly greater; the comparison is against the decoded size, not the base64 on the wire). 0 — the default — disables the feature outright |
spooler.offload.aggregate_bytes | 0 | RECOMMENDED (public). Decoded attachment bytes one message may leave inline in total, above which the largest eligible parts are offloaded until the rest fits (strictly greater, decoded, like threshold_bytes). 0 — the default — leaves this rule off; a non-zero value arms the offload on its own. Counts attachment parts only, never the text or HTML bodies, and is a target rather than a guarantee: a part content_id holds inline still counts toward it but is never taken to satisfy it |
spooler.offload.content_id | "never" | What to do with an over-threshold part carrying a Content-ID — the header an HTML body uses to render a part inline as cid:…. "never" holds every such part inline at any size; "orphaned" also offloads the ones no body actually references; "all" additionally offloads referenced ones, rewriting each <img> that rendered one into a link. See What is offloaded — and what never is |
A pin failure tempfails the whole submission (451) rather than
delivering the attachment inline: the sending MTA retries with the
attachment intact, while a silent downgrade would defeat the threshold in
exactly the case it exists for.
Commented block from sithbitd.example.toml (defaults shown):
# [spooler.offload]
# gateway_url = "http://127.0.0.1:8183"
# threshold_bytes = 0
# aggregate_bytes = 0
# content_id = "never"
Choosing a threshold. 5 MiB (5242880) is the suggested production
value: comfortably above ordinary photo and PDF attachments — which stay
inline and behave like ordinary mail — and well under the 25 MiB
max_message_size accept
ceiling, where a single attachment would otherwise dominate every stored
and pinned copy. Set it lower only if you would rather more attachments
became links.
Choosing a budget. 10 MiB (10485760) is the suggested production
value for aggregate_bytes: it leaves ordinary multi-attachment mail
alone while bounding the pathological case the threshold misses. The two
numbers answer different questions and are worth setting together — the
threshold decides “is this one file too big to store?”, the budget
decides “is this whole message too big?” — and because the budget takes
the largest parts first, a message that trips only the budget loses as
few of its inline attachments as the arithmetic allows.