Configurable date and time formats, defaulting to the Stalwart locale
Every user-visible date now goes through web/src/lib/datetime.ts, driven by three settings (Settings > General > Locale): - Language & region: automatic, or any of the 618 locales CLDR has data for, each named in its own language and script (web/src/lib/locales.ts, generated by probing Intl over the subtag space). - Date format: automatic (locale order), 22.11.2025, 22/11/2025, 11/22/2025, or ISO 8601 2025-11-22. - Time format: automatic (locale), 24-hour, or 12-hour. Automatic takes the locale Stalwart has for the account, read best-effort at login via x:Account/get (urn:stalwart:jmap) and passed to the client in the session; servers without the capability, or that deny sysAccountGet to a regular user, fall back to the browser locale. POSIX forms are normalised (de_DE.UTF-8 -> de-DE) and script modifiers kept (sr_RS@latin -> sr-Latn-RS, uz_UZ@cyrillic -> uz-Cyrl-UZ), while dialect/variant/currency modifiers are dropped and a script the locale already implies is not appended. Numerals follow the locale (22.11.2025 renders as Arabic-Indic digits under ar-EG); ISO 8601 is the exception and pins date and clock to Latin digits so one line never mixes digit systems. Rewired: message list and headers, quoted reply headers, calendar (titles, weekday and hour gutters, mini calendar, agenda, popovers, invite cards, free/busy), contacts, files, sessions. No raw toLocale*String date calls are left in web/src. Native <input type="datetime-local"> pickers always follow the browser locale and cannot be restyled by a page, so the out-of-office fields echo the entered instant in the chosen format underneath. Also: month-grid day labels no longer wrap when they hold a date, and the mock server serves x:Account/get (MOCK_LOCALE, default en_US). Closes #1
This commit is contained in:
+8
-3
@@ -11,6 +11,7 @@ import {
|
||||
expandTemplate,
|
||||
fetchUpstreamSession,
|
||||
forgetUpstreamSession,
|
||||
getAccountLocale,
|
||||
getUpstreamSession,
|
||||
localizeSession,
|
||||
} from "./upstream.js";
|
||||
@@ -170,7 +171,8 @@ export function createApp(): Hono<Env> {
|
||||
ip,
|
||||
});
|
||||
setSessionCookie(c, cookie, session.remember);
|
||||
return c.json(localizeSession(upstream, sessionExtras(session)));
|
||||
const locale = await getAccountLocale(session.id, session.authorization, upstream);
|
||||
return c.json(localizeSession(upstream, sessionExtras(session, locale)));
|
||||
} catch (err) {
|
||||
return upstreamFailure(c, err);
|
||||
}
|
||||
@@ -180,7 +182,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");
|
||||
return c.json(localizeSession(upstream, sessionExtras(session)));
|
||||
const locale = await getAccountLocale(session.id, session.authorization, upstream);
|
||||
return c.json(localizeSession(upstream, sessionExtras(session, locale)));
|
||||
} catch (err) {
|
||||
if (err instanceof UpstreamError && err.status === 401) {
|
||||
sessions.destroy(session.id);
|
||||
@@ -350,7 +353,7 @@ export function createApp(): Hono<Env> {
|
||||
return app;
|
||||
}
|
||||
|
||||
function sessionExtras(session: LiveSession) {
|
||||
function sessionExtras(session: LiveSession, userLocale: string | null = null) {
|
||||
return {
|
||||
ihasmail: {
|
||||
appName: config.appName,
|
||||
@@ -359,6 +362,8 @@ function sessionExtras(session: LiveSession) {
|
||||
sessionId: session.id,
|
||||
loginName: session.username,
|
||||
remember: session.remember,
|
||||
/** Locale configured for the account in Stalwart's directory, if readable. */
|
||||
userLocale,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import { randomUUID } from "node:crypto";
|
||||
const PORT = Number(process.env.MOCK_PORT ?? 8788);
|
||||
const ACCOUNT = "a1";
|
||||
const USER = process.env.MOCK_USER ?? "[email protected]";
|
||||
/** Locale the fake directory reports for the account (POSIX style, as Stalwart does). */
|
||||
const MOCK_LOCALE = process.env.MOCK_LOCALE ?? "en_US";
|
||||
const PASS = process.env.MOCK_PASS ?? "demo";
|
||||
|
||||
type Obj = Record<string, unknown>;
|
||||
@@ -254,6 +256,12 @@ function genericSet(list: Obj[], prefix: string, onCreate?: (o: Obj) => void) {
|
||||
}
|
||||
|
||||
const handlers: Record<string, Handler> = {
|
||||
// Stalwart's directory extension - the client reads the account locale from here.
|
||||
"x:Account/get": (a) => {
|
||||
const ids = (a.ids as string[] | null) ?? [ACCOUNT];
|
||||
const list = ids.filter((id) => id === ACCOUNT).map((id) => ({ id, name: USER, locale: MOCK_LOCALE, timeZone: null }));
|
||||
return { accountId: ACCOUNT, state: String(state.n), list, notFound: ids.filter((id) => id !== ACCOUNT) };
|
||||
},
|
||||
"Mailbox/get": genericGet(mailboxes),
|
||||
"Mailbox/set": (a) => { const r = genericSet(mailboxes, "m", (o) => Object.assign(o, { ...mb(o.id as string, o.name as string, null, (o.parentId as string) ?? null), ...o }))(a); recount(); return r; },
|
||||
"Mailbox/changes": () => ({ accountId: ACCOUNT, oldState: "1", newState: String(state.n), hasMoreChanges: false, created: [], updated: [], destroyed: [] }),
|
||||
@@ -352,9 +360,9 @@ function readBody(req: IncomingMessage): Promise<Buffer> {
|
||||
}
|
||||
|
||||
const session = () => ({
|
||||
capabilities: { "urn:ietf:params:jmap:core": { maxSizeUpload: 50000000, maxConcurrentUpload: 4, maxSizeRequest: 10000000, maxConcurrentRequests: 4, maxCallsInRequest: 16, maxObjectsInGet: 500, maxObjectsInSet: 500, collationAlgorithms: ["i;ascii-casemap"] }, "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": { implementation: "mock" }, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:calendars:parse": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:contacts:parse": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:principals:availability": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:blob": {}, "urn:ietf:params:jmap:filenode": {} },
|
||||
capabilities: { "urn:ietf:params:jmap:core": { maxSizeUpload: 50000000, maxConcurrentUpload: 4, maxSizeRequest: 10000000, maxConcurrentRequests: 4, maxCallsInRequest: 16, maxObjectsInGet: 500, maxObjectsInSet: 500, collationAlgorithms: ["i;ascii-casemap"] }, "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": { implementation: "mock" }, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:calendars:parse": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:contacts:parse": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:principals:availability": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:blob": {}, "urn:ietf:params:jmap:filenode": {}, "urn:stalwart:jmap": {} },
|
||||
accounts: { [ACCOUNT]: { name: USER, isPersonal: true, isReadOnly: false, accountCapabilities: { "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {} } } },
|
||||
primaryAccounts: Object.fromEntries(["mail", "submission", "vacationresponse", "sieve", "calendars", "contacts", "principals", "quota", "filenode", "blob"].map((c) => [`urn:ietf:params:jmap:${c}`, ACCOUNT])),
|
||||
primaryAccounts: { ...Object.fromEntries(["mail", "submission", "vacationresponse", "sieve", "calendars", "contacts", "principals", "quota", "filenode", "blob"].map((c) => [`urn:ietf:params:jmap:${c}`, ACCOUNT])), "urn:stalwart:jmap": ACCOUNT },
|
||||
username: USER,
|
||||
apiUrl: `http://127.0.0.1:${PORT}/jmap/`,
|
||||
downloadUrl: `http://127.0.0.1:${PORT}/jmap/download/{accountId}/{blobId}/{name}?accept={type}`,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { SessionStore } from "./sessions.js";
|
||||
import { normalizeLocale } from "./upstream.js";
|
||||
import { deriveKey, open, seal, sha256 } from "./crypto.js";
|
||||
import { RateLimiter } from "./ratelimit.js";
|
||||
import { randomBytes } from "node:crypto";
|
||||
@@ -46,3 +47,19 @@ test("rate limiter blocks after max hits in window", () => {
|
||||
rl.reset("k");
|
||||
assert.equal(rl.check("k"), true);
|
||||
});
|
||||
|
||||
test("normalizes Stalwart account locales to BCP-47 tags", () => {
|
||||
assert.equal(normalizeLocale("de_DE"), "de-DE");
|
||||
assert.equal(normalizeLocale("de_DE.UTF-8"), "de-DE");
|
||||
assert.equal(normalizeLocale("ca_ES@valencia"), "ca-ES");
|
||||
assert.equal(normalizeLocale("sr_RS@latin"), "sr-Latn-RS");
|
||||
assert.equal(normalizeLocale("uz_UZ@cyrillic"), "uz-Cyrl-UZ");
|
||||
assert.equal(normalizeLocale("ru_RU@cyrillic"), "ru-RU");
|
||||
assert.equal(normalizeLocale("en"), "en");
|
||||
assert.equal(normalizeLocale("POSIX"), null);
|
||||
assert.equal(normalizeLocale("C"), null);
|
||||
assert.equal(normalizeLocale(""), null);
|
||||
assert.equal(normalizeLocale(undefined), null);
|
||||
assert.equal(normalizeLocale({ locale: "de_DE" }), null);
|
||||
assert.equal(normalizeLocale("../etc/passwd"), null);
|
||||
});
|
||||
|
||||
@@ -59,6 +59,100 @@ export async function getUpstreamSession(sessionId: string, authorization: strin
|
||||
|
||||
export function forgetUpstreamSession(sessionId: string): void {
|
||||
sessionCache.delete(sessionId);
|
||||
localeCache.delete(sessionId);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Account locale */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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 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.
|
||||
*/
|
||||
async function fetchAccountLocale(authorization: string, session: UpstreamSession): Promise<string | null> {
|
||||
if (!session.capabilities || !(STALWART_CAP in session.capabilities)) return null;
|
||||
const accountId =
|
||||
session.primaryAccounts?.[STALWART_CAP] ??
|
||||
session.primaryAccounts?.["urn:ietf:params:jmap:mail"] ??
|
||||
Object.keys(session.accounts ?? {})[0];
|
||||
if (!accountId) return null;
|
||||
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"]],
|
||||
}),
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
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;
|
||||
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;
|
||||
try {
|
||||
locale = await fetchAccountLocale(authorization, session);
|
||||
} catch {
|
||||
/* the server locale is a nicety - never fail the session over it */
|
||||
}
|
||||
localeCache.set(sessionId, { locale, fetchedAt: Date.now() });
|
||||
return locale;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user