Changelog

All notable changes to MailFathom are recorded here, in the format of Keep a Changelog 1.1.0. Versions follow Semantic Versioning 2.0.0 as ADR 0004 interprets it over MailFathom's four public surfaces: the MCP tool contract, the configuration schema, the database schema, and the deployment contract.

It is written for whoever runs MailFathom — the person installing it and the administrator keeping it running — and every section answers the same question before an upgrade: what is new for you, what was fixed, what breaks, and what you have to do about it. So what earns an entry is what you would notice: anything reaching one of the four surfaces, a fixed defect that was observable from outside, and any change with a security consequence. A refactor, a test, a continuous-integration adjustment, a documentation edit, and an internal rename earn none, and nothing below is written in the terms of the code that produced it.

A breaking entry opens with **Breaking (<surface>)** and states the operator's action rather than only the fact. A release that touches the database schema says whether a migration must be applied, whether it can be applied while the previous version is still running, and whether the release can be deployed over the previous release's data at all.

MailFathom is pre-release. Within 0.x a minor bump may break any of the four surfaces, and every break is named below against the surface it breaks; a patch is compatible on all four. There is no Unreleased heading, and neither nightly nor prerelease builds get a section of their own: what a nightly carries is, by definition, whatever has been merged since the newest section below.

This file is written by the release pull request and by nothing else. Ordinary work does not touch it — not a feature, not a fix, not a refactor — because a changelog is a statement about a release, and a release is what the tagged and published pull request makes. $prepare-release composes each section from the work merged since the previous tag, and that same pull request is the one whose merge commit is tagged and published to the container registries. CHANGELOG.md is a protected path for the same reason: an edit to it outside that flow changes what a release claims it shipped.

0.6.0 - 2026-08-14

The sixth release, and the first one that does something with your mail rather than only reading it. Rules you write in a configuration file move, copy, delete, and mark messages as read; a spam classification files junk on the server; and a durable queue underneath both means a crash loses none of it. The rules and the classification are off until you turn them on — the queue beneath them runs on every instance and is switched off only for a replica serving reads — and a rule is only ever authored in the file you provisioned, so what an instance will do to a mailbox is reviewable in a diff before it does anything.

The other half of the release goes the opposite way. A message's text can be redacted before anything is derived from it and before any of that text leaves this deployment: secrets are found in this process, personal data by an analyzer you run beside it, and what MailFathom then chunks, embeds, retrieves, and returns is the redacted text. An attachment's content is not text a scan reaches — the signed link serves the file exactly as it was stored, so a credential inside an attached file is not covered by turning a scanner on. That is off by default as well, and off it costs nothing at all — no container is started, no image is pulled, and no memory is held.

Seven things need an edit before this release serves what 0.5.0 served. The folder argument of list_emails, search_emails, and ask_mail is folders where it was folderAliases, and the old spelling is ignored rather than refused — so a client that keeps sending it reads every folder instead of the one it named. Every folder you want read is now named in configuration, and mail under an alias your file no longer names is unreachable until an entry names it again. get_email_content hands back a signed link per attachment instead of base64: a call asks for the links with includeAttachmentDownloadLinks where 0.5.0 asked with includeAttachmentContent, the old name is ignored rather than refused, and no link is issued at all unless Deployment:PublicBaseAddress is declared. Delete EmailContent:MaxAttachmentBytes and EmailContent:MaxAttachmentBytesPerRead from your configuration file, or the host refuses to start on a key it no longer knows. Mail in a folder your configuration maps as junk is withheld from listing and search unless the call asks for it, and withheld from answering with no way to ask. And a folder whose Synchronize you switch off now keeps its stored mail instead of erasing it.

The database schema moves by twelve migrations that add eight tables, four columns on three tables that already held data, and the indexes for both, and that change nothing 0.5.0 reads — so the schema step applies while 0.5.0 is still serving, 0.5.0 serves the result unchanged if you roll the image back, and this release deploys over the previous release's data. Nothing else 0.5.0 promised is withdrawn: every setting not named below still means what it meant, no tool was removed, and every artifact a release publishes still publishes — the image, the chart, the schema script, and an mfctl binary per platform, Windows included. What is paused is the winget submission, which has never produced a package: the two already open are waiting for the community repository's review.

Added

