Configuration reference
Every user-settable option, in one place, checked against the options classes that bind it. Each section's table states the key, its type, the value a deployment gets by writing nothing, the constraint startup enforces, and what a change needs to take effect. The prose around a setting group — what it means, why it is shaped that way, how to choose a value — lives on the page each section links; this page is the inventory.
How to read the tables
Keys. Written in configuration-section form. As an environment variable, : becomes __ and a list index is a
numbered segment: MailSynchronization:Accounts:0:Host is MailSynchronization__Accounts__0__Host. Where the
configuration comes from, and which source wins, is configuration sources.
Types. A TimeSpan binds from hh:mm:ss ("00:05:00" is five minutes; a leading d. adds days). A date binds
as yyyy-MM-dd, an instant as ISO 8601 with an explicit offset. An enum binds by member name, and a secret block
is the three-field shape secret provisioning defines:
{ "Name": "imap-primary-password", "SecretReference": "file:/etc/mailfathom/secrets/imap-primary-password", "Lifetime": "NoLimit" }
Name is the identity diagnostics use, SecretReference is <scheme>:<target> with the schemes
systemd-credential:, file:, env:, and plaintext:, and Lifetime is NoLimit (the default) or the ISO 8601
instant the material stops being accepted. Trust-anchor and certificate blocks nest a fourth field, Password, itself
a secret block, for protected PKCS#12 bundles.
Change. What ADR 0002 classifies for the group:
- restart — the section is read while the host composes itself; edit it, then restart.
- reload — a changed value is validated and, if sound, adopted by the next operation without a restart; a rejected candidate leaves the running configuration in force. Reload of a file-shaped source has caveats of its own under Kubernetes — see configuration sources.
Whatever the classification, the material behind a secret reference is read per use: rotating a password, key, or certificate behind an unchanged reference needs no restart and no reload. Secret rotation walks each case.
Validation. Every MailFathom section below is bound strictly: a key the section does not define fails startup
naming it, so a typo cannot silently leave a default in force. Values are validated on start, and a violated
constraint fails startup with the configuration path in the message. The two exceptions are the framework-shaped
entries — Logging and ConnectionStrings — and the single-key Secrets:Interpretation, which is read with a
default rather than bound as a section.
ConfigurationSources
Names JSON configuration provisioned outside the application — a mounted ConfigMap, a systemd drop-in. Configuration sources is the page.
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
ConfigurationSources:Directory |
string | unset | Must exist when named | restart |
ConfigurationSources:File |
string | unset | Must exist when named | restart |
The content of files that existed at startup reloads; adding or removing a file is a restart.
Secrets
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
Secrets:Interpretation |
enum | ReferenceOnly |
ReferenceOnly, ReferenceOrInline, InlineOnly |
restart |
Under the default, a plain-text value where a reference belongs fails startup instead of authenticating.
Interpretation modes records when the other two are appropriate;
development keeps ReferenceOrInline so plaintext: references stay convenient.
MailSynchronization
Whether and how mailboxes are synchronized. IMAP synchronization explains the model. The section reloads per operation — a run takes one validated snapshot when it begins, so a changed account list, bound, or policy is adopted at the next run rather than mid-run — except the four values that shape the coordinator loop itself, which are read once at start and marked restart below.
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
MailSynchronization:Enabled |
bool | false |
Enabled requires at least one account | restart |
MailSynchronization:Interval |
TimeSpan | 00:05:00 |
10 s – 1 day; measured end-of-run to start-of-run | restart |
MailSynchronization:MaxFailureBackoff |
TimeSpan | 00:30:00 |
10 s – 1 day, and never below Interval |
reload |
MailSynchronization:MaxConcurrentAccounts |
int | 4 |
1 – 100 | restart |
MailSynchronization:MaxConcurrentFoldersPerAccount |
int | 1 |
1 – 20 | reload |
MailSynchronization:WriteConnectionIdlePeriod |
TimeSpan | 00:02:00 |
5 s – 30 min; how long an account's single write connection keeps its slot after the last change it carried | restart |
MailSynchronization:MaxMutationAttempts |
int | 5 |
1 – 100; how many attempts one recorded change to a mailbox may spend before it is given up on and left visible as stuck | restart |
MailSynchronization:MaxMutationsPerConvergencePass |
int | 50 |
1 – 1000; how many unfinished changes one account run takes in hand before the rest wait for the next run | reload |
MailSynchronization:UnknownMutationOutcomeGrace |
TimeSpan | 06:00:00 |
1 min – 7 days; how long a change whose placement was never acknowledged waits to be settled by observation before it is given up on | reload |
MailSynchronization:ShutdownDrainTimeout |
TimeSpan | 00:00:10 |
0 – 2 min | restart |
MailSynchronization:MaxMetadataBatchSize |
int | 100 |
1 – 1000 | reload |
MailSynchronization:MaxRawMimeBytes |
long | 26214400 (25 MiB) |
1024 – 104857600; larger messages are stored without content | reload |
MailSynchronization:MaxMetadataBatchesPerRun |
int | 10 |
1 – 1000 | reload |
MailSynchronization:MaxReconciledEmailsPerRun |
int | 500 |
1 – 10000 | reload |
MailSynchronization:MaxMimePartCount |
int | 1000 |
1 – 100000 | reload |
MailSynchronization:MaxMimeNestingDepth |
int | 30 |
1 – 1000 | reload |
MailSynchronization:MaxExtractedTextCharacters |
int | 100000 |
1000 – 200000; the ceiling keeps the search vector inside PostgreSQL's limit | reload |
MailSynchronization:PushRenewalInterval |
TimeSpan | 00:20:00 |
1 min – 29 min; the lifetime of one IDLE command, not a polling cycle — the ceiling is what RFC 2177 mandates |
reload |
MailSynchronization:MaxConsecutivePushFailures |
int | 3 |
1 – 100 | reload |
MailSynchronization:PushDegradationPeriod |
TimeSpan | 00:15:00 |
10 s – 1 day | reload |
MailSynchronization:MaxSubscribedFolders |
int | 20 |
1 – 100; how many folders one push subscription may name on a server supporting NOTIFY, the rest synchronizing on the account's interval |
reload |
One account — MailSynchronization:Accounts:<n>
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
…:AccountId |
string | — | Required; unique across accounts after normalization | reload |
…:Host |
string | — | Required when synchronization is enabled | reload |
…:Port |
int | 993 |
1 – 65535 | reload |
…:UserName |
string | — | Required when synchronization is enabled; an identifier, not a secret | reload |
…:Secrets:Password |
secret block | unset | Required when the permitted mechanisms include any password mechanism; must resolve at startup | reload; material per connection |
…:Mode |
enum | Polling |
Polling, Push; push holds one connection open per account on a server supporting NOTIFY, and one per folder on a server offering only IDLE |
reload; the next run adopts it |
…:EarliestEmailReceivedDate |
date | unset (everything) | Not in the future (compared in UTC) | reload |
…:RemotelyDeletedEmailDisposition |
enum | RetainTombstone |
RetainTombstone, EraseLocalCopy |
reload; governs disappearances observed from then on |
…:Folders |
list | inbox by role | Aliases unique; each entry below | reload |
A folder entry names Alias (required — your stable name for the folder) and exactly one of RemotePath (the
server's own path) or SpecialUse (a role discovery resolves: Inbox, Archive, Drafts, Sent, Junk, Trash,
All, Flagged, Important). Configuring no folder synchronizes the inbox by role.
OAuth — …:OAuth
Read only when the account's permitted mechanisms include XOAUTH2 or OAUTHBEARER. An account that authenticates
with a password leaves the whole block unset, and configuring it anyway fails startup rather than provisioning
credentials nothing can use. Mailbox OAuth covers where each value comes from.
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
…:OAuth:Grant |
string | — | refresh_token or client_credentials |
reload |
…:OAuth:TokenEndpoint |
string | — | Absolute HTTPS address; no opt-in exists for http |
reload |
…:OAuth:ClientId |
string | — | Required; an identifier, not a secret | reload |
…:OAuth:Scope |
string | — | Space-delimited, as RFC 6749 defines it | reload |
…:OAuth:PublicClient |
bool | false |
Set when the application is registered as a public client, which holds no secret | reload |
…:OAuth:ClientSecret |
secret block | unset | Required unless PublicClient is true, and refused alongside it; must resolve at startup |
reload; material per token request |
…:OAuth:RefreshToken |
secret block | unset | Required by refresh_token; absent for client_credentials |
reload; material per token request |
Transport security — …:TransportSecurity
The rules are the domain's; every weakening is an explicit opt-in, and unsafe combinations fail startup.
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
…:ConnectionSecurity |
enum | TlsOnConnect |
Auto, TlsOnConnect, StartTlsRequired, StartTlsWhenAvailable, None; anything but the two guaranteed-TLS modes requires AllowInsecureConnection |
reload |
…:PermittedAuthenticationMechanisms |
string list | PLAIN, LOGIN |
Supported SASL names, including XOAUTH2 and OAUTHBEARER; an unordered allow-list, the client picks the strongest that survives |
reload |
…:AllowInsecureConnection |
bool | false |
Opt-in for modes that can leave the channel unencrypted | reload |
…:AllowClearTextAuthenticationOverUnencryptedConnection |
bool | false |
Opt-in on top of the above | reload |
…:CertificateTrust |
enum | SystemTrustStore |
SystemTrustStore, AdditionalTrustedAuthority |
reload |
…:TrustedCertificateAuthority |
secret block | unset | Required by, and only valid with, AdditionalTrustedAuthority |
reload; material per connection |
Certificate validation itself cannot be disabled; a private server is supported by trusting its authority.
Persistence and the connection string
Where the local copy lives. The connection settings travel through the validated snapshot, so repointing them reaches the next physical connection without a restart; the remaining settings are read while the host composes itself.
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
ConnectionStrings:mailfathom |
string | Host=localhost;Database=mailfathom;Username=mailfathom |
Carries no password | reload (new connections) |
Persistence:ConnectionString |
secret block | unset | Replaces ConnectionStrings:mailfathom entirely when set |
reload (new connections) |
Persistence:Password |
secret block | unset | A present block must carry a reference | reload (new connections); material per connection |
Persistence:MaximumConcurrencyCommitAttempts |
int | 2 |
1 – 10; counts the first attempt | restart |
Persistence:CommandTimeoutSeconds |
int | 30 |
1 – 600; bounds one command, not one unit of work | restart |
Persistence:TextSearchConfiguration |
string | simple |
A stock PostgreSQL text search configuration (simple, english, german, …) |
restart — and it is part of the schema: the value is compiled into the index, startup fails with 32003 on a mismatch, and changing it means regenerating the migration and rebuilding the search documents |
Repointing a reference or editing the connection string reloads; changing which setting supplies the credential —
moving a password out of the connection string into Persistence:Password, or back — is refused on reload and needs a
restart, because the connection pool attaches its password provider once.
DataEncryption
The key ring every value MailFathom seals at rest is sealed under. A configuration root of its own rather than a
section of Persistence, because the database is the first thing sealed under it and there is no reason it is the
last. ADR 0005 records the whole decision, and
secret provisioning states how the material is generated and referenced.
An absent section is a valid deployment that seals nothing. Configuring the section makes every rule below apply.
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
DataEncryption:ActiveKeyId |
string | unset | Must name one of Keys; required once any key is configured, and refused when none is |
reload |
DataEncryption:Keys:<n>:KeyId |
string | — | Up to 64 letters, digits, dots, dashes, and underscores, beginning with a letter or a digit; unique within the ring | reload |
DataEncryption:Keys:<n>:Material |
secret block | — | Base64 decoding to exactly 32 bytes, generated with openssl rand -base64 32 |
reload; material per operation |
KeyId is stored beside every value the key seals, so it is chosen once and never edited — renaming it orphans every
value already carrying the previous spelling. The operator's own label for a key is its material's Name, which every
secret block requires; there is no second name on the entry.
The ring holds several keys so that rotation needs no downtime: move ActiveKeyId to the new key, leave the previous
key configured, and every value still carrying it keeps opening under it. Removing a key the database still references
makes those values unopenable, and the failure appears at the next read rather than at the edit.
MailboxSearch
The deployment-wide privacy bound on what a search result may quote. Lexical email search records how snippets are cut.
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
MailboxSearch:SnippetsPerEmail |
int | 3 |
1 – 10 | restart |
MailboxSearch:WordsPerSnippet |
int | 24 |
4 – 100 | restart |
EmailContent
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
EmailContent:MaxBodyCharacters |
int | 100000 |
1000 – 1000000; each body representation is truncated to it, explicitly | restart |
EmailContent:MaxCharactersPerRead |
int | 200000 |
2000 – 2000000, and at least twice MaxBodyCharacters; the body characters one call returns across every email it names |
restart |
Embeddings
What this deployment intends to embed with. Writing nothing is a supported deployment: no vectors are produced, semantic search is unavailable, and lexical search serves exactly as before. Declaring a chain does not start spending — an activation does. Embedding generation records what a declaration means and what it costs.
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
Embeddings:AllowTrimVectors |
bool | false |
with it off, a declared dimension above 2000 is refused at startup; with it on, a wider answer is cut to the declared width and renormalized | restart |
Embeddings:MaxPassagesPerRequest |
int | 64 |
1 – 2048; the batch bound, applied before the provider sees a request | restart |
Embeddings:RequestTimeout |
TimeSpan | 00:01:00 |
positive; one request to one endpoint | restart |
Embeddings:MaxQueuedEmails |
int | 1024 |
1 – 1000000; newly synchronized messages that may wait to be embedded at once, beyond which synchronization stops offering and the backfill reaches the rest | restart |
One endpoint — Embeddings:Endpoints:<n>
An ordered chain. Every entry declares the same geometry and reaches the same vector space, so a failing endpoint falls through to the next without changing what any stored vector means; startup refuses a chain whose entries disagree, naming both aliases and the property.
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
…:Alias |
string | — | required, unique within the chain; what a log line, a metric tag, a resilience circuit, and a failure message call this endpoint | restart |
…:Provider |
string | — | required, at most 64 characters; the vendor whose model defines the space, not the endpoint it is reached at | restart |
…:Model |
string | — | required, at most 128 characters; the vendor's published model identifier | restart |
…:ModelVersion |
string | (empty) | at most 64 characters; empty is a vendor that versions nothing, which is the ordinary case | restart |
…:RoutedModelName |
string | (empty) | at most 128 characters; what is sent as the model of a request where that differs — a cloud deployment's own name. Empty means it equals Model |
restart |
…:Dimension |
int | — | 1 – 16000, and 1 – 2000 unless AllowTrimVectors is on; the width the stored vectors have and the profile records |
restart |
…:DistanceMetric |
enum | Cosine |
Cosine, InnerProduct, EuclideanDistance |
restart |
…:InputCharacterLimit |
int | 8000 |
positive; what a passage is cut to before it is sent, which is what the model saw and therefore part of what a vector means | restart |
…:PassageInstruction |
string | (empty) | at most 512 characters; empty for a model that requires none. Whitespace is refused, because it would register a second profile for a space identical to one already registered | restart |
…:NormalizeVectors |
bool | true |
whether the space's vectors are of unit length | restart |
…:Address |
string | (empty) | absolute HTTPS; empty uses the provider library's default. A cloud resource's OpenAI-compatible address ends in /openai/v1/ |
restart |
…:SupportsRequestedDimension |
bool | true |
whether the endpoint honours a requested width, so the narrower space is asked for rather than cut out of a wider answer | restart |
…:ApiKey |
secret block | (absent) | the provider key. Exactly one of this and EntraCredential is declared |
restart, value read per request |
Microsoft Entra credential — Embeddings:Endpoints:<n>:EntraCredential
For an endpoint where no key exists to provision. All four shapes are non-interactive by construction: MailFathom is a
background service with nobody at a keyboard, and DefaultAzureCredential is deliberately not used because its chain
reaches an interactive browser credential and the developer-tool credentials of whoever is signed in on the host.
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
…:Kind |
enum | ManagedIdentity |
ManagedIdentity, WorkloadIdentity, ClientSecret, ClientCertificate. ApiKey is refused here; a key is declared as one |
restart |
…:TokenScope |
string | https://ai.azure.com/.default |
required; the audience an access token is minted for. Declared rather than derived from the address, so a renamed service does not silently mint tokens for the wrong audience | restart |
…:TenantId |
string | (empty) | required for ClientSecret and ClientCertificate |
restart |
…:ClientId |
string | (empty) | required for ClientSecret and ClientCertificate; optional for ManagedIdentity, where it selects a user-assigned identity |
restart |
…:ClientSecret |
secret block | (absent) | required for ClientSecret |
restart |
…:CertificatePath |
string | (empty) | required for ClientCertificate; a PKCS#12 file the process account can read |
restart |
…:CertificatePassword |
secret block | (absent) | where the certificate file has one | restart |
EmbeddingBackfill
The sweep that gives mail stored before the active profile its passages and its vectors. A root of its own rather than
a block inside Embeddings, because what an instance embeds with is a commitment and how fast it works through the
mail it already had is a rate an operator changes while watching a bill. Every key here is a pacing control:
BatchSize × MaxBatchesPerRun is the most one run may spend, and Interval is how often that is paid.
Embedding backfill describes what it reaches and why it repeats.
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
EmbeddingBackfill:Enabled |
bool | true |
turning it off stops the spending within one interval and loses nothing already embedded | restart |
EmbeddingBackfill:Interval |
TimeSpan | 00:00:30 |
1 s – 24 h; the pause between runs while messages still await embedding | restart |
EmbeddingBackfill:IdleSweepInterval |
TimeSpan | 00:15:00 |
1 s – 24 h; the pause before a sweep starts again after one reached the end | restart |
EmbeddingBackfill:BatchSize |
int | 20 |
1 – 500 | restart |
EmbeddingBackfill:MaxBatchesPerRun |
int | 5 |
1 – 1000 | restart |
MailExtractionBackfill
The worker that extracts text for messages stored before extraction existed or before a limit was raised.
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
MailExtractionBackfill:Enabled |
bool | true |
— | restart |
MailExtractionBackfill:Interval |
TimeSpan | 00:00:30 |
1 s – 1 h | restart |
MailExtractionBackfill:BatchSize |
int | 50 |
1 – 500 | restart |
MailExtractionBackfill:MaxBatchesPerRun |
int | 10 |
1 – 1000 | restart |
Where each surface is served
Every socket this process opens is named by the section of the surface that owns it, and by nothing else. McpEndpoint
and AdminEndpoint each state a BindAddress, a Port, and a Transport; HealthEndpoints states the same three
under a smaller set of TLS settings. Read those three sections and you have read every listener the process binds.
The host's own ways of naming a listener are refused at startup. ASPNETCORE_URLS, ASPNETCORE_HTTP_PORTS,
ASPNETCORE_HTTPS_PORTS, --urls, and any endpoint under Kestrel:Endpoints each fail the process with a message
naming the setting that replaces them. They are refused rather than ignored because ignoring them is silent: Kestrel
drops the URL-shaped addresses as soon as a listener is bound in code — which every surface here does — and binds a
configured endpoint beside them on a socket no section describes, no credential guards, and no isolation middleware was
composed for. An operator who states a port deserves to be told it moved, not to find the surface answering somewhere
else.
A deployment that enables no surface at all is refused for the same reason. Kestrel answers zero listeners by binding its own default address, and a development certificate on the machine would add a TLS one beside it, so the process would hold a socket nothing configured.
Transport
McpEndpoint:Transport and AdminEndpoint:Transport decide what the surface's clear-text socket does. The HTTPS half
is the profiles under Https:Endpoints, each with its own domain, certificate, TLS floor, and HTTP versions.
| Value | The socket at BindAddress:Port |
Https:Endpoints |
|---|---|---|
Http |
Serves the routes | Must be empty |
HttpAndHttps |
Redirects to the profiles, or serves the routes when Https:Redirect:Enabled is false |
Bind |
HttpsOnly |
Not opened at all | Bind |
Http is the default, so adopting a release costs no certificate work, and it is the right posture behind a
TLS-terminating reverse proxy and wrong anywhere else — startup warns about it either way, because only an operator
knows which they have.
HttpsOnly is the posture that leaves nothing reachable in clear text. HttpAndHttps keeps the clear-text socket, and
the redirect is on unless a deployment turns it off, so enabling TLS does not read as an outage to a client nobody has
repointed yet; turning the redirect off is what makes that socket serve the routes, which is the deliberate
both-schemes posture rather than the migration one. HealthEndpoints:Transport takes the same three values, and
because the probes carry one certificate rather than profiles, its HttpAndHttps needs a second port of its own in
HealthEndpoints:HttpsPort.
Sharing a socket
Two surfaces, or all three, may name one port. That is the posture a single-node deployment behind one ingress wants —
one socket to publish and one backend to route — and it is why both request-serving surfaces default to 8080 for clear
text and 8443 for a profile. The port is bound once and serves each surface's own paths; which paths a request may ask
for is decided from the port it arrived on, so a surface that is not on that port is still refused there with a 404.
What sharing costs is exposure. The probes answer without a credential and the administrative surface is a different authority from the mailbox, so putting either on the endpoint's port publishes it wherever that port is published. Keep them apart when that matters; the ports exist so the decision is yours.
Which settings a shared socket couples
Sharing is per socket, not per surface. Each surface declares its clear-text socket (BindAddress + Port) and one
socket per HTTPS profile (Https:Endpoints:<n>:BindAddress + :Port) separately, so two surfaces may share the
clear-text one and keep TLS sockets of their own. Every rule below applies to one socket at a time, and each failure
names both sections.
| Setting | Coupled on | Why it cannot differ |
|---|---|---|
Transport — whether this socket carries TLS |
Every shared socket | One socket serves one scheme |
Https:Redirect:Enabled |
A shared clear-text socket | The socket either redirects or serves the routes; it cannot do both |
| The domain a redirect resolves | A shared redirecting socket | The client sent one host name, so two answers to it would be settled by composition order |
| Profiles by server name vs one certificate | A shared TLS socket | A socket answers a handshake one way; the probes present one certificate and the endpoints select by name |
ClientCertificateProfiles — configured or not |
A shared TLS socket | Whether a certificate is asked for is settled while the connection is established |
Https:Endpoints:<n>:Domain — uniqueness |
A shared TLS socket | One name served by two surfaces would leave composition order deciding which the client reached |
Https:Endpoints:<n>:HttpProtocols |
A shared TLS socket | ALPN offers what the listener was bound with, which is before any server name has been read |
BindAddress — a wildcard beside a specific address |
The same port | The operating system grants only one of those two sockets |
What stays each surface's own, on a shared socket as much as on a separate one:
- The HTTPS ports. Two surfaces sharing a clear-text socket may redirect to profiles on ports of their own, because a
redirect resolves the name the client asked for:
mail.example.testgoes to the MCP endpoint's port andadmin.example.testto the administrative one, from the same8080. Only publishing one name at two ports is refused. MinimumTlsVersion, per profile. The TLS floor is settled per connection, after the server name is known, so profiles on one socket may each keep their own.- Different domains on one TLS socket, which is what sharing one is for.
- Everything decided per request rather than per socket:
Authenticationand every method it carries,Cors,RateLimiting, origin validation, and the route prefix. An API key provisioned for an agent still authenticates nothing under/api/admin, whichever port both are reached on.
Two specific addresses on one port are two sockets and are accepted; none of the rules above applies between them.
ReverseProxy
Which peers this process accepts a public scheme and host from, when something in front of it terminates TLS. One section for the whole process rather than one per surface: it runs at the front of the one request pipeline every listener shares, so a proxy named here is trusted on each of them. Behind a TLS-terminating reverse proxy is the page.
X-Forwarded-Proto and X-Forwarded-Host are always read; there is no key that switches that off. What the section
carries is who they are believed from, and an unconfigured section believes every peer.
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
ReverseProxy:TrustedProxies |
string list | empty, which trusts 0.0.0.0/0 and ::/0 |
Each entry an IP address or a CIDR network whose host bits are clear — not a DNS name. What is named replaces the default rather than adding to it, and the framework's loopback default is cleared rather than inherited. Left empty, or written as 0.0.0.0/0 and ::/0, it trusts every peer and so disables the refusal of an OAuth token that arrived without TLS — see what the default costs |
restart |
ReverseProxy:MaximumForwardedHops |
int | 1 |
At least 1; how far right-to-left through each header a value is believed | restart |
X-Forwarded-For is never read, so the peer MailFathom observes stays the one that opened the connection, and the
configured OAuth Resource stays a value you wrote rather than anything derived from a header.
McpEndpoint
Whether the protocol surface is served and what a client must present. The whole section is restart — it decides
routing and listeners — while key and certificate material is read per request or per handshake. Where it is served is
its own BindAddress, Port, and Transport.
The MCP endpoint is the page, section by section.
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
McpEndpoint:Enabled |
bool | false |
— | restart |
McpEndpoint:BindAddress |
string | 0.0.0.0 |
An IP address; binds the clear-text socket, which HttpsOnly does not open |
restart |
McpEndpoint:Port |
int | 8080 |
1–65535. The administrative endpoint's default as well — see sharing a socket | restart |
McpEndpoint:Transport |
enum | Http |
Http, HttpAndHttps, HttpsOnly — see Transport |
restart |
McpEndpoint:Authentication |
list of credentials | empty | One entry per accepted credential; empty warns at startup. A value written here rather than a list fails startup | restart |
The accepted credentials — McpEndpoint:Authentication:<n>
Each entry carries the block of whichever method judges it, and the block's presence is what selects that method — there is no separate setting naming it. As many entries may state any method as a deployment needs, and one entry may carry several blocks; the only entry that fails startup is one carrying none, named by its position. Both endpoints take the same entries; the administrative one adds a single rule, stated with it below.
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
…:<n>:ApiKey |
secret block | — | One named secret with its own Lifetime; a second key is a second entry |
restart; material per request |
…:<n>:PublicKey |
secret block | — | One named secret with its own Lifetime, resolving to one client's PEM public key. Startup refuses material that is not one, an RSA key below 2048 bits, and — explicitly — material carrying a private key |
restart; material per request |
…:<n>:OAuth:Resource |
string | — | Required; the canonical https URL clients reach this endpoint at — behind a proxy, the proxy's public URL. Every OAuth entry names the same one, because the endpoint publishes one metadata document at an address derived from it |
restart |
…:<n>:OAuth:RequiredScopes |
string list | empty | Scopes a token from this entry's servers must carry; empty accepts any token they issued for this resource | restart |
…:<n>:OAuth:AuthorizationServers:<m>:Name |
string | — | Required; the identity diagnostics use, and unique across every entry because it composes the scheme its validator registers under | restart |
…:<n>:OAuth:AuthorizationServers:<m>:Issuer |
string | — | Required; a well-formed https issuer, compared against iss exactly, and unique across every entry |
restart |
…:<n>:OAuth:AuthorizationServers:<m>:MetadataAddress |
string | unset | An absolute https URL on the issuer's own host; overrides issuer-derived discovery |
restart |
…:<n>:OAuth:AuthorizationServers:<m>:AuthorizedSubjects |
string list | — | At least one; a token whose sub is not listed is refused, so every user the server can sign in does not automatically read this mailbox |
restart |
MailFathom is a protected resource only; an external authorization server signs users in.
OAuth records what a token must prove,
API keys what a key is compared against, and
Key pairs what a client signs and what the deployment verifies — including the audience,
expiry, and replay identifier an assertion carries, none of which is a setting.
Browser origins — McpEndpoint:Cors
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
…:AllowedOrigins |
string list | absent = every origin | * for every origin, a list for exactly those, an empty list for none |
restart |
The default is deliberately the permissive one — an Origin header only exists in browsers, and a native client is
unaffected — but a deployment reachable from a browser should narrow it.
CORS and the Origin header explains what the check does and does not
protect.
TLS termination — McpEndpoint:Https:Endpoints:<n>
Read under the two Transport modes that terminate TLS and refused under the one that does not. Configuring any
profile takes over the host's listeners: only the profiles' sockets are opened.
HTTPS and your own domain is the page.
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
…:Name |
string | — | Required; unique | restart |
…:Domain |
string | — | Required; the DNS name the certificate is proven to cover | restart |
…:BindAddress |
string | 0.0.0.0 |
An IP address | restart |
…:Port |
int | 8443 |
1 – 65535 | restart |
…:MinimumTlsVersion |
enum | Tls12 |
Tls12, Tls13 |
restart |
…:HttpProtocols |
enum list | Http1, Http2 |
Http1, Http2, Http3; selecting Http3 where the platform provides no QUIC fails startup rather than falling back |
restart |
…:ServerCertificate |
certificate block | — | Required; see below | restart; renewal behind unchanged references — see secret rotation |
A certificate block names either Bundle (one PKCS#12 secret block, optionally with a nested Password) or the pair
CertificateChain and PrivateKey (PEM, as two secret blocks). Startup proves the material loads, covers the stated
domain, and is not expired — before any listener opens.
Clear-text redirect — McpEndpoint:Https:Redirect
What the surface's clear-text socket does while the profiles above are served. On, it answers every request with a 308
to the address those profiles are at, so enabling TLS does not read as an outage to a client nobody repointed yet; it
then maps no route and runs no credential check. Off, the same socket serves the routes in clear text.
The socket is McpEndpoint:BindAddress and McpEndpoint:Port — there is no address here to state again. The section is
meaningful under Transport: HttpAndHttps alone, which is the one mode with both a clear-text socket and somewhere to
send what arrives on it; writing it under either other mode fails startup.
Redirecting a client still pointed at http:// records what
a redirect does and does not protect.
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
…:Enabled |
bool | true |
Refused unless Transport is HttpAndHttps |
restart |
Client certificates — McpEndpoint:ClientCertificateProfiles:<n>
Mutual TLS, judged per configured client application. A certificate exists only on a TLS connection this process
terminates — over the HTTPS profiles above, or over a listener the deployment configured with TLS otherwise — so a
plain-HTTP deployment presents none, which a Required profile refuses.
Client certificates records how a presented certificate is judged.
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
…:Name |
string | — | Required; unique | restart |
…:Requirement |
enum | — | Optional, Required; required to be stated |
restart |
…:TrustAnchors |
list of secret blocks | — | At least one; the authorities the client's chain must anchor in | restart; material per handshake |
…:SubjectAlternativeNames |
string list | — | At least one; a DNS name the certificate must carry | restart |
Rate limiting — McpEndpoint:RateLimiting and AdminEndpoint:RateLimiting
The one endpoint subsection where every value has a product default, so an enabled endpoint is bounded whether or not anyone wrote a number. Both endpoints carry it, with the same keys, defaults, and validation, and configure it independently: neither one's traffic reaches the other's limits. Rate limiting records whose capacity a request spends, and administering a deployment records the one behavioural difference on the administrative endpoint — its burst is the endpoint's rather than one caller's, because that surface judges a credential behind the limiter.
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
…:Enabled |
bool | true |
Turning it off costs a startup warning | restart |
…:MaxConcurrentRequests |
int | 20 |
1 – 1000; process-wide, per endpoint | restart |
…:ConcurrencyQueueLimit |
int | 0 |
0 – 1000; 0 refuses instead of queueing |
restart |
…:TokenCapacity |
int | 60 |
1 – 1000000; the largest burst one caller may spend | restart |
…:TokensPerReplenishmentPeriod |
int | 60 |
1 – 1000000, and not above TokenCapacity |
restart |
…:ReplenishmentPeriod |
TimeSpan | 00:01:00 |
1 s – 1 h | restart |
…:RequestQueueLimit |
int | 0 |
0 – 1000, and below MaxConcurrentRequests |
restart |
AdminEndpoint
Whether the administrative surface the mfctl command reaches is served, and what a client must present. Its own
listener, its own credentials, and its own authorization servers: a key configured under McpEndpoint authenticates
nothing here, and the reverse holds. The whole section is restart, while key and certificate material is read per
request or per handshake. Administering a deployment is the page.
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
AdminEndpoint:Enabled |
bool | false |
— | restart |
AdminEndpoint:BindAddress |
string | 0.0.0.0 |
An IP address; binds the clear-text socket, which HttpsOnly does not open |
restart |
AdminEndpoint:Port |
int | 8080 |
1–65535. The MCP endpoint's default as well, so enabling both without stating a port publishes one shared socket — see sharing a socket | restart |
AdminEndpoint:Transport |
enum | Http |
Http, HttpAndHttps, HttpsOnly — the same setting the MCP endpoint carries, read the same way |
restart |
AdminEndpoint:Authentication |
list of credentials | empty | Same shape and rules as McpEndpoint:Authentication:<n>, with two additions: every OAuth block's Resource must end in /api/admin, because that is where these routes answer and what mfctl appends to find the metadata document; and a client assertion presented here names the audience urn:mailfathom:admin rather than urn:mailfathom:mcp |
restart; material per request |
AdminEndpoint:Https:Endpoints:<n> |
list of profiles | empty | Same shape and rules as McpEndpoint:Https:Endpoints:<n>, read under the two Transport modes that terminate TLS |
restart; material per handshake |
AdminEndpoint:Https:Redirect |
block | on | Same shape and rules as McpEndpoint:Https:Redirect; its socket is this surface's own BindAddress and Port, so terminating TLS on both surfaces opens two clear-text ports that do not collide |
restart |
AdminEndpoint:RateLimiting |
block | bounded | Same shape, defaults, and rules as McpEndpoint:RateLimiting above; applied whether or not it is written |
restart |
The routes are served beneath /api/admin, which is a constant rather than a setting: a client is configured with a
host and a port and appends the rest.
HealthEndpoints
The startup, readiness, and liveness probes and the dedicated listener they answer on. Health endpoints records why the surface carries no credential and how each transport behaves.
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
HealthEndpoints:Enabled |
bool | true |
Off maps no probe route and opens no listener | restart |
HealthEndpoints:BindAddress |
string | 0.0.0.0 |
An IP address; 127.0.0.1 restricts to the machine |
restart |
HealthEndpoints:Port |
int | 8081 |
1 – 65535. A port another surface binds is permitted and shares that socket — see sharing a socket | restart |
HealthEndpoints:HttpsPort |
int | unset | Required by, and only valid with, HttpAndHttps |
restart |
HealthEndpoints:Transport |
enum | Http |
Http, HttpAndHttps, HttpsOnly |
restart |
HealthEndpoints:Domain |
string | — | Required by the TLS transports; the name the certificate is proven against | restart |
HealthEndpoints:ServerCertificate |
certificate block | unset | Required by the TLS transports; refused otherwise | restart |
Resilience
Retry, timeout, circuit-breaker, and concurrency budgets for the non-HTTP outbound dependencies, one subsection per
dependency class: MailboxSessionEstablishment, MailboxDataRetrieval, MailAuthorizationServerInvocation,
EmailDelivery, DatabaseCommandExecution, AiProviderInvocation. A subsection naming no class fails startup. Every setting is restart by construction, and
outbound resilience explains each strategy and the
per-class reasoning.
Settings, per class:
| Key | Type | Constraint |
|---|---|---|
Resilience:<Class>:MaxAttempts |
int | 1 – 10; counts the first call, so 1 disables retry |
Resilience:<Class>:BaseDelay / MaxDelay |
TimeSpan | Jittered exponential backoff between attempts |
Resilience:<Class>:AttemptTimeout / TotalTimeout |
TimeSpan | One attempt / the whole operation |
Resilience:<Class>:CircuitBreakerFailureRatio |
double | 0.01 – 1.0 |
Resilience:<Class>:CircuitBreakerMinimumThroughput |
int | 2 – 1000 |
Resilience:<Class>:CircuitBreakerSamplingDuration / CircuitBreakerBreakDuration |
TimeSpan | — |
Resilience:<Class>:ConcurrencyLimit |
int | 1 – 1000 |
Defaults, per class:
| Class | Attempts | Base/max delay | Attempt/total timeout | Breaker ratio · min · sampling · break | Concurrency |
|---|---|---|---|---|---|
MailboxSessionEstablishment |
3 | 2 s / 30 s | 30 s / 2 min | 0.5 · 5 · 60 s · 30 s | 4 |
MailboxDataRetrieval |
3 | 1 s / 15 s | 60 s / 3 min | 0.5 · 10 · 30 s · 15 s | 8 |
MailAuthorizationServerInvocation |
3 | 500 ms / 5 s | 10 s / 30 s | 0.5 · 10 · 60 s · 30 s | 8 |
EmailDelivery |
2 | 5 s / 60 s | 60 s / 3 min | 0.5 · 5 · 60 s · 60 s | 4 |
DatabaseCommandExecution |
3 | 200 ms / 2 s | 15 s / 30 s | 0.5 · 20 · 30 s · 5 s | 32 |
AiProviderInvocation |
3 | 2 s / 30 s | 120 s / 5 min | 0.5 · 5 · 60 s · 30 s | 4 |
Logging
The standard .NET Logging section applies unchanged, and the host clears no provider: Console, Debug, and
EventSource stay attached beside the OpenTelemetry provider that the service defaults add. Debug writes only under
an attached debugger, and EventSource writes to the Microsoft-Extensions-Logging event source, which produces
nothing until something collects it — a dotnet-trace session, typically. So on a deployment the console is the
provider that produces output, and until OTEL_EXPORTER_OTLP_ENDPOINT names a collector it is where logs go at all,
which telemetry records is the shipped default for both the Compose deployment and the chart. Log
lines are structured and never carry credentials, message content, or raw MIME, whatever the level or the format.
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
Logging:LogLevel:<category> |
enum | Information, and Warning for Microsoft.AspNetCore |
A LogLevel name. Default is the catch-all; any other segment is a log-category prefix |
reload |
Logging:Console:LogLevel:<category> |
enum | the Logging:LogLevel value |
Filters the console alone, leaving what the OTLP exporter sends untouched | reload |
Logging:Console:FormatterName |
string | simple |
simple, systemd, or json |
reload |
Logging:Console:FormatterOptions:<name> |
mixed | — | IncludeScopes, TimestampFormat, and UseUtcTimestamp under any formatter; SingleLine and ColorBehavior under simple alone; JsonWriterOptions under json alone |
reload |
An option the selected formatter does not define is accepted and does nothing — SingleLine under json is the one
worth naming, because it reads like it would fold a record onto one line and the JSON formatter already writes one
line per record without it.
reload here is the logging framework's own rather than a classification ADR 0002 made: a changed value is observed
by the next record written, without a restart and without reloading anything else. It is also why this section is
among the framework-shaped entries exempt from the strict binding above — a key this table does not name is the
framework's to accept or to ignore, so a misspelling here leaves a default in force instead of failing startup with
the path.
Select json where something parses the stream rather than reads it, and systemd where journalctl should read
the level rather than print it as text. Both are worth setting deliberately: simple is the default because it is
what a person reading docker compose logs wants, and it is the wrong shape for everything downstream of that.
The startup records ignore every key above. The host writes those four through a pipeline composed before
configuration exists, which attaches a console of its own at a fixed Information level, so a deployment that selects
json gets a stream whose MailFathom.Host.Startup records are still simple text — the Critical one explaining a
failed start included. Give a log shipper a path for those lines rather than assuming the stream is uniform;
host startup telemetry records why that pipeline cannot read this section.
Environment-only settings
A few settings are read from the environment alone, because they configure the process before configuration exists or belong to the platform rather than to MailFathom:
| Variable | What it does |
|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT |
Attaches the OTLP exporter for logs, metrics, and traces — startup records included. Unset exports nothing. Telemetry is the page, including the sibling OTEL_* variables the exporter reads itself |
OTEL_SERVICE_NAME |
The service identity the startup records and every exported record carry. Unset reports the host assembly's own name |
ASPNETCORE_URLS / ASPNETCORE_HTTP_PORTS / ASPNETCORE_HTTPS_PORTS |
Nothing — each surface states where it is served, and setting one of these fails startup with a message naming the key that replaces it |
DOTNET_ENVIRONMENT / ASPNETCORE_ENVIRONMENT |
The environment name; Development is what admits user secrets and appsettings.Development.json |
DOTNET_USE_POLLING_FILE_WATCHER |
Set to 1 where reload must observe a mounted volume's atomic update — Kubernetes ConfigMaps in particular |
OPENSSL_CONF |
The OpenSSL configuration file every TLS connection in the process is handshaked under. Unset is the platform's own policy; setting it is how a mail server the platform refuses is reached at all, and the host warns at startup that it is in force. The platform TLS policy is the page |
Each of these has a reader that runs before MailFathom's configuration exists, or that never consults it: the bootstrap
logging pipeline is composed before the configuration providers are, because a malformed appsettings.json is one of
the failures it exists to report; the OpenTelemetry exporter reads its own OTEL_* variables directly; the .NET host
settles the environment name before the application's configuration is composed; and OpenSSL reads OPENSSL_CONF while
it initializes, which is the one entry here that could not be a MailFathom setting even in principle.
Writing one anywhere else fails startup
A value for any of them that did not come from the process environment is refused, naming every such variable at once:
Settings only the process environment can deliver carry a value that did not come from it: OPENSSL_CONF,
OTEL_SERVICE_NAME. Each is read before MailFathom's configuration exists, or by a library that never consults it, so a
value written into an appsettings file, a provisioned configuration file, or a command-line argument reaches nobody. Set
each as an environment variable on the host process, or remove it.
That failure carries error code 12002 and ends the process through the same bootstrap pipeline every other startup
failure does. It exists because the mistake is otherwise invisible: the configuration pipeline accepts
"OTEL_SERVICE_NAME" in a mounted ConfigMap and reads it back happily, while the exporter — which took its value from
the environment long before that file was layered in — keeps reporting under the assembly name. Nothing in the file, in
the logs, or in the process would say which of the two an operator was looking at.
The check compares against the environment rather than merely looking for the name, because the environment provider
puts these names into configuration too, and a value that arrived that way is exactly what a correct deployment looks
like. What it catches beyond an absent variable is an override: a command-line argument outranks the environment
provider, so --OTEL_SERVICE_NAME=… leaves configuration reporting one identity while the exporter keeps using another.
Whole families are covered rather than the names in the table alone. Every OTEL_*, ASPNETCORE_*, and DOTNET_*
variable belongs to a reader that takes it from the environment, so naming only the handful MailFathom itself reads
would leave the rest — OTEL_EXPORTER_OTLP_HEADERS above all, which carries a collector's credential — silently
ignorable. A blank value counts as unset on both sides, because templating a manifest routinely emits an empty string
for a setting nobody chose.
The three URL-shaped listener addresses are the exception, and they are stricter rather than looser: they are refused from every source, the environment included, because no MailFathom surface is served from one at all.