Files
mesh-catalog/modules/mailu/client.ts
T
jochen 0cb0f814b4 Every credential provider says whether it still holds a consumer
holds() for postgres, mssql, mongodb, minio, lavinmq, mosquitto, mailu
and gitea, so the harness makes again a login the backend lost (hq issue
120). Each checks the mesh's password as the consumer presents it, or
compares it read-only, and returns false only when the backend says the
credential is absent or wrong; an unreachable backend throws.
2026-09-26 01:09:52 +02:00

268 lines
11 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> {
await this.api("PATCH", `/user/${encodeURIComponent(email)}`, { raw_password: password });
}
async deleteUser(email: string): Promise<void> {
await this.api("DELETE", `/user/${encodeURIComponent(email)}`);
}
/**
* Whether a mailbox exists, is enabled, and accepts exactly this password. Read-only. Existence
* from the admin API; the password from `doveadm auth test` in the imap container, which is how
* the mail server itself authenticates, and which exits 77 for a refused login. The password
* reaches doveadm through the exec's environment, never the host's argv. An unreachable API or
* container rejects (novox/hq issue 120).
*/
async holdsUser(email: string, password: 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 };
if (user.enabled === false) return false;
try {
await run(
"docker",
["exec", "-e", "MESH_USER", "-e", "MESH_PW", this.imapContainer,
"sh", "-c", 'doveadm auth test "$MESH_USER" "$MESH_PW"'],
{ env: { ...process.env, MESH_USER: email, MESH_PW: password }, timeout: 30_000 },
);
return true;
} catch (err) {
if ((err as { code?: number }).code === 77) return false;
throw err;
}
}
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,
};
}