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.
260 lines
10 KiB
TypeScript
260 lines
10 KiB
TypeScript
// The Mailu API client — mailu's own code, living in the module (novox/hq ADR 0039). Moved out of
|
|
// the shared hal sdk, where a change to Mailu's surface rebuilt everything; here it rebuilds only
|
|
// mailu. Both this module's tools and its events entrypoint import it, and nothing outside mailu does.
|
|
//
|
|
// hal drove Mailu through its flask CLI over `docker compose exec` into the admin container. That
|
|
// coupling was to the container, not to Mailu: it needed a shell on the box. The module's real
|
|
// coupling is the admin REST API, so that is what this client speaks — a token and a URL, no shell.
|
|
// The one exception is reading a mailbox: the admin API exposes no message reads, so that alone
|
|
// falls back to `doveadm` inside the imap container, the operation the HTTP surface cannot serve.
|
|
|
|
import { execFile } from "node:child_process";
|
|
import { readFileSync } from "node:fs";
|
|
import { promisify } from "node:util";
|
|
|
|
const run = promisify(execFile);
|
|
|
|
export interface MailuUser {
|
|
email: string;
|
|
displayed_name?: string;
|
|
global_admin?: boolean;
|
|
enabled?: boolean;
|
|
forward_enabled?: boolean;
|
|
forward_destination?: string[];
|
|
quota_bytes?: number;
|
|
}
|
|
|
|
export interface MailuAlias {
|
|
email: string;
|
|
destination: string[];
|
|
wildcard?: boolean;
|
|
}
|
|
|
|
export interface MailuDomain {
|
|
name: string;
|
|
}
|
|
|
|
/** One parsed message from a doveadm fetch — the subset the read/search tools surface. */
|
|
export interface MailMessage {
|
|
date?: string;
|
|
from?: string;
|
|
subject?: string;
|
|
preview?: string;
|
|
}
|
|
|
|
// The fields we ask doveadm for, once — kept together so read and search stay identical in shape.
|
|
const FETCH_FIELDS = "date.received hdr.subject hdr.from body.snippet";
|
|
|
|
/** The settings-merged config the mesh delivers (novox/hq ADR 0046): { url, apiKey, token, password, user, ... }. */
|
|
function meshConfig(file?: string): Record<string, string> {
|
|
if (!file) return {};
|
|
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
|
|
catch { return {}; }
|
|
}
|
|
|
|
/** Read a secret from the file the mesh mounted it at (an own-secret), if the pointing env is set. */
|
|
function readSecret(path: string | undefined): string | undefined {
|
|
if (!path) return undefined;
|
|
try { return readFileSync(path, "utf8").trim() || undefined; }
|
|
catch { return undefined; }
|
|
}
|
|
|
|
export class MailuClient {
|
|
readonly baseUrl: string;
|
|
|
|
constructor(
|
|
url: string,
|
|
private readonly apiKey: string,
|
|
/** The container name doveadm runs in — reads bypass the API, so they need the box, not a token. */
|
|
private readonly imapContainer: string,
|
|
) {
|
|
this.baseUrl = url.replace(/\/$/, "");
|
|
}
|
|
|
|
/**
|
|
* Build from the module's resolved environment. MESH_MAILU_URL points at the admin API (e.g. the
|
|
* admin container's /api/v1); the token authenticates against it. The token is an own-secret,
|
|
* so it arrives as a file the mesh mounts (MESH_MAILU_API_KEY_FILE) — the deployed path — with a
|
|
* bare MESH_MAILU_API_KEY honoured only as a fallback for a hand-run instance. URL and token are
|
|
* both required: a client with neither would only fail later, one call at a time, so it fails here.
|
|
*/
|
|
static fromEnv(env: NodeJS.ProcessEnv = process.env): MailuClient {
|
|
const cfg = meshConfig(env.MESH_MAILU_CONFIG_FILE);
|
|
const url = cfg.url ?? env.MESH_MAILU_URL;
|
|
const apiKey = cfg.apiKey ?? readSecret(env.MESH_MAILU_API_KEY_FILE) ?? env.MESH_MAILU_API_KEY;
|
|
if (!url || !apiKey) {
|
|
throw new Error("Mailu is not configured — set MESH_MAILU_URL and the API token own-secret");
|
|
}
|
|
const imapContainer = cfg.container ?? env.MESH_MAILU_IMAP_CONTAINER ?? "mailu-imap";
|
|
return new MailuClient(url, apiKey, imapContainer);
|
|
}
|
|
|
|
// --- The admin REST API: users, aliases, domains. ---------------------------------------------
|
|
|
|
private async api<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
method,
|
|
headers: {
|
|
// Mailu's admin API takes the token directly in Authorization, no scheme prefix.
|
|
Authorization: this.apiKey,
|
|
Accept: "application/json",
|
|
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
|
|
},
|
|
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
});
|
|
if (!res.ok) throw new Error(`Mailu API ${method} ${path}: ${res.status} ${await res.text()}`);
|
|
// DELETE and some writes answer with an empty body or a bare string; guard the JSON parse.
|
|
const text = await res.text();
|
|
return (text ? JSON.parse(text) : undefined) as T;
|
|
}
|
|
|
|
async listUsers(): Promise<MailuUser[]> {
|
|
const users = await this.api<any[]>("GET", "/user");
|
|
return (users ?? []).map((u) => ({
|
|
email: u.email,
|
|
displayed_name: u.displayed_name,
|
|
global_admin: u.global_admin,
|
|
enabled: u.enabled,
|
|
forward_enabled: u.forward_enabled,
|
|
forward_destination: u.forward_destination,
|
|
quota_bytes: u.quota_bytes,
|
|
}));
|
|
}
|
|
|
|
/** Create a mailbox. Mailu wants the full address and the plaintext password it will hash. */
|
|
async createUser(email: string, password: string): Promise<void> {
|
|
await this.api("POST", "/user", { email, raw_password: password });
|
|
}
|
|
|
|
async changePassword(email: string, password: string): Promise<void> {
|
|
// enabled: a disabled mailbox is what the provisioner's check reports as lost, so applying the
|
|
// mesh's password again also enables it; otherwise the two would disagree for ever.
|
|
await this.api("PATCH", `/user/${encodeURIComponent(email)}`, { raw_password: password, enabled: true });
|
|
}
|
|
|
|
async deleteUser(email: string): Promise<void> {
|
|
await this.api("DELETE", `/user/${encodeURIComponent(email)}`);
|
|
}
|
|
|
|
/**
|
|
* Whether a mailbox exists and is enabled. Read-only, through the admin API.
|
|
*
|
|
* **The password is not checked.** Mailu authenticates in its admin service, behind the front;
|
|
* the imap server's own password database accepts any password from Mailu's subnet, so asking it
|
|
* (`doveadm auth test`) proves nothing, or refuses everyone. A lost or disabled mailbox is caught;
|
|
* a password changed by hand is not (novox/hq issue 120).
|
|
*/
|
|
async holdsUser(email: string): Promise<boolean> {
|
|
const res = await fetch(`${this.baseUrl}/user/${encodeURIComponent(email)}`, {
|
|
headers: { Authorization: this.apiKey, Accept: "application/json" },
|
|
});
|
|
if (res.status === 404) return false;
|
|
if (!res.ok) throw new Error(`Mailu API GET /user/${email}: ${res.status} ${await res.text()}`);
|
|
const user = (await res.json()) as { enabled?: boolean };
|
|
return user.enabled !== false;
|
|
}
|
|
|
|
|
|
async listAliases(): Promise<MailuAlias[]> {
|
|
const aliases = await this.api<any[]>("GET", "/alias");
|
|
return (aliases ?? []).map((a) => ({
|
|
email: a.email,
|
|
// The API returns destination as a comma-joined string on some versions, a list on others.
|
|
destination: Array.isArray(a.destination)
|
|
? a.destination
|
|
: String(a.destination ?? "").split(",").map((d: string) => d.trim()).filter(Boolean),
|
|
wildcard: a.wildcard,
|
|
}));
|
|
}
|
|
|
|
async createAlias(email: string, destination: string[], wildcard = false): Promise<void> {
|
|
await this.api("POST", "/alias", { email, destination, wildcard });
|
|
}
|
|
|
|
async deleteAlias(email: string): Promise<void> {
|
|
await this.api("DELETE", `/alias/${encodeURIComponent(email)}`);
|
|
}
|
|
|
|
async listDomains(): Promise<MailuDomain[]> {
|
|
const domains = await this.api<any[]>("GET", "/domain");
|
|
return (domains ?? []).map((d) => ({ name: d.name }));
|
|
}
|
|
|
|
// --- Reading mail: doveadm, because the admin API has no message reads. -----------------------
|
|
|
|
/** Recent messages in a mailbox, newest last, capped to `limit`. */
|
|
async readMail(user: string, mailbox = "INBOX", limit = 10): Promise<MailMessage[]> {
|
|
const messages = await this.doveadmFetch(user, ["mailbox", mailbox]);
|
|
return messages.slice(-limit);
|
|
}
|
|
|
|
/**
|
|
* Search a mailbox by subject, sender and/or date. doveadm fetch takes a search query directly,
|
|
* so we build one from whichever criteria were given — `all` when none were, to avoid an empty
|
|
* query that would match nothing.
|
|
*/
|
|
async searchMail(
|
|
user: string,
|
|
criteria: { subject?: string; from?: string; since?: string },
|
|
limit = 10,
|
|
): Promise<MailMessage[]> {
|
|
const query: string[] = [];
|
|
if (criteria.subject) query.push("subject", criteria.subject);
|
|
if (criteria.from) query.push("from", criteria.from);
|
|
if (criteria.since) query.push("since", criteria.since);
|
|
if (query.length === 0) query.push("all");
|
|
const messages = await this.doveadmFetch(user, query);
|
|
return messages.slice(-limit);
|
|
}
|
|
|
|
private async doveadmFetch(user: string, query: string[]): Promise<MailMessage[]> {
|
|
const output = await run(
|
|
"docker",
|
|
["exec", "-i", this.imapContainer, "doveadm", "fetch", "-u", user, FETCH_FIELDS, ...query],
|
|
{ timeout: 30_000 },
|
|
)
|
|
.then((r) => r.stdout)
|
|
// An empty mailbox is not an error; doveadm says so on stderr and exits non-zero.
|
|
.catch((e: { stderr?: string; message?: string }) => {
|
|
const text = `${e.stderr ?? ""}${e.message ?? ""}`;
|
|
if (text.includes("no matching mails")) return "";
|
|
throw e;
|
|
});
|
|
return parseDoveadmFetch(output).map(toMailMessage);
|
|
}
|
|
}
|
|
|
|
// doveadm fetch prints one record per message, records separated by a blank line (a form feed in
|
|
// some builds), each field on its own `name: value` line. A folded value continues on later lines.
|
|
function parseDoveadmFetch(output: string): Array<Record<string, string>> {
|
|
const messages: Array<Record<string, string>> = [];
|
|
let current: Record<string, string> = {};
|
|
for (const line of output.split("\n")) {
|
|
if (line === "" || line === "\f") {
|
|
if (Object.keys(current).length) {
|
|
messages.push(current);
|
|
current = {};
|
|
}
|
|
continue;
|
|
}
|
|
const colonIdx = line.indexOf(": ");
|
|
if (colonIdx > 0) {
|
|
const key = line.slice(0, colonIdx);
|
|
const value = line.slice(colonIdx + 2);
|
|
current[key] = current[key] ? `${current[key]}\n${value}` : value;
|
|
}
|
|
}
|
|
if (Object.keys(current).length) messages.push(current);
|
|
return messages;
|
|
}
|
|
|
|
function toMailMessage(m: Record<string, string>): MailMessage {
|
|
const preview = m["body.snippet"];
|
|
return {
|
|
date: m["date.received"]?.trim(),
|
|
from: m["hdr.from"]?.trim(),
|
|
subject: m["hdr.subject"]?.trim(),
|
|
preview: preview ? preview.trim().slice(0, 200) : undefined,
|
|
};
|
|
}
|