Table of Contents

The documentation site

The pages under docs/ are published as a browsable site at https://krzysztof318.github.io/MailFathom/. The site is generated from this repository by docfx, deployed by GitHub Pages, and rebuilt in full by two pushes: a merge to main, which moves latest, and a release tag, which adds that release's version to the selector. It is the tag rather than the release, because the version list below is read from the tags in the checkout rather than from GitHub's releases — and because a release: published trigger cannot start a run at all, the release being created by Release with GITHUB_TOKEN and GitHub starting no workflow from an event that token generated.

Nothing is authored on the site. A page is written here, reviewed in the same pull request as the behavior it describes, and published by the merge — which is the whole reason the site is generated from docs/ rather than kept in a wiki. A wiki is a second repository with no pull request and no review, so nothing would make a page change when the code it describes does, and the drift would be invisible until a reader hit it.

What the site carries

Everything under docs/ except the architectural decision records, plus two things from outside it:

On the site Where it comes from
The user guide, the operations pages, the feature pages, and the architecture pages docs/, unchanged
The API reference Generated from the XML documentation comments in src/, every project but AppHost
The changelog The repository-root CHANGELOG.md, published as it stands
The landing page docs/index.md, which exists for the site alone

Four kinds of file under docs/ are deliberately not published:

  • The architectural decision records. An ADR records why a decision was taken and is closed once accepted; it is not documentation of how MailFathom behaves, and a reader looking for the current shape of something should reach the page that describes it rather than the record of the argument behind it. They stay readable in the repository, and a page that cites one links to it as an absolute GitHub URL.
  • README.md, which is the index GitHub shows when somebody browses docs/ in the repository. On the site the navigation is the index, so publishing it would be a second one to keep in step.
  • Every AGENTS.md and CLAUDE.md, which are instructions to whoever is changing the repository rather than documentation of the product.
  • The toc.yml files, which are the navigation itself.

The architecture draft under specs/ is not published either, and is not under docs/ to begin with: the draft states intent, and every page on this site states fact.

What an agent reads

The same documentation is published a second time, in the form an AI agent uses. A rendered page is a template around the text; an agent wants the text, and it wants to know which page to fetch before it fetches anything. Three artifacts answer that, and scripts/write-docs-agent-artifacts.sh writes all three into every version the site carries, latest included:

Artifact What it is
llms.txt at the version's root The map: every published page, its title, and one line saying what it answers, in the order the navigation puts it. It follows the llms.txt convention — a heading, a summary, and one ## section per part of the documentation
<page>.md beside each rendered page The page's Markdown source, at the same address with a .md extension, so a link from the map fetches text rather than a template
llms-operator.txt and llms-mailbox-user.txt The user guide's two reading paths, each page of one path concatenated into one file: installing, starting, configuring a mailbox and administering for the first; connecting a client and using the tools for the second

The map is the navigation. A page's title and its place in the map are the name: and the position it already has in a toc.yml, and the line saying what it answers is a description: beside them — so a page joins the map by joining the navigation of its section, which is one file to write rather than two. That is also what stops the map from rotting: an entry cannot be forgotten, because there is no second list to forget it in.

Both halves of that agreement are checked, and the build refuses either way round. A published page the map does not list fails scripts/build-docs-site.sh, and so does a map entry the version carries no page for; a table-of-contents entry naming a page with no description: fails the same way, because a map of titles is the search over fragments the map exists to replace. scripts/test-agent-workflow.sh asserts each of those on every pull request, so the failure arrives before a publish rather than during one.

The map and the bundles are written by this repository rather than copied from it, so each one ends with the same three lines every file here carries — the copyright, the grant, and the repository URL — as prose rather than as a comment, because that is the form whatever reads them parses. A page's mirrored source carries none, for the reason the page itself carries none: a copy states what the original states.

Two things are deliberately not published here:

  • No bundle of the whole of docs/. It is roughly 1.8 MB of Markdown. Nothing loads that, so it would be an artifact to keep correct that no reader ever benefits from — the map exists precisely so that a whole-set fetch is never the way to an answer.
  • No Markdown for the API reference. It is generated from XML documentation comments into a thousand pages named after types, and the map links its introduction like any other page. The types themselves are read from src/.

A link inside these artifacts follows the rule the rest of this page states, resolved for where the artifact sits. A page's Markdown source keeps its links exactly as written, because the sources mirror the tree the pages came from. A bundle sits at the version's root rather than in users/, so scripts/rebase-markdown-links.sh resolves each relative link for that move — ../operations/mcp-endpoint.md in a page of the user guide becomes operations/mcp-endpoint.md — and leaves every absolute URL alone.

