diff --git a/modules/mailu/client.ts b/modules/mailu/client.ts new file mode 100644 index 0000000..fef227f --- /dev/null +++ b/modules/mailu/client.ts @@ -0,0 +1,220 @@ +// 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 { 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"; + +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 url = env.MESH_MAILU_URL; + const 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 = 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, + }; +} diff --git a/modules/mailu/index.ts b/modules/mailu/index.ts new file mode 100644 index 0000000..3526dd7 --- /dev/null +++ b/modules/mailu/index.ts @@ -0,0 +1,61 @@ +// mailu's events. The tool runtime imports this once the broker is bound. It watches the mail +// server's accounts and announces what changed, so the rest of the mesh can react to a mailbox +// appearing or an alias being pointed somewhere new. +// +// Emits (novox/hq ADR 0046/0047): +// module.mailu.user.created / .deleted — a mailbox appeared or was removed +// module.mailu.alias.created / .deleted — an alias was added or removed +// +// Consumes: nothing. Mail accounts are not something the mesh should mutate in the background off +// another module's event — a wrong reaction here silently loses mail. mailu observes and announces; +// it does not act on what others do. If a real consumer is ever wanted, it is a deliberate addition. +// +// Detection is by polling the admin API and diffing, exactly as plex diffs its sessions: the change +// may have come from the web admin as easily as from a tool, and a diff catches both. The first +// look primes silently, or a restart would re-announce every existing account as freshly created. + +import { emit } from "@novox/mesh-sdk/events"; +import { MailuClient } from "./client.js"; + +const mailu = MailuClient.fromEnv(); + +// A generic diff over a keyed set: emit `created` for keys that appeared, `deleted` for keys that +// went away, and stay silent until primed. Users and aliases are the same shape of watch. +function watcher(created: string, deleted: string): (keys: string[]) => Promise { + const known = new Set(); + let primed = false; + return async (keys: string[]) => { + const now = new Set(keys); + if (primed) { + for (const key of now) if (!known.has(key)) await emit(created, { email: key }); + for (const key of known) if (!now.has(key)) await emit(deleted, { email: key }); + } + known.clear(); + for (const key of now) known.add(key); + primed = true; + }; +} + +const watchUsers = watcher("module.mailu.user.created", "module.mailu.user.deleted"); +const watchAliases = watcher("module.mailu.alias.created", "module.mailu.alias.deleted"); + +async function pollUsers(): Promise { + await watchUsers((await mailu.listUsers()).map((u) => u.email)); +} + +async function pollAliases(): Promise { + await watchAliases((await mailu.listAliases()).map((a) => a.email)); +} + +const tick = (fn: () => Promise, everyMs: number): void => { + const runOnce = (): void => void fn().catch((err) => console.error(`[mailu] ${err}`)); + setInterval(runOnce, everyMs); + runOnce(); +}; + +// Accounts and aliases change on human time, not machine time — a minute's latency is fine, and +// polling the admin API harder buys immediacy nobody asked for. +tick(pollUsers, 60_000); +tick(pollAliases, 60_000); + +console.log("[mailu] watching users and aliases"); diff --git a/modules/mailu/module.json b/modules/mailu/module.json index b1fe323..33aafe4 100644 --- a/modules/mailu/module.json +++ b/modules/mailu/module.json @@ -4,6 +4,12 @@ "capabilities": [ "container-runtime" ], + "emits": [ + "module.mailu.user.created", + "module.mailu.user.deleted", + "module.mailu.alias.created", + "module.mailu.alias.deleted" + ], "listens": [ { "port": 25, @@ -43,7 +49,8 @@ "own-secrets": { "secret-key": "/var/lib/mailu/secret-key.secret", "database": "/var/lib/mailu/database.secret", - "admin": "/var/lib/mailu/admin.secret" + "admin": "/var/lib/mailu/admin.secret", + "broker": "/var/lib/mailu/broker" }, "resources": [ { diff --git a/modules/mailu/package.json b/modules/mailu/package.json new file mode 100644 index 0000000..c640eac --- /dev/null +++ b/modules/mailu/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-mailu", + "version": "0.1.0", + "description": "mailu — mail server. Its API client, tools and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/mailu/tools/index.ts b/modules/mailu/tools/index.ts new file mode 100644 index 0000000..e948075 --- /dev/null +++ b/modules/mailu/tools/index.ts @@ -0,0 +1,154 @@ +// mailu's tools — moved here from the shared sdk (novox/hq ADR 0044), importing mailu's own client. +// They return structured data; the mesh serves them through the sdk's tool harness. Deletions are +// guarded by an explicit `confirm`, since removing a mailbox destroys its mail and cannot be undone. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { MailuClient } from "../client.js"; + +export function getMailuTools(mailu: MailuClient): ToolDefinition[] { + return [ + { + name: "mailu_list_users", + description: "List all email accounts on the mail server.", + input: {}, + run: async () => { + const users = await mailu.listUsers(); + return { count: users.length, users }; + }, + }, + { + name: "mailu_create_user", + description: "Create a new email account.", + input: { + email: { type: "string", description: "full address, e.g. user@example.com" }, + password: { type: "string", description: "the account's initial password" }, + }, + run: async (args) => { + const email = String(args.email); + await mailu.createUser(email, String(args.password)); + return { created: email }; + }, + }, + { + name: "mailu_change_password", + description: "Change the password of an email account.", + input: { + email: { type: "string", description: "full address of the account" }, + password: { type: "string", description: "the new password" }, + }, + run: async (args) => { + const email = String(args.email); + await mailu.changePassword(email, String(args.password)); + return { changed: email }; + }, + }, + { + name: "mailu_delete_user", + description: "Delete an email account. DESTRUCTIVE — removes the mailbox and all its mail.", + input: { + email: { type: "string", description: "full address of the account to delete" }, + confirm: { type: "boolean", description: "must be true — deletion is irreversible" }, + }, + run: async (args) => { + const email = String(args.email); + if (args.confirm !== true) return { aborted: "confirm must be true to delete a user", email }; + await mailu.deleteUser(email); + return { deleted: email }; + }, + }, + { + name: "mailu_list_aliases", + description: "List all email aliases and where they forward.", + input: {}, + run: async () => { + const aliases = await mailu.listAliases(); + return { count: aliases.length, aliases }; + }, + }, + { + name: "mailu_create_alias", + description: "Create an email alias forwarding to one or more destinations.", + input: { + localpart: { type: "string", description: "the part before @, e.g. 'sales'" }, + domain: { type: "string", description: "the domain, e.g. example.com" }, + destination: { type: "string", description: "destination address(es), comma-separated" }, + wildcard: { type: "boolean", description: "match any localpart under the domain (optional)" }, + }, + run: async (args) => { + const email = `${String(args.localpart)}@${String(args.domain)}`; + const destination = String(args.destination).split(",").map((d) => d.trim()).filter(Boolean); + await mailu.createAlias(email, destination, args.wildcard === true); + return { created: email, destination }; + }, + }, + { + name: "mailu_delete_alias", + description: "Delete an email alias.", + input: { + email: { type: "string", description: "the alias address to delete" }, + confirm: { type: "boolean", description: "must be true to confirm deletion" }, + }, + run: async (args) => { + const email = String(args.email); + if (args.confirm !== true) return { aborted: "confirm must be true to delete an alias", email }; + await mailu.deleteAlias(email); + return { deleted: email }; + }, + }, + { + name: "mailu_list_domains", + description: "List the mail domains the server handles.", + input: {}, + run: async () => { + const domains = await mailu.listDomains(); + return { count: domains.length, domains }; + }, + }, + { + name: "mailu_read_mail", + description: "Read recent messages in a user's mailbox — date, from, subject and a preview.", + input: { + user: { type: "string", description: "the mailbox owner's address" }, + mailbox: { type: "string", description: "which mailbox (default INBOX)" }, + limit: { type: "number", description: "how many recent messages (default 10)" }, + }, + run: async (args) => { + const user = String(args.user); + const mailbox = args.mailbox ? String(args.mailbox) : "INBOX"; + const messages = await mailu.readMail(user, mailbox, args.limit ? Number(args.limit) : 10); + return { user, mailbox, count: messages.length, messages }; + }, + }, + { + name: "mailu_search_mail", + description: "Search a user's mailbox by subject, sender, and/or date.", + input: { + user: { type: "string", description: "the mailbox owner's address" }, + subject: { type: "string", description: "substring to match in the subject (optional)" }, + from: { type: "string", description: "sender address or name to match (optional)" }, + since: { type: "string", description: "only messages since a date, e.g. 01-Jan-2026 (optional)" }, + limit: { type: "number", description: "maximum results (default 10)" }, + }, + run: async (args) => { + const user = String(args.user); + const criteria = { + subject: args.subject ? String(args.subject) : undefined, + from: args.from ? String(args.from) : undefined, + since: args.since ? String(args.since) : undefined, + }; + const messages = await mailu.searchMail(user, criteria, args.limit ? Number(args.limit) : 10); + return { user, count: messages.length, messages }; + }, + }, + ]; +} + +// The tools exist only when the admin API is configured; without it, mailu contributes none rather +// than failing the whole runtime. +registerModuleTools("mailu", (env) => { + try { + return getMailuTools(MailuClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/mailu/tsconfig.json b/modules/mailu/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/mailu/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "tools/index.ts"] +}