diff --git a/server/src/accountinfo.test.ts b/server/src/accountinfo.test.ts new file mode 100644 index 0000000..c9e1f34 --- /dev/null +++ b/server/src/accountinfo.test.ts @@ -0,0 +1,51 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { interpretAccountInfo } from "./upstream.js"; + +/** + * The account locale used to be read only from `x:Account/get`, which needs + * the `sysAccountGet` permission — one the built-in `user` role is not given. + * Ordinary users therefore silently fell back to the browser locale. Stalwart + * 0.16 exposes the same field on `x:AccountSettings`, which users *can* read, + * so both are asked for and whichever answers wins. + */ + +type Responses = [string, Record, string][]; + +const settingsOk = (locale: string): Responses[number] => ["x:AccountSettings/get", { list: [{ id: "singleton", locale }] }, "s"]; +const accountOk = (locale: string): Responses[number] => ["x:Account/get", { list: [{ id: "a1", locale }] }, "a"]; +const failed = (id: string, type: string): Responses[number] => ["error", { type }, id]; + +test("prefers the locale a regular user is allowed to read", () => { + const info = interpretAccountInfo([settingsOk("de_DE.UTF-8"), accountOk("fr_FR")]); + assert.equal(info.locale, "de-DE"); + assert.equal(info.generation, "0.16+"); +}); + +test("falls back to x:Account when the settings object is forbidden", () => { + const info = interpretAccountInfo([failed("s", "forbidden"), accountOk("sr_RS@latin")]); + assert.equal(info.locale, "sr-Latn-RS"); +}); + +test("an older server is recognised by its unknownMethod, and still yields a locale", () => { + const info = interpretAccountInfo([failed("s", "unknownMethod"), accountOk("en_GB")]); + assert.equal(info.generation, "pre-0.16"); + assert.equal(info.locale, "en-GB"); +}); + +test("a server answering the new method is 0.16+ even with no locale set", () => { + const info = interpretAccountInfo([["x:AccountSettings/get", { list: [] }, "s"], failed("a", "forbidden")]); + assert.equal(info.generation, "0.16+"); + assert.equal(info.locale, null); +}); + +test("neither answering leaves everything unknown rather than guessing", () => { + const info = interpretAccountInfo([failed("s", "forbidden"), failed("a", "forbidden")]); + assert.deepEqual(info, { locale: null, generation: null, edition: null }); + assert.deepEqual(interpretAccountInfo([]), { locale: null, generation: null, edition: null }); +}); + +test("locales that carry no language are dropped, not passed through", () => { + assert.equal(interpretAccountInfo([settingsOk("C")]).locale, null); + assert.equal(interpretAccountInfo([settingsOk("POSIX")]).locale, null); +}); diff --git a/server/src/app.ts b/server/src/app.ts index 238ea35..c43b301 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -6,12 +6,13 @@ import { config } from "./config.js"; import { SessionStore, type LiveSession } from "./sessions.js"; import { RateLimiter } from "./ratelimit.js"; import { + type AccountInfo, UpstreamError, absoluteUpstream, expandTemplate, fetchUpstreamSession, forgetUpstreamSession, - getAccountLocale, + getAccountInfo, getUpstreamSession, localizeSession, } from "./upstream.js"; @@ -190,8 +191,8 @@ export function createApp(): Hono { ip, }); setSessionCookie(c, cookie, session.remember); - const locale = await getAccountLocale(session.id, session.authorization, upstream); - return c.json(localizeSession(upstream, sessionExtras(session, locale))); + const info = await getAccountInfo(session.id, session.authorization, upstream); + return c.json(localizeSession(upstream, sessionExtras(session, info))); } catch (err) { return upstreamFailure(c, err); } @@ -201,8 +202,8 @@ export function createApp(): Hono { const session = c.get("session"); try { const upstream = await getUpstreamSession(session.id, session.authorization, c.req.query("refresh") === "1"); - const locale = await getAccountLocale(session.id, session.authorization, upstream); - return c.json(localizeSession(upstream, sessionExtras(session, locale))); + const info = await getAccountInfo(session.id, session.authorization, upstream); + return c.json(localizeSession(upstream, sessionExtras(session, info))); } catch (err) { if (err instanceof UpstreamError && err.status === 401) { sessions.destroy(session.id); @@ -564,7 +565,7 @@ function appPasswordName(c: Context): string { return `${config.appName} (${browser})`; } -function sessionExtras(session: LiveSession, userLocale: string | null = null) { +function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null, generation: null, edition: null }) { return { ihasmail: { appName: config.appName, @@ -574,7 +575,9 @@ function sessionExtras(session: LiveSession, userLocale: string | null = null) { loginName: session.username, remember: session.remember, /** Locale configured for the account in Stalwart's directory, if readable. */ - userLocale, + userLocale: info.locale, + /** What the upstream server would tell us about itself. */ + server: { generation: info.generation, edition: info.edition }, }, }; } diff --git a/server/src/mock/index.ts b/server/src/mock/index.ts index 96507f5..3b11cb2 100644 --- a/server/src/mock/index.ts +++ b/server/src/mock/index.ts @@ -265,6 +265,13 @@ function genericSet(list: Obj[], prefix: string, onCreate?: (o: Obj) => void) { } const handlers: Record = { + // 0.16 exposes the account locale here, under a permission ordinary users + // actually have (unlike x:Account below, which needs sysAccountGet). + "x:AccountSettings/get": (a) => { + const ids = (a.ids as string[] | null) ?? ["singleton"]; + const list = ids.filter((id) => id === "singleton").map((id) => ({ id, locale: MOCK_LOCALE, timeZone: null, description: null })); + return { accountId: ACCOUNT, state: String(state.n), list: list.map((x) => pick(x, a.properties as string[] | null)), notFound: ids.filter((id) => id !== "singleton") }; + }, // Stalwart's directory extension - the client reads the account locale from here. "x:Account/get": (a) => { const ids = (a.ids as string[] | null) ?? [ACCOUNT]; @@ -469,6 +476,11 @@ export const server = createServer(async (req, res) => { res.writeHead(200, { "content-type": "application/json" }); return res.end(JSON.stringify(session())); } + // 0.16's account info endpoint; the only place a server reports its edition. + if (url.pathname === "/api/account" && req.method === "GET") { + res.writeHead(200, { "content-type": "application/json" }); + return res.end(JSON.stringify({ permissions: ["jmapEmailGet", "sysAccountSettingsGet"], edition: "oss", locale: MOCK_LOCALE })); + } if (url.pathname === "/jmap/" && req.method === "POST") { const body = JSON.parse((await readBody(req)).toString()) as { methodCalls: [string, Obj, string][] }; const responses: [string, Obj, string][] = []; diff --git a/server/src/upstream.ts b/server/src/upstream.ts index 9a12ed1..36f14ee 100644 --- a/server/src/upstream.ts +++ b/server/src/upstream.ts @@ -59,7 +59,7 @@ export async function getUpstreamSession(sessionId: string, authorization: strin export function forgetUpstreamSession(sessionId: string): void { sessionCache.delete(sessionId); - localeCache.delete(sessionId); + infoCache.delete(sessionId); } /* ------------------------------------------------------------------ */ @@ -68,8 +68,23 @@ export function forgetUpstreamSession(sessionId: string): void { const STALWART_CAP = "urn:stalwart:jmap"; const JMAP_CORE = "urn:ietf:params:jmap:core"; -const localeCache = new Map(); -const LOCALE_CACHE_MS = 30 * 60_000; + +export interface AccountInfo { + /** BCP-47 tag configured for the account, or null if unreadable. */ + locale: string | null; + /** + * Which generation of Stalwart's API answered: "0.16+" has the registry + * (`x:AccountSettings`), older builds only have `x:Account`. Null when the + * server is not Stalwart or told us nothing. + */ + generation: "0.16+" | "pre-0.16" | null; + /** "oss" | "community" | "enterprise", where the server reports it. */ + edition: string | null; +} + +const infoCache = new Map(); +const INFO_CACHE_MS = 30 * 60_000; +const EMPTY_INFO: AccountInfo = { locale: null, generation: null, edition: null }; /** * glibc modifiers that name a script rather than a dialect or a currency: @@ -112,47 +127,95 @@ export function normalizeLocale(raw: unknown): string | null { } /** - * Best-effort lookup of the locale configured for this account in Stalwart's - * directory (`x:Account/get`, Stalwart's JMAP extension). Servers that do not - * expose it — or that deny a regular user the `sysAccountGet` permission — - * simply yield null and the client falls back to the browser locale. + * 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 fetchAccountLocale(authorization: string, session: UpstreamSession): Promise { - if (!session.capabilities || !(STALWART_CAP in session.capabilities)) return null; +async function fetchAccountInfo(authorization: string, session: UpstreamSession): Promise { + if (!session.capabilities || !(STALWART_CAP in session.capabilities)) return EMPTY_INFO; const accountId = session.primaryAccounts?.[STALWART_CAP] ?? session.primaryAccounts?.["urn:ietf:params:jmap:mail"] ?? Object.keys(session.accounts ?? {})[0]; - if (!accountId) return null; + 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:Account/get", { accountId, ids: [accountId], properties: ["locale"] }, "l"]], + methodCalls: [ + ["x:AccountSettings/get", { accountId, ids: ["singleton"], properties: ["locale"] }, "s"], + ["x:Account/get", { accountId, ids: [accountId], properties: ["locale"] }, "a"], + ], }), signal: AbortSignal.timeout(config.upstreamTimeout), }); - if (!res.ok) return null; + if (!res.ok) return EMPTY_INFO; const body = (await res.json()) as { methodResponses?: [string, Record, string][] }; - const call = body.methodResponses?.[0]; - if (!call || call[0] !== "x:Account/get") return null; + return interpretAccountInfo(body.methodResponses ?? []); +} + +/** + * Read the pair of replies: prefer the locale from `x:AccountSettings`, fall + * back to `x:Account` for servers (or permissions) where only that one works, + * and note which generation answered. + */ +export function interpretAccountInfo(responses: [string, Record, string][]): AccountInfo { + const settings = responses.find((r) => r[2] === "s"); + const account = responses.find((r) => r[2] === "a"); + // Only 0.16+ knows the method at all; older builds cannot even parse the name. + const generation: AccountInfo["generation"] = + settings && settings[0] !== "error" + ? "0.16+" + : (settings?.[1] as { type?: string } | undefined)?.type === "unknownMethod" + ? "pre-0.16" + : null; + return { locale: localeOf(settings) ?? localeOf(account), generation, edition: null }; +} + +function localeOf(call: [string, Record, 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); } -export async function getAccountLocale(sessionId: string, authorization: string, session: UpstreamSession): Promise { - const cached = localeCache.get(sessionId); - if (cached && Date.now() - cached.fetchedAt < LOCALE_CACHE_MS) return cached.locale; - let locale: string | null = null; +/** + * 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 { try { - locale = await fetchAccountLocale(authorization, session); + 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 { - /* the server locale is a nicety - never fail the session over it */ + return null; } - localeCache.set(sessionId, { locale, fetchedAt: Date.now() }); - return locale; +} + +export async function getAccountInfo(sessionId: string, authorization: string, session: UpstreamSession): Promise { + 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); + if (info.generation === "0.16+") 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; } /** diff --git a/web/src/jmap/types.ts b/web/src/jmap/types.ts index 79b5bd1..542e162 100644 --- a/web/src/jmap/types.ts +++ b/web/src/jmap/types.ts @@ -32,6 +32,12 @@ export interface JmapSession { remember: boolean; /** Locale configured for the account in Stalwart, if the server exposes it. */ userLocale?: string | null; + /** What the upstream server was willing to say about itself. */ + server?: { + /** Which API generation answered: Stalwart publishes no version number. */ + generation?: "0.16+" | "pre-0.16" | null; + edition?: string | null; + }; }; } diff --git a/web/src/store/settings.ts b/web/src/store/settings.ts index 1c492be..0bf3b7b 100644 --- a/web/src/store/settings.ts +++ b/web/src/store/settings.ts @@ -1,3 +1,4 @@ +import { useEffect, useState } from "react"; import { create } from "zustand"; import { loadJson, saveJson } from "@/lib/storage"; import { setDateTimePrefs, type DateFormat, type TimeFormat } from "@/lib/datetime"; @@ -186,6 +187,23 @@ if (typeof window !== "undefined") { window.matchMedia?.("(prefers-color-scheme: dark)").addEventListener("change", () => applyTheme()); } +/** + * The theme actually on screen, which is not the same as the setting: "system" + * resolves to whatever the OS is doing right now, and follows it as it changes. + */ +export function useEffectiveTheme(): "light" | "dark" { + const theme = useSettings((s) => s.settings.theme); + const [systemDark, setSystemDark] = useState(() => window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false); + useEffect(() => { + const mq = window.matchMedia?.("(prefers-color-scheme: dark)"); + if (!mq) return; + const onChange = () => setSystemDark(mq.matches); + mq.addEventListener("change", onChange); + return () => mq.removeEventListener("change", onChange); + }, []); + return theme === "dark" || (theme === "system" && systemDark) ? "dark" : "light"; +} + export const settings = () => useSettings.getState().settings; /** diff --git a/web/src/views/AppShell.tsx b/web/src/views/AppShell.tsx index fe08c65..5338fe0 100644 --- a/web/src/views/AppShell.tsx +++ b/web/src/views/AppShell.tsx @@ -1,8 +1,8 @@ import { useEffect, useState, type ReactNode } from "react"; import { Link, useLocation } from "wouter"; -import { Calendar, ChevronsUpDown, FolderOpen, HelpCircle, Mail, Menu as MenuIcon, PenSquare, Settings, Users, LogOut, Plus, RefreshCw } from "lucide-react"; +import { Calendar, ChevronsUpDown, FolderOpen, HelpCircle, Mail, Menu as MenuIcon, Moon, PenSquare, Settings, Sun, Users, LogOut, Plus, RefreshCw } from "lucide-react"; import { useSession } from "@/store/session"; -import { useSettings } from "@/store/settings"; +import { useEffectiveTheme, useSettings } from "@/store/settings"; import { useMail } from "@/store/mail"; import { draftFromMailto, useCompose } from "@/store/compose"; import { Avatar, useIsMobile } from "@/ui/misc"; @@ -70,6 +70,7 @@ export function AppShell({ children }: { children: ReactNode }) { + @@ -196,3 +197,27 @@ function QuotaBar() { ); } + +/** + * Flip between light and dark from the top bar. + * + * The stored setting has a third value, "system", so the button acts on what + * is actually on screen rather than on the setting: whichever theme you can + * see, one click gives you the other one. Choosing "match system" again lives + * in Settings › Appearance, where the three-way choice belongs. + */ +function ThemeToggle() { + const effective = useEffectiveTheme(); + const update = useSettings((s) => s.update); + const next = effective === "dark" ? "light" : "dark"; + return ( + + ); +} diff --git a/web/src/views/settings/AboutSettings.tsx b/web/src/views/settings/AboutSettings.tsx index 458406e..a3a1bb8 100644 --- a/web/src/views/settings/AboutSettings.tsx +++ b/web/src/views/settings/AboutSettings.tsx @@ -19,11 +19,13 @@ export function AboutSettings() { +
Signed in as{session?.username}
Stalwart{describeServer(session?.ihasmail?.server)}
Accounts{Object.values(session?.accounts ?? {}).map((a) => a.name).join(", ")}
Max upload{Math.round(client.maxSizeUpload / 1048576)} MB
Image privacy proxy{session?.ihasmail?.imageProxy ? "enabled" : "disabled"}
+

Stalwart does not publish its version number to mail clients, so ihasmail reports the API generation it detected instead.

Server capabilities

{caps.map((c) => {c.replace("urn:ietf:params:jmap:", "")})} @@ -31,3 +33,15 @@ export function AboutSettings() {
); } + +/** + * Stalwart deliberately withholds its version from clients (it reports a fixed + * "1.0.0" wherever it publishes one at all), so the most honest thing we can + * show is which generation of its API answered us, plus the edition where the + * server reports it. + */ +function describeServer(server: { generation?: "0.16+" | "pre-0.16" | null; edition?: string | null } | undefined): string { + if (!server?.generation) return "not detected"; + const generation = server.generation === "0.16+" ? "0.16 or newer" : "older than 0.16"; + return server.edition ? `${generation} (${server.edition})` : generation; +}