scripts/compose-docs-site.sh copies the default version's map to the site root, rebased into that version's directory, so an agent that asks for llms.txt without naming a version gets the release the site opens on. That copy names a version inside itself, unlike the stable addresses beside it, and it can: it is rewritten by every publish rather than written once into somebody else's page. A release built before these artifacts existed carries no map, and the site opens on the newest release — so the root map arrives with the first release that carries one, and the composition says so instead of failing a publish over a commit that could not have written it.

The version's own landing page links the map, which is what makes the artifacts reachable from the address every surface prints. docfx cannot resolve that link — the map is written into the build's output after docfx has finished — so scripts/build-docs-site.sh exempts exactly those targets from the link gate, and the artifact script's own check that every entry names a page the version carries is what covers them instead.

The mirror on Context7

MailFathom is registered with Context7 as /krzysztof318/mailfathom, so an agent that already has that connector reaches this documentation without being given an address. It is a mirror of the artifacts above rather than a second channel, and two properties are why: it indexes the default branch, so nothing binds an answer to the release a reader is running, and retrieval over chunks returns a rule without the pointer that completes it — these pages state a rule in one place and point at it from everywhere else, so a fragment of one is confidently incomplete rather than merely partial.

context7.json at the repository root is what bounds what it reads. Without it the service scans the whole tree, which here means the architectural decision records including superseded ones, the workflow contract every AGENTS.md carries, and the architecture draft under specs/ that states intent where every page here states fact — none of them product documentation, and each of them read as such by an agent that found it under this project's name. The file therefore names three of the four sections this site carries: the user guide, the operations pages, and the feature reference.

The architecture pages are left out, and that is a decision about the reader rather than about the pages. They are product documentation and the site publishes them as such; what they answer is where the boundaries of the code are drawn and why, which is a question somebody changing MailFathom asks with the repository open in front of them. The mirror answers an agent helping somebody install, configure, and use a deployment, and that agent has no more use for the project structure than it has for the API reference the map already leaves out. Widening the mirror to them is reversible and costs one entry; what it would buy has to be a reader who arrives at the architecture through a documentation lookup rather than through the repository.

Three things about how the file is matched are worth knowing before editing it, because none is a general JSON convention:

  • excludeFolders wins over folders, so a path named in both is excluded.
  • excludeFiles matches a bare filename, with no path and no pattern. That is what lets two entries — AGENTS.md and CLAUDE.md — cover every copy of them at any depth.
  • The repository's README.md is read whatever folders says. The parser keeps it deliberately, which is why the README states that this site is canonical and that the mirror can lag it.

A change here reaches the mirror at its next refresh, not at the merge. Context7 refreshes a library on a schedule set by its own popularity ranking, and a logged-in user can force one from the library page. So editing context7.json is half the act; the other half happens on the service, and until it does the mirror still carries what it last read.

Which versions are published, and which one opens

The site holds one directory per version and a landing page that sends a reader into one of them:

  • latest is built from main. It documents work that no release carries yet, so it is never what the site opens on — a page there can describe a setting the version you are running does not accept.
  • One directory per released minor line, at that line's newest patch, built from the release tag itself. 0.3.1 replaces 0.3.0 in the selector when it ships, because a patch exists to correct the line it belongs to.

The site opens on the newest release. latest is one click away in the selector every page carries, and every page outside the default version says which version it is and links to the current one.

A release tag is published only if it carries the site definition itself, so 0.1.0 and 0.2.0 are not on the site: they predate it. Every version on the site is built from its own commit, with the navigation, the pages, and the API surface that release actually shipped, which is what makes a page true of the release it is filed under — rendering an older tag's pages through today's configuration would produce navigation naming pages that release never had.

scripts/list-documented-versions.sh is where that policy lives, and it answers from the tags in the checkout it runs in. The whole site is a function of the repository rather than of the deployments that came before it: each publish rebuilds every version and replaces the site, so nothing accumulates and a version that stops being documented disappears on the next run.

The site's navigation is written rather than derived. docs/toc.yml is the header, and a toc.yml in each section directory is that section's sidebar, ordered by the question a reader arrives with rather than alphabetically.

A new page under docs/ joins the toc.yml of its section in the same change. A page in no table of contents is published and unreachable, which is worse than not publishing it, so scripts/test-agent-workflow.sh fails a page that no table of contents lists and an entry that names no page.

