Merge pull request #14 from LINUXexpert-org/self-service-credentials

Self-service credentials, plus the account-locale fix, server info and a theme toggle
This commit is contained in:
LINUXexpert.org
2026-08-24 09:13:55 -07:00
committed by GitHub
20 changed files with 1840 additions and 45 deletions
+6 -3
View File
@@ -52,12 +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 - Browse folders, upload (drag & drop), download, create folders, rename, move, delete
**Settings** **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 `<input type="date">` 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 `<input type="date">` 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 - 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** **Platform**
- Installable PWA (manifest + service worker), mobile layout with bottom tab bar, drawer navigation, full-screen composer, FAB - 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 - **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 - 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 ## Architecture
@@ -132,15 +135,15 @@ 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. - **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. - **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** — 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). - 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. - 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 ## Roadmap / not yet
- Snooze and scheduled send (needs server-side support) - Snooze and scheduled send (needs server-side support)
- Read-receipt (MDN) sending, S/MIME / OpenPGP - 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) - Translations (strings are English-only for now)
## License ## License
+7
View File
@@ -2459,6 +2459,12 @@
"node": ">=6" "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": { "node_modules/react": {
"version": "19.2.8", "version": "19.2.8",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
@@ -3826,6 +3832,7 @@
"@tanstack/react-virtual": "^3.13.2", "@tanstack/react-virtual": "^3.13.2",
"dompurify": "^3.2.4", "dompurify": "^3.2.4",
"lucide-react": "^0.477.0", "lucide-react": "^0.477.0",
"qrcode-generator": "^2.0.4",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"wouter": "^3.6.0", "wouter": "^3.6.0",
+163
View File
@@ -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 = "[email protected]";
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<string, string>), ...(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: "[email protected]", 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;
});
+386
View File
@@ -0,0 +1,386 @@
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<string, { backend: Backend; at: number }>();
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<Backend> {
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<Backend> {
// 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) {
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";
}
/* ------------------------------------------------------------------ */
/* 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, unknown>, 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<T>(ctx: Ctx, init: RequestInit): Promise<T> {
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<string, unknown> | 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<string, Record<string, unknown> | 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<string, unknown>) : 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<SecurityState> {
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<string, unknown>[] {
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<string, unknown>[]) : [];
}
function firstListItem(res: { methodResponses?: [string, unknown, string][] }, callId: string): Record<string, unknown> | null {
return listOf(res, callId)[0] ?? null;
}
export async function changePassword(
sessionId: string,
ctx: Ctx,
opts: { current: string; next: string; otpCode?: string },
): Promise<void> {
const backend = await detectBackend(sessionId, ctx);
if (backend === "registry") {
const update: Record<string, unknown> = { 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<unknown>(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<unknown>(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<void> {
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<unknown>(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<void> {
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<unknown>(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<void> {
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<unknown>(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<void> {
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 };
+67
View File
@@ -0,0 +1,67 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { getAccountInfo, interpretAccountInfo } from "./upstream.js";
/**
* The account locale used to be read only from `x:Account/get`, which needs
* the `sysAccountGet` permission — one the built-in `user` role is not given.
* Ordinary users therefore silently fell back to the browser locale. Stalwart
* 0.16 exposes the same field on `x:AccountSettings`, which users *can* read,
* so both are asked for and whichever answers wins.
*/
type Responses = [string, Record<string, unknown>, string][];
const settingsOk = (locale: string): Responses[number] => ["x:AccountSettings/get", { list: [{ id: "singleton", locale }] }, "s"];
const accountOk = (locale: string): Responses[number] => ["x:Account/get", { list: [{ id: "a1", locale }] }, "a"];
const failed = (id: string, type: string): Responses[number] => ["error", { type }, id];
test("prefers the locale a regular user is allowed to read", () => {
const info = interpretAccountInfo([settingsOk("de_DE.UTF-8"), accountOk("fr_FR")]);
assert.equal(info.locale, "de-DE");
assert.equal(info.generation, "0.16+");
});
test("falls back to x:Account when the settings object is forbidden", () => {
const info = interpretAccountInfo([failed("s", "forbidden"), accountOk("sr_RS@latin")]);
assert.equal(info.locale, "sr-Latn-RS");
});
test("an older server is recognised by its unknownMethod, and still yields a locale", () => {
const info = interpretAccountInfo([failed("s", "unknownMethod"), accountOk("en_GB")]);
assert.equal(info.generation, "pre-0.16");
assert.equal(info.locale, "en-GB");
});
test("a server answering the new method is 0.16+ even with no locale set", () => {
const info = interpretAccountInfo([["x:AccountSettings/get", { list: [] }, "s"], failed("a", "forbidden")]);
assert.equal(info.generation, "0.16+");
assert.equal(info.locale, null);
});
test("neither answering leaves everything unknown rather than guessing", () => {
const info = interpretAccountInfo([failed("s", "forbidden"), failed("a", "forbidden")]);
assert.deepEqual(info, { locale: null, generation: null, edition: null });
assert.deepEqual(interpretAccountInfo([]), { locale: null, generation: null, edition: null });
});
test("locales that carry no language are dropped, not passed through", () => {
assert.equal(interpretAccountInfo([settingsOk("C")]).locale, null);
assert.equal(interpretAccountInfo([settingsOk("POSIX")]).locale, null);
});
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);
});
+221 -7
View File
@@ -6,15 +6,28 @@ import { config } from "./config.js";
import { SessionStore, type LiveSession } from "./sessions.js"; import { SessionStore, type LiveSession } from "./sessions.js";
import { RateLimiter } from "./ratelimit.js"; import { RateLimiter } from "./ratelimit.js";
import { import {
type AccountInfo,
UpstreamError, UpstreamError,
absoluteUpstream, absoluteUpstream,
expandTemplate, expandTemplate,
fetchUpstreamSession, fetchUpstreamSession,
forgetUpstreamSession, forgetUpstreamSession,
getAccountLocale, getAccountInfo,
getUpstreamSession, getUpstreamSession,
localizeSession, localizeSession,
} from "./upstream.js"; } from "./upstream.js";
import {
AccountError,
assertEnrolmentCode,
beginOtpEnrolment,
changePassword,
createAppPassword,
disableOtp,
enableOtp,
forgetBackend,
getState,
revokeAppPassword,
} from "./account.js";
import { imageProxyHandler } from "./imageproxy.js"; import { imageProxyHandler } from "./imageproxy.js";
import { staticHandler } from "./static.js"; import { staticHandler } from "./static.js";
@@ -22,6 +35,13 @@ type Env = { Variables: { session: LiveSession } };
export const sessions = new SessionStore(config.sessionFile); export const sessions = new SessionStore(config.sessionFile);
const loginLimiter = new RateLimiter(config.loginRateLimit, 15 * 60_000); 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([ const HOP_BY_HOP = new Set([
"connection", "connection",
@@ -171,8 +191,8 @@ export function createApp(): Hono<Env> {
ip, ip,
}); });
setSessionCookie(c, cookie, session.remember); setSessionCookie(c, cookie, session.remember);
const locale = await getAccountLocale(session.id, session.authorization, upstream); const info = await getAccountInfo(session.id, session.authorization, upstream);
return c.json(localizeSession(upstream, sessionExtras(session, locale))); return c.json(localizeSession(upstream, sessionExtras(session, info)));
} catch (err) { } catch (err) {
return upstreamFailure(c, err); return upstreamFailure(c, err);
} }
@@ -182,8 +202,8 @@ export function createApp(): Hono<Env> {
const session = c.get("session"); const session = c.get("session");
try { try {
const upstream = await getUpstreamSession(session.id, session.authorization, c.req.query("refresh") === "1"); const upstream = await getUpstreamSession(session.id, session.authorization, c.req.query("refresh") === "1");
const locale = await getAccountLocale(session.id, session.authorization, upstream); const info = await getAccountInfo(session.id, session.authorization, upstream);
return c.json(localizeSession(upstream, sessionExtras(session, locale))); return c.json(localizeSession(upstream, sessionExtras(session, info)));
} catch (err) { } catch (err) {
if (err instanceof UpstreamError && err.status === 401) { if (err instanceof UpstreamError && err.status === 401) {
sessions.destroy(session.id); sessions.destroy(session.id);
@@ -215,6 +235,183 @@ export function createApp(): Hono<Env> {
return c.json({ revoked: n }); 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<Env>) => {
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<Env>): 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 ---------- // ---------- JMAP API proxy ----------
api.post("/jmap", requireSession, async (c) => { api.post("/jmap", requireSession, async (c) => {
const session = c.get("session"); const session = c.get("session");
@@ -353,7 +550,22 @@ export function createApp(): Hono<Env> {
return app; return app;
} }
function sessionExtras(session: LiveSession, userLocale: string | null = null) { async function readJson<T>(c: Context): Promise<T | null> {
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, info: AccountInfo = { locale: null, generation: null, edition: null }) {
return { return {
ihasmail: { ihasmail: {
appName: config.appName, appName: config.appName,
@@ -363,7 +575,9 @@ function sessionExtras(session: LiveSession, userLocale: string | null = null) {
loginName: session.username, loginName: session.username,
remember: session.remember, remember: session.remember,
/** Locale configured for the account in Stalwart's directory, if readable. */ /** 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 },
}, },
}; };
} }
+100 -3
View File
@@ -5,6 +5,7 @@
*/ */
import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { parseOtpauthUrl, verifyTotp } from "../totp.js";
const PORT = Number(process.env.MOCK_PORT ?? 8788); const PORT = Number(process.env.MOCK_PORT ?? 8788);
const ACCOUNT = "a1"; const ACCOUNT = "a1";
@@ -12,6 +13,14 @@ const USER = process.env.MOCK_USER ?? "[email protected]";
/** Locale the fake directory reports for the account (POSIX style, as Stalwart does). */ /** Locale the fake directory reports for the account (POSIX style, as Stalwart does). */
const MOCK_LOCALE = process.env.MOCK_LOCALE ?? "en_US"; const MOCK_LOCALE = process.env.MOCK_LOCALE ?? "en_US";
const PASS = process.env.MOCK_PASS ?? "demo"; 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<string, unknown>; type Obj = Record<string, unknown>;
const state = { n: 1 }; const state = { n: 1 };
@@ -256,6 +265,13 @@ function genericSet(list: Obj[], prefix: string, onCreate?: (o: Obj) => void) {
} }
const handlers: Record<string, Handler> = { const handlers: Record<string, Handler> = {
// 0.16 exposes the account locale here, under a permission ordinary users
// actually have (unlike x:Account below, which needs sysAccountGet).
"x:AccountSettings/get": (a) => {
const ids = (a.ids as string[] | null) ?? ["singleton"];
const list = ids.filter((id) => id === "singleton").map((id) => ({ id, locale: MOCK_LOCALE, timeZone: null, description: null }));
return { accountId: ACCOUNT, state: String(state.n), list: list.map((x) => pick(x, a.properties as string[] | null)), notFound: ids.filter((id) => id !== "singleton") };
},
// Stalwart's directory extension - the client reads the account locale from here. // Stalwart's directory extension - the client reads the account locale from here.
"x:Account/get": (a) => { "x:Account/get": (a) => {
const ids = (a.ids as string[] | null) ?? [ACCOUNT]; const ids = (a.ids as string[] | null) ?? [ACCOUNT];
@@ -302,6 +318,64 @@ const handlers: Record<string, Handler> = {
}, },
"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 }); }, "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)) }; }, "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/get": genericGet(identities),
"Identity/set": genericSet(identities, "i", (o) => Object.assign(o, { replyTo: null, bcc: null, textSignature: "", htmlSignature: "", mayDelete: true, ...o })), "Identity/set": genericSet(identities, "i", (o) => Object.assign(o, { replyTo: null, bcc: null, textSignature: "", htmlSignature: "", mayDelete: true, ...o })),
"EmailSubmission/set": (a) => { "EmailSubmission/set": (a) => {
@@ -349,11 +423,28 @@ function unauthorized(res: ServerResponse) {
res.writeHead(401, { "content-type": "application/json", "www-authenticate": 'Basic realm="mock"' }); res.writeHead(401, { "content-type": "application/json", "www-authenticate": 'Basic realm="mock"' });
res.end(JSON.stringify({ type: "about:blank", status: 401, title: "Unauthorized" })); 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 { function checkAuth(req: IncomingMessage): boolean {
const h = req.headers.authorization ?? ""; const h = req.headers.authorization ?? "";
if (!h.startsWith("Basic ")) return false; if (!h.startsWith("Basic ")) return false;
const [u, p] = Buffer.from(h.slice(6), "base64").toString().split(":"); const raw = Buffer.from(h.slice(6), "base64").toString();
return u === USER && p === PASS; 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<Buffer> { function readBody(req: IncomingMessage): Promise<Buffer> {
return new Promise((resolve) => { const chunks: Buffer[] = []; req.on("data", (c) => chunks.push(c)); req.on("end", () => resolve(Buffer.concat(chunks))); }); return new Promise((resolve) => { const chunks: Buffer[] = []; req.on("data", (c) => chunks.push(c)); req.on("end", () => resolve(Buffer.concat(chunks))); });
@@ -377,13 +468,19 @@ function broadcast(types: string[]) {
for (const c of sseClients) c.write(payload); 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}`); const url = new URL(req.url ?? "/", `http://127.0.0.1:${PORT}`);
if (!checkAuth(req)) return unauthorized(res); if (!checkAuth(req)) return unauthorized(res);
if (url.pathname === "/.well-known/jmap" || url.pathname === "/jmap/session") { if (url.pathname === "/.well-known/jmap" || url.pathname === "/jmap/session") {
res.writeHead(200, { "content-type": "application/json" }); res.writeHead(200, { "content-type": "application/json" });
return res.end(JSON.stringify(session())); 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") { if (url.pathname === "/jmap/" && req.method === "POST") {
const body = JSON.parse((await readBody(req)).toString()) as { methodCalls: [string, Obj, string][] }; const body = JSON.parse((await readBody(req)).toString()) as { methodCalls: [string, Obj, string][] };
const responses: [string, Obj, string][] = []; const responses: [string, Obj, string][] = [];
+24
View File
@@ -171,6 +171,30 @@ export class SessionStore {
return this.toLive(stored, creds.u, creds.p); 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 { destroy(id: string): void {
if (this.sessions.delete(id)) this.scheduleSave(); if (this.sessions.delete(id)) this.scheduleSave();
} }
+85
View File
@@ -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: "[email protected]", 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);
});
+145
View File
@@ -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<TotpParams, "secret"> = { 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);
}
+92 -22
View File
@@ -59,7 +59,7 @@ export async function getUpstreamSession(sessionId: string, authorization: strin
export function forgetUpstreamSession(sessionId: string): void { export function forgetUpstreamSession(sessionId: string): void {
sessionCache.delete(sessionId); sessionCache.delete(sessionId);
localeCache.delete(sessionId); infoCache.delete(sessionId);
} }
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@@ -68,8 +68,25 @@ export function forgetUpstreamSession(sessionId: string): void {
const STALWART_CAP = "urn:stalwart:jmap"; const STALWART_CAP = "urn:stalwart:jmap";
const JMAP_CORE = "urn:ietf:params:jmap:core"; const JMAP_CORE = "urn:ietf:params:jmap:core";
const localeCache = new Map<string, { locale: string | null; fetchedAt: number }>();
const LOCALE_CACHE_MS = 30 * 60_000; export interface AccountInfo {
/** BCP-47 tag configured for the account, or null if unreadable. */
locale: string | null;
/**
* Which generation of Stalwart's API answered: "0.16+" has the registry
* (`x:AccountSettings`), older builds only have `x:Account`. Null when the
* server is not Stalwart or told us nothing.
*/
generation: "0.16+" | "pre-0.16" | null;
/** "oss" | "community" | "enterprise", where the server reports it. */
edition: string | null;
}
const infoCache = new Map<string, { info: AccountInfo; fetchedAt: number }>();
const INFO_CACHE_MS = 30 * 60_000;
const EMPTY_INFO: AccountInfo = { locale: null, generation: null, edition: null };
/** 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: * glibc modifiers that name a script rather than a dialect or a currency:
@@ -112,47 +129,100 @@ export function normalizeLocale(raw: unknown): string | null {
} }
/** /**
* Best-effort lookup of the locale configured for this account in Stalwart's * Best-effort lookup of what the server can tell us about this account.
* directory (`x:Account/get`, Stalwart's JMAP extension). Servers that do not *
* expose it — or that deny a regular user the `sysAccountGet` permission — * The locale used to come from `x:Account/get`, which needs the `sysAccountGet`
* simply yield null and the client falls back to the browser locale. * permission — a tenant/admin one that ordinary users are not granted, so the
* setting silently fell back to the browser locale for exactly the people most
* likely to want it. Stalwart 0.16 exposes the same field on `x:AccountSettings`,
* whose `sysAccountSettingsGet` permission *is* part of the built-in user role.
* Ask for both in one request and take whichever the server allows, which also
* tells us which generation we are talking to.
*/ */
async function fetchAccountLocale(authorization: string, session: UpstreamSession): Promise<string | null> { async function fetchAccountInfo(authorization: string, session: UpstreamSession): Promise<AccountInfo> {
if (!session.capabilities || !(STALWART_CAP in session.capabilities)) return null; // 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 = const accountId =
session.primaryAccounts?.[STALWART_CAP] ?? session.primaryAccounts?.[STALWART_CAP] ??
session.primaryAccounts?.["urn:ietf:params:jmap:mail"] ?? session.primaryAccounts?.["urn:ietf:params:jmap:mail"] ??
Object.keys(session.accounts ?? {})[0]; Object.keys(session.accounts ?? {})[0];
if (!accountId) return null; if (!accountId) return EMPTY_INFO;
const res = await fetch(absoluteUpstream(session.apiUrl), { const res = await fetch(absoluteUpstream(session.apiUrl), {
method: "POST", method: "POST",
headers: { authorization, "content-type": "application/json", accept: "application/json" }, headers: { authorization, "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({ body: JSON.stringify({
using: [JMAP_CORE, STALWART_CAP], 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), signal: AbortSignal.timeout(config.upstreamTimeout),
}); });
if (!res.ok) return null; if (!res.ok) return EMPTY_INFO;
const body = (await res.json()) as { methodResponses?: [string, Record<string, unknown>, string][] }; const body = (await res.json()) as { methodResponses?: [string, Record<string, unknown>, string][] };
const call = body.methodResponses?.[0]; return interpretAccountInfo(body.methodResponses ?? []);
if (!call || call[0] !== "x:Account/get") return null; }
/**
* Read the pair of replies: prefer the locale from `x:AccountSettings`, fall
* back to `x:Account` for servers (or permissions) where only that one works,
* and note which generation answered.
*/
export function interpretAccountInfo(responses: [string, Record<string, unknown>, string][]): AccountInfo {
const settings = responses.find((r) => r[2] === "s");
const account = responses.find((r) => r[2] === "a");
// Only 0.16+ knows the method at all; older builds cannot even parse the name.
const generation: AccountInfo["generation"] =
settings && settings[0] !== "error"
? "0.16+"
: (settings?.[1] as { type?: string } | undefined)?.type === "unknownMethod"
? "pre-0.16"
: null;
return { locale: localeOf(settings) ?? localeOf(account), generation, edition: null };
}
function localeOf(call: [string, Record<string, unknown>, string] | undefined): string | null {
if (!call || call[0] === "error") return null;
const list = call[1]?.list; const list = call[1]?.list;
if (!Array.isArray(list) || !list.length) return null; if (!Array.isArray(list) || !list.length) return null;
return normalizeLocale((list[0] as { locale?: unknown } | undefined)?.locale); return normalizeLocale((list[0] as { locale?: unknown } | undefined)?.locale);
} }
export async function getAccountLocale(sessionId: string, authorization: string, session: UpstreamSession): Promise<string | null> { /**
const cached = localeCache.get(sessionId); * Which edition the server is running. Stalwart deliberately does not publish
if (cached && Date.now() - cached.fetchedAt < LOCALE_CACHE_MS) return cached.locale; * its version number to clients, but 0.16 does report its edition here.
let locale: string | null = null; */
async function fetchEdition(authorization: string): Promise<string | null> {
try { 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 { } catch {
/* the server locale is a nicety - never fail the session over it */ return null;
} }
localeCache.set(sessionId, { locale, fetchedAt: Date.now() }); }
return locale;
export async function getAccountInfo(sessionId: string, authorization: string, session: UpstreamSession): Promise<AccountInfo> {
const cached = infoCache.get(sessionId);
if (cached && Date.now() - cached.fetchedAt < INFO_CACHE_MS) return cached.info;
let info = EMPTY_INFO;
try {
info = await fetchAccountInfo(authorization, session);
if (info.generation === "0.16+") info = { ...info, edition: await fetchEdition(authorization) };
} catch {
/* all of this is a nicety - never fail the session over it */
}
infoCache.set(sessionId, { info, fetchedAt: Date.now() });
return info;
} }
/** /**
+1
View File
@@ -14,6 +14,7 @@
"@tanstack/react-virtual": "^3.13.2", "@tanstack/react-virtual": "^3.13.2",
"dompurify": "^3.2.4", "dompurify": "^3.2.4",
"lucide-react": "^0.477.0", "lucide-react": "^0.477.0",
"qrcode-generator": "^2.0.4",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"wouter": "^3.6.0", "wouter": "^3.6.0",
+6
View File
@@ -32,6 +32,12 @@ export interface JmapSession {
remember: boolean; remember: boolean;
/** Locale configured for the account in Stalwart, if the server exposes it. */ /** Locale configured for the account in Stalwart, if the server exposes it. */
userLocale?: string | null; 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;
};
}; };
} }
+18
View File
@@ -1,3 +1,4 @@
import { useEffect, useState } from "react";
import { create } from "zustand"; import { create } from "zustand";
import { loadJson, saveJson } from "@/lib/storage"; import { loadJson, saveJson } from "@/lib/storage";
import { setDateTimePrefs, type DateFormat, type TimeFormat } from "@/lib/datetime"; 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()); 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; export const settings = () => useSettings.getState().settings;
/** /**
@@ -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 (
<Dialog open onClose={() => undefined} title="Two fields">
<input id="first" value={first} onChange={(e) => setFirst(e.target.value)} />
<input id="second" value={second} onChange={(e) => setSecond(e.target.value)} />
</Dialog>
);
}
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(<TwoFieldDialog />));
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(<TwoFieldDialog />));
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");
});
});
+12 -2
View File
@@ -16,13 +16,23 @@ interface DialogProps {
export function Dialog({ open, onClose, title, children, footer, size = "md", closeOnBackdrop = true, className }: DialogProps) { export function Dialog({ open, onClose, title, children, footer, size = "md", closeOnBackdrop = true, className }: DialogProps) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(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(() => { useEffect(() => {
if (!open) return; if (!open) return;
const prev = document.activeElement as HTMLElement | null; const prev = document.activeElement as HTMLElement | null;
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") { if (e.key === "Escape") {
e.stopPropagation(); e.stopPropagation();
onClose(); onCloseRef.current();
} }
if (e.key === "Tab" && ref.current) { if (e.key === "Tab" && ref.current) {
const focusables = ref.current.querySelectorAll<HTMLElement>('button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"]),[contenteditable="true"]'); const focusables = ref.current.querySelectorAll<HTMLElement>('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); document.removeEventListener("keydown", onKey, true);
prev?.focus?.(); prev?.focus?.();
}; };
}, [open, onClose]); }, [open]);
if (!open) return null; if (!open) return null;
return createPortal( return createPortal(
<div <div
+41
View File
@@ -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 (
<svg
width={size}
height={size}
viewBox={`-2 -2 ${count + 4} ${count + 4}`}
role="img"
aria-label={title ?? "QR code"}
shapeRendering="crispEdges"
style={{ background: "#fff", borderRadius: 8, display: "block" }}
>
<title>{title ?? "QR code"}</title>
<path d={path} fill="#000" />
</svg>
);
}
+27 -2
View File
@@ -1,8 +1,8 @@
import { useEffect, useState, type ReactNode } from "react"; import { useEffect, useState, type ReactNode } from "react";
import { Link, useLocation } from "wouter"; 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 { useSession } from "@/store/session";
import { useSettings } from "@/store/settings"; import { useEffectiveTheme, useSettings } from "@/store/settings";
import { useMail } from "@/store/mail"; import { useMail } from "@/store/mail";
import { draftFromMailto, useCompose } from "@/store/compose"; import { draftFromMailto, useCompose } from "@/store/compose";
import { Avatar, useIsMobile } from "@/ui/misc"; import { Avatar, useIsMobile } from "@/ui/misc";
@@ -70,6 +70,7 @@ export function AppShell({ children }: { children: ReactNode }) {
<button className="icon-btn hide-mobile" aria-label="Keyboard shortcuts" title="Keyboard shortcuts (?)" onClick={() => setHelpOpen(true)}> <button className="icon-btn hide-mobile" aria-label="Keyboard shortcuts" title="Keyboard shortcuts (?)" onClick={() => setHelpOpen(true)}>
<HelpCircle size={21} /> <HelpCircle size={21} />
</button> </button>
<ThemeToggle />
<Link href="/settings" className={`icon-btn ${section === "settings" ? "active" : ""}`} aria-label="Settings" title="Settings"> <Link href="/settings" className={`icon-btn ${section === "settings" ? "active" : ""}`} aria-label="Settings" title="Settings">
<Settings size={21} /> <Settings size={21} />
</Link> </Link>
@@ -196,3 +197,27 @@ function QuotaBar() {
</div> </div>
); );
} }
/**
* Flip between light and dark from the top bar.
*
* The stored setting has a third value, "system", so the button acts on what
* is actually on screen rather than on the setting: whichever theme you can
* see, one click gives you the other one. Choosing "match system" again lives
* in Settings Appearance, where the three-way choice belongs.
*/
function ThemeToggle() {
const effective = useEffectiveTheme();
const update = useSettings((s) => s.update);
const next = effective === "dark" ? "light" : "dark";
return (
<button
className="icon-btn"
aria-label={`Switch to ${next} mode`}
title={`Switch to ${next} mode`}
onClick={() => update({ theme: next })}
>
{effective === "dark" ? <Sun size={21} /> : <Moon size={21} />}
</button>
);
}
+14
View File
@@ -19,11 +19,13 @@ export function AboutSettings() {
<table className="sessions-table"> <table className="sessions-table">
<tbody> <tbody>
<tr><td>Signed in as</td><td>{session?.username}</td></tr> <tr><td>Signed in as</td><td>{session?.username}</td></tr>
<tr><td>Stalwart</td><td>{describeServer(session?.ihasmail?.server)}</td></tr>
<tr><td>Accounts</td><td>{Object.values(session?.accounts ?? {}).map((a) => a.name).join(", ")}</td></tr> <tr><td>Accounts</td><td>{Object.values(session?.accounts ?? {}).map((a) => a.name).join(", ")}</td></tr>
<tr><td>Max upload</td><td>{Math.round(client.maxSizeUpload / 1048576)} MB</td></tr> <tr><td>Max upload</td><td>{Math.round(client.maxSizeUpload / 1048576)} MB</td></tr>
<tr><td>Image privacy proxy</td><td>{session?.ihasmail?.imageProxy ? "enabled" : "disabled"}</td></tr> <tr><td>Image privacy proxy</td><td>{session?.ihasmail?.imageProxy ? "enabled" : "disabled"}</td></tr>
</tbody> </tbody>
</table> </table>
<p className="hint" style={{ marginTop: 6 }}>Stalwart does not publish its version number to mail clients, so ihasmail reports the API generation it detected instead.</p>
<h2>Server capabilities</h2> <h2>Server capabilities</h2>
<div className="row wrap gap-4"> <div className="row wrap gap-4">
{caps.map((c) => <span key={c} className="chip mono" style={{ fontSize: ".78em" }}>{c.replace("urn:ietf:params:jmap:", "")}</span>)} {caps.map((c) => <span key={c} className="chip mono" style={{ fontSize: ".78em" }}>{c.replace("urn:ietf:params:jmap:", "")}</span>)}
@@ -31,3 +33,15 @@ export function AboutSettings() {
</div> </div>
); );
} }
/**
* Stalwart deliberately withholds its version from clients (it reports a fixed
* "1.0.0" wherever it publishes one at all), so the most honest thing we can
* show is which generation of its API answered us, plus the edition where the
* server reports it.
*/
function describeServer(server: { generation?: "0.16+" | "pre-0.16" | null; edition?: string | null } | undefined): string {
if (!server?.generation) return "not detected";
const generation = server.generation === "0.16+" ? "0.16 or newer" : "older than 0.16";
return server.edition ? `${generation} (${server.edition})` : generation;
}
+343 -6
View File
@@ -1,9 +1,11 @@
import { useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { apiFetch } from "@/jmap/client"; import { Copy, KeyRound, ShieldCheck, Smartphone } from "lucide-react";
import { apiFetch, ApiError } from "@/jmap/client";
import { useSession } from "@/store/session"; import { useSession } from "@/store/session";
import { formatFullDate } from "@/lib/format"; import { formatFullDate } from "@/lib/format";
import { toast } from "@/ui/toast"; import { toast } from "@/ui/toast";
import { confirmDialog } from "@/ui/dialog"; import { confirmDialog, Dialog } from "@/ui/dialog";
import { QrCode } from "@/ui/qrcode";
interface SessionRow { interface SessionRow {
id: string; id: string;
@@ -16,19 +18,72 @@ interface SessionRow {
ip: string; 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() { export function SecuritySettings() {
const [rows, setRows] = useState<SessionRow[] | null>(null); const [rows, setRows] = useState<SessionRow[] | null>(null);
const [current, setCurrent] = useState<string>(""); const [current, setCurrent] = useState<string>("");
const [state, setState] = useState<SecurityState | null>(null);
/** Set when the server has no self-service API at all (pre-0.15 or a proxy). */
const [unsupported, setUnsupported] = useState<string | null>(null);
const session = useSession((s) => s.session); const session = useSession((s) => s.session);
const logout = useSession((s) => s.logout); 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 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<SecurityState>("/api/account/security"));
setUnsupported(null);
} catch (err) {
setState(null);
setUnsupported(err instanceof ApiError && err.status === 501 ? err.message : (err as Error).message);
}
}, []);
useEffect(() => { useEffect(() => {
void load(); void load();
}, []); void loadSecurity();
}, [loadSecurity]);
return ( return (
<div> <div>
<h1>Security & sessions</h1> <h1>Security & sessions</h1>
<p className="lead">You're signed in as <b>{session?.username}</b>. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.</p> <p className="lead">You're signed in as <b>{session?.username}</b>. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.</p>
<h2>Password</h2>
{unsupported ? (
<p className="hint">{unsupported}</p>
) : (
<PasswordForm otpEnabled={state?.otpEnabled ?? false} onChanged={() => { void load(); }} />
)}
<h2>Two-factor authentication</h2>
{unsupported ? (
<p className="hint">Two-factor authentication is managed by your mail administrator.</p>
) : (
<TwoFactor state={state} reload={async () => { await loadSecurity(); await load(); }} />
)}
<h2>App passwords</h2>
{unsupported ? (
<p className="hint">App passwords are managed by your mail administrator.</p>
) : (
<AppPasswords state={state} reload={loadSecurity} />
)}
<h2>Active webmail sessions</h2> <h2>Active webmail sessions</h2>
{rows === null ? <p className="hint">Loading…</p> : ( {rows === null ? <p className="hint">Loading…</p> : (
<table className="sessions-table"> <table className="sessions-table">
@@ -50,8 +105,290 @@ export function SecuritySettings() {
<button className="btn" onClick={async () => { if (await confirmDialog({ title: "Sign out other sessions?", confirmLabel: "Sign out others" })) { const r = await apiFetch<{ revoked: number }>("/api/auth/sessions/revoke-others", { method: "POST" }); toast.success(`Signed out ${r.revoked} other session(s)`); void load(); } }}>Sign out all other sessions</button> <button className="btn" onClick={async () => { if (await confirmDialog({ title: "Sign out other sessions?", confirmLabel: "Sign out others" })) { const r = await apiFetch<{ revoked: number }>("/api/auth/sessions/revoke-others", { method: "POST" }); toast.success(`Signed out ${r.revoked} other session(s)`); void load(); } }}>Sign out all other sessions</button>
<button className="btn btn-ghost" onClick={() => void logout()}>Sign out here</button> <button className="btn btn-ghost" onClick={() => void logout()}>Sign out here</button>
</div> </div>
<h2>Password & two-factor</h2> </div>
<p className="hint">Password changes, app passwords and 2FA are managed by your mail administrator or via Stalwart's self-service portal.</p> );
}
/* ------------------------------------------------------------------ */
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 (
<form onSubmit={submit}>
<p className="hint" style={{ marginBottom: 12 }}>Changing your password signs out your other webmail sessions. Any app passwords keep working.</p>
<div className="field" style={{ maxWidth: 380 }}>
<label htmlFor="pw-current">Current password</label>
<input id="pw-current" type="password" autoComplete="current-password" value={current} onChange={(e) => setCurrent(e.target.value)} required />
</div>
{otpEnabled && (
<div className="field" style={{ maxWidth: 380 }}>
<label htmlFor="pw-code">Code from your authenticator</label>
<input id="pw-code" inputMode="numeric" autoComplete="one-time-code" value={code} onChange={(e) => setCode(e.target.value)} placeholder="123456" required />
</div>
)}
<div className="field-row" style={{ maxWidth: 780 }}>
<div className="field">
<label htmlFor="pw-new">New password</label>
<input id="pw-new" type="password" autoComplete="new-password" value={next} onChange={(e) => setNext(e.target.value)} required />
</div>
<div className="field">
<label htmlFor="pw-confirm">Confirm new password</label>
<input id="pw-confirm" type="password" autoComplete="new-password" value={confirm} onChange={(e) => setConfirm(e.target.value)} required />
</div>
</div>
<button className="btn btn-primary" disabled={busy || !current || !next}>{busy ? "Changing" : "Change password"}</button>
</form>
);
}
/* ------------------------------------------------------------------ */
function TwoFactor({ state, reload }: { state: SecurityState | null; reload: () => Promise<void> }) {
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 <p className="hint">Loading…</p>;
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 (
<div>
<p className="hint" style={{ marginBottom: 12 }}>
{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."}
</p>
<div className="row" style={{ alignItems: "center", gap: 10 }}>
<ShieldCheck size={18} className={state.otpEnabled ? "" : "muted"} />
<b>{state.otpEnabled ? "Enabled" : "Not enabled"}</b>
{state.otpEnabled
? <button className="btn btn-sm" onClick={() => { setDisabling(true); setCode(""); setPassword(""); }}>Turn off</button>
: <button className="btn btn-sm btn-primary" onClick={() => void begin()}>Set up</button>}
</div>
<Dialog open={Boolean(setup)} onClose={() => setSetup(null)} title="Set up two-factor authentication" size="md"
footer={<>
<button className="btn btn-ghost" onClick={() => setSetup(null)}>Cancel</button>
<button className="btn btn-primary" disabled={busy || code.length < 6 || !password} onClick={() => void enable()}>{busy ? "Verifying" : "Turn on"}</button>
</>}>
{setup && (
<div>
<ol style={{ paddingLeft: 18, marginTop: 0 }}>
<li>Scan this with your authenticator app.</li>
<li>Enter the six-digit code it shows, and your password.</li>
</ol>
<div className="row" style={{ gap: 16, alignItems: "flex-start", flexWrap: "wrap" }}>
<QrCode value={setup.url} size={188} title="Two-factor setup code" />
<div style={{ minWidth: 220, flex: 1 }}>
<div className="field">
<label>Can't scan? Enter this key by hand</label>
<CopyableSecret value={setup.secret} />
</div>
<div className="field">
<label htmlFor="tfa-code">Code from the app</label>
<input id="tfa-code" inputMode="numeric" autoComplete="one-time-code" value={code} onChange={(e) => setCode(e.target.value)} placeholder="123456" />
</div>
<div className="field">
<label htmlFor="tfa-pw">Your password</label>
<input id="tfa-pw" type="password" autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} />
</div>
</div>
</div>
<p className="hint">Codes are checked before anything is saved, so a mistyped key can't lock you out.</p>
</div>
)}
</Dialog>
<Dialog open={disabling} onClose={() => setDisabling(false)} title="Turn off two-factor authentication" size="sm"
footer={<>
<button className="btn btn-ghost" onClick={() => setDisabling(false)}>Cancel</button>
<button className="btn btn-danger" disabled={busy || !password || code.length < 6} onClick={() => void disable()}>{busy ? "Working" : "Turn off"}</button>
</>}>
<p>Your password alone will be enough to sign in again.</p>
<div className="field">
<label htmlFor="tfa-off-pw">Your password</label>
<input id="tfa-off-pw" type="password" autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} />
</div>
<div className="field">
<label htmlFor="tfa-off-code">Current code</label>
<input id="tfa-off-code" inputMode="numeric" autoComplete="one-time-code" value={code} onChange={(e) => setCode(e.target.value)} placeholder="123456" />
</div>
</Dialog>
</div>
);
}
/* ------------------------------------------------------------------ */
function AppPasswords({ state, reload }: { state: SecurityState | null; reload: () => Promise<void> }) {
const [name, setName] = useState("");
const [busy, setBusy] = useState(false);
const [issued, setIssued] = useState<{ description: string; secret: string } | null>(null);
if (!state) return <p className="hint">Loading…</p>;
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 (
<div>
<p className="hint" style={{ marginBottom: 12 }}>
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.
</p>
{state.appPasswords.length > 0 && (
<table className="sessions-table">
<thead><tr><th>Name</th>{!state.appPasswordsKeyedByName && <th>Created</th>}<th /></tr></thead>
<tbody>
{state.appPasswords.map((row) => (
<tr key={row.id}>
<td><KeyRound size={14} style={{ verticalAlign: "-2px", marginRight: 6 }} />{row.description}</td>
{!state.appPasswordsKeyedByName && <td>{row.createdAt ? formatFullDate(row.createdAt) : ""}</td>}
<td style={{ textAlign: "right" }}><button className="btn btn-sm btn-ghost" onClick={() => void revoke(row)}>Revoke</button></td>
</tr>
))}
</tbody>
</table>
)}
<form onSubmit={create} className="row mt-16" style={{ gap: 8, alignItems: "flex-end", flexWrap: "wrap" }}>
<div className="field" style={{ marginBottom: 0, minWidth: 240 }}>
<label htmlFor="ap-name">New app password for</label>
<input id="ap-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Thunderbird on my laptop" required />
</div>
<button className="btn" disabled={busy || !name.trim()}>{busy ? "Creating" : "Create"}</button>
</form>
{state.appPasswordsKeyedByName && <p className="hint mt-8">This mail server identifies app passwords by name, so give each one a different name.</p>}
<Dialog open={Boolean(issued)} onClose={() => setIssued(null)} title="Your new app password" size="sm"
footer={<button className="btn btn-primary" onClick={() => setIssued(null)}>Done</button>}>
{issued && (
<div>
<p>Copy it into <b>{issued.description}</b> now — it isn't shown again.</p>
<CopyableSecret value={issued.secret} />
<p className="hint mt-8"><Smartphone size={13} style={{ verticalAlign: "-2px" }} /> Use your usual address as the username.</p>
</div>
)}
</Dialog>
</div>
);
}
function CopyableSecret({ value }: { value: string }) {
return (
<div className="row" style={{ gap: 6, alignItems: "center" }}>
<code className="mono" style={{ userSelect: "all", wordBreak: "break-all", flex: 1, padding: "6px 8px", background: "var(--bg-hover)", borderRadius: 6 }}>{value}</code>
<button
type="button"
className="btn btn-sm btn-ghost"
title="Copy"
onClick={() => void navigator.clipboard?.writeText(value).then(() => toast.success("Copied"), () => toast.error("Could not copy"))}
>
<Copy size={14} />
</button>
</div> </div>
); );
} }