Point from the dashboard to Stalwart's own administration

A line under the cards says where the rest is: detailed metrics, the
delivery queue, logs and server settings are in Stalwart's own
administration. It links there when the operator sets STALWART_ADMIN_URL,
and stays plain text otherwise, because STALWART_URL is how this server
reaches Stalwart and is often an address no browser can open.

Several servers: a servers file entry may now be an object,
{"url": ..., "adminUrl": ...}, and a session routed to that server gets its
adminUrl. A routed domain without one gets no link rather than the default
server's, for the same reason routing never falls back. The URL is sent
only to a session that may administer.

The shipped example file stopped the server at startup: its "_comment"
key was read as a domain and refused as not a URL, while the test that
checks the example skipped it. Keys starting with an underscore are notes
now -- no mail domain starts with one -- and the example is also loaded
through the real parser in a test, so the two cannot disagree again.

Two new strings, in all nine catalogues.
This commit is contained in:
2026-09-15 08:16:52 -07:00
parent 0054b8a3ce
commit 4787e8bf12
21 changed files with 226 additions and 23 deletions
+46 -15
View File
@@ -202,9 +202,9 @@ function readSettingsPolicy(): { defaults: Record<string, unknown>; enforced: Re
* having an outage would take the other four down with it. What happens when
* one is unreachable is a sign-in question, answered in #239.
*/
function readStalwartServers(): Record<string, string> {
function readStalwartServers(): { urls: Record<string, string>; adminUrls: Record<string, string> } {
const file = process.env.STALWART_SERVERS_FILE;
if (!file) return {};
if (!file) return { urls: {}, adminUrls: {} };
if (!existsSync(file)) throw new Error(`STALWART_SERVERS_FILE does not exist: ${file}`);
let raw: unknown;
@@ -213,33 +213,55 @@ function readStalwartServers(): Record<string, string> {
} catch (err) {
throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): ${(err as Error).message}`);
}
return parseStalwartServers(raw, file);
}
/** The servers file's contents, checked. Exported so the shipped example is tested by the parser that reads it. */
export function parseStalwartServers(raw: unknown, file: string): { urls: Record<string, string>; adminUrls: Record<string, string> } {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): expected an object of domain to URL`);
}
const out: Record<string, string> = {};
for (const [rawDomain, rawUrl] of Object.entries(raw as Record<string, unknown>)) {
const adminUrls: Record<string, string> = {};
for (const [rawDomain, rawValue] of Object.entries(raw as Record<string, unknown>)) {
/* The example file explains itself in a `_comment` key, and a copy of it
used to stop the server as "not a URL". No mail domain starts with an
underscore, so a key that does is a note, not a mapping. */
if (rawDomain.startsWith("_")) continue;
/* Lower-cased and stripped of the root dot, because that is how a domain
taken off a username will arrive and comparing them any other way means
a mapping that silently never matches. */
const domain = rawDomain.trim().toLowerCase().replace(/\.$/, "");
if (!domain) throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): a domain key is empty`);
if (domain in out) throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" appears twice once normalised`);
if (typeof rawUrl !== "string") throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" is not a URL`);
let parsed: URL;
try {
parsed = new URL(rawUrl);
} catch {
throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" is not an absolute URL`);
/* A domain's value is its server's URL, or an object that also names where
that server's own administration is: `{"url": …, "adminUrl": …}`. */
const value = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? (rawValue as Record<string, unknown>) : { url: rawValue };
if (typeof value.url !== "string") throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" is not a URL`);
out[domain] = httpUrl(value.url, `STALWART_SERVERS_FILE (${file}): "${domain}"`);
if (value.adminUrl !== undefined) {
if (typeof value.adminUrl !== "string") throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" adminUrl is not a URL`);
adminUrls[domain] = httpUrl(value.adminUrl, `STALWART_SERVERS_FILE (${file}): "${domain}" adminUrl`);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" must be http or https`);
}
out[domain] = rawUrl.replace(/\/+$/, "");
}
return out;
return { urls: out, adminUrls };
}
/** An absolute http(s) URL without its trailing slash, or a startup error naming where it came from. */
function httpUrl(raw: string, where: string): string {
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
throw new Error(`Invalid ${where}: not an absolute URL`);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`Invalid ${where}: must be http or https`);
return raw.replace(/\/+$/, "");
}
const stalwartServers = readStalwartServers();
export const config = {
isProd,
appName: env("APP_NAME", "ihasmail"),
@@ -275,7 +297,16 @@ export const config = {
*/
basePath: normalizeBasePath(process.env.BASE_PATH),
stalwartUrl,
stalwartServers: readStalwartServers(),
stalwartServers: stalwartServers.urls,
/**
* Where an administrator reaches Stalwart's own administration, for the
* pointer on ihasmail's dashboard. Optional, and separate from STALWART_URL,
* which is how *this server* reaches Stalwart -- often an address no browser
* can open. Unset, the dashboard names Stalwart's administration without a
* link. A domain routed elsewhere takes its server's `adminUrl` instead.
*/
stalwartAdminUrl: process.env.STALWART_ADMIN_URL ? httpUrl(process.env.STALWART_ADMIN_URL, "STALWART_ADMIN_URL") : "",
stalwartAdminUrls: stalwartServers.adminUrls,
appSecret,
trustProxy: bool("TRUST_PROXY", true),
/**