The href in a toc.yml is resolved against the file's own directory in docs/, not against the site — which is why these files live beside the pages they order rather than in docfx/.

Every entry naming a page or a section carries a description: as well, which is the line the map an agent reads carries for it. It says what the page answers rather than what it is about, in one sentence, because the agent reading it is deciding whether this is the page to fetch. A group heading inside a section names no page and carries none: what it groups is the pages under it.

Two forms, and which one to use is decided by whether the target is on the site:

  • A link to another published page stays relative, as ../operations/mcp-endpoint.md. docfx rewrites it to the published page, and GitHub resolves it to the file, so one link works in both renderings.
  • A link to anything the site does not carry is written as an absolute https://github.com/Krzysztof318/MailFathom URL — an ADR, the architecture draft, a deployment asset, a source file. A relative link to one of those resolves on GitHub and reaches a 404 on the site.
  • A link into the API reference is a xref:, naming the type or namespace rather than the generated file, as [Domain](xref:MailFathom.Domain). It is the one kind of link a refactor breaks without touching the page, so it resolves through docfx's cross-reference map and fails the build when the name stops being generated. On GitHub such a link renders as text rather than as a link, which is why only docs/api/index.md uses it: that page exists for the site.

scripts/build-docs-site.sh fails when docfx resolves no target for a link, so neither mistake reaches a reader: the site build runs on every pull request that changes docs/ or the site definition, and a broken link is a red check rather than something found later. CHANGELOG.md is the one file exempt from that check, because it is written by the release pull request and by nothing else — a link in it is corrected by the next release rather than by whoever notices it, and failing every documentation build in the meantime would stop the site over a file the build is not allowed to touch. The same rule reaches the XML documentation comments, where a <see href> to an ADR is an absolute URL for a second reason — a relative path from a source file resolves to nothing in an editor either.

The repository-root README.md is a separate case with a stricter rule, recorded in AGENTS.md: every link in it is absolute, because it is rendered outside the repository as well, and a link to a published page goes to the site rather than to the Markdown file behind it.

The addresses that outlive a release

A page has two addresses. …/MailFathom/v<version>/operations/mcp-endpoint.html is where it is served, and …/MailFathom/operations/mcp-endpoint.html — the same path without the version — is a redirect at the site root that lands on whichever version the site opens on, carrying the heading anchor with it.

The second is what anything outside the site links to, the root README.md above all. A link carrying a version would be wrong the day the next one ships, and one carrying latest would quietly opt a reader out of the release the site opens on, which is the whole point of opening on it.

scripts/compose-docs-site.sh writes one such redirect per page of the default version, so the set of stable addresses moves with the pages rather than being maintained beside them: a page that a release removes loses its address on the next publish. The API reference is left out — it is a thousand generated pages whose names are type names, nothing links into it by hand, and mirroring it would treble the file count of the site for nothing.

A running deployment is the exception, and it names a version deliberately. Four surfaces print the version directory rather than the version-agnostic address: the container image's org.opencontainers.image.documentation label, the chart's install notes, mfctl status, and the instructions an MCP session carries. Each of them knows a version at the moment it speaks, and what its reader wants is the pages for the release in front of them rather than the one the site currently opens on — an address that follows the newest release would show somebody running an older one a setting their deployment does not accept. A prerelease resolves to latest there, because a nightly is named after a release the site publishes nothing for yet and what it carries is main. The rule stands unchanged for everything written down: a link in a file names no version, because a file outlives the release it was written in.

Building it locally

scripts/build-docs-site.sh                 # artifacts/docs-site
dotnet docfx serve artifacts/docs-site     # http://localhost:8080

The build restores the solution, because generating the API reference loads every project through MSBuild, and then runs docfx once. Expect a few minutes the first time. While a page is being written, dotnet docfx docfx/docfx.json --serve rebuilds and serves in one step instead — that shorter loop runs docfx alone, so the artifacts an agent reads are absent from what it serves; scripts/write-docs-agent-artifacts.sh <directory> writes them into an already-built version in a second, without rebuilding anything.

docfx is pinned in .config/dotnet-tools.json and restored by dotnet tool restore like the other command-line tools, so no global install is involved. A locally built site carries no version selector: the selector reads a manifest that scripts/compose-docs-site.sh writes above the version directories, and a single build has nothing above it.

What the template adds

