Find Stalwart's administration instead of asking for it

The dashboard's link to Stalwart's own administration needed
STALWART_ADMIN_URL, which an operator had to know to set. Everything it
holds can be read from the server:

- the origin is the host Stalwart advertises in its own session URLs, the
  one people reach it at even when ihasmail talks to it on a private
  address;
- the prefix is where its web interface application is installed. The
  production server's x:Application reads "Stalwart Web Interface",
  enabled, urlPrefix {"/admin", "/account"}, and /admin is also what
  Stalwart writes at first boot.

So, for an account that administers, the account info fetch now asks the
account's own server for its applications and links to origin + /admin/.
An installation whose web interface is disabled or moved gets no link; an
administrator who may not read applications gets Stalwart's default
/admin. It is cached with the rest of the account info.

STALWART_ADMIN_URL and a servers file entry's adminUrl still win, for an
administration that lives somewhere else. A routed domain without one now
takes what its own server said, never the default server's.
This commit is contained in:
2026-09-15 10:01:18 -07:00
parent 53d9cd37fb
commit 2e2724c55c
7 changed files with 136 additions and 27 deletions
+10 -6
View File
@@ -1153,11 +1153,15 @@ so rows come out even: six are three over three, and fall to two and then one
as the space narrows. **Refresh** reads everything again; nothing is polled. as the space narrows. **Refresh** reads everything again; nothing is polled.
Below the cards, a line says where the rest is: detailed metrics, the delivery Below the cards, a line says where the rest is: detailed metrics, the delivery
queue, logs and server settings are in Stalwart's own administration. It links queue, logs and server settings are in Stalwart's own administration, and it
there when the operator sets `STALWART_ADMIN_URL` — or, for a domain routed to links there. The address is found rather than configured: the public host
another server, that server's `adminUrl` in the servers file — and is plain text Stalwart advertises in its own session — the one people reach it at, even when
otherwise, since the address ihasmail reaches Stalwart on is often not one a ihasmail talks to it on a private address — and the prefix its web interface is
browser can open. installed under, read from its `x:Application` objects (`/admin` unless it was
moved). A server whose web interface is disabled or moved away gets no link, and
an administrator who may not read applications gets Stalwart's default `/admin`.
`STALWART_ADMIN_URL`, or a servers file entry's `adminUrl`, overrides it for an
administration that lives somewhere else.
## Accounts ## Accounts
@@ -1715,7 +1719,7 @@ wizard, because either would be state.
| --- | --- | --- | | --- | --- | --- |
| `STALWART_URL` | — | Where Stalwart is; the JMAP session is discovered at `/.well-known/jmap` | | `STALWART_URL` | — | Where Stalwart is; the JMAP session is discovered at `/.well-known/jmap` |
| `SHOW_ENTERPRISE_NOTICES` | `0` | Say an Enterprise-only section (Tenants) is Enterprise-only even when the server is Enterprise. For a demo that reports Enterprise to show those sections; a real installation leaves it off | | `SHOW_ENTERPRISE_NOTICES` | `0` | Say an Enterprise-only section (Tenants) is Enterprise-only even when the server is Enterprise. For a demo that reports Enterprise to show those sections; a real installation leaves it off |
| `STALWART_ADMIN_URL` | | Where a browser opens Stalwart's own administration, linked from the Administration dashboard. Separate from `STALWART_URL`, which is often an address only this server can reach; unset, the dashboard names Stalwart's administration without a link | | `STALWART_ADMIN_URL` | found | Where a browser opens Stalwart's own administration, linked from the Administration dashboard. Unset, it is found: the host Stalwart advertises and its web interface's prefix. Set it only when the administration lives somewhere else |
| `APP_SECRET` | — | Key material for sealing sessions. **Required in production** — the server refuses to start without it | | `APP_SECRET` | — | Key material for sealing sessions. **Required in production** — the server refuses to start without it |
| `HOST` / `PORT` | `0.0.0.0` / `8080` | Listen address | | `HOST` / `PORT` | `0.0.0.0` / `8080` | Listen address |
| `BASE_PATH` | — (the domain root) | Subpath to serve from, e.g. `/mail`. Must be set for the **build** as well as the run — see below | | `BASE_PATH` | — (the domain root) | Subpath to serve from, e.g. `/mail`. Must be set for the **build** as well as the run — see below |
+6 -5
View File
@@ -215,11 +215,12 @@ installation that sets nothing else behaves exactly as it always has.
``` ```
[`stalwart-servers.example.json`](stalwart-servers.example.json) is that file [`stalwart-servers.example.json`](stalwart-servers.example.json) is that file
with the rules written in it. A domain's value may also be an object that names with the rules written in it. The Administration dashboard links each administrator to
where that server's own administration is, for the Administration dashboard's their own server's administration, found from that server; a domain's value may
link — `{"url": "https://jmap.customer-b.test", "adminUrl": "https://admin.customer-b.test"}`. also be an object that overrides it, for an administration that lives elsewhere —
`STALWART_ADMIN_URL` is the same for the default server. A listed domain with no `{"url": "https://jmap.customer-b.test", "adminUrl": "https://admin.customer-b.test"}`.
`adminUrl` gets no link rather than the default server's. `STALWART_ADMIN_URL` is the same for the default server. A listed domain is never
pointed at the default server's administration.
A domain nobody listed — and a bare username, which Stalwart accepts and which A domain nobody listed — and a bare username, which Stalwart accepts and which
has no domain at all — goes to `STALWART_URL`. **A listed domain never falls has no domain at all — goes to `STALWART_URL`. **A listed domain never falls
+25 -2
View File
@@ -18,7 +18,7 @@ process.env.STALWART_URL = "https://default.example";
process.env.STALWART_ADMIN_URL = "https://admin.default.example/"; process.env.STALWART_ADMIN_URL = "https://admin.default.example/";
process.env.STALWART_SERVERS_FILE = file; process.env.STALWART_SERVERS_FILE = file;
const { adminUrlFor, upstreamFor } = await import("./upstream.js"); const { adminPrefixFrom, adminUrlFor, advertisedOrigin, upstreamFor } = await import("./upstream.js");
const { config, parseStalwartServers } = await import("./config.js"); const { config, parseStalwartServers } = await import("./config.js");
/** /**
@@ -39,10 +39,33 @@ test("an unmapped domain and a bare username open the default administration", (
test("a routed domain opens its own server's administration, and never the default's", () => { test("a routed domain opens its own server's administration, and never the default's", () => {
assert.equal(adminUrlFor("[email protected]"), "https://admin.linked.test"); assert.equal(adminUrlFor("[email protected]"), "https://admin.linked.test");
// Routed away, with no adminUrl of its own: no link rather than the wrong server. // Routed away, with no adminUrl of its own and nothing found: no link rather than the wrong server.
assert.equal(adminUrlFor("[email protected]"), null); assert.equal(adminUrlFor("[email protected]"), null);
}); });
test("what the operator configured wins over what was found, and what was found fills the gap", () => {
assert.equal(adminUrlFor("[email protected]", "https://found.example/admin/"), "https://admin.default.example");
assert.equal(adminUrlFor("[email protected]", "https://found.example/admin/"), "https://admin.linked.test");
// The routed domain without an adminUrl takes what its own server said.
assert.equal(adminUrlFor("[email protected]", "https://mail.plain.test/admin/"), "https://mail.plain.test/admin/");
});
/** Finding the administration on the server itself, as production's answered on 2026-09-15. */
test("the web interface's prefix is read from the applications Stalwart has installed", () => {
const got = (list: unknown[]) => adminPrefixFrom([["x:Application/query", { ids: ["a"] }, "q"], ["x:Application/get", { list }, "g"]]);
assert.equal(got([{ enabled: true, description: "Stalwart Web Interface", urlPrefix: { "/admin": true, "/account": true } }]), "/admin");
assert.equal(got([{ enabled: false, urlPrefix: { "/admin": true } }]), null);
assert.equal(got([{ enabled: true, urlPrefix: { "/console": true } }]), null);
assert.equal(got([]), null);
// May not read applications: not an answer, so Stalwart's own default.
assert.equal(adminPrefixFrom([["error", { type: "forbidden" }, "q"], ["error", { type: "forbidden" }, "g"]]), "/admin");
});
test("the origin is the one Stalwart advertises, even when it is reached on a private address", () => {
assert.equal(advertisedOrigin({ apiUrl: "https://mail.example.com/jmap/", baseUrl: "http://127.0.0.1:8080" }), "https://mail.example.com");
assert.equal(advertisedOrigin({ apiUrl: "/jmap/", baseUrl: "https://mail.example.com" }), "https://mail.example.com");
});
test("the shipped example loads through the parser that reads it", () => { test("the shipped example loads through the parser that reads it", () => {
const example = new URL("../../stalwart-servers.example.json", import.meta.url); const example = new URL("../../stalwart-servers.example.json", import.meta.url);
const parsed = parseStalwartServers(JSON.parse(readFileSync(example, "utf8")), "example"); const parsed = parseStalwartServers(JSON.parse(readFileSync(example, "utf8")), "example");
+1 -1
View File
@@ -898,7 +898,7 @@ function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null,
*/ */
server: { server: {
edition: info.edition, edition: info.edition,
adminUrl: administrationAllowed(config.administration, session.remember) ? adminUrlFor(session.username) : null, adminUrl: administrationAllowed(config.administration, session.remember) ? adminUrlFor(session.username, info.adminUrl ?? null) : null,
/** SHOW_ENTERPRISE_NOTICES: say "Enterprise feature" on Enterprise too, as the demo does. */ /** SHOW_ENTERPRISE_NOTICES: say "Enterprise feature" on Enterprise too, as the demo does. */
enterpriseNotices: config.showEnterpriseNotices, enterpriseNotices: config.showEnterpriseNotices,
}, },
+7 -1
View File
@@ -45,7 +45,7 @@ const OPS = ["Get", "Query", "Create", "Update", "Destroy"] as const;
const all = (...objects: string[]) => objects.flatMap((o) => OPS.map((op) => `sys${o}${op}`)); const all = (...objects: string[]) => objects.flatMap((o) => OPS.map((op) => `sys${o}${op}`));
/** What the dashboard reads beyond the directory. */ /** What the dashboard reads beyond the directory. */
const READ_SERVER = ["sysQueuedMessageGet", "sysQueuedMessageQuery", "sysMetricGet", "sysMetricQuery"]; const READ_SERVER = ["sysQueuedMessageGet", "sysQueuedMessageQuery", "sysMetricGet", "sysMetricQuery", "sysApplicationGet", "sysApplicationQuery"];
/** A few of the ordinary ones, so the list looks like what a server sends. */ /** A few of the ordinary ones, so the list looks like what a server sends. */
const USER_PERMISSIONS = ["jmapEmailGet", "jmapEmailUpdate", "jmapMailboxGet", "sysAccountSettingsGet"]; const USER_PERMISSIONS = ["jmapEmailGet", "jmapEmailUpdate", "jmapMailboxGet", "sysAccountSettingsGet"];
@@ -226,6 +226,8 @@ export function createDirectory(opts: Options) {
push(4, "Counter", "queue.report-queued", h % 4 === 1 ? 2 : 0); push(4, "Counter", "queue.report-queued", h % 4 === 1 ? 2 : 0);
} }
} }
const applications: Obj[] = [{ id: "app1", description: "Stalwart Web Interface", enabled: true, urlPrefix: { "/admin": true, "/account": true } }];
/** Tenants: a name, limits, and whatever names them in its memberTenantId. */ /** Tenants: a name, limits, and whatever names them in its memberTenantId. */
const tenants: Obj[] = [ const tenants: Obj[] = [
{ id: "t1", name: "Acme Corp", logo: null, roles: { "@type": "Default" }, permissions: { "@type": "Inherit" }, quotas: { maxAccounts: 25, maxDomains: 2, maxDiskQuota: 50 * GIB }, createdAt: "2026-07-01T09:00:00Z" }, { id: "t1", name: "Acme Corp", logo: null, roles: { "@type": "Default" }, permissions: { "@type": "Inherit" }, quotas: { maxAccounts: 25, maxDomains: 2, maxDiskQuota: 50 * GIB }, createdAt: "2026-07-01T09:00:00Z" },
@@ -693,6 +695,10 @@ export function createDirectory(opts: Options) {
} }
return { accountId: opts.accountId, oldState: "1", newState: "2", created, updated, destroyed, ...(Object.keys(notCreated).length ? { notCreated } : {}), ...(Object.keys(notUpdated).length ? { notUpdated } : {}), ...(Object.keys(notDestroyed).length ? { notDestroyed } : {}) }; return { accountId: opts.accountId, oldState: "1", newState: "2", created, updated, destroyed, ...(Object.keys(notCreated).length ? { notCreated } : {}), ...(Object.keys(notUpdated).length ? { notUpdated } : {}), ...(Object.keys(notDestroyed).length ? { notDestroyed } : {}) };
}, },
// Stalwart's web interface is an installed application; ihasmail reads its
// prefix to link the dashboard to it.
"x:Application/query": query(() => applications, "sysApplicationQuery", ["text"], () => true),
"x:Application/get": get(applications, "sysApplicationGet"),
"x:Role/get": get(roles, "sysRoleGet"), "x:Role/get": get(roles, "sysRoleGet"),
"x:Role/query": query(() => roles, "sysRoleQuery", ["text", "description", "memberTenantId"], (o, f) => (f.memberTenantId === undefined || (o.memberTenantId ?? null) === f.memberTenantId) && matchText(o, f.description)), "x:Role/query": query(() => roles, "sysRoleQuery", ["text", "description", "memberTenantId"], (o, f) => (f.memberTenantId === undefined || (o.memberTenantId ?? null) === f.memberTenantId) && matchText(o, f.description)),
}; };
+82 -7
View File
@@ -1,4 +1,5 @@
import { config } from "./config.js"; import { config } from "./config.js";
import { grantsAdministration } from "./adminGate.js";
export interface UpstreamSession { export interface UpstreamSession {
capabilities: Record<string, unknown>; capabilities: Record<string, unknown>;
@@ -56,17 +57,83 @@ export function upstreamFor(username: string): string {
/** /**
* Where the administrator signed in as `username` opens Stalwart's own * Where the administrator signed in as `username` opens Stalwart's own
* administration, or null when the operator has not said. * administration.
* *
* Follows the same routing as `upstreamFor`, and for the same reason never * What the operator configured wins -- STALWART_ADMIN_URL for the default
* falls back: a domain routed to another server is not pointed at the default * server, a servers file entry's `adminUrl` for a routed domain -- and what was
* server's administration, where its accounts are not. * found on the account's own server (`detected`) is used otherwise. Routing is
* the same as `upstreamFor`: a routed domain is never pointed at the default
* server's administration, and `detected` already came from its own server.
*/ */
export function adminUrlFor(username: string): string | null { export function adminUrlFor(username: string, detected: string | null = null): string | null {
const at = username.lastIndexOf("@"); const at = username.lastIndexOf("@");
const domain = at < 0 ? "" : username.slice(at + 1).trim().toLowerCase().replace(/\.$/, ""); const domain = at < 0 ? "" : username.slice(at + 1).trim().toLowerCase().replace(/\.$/, "");
if (domain && domain in config.stalwartServers) return config.stalwartAdminUrls[domain] ?? null; if (domain && domain in config.stalwartServers) return config.stalwartAdminUrls[domain] ?? detected;
return config.stalwartAdminUrl || null; return config.stalwartAdminUrl || detected;
}
/** Stalwart's own default for its web interface, written at first boot (`manager/defaults.rs`). */
const DEFAULT_ADMIN_PREFIX = "/admin";
/**
* The prefix Stalwart's administration is served under, from the `x:Application`
* answers: "/admin" if an enabled application claims it, null if the server
* says there is none (disabled, removed, or moved to another prefix). A refusal
* -- the account may not read applications -- is not an answer, and gets
* Stalwart's default.
*/
export function adminPrefixFrom(responses: [string, Record<string, unknown>, string][]): string | null {
const get = responses.find(([name]) => name === "x:Application/get" || name === "error");
if (!get || get[0] === "error") return DEFAULT_ADMIN_PREFIX;
const list = (get[1].list as Array<{ enabled?: unknown; urlPrefix?: unknown }> | undefined) ?? [];
const claims = list.some((app) => app.enabled !== false && app.urlPrefix && typeof app.urlPrefix === "object" && DEFAULT_ADMIN_PREFIX in (app.urlPrefix as object));
return claims ? DEFAULT_ADMIN_PREFIX : null;
}
/**
* The public origin a Stalwart session belongs to: the host it advertises in
* its own URLs, which is the address people reach it at even when this server
* talks to it on a private one (STALWART_URL=http://127.0.0.1:…). A relative
* URL falls back to the configured base.
*/
export function advertisedOrigin(session: Pick<UpstreamSession, "apiUrl" | "baseUrl">): string | null {
try {
return new URL(session.apiUrl, session.baseUrl).origin;
} catch {
return null;
}
}
/**
* Where this session's server serves its own administration, found from the
* server itself: its advertised origin, and the prefix its web interface
* application is installed under. Null when the server says it has none.
*/
async function detectAdminUrl(authorization: string, session: UpstreamSession): Promise<string | null> {
const origin = advertisedOrigin(session);
const accountId = session.primaryAccounts?.[STALWART_CAP];
if (!origin) return null;
let prefix: string | null = DEFAULT_ADMIN_PREFIX;
if (accountId) {
try {
const res = await fetch(absoluteUpstream(session.apiUrl, session.baseUrl), {
method: "POST",
headers: { authorization, "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({
using: [JMAP_CORE, STALWART_CAP],
methodCalls: [
["x:Application/query", { accountId }, "q"],
["x:Application/get", { accountId, "#ids": { resultOf: "q", name: "x:Application/query", path: "/ids" }, properties: ["enabled", "urlPrefix"] }, "g"],
],
}),
signal: AbortSignal.timeout(config.upstreamTimeout),
});
if (res.ok) prefix = adminPrefixFrom(((await res.json()) as { methodResponses?: [string, Record<string, unknown>, string][] }).methodResponses ?? []);
} catch {
/* unreachable is not "none": keep the default */
}
}
return prefix ? `${origin}${prefix}/` : null;
} }
export function wellKnownUrl(base: string = config.stalwartUrl): string { export function wellKnownUrl(base: string = config.stalwartUrl): string {
@@ -157,6 +224,11 @@ export interface AccountInfo {
* access. * access.
*/ */
permissions: string[]; permissions: string[];
/**
* Where this server's own administration is, found rather than configured:
* see `detectAdminUrl`. Only looked for when the account administers.
*/
adminUrl?: string | null;
} }
const infoCache = new Map<string, { info: AccountInfo; fetchedAt: number }>(); const infoCache = new Map<string, { info: AccountInfo; fetchedAt: number }>();
@@ -309,6 +381,9 @@ export async function getAccountInfo(sessionId: string, authorization: string, s
try { try {
info = await fetchAccountInfo(authorization, session); info = await fetchAccountInfo(authorization, session);
info = { ...info, ...(await fetchServerAccount(authorization, session.baseUrl)) }; info = { ...info, ...(await fetchServerAccount(authorization, session.baseUrl)) };
// Only an administrator is shown the link, so only an administrator's
// server is asked where it is.
if (grantsAdministration(info.permissions)) info = { ...info, adminUrl: await detectAdminUrl(authorization, session) };
} catch { } catch {
/* all of this is a nicety - never fail the session over it */ /* all of this is a nicety - never fail the session over it */
} }
+5 -5
View File
@@ -17,11 +17,11 @@
"JSON, a duplicate domain, or a value that is not an http(s) URL stops the", "JSON, a duplicate domain, or a value that is not an http(s) URL stops the",
"server at startup rather than failing quietly at somebody's sign-in.", "server at startup rather than failing quietly at somebody's sign-in.",
"", "",
"A value may instead be an object that also says where that server's own", "ihasmail's Administration dashboard links to each server's own",
"administration is, for the link on ihasmail's Administration dashboard:", "administration, found from the server. A value may instead be an object",
"{\"url\": ..., \"adminUrl\": ...}. STALWART_ADMIN_URL is the same for the", "that overrides where it is: {\"url\": ..., \"adminUrl\": ...}.",
"default server. A listed domain without adminUrl gets no link, never the", "STALWART_ADMIN_URL is the same for the default server. A listed domain is",
"default server's.", "never pointed at the default server's administration.",
"", "",
"Docs: https://docs.ihasmail.org/configure/#several-stalwart-servers" "Docs: https://docs.ihasmail.org/configure/#several-stalwart-servers"
], ],