Feature 57 (config and provisioning): modules declare config and provisions, their files rendered from templates; synced with main (issues of feature 57, 874)

This commit is contained in:
2026-10-16 07:37:43 +00:00
parent 1a2c917abf
commit 9652b2d5ce
771 changed files with 52063 additions and 1774 deletions
+2
View File
@@ -0,0 +1,2 @@
INITIAL_ADMIN_PW=${secret:admin}
API_TOKEN=${secret:api-token}
+1
View File
@@ -0,0 +1 @@
{}
+5
View File
@@ -0,0 +1,5 @@
DB_FLAVOR=postgresql
DB_HOST=${bound:postgres-database:at}:${bound:postgres-database:port}
DB_USER=${bound:postgres-database:as}
DB_NAME=${bound:postgres-database:as}
DB_PW=${secret:postgres-database}
+78
View File
@@ -0,0 +1,78 @@
ADMIN_ADDRESS=mailu-admin
ANTISPAM_ADDRESS=mailu-antispam
ANTIVIRUS_ADDRESS=mailu-antivirus
IMAP_ADDRESS=mailu-imap
SMTP_ADDRESS=mailu-smtp
FRONT_ADDRESS=mailu-front
WEBMAIL_ADDRESS=mailu-webmail
WEBDAV_ADDRESS=mailu-webdav
REDIS_ADDRESS=mailu-redis
PORTS=25,80,443,465,993,995,4190,110,143,587
DOMAIN=${config:domain}
HOSTNAMES=${bound:route:name-web}
POSTMASTER=admin
SITENAME=${config:sitename}
WEBSITE=${config:website}
TLS_FLAVOR=letsencrypt
SUBNET=192.168.203.0/24
COMPOSE_PROJECT_NAME=mailu
ANTIVIRUS=clamav
WEBMAIL=roundcube
WEBDAV=radicale
FETCHMAIL_ENABLED=True
FETCHMAIL_DELAY=600
ADMIN=true
WEB_ADMIN=/admin
WEB_WEBMAIL=/webmail
WEBROOT_REDIRECT=/webmail
API=true
WEB_API=/api
AUTH_RATELIMIT_IP=6000/hour
AUTH_RATELIMIT_USER=1000/day
CREDENTIAL_ROUNDS=12
PASSWORD_SCHEME=PBKDF2
DISABLE_STATISTICS=True
MESSAGE_SIZE_LIMIT=50000000
MESSAGE_RATELIMIT=200/day
RECIPIENT_DELIMITER=+
POSTFIX_MYNETWORKS=127.0.0.0/8 [::1]/128
RELAYNETS=
RELAYHOST=
REJECT_UNLISTED_RECIPIENT=
DB_FLAVOR=postgresql
INITIAL_ADMIN_ACCOUNT=admin
INITIAL_ADMIN_DOMAIN=${config:domain}
INITIAL_ADMIN_MODE=ifmissing
SMTP_PORT=25
SMTPS_PORT=465
SUBMISSION_PORT=587
POP3_PORT=110
POP3S_PORT=995
IMAP_PORT=143
IMAPS_PORT=993
HTTP_PORT=7080
HTTPS_PORT=7443
AUTOMX_PORT=4243
AMX_SMTP_ADDRESS=${bound:route:name-web}
AMX_SMTP_PORT=587
AMX_IMAP_ADDRESS=${bound:route:name-web}
AMX_IMAP_PORT=143
AMX_MAIL_DOMAINS=${config:domain}
DMARC_RUA=admin
DMARC_RUF=admin
LETSENCRYPT_SHORTCHAIN=True
TZ=Etc/UTC
LOG_LEVEL=INFO
WELCOME=false
REAL_IP_HEADER=X-Real-IP
REAL_IP_FROM=${config:proxy-address}
COMPRESSION=
COMPRESS_LEVEL=
COMPRESSION_LEVEL=
BIND_ADDRESS4=127.0.0.1
BIND_ADDRESS6=::1
MAILU_VERSION=1.9
DOCKER_ORG=mailu
DOCKER_PREFIX=
WELCOME_SUBJECT=Welcome to your new email account
WELCOME_BODY=Welcome to your new email account, if you can read this, then it is configured properly!
+1
View File
@@ -0,0 +1 @@
SECRET_KEY=${secret:secret-key}
+216
View File
@@ -0,0 +1,216 @@
// Whether mail flows through mailu, as the node-engine asks it every interval (novox/hq ADR 0240, kind
// `tool`; issue 690). Every container read healthy on 2026-10-14 while postfix could not resolve a name:
// inbound mail was refused with 450 and outbound mail sat in the deferred queue for days. The parts'
// checks judge parts; this judges the flow, from inside the postfix container, the way postfix sees it:
//
// 1. postfix runs (`postfix status`, standing in for the image's own check, `! /usr/libexec/postfix/master
// -t`, which this one replaces);
// 2. the deferred queue (`postqueue -j`) shows no fault of this side: a recipient deferred longer than the
// bound because a lookup failed here, or overdue mail to three or more domains. One or two far domains
// that do not answer are theirs, and can stay deferred for days: said, never unhealthy, so one dead
// recipient does not page the operator for days;
// 3. a public mail host's name resolves through the resolver postfix is given: a resolver that
// SERVFAILs refuses inbound mail before anything reaches the queue.
//
// The answer is the health answer every health tool gives (mesh-sdk go/health): `state`, `healthy` beside
// it for an older reader, and `why` in words — counts, the oldest message's age and its first reason —
// never a bare false.
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const run = promisify(execFile);
/** How long a message may wait in the deferred queue before mail is said not to flow. */
export const DEFERRED_BOUND_SECONDS = 30 * 60;
/** The public name resolved through postfix's resolver: gmail's mail exchanger, which mail needs. */
export const PUBLIC_NAME = "gmail-smtp-in.l.google.com";
// The lookup postfix's own would make, through the resolver the container is given; a failure is said on one
// line ("gaierror: [Errno -3] Try again" is what a SERVFAIL reads as), never as a traceback.
const RESOLVE = [
"import socket, sys",
"try:",
` socket.getaddrinfo(${JSON.stringify(PUBLIC_NAME)}, 25)`,
"except Exception as e:",
" sys.exit(type(e).__name__ + ': ' + str(e))",
].join("\n");
/** Overdue mail to this many recipient domains or more is our side's fault, not one far server's. */
export const SPREAD_DOMAINS = 3;
/**
* What postfix writes when its own lookups fail, as on 2026-10-14 (issue 687): "Host or domain name not
* found. Name service error for name=gmail.com type=MX: Host not found, try again", or "Temporary lookup
* failure". A far server that is down reads otherwise ("Connection timed out", "Connection refused").
*/
export const LOCAL_LOOKUP_FAILURE =
/Name service error|Temporary lookup failure|Host or domain name not found.*try again/is;
/** The domain of an address, lower-cased; "(no recipient)" when there is none. */
function domainOf(address?: string): string {
const at = address?.lastIndexOf("@") ?? -1;
return address && at >= 0 ? address.slice(at + 1).toLowerCase() : "(no recipient)";
}
/** Runs a command in a container: its stdout, or a rejection carrying stderr. */
export type Exec = (container: string, command: string[]) => Promise<string>;
/** The health answer (mesh-sdk go/health): healthy, or unhealthy with why. */
export interface HealthAnswer {
state: "healthy" | "unhealthy";
healthy: boolean;
why: string;
queue?: {
messages: number;
deferred: number;
overdue: number;
oldestDeferredSeconds?: number;
overdueDomains?: string[];
nameServiceFailures?: number;
};
}
/** One message as `postqueue -j` prints it, one JSON object a line (postfix 3.1 and later). */
interface Queued {
queue_name?: string;
queue_id?: string;
arrival_time?: number;
recipients?: Array<{ address?: string; delay_reason?: string }>;
}
/** docker exec, bounded: a hung container is an answer too. */
export function dockerExec(timeoutMs = 10_000): Exec {
return async (container, command) =>
(await run("docker", ["exec", container, ...command], { timeout: timeoutMs })).stdout;
}
/** What a failed exec said, on one line: its stderr when it has one, else its message. */
function said(e: unknown): string {
const err = e as { stderr?: string; message?: string; killed?: boolean; signal?: string };
if (err?.killed || err?.signal === "SIGTERM") return "it did not answer in time";
const text = (err?.stderr ?? "").trim() || (err?.message ?? String(e)).trim();
return text.split("\n").map((l) => l.trim()).filter(Boolean)[0] ?? "it failed and said nothing";
}
/** An age in words: "2 days 3 hours", "45 minutes". */
export function age(seconds: number): string {
const s = Math.max(0, Math.floor(seconds));
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60);
const unit = (n: number, w: string) => `${n} ${w}${n === 1 ? "" : "s"}`;
if (d > 0) return h > 0 ? `${unit(d, "day")} ${unit(h, "hour")}` : unit(d, "day");
if (h > 0) return m > 0 ? `${unit(h, "hour")} ${unit(m, "minute")}` : unit(h, "hour");
return unit(m, "minute");
}
function parseQueue(output: string): Queued[] {
const queued: Queued[] = [];
for (const line of output.split("\n")) {
const text = line.trim();
if (!text.startsWith("{")) continue; // "Mail queue is empty" and the like
queued.push(JSON.parse(text) as Queued);
}
return queued;
}
/**
* Judge whether mail flows. `now` is in seconds since the epoch. Every part is asked even when one
* fails, so the answer says everything that is wrong at once.
*/
export async function mailFlows(
exec: Exec,
container: string,
now: number = Date.now() / 1000,
boundSeconds: number = DEFERRED_BOUND_SECONDS,
): Promise<HealthAnswer> {
const faults: string[] = [];
const [status, queue, resolve] = await Promise.allSettled([
exec(container, ["postfix", "status"]),
exec(container, ["postqueue", "-j"]),
exec(container, ["python3", "-c", RESOLVE]),
]);
if (status.status === "rejected") {
faults.push(`postfix in ${container} does not run: ${said(status.reason)}.`);
}
let summary: HealthAnswer["queue"];
const notes: string[] = [];
if (queue.status === "rejected") {
faults.push(`postfix's queue in ${container} could not be read: ${said(queue.reason)}.`);
} else {
let queued: Queued[] = [];
try {
queued = parseQueue(queue.value);
} catch (e) {
faults.push(`postfix's queue in ${container} printed a line that is not JSON: ${said(e)}.`);
}
const deferred = queued.filter((q) => q.queue_name === "deferred");
const overdue = deferred
.filter((q) => typeof q.arrival_time === "number" && now - q.arrival_time > boundSeconds)
.sort((a, b) => (a.arrival_time ?? 0) - (b.arrival_time ?? 0));
const oldest = deferred.reduce<number | undefined>(
(o, q) => (typeof q.arrival_time === "number" && (o === undefined || q.arrival_time < o) ? q.arrival_time : o),
undefined,
);
// Each overdue recipient, oldest message first: what our side is judged by, not one far host.
const late = overdue.flatMap((q) =>
(q.recipients?.length ? q.recipients : [{}]).map((r) => ({
to: r.address,
domain: domainOf(r.address),
reason: r.delay_reason?.trim() || "postfix gave no reason",
waited: now - (q.arrival_time ?? now),
})),
);
const lookups = late.filter((r) => LOCAL_LOOKUP_FAILURE.test(r.reason));
const domains = [...new Set(late.map((r) => r.domain))];
summary = {
messages: queued.length,
deferred: deferred.length,
overdue: overdue.length,
...(oldest !== undefined ? { oldestDeferredSeconds: Math.max(0, Math.floor(now - oldest)) } : {}),
...(domains.length > 0 ? { overdueDomains: domains } : {}),
...(lookups.length > 0 ? { nameServiceFailures: lookups.length } : {}),
};
const one = (r: (typeof late)[number]) =>
`the oldest, ${age(r.waited)} old${r.to ? ` to ${r.to}` : ""}, waits because: ${r.reason}`;
const bound = age(boundSeconds);
if (lookups.length > 0) {
faults.push(
`${lookups.length} deferred ${lookups.length === 1 ? "recipient has" : "recipients have"} waited longer ` +
`than ${bound} on a name-service failure, which is this server's resolver; ${one(lookups[0])}.`,
);
} else if (domains.length >= SPREAD_DOMAINS) {
faults.push(
`${overdue.length} of ${deferred.length} deferred messages have waited longer than ${bound}, to ` +
`${domains.length} domains (${domains.join(", ")}): more than one far server's fault; ${one(late[0])}.`,
);
} else if (late.length > 0) {
notes.push(
`${overdue.length} deferred ${overdue.length === 1 ? "message has" : "messages have"} waited longer than ` +
`${bound}, only to ${domains.join(" and ")}, which reads as the far side's: ${one(late[0])}.`,
);
}
}
if (resolve.status === "rejected") {
faults.push(`${container} cannot resolve ${PUBLIC_NAME} through its resolver: ${said(resolve.reason)}.`);
}
if (faults.length > 0) {
const why = [`Mail does not flow.`, ...faults, ...notes].join(" ");
return { state: "unhealthy", healthy: false, why, queue: summary };
}
const waiting = !summary || summary.deferred === 0
? " The deferred queue is empty."
: notes.length > 0
? ` ${notes.join(" ")}`
: ` ${summary.deferred} deferred, the oldest ${age(summary.oldestDeferredSeconds ?? 0)} old, within ${age(boundSeconds)}.`;
return {
state: "healthy",
healthy: true,
why: `Mail flows: postfix runs, ${PUBLIC_NAME} resolves.${waiting}`,
queue: summary,
};
}
+82 -10
View File
@@ -5,6 +5,10 @@
"policy": "record",
"why": "people's mail: any change to how its containers are declared recreates them together in one send, and the mail is down for everyone until they are up again — a person chooses the moment (hq ADR 0242, issue 295)"
},
"unhealthy": {
"severity": "urgent",
"why": "while mailu is broken, mail is refused or stuck for everyone with a mailbox here (hq ADR 0355, issue 687)"
},
"capabilities": [
"container-runtime"
],
@@ -56,6 +60,19 @@
"api-token": "${dir:state}/api-token.secret"
}
},
"tools": [
"mailu_list_users",
"mailu_create_user",
"mailu_change_password",
"mailu_delete_user",
"mailu_list_aliases",
"mailu_create_alias",
"mailu_delete_alias",
"mailu_list_domains",
"mailu_read_mail",
"mailu_search_mail",
"mailu_health"
],
"emits": [
"user.created",
"user.deleted",
@@ -231,6 +248,44 @@
}
}
},
"config": {
"domain": {
"type": "string",
"description": "The mail domain Mailu serves, such as example.org: its first mail domain, the postmaster's and the one consumers send as; the operator's, with no default (novox/hq ADR 0112).",
"trusted": true,
"required": true
},
"proxy-address": {
"type": "string",
"description": "The address of the reverse proxy in front of Mailu, whose X-Real-IP header Mailu believes; the operator's, with no default.",
"trusted": true,
"required": true
},
"sitename": {
"type": "string",
"description": "The name Mailu's web pages show for the mail service; the operator's, with no default.",
"trusted": true,
"required": true
},
"website": {
"type": "string",
"description": "The address of the website Mailu's web pages link to, as a URL; the operator's, with no default.",
"trusted": true,
"required": true
},
"url": {
"type": "string",
"description": "The address the module's tools reach Mailu's API at, as a URL; empty leaves them to MESH_MAILU_URL.",
"trusted": true,
"default": ""
},
"container": {
"type": "string",
"description": "The name of the container holding Mailu's mailboxes, which the module's tools read mail in; empty is mailu-imap.",
"trusted": true,
"default": ""
}
},
"resources": [
{
"id": "mesh-state",
@@ -259,28 +314,28 @@
"type": "file",
"path": "${dir:state}/mailu.env",
"mode": "0644",
"content": "ADMIN_ADDRESS=mailu-admin\nANTISPAM_ADDRESS=mailu-antispam\nANTIVIRUS_ADDRESS=mailu-antivirus\nIMAP_ADDRESS=mailu-imap\nSMTP_ADDRESS=mailu-smtp\nFRONT_ADDRESS=mailu-front\nWEBMAIL_ADDRESS=mailu-webmail\nWEBDAV_ADDRESS=mailu-webdav\nREDIS_ADDRESS=mailu-redis\nPORTS=25,80,443,465,993,995,4190,110,143,587\nDOMAIN=${setting:domain}\nHOSTNAMES=${bound:route:name-web}\nPOSTMASTER=admin\nSITENAME=${setting:sitename}\nWEBSITE=${setting:website}\nTLS_FLAVOR=letsencrypt\nSUBNET=192.168.203.0/24\nCOMPOSE_PROJECT_NAME=mailu\nANTIVIRUS=clamav\nWEBMAIL=roundcube\nWEBDAV=radicale\nFETCHMAIL_ENABLED=True\nFETCHMAIL_DELAY=600\nADMIN=true\nWEB_ADMIN=/admin\nWEB_WEBMAIL=/webmail\nWEBROOT_REDIRECT=/webmail\nAPI=true\nWEB_API=/api\nAUTH_RATELIMIT_IP=6000/hour\nAUTH_RATELIMIT_USER=1000/day\nCREDENTIAL_ROUNDS=12\nPASSWORD_SCHEME=PBKDF2\nDISABLE_STATISTICS=True\nMESSAGE_SIZE_LIMIT=50000000\nMESSAGE_RATELIMIT=200/day\nRECIPIENT_DELIMITER=+\nPOSTFIX_MYNETWORKS=127.0.0.0/8 [::1]/128\nRELAYNETS=\nRELAYHOST=\nREJECT_UNLISTED_RECIPIENT=\nDB_FLAVOR=postgresql\nINITIAL_ADMIN_ACCOUNT=admin\nINITIAL_ADMIN_DOMAIN=${setting:domain}\nINITIAL_ADMIN_MODE=ifmissing\nSMTP_PORT=25\nSMTPS_PORT=465\nSUBMISSION_PORT=587\nPOP3_PORT=110\nPOP3S_PORT=995\nIMAP_PORT=143\nIMAPS_PORT=993\nHTTP_PORT=7080\nHTTPS_PORT=7443\nAUTOMX_PORT=4243\nAMX_SMTP_ADDRESS=${bound:route:name-web}\nAMX_SMTP_PORT=587\nAMX_IMAP_ADDRESS=${bound:route:name-web}\nAMX_IMAP_PORT=143\nAMX_MAIL_DOMAINS=${setting:domain}\nDMARC_RUA=admin\nDMARC_RUF=admin\nLETSENCRYPT_SHORTCHAIN=True\nTZ=Etc/UTC\nLOG_LEVEL=INFO\nWELCOME=false\nREAL_IP_HEADER=X-Real-IP\nREAL_IP_FROM=${setting:proxy-address}\nCOMPRESSION=\nCOMPRESS_LEVEL=\nCOMPRESSION_LEVEL=\nBIND_ADDRESS4=127.0.0.1\nBIND_ADDRESS6=::1\nMAILU_VERSION=1.9\nDOCKER_ORG=mailu\nDOCKER_PREFIX=\nWELCOME_SUBJECT=Welcome to your new email account\nWELCOME_BODY=Welcome to your new email account, if you can read this, then it is configured properly!\n"
"template": "files/mailu.env"
},
{
"id": "secret-env",
"type": "file",
"path": "${dir:state}/secret.env",
"mode": "0600",
"content": "SECRET_KEY=${secret:secret-key}\n"
"template": "files/secret.env"
},
{
"id": "database-env",
"type": "file",
"path": "${dir:state}/database.env",
"mode": "0600",
"content": "DB_FLAVOR=postgresql\nDB_HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\nDB_USER=${bound:postgres-database:as}\nDB_NAME=${bound:postgres-database:as}\nDB_PW=${secret:postgres-database}\n"
"template": "files/database.env"
},
{
"id": "admin-env",
"type": "file",
"path": "${dir:state}/admin.env",
"mode": "0600",
"content": "INITIAL_ADMIN_PW=${secret:admin}\nAPI_TOKEN=${secret:api-token}\n"
"template": "files/admin.env"
},
{
"id": "data-certs",
@@ -371,7 +426,7 @@
"id": "resolver",
"type": "container",
"name": "mailu-resolver",
"image": "ghcr.io/mailu/unbound@sha256:3a0fdfb364a63f4f9259526e013c1ef40f5f14de3621ce1560804b3a5909584a",
"image": "ghcr.io/mailu/unbound@sha256:2c037bc5c988b5c0ab7e1b6e6ffa762e14ba1e45c96281ac87f8b9be8eee8b6e",
"health": {
"kind": "runtime"
},
@@ -443,7 +498,12 @@
"name": "mailu-smtp",
"image": "ghcr.io/mailu/postfix@sha256:e2e49f39e53b80eac9e7a2f18d9df11edeb4914fd62dbba89b3155e8e034f62e",
"health": {
"kind": "runtime"
"kind": "tool",
"tool": "mailu_health",
"interval": "60s",
"timeout": "30s",
"looks": 3,
"grace": "120s"
},
"network": "mailu",
"env-file": [
@@ -560,7 +620,12 @@
"name": "mailu-front",
"image": "ghcr.io/mailu/nginx@sha256:36f98897cd1bc9d27628bbb4e04bdf60147af2ec7507d6da77f002c4f256896d",
"health": {
"kind": "runtime"
"kind": "exec",
"command": "curl -m3 -skfLo /dev/null http://127.0.0.1:10204/health && kill -0 $(cat /run/dovecot/master.pid) && python3 -c \"import smtplib; s = smtplib.SMTP('127.0.0.1', 25, timeout=5); s.ehlo('health'); s.quit()\" && openssl x509 -checkend 1209600 -noout -in /certs/letsencrypt/live/mailu/nginx-chain.pem",
"interval": "60s",
"timeout": "15s",
"looks": 3,
"grace": "120s"
},
"network": "mailu",
"env-file": [
@@ -591,7 +656,8 @@
"type": "file",
"path": "${dir:mesh-state}/config.json",
"mode": "0600",
"content": "{}\n",
"template": "files/config.json",
"takes-config": ["domain", "proxy-address", "sitename", "website", "url", "container"],
"merge": "json"
},
{
@@ -637,6 +703,7 @@
"MESH_MAILU_URL": "http://127.0.0.1:${port:8080}/api/v1",
"MESH_MAILU_API_KEY_FILE": "${dir:state}/api-token.secret",
"MESH_MAILU_IMAP_CONTAINER": "mailu-imap",
"MESH_MAILU_SMTP_CONTAINER": "mailu-smtp",
"MESH_MAILU_CONFIG_FILE": "${dir:mesh-state}/config.json",
"MESH_RECEIVES": "${dir:grants}/mesh.json"
}
@@ -645,6 +712,11 @@
"name": "automx",
"kind": "image",
"from": "automx/Dockerfile"
},
{
"name": "files",
"kind": "archive",
"from": "files"
}
]
},
@@ -661,7 +733,7 @@
"serves": {
"smtp": {
"port": 587,
"domain": "${setting:domain}"
"domain": "${config:domain}"
}
},
"receives": {
@@ -673,7 +745,7 @@
"jails": [
{
"name": "mailu-front",
"failregex": "^.*(?:imap|pop3|submission|managesieve)-login: .*\\(auth failed, \\d+ attempts(?: in \\d+ secs)?\\):.*rip=<HOST>(?:,|$)",
"failregex": "^.*(?:imap|pop3|submission|managesieve)-login: .*\\(auth failed, \\d+ attempts(?: in \\d+ secs)?\\):.*rip=<HOST>(?:,|$)\n ^.*client login failed: \"AUTH not supported\" while in http auth state, client: <HOST>, server: \\S+:25,",
"jail": "backend = systemd\njournalmatch = CONTAINER_NAME=mailu-front\nport = smtp,submission,submissions,imap,imaps,pop3,pop3s\nmaxretry = 3\nfindtime = 1d\nbantime = 1d"
}
]
+140
View File
@@ -0,0 +1,140 @@
// Whether mail flows (novox/hq issue 690): the health tool is unhealthy, in words, when a message waits in
// postfix's deferred queue longer than the bound, when postfix does not run, when a public name does not
// resolve, and when the container cannot be reached — and healthy otherwise. Run against the compiled
// module, the way the runtime loads it.
import { test } from "node:test";
import assert from "node:assert/strict";
import { mailFlows, DEFERRED_BOUND_SECONDS } from "../dist/health.js";
const NOW = 1_800_000_000;
type Answers = { status?: string | Error; queue?: string | Error; resolve?: string | Error };
/** A container that answers each command as told; an Error is a failed exec, with its stderr. */
function container(a: Answers) {
const asked: string[][] = [];
const exec = async (_name: string, command: string[]) => {
asked.push(command);
const answer = command[0] === "postfix" ? a.status ?? "" : command[0] === "postqueue" ? a.queue ?? "" : a.resolve ?? "";
if (answer instanceof Error) throw answer;
return answer;
};
return { exec, asked };
}
function failed(stderr: string): Error {
return Object.assign(new Error("Command failed: docker exec"), { stderr });
}
function deferred(id: string, ageSeconds: number, reason: string, to = "friend@example.com"): string {
return JSON.stringify({
queue_name: "deferred",
queue_id: id,
arrival_time: NOW - ageSeconds,
message_size: 1234,
sender: "someone@example.org",
recipients: [{ address: to, delay_reason: reason }],
});
}
test("an empty queue is healthy, and says so", async () => {
const c = container({ queue: "" });
const a = await mailFlows(c.exec, "mailu-smtp", NOW);
assert.equal(a.state, "healthy");
assert.equal(a.healthy, true);
assert.match(a.why, /deferred queue is empty/);
assert.deepEqual(a.queue, { messages: 0, deferred: 0, overdue: 0 });
assert.equal(c.asked.length, 3, "postfix, its queue and a lookup were each asked");
});
test("a recipient overdue on a name-service failure is unhealthy, with counts, its age and its reason", async () => {
const reason = "Host or domain name not found. Name service error for name=gmail.com type=MX: Host not found, try again";
const queue = [
deferred("AAA111", 3 * 86400 + 2 * 3600, reason),
deferred("BBB222", 5 * 60, "connect to mx.example.net[192.0.2.1]:25: Connection timed out"),
JSON.stringify({ queue_name: "active", queue_id: "CCC333", arrival_time: NOW - 10, recipients: [] }),
].join("\n");
const a = await mailFlows(container({ queue }).exec, "mailu-smtp", NOW);
assert.equal(a.state, "unhealthy");
assert.equal(a.healthy, false);
assert.match(a.why, /^Mail does not flow\./);
assert.match(a.why, /1 deferred recipient has waited longer than 30 minutes on a name-service failure/);
assert.match(a.why, /3 days 2 hours old to friend@example\.com/);
assert.ok(a.why.includes(reason), "the reason is said");
assert.deepEqual(a.queue, {
messages: 3, deferred: 2, overdue: 1, oldestDeferredSeconds: 3 * 86400 + 2 * 3600,
overdueDomains: ["example.com"], nameServiceFailures: 1,
});
});
test("one dead far domain overdue is healthy, and said", async () => {
const queue = [
deferred("AAA111", 2 * 86400, "connect to mx.dead.example[192.0.2.9]:25: Connection timed out", "a@dead.example"),
deferred("BBB222", 3600, "connect to mx.dead.example[192.0.2.9]:25: Connection timed out", "b@dead.example"),
].join("\n");
const a = await mailFlows(container({ queue }).exec, "mailu-smtp", NOW);
assert.equal(a.state, "healthy");
assert.equal(a.healthy, true);
assert.match(a.why, /2 deferred messages have waited longer than 30 minutes, only to dead\.example, which reads as the far side's/);
assert.match(a.why, /2 days old to a@dead\.example, waits because: connect to mx\.dead\.example/);
assert.deepEqual(a.queue?.overdueDomains, ["dead.example"]);
});
test("overdue mail to three domains is unhealthy, whatever the reason", async () => {
const timedOut = "connect to mx[192.0.2.1]:25: Connection timed out";
const queue = [
deferred("AAA111", 7200, timedOut, "a@one.example"),
deferred("BBB222", 3600, timedOut, "b@two.example"),
deferred("CCC333", 3600, timedOut, "c@three.example"),
].join("\n");
const a = await mailFlows(container({ queue }).exec, "mailu-smtp", NOW);
assert.equal(a.state, "unhealthy");
assert.match(a.why, /3 of 3 deferred messages have waited longer than 30 minutes, to 3 domains \(one\.example, two\.example, three\.example\)/);
assert.match(a.why, /the oldest, 2 hours old to a@one\.example/);
});
test("a deferred message younger than the bound is healthy, and is said", async () => {
const queue = deferred("BBB222", DEFERRED_BOUND_SECONDS - 60, "connect to mx.example.net: Connection timed out");
const a = await mailFlows(container({ queue }).exec, "mailu-smtp", NOW);
assert.equal(a.state, "healthy");
assert.equal(a.healthy, true);
assert.match(a.why, /1 deferred, the oldest 29 minutes old, within 30 minutes/);
});
test("a container that cannot be reached is unhealthy, and says what docker said", async () => {
const gone = failed("Error response from daemon: No such container: mailu-smtp\n");
const a = await mailFlows(container({ status: gone, queue: gone, resolve: gone }).exec, "mailu-smtp", NOW);
assert.equal(a.state, "unhealthy");
assert.equal(a.healthy, false);
assert.match(a.why, /postfix in mailu-smtp does not run: Error response from daemon: No such container: mailu-smtp\./);
assert.match(a.why, /postfix's queue in mailu-smtp could not be read: Error response from daemon: No such container/);
assert.equal(a.queue, undefined);
});
test("a name that does not resolve is unhealthy, though the queue is empty", async () => {
const a = await mailFlows(
container({ queue: "", resolve: failed("gaierror: [Errno -3] Try again\n") }).exec,
"mailu-smtp",
NOW,
);
assert.equal(a.state, "unhealthy");
assert.match(a.why, /mailu-smtp cannot resolve gmail-smtp-in\.l\.google\.com through its resolver: gaierror: \[Errno -3\] Try again\./);
});
test("a container that does not answer in time is said so", async () => {
const hung = Object.assign(new Error("Command failed"), { killed: true, signal: "SIGTERM" });
const a = await mailFlows(container({ queue: hung }).exec, "mailu-smtp", NOW);
assert.equal(a.state, "unhealthy");
assert.match(a.why, /postfix's queue in mailu-smtp could not be read: it did not answer in time\./);
});
test("the health tool is served without the admin API, on the smtp container it is told", async () => {
const { collectTools } = await import("@novox/mesh-sdk/tools");
await import("../dist/tools/index.js");
const mailu = collectTools({ MESH_MAILU_SMTP_CONTAINER: "mailu-smtp" }).find((m) => m.module === "mailu");
const names = (mailu?.tools ?? []).map((t) => t.name);
assert.deepEqual(names, ["mailu_health"]);
assert.match(mailu!.tools[0].description, /inside mailu-smtp/);
});
+27 -4
View File
@@ -4,6 +4,27 @@
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 [
@@ -143,12 +164,14 @@ export function getMailuTools(mailu: MailuClient): ToolDefinition[] {
];
}
// The tools exist only when the admin API is configured; without it, mailu contributes none rather
// than failing the whole runtime.
// 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 {
return getMailuTools(MailuClient.fromEnv(env));
tools = getMailuTools(MailuClient.fromEnv(env));
} catch {
return [];
tools = [];
}
return [...tools, getHealthTool(env)];
});
+1 -1
View File
@@ -8,5 +8,5 @@
"skipLibCheck": true,
"noEmit": true
},
"include": ["client.ts", "index.ts", "tools/index.ts"]
"include": ["client.ts", "health.ts", "index.ts", "tools/index.ts"]
}