Merge pull request #366 from Coffey-Labs/feat/detect-stalwart-admin-url

Find Stalwart's administration instead of asking for it
This commit is contained in:
jcoffey
2026-09-15 10:07:27 -07:00
committed by GitHub
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.
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
there when the operator sets `STALWART_ADMIN_URL` — or, for a domain routed to
another server, that server's `adminUrl` in the servers file — and is plain text
otherwise, since the address ihasmail reaches Stalwart on is often not one a
browser can open.
queue, logs and server settings are in Stalwart's own administration, and it
links there. The address is found rather than configured: the public host
Stalwart advertises in its own session — the one people reach it at, even when
ihasmail talks to it on a private address — and the prefix its web interface is
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
@@ -1715,7 +1719,7 @@ wizard, because either would be state.
| --- | --- | --- |
| `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 |
| `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 |
| `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 |
+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
with the rules written in it. A domain's value may also be an object that names
where that server's own administration is, for the Administration dashboard's
link — `{"url": "https://jmap.customer-b.test", "adminUrl": "https://admin.customer-b.test"}`.
`STALWART_ADMIN_URL` is the same for the default server. A listed domain with no
`adminUrl` gets no link rather than the default server's.
with the rules written in it. The Administration dashboard links each administrator to
their own server's administration, found from that server; a domain's value may
also be an object that overrides it, for an administration that lives elsewhere —
`{"url": "https://jmap.customer-b.test", "adminUrl": "https://admin.customer-b.test"}`.
`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
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_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");
/**
@@ -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", () => {
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);
});
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", () => {
const example = new URL("../../stalwart-servers.example.json", import.meta.url);
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: {
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. */
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}`));
/** 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. */
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);
}
}
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. */
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" },
@@ -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 } : {}) };
},
// 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/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 { grantsAdministration } from "./adminGate.js";
export interface UpstreamSession {
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
* administration, or null when the operator has not said.
* administration.
*
* 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.
* What the operator configured wins -- STALWART_ADMIN_URL for the default
* server, a servers file entry's `adminUrl` for a routed domain -- and what was
* 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 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;
if (domain && domain in config.stalwartServers) return config.stalwartAdminUrls[domain] ?? detected;
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 {
@@ -157,6 +224,11 @@ export interface AccountInfo {
* access.
*/
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 }>();
@@ -309,6 +381,9 @@ export async function getAccountInfo(sessionId: string, authorization: string, s
try {
info = await fetchAccountInfo(authorization, session);
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 {
/* 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",
"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",
"administration is, for the link on ihasmail's Administration dashboard:",
"{\"url\": ..., \"adminUrl\": ...}. STALWART_ADMIN_URL is the same for the",
"default server. A listed domain without adminUrl gets no link, never the",
"default server's.",
"ihasmail's Administration dashboard links to each server's own",
"administration, found from the server. A value may instead be an object",
"that overrides where it is: {\"url\": ..., \"adminUrl\": ...}.",
"STALWART_ADMIN_URL is the same for the default server. A listed domain is",
"never pointed at the default server's administration.",
"",
"Docs: https://docs.ihasmail.org/configure/#several-stalwart-servers"
],