Read the locale where users can actually read it, and say which Stalwart answered
The account locale came from `x:Account/get`, which needs `sysAccountGet` — a permission the built-in `user` role is not given, so the setting silently fell back to the browser locale for exactly the people most likely to have set it. Stalwart 0.16 carries the same field on `x:AccountSettings`, whose `sysAccountSettingsGet` *is* part of that role. Both are now asked for in one request and whichever answers wins, so admins and older servers keep working. That pair of replies also says which generation we are talking to: only 0.16+ can parse the method name at all. About now reports that, plus the edition from /api/account where the server offers it. It does not report a version number because Stalwart does not publish one to clients — it hardcodes a public "1.0.0" and keeps the real version to its SMTP internals — so the screen says what was actually detected rather than inventing precision. Also adds a light/dark toggle to the top bar, left of the settings button. The stored setting is three-way, so the button acts on the theme actually on screen: whichever one you see, a click gives you the other. Choosing "match system" again stays in Settings › Appearance, where a three-way choice belongs.
This commit is contained in:
@@ -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, unknown>, 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);
|
||||
});
|
||||
+10
-7
@@ -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<Env> {
|
||||
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<Env> {
|
||||
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 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -265,6 +265,13 @@ function genericSet(list: Obj[], prefix: string, onCreate?: (o: Obj) => void) {
|
||||
}
|
||||
|
||||
const handlers: Record<string, Handler> = {
|
||||
// 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][] = [];
|
||||
|
||||
+85
-22
@@ -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<string, { locale: string | null; fetchedAt: number }>();
|
||||
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<string, { info: AccountInfo; fetchedAt: number }>();
|
||||
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<string | null> {
|
||||
if (!session.capabilities || !(STALWART_CAP in session.capabilities)) return null;
|
||||
async function fetchAccountInfo(authorization: string, session: UpstreamSession): Promise<AccountInfo> {
|
||||
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, unknown>, 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, unknown>, 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, 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);
|
||||
}
|
||||
|
||||
export async function getAccountLocale(sessionId: string, authorization: string, session: UpstreamSession): Promise<string | null> {
|
||||
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<string | null> {
|
||||
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<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);
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 }) {
|
||||
<button className="icon-btn hide-mobile" aria-label="Keyboard shortcuts" title="Keyboard shortcuts (?)" onClick={() => setHelpOpen(true)}>
|
||||
<HelpCircle size={21} />
|
||||
</button>
|
||||
<ThemeToggle />
|
||||
<Link href="/settings" className={`icon-btn ${section === "settings" ? "active" : ""}`} aria-label="Settings" title="Settings">
|
||||
<Settings size={21} />
|
||||
</Link>
|
||||
@@ -196,3 +197,27 @@ function QuotaBar() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<button
|
||||
className="icon-btn"
|
||||
aria-label={`Switch to ${next} mode`}
|
||||
title={`Switch to ${next} mode`}
|
||||
onClick={() => update({ theme: next })}
|
||||
>
|
||||
{effective === "dark" ? <Sun size={21} /> : <Moon size={21} />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,11 +19,13 @@ export function AboutSettings() {
|
||||
<table className="sessions-table">
|
||||
<tbody>
|
||||
<tr><td>Signed in as</td><td>{session?.username}</td></tr>
|
||||
<tr><td>Stalwart</td><td>{describeServer(session?.ihasmail?.server)}</td></tr>
|
||||
<tr><td>Accounts</td><td>{Object.values(session?.accounts ?? {}).map((a) => a.name).join(", ")}</td></tr>
|
||||
<tr><td>Max upload</td><td>{Math.round(client.maxSizeUpload / 1048576)} MB</td></tr>
|
||||
<tr><td>Image privacy proxy</td><td>{session?.ihasmail?.imageProxy ? "enabled" : "disabled"}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="hint" style={{ marginTop: 6 }}>Stalwart does not publish its version number to mail clients, so ihasmail reports the API generation it detected instead.</p>
|
||||
<h2>Server capabilities</h2>
|
||||
<div className="row wrap gap-4">
|
||||
{caps.map((c) => <span key={c} className="chip mono" style={{ fontSize: ".78em" }}>{c.replace("urn:ietf:params:jmap:", "")}</span>)}
|
||||
@@ -31,3 +33,15 @@ export function AboutSettings() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user