Files
mesh-catalog/modules/mailu/tools/index.ts
T
jochen 6049537655
mesh/merge-gate head 60495376551d merged into trunk 50861c74f8b4 — fail: builds mailu, sent nowhere; no bus step; 1 wait(s) for a person; a manifest the ch…
mesh/repo-check head 60495376551d merged into trunk 50861c74f8b4 — fail: its merge-check.sh failed: files asking for a setting without saying whether it is…
mesh/delivery superseded: a newer head of the same pull request
Judge mailu's deferred queue by this side's faults, not one dead recipient
A message to a far host that is down stays deferred for up to five days,
and mailu's unhealthy now pages the operator, so one dead recipient would
page for days. Overdue deferred mail is unhealthy only when a lookup failed
here (a name-service failure, as in issue 687) or when it reaches three or
more domains; one or two far domains are said in the answer, not alarmed.
2026-10-14 15:19:08 +02:00

178 lines
7.5 KiB
TypeScript

// mailu's tools — moved here from the shared sdk (novox/hq ADR 0039), 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";
import { dockerExec, mailFlows, DEFERRED_BOUND_SECONDS, PUBLIC_NAME, SPREAD_DOMAINS } from "../health.js";
/**
* Whether mail flows (novox/hq ADR 0240, issue 690): the tool the node-engine asks as the smtp container's
* check. It needs no admin API, only the postfix container, so it is served even when the API is not
* configured — a check that vanished with the API would say unhealthy for the wrong reason.
*/
export function getHealthTool(env: NodeJS.ProcessEnv): ToolDefinition {
const container = env.MESH_MAILU_SMTP_CONTAINER || "mailu-smtp";
return {
name: "mailu_health",
description:
`Whether mail flows through mailu, asked inside ${container}: postfix runs; no recipient has waited ` +
`deferred longer than ${DEFERRED_BOUND_SECONDS / 60} minutes on a name-service failure here, and overdue ` +
`deferred mail does not reach ${SPREAD_DOMAINS} or more domains (one or two far domains are said, not ` +
`unhealthy); and ${PUBLIC_NAME} resolves through the resolver postfix uses. Says counts, domains, the ` +
"oldest message's age and its reason (novox/hq ADR 0240, issue 690).",
input: {},
run: async () => mailFlows(dockerExec(), container),
};
}
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 admin tools exist only when the admin API is configured; without it, mailu contributes only its
// health tool rather than failing the whole runtime.
registerModuleTools("mailu", (env) => {
let tools: ToolDefinition[] = [];
try {
tools = getMailuTools(MailuClient.fromEnv(env));
} catch {
tools = [];
}
return [...tools, getHealthTool(env)];
});