Mail rules — what should happen to a message, written in your configuration file and applied to your mailbox. A rule names the accounts it applies to, one condition over the message, and what a match asks for; a match moves the message to a folder, copies it, deletes it, or marks it as read, and MailFathom's convergence pass carries the change to the server the way every other change is carried, so a restart neither loses it nor repeats it (#712, #725). Mail rules is the page, condition by condition.

  • The condition is one expression over twenty-two facts about the message — the account alias, the folder alias and the role that folder plays, the subject, the sender's address and domain, the recipient addresses and domains, when it was received and sent, its age, its size, the attachment count and bytes, seven flags the server or the extraction reported, and the body text — with seven functions and the ordinary operators. Anything outside that set is refused when the file is read rather than at the moment a message meets it, and a rule set with three mistakes reports all three at once (#696).
  • A rule runs on the occasions it declares. MailRules:Rules:<n>:Triggers names them: Arrival for mail as it is synchronized, Schedule with a Schedule beside it for a recurring pass, and neither for a rule only an operator starts (#727, #820).
  • Each account states which of the four actions a rule may ask of it under RuleActions, with deletion opt-in and the three reversible actions opt-out. A rule asking for a refused action fails startup naming the rule, the action, and the account (#725).
  • No IMAP command a rule asks for leaves the pass, and the pass touches no \Seen flag itself. Every change a match asks for is written down and carried by the account's convergence pass, the way every other change to a mailbox is. The one thing the pass does reach a mail server for is finding a destination folder the account maps and does not mirror, and only where a rule files into one; everything it reads about the mail was already stored, so no MCP read waits on it however long it takes. It is a step of the account's own synchronization run, after the classification and in front of the passages being cut.
  • mfctl runs the rules and explains what they did. mfctl rules list and mfctl rules show state which rules are loaded, in the order they run, and what fires each; mfctl rules run applies them to mail that arrived before them and returns at once rather than holding the terminal open; mfctl rules run-status says where that run has got to; and mfctl rules history answers why a message is where it is, one row per rule per message, recording that a condition read senderDomain and never what the domain was (#728).
  • An edit takes effect on reload, and an invalid one changes nothing and is reported instead of disappearing. A run under way stays on the rule set it started with and reports itself superseded rather than half-applying two (#682).

Spam classification, and filing junk on the server. The verdict is read from what the message already carries: the provider's own X-Spam-* headers, and the folder it arrived in, which outranks them because it is a decision somebody already acted on. The authentication results, the ARC chain included, are recorded beside the verdict as signals rather than judged from — a DMARC failure is something your receiving server saw and chose to deliver anyway, so turning it into a spam verdict here would file mail your own provider decided to accept (#731). SpamClassification:Enabled turns it on, and it is off. It covers the folders SpamClassification:ScannedFolders names, and where you name none, every account's inbox and nothing else — so a deployment whose own filter delivers somewhere other than the inbox names that folder there, or gets no verdict for the mail in it. Spam classification is the page.

  • An Apache SpamAssassin daemon beside the service scores what the headers cannot, deployed only where SpamClassification:UseScanner is on: the Helm chart renders no workload for it, the Compose deployment keeps it behind an inactive profile, and the Quadlet unit is a file you never copy. Its DNS blocklists are off, because those rules send the sender addresses and link hosts out of your mail to third-party lists (#777).
  • What a verdict may do is two switches, both off. SpamClassification:Actions:FileInJunkFolder files the message into the account's junk folder and MarkAsRead marks it read, each through the same durable change record a rule uses (#779).
  • Arriving mail is classified as it is stored, as a queued job retried per message, so one unreachable scanner delays one message rather than the whole account (#826).
  • mfctl spam run classifies a whole mailbox, and its default posture is a dry run — the first thing you do with a scanner is find out what it would do. mfctl spam run-status follows the walk and mfctl spam classifications reads the verdicts back (#795).
  • Junk is withheld from everything derived from it. Where classification is on, a message it calls spam — and one the receiving server already filed in junk — is never cut into passages, never embedded, never sent to an embedding provider, and never offered to the rule set. A message still waiting for a verdict is held back only until SpamClassification:ClassificationWait expires, fifteen minutes unless you say otherwise, so a wedged scanner or a deep queue does not stall the index: past the wait the message is derived from like any other, and a spam verdict arriving afterwards discards the passages and vectors it produced, in the transaction that records the verdict. A shorter wait therefore costs more, since more unscored mail is embedded and then stripped — budget the provider spend accordingly if you lower it or run the scanner near its limit. Nothing else is written down, so dragging a message out of junk in any mail client is the whole of the correction (#805).

Sensitive-content scanning: mail redacted before it is derived from or handed out. Two switches under SensitiveContent, both off, and each finding is replaced by [redacted:<category>] — the category and nothing else, so no length and no surviving prefix narrows what stood there (#687). Sensitive-content scanning states the categories and what turning each on costs a search.

  • Secrets runs in this process, over a 204-rule corpus assembled from Microsoft.Security.Utilities.Core, the gitleaks rule data, and three shapes both of those miss because both are written for source control: a database connection URI, a connection string's password, and a link whose query string is the credential (#701).
  • Pii reaches a Presidio analyzer you deploy beside the service, mapped onto eleven categories you can suppress by rule without switching a category off. Turning the switch on with nowhere to ask fails startup rather than running unprotected (#724).
  • Everything stored that is derived from a body is derived from the redacted text, and each derived row records a digest of the configuration it was built under — so switching a scanner on later is visible rather than silent, and a startup line names SensitiveContent:RebuildStaleDerivedData when rows predate the current settings. Stored raw MIME is never rewritten (#790).
  • get_email_content is scanned on every call. Both body representations, the subject, and participant display names are redacted in flight, nothing is rewritten in the store, and a body cut short by the scan's ceiling says so as sensitiveContentScanCeiling (#803).

A folder mapping now says how far into MailFathom a folder is admitted, through three switches on the entry that each default to true: Synchronize decides whether the folder is mirrored at all, GenerateEmbeddings whether what is stored is ever cut into passages and sent to a provider, and VisibleToTools whether any tool lists, searches, reads, or answers from it. A folder mapped with Synchronize: false is still a folder a rule can file mail into (#706).

  • A mapping can ask for its folder to be created on the server. CreateIfMissing defaults to false and applies only to an entry naming a RemotePath, so a mistyped path stays an unresolved alias instead of becoming a folder named after the mistake. Creation is issued where the alias is resolved, level by level, and the alias binds to the folder as the server advertises it (#723, #726).
  • A mapping states a role independent of how the folder is found. An entry may name both a RemotePath and a SpecialUse, a role is unique per account, and role:Junk is how a rule or the classification names a destination without knowing what the server calls it (#729).
  • mfctl folder erase is the one thing in MailFathom that removes a folder's local copy, one bounded pass per request, printing a running total and taking the raw MIME, the search document, the passages, their vectors, the spam verdicts and the signals behind them, the rule-execution history for that mail, and the checkpoint with the rows — so mfctl spam classifications and mfctl rules history stop answering for a folder you erase (#789).

Durable background work, with the queue an operator can see and act on. Jobs are persisted, leased, and claimed one statement at a time, so work in flight when a process dies is picked up again rather than stranded — execution is at-least-once, and every handler is registered on the promise that running it twice with one payload is the same as running it once, so the second attempt a crash produces is safe rather than absent. An attempt runs under a bounded timeout with its lease renewed while it works (#797, #800).

  • Failure is classified rather than counted. What is transient is retried with jittered backoff up to Jobs:MaxAttempts, what cannot succeed is dead-lettered at once, and the recorded reason carries nothing from the message (#806).
  • Capacity is bounded at both ends. Jobs:MaxConcurrentJobs, Jobs:MaxConcurrentJobsPerType, and Jobs:MaxQueueDepthPerType govern how much runs at once and refuse an enqueue past the depth rather than accepting work the deployment cannot reach (#807).
  • mfctl jobs dead-letters, mfctl jobs retry, and mfctl jobs drop list what has stopped, put one back, and give one up, and seven instruments publish what ran, how long it took, how much was repeated, what stopped, what is waiting, what a recurring dispatch decided, and how many of its occasions were skipped — the last of those being the one thing the others cannot show, since a skipped occasion enqueues nothing (#814, #820).

A model server on a private address, reached with no credential at all. Unauthenticated is a third way to declare what an endpoint presents, beside ApiKey and EntraCredential, and a plain http address is accepted for an endpoint that declares it — which is the shape every local inference server has. Needing no credential is written rather than inferred from the other two being absent, because an omission is what a forgotten key reference also looks like, and a startup warning names each endpoint reached in the clear and what crosses it readable (#695). Provider endpoints records which services were checked and what each check rests on (#698, #702).

An inbound request now has an upper duration, and the process an upper connection count. The MCP and administrative endpoints each carry RequestTimeout beside their rate limiting, defaulted so enabling an endpoint bounds it — the health probes stay outside it deliberately, since they have to keep answering while an endpoint is refusing — and ConnectionLimits bounds what the machine accepts at all, the probe listener included, because the accept, the TLS handshake, and the client certificate's chain building all happen before any rate limiter can see them (#684).

A Podman Quadlet deployment, so a container can take the encrypted, machine-bound credentials systemd provisions. deploy/quadlet/ holds the unit sources for the application, PostgreSQL, the two networks, the volume, and the two optional sidecars, and every secret reference in its configuration example is systemd-credential: rather than file: (#704). The Quadlet deployment is the guide.

Traces and metrics over the parts of MailFathom no library instruments. A synchronization cycle opens a span per account with one per folder beneath it, and eight instruments report how long a cycle took, what it stored and skipped, what stopped a folder run, and how far behind an account is (#817). The local read path is spanned from the MCP call down to the content store (#819); tool calls, the content store, the extraction backfill, and database commits are metered (#822); and every call to a model opens a span measuring one attempt against the provider, with prompt and completion capture explicitly off (#828). Tracing is parent-based always-on, and OTEL_TRACES_SAMPLER still decides where you set it (#832). Telemetry lists every span and instrument.

An authorization server's document can advertise a scope this deployment does not require. McpEndpoint:Authentication:<n>:OAuth:AdvertisedScopes is published beside the required ones and never enforced, which is what lets a client be told to ask for offline_access without every token lacking it being refused (#818).

ask_mail can narrow its lookups the way a search can. The seven structured filters search_emails publishes are now available to an answering run, validated by the same use case in the same words, and a filter it refuses is reported back to the model rather than absorbed into an empty answer (#686).

Documentation for the two questions the previous release left you to work out on your own. Configuring a mailbox at your provider states the address, port, and credential kind each popular mail service publishes and what each does differently once synchronization runs (#703), and connecting the chat client you already use says where the dialog is in each one and which of them cannot present an API key at all (#710). An MCP client OAuth connection is documented end to end from the identity provider's side (#700).

Every surface that knows a version now says where that version's documentation is — the image's org.opencontainers.image.documentation label, the chart's install notes, mfctl status for the version the deployment reports, and the MCP server's own instructions to an initializing client (#798). The site publishes each version's pages as artifacts an AI agent can read as well: a map at the version's root, the Markdown source beside every documentation page the map links — the generated API reference is published as pages only — and one file per reading path (#793).

mfctl installs on Linux with one command, which fetches the binary for the platform, verifies it against the checksum published beside it, and installs it into ~/.local/bin. Where that directory is not already on your PATH the script prints the export PATH line to add to a shell profile rather than editing one for you (#794).

Changed

  • Breaking (MCP tool contract)list_emails, search_emails, and ask_mail take folders where they took folderAliases, because the argument now accepts a role — role:Junk — as readily as an alias you chose. An argument the tool does not declare is ignored rather than refused, so a client still sending folderAliases is not stopped: its folder filter disappears and the call reads every folder in scope instead of the one it named. Update any stored prompt, tool description, or client configuration that spells the argument out. Naming a role no account in scope maps is now refused with 53003 MailFolderRoleUnmapped rather than answered with an empty page; an alias nothing maps still selects nothing, because an alias is a name the caller chose while an empty page for a role would read as a folder holding no mail (#729).
  • Breaking (MCP tool contract)get_email_content returns a signed download link per attachment instead of base64 content, and asks for it with includeAttachmentDownloadLinks where 0.5.0 asked with includeAttachmentContent. An argument the tool does not declare is ignored rather than refused, so a client that keeps sending the old name is not stopped: it receives every attachment described, no link, and no error. Update every client that fetches attachment content before the upgrade. Every attachment is still described in full — file name, media type, decoded size — and a call that asks for the files receives one https URL per attachment, valid for EmailContent:AttachmentDownloads:LinkLifetime and scoped to that one attachment. Declare Deployment:PublicBaseAddress, an absolute address with no path and https unless the host is loopback: a deployment that declares none serves every other part of the read and reports each attachment as Unavailable, and so does one that configures no data-encryption key ring, because the signing key is derived from that ring. Nothing composes the address from a request header, so it cannot be guessed on your behalf (#679).
  • Breaking (configuration schema)EmailContent:MaxAttachmentBytes and EmailContent:MaxAttachmentBytesPerRead are removed, and a configuration file still carrying either fails startup. They bounded how many attachment octets one response and one call could carry, and no response carries an attachment's octets any more — what a caller receives is a link, and what bounds it is its lifetime rather than its size. The EmailContent section is bound strictly, so an unknown key is refused rather than ignored: a 0.5.0 file reaches this release and the host declines to start naming the key. Delete both lines, including the MaxAttachmentBytes: 0 that 0.5.0 documented as the way to return no attachment content at all — a deployment that wants the metadata and nothing else now simply does not ask for links (#679).
  • Breaking (MCP tool contract)junk mail is withheld from listing, search, and answering by default. list_emails and search_emails gain an optional includeJunkMail, defaulting to false, and every result of either carries a new required includedJunkMail field — so a client parsing a result strictly sees a new field, and one that names no new argument sees mail in a folder mapped as junk stop appearing. ask_mail excludes junk and offers no override, because its answer is composed by a model out of the mail it retrieved and content written to deceive a reader would arrive as ordinary correspondence. get_email_content is unaffected: a message reached by its identifier is one somebody already has in hand (#731).
  • Breaking (configuration schema)MailSynchronization:Accounts:<n>:Folders is now the whole of the folders the deployment has, rather than a list of folders it treats specially. A folder no entry names is unreachable for every reader: no tool lists, searches, reads, or answers from it, no rule is evaluated against its mail, nothing embeds it, and no alias of it resolves as a destination. Anyone whose deployment holds mail under an alias the current file does not name — a folder mapped once and later removed, or renamed in configuration — adds an entry for it to read that mail again. Nothing is deleted: the rows stay and become readable the moment a mapping names them, with the folder resuming from its retained checkpoint. The default is untouched, so an account configuring no folder still mirrors its inbox by role (#784).
  • Breaking (configuration schema)switching a folder's Synchronize off now keeps the mail it had already stored, where 0.5.0 erased it. The rows go on occupying the database while staying unreadable by every tool, query, embedding pass, and rule. No configuration value erases them any more; mfctl folder erase is what does, and an operator who switched the flag off expecting the storage back runs it (#781, #789).
  • Breaking (configuration schema)SpamClassification:UseScanner is read at startup, and a deployment that turns it on without a reachable daemon fails to start instead of quietly classifying from headers alone. Name a daemon in SpamClassification:Scanner:Host and deploy one, or leave UseScanner off (#777). The personal-data analyzer refuses startup on the same terms: SensitiveContent:Pii on with no analyzer that can answer for a switched-on category is a scanner that would find nothing, which is indistinguishable from clean mail (#724).
  • Breaking (configuration schema)a folder entry may now name both RemotePath and SpecialUse where exactly one was required, and CreateIfMissing is refused only on an entry naming no RemotePath. Both are relaxations, so configuration 0.5.0 accepted still binds. What is new is that two folders of one account naming the same role, or an alias beginning role:, fail startup — neither of which a previous file could have relied on (#729).
  • Twelve migrations, adding eight tables for rules, their execution history, spam classifications and their signals, classification runs, jobs, and rule schedules; four columns on stored_emails, email_search_documents, and backfill_positions; and the indexes both need. One statement renames a column on a table this release itself introduced. Nothing 0.5.0 reads changes shape, so the schema step applies while 0.5.0 is still serving, this release deploys over the previous release's data, and rolling the image back leaves 0.5.0 serving the result unchanged. Apply it the way every release's schema is applied (#797, #814, #820).
  • The release no longer submits winget manifests. Two submissions are open against the community repository and neither has been reached, and it accepts exactly one pull request per package version — so submitting again could only queue a version behind them. No page offers winget as a way to get mfctl while that holds; take the binary from the release, or use the install script on Linux (#794).
  • A message's passages are cut after classification and after the rules, not inside the transaction that stores it. Cutting is now the account run's last local step, so a message is never chunked before the classification could withhold it or before a rule could file it somewhere mapped differently — and passages are not undone by a message moving afterwards (#811). The arrival pipeline states the whole order.
  • The folder-count refusal on list_emails, search_emails, and ask_mail reads for the argument rather than for aliases. All three bind folders through the same resolver, so all three raise it. The five-digit code 51002 is unchanged and its message and filter name are worded differently; a client that matched on the text rather than the code sees new text, which is what the code exists so it need not do (#776).

Fixed

  • A client following the published OAuth metadata was sent back through /authorize every time its token expired. The document listed only the scopes a token would be refused for lacking, so offline_access could not appear in it — requiring it would refuse every token from an authorization server that grants offline access without echoing the value into the access token. Clients therefore asked for the published scopes, were issued no refresh token, and re-authorized on every expiry. Advertised and required scopes are two lists now, and mfctl no longer compensates by appending the value itself (#818).
  • ask_mail answered worse than search_emails on questions a search alone handles. An answering run could only rank free text across the whole scope, which is the one shape both lexical and vector similarity are weakest at; it can now narrow by sender, recipient, subject fragment, date bounds, read state, and attachments, and a filter it wrote badly is reported back to it instead of arriving as an empty mailbox (#686).
  • A folder mapped but not mirrored could not be reached as a destination. It is resolved on demand the first time something files mail into it, through the same resolver every other destination goes through — which also means CreateIfMissing reaches such a folder (#778).

Security

  • Mail can be redacted before it crosses out of the deployment, and the guard fails closed. Four egress points are named and each is guarded: the question and the retrieved extracts sent to a chat endpoint, every passage sent to an embedding endpoint, the subjects, snippets, and answers the MCP tools return, and — for a message a client asked for by identifier — its body representations, its subject, and the display names its headers wrote. What is deliberately left as read at that fourth point is what a caller acts on rather than reads: the addresses, the sizes, the flags, and every attachment's file name. A detector that is unavailable, times out, or errors fails the call rather than serving unredacted text (#772, #803).
  • No response carries an attachment's bytes any more. A call asking for files receives a link per attachment, carrying an opaque capability signed with a key derived from the deployment's existing key ring under HMAC-SHA256 and compared in constant time, valid for minutes, scoped to one attachment, and resolved through the live mailbox so it dies with the message it points at. A rotation leaves outstanding links verifiable for the rest of their own lifetime and issues new ones under the new key (#679).
  • Both scanners are meant to stay inside your trust boundary, and the deployment assets say so. The personal-data analyzer and the spam daemon are reached over your own network by default, the analyzer's confidence floor is set where every measured false positive drops while every category stays detectable, and the daemon's DNS blocklists — which would send sender addresses and link hosts to third-party lists — are off unless you operate a resolver and accept what it sends (#724, #777).
  • A slow or numerous caller can no longer hold a surface out of service. Twenty concurrent requests with no upper duration was enough to do it, since a permit was held for as long as the request took; the MCP and administrative endpoints now carry a request timeout, the probes staying outside it, and a process-wide connection ceiling bounds what is accepted before any routing has happened (#684).
  • Junk mail never reaches the model that answers a question. ask_mail excludes it with no override, so a message written to deceive a reader cannot arrive as ordinary correspondence in the material an answer is composed from (#731).
  • A telemetry record still carries no mail. Every publisher of a span or a measurement in the deployment is held against the redaction contract — driven through a listener and judged on what it emitted where a test can drive it, and read from its declarations where it cannot, which is how a span a background worker opens is covered — so the rule is asserted over the whole surface rather than sampled where somebody remembered to check (#832).

0.5.0 - 2026-08-10

The fifth release, and the one that lets a client ask about your mail rather than only look through it. ask_mail answers a question in prose and cites the messages the answer was drawn from, and search_emails ranks semantically as well as lexically. Both stay dark until you declare the AI endpoints they need, so a deployment that declares none serves exactly what it served before — and pays exactly what it paid before, which for a feature that bills per call is the more important half.

Four things need an edit before this release starts, renders, or answers a client. Every account states a DisplayName now; two arguments on the MCP tools were renamed, and the previous spellings are ignored rather than refused — so a client that keeps sending accountIds reads every account instead of being stopped; a Helm values document that names database.host — which every 0.4.0 one does — needs database.deploy.enabled: false beside it, or the chart refuses to render at all; and the database is PostgreSQL 18, which does not read a data directory PostgreSQL 17 wrote. The last of those is the expensive one: an existing deployment moves its data across a dump before the new image comes up, and upgrading a deployment that ran PostgreSQL 17 is the procedure, command by command.

The database schema moves as well, by six migrations that add four tables, four columns on tables that already held data, and two indexes on those, and that change nothing 0.4.0 reads — so the schema step applies while 0.4.0 is still serving, 0.4.0 serves the result unchanged if you roll the image back, and this release deploys over the previous release's data. Nothing else 0.4.0 promised is withdrawn: every setting not named below still means what it meant, and no tool was removed.

The defect 0.4.0 shipped with is gone, and it was the whole image. The published 0.4.0 container could not start: its base image sets ASPNETCORE_HTTP_PORTS, 0.4.0 is the release that began refusing that variable, and the Dockerfile never cleared the inherited value — so every container built from that image failed startup on a setting nobody wrote.

Added

ask_mail — a question about your mail, answered in prose and cited back to the messages it came from. The question is not a search query: its words are never matched against your mail, and the lookups behind it are written by the model, which is what lets did the supplier ever confirm the March delivery date find the message that says so without containing any of those words (#579). Every answer carries citations, one entry per email the run actually read, so nothing it says is un-checkable.

  • It is advertised only where it can work — a declared Chat endpoint, and mail that is embedded. A server with neither does not publish the tool, so a client never sees an ability the deployment does not have. Called on a deployment that cannot answer, it fails with 56001 and the message says which half is missing.
  • Configuring it is one section. Chat:Alias, Chat:Model, and one credential — an API key or a Microsoft Entra credential — declare the endpoint; Chat:Api states whether the deployment's server serves chat completions or the responses API, because the routed name is your own deployment's and nothing about it says which paths exist. A reasoning model states Chat:ReasoningEffort as the provider spells it, and unset sends no reasoning parameter at all (#557, #624).
  • What one question may spend is bounded before it is asked, and what a period may spend is bounded above that. MailAnswering sets how many passages a lookup draws on, how much of any one message it draws out, how much retrieved mail may leave the process for one question, how many provider calls and tokens one run may spend, and how many runs and tokens a period may. A run that reaches a ceiling stops with 57001 rather than continuing quietly, and a run that reaches only the retrieval ceiling answers from what it has and says the mailbox was not read in full (#592).
  • An optional second filter judges the retrieved passages with the model before they reach the answer, dropping what scores below Chat:RelevanceFilter:MinimumRelevance. It is off by default, because it is a second call per lookup (#573).
  • An account can keep a record of what each question read, off by default and enabled per account with AnsweringAuditTrail:Enabled and a retention window. It names the mail a run drew on and holds none of it, and GET /api/admin/answering/audit reads it back in bounded pages (#610).
  • The model is composed over Agent Framework and the mail it reads is fenced away from the instructions it follows, so an instruction written into a message is data rather than a command (#564, #565, #603). Mail answering is the page.

search_emails ranks semantically as well as lexically. Where an embedding profile is active, a search fuses the two rankings with Reciprocal Rank Fusion and reports retrievalMode: hybrid; where none is, it answers exactly as 0.4.0 did and says lexical (#555). Every response also carries semanticSearchinactive, available, or degraded — so a client can tell a server that never embedded anything from one whose provider is failing right now, which the two modes alone cannot distinguish (#562).

  • A failing provider degrades the search rather than the deployment. An unhealthy profile falls back to lexical ranking and says so, instead of failing the call.
  • Changing the model is a reindex with no search outage. A new vector generation is built beside the one that is serving and takes over only when it is complete, and POST /api/admin/embeddings/reindex/cancellation stops one under way and leaves the serving generation where it is (#570).
  • What embedding may cost is bounded before it is spent. Embeddings:MaxRequestsPerMinute paces a provider whose quota is per minute, Embeddings:MaxInputCharactersPerPeriod and Embeddings:SpendPeriod cap what a fixed window may send, and Embeddings:MaxCharactersPerEmail bounds a single enormous message rather than refusing it (#581).
  • mfctl administers it. mfctl embedding status reports whether semantic search is working, how far behind it is, and when the next backfill pass is due; mfctl embedding activate forecasts what taking up the declared model would cost before it starts, and starting it wakes the backfill instead of leaving the deployment to look broken for up to fifteen minutes (#593, #626). Embedding profiles states the whole lifecycle.

list_accounts, so a client can find out what it may ask about. It reports each account's identifier, the display name you gave it, whether its next pass polls or listens, and one entry per folder with how fresh that folder's local copy is — and deliberately publishes no address, no credential, and no server name (#637). Every tool that takes accounts now accepts either the identifier or the display name, so a person can say work where the configuration says work-imap-01.

get_email_content returns attachment content. Pass includeAttachmentContent and each attachment arrives as base64, bounded by EmailContent:MaxAttachmentBytes per file and EmailContent:MaxAttachmentBytesPerRead across the call; a file over the limit is described and not returned, never truncated (#633). Setting MaxAttachmentBytes to 0 returns no attachment content at all, which is the deployment that wants the metadata and nothing else.

A record of every change MailFathom makes to a mailbox, off by default and enabled per account. Nothing in 0.5.0 asks it to make one — no tool on the MCP surface writes, and the first caller is the rule engine a later release brings — so an account that turns the trail on today gets an empty page and keeps getting one until that caller exists. What it buys now is that the decision is made and the storage is in place before the first write, and that is the whole of it. AuditTrail:Enabled and AuditTrail:Retention turn it on per account, one entry is written per finished change, and GET /api/admin/mailbox/mutations/audit reads it back filterable by account, by change, and by time (#568).

  • It holds no mail content and it outlives the mail. Folder paths, identifiers, a five-digit failure code where there was one, and MailFathom's own configured names are all an entry carries — no subject, no address, no body fragment, no filename — and erasing the email leaves the entry standing, including where the change recorded was that deletion.
  • Retention rides the account's own run and erases at most five thousand entries a pass, so shortening a long window clears the backlog over several runs rather than in one delete that locks the trail.
  • AuthoredDeleteEmailDisposition decides what becomes of the local copy of mail MailFathom itself deleted — RetainLocalCopy, RetainTombstone, or EraseLocalCopy — separately from the setting that governs mail somebody else deleted, because a deletion of ours and a deletion of theirs are different facts (#554, #561, #563).

A ceiling on how much mail one deployment stores, and how much one run brings in. MailSynchronization:MaxStoredContentBytes is what stops a large mailbox from filling the volume: past it, ingestion degrades to metadata only and keeps listing and searching rather than failing, and the messages it skipped are picked up once there is room. MaxContentBytesPerRun ends a folder run at its checkpoint instead of at the end of the mailbox, and MaxInFlightRawMimeBytes bounds what a run holds in memory at once (#580).

mfctl reaches a deployment whose certificate this machine does not trust, by asking once. mfctl login shows the fingerprint, asks, and pins what you accept to that profile, so a later renewal is a question rather than a silent acceptance; --trust-untrusted-certificate and --allow-clear-text answer the same two questions where there is no terminal to ask on (#560).

Every exported record names the build it came from. service.version carries the semantic version and vcs.ref.head.revision the commit, on every log record, metric, and span the host exports, so a report from a deployment can be tied to the code that produced it (#620, #655).

Changed

  • Breaking (deployment contract)the database is PostgreSQL 18.4 with pgvector 0.8.6, where 0.4.0 ran 17. PostgreSQL does not read a data directory an earlier major version wrote, so bringing the new image up over an existing volume does not upgrade it — the container exits 1 naming the data it found, the server never listens, and nothing that depends on it comes up. The attempt writes nothing, so the old directory is intact and still dumpable afterwards. Move the data across a dump from a PostgreSQL 17 server before the upgrade, or delete the volume and let synchronization refill it from IMAP — which costs the embeddings and the audit trails, since neither is in the mailbox (#658). Upgrading a deployment that ran PostgreSQL 17 is the sequence for Compose, and the same reasoning holds for a claim the chart wrote.
  • Breaking (deployment contract)the Helm chart runs PostgreSQL itself unless you tell it not to, where 0.4.0 installed none and required database.host. A values document that names a host now fails to render, because database.host is refused while database.deploy.enabled is on and the address is derived from the release name instead: two values naming one server is how a deployment ends up connecting somewhere it did not install. Keep your own server by setting database.deploy.enabled: false beside the host you already have. A deployment that takes the default instead names a second Secret in database.deploy.superuserPasswordSecret — separate from secrets.existingSecret, and refused if it is the same one — because the application's Secret is mounted whole into the pod that parses untrusted mail (#658).
  • Breaking (deployment contract)mfctl refuses a deployment from another release line before it sends anything. A 0.4.x command against a 0.5.x deployment stops with a message naming both versions, because the administrative contract is what a minor may break and a command that guesses at it is worse than one that declines. Take mfctl from the deployment's own release; two builds that share a major.minor and differ otherwise warn and run, and a version that cannot be read warns and runs (#628).
  • Breaking (configuration schema)every account states a DisplayName, and startup fails naming the account that has none. It is what a client sees and what a person names an account by, it is at most 128 characters, and it may not collide with another account's identifier or display name compared without regard to case. Add one line per account under MailSynchronization:Accounts (#637).
  • Breaking (MCP tool contract)list_emails and search_emails take accounts where they took accountIds. The argument was renamed because it now accepts a display name as readily as an identifier. An argument the tool does not declare is ignored rather than refused, so a client still sending accountIds is not stopped — its account filter simply disappears, and the call reads every account the deployment serves instead of the one it named. Update every client that names accounts before the upgrade, and read list_accounts for the names (#637).
  • Breaking (MCP tool contract)get_email_content takes includeAttachmentContent where it took includeAttachmentDetails, and every attachment's file name, media type, and decoded size are now returned whether or not the call asks for anything. The old argument bought the metadata; the new one buys the bytes, so a client that passed it to see what was attached needs to pass nothing at all. It is ignored the same way when it is still sent, which here costs nothing — the metadata arrives regardless, and no attachment content is returned without the new argument (#633).
  • search_emails can report a retrievalMode it never reported before. lexical was the only value 0.4.0 produced; hybrid is a second one, and a client matching on the field exactly rather than on the results should expect it (#555).
  • Every SQL statement MailFathom runs is logged at Debug rather than Information. A deployment at the default level no longer writes one log record per database command, which is where the bulk of its log volume was going — and those records carry the text of every query the mailbox is read with. Set Logging:LogLevel:Microsoft.EntityFrameworkCore.Database.Command to Debug to get them back (#654).

Fixed

  • The published 0.4.0 container could not start. Its base image sets ASPNETCORE_HTTP_PORTS, 0.4.0 refuses that variable by design, and the Dockerfile did not clear the inherited value — so the image failed startup on a setting nobody had written, with a message naming a variable that is not in any configuration file (#575).
  • An MCP endpoint configured with OAuth and nothing else threw on every request that carried no credential, including the health probes, which are documented as carrying none. The endpoint now answers an uncredentialed request with the challenge it is supposed to, and the probes are served without one (#577).
  • A list_emails date filter written at a non-zero UTC offset failed the whole listing. receivedOnOrAfter and receivedBefore reached the database unconverted, and anything but +00:00 was refused there — so a client in a time zone sent the value its clock produced and got a failure rather than a page. Both bounds are held as instants now, and every offset names the same moment (#612).

Security

  • A question and the mail that answered it leave no copy at the provider. A chat endpoint declaring Api: Responses reached an API that retains what it is sent for thirty days by default and makes it readable in the provider's dashboard — so adopting that API would have placed the operator's correspondence in a third party's log because of a default nobody wrote. Every request states that it is stateless, and the model's reasoning is carried between turns as the encrypted content the provider returns rather than by leaving the conversation behind (#636).
  • What leaves the process to answer one question is bounded and countable. The passages a run may send, how much of any single message goes with them, and the total that may cross the boundary for one question are each configured and each enforced before the call rather than after it (#592).
  • A database the chart deploys keeps its superuser credential out of the pod that parses mail. The application's Secret is mounted whole, because MailFathom reads the keys your own configuration names, so the superuser password lives in a second Secret the application never mounts and the chart refuses a values document that names one Secret for both. The role MailFathom connects as is never a superuser in either arrangement (#658).

0.4.0 - 2026-08-07

The fourth release, and the first that asks every deployment to edit its configuration before it will start. Two things every installation states have moved: where each surface is served, and how a credential is configured. Neither previous form is ignored — both fail startup naming what replaces them — so an upgrade that skips the edit stops rather than quietly serving something you did not configure. The database schema moves as well, by five migrations that add three tables and then refine one of the three, and that touch nothing 0.3.0 reads — so the schema step belongs to this upgrade, it applies while 0.3.0 is still running, and 0.3.0 serves the result unchanged if you go back.

Nothing else 0.3.0 promised is withdrawn. The MCP tool contract is untouched — list_emails, get_email_content, and search_emails answer exactly as they did — and every setting not named below still means what it meant.

The defect 0.3.0 shipped with is gone. A deployment that set HealthEndpoints:Enabled to false and enabled the administrative endpoint lost its application listener and refused every MCP client. There is no application listener to lose now, because every surface binds the socket its own section names.

Added

A key pair as a third way to authenticate, on both endpoints. The client holds the private key and the deployment holds only the public half, so nothing this host stores in order to verify a request is worth stealing from it — not from the configuration, not from a backup of it, and not from the deployment tool that wrote it (#527).

  • Configure a PublicKey entry under Authentication exactly as you would a key: one named secret, reached through every reference scheme the deployment already has, with a Name diagnostics correlate on and a Lifetime that is enforced. Startup refuses material that is not a PEM public key, an RSA key below 2048 bits, a curve outside P-256, P-384, and P-521, and — explicitly — material carrying a private key.
  • The client mints a short-lived JSON Web Token, signs it with the private half, and presents it as an ordinary bearer credential: the arrangement RFC 7523 describes and OpenID Connect deploys as private_key_jwt. It carries typ: mailfathom-client-assertion+jwt, an audience of urn:mailfathom:mcp or urn:mailfathom:admin, an expiry no more than five minutes ahead, and a fresh identifier the endpoint refuses to serve twice — so a captured assertion stops working on its own, and cannot be replayed even inside its remaining seconds.
  • mfctl login --mode keypair --private-key <file> mints all of it and stores no credential; every command signs its own assertion.
  • Rotating a key is an overlap with no secret to coordinate across two machines: add the new public key as a second entry, move the client to the new private key, remove the old entry. Key pairs is the page.

mfctl from the Windows Package Manager. Each release submits its own manifest, so winget install MailFathom.mfctl becomes a packaged path beside the download and winget upgrade carries you to the next release (#498). The manifest names the same release asset the releases page does and carries the same hash the checksum file does, so both paths install the same bytes and check them the same way. A version is offered a little after it is attached here, because the community repository reviews the submission; until one is accepted, the releases page is where the command comes from on every platform.

The metrics and traces the libraries underneath MailFathom already emit. Where OTEL_EXPORTER_OTLP_ENDPOINT names a destination, four more meters now reach it: Npgsql for connection-pool state and command durations and counts, Microsoft.EntityFrameworkCore for contexts, queries, saves, compiled-query cache hits, and concurrency failures, Experimental.ModelContextProtocol for MCP session duration and per-operation duration broken down by protocol method and tool name, and Polly for every outbound pipeline's attempts, outcomes, timeouts, and circuit-breaker transitions (#521). Database commands and MCP protocol operations are spanned as well and correlated with the request that caused them; the probe paths stay untraced, because a probe arrives every few seconds and says the same thing every time.

  • Every tag on them is a bounded set — a protocol method, a transport, one of the three tool names, an outcome — so none of them opens a time series per message or per person.
  • What MailFathom publishes under a name of its own goes under exactly one: MailFathom, serving as both activity source and meter, which is what a dashboard filters on to see this process and nothing a library emits (#510). Telemetry records each of them.

Changed

  • Breaking (configuration schema)every surface states where it is served, and the host's own ways of naming a listener are refused. ASPNETCORE_URLS, ASPNETCORE_HTTP_PORTS, ASPNETCORE_HTTPS_PORTS, --urls, and any entry under Kestrel:Endpoints each fail startup with a message naming the setting that replaces them. Write McpEndpoint:BindAddress, McpEndpoint:Port, and McpEndpoint:Transport; the administrative endpoint and the probes take the same three. A deployment of your own that sets ASPNETCORE_HTTP_PORTS sets McpEndpoint__Port instead — the published image and the packaged chart already do, so an upgrade that takes both as they ship needs no edit here (#459). They are refused rather than ignored because ignoring them is silent: Kestrel drops URL-shaped addresses as soon as a listener is bound in code, which every surface now does, and a configured endpoint would otherwise be bound beside them on a socket no section describes and no credential guards. A deployment that enables no surface at all is refused for the same reason.
  • Breaking (configuration schema)the administrative endpoint's default port is 8080, the MCP endpoint's, where 0.3.0 gave it 8090. Two surfaces may deliberately share one socket now — the posture a single-node deployment behind one ingress wants — so a deployment that enabled the administrative endpoint without stating a port publishes it wherever 8080 is published rather than on a port of its own. State AdminEndpoint:Port, where 8090 restores what you had, unless sharing is what you want; the socket serves each surface's own paths either way, and a path a surface does not own is still refused there with a 404 (#459).
  • Breaking (configuration schema)Transport decides what a surface's clear-text socket does, where 0.3.0 inferred that from whether HTTPS profiles were configured. Http serves the routes and refuses profiles, HttpAndHttps binds the profiles and redirects the clear-text socket to them, and HttpsOnly does not open it at all. Http is the default, so adopting this release costs no certificate work (#459).
  • Breaking (configuration schema)Https:Redirect no longer binds a port of its own. 0.3.0 gave it 8080 beside the MCP profiles and 8091 beside the administrative ones; the redirect now answers on the surface's own BindAddress and Port. A deployment that published 8091 to reach the administrative redirect publishes that surface's own port instead (#459).
  • Breaking (configuration schema)authentication is a list of the credentials an endpoint accepts, where 0.3.0 named methods in a flag set and configured each in a sibling section. McpEndpoint:Authentication and AdminEndpoint:Authentication each take entries, and the block an entry carries is what selects the method that judges it — there is no setting naming the method any more. Authentication: "ApiKey" beside an ApiKeys list becomes one entry per key, each carrying an ApiKey block; Authentication: "OAuth" beside an OAuth section becomes an entry carrying that section. An entry carrying no block fails startup, named by its position (#515).
    • RequiredScopes is per entry rather than per endpoint, so two authorization servers one endpoint accepts may demand different scopes. Every OAuth entry still names the same Resource, because the endpoint publishes one metadata document.
    • An empty list warns at startup exactly as None did, and a value written where the list belongs fails it rather than being read as a method name.
  • Breaking (configuration schema)a setting only the process environment can deliver, written anywhere else, fails startup naming every such variable at once, with error code 12002. OPENSSL_CONF, OTEL_SERVICE_NAME, and every OTEL_*, ASPNETCORE_*, and DOTNET_* variable are 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 reached nobody — while the file read it back happily and nothing said which of the two you were looking at. Set each on the host process, or remove it (#509).
  • Every synchronized message is also cut into passages and stored, in the same transaction that stores what was extracted from it, so a mailbox costs more storage per message than it did under 0.3.0 — roughly its extracted text again, in overlapping windows (#488). A message that yielded no text is cut into nothing, mail stored before this release is not revisited, and nothing else in this release reads a passage.

Removed

  • Breaking (deployment contract)GET / no longer answers. 0.3.0 served {"service":"MailFathom","status":"ready"} at the root of the application listener; the MCP endpoint's port serves /mcp and answers everything else with 404. An external check pointed at / moves to the probes on their own listener — /alive for liveness, /health for readiness, /started for startup, on HealthEndpoints:Port unless you moved it (#459).

Fixed

  • A file: secret reference pointing at a FIFO or a stalled mount hung the host indefinitely. Opening the file is bounded now, so an unreachable mount is reported as one line of the startup failure report rather than as a process that never finishes starting and never says why (#511).
  • The device sign-in prompt raced the rest of mfctl's output. Both device-code flows handed the verification address and the short code to the console through a type that marshals onto a synchronization context a console process does not have, so nothing ordered the printing of the code against the wait for you to type it. The prompt now reaches the terminal before polling begins, on the thread that asked for it (#418).
  • HealthEndpoints:Enabled: false beside an enabled administrative endpoint no longer costs the application listener — the defect 0.3.0's notes named as shipped with it (#419), and one that cannot recur now that each surface binds its own socket (#459).

Security

  • A key pair leaves nothing on the host worth stealing. An API key is a shared secret, so a copy of every credential that reaches the mailbox sits in the configuration and in whatever produced it; a public key verifies the same client and is not a secret at all. It is the method for a scheduled job, which has no person to sign in as (#527).
  • The administrative endpoint shares the MCP endpoint's port unless you say otherwise. Administering the service is a different authority from reading the mailbox, and the probes answer without a credential, so putting either on the endpoint's port publishes it wherever that port is published. The ports exist so the decision is yours; take it rather than inherit it (#459).
  • A listener nothing configured can no longer be bound. Refusing the host's own address settings closes the case where a Kestrel:Endpoints entry survived beside a listener bound in code and served the routes on a socket no section describes, no credential guards, and no isolation middleware was composed for (#459).

0.3.0 - 2026-08-04

The third release, and the first whose upgrade is a new image and nothing else: the database schema does not move. No migration is added, so 0.3.0 serves the database 0.2.0 was serving, and 0.2.0 serves it again if you go back. Nothing 0.1.0 or 0.2.0 promised is withdrawn either — the MCP tool contract is unchanged, and every configuration key 0.2.0 accepted is still accepted and still means the same thing. There is no breaking entry below.

What is new stands in front of the service rather than inside it: what terminates TLS for it, and what bounds the surface you administer it through. The pages describing all of it are now published as a documentation site, with search, an API reference generated from the source, and a version selector; 0.3.0 is the first release it carries a version for.

One caveat, and it is a defect this release ships with: a deployment that sets HealthEndpoints:Enabled to false and enables the administrative endpoint loses its application listener, because binding a socket in code makes Kestrel ignore ASPNETCORE_HTTP_PORTS and only the probe path restates it. The process starts and serves the administrative port alone, and every MCP client is refused. #395 carries the fix. Until it lands, leave the probes enabled — the default — or state the application listener as a Kestrel:Endpoints entry.

Added

A deployment behind a TLS-terminating reverse proxy. When nginx, Traefik, or an ingress controller holds the certificate, the request that reaches MailFathom arrives as http under an internal name, and the deployment's public identity survives the hop only in two headers.

  • X-Forwarded-Proto and X-Forwarded-Host are read and applied before anything else sees the request, so OAuth discovery, the 401 challenge, and every absolute address MailFathom writes carry your public name — the protected-resource metadata document included, which is what a proxied OAuth deployment needed (#371).
  • ReverseProxy:TrustedProxies names the addresses or CIDR networks those headers are believed from, and ReverseProxy:MaximumForwardedHops (default 1) how far back through each header a value is believed (#371, #397). It is one section for the whole process rather than one per surface: a proxy's address is a network fact, so it is stated once and holds on every listener. What you name replaces the framework's loopback default rather than adding to it, and 10.0.0.5/24 is refused naming the 10.0.0.0/24 it would otherwise silently have become.
  • X-Forwarded-For is never read, so the peer MailFathom observes stays the one that opened the connection, and McpEndpoint:OAuth:Resource stays a value you wrote rather than anything derived from a header (#371).
  • Client certificates are unreachable in this posture, because the handshake ended at the proxy and no header is read as a substitute (#371). Behind a TLS-terminating reverse proxy is the page, including what the proxy owns and what MailFathom keeps owning.

A clear-text listener that redirects to HTTPS. A surface that terminates TLS also binds one listener whose only answer is a 308 to the address its profiles are served at, so a client nobody repointed meets a redirect rather than a refused connection indistinguishable from an outage (#374).

  • McpEndpoint:Https:Redirect binds port 8080 and AdminEndpoint:Https:Redirect port 8091 unless you state another, each taking Enabled, BindAddress, and Port. The defaults differ so terminating TLS on both surfaces opens two clear-text ports that do not collide.
  • That listener maps no route. Every path is answered the same way, and no authentication, rate-limiting, CORS, or client-certificate handler runs for a request that arrived on it, so there is nothing reachable over it to protect.
  • 308 rather than 301 or 302, because the MCP transport is a POST the older codes permit a client to re-send as a GET. The path and query are preserved, each domain redirects to its own profile's port, a Host header naming no configured domain gets 400, and :443 is left out of the Location.
  • Writing the section for a surface that terminates no TLS fails startup rather than being ignored, and a socket conflict with any other listener in the process is reported against the section that asked for it. The health probes keep their own listener and are never asked on this port, because a probe follows no redirect.

Rate limiting on the administrative endpoint. AdminEndpoint:RateLimiting is the section McpEndpoint:RateLimiting is, with the same keys, the same product defaults, and the same validation (#373).

  • The two are configured independently and partitioned per surface, so neither endpoint's traffic reaches the other's limits: an agent that exhausted /mcp has taken nothing from the surface you would use to stop it, and the concurrency limits are separate for the same reason.
  • The burst is the endpoint's rather than one caller's. These routes carry no authentication middleware of their own — the credential is judged behind the limiter, so a request about to be refused for a wrong key has still spent capacity — and there is therefore no identity to partition on. Size TokenCapacity as what the whole endpoint may burst to rather than what one operator may.

Changed

  • An enabled administrative endpoint is bounded whether or not you configure it: 20 concurrent requests and a burst of 60 restored every minute, which are the MCP endpoint's defaults. 0.2.0 served it unbounded, so a deployment whose automation asks faster than that raises the numbers or sets AdminEndpoint:RateLimiting:Enabled to false, which costs one startup warning (#373).
  • Configuring an HTTPS profile now also binds a clear-text port8080 beside the MCP profiles, 8091 beside the administrative ones. Where a proxy in front of the process already answers that port, or something else on the host holds it, set …:Https:Redirect:Enabled to false. A conflict with another listener of this process is refused at startup naming the section that asked for it rather than failing later as an address-in-use error (#374).
  • Startup now reports the rate limits once per enabled endpoint rather than once, and under a different logger category: MailFathom.Host.Hosting.Warnings.TransportRateLimitingStartupReport, where 0.2.0 wrote …McpRateLimitingStartupReport. A log pipeline that matches on that category updates it, or it stops seeing the line (#373).
  • The clear-text transport warning describes the deployment you configured once a trusted proxy is named, rather than suggesting McpEndpoint:Https:Endpoints to a deployment whose certificate lives on the proxy (#378).

Security

  • A deployment that names no trusted proxy trusts every peer. An OAuth access token is refused when the request did not arrive over transport encryption, and that check reads the scheme a forwarded header set — so with ReverseProxy:TrustedProxies left empty, anything that can open a connection sends X-Forwarded-Proto: https and has a reusable credential accepted over a clear-text hop, and X-Forwarded-Host is believed on the same terms. Name the addresses or CIDR networks your proxies actually use. Every startup running on the wide default logs one line naming what the deployment gave up (#378, #397).
  • The administrative endpoint is bounded by default, which is what stops a surface reachable from a network from serving unbounded API-key guessing — the attack it is most exposed to, and the one where a successful guess is worth the most (#373).
  • A redirect protects the next request and never the one that arrived: a credential sent in clear text was on the wire before anything answered. Treat the redirect as a way to find out that a client needs repointing rather than as a supported way to reach the endpoint (#374).

0.2.0 - 2026-08-04

The second release, and the first that had a previous one to differ from. Nothing 0.1.0 promised is withdrawn: the MCP tool contract is unchanged, every configuration key 0.1.0 accepted is still accepted and still means the same thing, and both schema changes are additive. There is no breaking entry below, so an upgrade is the schema step and a new image.

What is new is how a mailbox authenticates, how quickly a change on the mail server reaches the local copy, and a second HTTP surface — an administrative endpoint with a command-line client of its own — that an operator reaches without going through the MCP surface.

The database schema. Two migrations, both additive: one table and one nullable column (#343, #346). 0.2.0 refuses to serve until they are applied — startup is gated on the migrations the binary carries and will not migrate a database out from under a running process — but 0.1.0 neither reads nor writes what they add, so they can be applied while 0.1.0 is still serving, and the release then deploys over 0.1.0's data unchanged. The gate reads only what is pending, so a database already carrying both migrations still starts 0.1.0: going back needs no schema step of its own. Applying the database schema records the apply path and the ordering a deployment follows.

Added

Mailbox authentication. An IMAP account can present an OAuth token instead of a password.

  • XOAUTH2 and OAUTHBEARER are accepted in MailSynchronization:Accounts:<n>:TransportSecurity:PermittedAuthenticationMechanisms, and naming either one turns on that account's …:OAuth block: the token endpoint, the client, the scope, and the grant — refresh_token or client_credentials — with the client secret and the refresh token supplied by reference like every other credential. Configuring the block for an account that authenticates with a password fails startup rather than provisioning something nothing can use (#306). Mailbox OAuth is the page, including where each value comes from for the providers this was verified against.
  • Calls to an authorization server get a retry, timeout, circuit-breaker, and concurrency budget of their own, as the Resilience:MailAuthorizationServerInvocation class, rather than borrowing the mailbox session's (#306).

Continuous synchronization. A folder change on the mail server can start a pass, instead of every change waiting for the account's next interval.

  • MailSynchronization:Accounts:<n>:Mode selects Polling0.1.0's behaviour, and still the default — or Push (#339).
  • Under Push, a server offering NOTIFY is watched over one connection per account covering every configured folder, and a server offering only IDLE over one connection per folder (#339, #346). MaxSubscribedFolders (default 20) bounds how many folders one subscription may name; the rest synchronize on the account's interval rather than being dropped.
  • Where the server offers CONDSTORE and QRESYNC, a pass asks what changed since the modification sequence it last reconciled through instead of re-reading the folder, which is what the new nullable synchronization_checkpoints.ReconciledThroughModSeq column records (#346).
  • Push degrades to polling rather than stalling: MaxConsecutivePushFailures (default 3) and PushDegradationPeriod (default 15 min) decide when an account falls back and for how long, and PushRenewalInterval (default 20 min) is the lifetime of one IDLE command — RFC 2177's ceiling, not a polling cycle (#339, #341). Synchronization stays read-only throughout: a push pass sets the remote \Seen flag no more than a polled one does.

An administrative endpoint, and the mfctl command that reaches it.

  • AdminEndpoint serves administrative routes beneath /api/admin on a listener, a credential set, and a set of authorization servers of its own. It is off by default, and a key or an issuer configured under McpEndpoint authenticates nothing here — the two surfaces are protected independently rather than sharing one policy (#313, #317).

  • Each release now attaches mfctl, a self-contained binary per platform — linux-x64, linux-arm64, win-x64, win-arm64 — plus one checksum file covering all of them. It runs where you administer from rather than where the service runs, and needs no .NET installation (#317).

  • mfctl login signs in with an API key read from standard input, with a browser redirect caught locally, or with a device code entered elsewhere, and keeps the credential in a profile file of its own with the tokens encrypted at rest — the refresh token included, so a session outlives an access token's expiry rather than sending the operator back through the flow (#348). Administering your deployment states what that encryption protects and what it does not.

  • mfctl mailbox authorize runs a mailbox's own OAuth flow from the operator's machine and sends the resulting refresh token to the deployment, which seals and stores it, instead of printing it for the operator to paste into a configuration file (#356).

  • Administering a deployment is the reference: every route, every --mode, the configuration, and what each failure means.

    One caveat, and it is a defect this release ships with: a deployment that sets HealthEndpoints:Enabled to false and enables this endpoint loses its application listener, because binding the administrative socket in code makes Kestrel ignore ASPNETCORE_HTTP_PORTS and only the probe path restates it. The process starts and serves the administrative port alone, and every MCP client is refused. #325 carries the fix. Until it lands, leave the probes enabled — the default — or state the application listener as a Kestrel:Endpoints entry.

Encryption at rest.

  • DataEncryption configures a key ring: one active key, any number of retained ones, each 32 bytes of material supplied by reference like every other credential, under which MailFathom seals what it stores. An absent section is a valid deployment that seals nothing, and rotation is moving ActiveKeyId while leaving the previous key configured, so nothing already sealed becomes unopenable (#338). ADR 0005 records the decision.
  • The refresh token an authorization server rotates is followed and stored sealed under that ring, in the new mailbox_refresh_tokens table, so a provider that issues a new refresh token on every exchange no longer strands an account at the next restart (#343).
  • Docker Compose, the Helm chart, and the native systemd unit each provision the key by the same mechanism they provision every other secret, and the guides state where the file goes in each (#354). Secret provisioning is the contract. Back the key up with the database: nothing in MailFathom regenerates it, and a database restored without its key restores nothing that was sealed under it.

Changed

  • MailSynchronization:Accounts:<n>:Secrets:Password is required only when the account's permitted mechanisms include a password mechanism. It was unconditionally required in 0.1.0, which every configuration written for 0.1.0 already satisfies; what changes is that an account authenticating with OAuth alone now configures no password at all (#306).

Security

  • A mailbox refresh token is held sealed in the database under the deployment's key ring rather than sitting in a configuration file or a secret file that nothing rotates, and the rotation an authorization server performs is followed rather than lost (#343).
  • The refresh token an authorization flow produces never reaches the operator's terminal: mfctl mailbox authorize sends it to the deployment over the administrative endpoint, so it is not in scrollback, in a shell history, or in a file somebody has to remember to delete (#356).
  • The administrative endpoint carries its own credentials, its own authorization servers, and its own TLS profiles, so granting somebody administrative access does not grant them the MCP surface and the reverse holds (#313, #317).

0.1.0 - 2026-08-02

The first public release, and the point at which MailFathom's four public surfaces begin to promise anything. There is no earlier release for this one to have changed, so every entry below is an addition rather than a difference.

What it is. A Model Context Protocol server for your own mail. It synchronizes IMAP mailboxes read-only into a local PostgreSQL copy and serves that copy to an MCP client as three tools, so a client can list, read, and search mail without a request ever reaching a mail server and without a message being marked as read.

The database schema. This release creates it. One baseline migration (#241, #127) builds the whole schema on an empty database, so there is no previous version to apply it beside and nothing of an earlier release's to deploy over. The migration must be applied before the host will serve: startup is gated on the schema and refuses to start against a database that is behind it, rather than migrating one out from under a running process. Applying the database schema records the apply path and the ordering a deployment follows.

Added

Mail synchronization.

  • Read-only IMAP synchronization of configured accounts and folders into a local PostgreSQL copy. Synchronization never sets the remote \Seen flag, and that invariant is proven against a real IMAP server rather than asserted (#13, #132).
  • Each configured account synchronizes on a schedule of its own, and a failure is isolated to the account and the folder it happened in rather than stopping the rest (#167).
  • Remote deletions and flag changes are reconciled back onto the local copy (#171).
  • Synchronization is bounded by a configured earliest received date, so an established mailbox is not backfilled in full on first run (#133).
  • Senders, recipients, subjects, and dates are read out of each stored message and indexed, so listing a folder by date reads an index rather than re-parsing stored mail (#98, #106).
  • Message text is indexed for full-text search as mail arrives, and anything already stored before that indexing existed is caught up in the background rather than left unsearchable (#110).
  • A folder renamed or re-created on the mail server is detected rather than silently followed (#94).
  • Everything MailFathom calls out to runs under a configurable timeout, a bounded retry with jittered backoff, and a circuit breaker, set per class of dependency (#83), and a dropped IMAP session is recovered under that same budget (#92).

The MCP tool contract. Served over the Streamable HTTP transport (#135). Every call reads the local copy only, so no tool request can wait on IMAP or change anything remotely, and every tool bounds how much mail one call can draw out.

  • list_emails returns a bounded keyset page of message summaries — at most 100, with no body text — filtered by account, folder, and date (#136).
  • get_email_content returns bounded bodies for at most 10 named emails under a shared character budget, and names attachments only when asked (#137, #153, #232).
  • search_emails returns a bounded ranked window of at most 50 lexical matches, each with bounded extracts (#138, #163).
  • Every descriptor declares readOnlyHint, destructiveHint, idempotentHint, and openWorldHint, so a client can judge a tool before calling it. No error and no log line carries a filter value, a mailbox address, a subject, body text, raw MIME, or an internal identifier; every published failure carries a five-digit error code instead (#111).

What protects that endpoint. It is disabled by default, and enabling it requires stating what a client presents.

  • Named, expiring API keys, and Origin validation for browser callers through configurable CORS (#169).
  • OAuth 2.1 access tokens from configured authorization servers, judged against the issuer, this resource, the required scopes, and an explicit list of authorized subjects — so signing in to the authorization server does not by itself grant a user this mailbox (#183).
  • HTTPS on operator-provided domains and certificates, with the material proven to load, to cover the stated domain, and not to have expired before any listener opens (#175).
  • Mutual TLS through named client-certificate profiles, proven against a real TLS handshake (#177, #196).
  • Per-client token-bucket and process-wide concurrency rate limits, enabled by default, so an endpoint is bounded whether or not anyone wrote a number (#176).
  • A per-account mail transport security policy decides what TLS an account's connections require (#58), and a host whose platform TLS policy refuses a mail server can be configured to reach it anyway, and says so when it does (#226).

The configuration schema. Every MailFathom section is bound strictly: a key the section does not define fails startup naming it, so a typo cannot silently leave a default in force, and a violated constraint fails startup with the configuration path in the message. The configuration reference is the whole surface, key by key, including which keys reload and which need a restart.

  • Secrets are supplied as references rather than inline values by default, so a plain-text credential where a reference belongs fails startup instead of authenticating (#64).
  • Certificate material and secrets are re-read behind unchanged references, so a renewal reaches the process without a restart (#73).
  • A mounted directory or file of JSON — a Kubernetes ConfigMap, a systemd drop-in — is a first-class configuration source (#168).
  • The deployment-wide privacy bounds on what a search result may quote, and on how much body text one read may return, are configuration rather than constants a caller could raise.

The deployment contract.

  • A multi-architecture container image for linux/amd64 and linux/arm64, published to ghcr.io and docker.io as one manifest list under one digest, under its immutable version tag with latest moved onto that same digest. The registry to pull from is whichever your environment already reaches (#240, #256, #281).
  • The Helm chart is published with the image, in the same run and at the same version, as an OCI artifact at oci://ghcr.io/krzysztof318/charts/mailfathom. Its appVersion is that release, so a chart states which application version it deploys without being unpacked, and it is listed on Artifact Hub (#281).
  • Every published artifact, image and chart alike, carries a signed provenance statement that gh attestation verify checks against this repository (#281).
  • Three supported installation shapes: Docker Compose, which provisions PostgreSQL for you; the Helm chart, which deliberately installs neither a database nor a Secret; and a native systemd process taking its secrets as systemd credentials (#180). Linux is the only platform this project supports.
  • Startup, readiness, and liveness probes on a listener of their own, with a configurable transport, which a deployment can turn off entirely (#198, #264).
  • Each release publishes an idempotent mailfathom-schema-<version>.sql artifact naming the migrations it carries and the checksum that identifies it (#258).
  • One version identifies a deployment wherever you look for it: the assemblies, the image's tags and labels, the packaged chart's appVersion, the line the host writes at startup, and the server's MCP initialize response all report the same number (#208).
  • OpenTelemetry logs, metrics, and traces export when OTEL_EXPORTER_OTLP_ENDPOINT is set, and host start, startup failure, and shutdown are reported from a bootstrap logger that exists before configuration does (#89).
  • Every published artifact carries LICENSE and NOTICE (#172). MailFathom is licensed under Apache-2.0, and THIRD_PARTY_LICENSES.md registers every dependency it ships beside (#173).