Nineteen modules gain a broker-bound runtime container that serves the module's tools under its own scoped account: bazarr, gitea, grafana, home-assistant, icecast, influxdb, jackett, keycloak, mailu, nextcloud, nodered, nzbget, ombi, photos, portainer, qbittorrent, searxng, tautulli, verdaccio. Config is the assignment's, not the manifest's (ADR 0051): each client's fromEnv overlays a settings-merged config file (MESH_<M>_CONFIG_FILE) over its env fallbacks, so URL and credentials come from `settings set`, with the URL defaulting to the server on the node. nextcloud and mailu also mount the docker socket for their exec-based tools. Proven in the mesh-lab: assigned-grafana green — settings deliver the URL and token, the runtime reads the merged config and serves grafana's tools under the scoped account, with nothing in the manifest. Two gaps this surfaced are filed as hq issues 008 (a provider runtime's seal key) and 009 (a settings change does not restart a container runtime). Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
230 lines
8.7 KiB
TypeScript
230 lines
8.7 KiB
TypeScript
// The Mailu API client — mailu's own code, living in the module (novox/hq ADR 0044). 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 0051): { 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 {}; }
|
|
}
|
|
|
|
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<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)}`);
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|