From 3621e81d0c383a6ae1f6aeff763a30946d3dc06b Mon Sep 17 00:00:00 2001 From: John Ellis Date: Mon, 24 Aug 2026 08:18:47 -0700 Subject: [PATCH 1/6] Manage your own password, app passwords and 2FA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings › Security grows three working sections instead of a note telling people to use Stalwart's own portal. Stalwart moved this API between releases, so ihasmail speaks both: 0.16+ has the x:AccountPassword singleton and x:AppPassword registry objects over JMAP, while 0.15.x has the /api/account/auth REST endpoint. Which one answers the probe is the only reliable way to tell them apart, and the result is cached per session. The built-in `user` role already grants sysAccountPassword* and sysAppPassword*, so no administrator setup is needed. Two problems are worth calling out, because both would bite a user hard: Stalwart validates the credentials already on the account when 2FA is turned on and never checks the new secret, so an authenticator that was mistyped or out of step would lock someone out of their mailbox at the next sign-in. We verify a code against the new secret ourselves first (RFC 6238, tested against the spec's vectors) and only then ask the server to store anything. Every proxied call re-authenticates with the credential sealed into the session, and from the moment 2FA is on Stalwart wants a fresh TOTP code with it — which we cannot produce between requests. Turning 2FA on would therefore sign the user out of the browser they just turned it on in. App passwords authenticate without a second factor, so the session is moved onto one minted for this browser, and the session cookie is re-sealed with it. The order matters: it is minted while the old credential still works, and revoked again if enabling then fails. Password changes re-seal this session too and drop the others, whose sealed copies of the old password would fail on their next call. The mock now enforces what a real server does — current password, password policy, a TOTP code on every request once 2FA is on, app passwords exempt — so the whole flow is exercised in tests rather than only by hand. --- README.md | 3 +- package-lock.json | 7 + server/src/account.test.ts | 163 +++++++++ server/src/account.ts | 380 ++++++++++++++++++++ server/src/app.ts | 211 +++++++++++ server/src/mock/index.ts | 91 ++++- server/src/sessions.ts | 24 ++ server/src/totp.test.ts | 85 +++++ server/src/totp.ts | 145 ++++++++ web/package.json | 1 + web/src/ui/qrcode.tsx | 41 +++ web/src/views/settings/SecuritySettings.tsx | 349 +++++++++++++++++- 12 files changed, 1490 insertions(+), 10 deletions(-) create mode 100644 server/src/account.test.ts create mode 100644 server/src/account.ts create mode 100644 server/src/totp.test.ts create mode 100644 server/src/totp.ts create mode 100644 web/src/ui/qrcode.tsx diff --git a/README.md b/README.md index 2f8eb36..842e899 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ ihasmail is a JMAP-first web client: mail, calendars, contacts, files, filters a **Settings** - **Dates & times**: language/region (every one of the ~620 locales CLDR has data for, each named in its own language and script), date order (locale default, `22.11.2025`, `22/11/2025`, `11/22/2025` or ISO `2025-11-22`) and 12h/24h clock, applied everywhere — message list and headers, calendar, contacts, files, sessions. The default comes from the locale configured for the account in Stalwart (`x:Account/get`), falling back to the browser's; POSIX forms are normalised (`de_DE.UTF-8` → `de-DE`) and script modifiers preserved (`sr_RS@latin` → `sr-Latn-RS`). Numerals follow the locale (`٢٢.١١.٢٠٢٥` for `ar-EG`), except under ISO 8601, which pins date *and* clock to Latin digits. Dates are **entered** through custom pickers in the same format (browsers render `` in their own locale and ignore the page's), with a calendar popover, a time list, keyboard navigation, and lenient typing — `22.11.`, `221125`, `6:23pm` and bare ISO all parse +- **Self-service credentials** in Settings › Security: change your password, manage **app passwords** (a separate password per mail app or device, revocable on its own), and turn **two-factor authentication** on or off by scanning a QR code. Enrolment codes are verified before anything is stored, so a mistyped key cannot lock you out, and switching 2FA on moves this browser's session onto a dedicated app password instead of signing you straight back out. Works against both Stalwart generations: the `x:AccountPassword` / `x:AppPassword` registry objects on 0.16+, and the `/api/account/auth` REST endpoint on 0.15.x - Identities & signatures, **Sieve filters** (visual rule builder that round-trips to a Sieve script, plus a raw script editor with server-side validation), out-of-office (`VacationResponse`), folders, labels, templates, notifications, calendar defaults, sessions (sign out other devices), keyboard shortcuts, import/export of settings **Platform** @@ -132,6 +133,7 @@ Verified against the mock server and, for the core mail flows, against a live St - **HTML signatures** — Stalwart caps identity signatures at 2 KB. ihasmail compacts pasted HTML, moves images to Files and, if still too large, keeps the full signature in Files behind a short marker (other clients see a text fallback). The end-to-end flow (save → compose → send with inline logo) is implemented but not yet confirmed on the live server. - **Files** — the live server runs an older Stalwart build than `main`; `FileNode/query` there rejects `isTopLevel`/`parentId` filters, so ihasmail falls back to listing all nodes and building the tree client-side. Upload/rename/move/delete still need a live pass. +- **Self-service credentials** — verified end to end against the mock (which enforces the same rules: current password required, password policy, a TOTP code on every request once 2FA is on, app passwords exempt from it). The **0.16 registry path is not yet confirmed against a real 0.16 server**, and the **0.15.x REST path is not confirmed at all**; the live server is 0.15.5. Password changes are also refused by Stalwart for accounts backed by an external directory (LDAP/SQL/OIDC) — the server's own message is shown when that happens. - Recurring events: colour/category/edit/delete apply to the whole series (per-occurrence overrides aren't supported by the server yet). - Editable date boxes are always Gregorian and in Latin digits, even for locales whose *display* uses another calendar or numbering system (`fa-IR`, `th-TH`, `ar-EG`) — they keep the locale's field order and separator, but a Buddhist-era year in a text box does not round-trip against the Gregorian calendar grid. Non-Gregorian calendar support is not implemented. - The account locale is read with Stalwart's `x:Account/get`, which needs the `sysAccountGet` permission; where a regular user is not granted it, ihasmail silently falls back to the browser locale and the setting can be chosen by hand. @@ -140,7 +142,6 @@ Verified against the mock server and, for the core mail flows, against a live St - Snooze and scheduled send (needs server-side support) - Read-receipt (MDN) sending, S/MIME / OpenPGP -- Self-service password / app-password / 2FA management (Stalwart exposes this through its own account portal) - Translations (strings are English-only for now) ## License diff --git a/package-lock.json b/package-lock.json index bb00554..65f333b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2459,6 +2459,12 @@ "node": ">=6" } }, + "node_modules/qrcode-generator": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz", + "integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==", + "license": "MIT" + }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -3826,6 +3832,7 @@ "@tanstack/react-virtual": "^3.13.2", "dompurify": "^3.2.4", "lucide-react": "^0.477.0", + "qrcode-generator": "^2.0.4", "react": "^19.0.0", "react-dom": "^19.0.0", "wouter": "^3.6.0", diff --git a/server/src/account.test.ts b/server/src/account.test.ts new file mode 100644 index 0000000..587fb5b --- /dev/null +++ b/server/src/account.test.ts @@ -0,0 +1,163 @@ +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; + +/** + * End-to-end self-service credential flows against the mock, which enforces + * the same rules a real 0.16 server does: the current password is checked, + * password policy is applied, and once 2FA is on every request wants a fresh + * TOTP code — except one authenticating with an app password. + */ + +const PORT = 18797; +process.env.MOCK_PORT = String(PORT); +process.env.MOCK_USER = "demo@example.com"; +process.env.MOCK_PASS = "demo-password"; +process.env.STALWART_URL = `http://127.0.0.1:${PORT}`; +process.env.APP_SECRET = "test-secret-for-account-flows"; + +const mock = await import("./mock/index.js"); +const { createApp } = await import("./app.js"); +const { parseOtpauthUrl, totpCode } = await import("./totp.js"); + +const app = createApp(); +let cookie = ""; + +const HEADERS = { "content-type": "application/json", "x-requested-with": "ihasmail" }; + +async function call(path: string, init: RequestInit = {}): Promise<{ status: number; body: any }> { + const res = await app.request(path, { + ...init, + headers: { ...HEADERS, ...(init.headers as Record), ...(cookie ? { cookie } : {}) }, + }); + const setCookie = res.headers.get("set-cookie"); + if (setCookie) cookie = setCookie.split(";")[0]!; + const text = await res.text(); + return { status: res.status, body: text ? JSON.parse(text) : null }; +} + +const post = (path: string, body: unknown) => call(path, { method: "POST", body: JSON.stringify(body) }); + +before(async () => { + const res = await post("/api/auth/login", { username: "demo@example.com", password: "demo-password" }); + assert.equal(res.status, 200, "login should succeed against the mock"); +}); + +after(() => { + (mock as { server?: { close(): void } }).server?.close(); +}); + +test("the 0.16 registry backend is detected and reported empty", async () => { + const res = await call("/api/account/security"); + assert.equal(res.status, 200); + assert.equal(res.body.backend, "registry"); + assert.equal(res.body.otpEnabled, false); + assert.deepEqual(res.body.appPasswords, []); + assert.equal(res.body.appPasswordsKeyedByName, false); +}); + +test("app passwords are created, listed once with their secret, and revoked", async () => { + const created = await post("/api/account/app-passwords", { description: "Thunderbird" }); + assert.equal(created.status, 200); + assert.match(created.body.secret, /^\$app\$/, "the server's generated secret is returned"); + assert.ok(created.body.id); + + const list = await call("/api/account/security"); + assert.equal(list.body.appPasswords.length, 1); + assert.equal(list.body.appPasswords[0].description, "Thunderbird"); + assert.equal(list.body.appPasswords[0].secret, undefined, "the secret is never listed again"); + + const revoked = await post("/api/account/app-passwords/revoke", { id: created.body.id }); + assert.equal(revoked.status, 200); + assert.deepEqual((await call("/api/account/security")).body.appPasswords, []); +}); + +test("an app password needs a name", async () => { + const res = await post("/api/account/app-passwords", { description: " " }); + assert.equal(res.status, 400); + assert.equal(res.body.error, "missing_fields"); +}); + +test("the wrong current password is refused with the server's reason", async () => { + const res = await post("/api/account/password", { current: "not-my-password", next: "a-much-longer-password" }); + assert.equal(res.status, 403); + assert.match(res.body.message, /Current secret is incorrect/); +}); + +test("the server's password policy is surfaced verbatim", async () => { + const res = await post("/api/account/password", { current: "demo-password", next: "short" }); + assert.equal(res.status, 400); + assert.match(res.body.message, /at least 8 characters/); +}); + +test("a password unchanged from the old one is rejected before we ask upstream", async () => { + const res = await post("/api/account/password", { current: "demo-password", next: "demo-password" }); + assert.equal(res.status, 400); + assert.equal(res.body.error, "unchanged"); +}); + +test("changing the password keeps this session working", async () => { + const res = await post("/api/account/password", { current: "demo-password", next: "a-brand-new-password" }); + assert.equal(res.status, 200); + // The stored credential was re-sealed, so the next proxied call still passes + // upstream authentication with the new password. + assert.equal((await call("/api/auth/session")).status, 200); + assert.equal((await call("/api/account/security")).status, 200); +}); + +test("enabling 2FA rejects a code the new secret did not produce", async () => { + const begin = await post("/api/account/2fa/begin", {}); + assert.equal(begin.status, 200); + assert.match(begin.body.url, /^otpauth:\/\/totp\//); + const res = await post("/api/account/2fa/enable", { url: begin.body.url, code: "000000", current: "a-brand-new-password" }); + assert.equal(res.status, 400); + assert.equal(res.body.code, undefined); + assert.match(res.body.message, /doesn't match/); + assert.equal((await call("/api/account/security")).body.otpEnabled, false, "nothing was stored"); +}); + +test("enabling 2FA switches the session onto an app password so it survives", async () => { + const begin = await post("/api/account/2fa/begin", {}); + const params = parseOtpauthUrl(begin.body.url); + assert.ok(params); + const res = await post("/api/account/2fa/enable", { + url: begin.body.url, + code: totpCode(params), + current: "a-brand-new-password", + }); + assert.equal(res.status, 200); + assert.equal(res.body.sessionKept, true); + + const state = await call("/api/account/security"); + assert.equal(state.status, 200, "the session still authenticates upstream"); + assert.equal(state.body.otpEnabled, true); + assert.equal(state.body.appPasswords.length, 1, "one app password was minted for this browser"); + assert.match(state.body.appPasswords[0].description, /\(/, "it is named after the browser"); +}); + +test("with 2FA on, a password change needs the current code too", async () => { + const withoutCode = await post("/api/account/password", { current: "a-brand-new-password", next: "yet-another-password" }); + assert.equal(withoutCode.status, 403); + assert.match(withoutCode.body.message, /OTP code is required/); +}); + +test("2FA is switched off with the password and a current code", async () => { + const state = await call("/api/account/security"); + assert.equal(state.body.otpEnabled, true); + // The enrolment secret is known only to the client, so disabling uses a code + // from the authenticator - here, the one the mock stored. + const stored = (mock as { account: { otpUrl: string | null } }).account.otpUrl; + const params = parseOtpauthUrl(stored!); + assert.ok(params); + const res = await post("/api/account/2fa/disable", { current: "a-brand-new-password", code: totpCode(params) }); + assert.equal(res.status, 200); + assert.equal((await call("/api/account/security")).body.otpEnabled, false); +}); + +test("credential endpoints reject unauthenticated callers", async () => { + const saved = cookie; + cookie = ""; + assert.equal((await call("/api/account/security")).status, 401); + assert.equal((await post("/api/account/password", { current: "a", next: "b" })).status, 401); + assert.equal((await post("/api/account/2fa/begin", {})).status, 401); + cookie = saved; +}); diff --git a/server/src/account.ts b/server/src/account.ts new file mode 100644 index 0000000..7a95d25 --- /dev/null +++ b/server/src/account.ts @@ -0,0 +1,380 @@ +import { config } from "./config.js"; +import { absoluteUpstream, UpstreamError, type UpstreamSession } from "./upstream.js"; +import { generateSecret, otpauthUrl, parseOtpauthUrl, verifyTotp } from "./totp.js"; +import { randomBytes } from "node:crypto"; + +/** + * Self-service credential management, across two incompatible Stalwart APIs. + * + * 0.16+ JMAP registry objects: x:AccountPassword (a singleton holding the + * password and the otpauth URL) and x:AppPassword. + * 0.15.x a REST endpoint, POST /api/account/auth, taking a list of actions. + * + * The registry crate does not exist before 0.16 and the REST endpoint is gone + * after it, so which one answers is the only reliable way to tell them apart. + */ + +const STALWART_CAP = "urn:stalwart:jmap"; +const JMAP_CORE = "urn:ietf:params:jmap:core"; +/** Stalwart's id for a singleton object; the number it encodes spells this. */ +const SINGLETON = "singleton"; +/** Returned in place of a stored secret; echo it back to leave one unchanged. */ +const MASKED = "[********]"; + +export type Backend = "registry" | "legacy"; + +export interface AppPasswordRow { + /** Registry object id, or the name itself on legacy servers. */ + id: string; + description: string; + createdAt: string | null; + expiresAt: string | null; +} + +export interface SecurityState { + backend: Backend; + otpEnabled: boolean; + appPasswords: AppPasswordRow[]; + /** + * Legacy servers key app passwords by name and hand back nothing else, so + * the UI must keep names unique and cannot show when one was created. + */ + appPasswordsKeyedByName: boolean; +} + +/** An error with a message meant for the person using the app. */ +export class AccountError extends Error { + constructor( + message: string, + public readonly status = 400, + public readonly code = "account_error", + ) { + super(message); + this.name = "AccountError"; + } +} + +interface Ctx { + authorization: string; + session: UpstreamSession; + username: string; +} + +/* ------------------------------------------------------------------ */ +/* Backend detection */ +/* ------------------------------------------------------------------ */ + +const backendCache = new Map(); +const BACKEND_CACHE_MS = 30 * 60_000; + +export function forgetBackend(sessionId: string): void { + backendCache.delete(sessionId); +} + +export async function detectBackend(sessionId: string, ctx: Ctx): Promise { + const cached = backendCache.get(sessionId); + if (cached && Date.now() - cached.at < BACKEND_CACHE_MS) return cached.backend; + const backend = await probeBackend(ctx); + backendCache.set(sessionId, { backend, at: Date.now() }); + return backend; +} + +async function probeBackend(ctx: Ctx): Promise { + // A server with the registry answers x:AccountPassword/get; one without it + // fails to parse the method name at all and returns unknownMethod. + if (ctx.session.capabilities && STALWART_CAP in ctx.session.capabilities) { + const res = await jmap(ctx, [["x:AccountPassword/get", { accountId: accountId(ctx), ids: [SINGLETON] }, "p"]]); + const [name, args] = res.methodResponses?.[0] ?? []; + if (name && name !== "error") return "registry"; + const type = (args as { type?: string } | undefined)?.type; + if (type && type !== "unknownMethod") return "registry"; // present, but refused us + } + return "legacy"; +} + +/* ------------------------------------------------------------------ */ +/* Transports */ +/* ------------------------------------------------------------------ */ + +function accountId(ctx: Ctx): string { + return ( + ctx.session.primaryAccounts?.[STALWART_CAP] ?? + ctx.session.primaryAccounts?.["urn:ietf:params:jmap:mail"] ?? + Object.keys(ctx.session.accounts ?? {})[0] ?? + "" + ); +} + +type Invocation = [string, Record, string]; + +async function jmap(ctx: Ctx, methodCalls: Invocation[]): Promise<{ methodResponses?: [string, unknown, string][] }> { + const res = await fetch(absoluteUpstream(ctx.session.apiUrl), { + method: "POST", + headers: { authorization: ctx.authorization, "content-type": "application/json", accept: "application/json" }, + body: JSON.stringify({ using: [JMAP_CORE, STALWART_CAP], methodCalls }), + signal: AbortSignal.timeout(config.upstreamTimeout), + }); + if (res.status === 401 || res.status === 403) throw new UpstreamError("Invalid credentials", 401); + if (!res.ok) throw new UpstreamError(`Stalwart rejected the request (${res.status})`, 502); + return (await res.json()) as { methodResponses?: [string, unknown, string][] }; +} + +async function legacy(ctx: Ctx, init: RequestInit): Promise { + const res = await fetch(`${config.stalwartUrl}/api/account/auth`, { + ...init, + headers: { authorization: ctx.authorization, "content-type": "application/json", accept: "application/json" }, + signal: AbortSignal.timeout(config.upstreamTimeout), + }); + if (res.status === 401 || res.status === 403) throw new UpstreamError("Invalid credentials", 401); + if (res.status === 404) { + throw new AccountError("This mail server does not offer self-service credential management.", 501, "unsupported"); + } + if (!res.ok) { + let detail = ""; + try { + const body = (await res.json()) as { error?: string; details?: string; reason?: string }; + detail = body.details ?? body.reason ?? body.error ?? ""; + } catch { + /* fall through to the generic message */ + } + throw new AccountError(detail || `The mail server rejected the change (${res.status}).`, 502, "upstream"); + } + return ((await res.json()) as { data: T }).data; +} + +/** + * Pull the single result out of a /set, turning JMAP's several failure shapes + * into one error carrying whatever the server was willing to explain. + */ +function setResult(res: { methodResponses?: [string, unknown, string][] }, kind: "created" | "updated" | "destroyed"): Record | null { + const [name, args] = res.methodResponses?.[0] ?? []; + if (!name) throw new AccountError("The mail server sent no response.", 502, "upstream"); + if (name === "error") { + const err = args as { type?: string; description?: string }; + if (err.type === "unknownMethod") { + throw new AccountError("This mail server does not offer self-service credential management.", 501, "unsupported"); + } + throw new AccountError(err.description ?? `The mail server refused the request (${err.type ?? "error"}).`, 502, err.type ?? "upstream"); + } + const body = args as Record | undefined>; + const notKind = kind === "created" ? "notCreated" : kind === "updated" ? "notUpdated" : "notDestroyed"; + const failures = body[notKind]; + const failure = failures && Object.values(failures)[0]; + if (failure) { + const err = failure as { type?: string; description?: string; properties?: string[] }; + throw new AccountError(describeSetError(err), err.type === "forbidden" ? 403 : 400, err.type ?? "invalid"); + } + const ok = body[kind]; + return ok ? ((Object.values(ok)[0] ?? {}) as Record) : null; +} + +function describeSetError(err: { type?: string; description?: string; properties?: string[] }): string { + if (err.description) return err.description; + if (err.type === "forbidden") return "The mail server refused the change."; + if (err.type === "overQuota") return "You have reached the number of app passwords this account allows."; + if (err.type === "invalidProperties") { + return err.properties?.length ? `The mail server rejected ${err.properties.join(", ")}.` : "The mail server rejected the value."; + } + return `The mail server refused the change (${err.type ?? "error"}).`; +} + +/* ------------------------------------------------------------------ */ +/* Operations */ +/* ------------------------------------------------------------------ */ + +export async function getState(sessionId: string, ctx: Ctx): Promise { + const backend = await detectBackend(sessionId, ctx); + if (backend === "legacy") { + const data = await legacy<{ otpEnabled?: boolean; appPasswords?: string[] }>(ctx, { method: "GET" }); + return { + backend, + otpEnabled: Boolean(data.otpEnabled), + appPasswords: (data.appPasswords ?? []).map((name) => ({ id: name, description: name, createdAt: null, expiresAt: null })), + appPasswordsKeyedByName: true, + }; + } + const id = accountId(ctx); + const res = await jmap(ctx, [ + ["x:AccountPassword/get", { accountId: id, ids: [SINGLETON] }, "p"], + ["x:AppPassword/get", { accountId: id, ids: null }, "a"], + ]); + const pass = firstListItem(res, "p") as { otpAuth?: { otpUrl?: string | null } } | null; + const apps = listOf(res, "a"); + return { + backend, + // The URL itself is masked; its presence is what tells us 2FA is on. + otpEnabled: Boolean(pass?.otpAuth?.otpUrl), + appPasswords: apps.map((a) => ({ + id: String(a.id ?? ""), + description: String(a.description ?? "App password"), + createdAt: typeof a.createdAt === "string" ? a.createdAt : null, + expiresAt: typeof a.expiresAt === "string" ? a.expiresAt : null, + })), + appPasswordsKeyedByName: false, + }; +} + +function listOf(res: { methodResponses?: [string, unknown, string][] }, callId: string): Record[] { + const call = res.methodResponses?.find((r) => r[2] === callId); + if (!call || call[0] === "error") return []; + const list = (call[1] as { list?: unknown }).list; + return Array.isArray(list) ? (list as Record[]) : []; +} + +function firstListItem(res: { methodResponses?: [string, unknown, string][] }, callId: string): Record | null { + return listOf(res, callId)[0] ?? null; +} + +export async function changePassword( + sessionId: string, + ctx: Ctx, + opts: { current: string; next: string; otpCode?: string }, +): Promise { + const backend = await detectBackend(sessionId, ctx); + if (backend === "registry") { + const update: Record = { currentSecret: opts.current, secret: opts.next }; + if (opts.otpCode) update["otpAuth/otpCode"] = opts.otpCode; + const res = await jmap(ctx, [["x:AccountPassword/set", { accountId: accountId(ctx), update: { [SINGLETON]: update } }, "s"]]); + setResult(res, "updated"); + return; + } + // The legacy endpoint changes the password without asking for the old one, + // so anyone holding a live session could set it. Prove it ourselves first. + await assertCurrentPassword(ctx, opts.current, opts.otpCode); + await legacy(ctx, { method: "POST", body: JSON.stringify([{ type: "setPassword", password: opts.next }]) }); +} + +export async function createAppPassword( + sessionId: string, + ctx: Ctx, + opts: { description: string }, +): Promise<{ id: string; secret: string }> { + const backend = await detectBackend(sessionId, ctx); + const description = opts.description.trim() || "App password"; + if (backend === "registry") { + const res = await jmap(ctx, [["x:AppPassword/set", { accountId: accountId(ctx), create: { n: { description } } }, "s"]]); + const created = setResult(res, "created"); + const secret = created && typeof created.secret === "string" ? created.secret : ""; + if (!secret) throw new AccountError("The mail server created the app password but did not return it.", 502, "upstream"); + return { id: String(created?.id ?? description), secret }; + } + // Legacy servers take a secret of our choosing and key it by name. + const secret = readableSecret(); + await legacy(ctx, { + method: "POST", + body: JSON.stringify([{ type: "addAppPassword", name: description, password: secret }]), + }); + return { id: description, secret }; +} + +export async function revokeAppPassword(sessionId: string, ctx: Ctx, id: string): Promise { + const backend = await detectBackend(sessionId, ctx); + if (backend === "registry") { + const res = await jmap(ctx, [["x:AppPassword/set", { accountId: accountId(ctx), destroy: [id] }, "s"]]); + setResult(res, "destroyed"); + return; + } + await legacy(ctx, { method: "POST", body: JSON.stringify([{ type: "removeAppPassword", name: id }]) }); +} + +/** + * Start enrolment: mint a secret and hand back the URL to show as a QR code. + * Nothing is stored until the user proves they can produce a code from it. + */ +export function beginOtpEnrolment(ctx: Ctx): { secret: string; url: string } { + const secret = generateSecret(); + return { secret, url: otpauthUrl({ secret, account: ctx.username, issuer: config.appName || "ihasmail" }) }; +} + +/** + * Prove the user can produce a code from the secret they just scanned. + * + * Stalwart validates the credentials already on the account and never looks at + * the new secret, so without this an authenticator that was mistyped or out of + * step would lock the user out of their mailbox at the next sign-in. + */ +export function assertEnrolmentCode(url: string, code: string): void { + const params = parseOtpauthUrl(url); + if (!params) throw new AccountError("That two-factor secret is not usable.", 400, "bad_otp_url"); + if (!verifyTotp(params, code)) { + throw new AccountError("That code doesn't match. Check your authenticator app and try the next code.", 400, "bad_code"); + } +} + +export async function enableOtp( + sessionId: string, + ctx: Ctx, + opts: { url: string; code: string; current: string }, +): Promise { + assertEnrolmentCode(opts.url, opts.code); + const backend = await detectBackend(sessionId, ctx); + if (backend === "registry") { + const res = await jmap(ctx, [ + [ + "x:AccountPassword/set", + { accountId: accountId(ctx), update: { [SINGLETON]: { currentSecret: opts.current, "otpAuth/otpUrl": opts.url } } }, + "s", + ], + ]); + setResult(res, "updated"); + return; + } + await assertCurrentPassword(ctx, opts.current); + await legacy(ctx, { method: "POST", body: JSON.stringify([{ type: "enableOtpAuth", url: opts.url }]) }); +} + +export async function disableOtp( + sessionId: string, + ctx: Ctx, + opts: { current: string; code: string }, +): Promise { + const backend = await detectBackend(sessionId, ctx); + if (backend === "registry") { + const res = await jmap(ctx, [ + [ + "x:AccountPassword/set", + { + accountId: accountId(ctx), + update: { [SINGLETON]: { currentSecret: opts.current, "otpAuth/otpCode": opts.code, "otpAuth/otpUrl": null } }, + }, + "s", + ], + ]); + setResult(res, "updated"); + return; + } + await assertCurrentPassword(ctx, opts.current, opts.code); + await legacy(ctx, { method: "POST", body: JSON.stringify([{ type: "disableOtpAuth", url: null }]) }); +} + +/** + * Confirm a password by authenticating with it, for the legacy endpoint that + * would otherwise take our word for it. + */ +async function assertCurrentPassword(ctx: Ctx, current: string, otpCode?: string): Promise { + const secret = otpCode ? `${current}$${otpCode}` : current; + const authorization = `Basic ${Buffer.from(`${ctx.username}:${secret}`, "utf8").toString("base64")}`; + const res = await fetch(`${config.stalwartUrl}/.well-known/jmap`, { + headers: { authorization, accept: "application/json" }, + redirect: "follow", + signal: AbortSignal.timeout(config.upstreamTimeout), + }); + if (res.status === 401 || res.status === 403) { + throw new AccountError("That password is incorrect.", 403, "bad_password"); + } + if (!res.ok) throw new UpstreamError(`Could not verify the current password (${res.status})`, 502); +} + +/** A legacy app password a person can read off a screen and type. */ +function readableSecret(): string { + const alphabet = "abcdefghijkmnopqrstuvwxyz23456789"; // no l/1/0 lookalikes + const bytes = randomBytes(20); + let out = ""; + for (let i = 0; i < 20; i++) { + if (i > 0 && i % 5 === 0) out += "-"; + out += alphabet[bytes[i]! % alphabet.length]; + } + return out; +} + +export { MASKED }; diff --git a/server/src/app.ts b/server/src/app.ts index 9fded67..238ea35 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -15,6 +15,18 @@ import { getUpstreamSession, localizeSession, } from "./upstream.js"; +import { + AccountError, + assertEnrolmentCode, + beginOtpEnrolment, + changePassword, + createAppPassword, + disableOtp, + enableOtp, + forgetBackend, + getState, + revokeAppPassword, +} from "./account.js"; import { imageProxyHandler } from "./imageproxy.js"; import { staticHandler } from "./static.js"; @@ -22,6 +34,13 @@ type Env = { Variables: { session: LiveSession } }; export const sessions = new SessionStore(config.sessionFile); const loginLimiter = new RateLimiter(config.loginRateLimit, 15 * 60_000); +/** + * Credential changes verify the current password upstream, and Stalwart's + * fail2ban counts those failures against the *caller's* IP — which for a proxy + * is shared by every user. Keep our own lid on it so one person guessing + * cannot get the whole deployment banned. + */ +const accountLimiter = new RateLimiter(10, 15 * 60_000); const HOP_BY_HOP = new Set([ "connection", @@ -215,6 +234,183 @@ export function createApp(): Hono { return c.json({ revoked: n }); }); + // ---------- Self-service credentials ---------- + /** + * Password, app passwords and 2FA. These live on the server rather than in + * the browser because the pre-0.16 API is REST rather than JMAP (the browser + * only ever sees /api/jmap), and because changing a credential means + * re-sealing the session cookie that holds it. + */ + const accountCtx = async (c: Context) => { + const session = c.get("session"); + const upstream = await getUpstreamSession(session.id, session.authorization); + return { authorization: session.authorization, session: upstream, username: session.username }; + }; + + const accountFailure = (c: Context, err: unknown) => { + if (err instanceof AccountError) { + return c.json({ error: err.code, message: err.message }, err.status as 400); + } + return upstreamFailure(c, err); + }; + + /** Guard the endpoints that check a password against brute-forcing. */ + const guarded = (c: Context): Response | null => { + const key = `account|${c.get("session").username.toLowerCase()}`; + if (accountLimiter.check(key)) return null; + c.header("Retry-After", String(accountLimiter.retryAfterSeconds(key))); + return c.json({ error: "rate_limited", message: "Too many attempts. Please wait and try again." }, 429); + }; + + api.get("/account/security", requireSession, async (c) => { + const session = c.get("session"); + try { + return c.json(await getState(session.id, await accountCtx(c))); + } catch (err) { + return accountFailure(c, err); + } + }); + + api.post("/account/password", requireSession, async (c) => { + const limited = guarded(c); + if (limited) return limited; + const session = c.get("session"); + const body = await readJson<{ current?: string; next?: string; otpCode?: string }>(c); + if (!body) return c.json({ error: "bad_request" }, 400); + const current = body.current ?? ""; + const next = body.next ?? ""; + if (!current || !next) return c.json({ error: "missing_fields", message: "Both passwords are required." }, 400); + if (next.length > 1024) return c.json({ error: "bad_request" }, 400); + if (next === current) { + return c.json({ error: "unchanged", message: "The new password matches the old one." }, 400); + } + try { + await changePassword(session.id, await accountCtx(c), { current, next, otpCode: body.otpCode?.trim() || undefined }); + } catch (err) { + return accountFailure(c, err); + } + // The old password is now dead: re-seal this session with the new one and + // drop the others, whose sealed copies would fail on their next call. + const otpCode = body.otpCode?.trim(); + sessions.reseal(getCookie(c, config.cookieName), otpCode ? `${next}$${otpCode}` : next); + forgetUpstreamSession(session.id); + const revoked = sessions.destroyAllForUser(session.username, session.id); + return c.json({ ok: true, revokedSessions: revoked }); + }); + + api.get("/account/app-passwords", requireSession, async (c) => { + const session = c.get("session"); + try { + const state = await getState(session.id, await accountCtx(c)); + return c.json({ appPasswords: state.appPasswords, keyedByName: state.appPasswordsKeyedByName }); + } catch (err) { + return accountFailure(c, err); + } + }); + + api.post("/account/app-passwords", requireSession, async (c) => { + const session = c.get("session"); + const body = await readJson<{ description?: string }>(c); + if (!body) return c.json({ error: "bad_request" }, 400); + const description = (body.description ?? "").trim().slice(0, 120); + if (!description) return c.json({ error: "missing_fields", message: "Give the app password a name." }, 400); + try { + return c.json(await createAppPassword(session.id, await accountCtx(c), { description })); + } catch (err) { + return accountFailure(c, err); + } + }); + + api.post("/account/app-passwords/revoke", requireSession, async (c) => { + const session = c.get("session"); + const body = await readJson<{ id?: string }>(c); + if (!body?.id) return c.json({ error: "bad_request" }, 400); + try { + await revokeAppPassword(session.id, await accountCtx(c), body.id); + return c.json({ ok: true }); + } catch (err) { + return accountFailure(c, err); + } + }); + + api.post("/account/2fa/begin", requireSession, async (c) => { + try { + // Nothing is stored yet; the client hands the URL back to confirm. + return c.json(beginOtpEnrolment(await accountCtx(c))); + } catch (err) { + return accountFailure(c, err); + } + }); + + api.post("/account/2fa/enable", requireSession, async (c) => { + const limited = guarded(c); + if (limited) return limited; + const session = c.get("session"); + const body = await readJson<{ url?: string; code?: string; current?: string }>(c); + if (!body?.url || !body.code || !body.current) return c.json({ error: "bad_request" }, 400); + const ctx = await accountCtx(c); + const code = body.code.trim(); + /* + * Every proxied call re-authenticates with the stored password, and once + * 2FA is on the server wants a fresh TOTP code alongside it — which we + * cannot produce between requests. An app password authenticates without + * one, so the session moves onto a dedicated app password rather than + * being signed out the moment 2FA is switched on. + * + * Order matters: mint it while the current credential still works, since + * the moment 2FA is enabled this session can no longer authenticate at all. + */ + try { + assertEnrolmentCode(body.url, code); + } catch (err) { + return accountFailure(c, err); + } + let app: { id: string; secret: string } | null = null; + try { + app = await createAppPassword(session.id, ctx, { description: appPasswordName(c) }); + } catch (err) { + // Out of app-password quota, say. 2FA is still worth having; the user + // just has to sign in again afterwards. + console.warn("[ihasmail] could not mint a session app password:", (err as Error).message); + } + try { + await enableOtp(session.id, ctx, { url: body.url, code, current: body.current }); + } catch (err) { + if (app) { + // Don't leave a credential behind for a change that never happened. + await revokeAppPassword(session.id, ctx, app.id).catch(() => {}); + } + return accountFailure(c, err); + } + let sessionKept = false; + if (app) { + sessionKept = sessions.reseal(getCookie(c, config.cookieName), app.secret); + if (sessionKept) forgetUpstreamSession(session.id); + } + // Other sessions still hold the bare password and will be refused. + const revoked = sessions.destroyAllForUser(session.username, session.id); + return c.json({ ok: true, sessionKept, revokedSessions: revoked }); + }); + + api.post("/account/2fa/disable", requireSession, async (c) => { + const limited = guarded(c); + if (limited) return limited; + const session = c.get("session"); + const body = await readJson<{ current?: string; code?: string }>(c); + if (!body?.current || !body.code) return c.json({ error: "bad_request" }, 400); + try { + await disableOtp(session.id, await accountCtx(c), { current: body.current, code: body.code.trim() }); + } catch (err) { + return accountFailure(c, err); + } + // This session may be running on the app password minted when 2FA went on; + // the plain password works again now, so put it back. + sessions.reseal(getCookie(c, config.cookieName), body.current); + forgetUpstreamSession(session.id); + forgetBackend(session.id); + return c.json({ ok: true }); + }); + // ---------- JMAP API proxy ---------- api.post("/jmap", requireSession, async (c) => { const session = c.get("session"); @@ -353,6 +549,21 @@ export function createApp(): Hono { return app; } +async function readJson(c: Context): Promise { + try { + return (await c.req.json()) as T; + } catch { + return null; + } +} + +/** Name the app password after the browser it will live in. */ +function appPasswordName(c: Context): string { + const ua = c.req.header("user-agent") ?? ""; + const browser = /Firefox\//.test(ua) ? "Firefox" : /Edg\//.test(ua) ? "Edge" : /Chrome\//.test(ua) ? "Chrome" : /Safari\//.test(ua) ? "Safari" : "browser"; + return `${config.appName} (${browser})`; +} + function sessionExtras(session: LiveSession, userLocale: string | null = null) { return { ihasmail: { diff --git a/server/src/mock/index.ts b/server/src/mock/index.ts index 0740de1..96507f5 100644 --- a/server/src/mock/index.ts +++ b/server/src/mock/index.ts @@ -5,6 +5,7 @@ */ import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { randomUUID } from "node:crypto"; +import { parseOtpauthUrl, verifyTotp } from "../totp.js"; const PORT = Number(process.env.MOCK_PORT ?? 8788); const ACCOUNT = "a1"; @@ -12,6 +13,14 @@ const USER = process.env.MOCK_USER ?? "demo@example.com"; /** 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"; +/** + * Credential state, mutable so the self-service flows can be exercised against + * the mock the way they run against a real 0.16 server: the password changes, + * 2FA starts demanding a code on every request, and app passwords keep working + * without one. + */ +export const account = { password: PASS, otpUrl: null as string | null, appPasswords: [] as Obj[] }; +const MASKED = "[********]"; type Obj = Record; const state = { n: 1 }; @@ -302,6 +311,64 @@ const handlers: Record = { }, "Email/import": (a) => { const created: Obj = {}; for (const [cid, spec] of Object.entries((a.emails as Obj) ?? {})) { const id = `e${counter++}`; emails.push({ id, blobId: (spec as Obj).blobId, threadId: `t${id}`, mailboxIds: (spec as Obj).mailboxIds, keywords: (spec as Obj).keywords ?? {}, size: 100, receivedAt: new Date().toISOString(), subject: "(imported message)", from: [{ name: null, email: "import@example" }], to: null, preview: "", hasAttachment: false, textBody: [], htmlBody: [], attachments: [], bodyValues: {} }); created[cid] = { id }; } recount(); return setResp({ created }); }, "Thread/get": (a) => { const ids = a.ids as string[]; const list = ids.map((id) => ({ id, emailIds: emails.filter((e) => e.threadId === id).sort((x, y) => String(x.receivedAt).localeCompare(String(y.receivedAt))).map((e) => e.id) })).filter((t) => t.emailIds.length); return { accountId: ACCOUNT, state: String(state.n), list, notFound: ids.filter((id) => !list.some((t) => t.id === id)) }; }, + // Stalwart 0.16 registry objects backing self-service credentials. + "x:AccountPassword/get": () => ({ + accountId: ACCOUNT, + state: String(state.n), + list: [{ id: "singleton", otpAuth: { otpUrl: account.otpUrl ? MASKED : null, otpCode: null } }], + notFound: [], + }), + "x:AccountPassword/set": (a) => { + const patch = ((a.update as Obj) ?? {})["singleton"] as Obj | undefined; + if (!patch) return setResp({ updated: {} }); + const current = patch.currentSecret as string | undefined; + const code = (patch["otpAuth/otpCode"] ?? (patch.otpAuth as Obj | undefined)?.otpCode) as string | undefined; + if (!current) { + return setResp({ notUpdated: { singleton: { type: "forbidden", description: "Current secret must be provided to change the password or OTP auth." } } }); + } + if (current !== account.password) { + return setResp({ notUpdated: { singleton: { type: "forbidden", description: "Current secret is incorrect." } } }); + } + if (account.otpUrl && !code) { + return setResp({ notUpdated: { singleton: { type: "forbidden", description: "Current OTP code is required to change the password or OTP auth." } } }); + } + if (account.otpUrl && !checkOtp(code!)) { + return setResp({ notUpdated: { singleton: { type: "forbidden", description: "Current secret is incorrect." } } }); + } + const secret = patch.secret as string | undefined; + if (secret !== undefined && secret !== MASKED) { + if (secret.length < 8) { + return setResp({ notUpdated: { singleton: { type: "invalidProperties", properties: ["secret"], description: "Password must be at least 8 characters long." } } }); + } + account.password = secret; + } + if ("otpAuth/otpUrl" in patch) { + const url = patch["otpAuth/otpUrl"] as string | null; + if (url !== MASKED) account.otpUrl = url; + } + state.n++; + return setResp({ updated: { singleton: null } }); + }, + "x:AppPassword/get": (a) => genericGet(account.appPasswords)(a), + "x:AppPassword/set": (a) => { + const created: Obj = {}; + const destroyed: string[] = []; + for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) { + const id = `ap${randomUUID().slice(0, 6)}`; + // Real app passwords carry their credential id, so the server can spot + // one by its shape alone. Mirror that. + const secret = `$app$${id}$${randomUUID().replace(/-/g, "").slice(0, 20)}`; + const row: Obj = { id, description: (obj as Obj).description ?? "App password", createdAt: new Date().toISOString(), expiresAt: null, secret }; + account.appPasswords.push(row); + created[cid] = { id, secret, createdAt: row.createdAt }; + } + for (const id of (a.destroy as string[]) ?? []) { + const i = account.appPasswords.findIndex((x) => x.id === id); + if (i >= 0) { account.appPasswords.splice(i, 1); destroyed.push(id); } + } + state.n++; + return setResp({ created, destroyed }); + }, "Identity/get": genericGet(identities), "Identity/set": genericSet(identities, "i", (o) => Object.assign(o, { replyTo: null, bcc: null, textSignature: "", htmlSignature: "", mayDelete: true, ...o })), "EmailSubmission/set": (a) => { @@ -349,11 +416,28 @@ function unauthorized(res: ServerResponse) { res.writeHead(401, { "content-type": "application/json", "www-authenticate": 'Basic realm="mock"' }); res.end(JSON.stringify({ type: "about:blank", status: 401, title: "Unauthorized" })); } +function checkOtp(code: string | undefined): boolean { + if (!account.otpUrl) return true; + const params = parseOtpauthUrl(account.otpUrl); + return Boolean(code && params && verifyTotp(params, code)); +} + function checkAuth(req: IncomingMessage): boolean { const h = req.headers.authorization ?? ""; if (!h.startsWith("Basic ")) return false; - const [u, p] = Buffer.from(h.slice(6), "base64").toString().split(":"); - return u === USER && p === PASS; + const raw = Buffer.from(h.slice(6), "base64").toString(); + const sep = raw.indexOf(":"); + if (sep < 0) return false; + const u = raw.slice(0, sep); + const p = raw.slice(sep + 1); + if (u !== USER) return false; + // App passwords are recognised by shape and skip the second factor, which is + // exactly what lets a webmail session survive 2FA being switched on. + if (account.appPasswords.some((a) => a.secret === p)) return true; + if (!account.otpUrl) return p === account.password; + const at = p.lastIndexOf("$"); + if (at < 0) return false; + return p.slice(0, at) === account.password && checkOtp(p.slice(at + 1)); } function readBody(req: IncomingMessage): Promise { return new Promise((resolve) => { const chunks: Buffer[] = []; req.on("data", (c) => chunks.push(c)); req.on("end", () => resolve(Buffer.concat(chunks))); }); @@ -377,7 +461,8 @@ function broadcast(types: string[]) { for (const c of sseClients) c.write(payload); } -createServer(async (req, res) => { +/** Exported so tests can drive the mock in-process and shut it down. */ +export const server = createServer(async (req, res) => { const url = new URL(req.url ?? "/", `http://127.0.0.1:${PORT}`); if (!checkAuth(req)) return unauthorized(res); if (url.pathname === "/.well-known/jmap" || url.pathname === "/jmap/session") { diff --git a/server/src/sessions.ts b/server/src/sessions.ts index f5c36ae..dbeb7f1 100644 --- a/server/src/sessions.ts +++ b/server/src/sessions.ts @@ -171,6 +171,30 @@ export class SessionStore { return this.toLive(stored, creds.u, creds.p); } + /** + * Re-seal this session's stored credentials. + * + * The upstream password is what every proxied call authenticates with, so a + * password change (or swapping in an app password when 2FA is switched on) + * would otherwise leave the session holding a credential the server no + * longer accepts. Needs the cookie: the sealing key is derived from the + * secret half of it, which the server never keeps. + */ + reseal(cookie: string | undefined, password: string): boolean { + if (!cookie) return false; + const idx = cookie.indexOf(COOKIE_SEP); + if (idx <= 0) return false; + const id = cookie.slice(0, idx); + const secret = cookie.slice(idx + 1); + const stored = this.sessions.get(id); + if (!stored) return false; + if (!safeEqual(stored.secretHash, sha256(secret))) return false; + const key = deriveKey(secret, config.appSecret, Buffer.from(stored.salt, "base64")); + stored.sealedCredentials = seal(JSON.stringify({ u: stored.username, p: password }), key); + this.scheduleSave(); + return true; + } + destroy(id: string): void { if (this.sessions.delete(id)) this.scheduleSave(); } diff --git a/server/src/totp.test.ts b/server/src/totp.test.ts new file mode 100644 index 0000000..6c18154 --- /dev/null +++ b/server/src/totp.test.ts @@ -0,0 +1,85 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { base32Decode, base32Encode, generateSecret, otpauthUrl, parseOtpauthUrl, verifyTotp } from "./totp.js"; + +/** RFC 6238 Appendix B seeds. */ +const SHA1_SECRET = base32Encode(Buffer.from("12345678901234567890", "ascii")); +const SHA256_SECRET = base32Encode(Buffer.from("12345678901234567890123456789012", "ascii")); + +test("base32 matches the RFC 4648 alphabet and round-trips", () => { + assert.equal(SHA1_SECRET, "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"); + assert.equal(base32Encode(Buffer.from("f", "ascii")), "MY"); + assert.equal(base32Encode(Buffer.from("foobar", "ascii")), "MZXW6YTBOI"); + assert.deepEqual(base32Decode("MZXW6YTBOI"), Buffer.from("foobar", "ascii")); + // Users paste secrets with spaces, lowercase and padding. + assert.deepEqual(base32Decode("mzxw 6ytb-oi==="), Buffer.from("foobar", "ascii")); + assert.equal(base32Decode("not base32!"), null); +}); + +test("verifyTotp accepts the RFC 6238 SHA-1 test vectors", () => { + const params = { secret: SHA1_SECRET, algorithm: "SHA1" as const, digits: 8, period: 30 }; + for (const [time, code] of [ + [59, "94287082"], + [1111111109, "07081804"], + [1111111111, "14050471"], + [1234567890, "89005924"], + [2000000000, "69279037"], + [20000000000, "65353130"], + ] as const) { + assert.equal(verifyTotp(params, code, { window: 0, now: time * 1000 }), true, `t=${time}`); + } +}); + +test("verifyTotp accepts the RFC 6238 SHA-256 test vectors", () => { + const params = { secret: SHA256_SECRET, algorithm: "SHA256" as const, digits: 8, period: 30 }; + for (const [time, code] of [ + [59, "46119246"], + [1111111109, "68084774"], + [1234567890, "91819424"], + ] as const) { + assert.equal(verifyTotp(params, code, { window: 0, now: time * 1000 }), true, `t=${time}`); + } +}); + +test("verifyTotp rejects wrong, malformed and mis-sized codes", () => { + const params = { secret: SHA1_SECRET, algorithm: "SHA1" as const, digits: 8, period: 30 }; + const at = { window: 0, now: 59_000 }; + assert.equal(verifyTotp(params, "94287083", at), false); + assert.equal(verifyTotp(params, "9428708", at), false, "too short"); + assert.equal(verifyTotp(params, "942870822", at), false, "too long"); + assert.equal(verifyTotp(params, "abcdefgh", at), false); + assert.equal(verifyTotp(params, "", at), false); + assert.equal(verifyTotp({ ...params, secret: "!!!" }, "94287082", at), false, "bad secret"); +}); + +test("the skew window covers a step either side and no further", () => { + const params = { secret: SHA1_SECRET, algorithm: "SHA1" as const, digits: 8, period: 30 }; + // 94287082 is the code for the step containing t=59. + assert.equal(verifyTotp(params, "94287082", { window: 1, now: 89_000 }), true, "one step late"); + assert.equal(verifyTotp(params, "94287082", { window: 1, now: 29_000 }), true, "one step early"); + assert.equal(verifyTotp(params, "94287082", { window: 1, now: 119_000 }), false, "two steps late"); +}); + +test("otpauth URLs round-trip through the parser", () => { + const secret = generateSecret(); + const url = otpauthUrl({ secret, account: "ann@example.org", issuer: "ihasmail" }); + assert.match(url, /^otpauth:\/\/totp\/ihasmail:ann%40example\.org\?/); + const parsed = parseOtpauthUrl(url); + assert.deepEqual(parsed, { secret, algorithm: "SHA1", digits: 6, period: 30 }); +}); + +test("generated secrets are 160-bit and distinct", () => { + const a = generateSecret(); + const b = generateSecret(); + assert.equal(base32Decode(a)?.length, 20); + assert.notEqual(a, b); +}); + +test("parseOtpauthUrl rejects anything that is not a usable TOTP URL", () => { + assert.equal(parseOtpauthUrl("https://example.org"), null); + assert.equal(parseOtpauthUrl("otpauth://hotp/a?secret=GEZDGNBV"), null, "counter-based"); + assert.equal(parseOtpauthUrl("otpauth://totp/a"), null, "no secret"); + assert.equal(parseOtpauthUrl("otpauth://totp/a?secret=!!!"), null, "unusable secret"); + assert.equal(parseOtpauthUrl("otpauth://totp/a?secret=GEZDGNBV&algorithm=MD5"), null); + assert.equal(parseOtpauthUrl("otpauth://totp/a?secret=GEZDGNBV&digits=99"), null); +}); diff --git a/server/src/totp.ts b/server/src/totp.ts new file mode 100644 index 0000000..ee099bb --- /dev/null +++ b/server/src/totp.ts @@ -0,0 +1,145 @@ +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; + +/** + * TOTP (RFC 6238) — just enough to enrol a second factor safely. + * + * Stalwart stores the otpauth:// URL and checks codes at login, but it does + * *not* check the new secret when 2FA is switched on: it verifies the + * credentials that are already on the account. A user whose authenticator was + * mistyped or whose clock has drifted would be locked out of their mailbox at + * the next sign-in. So ihasmail proves the enrolment itself, before asking the + * server to store anything. + */ + +export interface TotpParams { + secret: string; + algorithm: "SHA1" | "SHA256" | "SHA512"; + digits: number; + period: number; +} + +const DEFAULTS: Omit = { algorithm: "SHA1", digits: 6, period: 30 }; +const BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +export function base32Encode(buf: Buffer): string { + let bits = 0; + let value = 0; + let out = ""; + for (const byte of buf) { + value = (value << 8) | byte; + bits += 8; + while (bits >= 5) { + out += BASE32[(value >>> (bits - 5)) & 31]; + bits -= 5; + } + } + if (bits > 0) out += BASE32[(value << (5 - bits)) & 31]; + return out; +} + +/** Decode base32, tolerating lowercase, padding and the spaces users paste. */ +export function base32Decode(input: string): Buffer | null { + const clean = input.replace(/[\s-]/g, "").replace(/=+$/, "").toUpperCase(); + if (!clean || /[^A-Z2-7]/.test(clean)) return null; + let bits = 0; + let value = 0; + const out: number[] = []; + for (const ch of clean) { + value = (value << 5) | BASE32.indexOf(ch); + bits += 5; + if (bits >= 8) { + out.push((value >>> (bits - 8)) & 255); + bits -= 8; + } + } + return Buffer.from(out); +} + +/** A fresh 160-bit secret — the size RFC 4226 recommends for HMAC-SHA1. */ +export function generateSecret(): string { + return base32Encode(randomBytes(20)); +} + +/** + * Build the otpauth:// URL that authenticator apps scan and Stalwart stores. + * The label is "issuer:account" with the issuer repeated as a parameter, which + * is what totp-rs (Stalwart's parser) and every common app expect. + */ +export function otpauthUrl(opts: { secret: string; account: string; issuer: string }): string { + const label = `${encodeURIComponent(opts.issuer)}:${encodeURIComponent(opts.account)}`; + const params = new URLSearchParams({ + secret: opts.secret, + issuer: opts.issuer, + algorithm: DEFAULTS.algorithm, + digits: String(DEFAULTS.digits), + period: String(DEFAULTS.period), + }); + return `otpauth://totp/${label}?${params.toString()}`; +} + +export function parseOtpauthUrl(url: string): TotpParams | null { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return null; + } + if (parsed.protocol !== "otpauth:" || parsed.host.toLowerCase() !== "totp") return null; + const secret = parsed.searchParams.get("secret"); + if (!secret || !base32Decode(secret)) return null; + const algorithm = (parsed.searchParams.get("algorithm") ?? DEFAULTS.algorithm).toUpperCase(); + if (algorithm !== "SHA1" && algorithm !== "SHA256" && algorithm !== "SHA512") return null; + const digits = Number(parsed.searchParams.get("digits") ?? DEFAULTS.digits); + const period = Number(parsed.searchParams.get("period") ?? DEFAULTS.period); + if (!Number.isInteger(digits) || digits < 6 || digits > 10) return null; + if (!Number.isInteger(period) || period < 5 || period > 300) return null; + return { secret, algorithm, digits, period }; +} + +/** The HOTP code for one counter value. */ +function hotp(key: Buffer, counter: number, algorithm: string, digits: number): string { + const buf = Buffer.alloc(8); + buf.writeBigUInt64BE(BigInt(counter)); + const digest = createHmac(algorithm.toLowerCase(), key).update(buf).digest(); + const offset = digest[digest.length - 1]! & 0x0f; + const binary = digest.readUInt32BE(offset) & 0x7fffffff; + return (binary % 10 ** digits).toString().padStart(digits, "0"); +} + +/** The code an authenticator app would show at `now`. */ +export function totpCode(params: TotpParams, now = Date.now()): string { + const key = base32Decode(params.secret); + if (!key || !key.length) throw new Error("unusable TOTP secret"); + return hotp(key, Math.floor(now / 1000 / params.period), params.algorithm, params.digits); +} + +/** + * Check a user-supplied code, allowing `window` steps of clock skew either way + * (one step = 30s by default, so the default tolerates ±30s). + */ +export function verifyTotp(params: TotpParams, code: string, opts: { window?: number; now?: number } = {}): boolean { + const digits = params.digits; + const cleaned = code.replace(/\s/g, ""); + if (cleaned.length !== digits || !/^\d+$/.test(cleaned)) return false; + const key = base32Decode(params.secret); + if (!key || !key.length) return false; + const window = opts.window ?? 1; + const counter = Math.floor((opts.now ?? Date.now()) / 1000 / params.period); + let ok = false; + // Check every candidate rather than returning early, so the time taken does + // not reveal which step matched. + for (let i = -window; i <= window; i++) { + const step = counter + i; + if (step < 0) continue; // only reachable for times within a step of the epoch + const expected = hotp(key, step, params.algorithm, digits); + if (safeEqual(expected, cleaned)) ok = true; + } + return ok; +} + +function safeEqual(a: string, b: string): boolean { + const ba = Buffer.from(a); + const bb = Buffer.from(b); + if (ba.length !== bb.length) return false; + return timingSafeEqual(ba, bb); +} diff --git a/web/package.json b/web/package.json index 759e7ed..06ba186 100644 --- a/web/package.json +++ b/web/package.json @@ -14,6 +14,7 @@ "@tanstack/react-virtual": "^3.13.2", "dompurify": "^3.2.4", "lucide-react": "^0.477.0", + "qrcode-generator": "^2.0.4", "react": "^19.0.0", "react-dom": "^19.0.0", "wouter": "^3.6.0", diff --git a/web/src/ui/qrcode.tsx b/web/src/ui/qrcode.tsx new file mode 100644 index 0000000..1d15520 --- /dev/null +++ b/web/src/ui/qrcode.tsx @@ -0,0 +1,41 @@ +import { useMemo } from "react"; +import qrcode from "qrcode-generator"; + +/** + * A QR code as inline SVG. + * + * Drawn as one path of square modules so it scales cleanly and inherits the + * current colour, which keeps it legible in both themes without a second + * rendering path. Error correction is set to M: enough tolerance for a phone + * camera pointed at a screen, without inflating the module count. + */ +export function QrCode({ value, size = 200, title }: { value: string; size?: number; title?: string }) { + const { path, count } = useMemo(() => { + const qr = qrcode(0, "M"); + qr.addData(value); + qr.make(); + const count = qr.getModuleCount(); + let path = ""; + for (let row = 0; row < count; row++) { + for (let col = 0; col < count; col++) { + if (qr.isDark(row, col)) path += `M${col} ${row}h1v1h-1z`; + } + } + return { path, count }; + }, [value]); + + return ( + + {title ?? "QR code"} + + + ); +} diff --git a/web/src/views/settings/SecuritySettings.tsx b/web/src/views/settings/SecuritySettings.tsx index da0842e..ffa6fc7 100644 --- a/web/src/views/settings/SecuritySettings.tsx +++ b/web/src/views/settings/SecuritySettings.tsx @@ -1,9 +1,11 @@ -import { useEffect, useState } from "react"; -import { apiFetch } from "@/jmap/client"; +import { useCallback, useEffect, useState } from "react"; +import { Copy, KeyRound, ShieldCheck, Smartphone } from "lucide-react"; +import { apiFetch, ApiError } from "@/jmap/client"; import { useSession } from "@/store/session"; import { formatFullDate } from "@/lib/format"; import { toast } from "@/ui/toast"; -import { confirmDialog } from "@/ui/dialog"; +import { confirmDialog, Dialog } from "@/ui/dialog"; +import { QrCode } from "@/ui/qrcode"; interface SessionRow { id: string; @@ -16,19 +18,72 @@ interface SessionRow { ip: string; } +interface AppPasswordRow { + id: string; + description: string; + createdAt: string | null; + expiresAt: string | null; +} + +interface SecurityState { + backend: "registry" | "legacy"; + otpEnabled: boolean; + appPasswords: AppPasswordRow[]; + appPasswordsKeyedByName: boolean; +} + export function SecuritySettings() { const [rows, setRows] = useState(null); const [current, setCurrent] = useState(""); + const [state, setState] = useState(null); + /** Set when the server has no self-service API at all (pre-0.15 or a proxy). */ + const [unsupported, setUnsupported] = useState(null); const session = useSession((s) => s.session); const logout = useSession((s) => s.logout); + const load = () => apiFetch<{ current: string; sessions: SessionRow[] }>("/api/auth/sessions").then((r) => { setRows(r.sessions); setCurrent(r.current); }).catch(() => setRows([])); + + const loadSecurity = useCallback(async () => { + try { + setState(await apiFetch("/api/account/security")); + setUnsupported(null); + } catch (err) { + setState(null); + setUnsupported(err instanceof ApiError && err.status === 501 ? err.message : (err as Error).message); + } + }, []); + useEffect(() => { void load(); - }, []); + void loadSecurity(); + }, [loadSecurity]); + return (

