create re-enables what holds refuses (mssql login, mosquitto client, mailu mailbox, gitea user) and clears an expired postgres password, so no disabled account loops. mssql and mongodb checks take the password from the environment, never argv; mosquitto_ctrl failures no longer repeat -P. mosquitto reads 'could not ask' as an error, not absence. mailu checks existence and enabled only: its imap passdb cannot verify a password. mssql checks the user's SID; gitea pages teams at 50.
71 lines
3.8 KiB
TypeScript
71 lines
3.8 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 { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
|
|
import { MailuClient } from "../client.js";
|
|
|
|
const mailu = MailuClient.fromEnv();
|
|
|
|
// The mail server's own domain. From the environment the manifest composes, because the client's
|
|
// config file carries the admin API's coordinates, not the mail domain.
|
|
function domain(): string {
|
|
const named = (process.env.MESH_MAILU_DOMAIN ?? "").trim();
|
|
if (named === "") {
|
|
throw new Error("MESH_MAILU_DOMAIN is not set, so a consumer's address cannot be composed");
|
|
}
|
|
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.changePassword(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));
|
|
},
|
|
});
|