Table of Contents

Storage, keys, jobs, and logging

Every key about the deployment itself rather than about the mail passing through it: where its configuration is read from and how a secret-bearing value is interpreted, the database it writes to, the key ring that seals what it stores, the address it publishes as its own, the background queue that runs its work, what it does when an outbound dependency fails, and what it writes to its log. The tables read as the configuration reference says they do, and that page is the map to the rest of the sections.

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.

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. 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
Deployment:ReadOnly bool false reload

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.

ReadOnly is what a deployment holds rather than what it is currently configured to do. In it MailFathom sends no mail from any account, whatever an account's own Delivery:Enabled says and whoever asked — a tool call, a rule, a command. The refusal happens where the outgoing record would be written, which is the one place every author passes through, so nothing is queued and nothing waits for the mode to be turned off; mail delivery states what a caller is told. What it reaches is sending, which is what leaves this installation for somebody else's mailbox; changes to a mailbox this deployment reads are governed by the account's own rule action permissions and by the grant a caller holds.

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

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 every other section is bound with — 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.