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:MaxContentBytesPerRun |
long | 1073741824 (1 GiB) |
1024 – 1099511627776; how much raw MIME one folder run may fetch before it ends at its checkpoint. Must be at least MaxRawMimeBytes |
reload |
MailSynchronization:MaxStoredContentBytes |
long | (none) | 1024 – 9223372036854775807; how much storage stored content may occupy before ingestion degrades to metadata only. Unset means no ceiling. Must be at least MaxRawMimeBytes |
restart |
MailSynchronization:MaxInFlightRawMimeBytes |
long | 134217728 (128 MiB) |
1024 – 4294967296; how much raw MIME every folder work unit together may hold in memory. Must be at least MaxRawMimeBytes |
restart |
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 |
…:DisplayName |
string | — | Required, with no default; at most 128 characters, no control characters, and it may not be another account's identifier or display name compared without regard to case | 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 |
…:AuthoredDeleteEmailDisposition |
enum | RetainLocalCopy |
RetainLocalCopy, RetainTombstone, EraseLocalCopy; what becomes of the local copy of mail MailFathom itself deleted, and it takes precedence over the key above for those |
reload; governs deletes authored from then on |
…:RuleActions:Move |
bool | true |
Whether a rule may file this account's mail into another of its folders | reload; the next rule pass writes down no change this refuses |
…:RuleActions:Copy |
bool | true |
Whether a rule may place a copy of this account's mail in another of its folders | reload; the same |
…:RuleActions:Delete |
bool | false |
Whether a rule may remove this account's mail; the one action that is opt-in | reload; the same |
…:RuleActions:MarkAsRead |
bool | true |
Whether a rule may set or clear this account's remote \Seen flag |
reload; the same |
…:AuditTrail:Enabled |
bool | false |
Whether a finished change to this account's mailbox leaves a durable audit entry | reload; governs changes authored from then on |
…:AuditTrail:Retention |
TimeSpan | 90.00:00:00 |
1 day – 3650 days; how long this account's audit entries are kept | reload; the next account run erases against the new window |
…:AnsweringAuditTrail:Enabled |
bool | false |
Whether a finished ask_mail run leaves a durable entry naming the mail it read from this account |
reload; governs runs from then on |
…:AnsweringAuditTrail:Retention |
TimeSpan | 30.00:00:00 |
1 day – 3650 days; how long this account's answering entries are kept | reload; the next account run erases against the new window |
…:Folders |
list | inbox by role | Aliases unique; each entry below | reload |
RuleActions is what a rule is judged against rather than what it is filtered by: a rule declaring an action this
account does not permit fails startup naming the rule, the action, and the account, rather than running with that
action quietly dropped. An unscoped rule reaches every declared account, so permitting an action on one account and not
on another means either permitting it on both or narrowing the rule's own Accounts filter. Narrowing this block while
a rule set that declares the action is in force is the one case the two do not resolve together, since the sections
reload apart: the permission is read again as each change is written down, so the withdrawal takes effect at the next
pass and the rule is refused the next time the rule section is read.
What an account permits a rule to do states why
deletion is the one opt-in of the four.
AuditTrail is off by default because the record it keeps is derived personal data: it says where a person's mail has
been, when, and at whose instruction. Turning it on commits the deployment to holding that history, describing it, and
erasing it — which is why the retention is configured beside the switch rather than left unbounded, and why turning the
switch back off stops new entries while leaving the existing ones to age out under the window they were written under.
An account can keep a record of what was done to it
states what an entry holds and what it deliberately does not.
AnsweringAuditTrail is the same shape for the other record and the same default, and it is a separate decision
rather than the same switch: one says where a person's mail has been, the other says what it was read for, and an
operator may want either without the other. Its default window is shorter because an entry names every message one
question reached, so the record grows with how much an instance is asked rather than with how much it is told to change.
An account can keep a record of what a question read
states what an entry holds, what it deliberately does not, and the one way it differs from the trail above — an erased
message is erased from the runs that read it.
AccountId and DisplayName are both names for the account and they answer different questions. The identifier is the
stable key everything else is expressed in — every stored row, every continuation cursor, every log line — and it is
what you keep unchanged. The display name is what a caller reads: it appears beside the identifier in every MCP result
that names an account, and either spelling may be used to narrow a listing, a search, or a question to that mailbox.
There is deliberately no default, because a name MailFathom invented would be published to callers as though you had
chosen it. The two share one naming space so that a name can never select two mailboxes, which is why startup refuses a
display name that another account's identifier or display name already carries; a display name equal to the account's
own identifier is fine, since both spellings then reach the same mailbox.
A folder entry names Alias (required — your stable name for the folder) and at least one of RemotePath (the
server's own path) or SpecialUse (Inbox, Archive, Drafts, Sent, Junk, Trash, All, Flagged,
Important). Configuring no folder synchronizes the inbox by role.
This list is what the deployment has. A folder no entry names does not exist for any reader: nothing lists, searches,
reads, or answers from it, no rule is evaluated against its mail, nothing cuts it into passages or embeds it, and no
alias of it resolves as a rule's destination. That holds for mail an earlier configuration had already stored — removing
an entry makes its rows unreachable and leaves them in the database, since removing a mapping is an edit rather than an
act against stored mail, exactly as Synchronize: false is. Naming the folder again is what makes its mail readable
again, and the folder resumes from the checkpoint it kept rather than mirroring afresh.
What a mapping decides beyond where the folder is
states it beside the three switches.
SpecialUse says what the folder is for, and where the folder is found is a separate question. Named alone it
answers both: discovery resolves the folder the server advertises with that role. Named beside a RemotePath, the path
is what finds the folder and the role is what the folder plays, which is how a server that advertises nothing still
gets a junk folder anything can name by its role. A role belongs to at most one folder per account: startup refuses
a configuration giving one role to two folders of an account, naming both aliases and the role. Roles are optional and
most folders carry none.
Wherever a folder is named — a rule's destination, an MCP tool's folders argument — the role is written as
role:<role>, for example role:Junk, and anything without that prefix is an alias. A role no folder of the account
carries is refused, naming the role, rather than answered with an empty result.
The same entry decides what MailFathom does with the folder, through three switches that each default to true, and
whether the folder may be created at all, through a fourth that defaults to false:
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
…:Folders:<n>:Synchronize |
bool | true |
With false, no run schedules the folder: no connection is opened for it and nothing further of it is stored |
reload; the next run stops scheduling it and keeps everything already stored for it |
…:Folders:<n>:GenerateEmbeddings |
bool | true |
With false, stored mail of the folder is never cut into passages and never reaches an embedding provider; refused alongside Synchronize: false |
reload; governs what is stored from then on, and passages already produced stay |
…:Folders:<n>:VisibleToTools |
bool | true |
With false, no MCP tool lists, searches, reads, or answers from the folder; refused alongside Synchronize: false |
reload; the next request reads the new value |
…:Folders:<n>:CreateIfMissing |
bool | false |
With true, the folder is created on the mail server when the server advertises none at RemotePath; refused on a mapping that names no RemotePath |
reload; the next resolution of the alias creates it |
Startup refuses a folder that asks for embedding or tool visibility while Synchronize is false, naming the alias,
because a folder that stores nothing has nothing to embed and nothing a tool could read. Leaving a switch out is not
asking for it, so Synchronize: false on its own binds. Mirrored, embedded, and withheld from tools binds as well and
costs what it says: the vectors are produced and paid for while no reader reaches them, since the tools are the only
readers there are.
CreateIfMissing is the one switch here that authorizes an act against your mail server rather than withdrawing an
existing folder from something MailFathom does locally, which is why it defaults to false while the other three
default to true: a mapping that says nothing keeps a mistyped RemotePath reporting itself as an alias that resolves
to nothing, instead of turning the mistake into a folder named after it. Startup refuses it on a mapping that names no
RemotePath, naming the alias, because a folder that does not exist advertises no role and only an explicit path says
what to create. A mapping naming both a path and a role may ask for the creation: the path is what is created, and the
role is what the created folder plays. It is issued where the alias is
resolved — before the run of a folder the account mirrors, and at the moment a change first files into one it does not,
so the switch reaches a Synchronize: false mapping the first time something files mail into it. Renaming, deleting, and
unsubscribing from a folder stay refused outright, and no folder MailFathom did not create is ever subscribed to.
A folder the mapping asked for is created
states when the creation happens, what it does with a folder that already exists and with a hierarchical path, and what
a server's refusal reports.
Switching Synchronize off for a folder that was mirrored keeps what is stored for it. Nothing is removed, and the
folder's checkpoint stays where the last run left it, so switching the folder back on resumes: the next run fetches
what arrived while it was off and reconciles the retained mail rather than mirroring the folder again. What is kept is
inert — no tool lists, searches, reads, or answers from it, nothing of it is embedded, and no rule evaluates it — so
the only thing an operator gives up by leaving the switch off is the storage it occupies. No configuration value erases
stored mail: taking a folder's local copy away is an act somebody performs, never something a switch performs on their
behalf. The mapping stays too, so the alias goes on resolving, any role the mapping names goes on being answered, and
the folder stays a destination a rule may file mail into — resolved the first time a change names it, since no run
schedules it.
What a mapping decides beyond where the folder is
states all three switches together, what an unmapped folder is instead, and what becomes of the local copy of a message
relocated into a folder nothing mirrors.
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.
Deployment
What this installation is, rather than what any one surface it serves does. One key today, and a root of its own for that reason: the address clients reach this deployment at is not a property of the feature that first needed it, so an operator answers it once and whatever else has to hand back an absolute address later reads the same key.
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
Deployment:PublicBaseAddress |
url | — | Absolute, https unless the host is loopback, no path, no query, no fragment |
restart |
It has no default on purpose. Only an operator knows which name a client reaches this process by, and a guess would
produce addresses that resolve to nothing or, worse, to somebody else. Nothing derives it from a request either: an
address composed from a Host header would let whoever called a tool decide where the URL it receives points.
It carries no path because this process serves its routes at its root, and clear text is refused off this machine because what is composed beneath it may be a capability — a secret in transit. Today the one consumer is the attachment download link; a deployment that declares no address issues none, which is a supported posture rather than a misconfiguration.
SensitiveContent
What this deployment scans mail for before that mail is copied into a derived store or handed out. A configuration root of its own, because it is a property of the deployment rather than of its database, its accounts, or its providers, and because the switches it holds reach several of those at once. Sensitive-content scanning records what a finding is, what replaces it, and why a scanner that cannot answer refuses the operation it guards.
Both scanners are off by default, and an absent section is that default rather than a startup failure. Secrets runs in
this process. Pii reaches an analyzer deployed beside it, configured in the block below, and switching it on with
nowhere to ask fails startup rather than running unprotected.
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
SensitiveContent:Secrets:Enabled |
bool | false |
A scanner switched on with no detector registered fails startup | restart |
SensitiveContent:Secrets:Categories:<n> |
string | unset | Must name a category the scanner detects; the list replaces the scanner's defaults, and an absent list yields them | restart |
SensitiveContent:Secrets:Suppressions:<n>:Category |
string | — | Must name a category the scanner detects; naming one never switches it on | restart |
SensitiveContent:Secrets:Suppressions:<n>:Rule |
string | — | Must name a rule that category holds | restart |
SensitiveContent:Pii:Enabled |
bool | false |
As above, for the personal-data scanner | restart |
SensitiveContent:Pii:Categories:<n> |
string | unset | As above | restart |
SensitiveContent:Pii:Suppressions:<n>:Category |
string | — | As above | restart |
SensitiveContent:Pii:Suppressions:<n>:Rule |
string | — | As above | restart |
SensitiveContent:PersonalDataAnalyzer:Endpoint |
string | unset | Required once Pii is on, and an absolute http or https address; read by nothing while that switch is off |
restart |
SensitiveContent:PersonalDataAnalyzer:Language |
string | en |
Two lowercase letters, naming a language the analyzer loads a model for | restart |
SensitiveContent:PersonalDataAnalyzer:MinimumConfidence |
double | 0.4 |
0 – 1 inclusive, compared inclusively by the analyzer. It decides which regions are replaced, so it is part of the derivation stamp and changing it marks earlier-derived rows stale | restart |
SensitiveContent:MaximumAnalyzedCharacters |
int | 200000 |
1 – 10000000; text beyond it is dropped from the result rather than handed on unscanned. On the derived path that is what is stored, so lowering it truncates every message indexed afterwards and the value is part of the derivation stamp | restart |
SensitiveContent:ScanTimeout |
TimeSpan | 00:00:05 |
One second to two minutes, per call to one scanner | restart |
SensitiveContent:MaximumConcurrentScans |
int | 4 |
1 – 256, across the process | restart |
SensitiveContent:RebuildStaleDerivedData |
bool | false |
Read only while a scanner is on; re-derives every message whose derived text predates the current configuration | restart |
The rebuild switch spends a whole mailbox. Switching a scanner on, or widening what it looks for, protects what is
derived from that moment onward and reaches nothing already extracted, chunked, or embedded — the host reports how many
messages that leaves behind every time it starts. This key is what re-derives them, and it costs one full re-indexing of
the affected messages: each is read, extracted, scanned, re-chunked, and re-embedded, so a deployment with a hosted
embedding endpoint pays that provider again for every one. It rides the extraction backfill rather than a worker of its
own, so MailExtractionBackfill:Enabled has to be on for it to perform anything, and
MailExtractionBackfill's interval and batch size are what pace the spend. Switch it back off
once the count reaches zero. Derived data
records what is stamped, what makes a row stale, and why nothing rewrites stored text in place.
A category name is matched against what the scanner declares, ignoring capitalization, and the declared spelling is what survives the match — so a placeholder in redacted text does not depend on how the name was written here. A name that matches nothing fails startup and quotes both the value and the categories the scanner does detect, rather than being dropped by the binder and leaving the section reading as protection that is on. So does a suppression naming a rule that does not exist. A suppression inside a category this deployment does not look for is accepted and inert.
The Secrets scanner declares these seven categories. Six are on when Categories names none; the seventh is not, and
listing categories replaces the default set, so switching the entropy heuristic on means naming every category
wanted alongside it.
| Category | What it finds | On by default |
|---|---|---|
ProviderToken |
An API token, key, or session credential a named service issued and prefixes as its own | yes |
CloudAccessKey |
An access key or client secret for a cloud platform's own control plane | yes |
PrivateKey |
A private key or certificate bundle, armoured as PEM or encoded whole | yes |
JsonWebToken |
A JSON Web Token, in its ordinary form or encoded a second time | yes |
ConnectionString |
A connection string carrying the credential it connects with | yes |
CredentialUrl |
A URL carrying a credential in its user information, its path, or its query | yes |
HighEntropyString |
A string dense enough to be a credential, recognised by its randomness rather than its shape | no |
A rule name inside them is the corpus entry's own name — github-pat and aws-access-token from the gitleaks rule
data, AzureCosmosDBIdentifiableKey and UrlCredentials from the detection engine's own corpus, and
database-connection-uri-credential from MailFathom's. Sensitive-content
scanning records where each corpus comes from and what
the entropy heuristic costs.
The Pii scanner declares these eleven categories. The first five are on when Categories names none, and listing
categories replaces that set, so adding a personal name means naming the five wanted alongside it.
| Category | What it finds | On by default |
|---|---|---|
PaymentCard |
A payment card number | yes |
BankAccount |
An IBAN or another bank account number | yes |
NationalIdentifier |
A national identification, social-security, or tax number | yes |
IdentityDocument |
A passport, identity-card, or driving-licence number | yes |
HealthIdentifier |
A number that names a person inside a health system | yes |
PersonName |
A personal name | no |
EmailAddress |
An email address | no |
PostalAddress |
A postal address, or a place named precisely enough to be one | no |
PhoneNumber |
A telephone number | no |
Date |
A date or a time, absolute or relative | no |
NetworkAddress |
An address that identifies a machine, whether the network assigned it or the hardware carries it | no |
A rule name inside them is the analyzer's own entity name, spelled as the analyzer spells it — CREDIT_CARD,
IBAN_CODE, US_SSN, UK_NHS — which is what lets a suppression silence one recognizer inside a category that stays
on. An operator never names one in Categories: those are the units this product publishes, and the mapping between the
two is MailFathom's. The personal-data
scanner records what each of the six optional
categories costs retrieval, why the endpoint belongs inside the deployment, and what the confidence floor is protecting
against.
The analyzer block is read only while Pii is on. An address left behind under a scanner nobody runs is accepted and
inert, for the reason a category list under one is: it describes no protection, so refusing to start over it would be
refusing over a comment. The reverse — the scanner on with no address, a relative or non-HTTP address, a language that is
not two lowercase letters, or a floor outside 0 to 1 — fails startup naming the key.
The analyzed ceiling defaults to the same number as EmailContent:MaxCharactersPerRead, so an ordinary content read is
analyzed whole and only something pathological reaches it.
SpamClassification
Whether mail is classified as spam, and where. A root of its own for the same reason SensitiveContent is one: it is a
property of the deployment rather than of one account, and what it switches on reaches the mailbox reads as well as the
classification. Spam classification records what a classification holds, which
facts the deterministic stage reads, and why a scanner never overturns a provider's own verdict.
Every switch is off by default, and an absent section is that default rather than a startup failure. The deterministic
stage works alone and is the whole of the feature without a sidecar; UseScanner adds the Apache SpamAssassin daemon
described below.
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
SpamClassification:Enabled |
bool | false |
reload | |
SpamClassification:UseScanner |
bool | false |
Asking for a scanner while Enabled is false fails startup, because a scanner is only consulted where classification runs |
restart |
SpamClassification:ScannedFolders:<n> |
string | unset | A usable folder alias; an absent list is every account's inbox mapping, and an explicitly empty list is no folder at all | reload |
SpamClassification:ScannerThreshold |
double | unset | 0.1 – 1000; unset keeps the threshold the scanner itself answered with | reload |
SpamClassification:ClassificationWait |
TimeSpan | 00:15:00 |
1 s – 7 days; how long a stored message may wait for a verdict before it is derived from anyway | reload |
SpamClassification:RunBatchSize |
int | 50 |
1 – 10 000 | reload |
SpamClassification:MaxRunBatchesPerPass |
int | 4 |
1 – 1 000 | reload |
SpamClassification:Scanner:Host |
string | unset | Required once UseScanner is on, and a host name or IP address rather than a URL or an address with the port on it |
restart |
SpamClassification:Scanner:Port |
int | 783 |
1 – 65535 | restart |
SpamClassification:Scanner:ScanTimeoutSeconds |
int | 30 |
1 – 120 | restart |
SpamClassification:Scanner:MaximumMessageBytes |
int | 512000 |
32 000 – 33 554 432 | restart |
SpamClassification:Scanner:MaximumConcurrentScans |
int | 5 |
1 – 64 | restart |
SpamClassification:Actions:FileInJunkFolder |
bool | false |
Asking for it while Enabled is false fails startup, and so does an account that maps no destination to file into |
reload |
SpamClassification:Actions:MarkAsRead |
bool | false |
Asking for it while Enabled is false fails startup |
reload |
SpamClassification:Actions:JunkFolder |
string | role:Junk |
A folder alias, or a role written as role:<name>; every configured account has to map it once filing is on |
reload |
SpamClassification:Actions:Threshold |
double | unset | 0.1 – 1000; unset acts on every spam verdict, and a value judges what a scanner scored | reload |
UseScanner and the Scanner block are read once, at startup: whether a scanner exists at all decides what is
constructed and whether the host refuses to start without a daemon, which a reload cannot revisit. Everything else in
this section is read per classification.
ClassificationWait bounds the ordering rather than a scan. Wherever classification is on, a message it covers is not
chunked, embedded, or offered to the rule set until a verdict exists — and this is how long that may hold before the
message is derived from regardless, which is what stops a classifier nobody noticed was wedged from silently stopping
the index. Lengthening it delays mail of a classified folder by that much longer in the worst case; shortening it
narrows the window in which a verdict can arrive first. Zero is refused, because a wait of none releases every message
before anything could have scored it.
Junk is kept out of what a deployment derives from mail
records what each answer means and what is counted.
A scanner switched on with no daemon answering fails startup, with error code 81003 naming the key to repair
rather than the address it tried. That is deliberate asymmetry with what one message gets, where a failed scan leaves
the deterministic verdict standing: an instance whose sidecar never came up would classify everything from headers alone
and look healthy doing it. The bounds are validated whether or not the scanner is switched on, so a value written wrong
is reported before the run that first switches scanning on rather than during it.
The daemon receives whole messages, so it belongs inside the deployment's own trust boundary; the feature page states what an address outside it gives up, and what the rule-update and DNS postures cost. The deployment assets carry the sidecar itself — Kubernetes, Compose, and Quadlet.
The default scope follows the folder role rather than the text INBOX: it is whichever alias each account maps to
Inbox in MailSynchronization, so a server presenting the inbox under
another name is classified without the scope being restated here. The two shapes of an unset list are deliberately
distinguishable — writing no key asks for that default, and writing an empty list asks for no folder, which switches the
work off without switching the section off.
A folder alias that this system could never have issued fails startup and names itself, rather than being dropped by the binder and leaving the section reading as a scope that is covered. So does a threshold outside the range above: one at or below zero files every message whatever a scanner answered, and one beyond the ceiling can never be reached, so both are a typed digit rather than an intent.
The section is read per classification rather than captured, so a reload takes effect on the next one. What a reload never does is revisit a message already classified: replacing a verdict is an explicit operation.
Which folder is left out of list_emails and search_emails is not configured here. It is the folder mapped to the
Junk special use in MailSynchronization, and it is withheld whether or
not this section switches anything on.
The Actions block is the only part of this section that writes to a mailbox, and both of its switches are off. Each
works alone: filing moves the message on the server, marking read sets its \Seen flag, and turning both on sets the
flag first, because a relocation can renumber the message. Nothing else is ever done — no delete, no other flag, no
folder created, nothing sent.
JunkFolder does not have to be a folder MailFathom mirrors, and for most deployments it should not be: mapping it
with synchronize: false files spam out of the instance entirely, under the account's own
AuthoredDeleteEmailDisposition. What it does have to be is a folder every configured account maps, because
classification asks for none to be created — an account that maps no destination fails startup naming that account,
rather than leaving its spam unfiled with nothing said about why. A folder the account only maps is resolved against the
server the first time a filing needs it, exactly as a rule's destination is.
RunBatchSize and MaxRunBatchesPerPass bound one pass of the classification
run an operator asks for, and neither is a
schedule: a pass is a step of the account's synchronization run, so how often one happens is that run's interval. What
these decide is how much of a mailbox one pass takes in hand — raising them walks a mailbox nobody has scored in fewer
account runs, and lowering them shortens the stretch an interrupted pass has to cover again and leaves more of each run
for the folders it exists to fetch. Both defaults are smaller than the rule pass's, because a classification reads the
stored message and, with a scanner configured, sends the whole of it across a socket and waits for a score.
Threshold judges what a scanner scored, in the scanner's own scale, so an operator can label at ScannerThreshold and
move mail only from a higher score. It reaches no other stage, exactly as ScannerThreshold does not: a verdict resting
on a provider's header or on where the receiving server filed the message carries no score in this scale, and is acted
on. Raising it is deliberately not the same edit as switching classification off — the verdicts go on being recorded.
MailboxSearch
The deployment-wide privacy bound on what a search result may quote, whether the result was ranked lexically or hybridly. 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
What one get_email_content call may hand back, and where the files it describes are fetched from. Only text is
bounded here: no response carries an attachment's bytes, so a file costs a response the length of a URL whatever it
weighs. Email content records how each bound is applied and reported.
| 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 |
EmailContent:AttachmentDownloads:LinkLifetime |
duration | 00:10:00 |
1 to 30 minutes; how long a minted link stays redeemable, refused outside that range rather than clamped | restart |
Two things decide whether any link is issued at all, and neither is here. Deployment has to declare the
address a link is composed from, and DataEncryption has to configure a ring, because the signing
key is derived from it rather than provisioned separately. A deployment missing either serves every other part of a
read and answers each attachment with downloadState: unavailable.
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.
Nothing here is a switch for semantic search, and none of these keys turns it on. What a search reports as its semantic capability follows from three facts this section does not hold: whether a profile has been activated, whether the declaration below still names that profile's identity, and whether the last call to the endpoint chain was answered. A search never fails because one of them is not true — it answers lexically and says which of the three states it is in. Email search states what each means for a caller and what an operator does about it. Editing a key in this section and restarting therefore changes what is embedded next, never what a search is currently able to do; only an activation does that.
| 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 |
What an instance is willing to spend
The four keys below bound cost rather than correctness, and they are validated whether or not a chain is declared: passages are cut for every synchronized message on an instance that has chosen no provider, so a ceiling left unvalidated would be one already applying. None of them is part of an embedding profile — they decide how many vectors exist and never what one means, so moving any of them leaves every stored vector as comparable as it was. Embedding generation records what each bounds and why.
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
Embeddings:MaxCharactersPerEmail |
int | 200000 |
1000 – 10000000; how much of one message's extracted text is cut into passages. A message beyond it is bounded rather than refused — its opening is embedded and the length its text had is recorded on the message | restart |
Embeddings:MaxRequestsPerMinute |
int | 0 |
0 – 100000; 0 paces nothing, which is the default. For a provider whose quota is stated per minute; a caller takes the next free slot and waits for it |
restart |
Embeddings:MaxInputCharactersPerPeriod |
long | 50000000 |
zero or positive; the characters one period may send a provider, counted as sent rather than as stored. 0 declares no ceiling at all, which is supported and means an enabled feature can produce a bill nobody agreed to |
restart |
Embeddings:SpendPeriod |
TimeSpan | 1.00:00:00 |
1 min – 31 days; the fixed window the ceiling is counted over, anchored at the Unix epoch so every restart places it identically | restart |
Reaching MaxInputCharactersPerPeriod pauses embedding until the period rolls over, and resumes without anybody
acting; nothing is dropped, because a passage with no vector is what the backfill selects on. The ceiling binds to
within one batch: a batch is admitted whenever anything at all is left and is then paid for whole, because weighing it
against what remains would stall a deployment whose ceiling is smaller than one batch for ever.
The default is chosen to bind. Fifty million characters a day is roughly twelve million tokens and embeds something like sixteen thousand ordinary messages, so an instance keeping up with arriving mail never meets it and one working through a decade of archive is paced rather than surprised — raise it deliberately for an initial backfill, having seen the number.
Concurrency is not here. How many provider calls may be in flight at once is
Resilience:AiProviderInvocation:ConcurrencyLimit, which is the one setting that owns that question; outbound
resilience holds it, and a second limiter beside it would make two keys answer
for one behaviour.
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.
An entry declares any service reachable over the OpenAI wire protocol, not one of a fixed set: Provider, Model, and
ModelVersion are what the profile records, while RoutedModelName — or Model where that is empty — is the string a
request is routed on. Two rules bind the address and the credential, and one implementation applies them to this section
and to Chat alike: an address is absolute HTTP or HTTPS, with a plain http one refused wherever the endpoint holds a
credential, because the request would publish it to everything on the path; and exactly one of ApiKey,
EntraCredential, and Unauthenticated is declared, because none of them is what a forgotten reference looks like and
two leaves unsaid which one a request presents. Embedding
generation § An endpoint is any
service that speaks the OpenAI wire
protocol holds
both rules with their reasons, what each setting decides, and a worked example of an endpoint that is neither OpenAI
nor Azure. Embedding generation § A model server you run
yourself covers the plain-address case, what it
gains, what it gives up, and the startup warning an instance holding such an endpoint writes. Provider
endpoints is the register of services somebody checked — what each one's Address and
credential are, whether it serves an embeddings route at all, and whether SupportsRequestedDimension may stay at its
default.
| 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 HTTP or HTTPS; a plain http one only for an endpoint declaring Unauthenticated. 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, EntraCredential, and Unauthenticated is declared |
restart, value read per request |
…:Unauthenticated |
bool | false |
that this endpoint asks for no credential, so a request presents none — the shape of a model server you run yourself. Written rather than inferred from the other two being absent, because that is what a forgotten key reference looks like | restart |
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 and Unauthenticated are refused here; a key is declared as one, and an endpoint needing no credential says so on the endpoint |
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 |
Chat
What this deployment generates text with. A root of its own beside Embeddings rather than a block inside it, because
the two are separate choices with separate consequences: without an embedding provider semantic search is off and
lexical search continues, while without a chat provider search is unaffected and only the answering capability stops
being offered. Writing nothing is a supported deployment, exactly as writing no Embeddings section is. Chat
generation records what a declaration means and what one call may spend.
One endpoint rather than an ordered chain. A fallback embedding endpoint is another route to one vector space and startup proves it; nothing proves that of two chat models, so falling through would answer a person in a different model's voice with nothing above able to tell. An operator who wants failover puts a gateway in front of the declared endpoint.
The endpoint is any service reachable over the OpenAI wire protocol, under the same two rules the embedding chain
follows and through the same implementation of them: an absolute HTTP or HTTPS address with a plain http one refused
wherever a credential is held, and exactly one of ApiKey, EntraCredential, and Unauthenticated. Chat generation §
An endpoint is any service that speaks the OpenAI wire
protocol carries a
worked example of one that is neither OpenAI nor Azure, and Chat:Api is the key most often decided by which of the
two paths such a service serves. Provider endpoints records which paths each checked service
was found to serve.
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
Chat:Alias |
string | (empty) | writing one is what configures a chat provider at all; unique across every AI endpoint the deployment declares, embedding endpoints included. A section carrying other settings without it is refused rather than ignored | reload to rename, restart to declare or remove |
Chat:Model |
string | — | required once an alias is written; what a request is routed to, which for a cloud deployment is the deployment's own name rather than the vendor's model identifier | reload |
Chat:Address |
string | (empty) | absolute HTTP or HTTPS; a plain http one only for an endpoint declaring Chat:Unauthenticated. Empty uses the provider library's default. A cloud resource's OpenAI-compatible address ends in /openai/v1/ |
reload |
Chat:Api |
enum | ChatCompletions |
ChatCompletions or Responses; which of the provider's two request APIs a call goes to under the declared address. Declared rather than derived, because the routed model name is the operator's own deployment name and nothing about it says which paths the server serves. State Responses for a reasoning model that refuses function tools beside a stated effort; a server that does not serve that path answers request refused |
reload |
Chat:MaxOutputTokens |
int | 1024 |
1 – 200000; what one answer may occupy. Reaching it is not a failure — the answer arrives marked as cut short | reload |
Chat:Temperature |
float | (unset) | 0 – 2; left unset sends nothing, which is required by the models that reject the parameter outright | reload |
Chat:TopP |
float | (unset) | 0 – 1; unset the same way, and for the same reason | reload |
Chat:ReasoningEffort |
string | (unset) | the level the model documents, written as the provider spells it — none, minimal, low, medium, high, xhigh, or whatever a later model adds. Unset sends no reasoning parameter at all, which a model that does not reason requires. none is not the same as unset — it states an effort of none and sends it, which is what a provider refusing tools beside an unstated effort asks for. Startup checks the shape and not the vocabulary, because which levels exist belongs to the model; a level this deployment's model does not accept refuses the request rather than falling back |
reload |
Chat:MaxMessagesPerRequest |
int | 64 |
1 – 512; the turns one request carries, refused rather than truncated | reload |
Chat:MaxRequestCharacters |
int | 120000 |
1 – 4000000; what those turns may add up to. Stated in characters rather than tokens because counting tokens would mean carrying the model's own tokenizer; set it below what the model's context window allows | reload |
Chat:RequestTimeout |
TimeSpan | 00:02:00 |
positive; one request. Longer than an embedding request's by default, because generating an answer takes as long as the answer is | reload |
Chat:ApiKey |
secret block | (absent) | the provider key. Exactly one of this, EntraCredential, and Unauthenticated is declared |
reload, value read per request |
Chat:Unauthenticated |
bool | false |
that this endpoint asks for no credential, so a request presents none — the shape of a model server you run yourself. Written rather than inferred from the other two being absent, because that is what a forgotten key reference looks like | reload |
What a reload changes here, and what it does not. Everything the declared endpoint says is read again per
question, so correcting a model the provider refused — the ordinary case, because a wrong model is only discovered from
a refusal — costs an edit rather than a restart of a process that is synchronizing mailboxes and holding an IMAP IDLE
connection. A run already in flight keeps the declaration it began with, so a reload landing mid-question changes the
next question and not that one. A candidate that breaks any rule in the table is refused whole, logged with the key to
fix, and leaves the previous declaration answering; the process stays up either way. What stays a restart is the pair
that decides which services this deployment registered at all: whether Chat:Alias names an endpoint, and whether
Chat:RelevanceFilter:Enabled turns the second pass on. Renaming a declared alias reloads, because the credential and
the resilience circuit are both looked up by whatever the declaration in force calls it; going from no chat section to
one, or the reverse, does not, and is refused with that message rather than silently ignored.
What the declared model has to be able to do. ask_mail answers by offering the model a retrieval tool and reading mail when the model calls it, so a model that cannot be given function tools cannot answer a question here whatever else is written above. That is what the two settings in the middle of the table exist for: a current reasoning model refuses function tools beside an unstated reasoning effort and names the responses API as the way to have both, so such a model needs Chat:Api set to Responses and Chat:ReasoningEffort written — including written as none, which states an effort rather than omitting the parameter. A model this deployment cannot use is not detected at startup, because nothing here can ask a provider what a routed name supports without paying for a call; it surfaces as request refused on the first question. Chat generation holds the whole reasoning, and Mail answering describes the run that imposes the requirement.
Microsoft Entra credential — Chat:EntraCredential
The same block, with the same keys, defaults, and rules as
Embeddings:Endpoints:<n>:EntraCredential above.
One credential source resolves both sections, which is why the alias uniqueness rule spans them. Its keys reload here
and take a restart there, for the reason the table above gives: this section is read again per question and the
embedding chain is read once while the host composes itself.
Relevance filter — Chat:RelevanceFilter
The optional second pass over a retrieval: each candidate the fused ranking produced is put to the declared chat
endpoint on its own, and the ones the model scores below the threshold are dropped before an answer is written. A block
inside Chat rather than a root of its own, because the pass judges with that endpoint and has nowhere to send a
question without one — removing the chat section removes this with it.
Off by default, and off is a supported deployment: retrieval then hands over the fused ranking exactly as hybrid search produced it. Turning it on is a spend decision as much as a quality one — it costs one provider call per candidate on every lookup a question makes. Mail answering § An optional second pass describes what it drops, what it keeps, and what it does when the provider cannot tell it.
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
Chat:RelevanceFilter:Enabled |
bool | false |
turning it on requires a declared Chat:Alias, and a Chat:MaxMessagesPerRequest of at least 2, because a judgement is an instruction and a candidate |
restart |
Chat:RelevanceFilter:MaxCandidates |
int | (unset) | 1 – MailAnswering:MaxPassagesPerRetrieval, which is everything one retrieval hands over; a higher value would name candidates that never exist and is refused at startup rather than accepted and never met. Unset judges every passage the retrieval hands over, which is why there is no literal default here: one would go on saying a number of its own after the retrieval it follows was narrowed or widened. The ceiling on what one lookup spends and how long it takes; set below what retrieval returns it buys a weaker filter rather than a shorter result, because a passage nobody judged keeps its place |
reload |
Chat:RelevanceFilter:MinimumRelevance |
int | 50 |
1 – 100, on the scale the model answers a judgement on. A threshold of 0 is refused: it would pay for a judgement that can drop nothing | reload |
MailAnswering
What answering one question is allowed to cost, and how much of a mailbox may leave the process to do it. A root of its
own beside Chat rather than a block inside it, because the two answer different questions: Chat says which endpoint
generates text and what one call to it may carry, while this bounds a run — the conversation in which a model looks
mail up, reads it, and writes an answer — and the aggregate over every run of a period.
Unlike the provider sections, an absent section is not an absent capability. Every deployment has these ceilings and writing nothing takes the conservative defaults below, because an absent provider is a capability nobody asked for while an absent ceiling would be a bill nobody asked for. Mail answering § What one question may spend describes what each one does when it is reached, and what a caller is told.
The period is a fixed window anchored at the Unix epoch, placed exactly as
Embeddings:SpendPeriod places the other spend ceiling: an AggregatePeriod of one hour begins on the
hour, so a refused caller has a roll-over instant to come back at. A client that spends the whole allowance at the end
of one window and again at the start of the next has therefore spent twice the ceiling across an interval of the same
length.
Unlike the embedding ceiling, this ledger is process-local and not durable: a restart begins the current window with nothing spent. The difference is deliberate and is stated rather than implied — an embedding sweep charges inside a transaction that was committing vectors anyway, while answering opens no write of its own, so a durable count here would put a database write on the path of every provider call in every run.
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
MailAnswering:MaxPassagesPerRetrieval |
int | 20 |
1 – 50; how many messages one lookup may draw on. Capped by what one search can rank, because a retrieval is answered from a search window, and defaulted to the window search_emails itself returns so that asking a question reaches as many messages per lookup as searching for the same thing would |
restart |
MailAnswering:MaxCharactersPerPassage |
int | 1200 |
1 – 100000; how much of any single message a lookup may draw out. Separate from the count above, because one enormous extract and a spread across several messages say different things about a mailbox | restart |
MailAnswering:MaxRetrievedCharactersPerRun |
int | 20000 |
1 – 10000000, and at least MaxCharactersPerPassage or no lookup could hand over even one passage. The ceiling on how much retrieved mail leaves the process to answer one question, whatever the model asks for, and the one that decides how many lookups a run fits: a lookup whose every passage reached the per-passage ceiling would exhaust a run on its own. Reaching it cuts rather than refuses: the run answers from what it has and the response says the mailbox was not read in full |
restart |
MailAnswering:MaxProviderCallsPerRun |
int | 8 |
1 – 1000; the ceiling that holds whatever the provider reports, because a run is a tool loop whose length is the model's decision. Reaching it stops the run with 57001. The run is also bounded by wall clock: raising this means raising McpEndpoint:RequestTimeout:Duration with it, or the extra calls are bought and then abandoned with a 504 |
restart |
MailAnswering:MaxTokensPerRun |
long | 80000 |
1 – 100000000; the cost ceiling, stated in the unit a provider bills by. Checked before each call against what the calls before it reported, so the call that crosses it is paid for — what a call will cost is not knowable until it is answered. Reaching it stops the run with 57001 |
restart |
MailAnswering:MaxAnswerCharacters |
int | 20000 |
1 – 1000000; how much of the model's answer one response carries. Cut rather than refused, and the response says it was cut | restart |
MailAnswering:MaxCitations |
int | 20 |
1 – 1000; how many messages one response names. Cut the same way and reported the same way | restart |
MailAnswering:AggregatePeriod |
TimeSpan | 01:00:00 |
positive; how long one period lasts before what was spent in it is forgotten. An hour rather than a day, because a ceiling an operator only meets once a day is one they meet after the spend has happened | restart |
MailAnswering:MaxRunsPerPeriod |
int | 30 |
1 – 1000000; the ceiling on how enthusiastic a client may be. Nothing about the MCP surface stops one from asking a hundred questions in a minute, and without this a per-run ceiling bounds each of those hundred and none of the total. A question over it is refused with 57001 |
restart |
MailAnswering:MaxTokensPerPeriod |
long | 300000 |
1 – 10000000000; the same ceiling in what a provider bills. Checked before a run begins, against what the runs of this period have consumed so far | 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 |
Jobs
The queue of durable background work, and the worker that runs it. A root of its own rather than a block inside any feature, because the queue is a mechanism every consumer shares: what a job does belongs to the feature that enqueues it, and how much of the instance the queue may take belongs here. Nothing here names a job type, and an instance whose build registers no handler runs no pass at all — the worker says so once at startup and stops, which is what leaves work an older replica cannot run for a newer one.
MaxConcurrentJobs decides how much of the instance background work may take, and it is stated here rather than left to
emerge from the database connection pool. A limit nobody wrote down moves whenever anything else in the process opens a
connection, and it arrives as a query waiting on a pool rather than as a job waiting for its turn. BatchSize is a
different number — what one claim takes — so a claimed job waits for a slot like any other, and raising the batch buys
fewer round trips rather than more work in flight.
MaxConcurrentJobsPerType bounds one kind of work on its own, and startup refuses a value above MaxConcurrentJobs,
which already caps it. A job waiting on the per-type ceiling holds none of the instance-wide one, so a bulk
re-evaluation of one kind of work is never the reason another kind never runs.
MaxQueueDepthPerType bounds what may be waiting rather than what is running. An enqueue against a queue already
holding that many jobs of a type is refused and says so, and the caller slows down, asks again later, or stops
producing — the work is neither queued nor lost, and a request whose work is already queued is answered with that job
rather than turned away. It is the one setting here that still applies with Enabled switched off, because it bounds
enqueuing rather than running. Two callers meeting the bound together can both pass it, so a queue may overshoot by as
many enqueuers as raced; this is backpressure rather than an invariant, and what it exists to stop is a backlog growing
without limit.
ExecutionTimeout must be shorter than LeaseDuration, and startup refuses a pair that inverts them. That ordering is
what keeps two workers off one job: an attempt is cancelled before its lease can expire underneath it. The lease is
renewed at half its duration while a handler works, so a job that legitimately takes longer than one lease is not
reclaimed while it runs.
A failed attempt is classified before the attempt budget is consulted, and only a failure that could clear on its own is
attempted again. A permanent one — a credential the dependency refused, a request it rejected, anything whose meaning is
unknown — ends the job on its first attempt rather than spending MaxAttempts to reach an answer it already had. What
runs out of attempts and what could never succeed both become dead letters: terminal rows nothing claims again, which
hold up no other job and keep the classification and the reason they ended on. A shutdown is neither, and spends no
attempt: the job goes straight back to the queue with the attempt it was claimed for given back.
No key decides what becomes of a dead letter, because an operator does. mfctl jobs reads what has stopped and either returns one to the
queue or writes it off, and durable background work is what says one is there.
RetryMaxDelay must be at least RetryBaseDelay, and startup refuses a pair that inverts them. A retry delay doubles
per attempt from RetryBaseDelay, is capped at RetryMaxDelay, and is drawn from a range rather than computed exactly
— jobs that failed together failed on the same dependency, and an exact delay would return all of them to it in the same
instant.
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
Jobs:Enabled |
bool | true |
turning it off leaves enqueued work where it is, for a replica that runs it | restart |
Jobs:BatchSize |
int | 5 |
1 – 100; how many jobs one pass claims. Each of them waits for a concurrency slot, so this bounds what one claim takes rather than what runs at once | restart |
Jobs:MaxConcurrentJobs |
int | 4 |
1 – 32; how many jobs this instance runs at once, across every type together. Kept well below the connection pool a stock connection string provides, so the pool is never what expresses the limit | restart |
Jobs:MaxConcurrentJobsPerType |
int | 2 |
1 – 32, and at most Jobs:MaxConcurrentJobs; how many jobs of one type run at once. A job waiting on this holds none of the instance-wide ceiling |
restart |
Jobs:MaxQueueDepthPerType |
int | 10000 |
1 – 1000000; how many jobs of one type may be waiting before enqueuing is refused as backpressure. Applies whether or not Jobs:Enabled is on |
restart |
Jobs:LeaseDuration |
TimeSpan | 00:05:00 |
2 s – 1 h; how long work stays held after the process running it stops existing, which is the delay before a crash is recovered from | restart |
Jobs:ExecutionTimeout |
TimeSpan | 00:02:00 |
1 s – 1 h, and strictly shorter than Jobs:LeaseDuration; exceeding it cancels the job, which counts as a transient failure and is attempted again. Raise it where this kind of work legitimately takes longer |
restart |
Jobs:MaxAttempts |
int | 5 |
1 – 20; how many attempts one job may be handed out for before a transient failure dead-letters it. 1 leaves no retry at all. A permanent failure ends the job whatever this says |
restart |
Jobs:RetryBaseDelay |
TimeSpan | 00:00:30 |
1 s – 1 h; the delay the first retry is drawn around, doubling per attempt | restart |
Jobs:RetryMaxDelay |
TimeSpan | 00:30:00 |
1 s – 24 h, and at least Jobs:RetryBaseDelay; the ceiling a grown retry delay never exceeds |
restart |
Jobs:PollInterval |
TimeSpan | 00:00:10 |
1 s – 10 min; how long an idle worker waits before looking again, and how often at most it measures the queue depth it publishes and asks whether a rule's schedule has come due. A schedule is therefore noticed within one interval of its occasion rather than at it. A pass that filled its batch looks again at once | restart |
MailRules
The rules that select mail, written here rather than held in a table, because a rule is a statement about how an instance is configured. Mail rules documents the whole authoring surface a condition may use — every fact, every function, every operator — and this section documents the shape the rules are declared in.
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
MailRules:MaxConditionLength |
int | 1000 |
1 – 10000 characters; a condition over it is refused naming the rule | reload |
MailRules:MaxConditionNestingDepth |
int | 16 |
1 – 64 levels of the parsed condition. A length limit alone would admit a short expression nested past reading | reload |
MailRules:ConditionEvaluationTimeout |
TimeSpan | 00:00:01 |
Greater than zero and at most 00:00:30; bounds one condition against one email, including resolving the facts it names |
reload |
MailRules:EvaluationBatchSize |
int | 200 |
1 – 10000 messages read, evaluated, and committed together; the unit of progress an interrupted pass gives back | reload |
MailRules:MaxEvaluationBatchesPerPass |
int | 5 |
1 – 1000 batches per walk per account run; what a pass leaves behind is the next run's, so a long queue drains over several runs instead of holding one up | reload |
MailRules:HistoryRetention |
TimeSpan | 30.00:00:00 |
At most 3650 days; how long a recorded rule execution is kept. Zero or less keeps one for exactly as long as the message it names. A pass records one execution per rule it reached per message, so this is the bound on a record that would otherwise grow with the mailbox | reload; the next account run erases against the new window |
MailRules:Rules |
list | empty | At most 200 rules, evaluated in the order they are written | reload |
MailRules:Rules:0:Name |
string | required | 1 – 64 characters of letters, digits, spaces, and ., _, -; unique across the section, ignoring case |
reload |
MailRules:Rules:0:Accounts |
list | empty | The accounts the rule applies to, each naming a declared MailSynchronization:Accounts:<n>:AccountId exactly; empty applies the rule to every account |
reload |
MailRules:Rules:0:Condition |
string | required | One expression producing a boolean, within the two limits above | reload |
MailRules:Rules:0:StopWhenMatched |
bool | false |
A match ends the pass and the rules below it are not reached | reload |
MailRules:Rules:0:Enabled |
bool | true |
A rule switched off is left out of the set entirely | reload |
MailRules:Rules:0:Triggers |
list | [] |
The automatic occasions that run the rule; Arrival and Schedule are the declared names, an unknown or repeated name is refused, and naming none is a rule nothing fires by itself that a whole-mailbox run applies |
reload |
MailRules:Rules:0:Schedule |
string | unset | When a rule declaring the Schedule trigger runs: Every <hh:mm:ss> or Every <d.hh:mm:ss>, from one minute to 365 days, or Daily at <HH:mm> optionally followed by a time-zone identifier and read in UTC without one. Required by that trigger and refused without it |
reload |
MailRules:Rules:0:Actions:MoveTo |
string | unset | The alias of the folder a match is filed into; the account must mirror it | reload |
MailRules:Rules:0:Actions:CopyTo |
string | unset | The alias of the folder a copy of a match is placed in; the account must mirror it | reload |
MailRules:Rules:0:Actions:Delete |
bool | unset | true removes a match from the folder it matched in; the account must permit deletion |
reload |
MailRules:Rules:0:Actions:MarkAsRead |
bool | unset | true sets the remote \Seen flag and false clears it; leaving the key out leaves the flag alone |
reload |
An absent action key is a change the rule does not ask for, which is why MarkAsRead carries a value rather than being
a switch. At most one of MoveTo, CopyTo, and Delete may be declared by one rule, Delete admits nothing beside
it, and every permitted combination is applied in MailFathom's own order — the flag first and the relocation or the
deletion last. A rule declaring nothing here selects mail and changes nothing.
What a matching rule does states the whole table, the order, and
how a change reaches the mail server.
Every condition is read while the host composes itself, and a defect in one — an unparseable expression, a name that is not a fact, a call that is not an available function, a comparison between shapes that could never match, a result that is not a boolean — fails startup naming the rule and what was wrong. So does a scope naming an account the deployment does not declare, which would otherwise leave the rule reaching no mail in silence. Every defect in every rule is reported together.
An edit that does not validate is refused and logged, and the previously valid rule set stays in effect. That is deliberately stronger than the framework's own reload behaviour, which would drop the candidate without saying so.
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.
ConnectionLimits
How many connections this process accepts at once, across every listener it opens. The other section that belongs to
the whole process rather than to a surface, and for a stronger reason than ReverseProxy: a connection is accepted
before any routing has decided which endpoint it was for, so there is no per-surface form of this question to ask.
Read it as the process's ceiling, never as the sum of what the endpoints permit. McpEndpoint:RateLimiting:MaxConcurrentRequests
bounds what one surface serves at once; this bounds what the machine accepts at all, the probe listener included. The
two numbers are deliberately far apart, because a connection is not a request — a client holds one open across several,
and an idle one survives until the keep-alive timeout — so a ceiling near the request limit would refuse ordinary
clients long before it refused a flood.
It exists because every other limit is reached too late to see this. The rate limiter partitions a request that already
has an HttpContext, and what a connection flood spends before that point — the accept, the TLS handshake, and on the
MCP surface the client certificate's chain building — is the most expensive per-connection work the process does.
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
ConnectionLimits:Enabled |
bool | true |
Turning it off restores the framework's own default, which accepts connections until the operating system stops supplying them; it costs a startup warning | restart |
ConnectionLimits:MaxConcurrentConnections |
int | 1000 |
1 – 100000; process-wide, across every listener | restart |
Like every limit here it is counted in this process alone, so a deployment running several instances enforces it once per instance rather than once in total, and none of it is protection against a distributed flood.
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:AdvertisedScopes |
string list | empty | Scopes published in scopes_supported for a client to ask for and checked on no token — offline_access is what a client needs to be issued a refresh token. Every required scope is published regardless, so a value repeating one is refused, as is one that is not a scope token |
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 and
scopes you advertise but do not require why the published list
is longer than the checked one,
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
One of the two endpoint subsections where every value has a product default — request timeout is the other — 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 |
Request timeout — McpEndpoint:RequestTimeout and AdminEndpoint:RequestTimeout
How long one request may run before the endpoint abandons it, answering 504 and releasing the concurrency permit it
held. Defaulted throughout like the rate limits, carried by both endpoints with the same keys, and configured
independently of them — because how much traffic is admitted and how long an admitted request may hold what it was
admitted with are different questions, and a deployment may already have one answered in front of the process without
the other.
Without it, MaxConcurrentRequests bounds how many requests run at once and nothing bounds how long any of them lasts,
so twenty slow requests take a surface out of service without exceeding any rate.
| Key | Type | Default | Constraint | Change |
|---|---|---|---|---|
…:Enabled |
bool | true |
Turning it off costs a startup warning | restart |
…:Duration |
TimeSpan | 00:10:00 |
1 s – 1 h | restart |
The default is a bound on a hang rather than a promise that no legitimate request is abandoned. An ask_mail run is
a conversation whose length the model decides, bounded by MailAnswering:MaxProviderCallsPerRun at eight calls, each an
AiProviderInvocation whose own TotalTimeout defaults to five minutes — so a ceiling enclosing the maximum would sit
at forty minutes, which is not a request ceiling and would let one stalled run hold a concurrency permit
that long. Ten minutes clears an ordinary answering run by a wide margin and abandons one that walks its whole provider
budget, which is the trade taken. Raise it alongside MailAnswering:MaxProviderCallsPerRun if you raise that. A
deployment serving no AI-backed tool narrows it instead: every other MCP tool answers from the local mailbox copy with a
bounded query, so a minute is generous there. AdminEndpoint reaches no provider at all, which makes it the endpoint to
narrow without having to ask what a tool call needs.
The ceiling is applied ahead of the rate limiter, so time a request spends waiting for a limiter lease is inside it.
That wait is nothing under the default queue limits of 0, and is the whole point of the ordering once a queue is
configured: a request queued for its caller's tokens already holds a concurrency permit.
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 |
AdminEndpoint:RequestTimeout |
block | bounded | Same shape, defaults, and rules as McpEndpoint:RequestTimeout above; applied whether or not it is written. This surface reaches no AI provider, so it is the one the default can be narrowed on freely |
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.
Executed SQL is a Debug record. EF Core reports every command it runs through
Microsoft.EntityFrameworkCore.Database.Command, at Information in the library's own configuration. MailFathom logs
that one event at Debug instead, because a synchronization run, a backfill sweep, and every MCP read reach the
database repeatedly, and one record per round trip would leave the stream mostly SQL. What is lowered is the level of
the event rather than a filter over the category, so the records come back by asking for them — set
Logging:LogLevel:Microsoft.EntityFrameworkCore.Database.Command to Debug for the commands alone, or Default
where a whole run is being read. A command that fails is untouched and stays in the default stream: only the
executed-command event is lowered, and every other EF Core event keeps the level the library gives it.
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 |
OTEL_TRACES_SAMPLER / OTEL_TRACES_SAMPLER_ARG |
How much of a trace is recorded. Unset records every trace this process starts and honors the decision on one it did not; telemetry holds the values and why that is the default |
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.