Security & sessions

You're signed in as {session?.username}. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.

+ +

Password

+ {unsupported ? ( +

{unsupported}

+ ) : ( + { void load(); }} /> + )} + +

Two-factor authentication

+ {unsupported ? ( +

Two-factor authentication is managed by your mail administrator.

+ ) : ( + { await loadSecurity(); await load(); }} /> + )} + +

App passwords

+ {unsupported ? ( +

App passwords are managed by your mail administrator.

+ ) : ( + + )} +

Active webmail sessions

{rows === null ?

Loading…

: ( @@ -50,8 +105,290 @@ export function SecuritySettings() { -

Password & two-factor

-

Password changes, app passwords and 2FA are managed by your mail administrator or via Stalwart's self-service portal.

+ + ); +} + +/* ------------------------------------------------------------------ */ + +function PasswordForm({ otpEnabled, onChanged }: { otpEnabled: boolean; onChanged: () => void }) { + const [current, setCurrent] = useState(""); + const [next, setNext] = useState(""); + const [confirm, setConfirm] = useState(""); + const [code, setCode] = useState(""); + const [busy, setBusy] = useState(false); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + if (next !== confirm) { + toast.error("The new passwords don't match"); + return; + } + setBusy(true); + try { + const res = await apiFetch<{ revokedSessions: number }>("/api/account/password", { + method: "POST", + body: JSON.stringify({ current, next, otpCode: code || undefined }), + }); + setCurrent(""); setNext(""); setConfirm(""); setCode(""); + toast.success(res.revokedSessions ? `Password changed. ${res.revokedSessions} other session(s) signed out.` : "Password changed"); + onChanged(); + } catch (err) { + toast.error((err as Error).message); + } finally { + setBusy(false); + } + }; + + return ( + +

Changing your password signs out your other webmail sessions. Any app passwords keep working.

+
+ + setCurrent(e.target.value)} required /> +
+ {otpEnabled && ( +
+ + setCode(e.target.value)} placeholder="123456" required /> +
+ )} +
+
+ + setNext(e.target.value)} required /> +
+
+ + setConfirm(e.target.value)} required /> +
+
+ + + ); +} + +/* ------------------------------------------------------------------ */ + +function TwoFactor({ state, reload }: { state: SecurityState | null; reload: () => Promise }) { + const [setup, setSetup] = useState<{ secret: string; url: string } | null>(null); + const [code, setCode] = useState(""); + const [password, setPassword] = useState(""); + const [busy, setBusy] = useState(false); + const [disabling, setDisabling] = useState(false); + + if (!state) return

Loading…

; + + const begin = async () => { + try { + setSetup(await apiFetch<{ secret: string; url: string }>("/api/account/2fa/begin", { method: "POST", body: "{}" })); + setCode(""); setPassword(""); + } catch (err) { + toast.error((err as Error).message); + } + }; + + const enable = async () => { + if (!setup) return; + setBusy(true); + try { + const res = await apiFetch<{ sessionKept: boolean }>("/api/account/2fa/enable", { + method: "POST", + body: JSON.stringify({ url: setup.url, code, current: password }), + }); + setSetup(null); + await reload(); + if (res.sessionKept) { + toast.success("Two-factor authentication is on. This browser stays signed in."); + } else { + toast.success("Two-factor authentication is on. You'll need to sign in again with a code."); + } + } catch (err) { + toast.error((err as Error).message); + } finally { + setBusy(false); + } + }; + + const disable = async () => { + setBusy(true); + try { + await apiFetch("/api/account/2fa/disable", { method: "POST", body: JSON.stringify({ current: password, code }) }); + setDisabling(false); + await reload(); + toast.success("Two-factor authentication is off"); + } catch (err) { + toast.error((err as Error).message); + } finally { + setBusy(false); + } + }; + + return ( +
+

+ {state.otpEnabled + ? "Signing in requires a code from your authenticator app as well as your password." + : "Add a one-time code from an authenticator app to your sign-in, so a stolen password isn't enough on its own."} +

+
+ + {state.otpEnabled ? "Enabled" : "Not enabled"} + {state.otpEnabled + ? + : } +
+ + setSetup(null)} title="Set up two-factor authentication" size="md" + footer={<> + + + }> + {setup && ( +
+
    +
  1. Scan this with your authenticator app.
  2. +
  3. Enter the six-digit code it shows, and your password.
  4. +
+
+ +
+
+ + +
+
+ + setCode(e.target.value)} placeholder="123456" /> +
+
+ + setPassword(e.target.value)} /> +
+
+
+

Codes are checked before anything is saved, so a mistyped key can't lock you out.

+
+ )} +
+ + setDisabling(false)} title="Turn off two-factor authentication" size="sm" + footer={<> + + + }> +

