Files
ihasmail/server/src/upstream.ts
T
jcoffey-dev 94bf42cfda Drop Stalwart 0.15 support
ihasmail spoke to two generations of Stalwart that are less alike than
their version numbers suggest: 0.16 replaced the REST management API with
JMAP registry objects, changed the shape of FileNode, split its rights
up, and moved configuration into the store. Carrying both meant 34 branch
points across nine files, a 92-line compatibility shim whose only job was
telling them apart, a parallel REST implementation of every credential
operation, and a mock that had to model both.

The branches were not the real cost. The cost was that a wrong answer
about which generation had answered always had somewhere to fall back to,
so it failed quietly rather than loudly: one capability looked for in the
wrong place downgraded every real 0.16 server onto the 0.15 path, which
posted the current password to an endpoint 0.16 had removed, reported the
wrong generation on About, and ran Files on the older code. It reached
production and was recorded as verified when it was not. The mock
mirrored the same wrong placement, which is why the tests agreed.

Removed: the filenode compatibility shim, the dual "registry" | "legacy"
backend in account.ts, the pre-0.16 generation in AccountInfo and
everything that read it, the mock's LEGACY mode and dev:mock:legacy, and
the three test files that existed only to pin 0.15 behaviour.

Sign-in now refuses an older server by name, once, rather than letting
Files, the account locale and credentials each fail in their own way with
nothing connecting them. It says the credentials were fine -- someone
hitting this has typed a correct password, and telling them otherwise
sends them round in circles -- and names the tag to build from. Four
tests cover it, including that no session cookie is minted and that bad
credentials on such a server are still a plain 401.

Two fallbacks went that were not strictly about 0.15, and both for the
same reason the removal is happening. Files no longer answers a refused
filter or sort by fetching every node in the account, which would hide a
real fault behind a performance cliff nobody would notice. And the app
folder lookups now filter on parentId/isTopLevel alone and match names
client-side, since `name` is not a filter Stalwart is known to implement
and one it does not know fails the whole query rather than being ignored.

The last release that runs on 0.15 is tagged stalwart-0.15-support.

Verified against the mock end to end: sign-in, the Files tree on the 0.16
path with the app folder hidden, and self-service credentials over the
registry. 226 web + 75 server tests pass; typecheck and build clean.
2026-08-26 09:51:03 -07:00

272 lines
11 KiB
TypeScript

