mailu: the manifest matches the machine, provides smtp, and carries automx

Five gaps between the draft and what actually runs, each verified live
before being written down:

- front published bare 80 — the machine port Traefik holds; now the
  predecessor's own mappings (7080:80, 7443:443) plus the 110/143/995
  parity ports the draft dropped. Pruning legacy protocols is its own
  deliberate change, not a cutover side effect.
- TLS_FLAVOR said cert, which nothing supplies; live is letsencrypt —
  mailu runs its own certbot, state already on disk, HTTP-01 answered
  through a path-scoped route contribution (priority above the web one).
- the web route said http:7080, the redirect-loop shape; it now says
  what the hand-authored file always knew: https 7443, insecure.
- automx was absent entirely: the autoconfig responder is now a second
  artifact (its Containerfile moved in from the predecessor's images
  dir, base declared per ADR 0097), a container on a real data dir —
  the anonymous-volume loss of 2026-08-10 stays fixed — and the three
  public names are route contributions.
- and the reason this moved ahead of de-spiegel: mailu now provides
  smtp. A consumer contributes the account it sends as; the provisioner
  creates <account>@<domain> via the admin API and applies the minted
  password every reconcile (ADR 0048). The domain is served on the
  binding so a consumer composes its own login from mesh facts.

route-adapter learns to say no: a contribution over https, scoped to a
path, or carrying a policy is skipped aloud rather than written into a
file shape that cannot say it — plain http into a TLS listener was the
concrete wrong file this prevents. The hand-authored files keep covering
those routes until the mesh's own proxy takes over, exactly as today.
This commit is contained in:
2026-09-25 22:39:29 +02:00
parent 3875987656
commit ed5d1386ce
12 changed files with 473 additions and 9 deletions
+18
View File
@@ -151,6 +151,24 @@ export function routesFrom(document: unknown, machine: string): { routes: Route[
skipped.push(`${from} asked for ${JSON.stringify(name)}, which is not a name this can write`);
continue;
}
// What this adapter's one file shape cannot say, it skips aloud rather than approximating:
// a backend over its own TLS (the file would send plain http into a TLS listener), a
// path-scoped or refusing or redirecting rule (the file routes whole hosts). The mesh's own
// proxy serves all of these the day it takes over; until then the predecessor's hand-authored
// files keep covering them, exactly as they do today.
const scheme = typeof entry.values?.["scheme"] === "string" ? (entry.values["scheme"] as string).trim().toLowerCase() : "";
if (scheme !== "" && scheme !== "http") {
skipped.push(`${from} asked for route ${name} over ${scheme}, which this file shape cannot say`);
continue;
}
if (typeof entry.values?.["path"] === "string" && (entry.values["path"] as string).trim() !== "") {
skipped.push(`${from} asked for route ${name} scoped to a path, which this file shape cannot say`);
continue;
}
if (entry.values?.["deny"] === true || typeof entry.values?.["redirect"] === "string") {
skipped.push(`${from} asked for route ${name} with a policy this file shape cannot say`);
continue;
}
const port = asPort(entry.values?.["port"]);
if (port === undefined) {
skipped.push(`${from} asked for route ${name} and gave no usable port`);
@@ -205,6 +205,27 @@ test("a contribution it cannot act on is skipped and named", async () => {
assert.deepEqual(routesFrom(undefined, machine).routes, []);
});
// What the file shape cannot say is skipped aloud, never approximated: plain http into a TLS
// listener, a whole-host file for a path-scoped rule, a proxying file for a refusal or redirect.
// The mesh's own proxy serves all of these the day it takes over; until then the predecessor's
// hand-authored files keep covering them.
test("a contribution the file shape cannot say is skipped and says which part", async () => {
const machine = defaults.machine;
const { routes, skipped } = routesFrom({ given: [
{ from: "mailu", values: { name: "mail.example", port: 7443, scheme: "https", insecure: true } },
{ from: "mailu", values: { name: "mail.example", port: 7080, path: "/.well-known/acme-challenge" } },
{ from: "gitea", values: { name: "git.example", path: "/api/internal", deny: true, priority: 100000 } },
{ from: "site", values: { name: "www.example", redirect: "https://example" } },
{ from: "mailu", values: { name: "autoconfig.example", port: 4243 } },
] }, machine);
assert.deepEqual(routes.map((r) => r.name), ["autoconfig.example"]);
assert.equal(skipped.length, 4);
assert.match(skipped[0]!, /over https/);
assert.match(skipped[1]!, /scoped to a path/);
assert.match(skipped[2]!, /scoped to a path/);
assert.match(skipped[3]!, /policy/);
});
// The directory is the predecessor's and the mesh only mounts it. Absent, there is nothing to write
// into — and writing anyway would put route files somewhere nothing reads, reporting success.
test("it refuses when the predecessor's directory is not there, and says why", async () => {