// 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 { if (!file) return {}; try { return JSON.parse(readFileSync(file, "utf8")) as Record; } catch { return {}; } } 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), MESH_MAILU_API_KEY authenticates against it. Both are required — a * client with neither would only fail later, one call at a time, so it fails here instead. */ 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 ?? env.MESH_MAILU_API_KEY; if (!url || !apiKey) { throw new Error("Mailu is not configured — set MESH_MAILU_URL and MESH_MAILU_API_KEY"); } 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(method: string, path: string, body?: unknown): Promise { 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 { const users = await this.api("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 { await this.api("POST", "/user", { email, raw_password: password }); } async changePassword(email: string, password: string): Promise { await this.api("PATCH", `/user/${encodeURIComponent(email)}`, { raw_password: password }); } async deleteUser(email: string): Promise { await this.api("DELETE", `/user/${encodeURIComponent(email)}`); } async listAliases(): Promise { const aliases = await this.api("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 { await this.api("POST", "/alias", { email, destination, wildcard }); } async deleteAlias(email: string): Promise { await this.api("DELETE", `/alias/${encodeURIComponent(email)}`); } async listDomains(): Promise { const domains = await this.api("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 { 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 { 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 { 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> { const messages: Array> = []; let current: Record = {}; 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): 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, }; }