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:
@@ -0,0 +1,52 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), "ihasmail-servers-"));
|
||||
const file = join(dir, "servers.json");
|
||||
writeFileSync(
|
||||
file,
|
||||
JSON.stringify({
|
||||
_comment: ["A note, as the example file has."],
|
||||
"plain.test": "https://mail.plain.test/",
|
||||
"Linked.Test.": { url: "https://mail.linked.test", adminUrl: "https://admin.linked.test/" },
|
||||
}),
|
||||
);
|
||||
process.env.STALWART_URL = "https://default.example";
|
||||
process.env.STALWART_ADMIN_URL = "https://admin.default.example/";
|
||||
process.env.STALWART_SERVERS_FILE = file;
|
||||
|
||||
const { adminUrlFor, upstreamFor } = await import("./upstream.js");
|
||||
const { config, parseStalwartServers } = await import("./config.js");
|
||||
|
||||
/**
|
||||
* Where the dashboard's "Open Stalwart admin" points. STALWART_URL is how this
|
||||
* server reaches Stalwart; STALWART_ADMIN_URL is where a browser opens its
|
||||
* administration, and follows the same domain routing.
|
||||
*/
|
||||
test("a servers file entry may name its administration as well as its server, and a note is not a domain", () => {
|
||||
assert.deepEqual(config.stalwartServers, { "plain.test": "https://mail.plain.test", "linked.test": "https://mail.linked.test" });
|
||||
assert.deepEqual(config.stalwartAdminUrls, { "linked.test": "https://admin.linked.test" });
|
||||
assert.equal(upstreamFor("[email protected]"), "https://mail.linked.test");
|
||||
});
|
||||
|
||||
test("an unmapped domain and a bare username open the default administration", () => {
|
||||
assert.equal(adminUrlFor("[email protected]"), "https://admin.default.example");
|
||||
assert.equal(adminUrlFor("demo"), "https://admin.default.example");
|
||||
});
|
||||
|
||||
test("a routed domain opens its own server's administration, and never the default's", () => {
|
||||
assert.equal(adminUrlFor("[email protected]"), "https://admin.linked.test");
|
||||
// Routed away, with no adminUrl of its own: no link rather than the wrong server.
|
||||
assert.equal(adminUrlFor("[email protected]"), null);
|
||||
});
|
||||
|
||||
test("the shipped example loads through the parser that reads it", () => {
|
||||
const example = new URL("../../stalwart-servers.example.json", import.meta.url);
|
||||
const parsed = parseStalwartServers(JSON.parse(readFileSync(example, "utf8")), "example");
|
||||
assert.ok(Object.keys(parsed.urls).length > 0);
|
||||
assert.ok(!("_comment" in parsed.urls));
|
||||
assert.equal(Object.keys(parsed.adminUrls).length, 1);
|
||||
});
|
||||
+7
-2
@@ -23,6 +23,7 @@ import {
|
||||
getAccountInfo,
|
||||
getUpstreamSession,
|
||||
upstreamFor,
|
||||
adminUrlFor,
|
||||
localizeSession,
|
||||
} from "./upstream.js";
|
||||
import {
|
||||
@@ -865,8 +866,12 @@ function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null,
|
||||
remember: session.remember,
|
||||
/** Locale configured for the account in Stalwart's directory, if readable. */
|
||||
userLocale: info.locale,
|
||||
/** What the upstream server would tell us about itself. */
|
||||
server: { edition: info.edition },
|
||||
/**
|
||||
* What the upstream server would tell us about itself, and -- for a
|
||||
* session that may administer -- where the operator says its own
|
||||
* administration is.
|
||||
*/
|
||||
server: { edition: info.edition, adminUrl: administrationAllowed(config.administration, session.remember) ? adminUrlFor(session.username) : null },
|
||||
/**
|
||||
* Whether this session may administer: the installation offers it
|
||||
* (ADMINISTRATION) and the person signed in on a device marked as their own.
|
||||
|
||||
+46
-15
@@ -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),
|
||||
/**
|
||||
|
||||
@@ -74,9 +74,15 @@ test("every entry in the example mapping is a domain and an http(s) URL", () =>
|
||||
assert.ok(domain, "a domain key is empty");
|
||||
assert.ok(!seen.has(domain), `${domain} appears twice once normalised`);
|
||||
seen.add(domain);
|
||||
assert.equal(typeof value, "string", `${domain} is not a string`);
|
||||
const url = new URL(value as string);
|
||||
assert.ok(url.protocol === "http:" || url.protocol === "https:", `${domain} must be http or https`);
|
||||
// A URL, or an object naming the server's URL and its administration's.
|
||||
const entry = value && typeof value === "object" ? (value as Record<string, unknown>) : { url: value };
|
||||
for (const [field, v] of Object.entries(entry)) {
|
||||
assert.ok(field === "url" || field === "adminUrl", `${domain} has an unknown field ${field}`);
|
||||
assert.equal(typeof v, "string", `${domain} ${field} is not a string`);
|
||||
const url = new URL(v as string);
|
||||
assert.ok(url.protocol === "http:" || url.protocol === "https:", `${domain} ${field} must be http or https`);
|
||||
}
|
||||
assert.equal(typeof entry.url, "string", `${domain} has no url`);
|
||||
}
|
||||
assert.ok(seen.size > 0, "the example should show at least one mapping");
|
||||
});
|
||||
|
||||
@@ -54,6 +54,21 @@ export function upstreamFor(username: string): string {
|
||||
return config.stalwartServers[domain] ?? config.stalwartUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the administrator signed in as `username` opens Stalwart's own
|
||||
* administration, or null when the operator has not said.
|
||||
*
|
||||
* Follows the same routing as `upstreamFor`, and for the same reason never
|
||||
* falls back: a domain routed to another server is not pointed at the default
|
||||
* server's administration, where its accounts are not.
|
||||
*/
|
||||
export function adminUrlFor(username: string): string | null {
|
||||
const at = username.lastIndexOf("@");
|
||||
const domain = at < 0 ? "" : username.slice(at + 1).trim().toLowerCase().replace(/\.$/, "");
|
||||
if (domain && domain in config.stalwartServers) return config.stalwartAdminUrls[domain] ?? null;
|
||||
return config.stalwartAdminUrl || null;
|
||||
}
|
||||
|
||||
export function wellKnownUrl(base: string = config.stalwartUrl): string {
|
||||
return `${base}/.well-known/jmap`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user