docfx/template/ is a thin layer over docfx's own modern template — the appearance is docfx's, deliberately, and what is added is behavior the pages need. The one appearance rule is the header logo, which modern does not size at all: its own logo is an SVG whose intrinsic size already fits a header, so a raster file arrives at whatever size it was saved at. Nothing in the build can see that — docfx renders a page without laying it out, so a logo that fits and one that covers the page produce the same output.

The selector itself sits at the right-hand end of the header, in front of the icon links. That is inside the element modern renders and re-renders — it writes the section links and the icon links there after the template's own module has run, and writes them again whenever the theme picker among them is used — so the selector is placed and then kept placed, from an observer that puts it back rather than from an insertion that happens once. Reaching that end of the header takes one rule as well: modern carries the icon links there with an auto margin, a flex row divides its free space equally among every auto margin on it rather than spending it at the first, and two of them therefore hold the selector halfway across the bar. The template's margin is withdrawn in main.css so that the selector's is the only claim on that space.

That is the general shape of what this template can get wrong. Everything it adds happens in the browser, after the build has finished and against files the build never reads, so a page that renders the selector and one that silently does not are the same output as far as every gate here is concerned. The site's appearance and its run-time behaviour are the parts of it verified by looking at the deployed site, and every defect found that way so far — a logo at its natural size, a selector missing from the two pages served from a version's own directory, the same selector missing again when that directory was addressed without its trailing slash, and a selector that reached the header but stopped halfway across it — was invisible to a green build.

What the template adds beyond that:

  • The version selector in the header, and the banner a page outside the default version carries. Both read versions.json from the site root at run time, so a version built months ago joins the selector correctly without being rebuilt.
  • A viewer for diagrams and images. Clicking a Mermaid diagram or an image opens it over the page, where the wheel and the buttons zoom about the pointer, a drag pans, a double click resets, and Esc closes. Pinch-zoom works the same way on a touch screen. A diagram is drawn at the width of the article, which has nothing to do with how much detail it holds, and this is what makes the detail reachable.

Both are written against the DOM the modern template produces, which re-renders the navigation bar and every Mermaid diagram after the page loads. The selector is therefore re-placed by an observer whenever the navigation bar is written again, and the viewer opens from a single delegated listener rather than from handlers bound to elements about to be replaced.

Publishing

.github/workflows/publish-documentation.yml runs on every push to main, on a pushed release tag, and on demand. It resolves the version list, builds each version in parallel, composes them into one tree, and deploys that tree with the repository's own Pages deployment — actions/deploy-pages, not a bot pushing to a branch. It needs no secret: the pages: write and id-token: write scopes on the deploying job are all it holds, and nothing in it writes to the repository.

A pull request that changes docs/ or the site definition builds latest and deploys nothing. A change under src/ does not trigger it: an XML comment docfx dislikes is a warning rather than a failure, so the build would add minutes to nearly every pull request the repository sees and catch nothing.

Pages itself is enabled once, in the repository settings, with Build and deployment → Source set to GitHub Actions. The workflow cannot enable it: the action that would needs a token with administration rights, which is exactly the kind of credential this workflow is built not to hold.

Recovering a failed publish

A failed publish is recovered by running the workflow again, never by re-running the failed job. Use the workflow_dispatch on main; the next merge does it as well, since a publish rebuilds the whole site from the repository and therefore carries whatever the failed run would have.

Re-running is not merely the slower option — it cannot succeed, and it leaves the run worse than it was. actions/upload-pages-artifact writes an artifact named github-pages, the one from the previous attempt stays attached to the same run, and actions/deploy-pages refuses to deploy when it finds two:

Multiple artifacts named "github-pages" were unexpectedly found for this workflow run. Artifact count is 2.

Each further attempt adds another, so the count only climbs. The error names an artifact and reads like a defect in the workflow, which is what makes this worth stating rather than leaving to be worked out from the message: the run that failed is spent, and the fix is a new one.

What fails a publish in the first place is usually the Pages queue rather than anything here — the deployment reports deployment_queued until actions/deploy-pages gives up and cancels it. The step therefore waits thirty minutes rather than the action's default of ten, which is enough for every degradation seen so far.

A slow queue is not something the repository can see from the outside, and it is worth knowing that GitHub's status page may not show it either: it read all systems operational throughout the degradation that prompted the thirty minutes, while two consecutive publishes were cancelled at exactly the old ten. So the evidence that this is the platform rather than the site is the deployment's own log — a step that reports deployment_queued on every poll and never advances has not been given anything to fail on.