import { config } from "./config.js";
export interface UpstreamSession {
capabilities: Record<string, unknown>;
accounts: Record<string, unknown>;
primaryAccounts: Record<string, string>;
username: string;
apiUrl: string;
downloadUrl: string;
uploadUrl: string;
eventSourceUrl: string;
state: string;
}
export class UpstreamError extends Error {
constructor(
message: string,
public readonly status: number,
) {
super(message);
}
}
const sessionCache = new Map<string, { session: UpstreamSession; fetchedAt: number }>();
const SESSION_CACHE_MS = 5 * 60_000;
export function wellKnownUrl(): string {
return `${config.stalwartUrl}/.well-known/jmap`;
}
/**
* Fetch the JMAP session resource from Stalwart using the given Authorization
* header. Throws UpstreamError(401) on bad credentials.
*/
export async function fetchUpstreamSession(authorization: string): Promise<UpstreamSession> {
const res = await fetch(wellKnownUrl(), {
headers: { authorization, accept: "application/json" },
redirect: "follow",
signal: AbortSignal.timeout(config.upstreamTimeout),
});
if (res.status === 401 || res.status === 403) {
throw new UpstreamError("Invalid credentials", 401);
}
if (!res.ok) {
throw new UpstreamError(`Upstream session request failed (${res.status})`, 502);
}
const session = (await res.json()) as UpstreamSession;
if (!session.apiUrl) throw new UpstreamError("Upstream returned an invalid JMAP session", 502);
return session;
}
export async function getUpstreamSession(sessionId: string, authorization: string, force = false) {
const cached = sessionCache.get(sessionId);
if (!force && cached && Date.now() - cached.fetchedAt < SESSION_CACHE_MS) return cached.session;
const session = await fetchUpstreamSession(authorization);
sessionCache.set(sessionId, { session, fetchedAt: Date.now() });
return session;
}
export function forgetUpstreamSession(sessionId: string): void {
sessionCache.delete(sessionId);
infoCache.delete(sessionId);
}
/* ------------------------------------------------------------------ */
/* Account locale */
/* ------------------------------------------------------------------ */
const STALWART_CAP = "urn:stalwart:jmap";
const JMAP_CORE = "urn:ietf:params:jmap:core";
/**
* Whether this server has Stalwart's JMAP registry — the `x:` objects that
* carry credentials, account settings and the newer FileNode shape.
*
* `urn:stalwart:jmap` is the marker, but **not** in the session-level
* `capabilities`, which is where a JMAP client would naturally look. Stalwart
* builds that list from a fixed set that has never included this capability;
* it hands it out per-account instead, so it turns up in `primaryAccounts` and
* in each account's `accountCapabilities`. Checking only the session level
* therefore reported every real 0.16 server as older than 0.16 — which routed
* self-service credentials to a REST endpoint 0.16 had removed, and told the
* About page the wrong thing. The session level is still checked last, in case
* a later release advertises it there as well.
*
* This is now what sign-in tests to decide whether a server is supported at
* all, so the same mistake would lock every user out of a working server
* rather than merely misroute them.
*/
export function hasStalwartRegistry(session: Pick<UpstreamSession, "capabilities" | "accounts" | "primaryAccounts"> | undefined): boolean {
if (!session) return false;
if (session.primaryAccounts && STALWART_CAP in session.primaryAccounts) return true;
for (const account of Object.values(session.accounts ?? {})) {
const caps = (account as { accountCapabilities?: Record<string, unknown> } | null)?.accountCapabilities;
if (caps && STALWART_CAP in caps) return true;
}
return Boolean(session.capabilities && STALWART_CAP in session.capabilities);
}
export interface AccountInfo {
/** BCP-47 tag configured for the account, or null if unreadable. */
locale: string | null;
/** "oss" | "community" | "enterprise", where the server reports it. */
edition: string | null;
}
const infoCache = new Map<string, { info: AccountInfo; fetchedAt: number }>();
const INFO_CACHE_MS = 30 * 60_000;
const EMPTY_INFO: AccountInfo = { locale: null, edition: null };
/**
* glibc modifiers that name a script rather than a dialect or a currency:
* "sr_RS@latin" is Latin Serbian (sr-Latn-RS), not sr-RS. Anything not listed
* here (@valencia, @saaho, @euro …) carries no script and is dropped.
*/
const SCRIPT_MODIFIERS: Record<string, string> = {
latin: "Latn",
latn: "Latn",
cyrillic: "Cyrl",
cyrl: "Cyrl",
devanagari: "Deva",
iqtelif: "Latn",
};
/**
* Normalise a POSIX-style locale ("de_DE.UTF-8@euro") into a BCP-47 tag
* ("de-DE"). Returns null for the locale-less values ("C", "POSIX") and for
* anything that does not look like a language tag.
*/
export function normalizeLocale(raw: unknown): string | null {
if (typeof raw !== "string") return null;
const [head, modifier] = raw.trim().split("@");
const base = head!.split(".")[0]!.replace(/_/g, "-");
if (!base || base === "C" || base.toUpperCase() === "POSIX") return null;
if (!/^[A-Za-z]{2,8}(-[A-Za-z0-9]{2,8})*$/.test(base)) return null;
const script = modifier ? SCRIPT_MODIFIERS[modifier.toLowerCase()] : undefined;
try {
const [canonical] = Intl.getCanonicalLocales(base);
if (!canonical) return null;
if (!script) return canonical;
const loc = new Intl.Locale(canonical);
// Adding the script only helps when it differs from the one the locale
// already implies (ru-RU is Cyrillic, so "ru_RU@cyrillic" is just ru-RU).
const implied = loc.script ?? loc.maximize().script;
return implied === script ? canonical : new Intl.Locale(canonical, { script }).toString();
} catch {
return null;
}
}
/**
* Best-effort lookup of what the server can tell us about this account.
*
* The locale used to come from `x:Account/get`, which needs the `sysAccountGet`
* permission — a tenant/admin one that ordinary users are not granted, so the
* setting silently fell back to the browser locale for exactly the people most
* likely to want it. Stalwart 0.16 exposes the same field on `x:AccountSettings`,
* whose `sysAccountSettingsGet` permission *is* part of the built-in user role.
* Ask for both in one request and take whichever the server allows, which also
* tells us which generation we are talking to.
*/
async function fetchAccountInfo(authorization: string, session: UpstreamSession): Promise<AccountInfo> {
// Sign-in refuses a server without the registry, so this should not happen —
// but a session we cannot read capabilities from is not one to ask.
if (!session.capabilities || !hasStalwartRegistry(session)) return EMPTY_INFO;
const accountId =
session.primaryAccounts?.[STALWART_CAP] ??
session.primaryAccounts?.["urn:ietf:params:jmap:mail"] ??
Object.keys(session.accounts ?? {})[0];
if (!accountId) return EMPTY_INFO;
const res = await fetch(absoluteUpstream(session.apiUrl), {
method: "POST",
headers: { authorization, "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({
using: [JMAP_CORE, STALWART_CAP],
methodCalls: [
["x:AccountSettings/get", { accountId, ids: ["singleton"], properties: ["locale"] }, "s"],
["x:Account/get", { accountId, ids: [accountId], properties: ["locale"] }, "a"],
],
}),
signal: AbortSignal.timeout(config.upstreamTimeout),
});
// A locale request that fails — a permission we lack, a hiccup upstream —
// costs us the locale and nothing else.
if (!res.ok) return EMPTY_INFO;
const body = (await res.json()) as { methodResponses?: [string, Record<string, unknown>, string][] };
return interpretAccountInfo(body.methodResponses ?? []);
}
/**
* Read the pair of replies: prefer the locale from `x:AccountSettings`, whose
* permission the built-in user role has, and fall back to `x:Account` for the
* accounts allowed the admin-only `sysAccountGet` instead. Both are 0.16
* methods; this is a permissions fallback, not a version one.
*/
export function interpretAccountInfo(responses: [string, Record<string, unknown>, string][]): AccountInfo {
const settings = responses.find((r) => r[2] === "s");
const account = responses.find((r) => r[2] === "a");
return { locale: localeOf(settings) ?? localeOf(account), edition: null };
}
function localeOf(call: [string, Record<string, unknown>, string] | undefined): string | null {
if (!call || call[0] === "error") return null;
const list = call[1]?.list;
if (!Array.isArray(list) || !list.length) return null;
return normalizeLocale((list[0] as { locale?: unknown } | undefined)?.locale);
}
/**
* Which edition the server is running. Stalwart deliberately does not publish
* its version number to clients, but 0.16 does report its edition here.
*/
async function fetchEdition(authorization: string): Promise<string | null> {
try {
const res = await fetch(`${config.stalwartUrl}/api/account`, {
headers: { authorization, accept: "application/json" },
signal: AbortSignal.timeout(config.upstreamTimeout),
});
if (!res.ok) return null;
const body = (await res.json()) as { edition?: unknown };
return typeof body.edition === "string" ? body.edition : null;
} catch {
return null;
}
}
export async function getAccountInfo(sessionId: string, authorization: string, session: UpstreamSession): Promise<AccountInfo> {
const cached = infoCache.get(sessionId);
if (cached && Date.now() - cached.fetchedAt < INFO_CACHE_MS) return cached.info;
let info = EMPTY_INFO;
try {
info = await fetchAccountInfo(authorization, session);
info = { ...info, edition: await fetchEdition(authorization) };
} catch {
/* all of this is a nicety - never fail the session over it */
}
infoCache.set(sessionId, { info, fetchedAt: Date.now() });
return info;
}
/**
* Rewrite the upstream session so the browser talks to our same-origin proxy
* endpoints instead of Stalwart directly (no CORS, no credentials in browser).
*/
export function localizeSession(s: UpstreamSession, extras: Record<string, unknown>): Record<string, unknown> {
const caps = { ...s.capabilities };
// We proxy push as Server-Sent Events; hide the upstream websocket endpoint.
delete caps["urn:ietf:params:jmap:websocket"];
return {
...s,
capabilities: caps,
apiUrl: "/api/jmap",
downloadUrl: "/api/blob/{accountId}/{blobId}/{name}?accept={type}",
uploadUrl: "/api/upload/{accountId}",
eventSourceUrl: "/api/events?types={types}&closeafter={closeafter}&ping={ping}",
...extras,
};
}
/** Resolve a possibly-relative upstream URL template against STALWART_URL. */
export function absoluteUpstream(url: string): string {
try {
return new URL(url, config.stalwartUrl).toString();
} catch {
return url;
}
}
export function expandTemplate(template: string, vars: Record<string, string>): string {
return template.replace(/\{(\w+)\}/g, (_m, k: string) => encodeURIComponent(vars[k] ?? ""));
}