Your password alone will be enough to sign in again.

+
+ + setPassword(e.target.value)} /> +
+
+ + setCode(e.target.value)} placeholder="123456" /> +
+
+
+ ); +} + +/* ------------------------------------------------------------------ */ + +function AppPasswords({ state, reload }: { state: SecurityState | null; reload: () => Promise }) { + const [name, setName] = useState(""); + const [busy, setBusy] = useState(false); + const [issued, setIssued] = useState<{ description: string; secret: string } | null>(null); + + if (!state) return

Loading…

; + + const create = async (e: React.FormEvent) => { + e.preventDefault(); + setBusy(true); + try { + const res = await apiFetch<{ id: string; secret: string }>("/api/account/app-passwords", { + method: "POST", + body: JSON.stringify({ description: name }), + }); + setIssued({ description: name, secret: res.secret }); + setName(""); + await reload(); + } catch (err) { + toast.error((err as Error).message); + } finally { + setBusy(false); + } + }; + + const revoke = async (row: AppPasswordRow) => { + const ok = await confirmDialog({ + title: `Revoke "${row.description}"?`, + message: "Anything signed in with this password stops working immediately.", + confirmLabel: "Revoke", + danger: true, + }); + if (!ok) return; + try { + await apiFetch("/api/account/app-passwords/revoke", { method: "POST", body: JSON.stringify({ id: row.id }) }); + await reload(); + toast.success("App password revoked"); + } catch (err) { + toast.error((err as Error).message); + } + }; + + return ( +
+

+ A separate password for a mail app or device, which you can revoke on its own. App passwords skip two-factor codes, so they keep working in apps that can't ask for one. +

+ {state.appPasswords.length > 0 && ( +
+ {!state.appPasswordsKeyedByName && } + + {state.appPasswords.map((row) => ( + + + {!state.appPasswordsKeyedByName && } + + + ))} + +
NameCreated
{row.description}{row.createdAt ? formatFullDate(row.createdAt) : "—"}
+ )} +
+
+ + setName(e.target.value)} placeholder="Thunderbird on my laptop" required /> +
+ +
+ {state.appPasswordsKeyedByName &&

This mail server identifies app passwords by name, so give each one a different name.

} + + setIssued(null)} title="Your new app password" size="sm" + footer={}> + {issued && ( +
+

Copy it into {issued.description} now — it isn't shown again.

+ +

Use your usual address as the username.

+
+ )} +
+
+ ); +} + +function CopyableSecret({ value }: { value: string }) { + return ( +
+ {value} +
); } From c55b54163ff1f2c3b168091711640d8dd53086c8 Mon Sep 17 00:00:00 2001 From: John Ellis Date: Mon, 24 Aug 2026 08:32:10 -0700 Subject: [PATCH 2/6] Read the locale where users can actually read it, and say which Stalwart answered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- server/src/accountinfo.test.ts | 51 +++++++++++ server/src/app.ts | 17 ++-- server/src/mock/index.ts | 12 +++ server/src/upstream.ts | 107 ++++++++++++++++++----- web/src/jmap/types.ts | 6 ++ web/src/store/settings.ts | 18 ++++ web/src/views/AppShell.tsx | 29 +++++- web/src/views/settings/AboutSettings.tsx | 14 +++ 8 files changed, 223 insertions(+), 31 deletions(-) create mode 100644 server/src/accountinfo.test.ts 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; +} From 337c46ebdad4147898248126b818cb862ea74dc3 Mon Sep 17 00:00:00 2001 From: John Ellis Date: Mon, 24 Aug 2026 08:41:15 -0700 Subject: [PATCH 3/6] Treat an unreadable backend probe as the older server, not an error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stalwart before 0.16 does not know urn:stalwart:jmap, and rejects the whole request rather than the one call when `using` names a capability it cannot parse. The probe is only sent when the session advertises that capability, so this should not arise — but if it ever does, throwing turns a server we can still manage credentials on into a Security page that only shows an error. Fall through to the endpoint those servers do have. --- server/src/account.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/server/src/account.ts b/server/src/account.ts index 7a95d25..80f769b 100644 --- a/server/src/account.ts +++ b/server/src/account.ts @@ -83,11 +83,17 @@ async function probeBackend(ctx: Ctx): Promise { // A server with the registry answers x:AccountPassword/get; one without it // fails to parse the method name at all and returns unknownMethod. if (ctx.session.capabilities && STALWART_CAP in ctx.session.capabilities) { - const res = await jmap(ctx, [["x:AccountPassword/get", { accountId: accountId(ctx), ids: [SINGLETON] }, "p"]]); - const [name, args] = res.methodResponses?.[0] ?? []; - if (name && name !== "error") return "registry"; - const type = (args as { type?: string } | undefined)?.type; - if (type && type !== "unknownMethod") return "registry"; // present, but refused us + try { + const res = await jmap(ctx, [["x:AccountPassword/get", { accountId: accountId(ctx), ids: [SINGLETON] }, "p"]]); + const [name, args] = res.methodResponses?.[0] ?? []; + if (name && name !== "error") return "registry"; + const type = (args as { type?: string } | undefined)?.type; + if (type && type !== "unknownMethod") return "registry"; // present, but refused us + } catch { + // Not an answer we can read - most likely a server too old to know the + // capability we named, which rejects the whole request rather than the + // one call. Fall through and try the endpoint such servers do have. + } } return "legacy"; } From ad0b913efb6663de1c8fd1f1659520c50604b229 Mon Sep 17 00:00:00 2001 From: John Ellis Date: Mon, 24 Aug 2026 09:04:16 -0700 Subject: [PATCH 4/6] Fix two things live testing on 0.15.5 turned up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **About said "not detected".** Generation was only worked out from the reply to a registry method, which we never send to a server that does not advertise urn:stalwart:jmap — every 0.16 build does, and nothing older knows the capability at all, so its absence is already the answer. Say so, instead of shrugging. A session with no capabilities at all stays unknown, which is a different thing from old. **The caret jumped out of the OTP field after one digit.** Dialog's autofocus effect listed onClose in its dependencies, and every caller passes an inline arrow, so each keystroke in a dialog holding state tore the effect down, set it up again, and refocused the first field — which in the disable-2FA dialog is the password. Keep the handler in a ref so the effect depends only on `open`. This was a bug in the shared dialog rather than in one screen; every dialog with more than one field had it. The test for it fails against the old dependency array, not just passes against the new one. --- server/src/accountinfo.test.ts | 18 ++++- server/src/upstream.ts | 9 ++- web/src/ui/__tests__/dialog-focus.test.tsx | 82 ++++++++++++++++++++++ web/src/ui/dialog.tsx | 14 +++- 4 files changed, 119 insertions(+), 4 deletions(-) create mode 100644 web/src/ui/__tests__/dialog-focus.test.tsx diff --git a/server/src/accountinfo.test.ts b/server/src/accountinfo.test.ts index c9e1f34..e018e3d 100644 --- a/server/src/accountinfo.test.ts +++ b/server/src/accountinfo.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { interpretAccountInfo } from "./upstream.js"; +import { getAccountInfo, interpretAccountInfo } from "./upstream.js"; /** * The account locale used to be read only from `x:Account/get`, which needs @@ -49,3 +49,19 @@ 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); }); + +test("a server that never heard of the Stalwart capability is reported as pre-0.16", async () => { + // 0.16 always advertises urn:stalwart:jmap and nothing older knows it at all, + // so its absence is the answer - and asking anyway would fail the whole + // request on those servers. This is what the live 0.15.5 box hits. + const session = { capabilities: { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} }, accounts: {}, primaryAccounts: {} }; + const info = await getAccountInfo("session-pre-016", "Basic x", session as never); + assert.equal(info.generation, "pre-0.16"); + assert.equal(info.locale, null); + assert.equal(info.edition, null); +}); + +test("no capabilities at all leaves the generation unknown", async () => { + const info = await getAccountInfo("session-no-caps", "Basic x", { accounts: {}, primaryAccounts: {} } as never); + assert.equal(info.generation, null); +}); diff --git a/server/src/upstream.ts b/server/src/upstream.ts index 36f14ee..ea5036c 100644 --- a/server/src/upstream.ts +++ b/server/src/upstream.ts @@ -85,6 +85,8 @@ export interface AccountInfo { const infoCache = new Map(); const INFO_CACHE_MS = 30 * 60_000; const EMPTY_INFO: AccountInfo = { locale: null, generation: null, edition: null }; +/** A server that has never heard of the registry: nothing to read, but dated. */ +const PRE_REGISTRY_INFO: AccountInfo = { locale: null, generation: "pre-0.16", edition: null }; /** * glibc modifiers that name a script rather than a dialect or a currency: @@ -138,7 +140,12 @@ export function normalizeLocale(raw: unknown): string | null { * tells us which generation we are talking to. */ async function fetchAccountInfo(authorization: string, session: UpstreamSession): Promise { - if (!session.capabilities || !(STALWART_CAP in session.capabilities)) return EMPTY_INFO; + // Every 0.16 build advertises urn:stalwart:jmap, and no earlier one knows it + // at all, so its absence already answers the question — and asking anyway + // would fail the whole request, since those servers reject a `using` naming + // a capability they cannot parse. + if (!session.capabilities) return EMPTY_INFO; + if (!(STALWART_CAP in session.capabilities)) return PRE_REGISTRY_INFO; const accountId = session.primaryAccounts?.[STALWART_CAP] ?? session.primaryAccounts?.["urn:ietf:params:jmap:mail"] ?? diff --git a/web/src/ui/__tests__/dialog-focus.test.tsx b/web/src/ui/__tests__/dialog-focus.test.tsx new file mode 100644 index 0000000..7f8e924 --- /dev/null +++ b/web/src/ui/__tests__/dialog-focus.test.tsx @@ -0,0 +1,82 @@ +import { act, useState } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { Dialog } from "../dialog"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +/** + * Dialogs are almost always given an inline arrow for onClose, so its identity + * changes on every render of the parent. While that was in the effect's + * dependencies, any dialog holding state tore the effect down and set it up + * again on each keystroke — and its autofocus dragged the caret back to the + * first field. Typing a digit into the second field jumped you to the first. + */ + +/** A dialog with two fields, whose parent re-renders as either is typed in. */ +function TwoFieldDialog() { + const [first, setFirst] = useState(""); + const [second, setSecond] = useState(""); + return ( + undefined} title="Two fields"> + setFirst(e.target.value)} /> + setSecond(e.target.value)} /> + + ); +} + +describe("Dialog focus handling", () => { + let host: HTMLDivElement; + let root: Root; + + beforeEach(() => { + host = document.createElement("div"); + document.body.appendChild(host); + root = createRoot(host); + }); + + afterEach(() => { + act(() => root.unmount()); + host.remove(); + }); + + const type = (el: HTMLInputElement, value: string) => { + act(() => { + el.focus(); + // What React's onChange sees when a character is typed. + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + setter.call(el, value); + el.dispatchEvent(new Event("input", { bubbles: true })); + }); + }; + + it("autofocuses the first field when it opens", async () => { + act(() => root.render()); + await act(async () => { + await new Promise((r) => setTimeout(r, 30)); + }); + expect(document.activeElement?.id).toBe("first"); + }); + + it("leaves the caret alone while a later field is typed in", async () => { + act(() => root.render()); + await act(async () => { + await new Promise((r) => setTimeout(r, 30)); + }); + + const second = document.getElementById("second") as HTMLInputElement; + type(second, "1"); + // The old effect re-ran here and pulled focus back to the first field. + await act(async () => { + await new Promise((r) => setTimeout(r, 30)); + }); + expect(document.activeElement?.id).toBe("second"); + + type(second, "12"); + await act(async () => { + await new Promise((r) => setTimeout(r, 30)); + }); + expect(document.activeElement?.id).toBe("second"); + expect(second.value).toBe("12"); + }); +}); diff --git a/web/src/ui/dialog.tsx b/web/src/ui/dialog.tsx index cc0e46b..b746d77 100644 --- a/web/src/ui/dialog.tsx +++ b/web/src/ui/dialog.tsx @@ -16,13 +16,23 @@ interface DialogProps { export function Dialog({ open, onClose, title, children, footer, size = "md", closeOnBackdrop = true, className }: DialogProps) { const ref = useRef(null); + /* + * Callers almost always pass an inline arrow for onClose, so its identity + * changes on every render of the parent. Depending on it here would tear the + * effect down and set it up again on every keystroke in a dialog that holds + * state, and the autofocus below would drag the caret back to the first + * field mid-typing. Keep the latest handler in a ref instead, so the effect + * depends only on `open`. + */ + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; useEffect(() => { if (!open) return; const prev = document.activeElement as HTMLElement | null; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { e.stopPropagation(); - onClose(); + onCloseRef.current(); } if (e.key === "Tab" && ref.current) { const focusables = ref.current.querySelectorAll('button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"]),[contenteditable="true"]'); @@ -48,7 +58,7 @@ export function Dialog({ open, onClose, title, children, footer, size = "md", cl document.removeEventListener("keydown", onKey, true); prev?.focus?.(); }; - }, [open, onClose]); + }, [open]); if (!open) return null; return createPortal(
Date: Mon, 24 Aug 2026 09:08:43 -0700 Subject: [PATCH 5/6] Record what the live 0.15.5 run actually verified MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The REST credential path is no longer untested: password change, app passwords and enabling and disabling 2FA were all exercised against the live server on a real mailbox. The registry path is still mock-only. Also correct the locale line. Both methods it can use are 0.16 ones, so on an older server neither is reachable and the browser locale still wins — the fix helps 0.16+ users, and the entry should not imply otherwise. --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 842e899..32c34a0 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ ihasmail is a JMAP-first web client: mail, calendars, contacts, files, filters a **Settings** - **Dates & times**: language/region (every one of the ~620 locales CLDR has data for, each named in its own language and script), date order (locale default, `22.11.2025`, `22/11/2025`, `11/22/2025` or ISO `2025-11-22`) and 12h/24h clock, applied everywhere — message list and headers, calendar, contacts, files, sessions. The default comes from the locale configured for the account in Stalwart (`x:Account/get`), falling back to the browser's; POSIX forms are normalised (`de_DE.UTF-8` → `de-DE`) and script modifiers preserved (`sr_RS@latin` → `sr-Latn-RS`). Numerals follow the locale (`٢٢.١١.٢٠٢٥` for `ar-EG`), except under ISO 8601, which pins date *and* clock to Latin digits. Dates are **entered** through custom pickers in the same format (browsers render `` in their own locale and ignore the page's), with a calendar popover, a time list, keyboard navigation, and lenient typing — `22.11.`, `221125`, `6:23pm` and bare ISO all parse -- **Self-service credentials** in Settings › Security: change your password, manage **app passwords** (a separate password per mail app or device, revocable on its own), and turn **two-factor authentication** on or off by scanning a QR code. Enrolment codes are verified before anything is stored, so a mistyped key cannot lock you out, and switching 2FA on moves this browser's session onto a dedicated app password instead of signing you straight back out. Works against both Stalwart generations: the `x:AccountPassword` / `x:AppPassword` registry objects on 0.16+, and the `/api/account/auth` REST endpoint on 0.15.x +- **Self-service credentials** in Settings › Security: change your password, manage **app passwords** (a separate password per mail app or device, revocable on its own), and turn **two-factor authentication** on or off by scanning a QR code. Enrolment codes are verified before anything is stored, so a mistyped key cannot lock you out, and switching 2FA on moves this browser's session onto a dedicated app password instead of signing you straight back out. Works against both Stalwart generations: the `x:AccountPassword` / `x:AppPassword` registry objects on 0.16+, and the `/api/account/auth` REST endpoint on 0.15.x (the latter confirmed live) - Identities & signatures, **Sieve filters** (visual rule builder that round-trips to a Sieve script, plus a raw script editor with server-side validation), out-of-office (`VacationResponse`), folders, labels, templates, notifications, calendar defaults, sessions (sign out other devices), keyboard shortcuts, import/export of settings **Platform** @@ -133,10 +133,10 @@ Verified against the mock server and, for the core mail flows, against a live St - **HTML signatures** — Stalwart caps identity signatures at 2 KB. ihasmail compacts pasted HTML, moves images to Files and, if still too large, keeps the full signature in Files behind a short marker (other clients see a text fallback). The end-to-end flow (save → compose → send with inline logo) is implemented but not yet confirmed on the live server. - **Files** — the live server runs an older Stalwart build than `main`; `FileNode/query` there rejects `isTopLevel`/`parentId` filters, so ihasmail falls back to listing all nodes and building the tree client-side. Upload/rename/move/delete still need a live pass. -- **Self-service credentials** — verified end to end against the mock (which enforces the same rules: current password required, password policy, a TOTP code on every request once 2FA is on, app passwords exempt from it). The **0.16 registry path is not yet confirmed against a real 0.16 server**, and the **0.15.x REST path is not confirmed at all**; the live server is 0.15.5. Password changes are also refused by Stalwart for accounts backed by an external directory (LDAP/SQL/OIDC) — the server's own message is shown when that happens. +- **Self-service credentials** — the **0.15.x REST path is confirmed live** against Stalwart 0.15.5 (2026-08-24): password change, app passwords, and enabling and disabling 2FA, on a real mailbox. The **0.16 registry path has only been exercised against the mock**, which enforces the same rules a real server does (current password required, password policy, a TOTP code on every request once 2FA is on, app passwords exempt from it) — it still wants a pass against a real 0.16 server. Password changes are refused by Stalwart for accounts backed by an external directory (LDAP/SQL/OIDC); the server's own message is shown when that happens. - Recurring events: colour/category/edit/delete apply to the whole series (per-occurrence overrides aren't supported by the server yet). - Editable date boxes are always Gregorian and in Latin digits, even for locales whose *display* uses another calendar or numbering system (`fa-IR`, `th-TH`, `ar-EG`) — they keep the locale's field order and separator, but a Buddhist-era year in a text box does not round-trip against the Gregorian calendar grid. Non-Gregorian calendar support is not implemented. -- The account locale is read with Stalwart's `x:Account/get`, which needs the `sysAccountGet` permission; where a regular user is not granted it, ihasmail silently falls back to the browser locale and the setting can be chosen by hand. +- The account locale is read from `x:AccountSettings/get`, whose permission the built-in user role has, falling back to `x:Account/get` (which needs the admin-only `sysAccountGet`). Both are Stalwart 0.16 methods: **on older servers neither is reachable** — they do not implement the registry and reject a request that so much as names the `urn:stalwart:jmap` capability — so there the locale still falls back to the browser's and can be chosen by hand. ## Roadmap / not yet From c13a5375ab3eb2751f135178491db7162e5e2047 Mon Sep 17 00:00:00 2001 From: John Ellis Date: Mon, 24 Aug 2026 09:12:19 -0700 Subject: [PATCH 6/6] Finish the README pass: stale locale method, and the two new bits of UI The Dates & times entry still named x:Account/get as where the default locale comes from, which this branch changed. It also never mentioned the top-bar light/dark toggle or what About now reports about the server. --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 32c34a0..9ef8f49 100644 --- a/README.md +++ b/README.md @@ -52,13 +52,15 @@ ihasmail is a JMAP-first web client: mail, calendars, contacts, files, filters a - Browse folders, upload (drag & drop), download, create folders, rename, move, delete **Settings** -- **Dates & times**: language/region (every one of the ~620 locales CLDR has data for, each named in its own language and script), date order (locale default, `22.11.2025`, `22/11/2025`, `11/22/2025` or ISO `2025-11-22`) and 12h/24h clock, applied everywhere — message list and headers, calendar, contacts, files, sessions. The default comes from the locale configured for the account in Stalwart (`x:Account/get`), falling back to the browser's; POSIX forms are normalised (`de_DE.UTF-8` → `de-DE`) and script modifiers preserved (`sr_RS@latin` → `sr-Latn-RS`). Numerals follow the locale (`٢٢.١١.٢٠٢٥` for `ar-EG`), except under ISO 8601, which pins date *and* clock to Latin digits. Dates are **entered** through custom pickers in the same format (browsers render `` in their own locale and ignore the page's), with a calendar popover, a time list, keyboard navigation, and lenient typing — `22.11.`, `221125`, `6:23pm` and bare ISO all parse +- **Dates & times**: language/region (every one of the ~620 locales CLDR has data for, each named in its own language and script), date order (locale default, `22.11.2025`, `22/11/2025`, `11/22/2025` or ISO `2025-11-22`) and 12h/24h clock, applied everywhere — message list and headers, calendar, contacts, files, sessions. The default comes from the locale configured for the account in Stalwart (`x:AccountSettings/get`, falling back to `x:Account/get`), and from the browser where the server will not say; POSIX forms are normalised (`de_DE.UTF-8` → `de-DE`) and script modifiers preserved (`sr_RS@latin` → `sr-Latn-RS`). Numerals follow the locale (`٢٢.١١.٢٠٢٥` for `ar-EG`), except under ISO 8601, which pins date *and* clock to Latin digits. Dates are **entered** through custom pickers in the same format (browsers render `` in their own locale and ignore the page's), with a calendar popover, a time list, keyboard navigation, and lenient typing — `22.11.`, `221125`, `6:23pm` and bare ISO all parse - **Self-service credentials** in Settings › Security: change your password, manage **app passwords** (a separate password per mail app or device, revocable on its own), and turn **two-factor authentication** on or off by scanning a QR code. Enrolment codes are verified before anything is stored, so a mistyped key cannot lock you out, and switching 2FA on moves this browser's session onto a dedicated app password instead of signing you straight back out. Works against both Stalwart generations: the `x:AccountPassword` / `x:AppPassword` registry objects on 0.16+, and the `/api/account/auth` REST endpoint on 0.15.x (the latter confirmed live) +- **Light and dark** follow the system by default, with a toggle in the top bar for flipping between them and a three-way choice in Settings › Appearance - Identities & signatures, **Sieve filters** (visual rule builder that round-trips to a Sieve script, plus a raw script editor with server-side validation), out-of-office (`VacationResponse`), folders, labels, templates, notifications, calendar defaults, sessions (sign out other devices), keyboard shortcuts, import/export of settings **Platform** - Installable PWA (manifest + service worker), mobile layout with bottom tab bar, drawer navigation, full-screen composer, FAB - **Default mail app**: register ihasmail as the browser's handler for `mailto:` links from Settings › General (`registerProtocolHandler`; needs HTTPS and a browser that supports it — Safari does not). Installed as an app it also declares `protocol_handlers` in the manifest, which is what lets the operating system offer ihasmail wherever it asks for a mail client. Links arrive with recipients, Cc, Bcc, subject and body filled in +- **About** reports the Stalwart generation ihasmail detected (0.16+ or older) and the edition where the server gives one. Stalwart does not publish a version number to clients, so no version is shown rather than a made-up one - Security: no credentials in the browser (server-side session with per-session encrypted upstream credentials), httpOnly SameSite cookies, CSRF header + Sec-Fetch-Site checks, strict CSP, sandboxed blob downloads, SSRF-safe image proxy, login rate limiting, security headers ## Architecture