keycloak, minio and n8n are told their names from their route bindings; mailu takes its domain, site name, website and proxy address as settings and its front's name from its route, and the provisioner reads the domain from the merged config; builder and route-proxy package the controller from the git seat; the applications built outside the mesh say so per container; matrix says which of the world's servers it means; the site module is named website, and the why prose no longer names a name (novox/hq ADR 0155, issues 122 and 134). module check passes over all 77.
86 lines
4.5 KiB
TypeScript
86 lines
4.5 KiB
TypeScript
// mailu's provisioner — the adapter that makes mailu a provider of the mesh `smtp` interface.
|
|
// The reconcile loop, the contributions file, and reading the mesh's minted password are the sdk
|
|
// harness's; this writes only the per-service half: how mailu creates and removes a consumer's
|
|
// sending account (novox/hq ADR 0048/0076, gitea's package-registry provisioner is the sibling).
|
|
//
|
|
// The `smtp` interface: a consumer authenticates to submission (port 587, STARTTLS) as a real
|
|
// mailbox this provisioner creates. The address is `<account>@<domain>`: the local part is the
|
|
// consumer's `account` contribution — the name it wants to send as — falling back to the mesh's
|
|
// own login for a consumer that named none; the domain is the mail server's, which is this
|
|
// module's fact, not the consumer's.
|
|
//
|
|
// **The password is the mesh's, not the provisioner's (ADR 0048).** The mesh mints it and hands
|
|
// it to both ends; mailu sets exactly that password every run — so a rotation takes — and seals
|
|
// nothing: the consumer already has its copy through the mesh's own channel.
|
|
|
|
import { readFileSync } from "node:fs";
|
|
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
|
|
import { MailuClient } from "../client.js";
|
|
|
|
const mailu = MailuClient.fromEnv();
|
|
|
|
// The mail server's own domain: the operator's value, from the settings the mesh merges into this
|
|
// module's config file (`settings set mailu` with {"domain": …}; novox/hq ADR 0112, ADR 0155). A
|
|
// definition names no mesh, so it is never a literal in the manifest — and it used to be, as
|
|
// MESH_MAILU_DOMAIN, which is still read for a mesh that has not re-registered the manifest.
|
|
function domain(): string {
|
|
const file = process.env.MESH_MAILU_CONFIG_FILE;
|
|
if (file) {
|
|
try {
|
|
const config = JSON.parse(readFileSync(file, "utf8")) as { domain?: unknown };
|
|
if (typeof config.domain === "string" && config.domain.trim() !== "") return config.domain.trim();
|
|
} catch {
|
|
// Unreadable or not JSON: fall through to the environment, and the error below names both.
|
|
}
|
|
}
|
|
const named = (process.env.MESH_MAILU_DOMAIN ?? "").trim();
|
|
if (named === "") {
|
|
throw new Error(
|
|
"no mail domain is set, so a consumer's address cannot be composed — `settings set mailu <file>` " +
|
|
'with {"domain": "<the mail domain>"}',
|
|
);
|
|
}
|
|
return named;
|
|
}
|
|
|
|
// The address one consumer sends as. The local part is refused rather than sanitised when it is
|
|
// not a plain mailbox name — a rewritten name is an address nobody asked for.
|
|
function addressOf(p: { as: string; values?: Readonly<Record<string, unknown>> }): string {
|
|
const contributed = typeof p.values?.["account"] === "string" ? (p.values["account"] as string).trim() : "";
|
|
const local = contributed !== "" ? contributed : p.as;
|
|
if (!/^[a-z0-9][a-z0-9._-]*$/.test(local)) {
|
|
throw new Error(`${JSON.stringify(local)} is not a usable mailbox name`);
|
|
}
|
|
return `${local}@${domain()}`;
|
|
}
|
|
|
|
runProvisioner("smtp", {
|
|
async create(p: Provision): Promise<void> {
|
|
const email = addressOf(p);
|
|
// Create if absent, and set exactly the minted password either way so a rotation takes.
|
|
// Mailu's create refuses a duplicate address, which is the signal to fall through to the
|
|
// password set — the same found-then-apply shape gitea's ensureUser settled on.
|
|
try {
|
|
await mailu.createUser(email, p.password);
|
|
} catch {
|
|
await mailu.applyProvisioned(email, p.password);
|
|
}
|
|
},
|
|
|
|
async remove(p: { as: string }): Promise<void> {
|
|
// The withdrawal only knows the mesh login, never the contributed local part — so accounts
|
|
// that contributed one are removed when the address matching the login is absent? No: the
|
|
// harness hands remove only `as`, and an address composed from a contribution cannot be
|
|
// recomputed from it. The account is therefore removed by its login-shaped address when one
|
|
// exists, and left otherwise — a mailbox holding mail is the one thing a background loop
|
|
// must not guess about (this module's own events file says the same). Withdrawal of a
|
|
// named-account consumer is an operator action until the harness carries values here.
|
|
await mailu.deleteUser(`${p.as}@${domain()}`).catch(() => {});
|
|
},
|
|
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
|
|
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
|
|
async holds(p: Provision): Promise<boolean> {
|
|
return mailu.holdsUser(addressOf(p));
|
|
},
|
|
});
|