Merge pull request #55 from LINUXexpert-org/settings-server-side

Keep settings with the account, not the browser
This commit is contained in:
LINUXexpert.org
2026-08-26 08:24:03 -07:00
committed by GitHub
10 changed files with 520 additions and 55 deletions
+2 -1
View File
@@ -83,6 +83,7 @@ seconds of downtime with nothing lost.
- **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) - **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 - **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
- **Settings follow the account, not the browser** (Stalwart 0.16+): they are kept in a `settings.json` in the account's own JMAP Files, so the default identity, locale, date and time formats, theme, labels, templates, folder colours and the rest are the same wherever you sign in — including a private window. ihasmail still stores nothing itself; the file lives in the mail store and is backed up with it. Settings that describe *this* screen or browser stay local, because syncing them would be wrong rather than helpful: list-pane sizes, density, font size, sidebar state, and the notification toggles (which track a permission the browser grants per-device). localStorage is kept as a cache so the first frame is already right, and the file corrects it a moment later. On Stalwart 0.15 nothing changes — settings stay local, as before
**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
@@ -101,7 +102,7 @@ browser ──(same-origin /api/*)──► ihasmail server (Node + Hono) ─
- `web/` — Vite + React 19 + TypeScript SPA. `src/jmap` (client, push, types), `src/store` (zustand stores: session, mail, compose, contacts, calendar, files, sieve, settings), `src/views` (mail, compose, calendar, contacts, files, settings), `src/lib` (sanitiser, search parser, Sieve codec, dates and locale-aware formatting, vCard, …). - `web/` — Vite + React 19 + TypeScript SPA. `src/jmap` (client, push, types), `src/store` (zustand stores: session, mail, compose, contacts, calendar, files, sieve, settings), `src/views` (mail, compose, calendar, contacts, files, settings), `src/lib` (sanitiser, search parser, Sieve codec, dates and locale-aware formatting, vCard, …).
- `server/` — tiny Node/Hono backend: authenticates against Stalwart's JMAP session endpoint, stores the credentials sealed with a key derived from the cookie secret (the server never persists plaintext passwords), proxies JMAP/blob/SSE calls, serves the SPA with a strict CSP. Also contains `src/mock/` — an in-memory fake Stalwart for local development and demos. - `server/` — tiny Node/Hono backend: authenticates against Stalwart's JMAP session endpoint, stores the credentials sealed with a key derived from the cookie secret (the server never persists plaintext passwords), proxies JMAP/blob/SSE calls, serves the SPA with a strict CSP. Also contains `src/mock/` — an in-memory fake Stalwart for local development and demos.
Stalwart capabilities used: `core`, `mail`, `submission`, `vacationresponse`, `sieve`, `contacts`(+`parse`), `calendars`(+`parse`), `principals`(+`availability`), `quota`, `blob`, `filenode`, EventSource push, plus Stalwart's own `urn:stalwart:jmap` (read-only, for the account locale). Features degrade gracefully when a capability is missing. Stalwart capabilities used: `core`, `mail`, `submission`, `vacationresponse`, `sieve`, `contacts`(+`parse`), `calendars`(+`parse`), `principals`(+`availability`), `quota`, `blob`, `filenode`, EventSource push, plus Stalwart's own `urn:stalwart:jmap` (read-only, for the account locale and to tell the generations apart). Features degrade gracefully when a capability is missing.
## Quick start (Docker) ## Quick start (Docker)
+22 -1
View File
@@ -17,7 +17,8 @@ import { AppShell } from "@/views/AppShell";
import { MailView } from "@/views/mail/MailView"; import { MailView } from "@/views/mail/MailView";
import { ComposerDock } from "@/views/compose/ComposerDock"; import { ComposerDock } from "@/views/compose/ComposerDock";
import { setUnreadBadge } from "@/lib/notify"; import { setUnreadBadge } from "@/lib/notify";
import { useSettings } from "@/store/settings"; import { useSettings, syncedPart } from "@/store/settings";
import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsSyncAvailable } from "@/lib/settingsSync";
const ContactsView = lazy(() => import("@/views/contacts/ContactsView").then((m) => ({ default: m.ContactsView }))); const ContactsView = lazy(() => import("@/views/contacts/ContactsView").then((m) => ({ default: m.ContactsView })));
const CalendarView = lazy(() => import("@/views/calendar/CalendarView").then((m) => ({ default: m.CalendarView }))); const CalendarView = lazy(() => import("@/views/calendar/CalendarView").then((m) => ({ default: m.CalendarView })));
@@ -51,6 +52,26 @@ function AuthedApp() {
const accountId = useSession((s) => s.accountId); const accountId = useSession((s) => s.accountId);
const [location] = useLocation(); const [location] = useLocation();
// Settings that live with the account rather than the browser. The cached
// ones have already painted, so this only has to correct them (issue #54).
useEffect(() => {
if (!accountId) return;
let cancelled = false;
void (async () => {
const remote = await loadRemoteSettings();
if (cancelled) return;
if (remote) useSettings.getState().hydrate(remote);
// Pushes were held back until now so they could not race the load.
armSettingsSync();
// No file yet — seed one from what this browser has, so the next device
// to sign in starts from these rather than from the defaults.
if (!remote && settingsSyncAvailable()) queueSettingsPush(syncedPart(useSettings.getState().settings));
})();
return () => {
cancelled = true;
};
}, [accountId]);
// Initial data + push wiring // Initial data + push wiring
useEffect(() => { useEffect(() => {
if (!accountId) return; if (!accountId) return;
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_SETTINGS, DEVICE_KEYS, acceptRemote, syncedPart, type Settings } from "@/store/settings";
import { isAppFolder } from "../appFolder";
/**
* Settings used to live only in localStorage, so nothing followed the user
* between devices — issue #54, whose sharpest case is the default identity:
* with none set, the address that sorts first wins, so mail goes out from an
* address the recipient may not recognise.
*
* The split is written as a list of exceptions, which means the interesting
* test is not "does this key sync" but "does a key added later sync without
* anyone remembering to add it".
*/
describe("which settings follow the account", () => {
it("syncs everything that is not explicitly device-local", () => {
const synced = syncedPart(DEFAULT_SETTINGS);
const expected = (Object.keys(DEFAULT_SETTINGS) as Array<keyof Settings>).filter((k) => !DEVICE_KEYS.has(k));
expect(Object.keys(synced).sort()).toEqual(expected.sort());
});
it("keeps this screen's and this browser's settings out of the file", () => {
const synced = syncedPart(DEFAULT_SETTINGS);
// A pane width picked on a monitor is wrong on a laptop, and the
// notification toggles track a per-browser permission grant.
for (const key of ["listPaneWidth", "listPaneHeight", "density", "fontSize", "sidebarCollapsed", "desktopNotifications", "notificationSound"]) {
expect(synced, key).not.toHaveProperty(key);
}
});
it("syncs the default identity, which is what #54 was actually about", () => {
const settings: Settings = { ...DEFAULT_SETTINGS, defaultIdentityByAccount: { a1: "i7" } };
expect(syncedPart(settings).defaultIdentityByAccount).toEqual({ a1: "i7" });
});
it("syncs theme and reading pane", () => {
const synced = syncedPart({ ...DEFAULT_SETTINGS, theme: "dark", readingPane: "bottom" });
expect(synced.theme).toBe("dark");
expect(synced.readingPane).toBe("bottom");
});
});
describe("applying a settings file", () => {
it("takes known, non-device keys", () => {
const applied = acceptRemote({ theme: "dark", weekStart: 0, locale: "de-DE" });
expect(applied).toEqual({ theme: "dark", weekStart: 0, locale: "de-DE" });
});
it("ignores keys it has never heard of", () => {
// A newer ihasmail's settings, or a hand-edited file.
expect(acceptRemote({ theme: "dark", somethingNewer: 42 })).toEqual({ theme: "dark" });
});
it("refuses device keys even when the file carries them", () => {
// An earlier build wrote the whole settings object up; that file must not
// now drag one machine's pane width onto every other one.
expect(acceptRemote({ theme: "dark", listPaneWidth: 900, fontSize: "large" })).toEqual({ theme: "dark" });
});
it("does not invent keys from an empty file", () => {
expect(acceptRemote({})).toEqual({});
});
it("keeps a false or zero value, which is not the same as absent", () => {
const applied = acceptRemote({ conversationMode: false, markReadDelay: 0 });
expect(applied).toEqual({ conversationMode: false, markReadDelay: 0 });
});
});
describe("the client's own folder", () => {
it("is the top-level ihasmail directory", () => {
expect(isAppFolder({ name: "ihasmail", parentId: null, nodeType: "directory" })).toBe(true);
});
it("is not a folder of that name someone made inside another one", () => {
expect(isAppFolder({ name: "ihasmail", parentId: "n1", nodeType: "directory" })).toBe(false);
});
it("is not a file that happens to be called that", () => {
expect(isAppFolder({ name: "ihasmail", parentId: null, nodeType: "file" })).toBe(false);
});
});
+91
View File
@@ -0,0 +1,91 @@
/**
* The `ihasmail` folder in JMAP Files, where the client keeps its own state:
* signature images and over-sized signature HTML (Stalwart caps a signature at
* 2 KB), and the synced settings file.
*
* It is a real folder in the user's account — that is the whole point, since it
* is what makes this state travel between devices without ihasmail storing
* anything server-side of its own — but it is housekeeping rather than
* something anyone filed there, so the Files view hides it. See `isAppFolder`.
*/
import { client, setErrorMessage } from "@/jmap/client";
import type { FileNode, GetResponse, Id, SetResponse } from "@/jmap/types";
import { directoryCreate, normalizeFileNodes, queryOmitsDirectories, supportsNodeType } from "@/lib/filenode";
export const APP_FOLDER = "ihasmail";
/** Just enough to find the folder, asking for nodeType only where it exists. */
export const folderProps = (): string[] =>
supportsNodeType() ? ["id", "name", "nodeType", "parentId"] : ["id", "name", "parentId", "blobId", "size", "type"];
/** The client's own folder, which the Files view does not show. */
export function isAppFolder(n: Pick<FileNode, "name" | "parentId" | "nodeType">): boolean {
return n.name === APP_FOLDER && !n.parentId && n.nodeType === "directory";
}
/** Every node in the account, for servers whose query cannot see directories. */
async function allNodes(accountId: Id, properties: string[]): Promise<FileNode[]> {
const res = await client.call<GetResponse<FileNode>>("FileNode/get", { accountId, ids: null, properties });
return normalizeFileNodes(res.list);
}
/** Find the app folder, or make it. Returns its node id. */
export async function ensureFolder(accountId: Id): Promise<Id> {
const props = folderProps();
let list: FileNode[] = [];
if (queryOmitsDirectories()) {
// Query cannot see a directory on these servers, so it would never find the
// folder and we would make a fresh one on every save. Ask get for the lot.
list = await allNodes(accountId, props);
} else {
try {
const res = await client.chain([
["FileNode/query", { accountId, filter: { isTopLevel: true, nodeType: "directory", name: APP_FOLDER }, limit: 5 }, "q"],
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: props }, "g"],
]);
list = normalizeFileNodes((res.get("g")?.[0] as unknown as GetResponse<FileNode>).list);
} catch {
// Filters unsupported: scan everything and pick it out here.
const res = await client.chain([
["FileNode/query", { accountId, limit: 1000 }, "q"],
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: props }, "g"],
]);
list = normalizeFileNodes((res.get("g")?.[0] as unknown as GetResponse<FileNode>).list);
}
}
const existing = list.find(isAppFolder);
if (existing) return existing.id;
const set = await client.call<SetResponse<FileNode>>("FileNode/set", { accountId, create: { d: directoryCreate(null, APP_FOLDER) } });
const err = set.notCreated?.d;
if (err) throw new Error(setErrorMessage(err));
return set.created!.d!.id;
}
/** A node's persistent blobId, for servers that do not return one on create. */
export async function nodeBlobId(accountId: Id, id?: Id): Promise<Id | undefined> {
if (!id) return undefined;
try {
const res = await client.call<GetResponse<FileNode>>("FileNode/get", { accountId, ids: [id], properties: ["id", "blobId"] });
return res.list[0]?.blobId ?? undefined;
} catch {
return undefined;
}
}
/** Find a file by name inside the app folder. */
export async function findInFolder(accountId: Id, folderId: Id, name: string): Promise<FileNode | undefined> {
const props = ["id", "name", "parentId", "blobId", "size", "type", ...(supportsNodeType() ? ["nodeType"] : [])];
try {
const res = await client.chain([
["FileNode/query", { accountId, filter: { parentId: folderId, name }, limit: 5 }, "q"],
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: props }, "g"],
]);
const list = normalizeFileNodes((res.get("g")?.[0] as unknown as GetResponse<FileNode>).list);
const hit = list.find((n) => n.name === name && n.parentId === folderId);
if (hit) return hit;
} catch {
/* filters unsupported: fall through to the full scan */
}
const list = await allNodes(accountId, props);
return list.find((n) => n.name === name && n.parentId === folderId);
}
+158
View File
@@ -0,0 +1,158 @@
/**
* Settings that follow the account rather than the browser.
*
* Everything used to live in localStorage, which meant no preference travelled
* between devices — most painfully the default identity, where the fallback is
* whichever address sorts first, so a forgotten setting sends mail from an
* address the recipient may not know (issue #54).
*
* The store is a `settings.json` in the account's own JMAP Files, beside the
* signature images that are already kept there. That keeps ihasmail itself
* stateless: no volume, no database, nothing to back up separately, and the
* settings are covered by whatever backs up the mail store.
*
* localStorage stays, demoted to a cache: it is what paints the first frame,
* and the file overwrites it once it lands. A browser with no cache (a private
* window) therefore shows defaults for one frame before the account's real
* settings arrive.
*
* Requires Stalwart 0.16: `FileNode/query` before that cannot see directories
* and the rights model differs. On an older server the settings simply stay
* local, exactly as they were.
*/
import { CAP, client, setErrorMessage } from "@/jmap/client";
import type { FileNode, Id, SetResponse } from "@/jmap/types";
import { ensureFolder, findInFolder, nodeBlobId } from "@/lib/appFolder";
import { fileCreate, supportsNodeType } from "@/lib/filenode";
import { useSession } from "@/store/session";
const FILE = "settings.json";
const TYPE = "application/json";
/** How long a change sits before it is written up. */
const DEBOUNCE_MS = 3000;
let timer: number | null = null;
let pending: Record<string, unknown> | null = null;
let inFlight: Promise<void> | null = null;
/** Nothing is pushed before the first load has settled, or we would race it. */
let armed = false;
let listenersBound = false;
export function settingsSyncAvailable(): boolean {
return supportsNodeType() && client.hasCapability(CAP.filenode) && Boolean(useSession.getState().accountFor(CAP.filenode));
}
/**
* Read the account's settings file. Returns null when there is nothing to read
* — no file yet, no Files, an older server — which leaves the local cache in
* charge rather than wiping it.
*/
export async function loadRemoteSettings(): Promise<Record<string, unknown> | null> {
if (!settingsSyncAvailable()) return null;
const accountId = useSession.getState().accountFor(CAP.filenode)!;
try {
const folderId = await ensureFolder(accountId);
const node = await findInFolder(accountId, folderId, FILE);
if (!node?.blobId) return null;
const text = await client.fetchBlobText(accountId, node.blobId, TYPE);
const parsed = JSON.parse(text) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
return parsed as Record<string, unknown>;
} catch {
// A settings file we cannot read must not cost anyone their session; the
// cached settings are still perfectly good.
return null;
}
}
/** Allow pushes. Called once the first load has settled, either way. */
export function armSettingsSync(): void {
armed = true;
bindFlushListeners();
}
/** Stop syncing and drop anything queued (logout). */
export function stopSettingsSync(): void {
armed = false;
pending = null;
if (timer !== null) {
window.clearTimeout(timer);
timer = null;
}
}
/**
* Queue the synced settings for writing. Called on every change — including
* each frame of a splitter drag — so it coalesces: the newest value wins and
* one request goes out once the changes stop.
*/
export function queueSettingsPush(synced: Record<string, unknown>): void {
if (!armed || !settingsSyncAvailable()) return;
pending = synced;
if (timer !== null) window.clearTimeout(timer);
timer = window.setTimeout(() => {
timer = null;
void flushSettingsPush();
}, DEBOUNCE_MS);
}
/** Write anything queued now, rather than waiting out the debounce. */
export async function flushSettingsPush(): Promise<void> {
if (timer !== null) {
window.clearTimeout(timer);
timer = null;
}
if (!pending || !armed) return;
const body = pending;
pending = null;
// Serialise: two overlapping writes could land in either order.
inFlight = (inFlight ?? Promise.resolve()).then(() => writeSettings(body)).catch(() => undefined);
await inFlight;
}
async function writeSettings(body: Record<string, unknown>): Promise<void> {
if (!settingsSyncAvailable()) return;
const accountId = useSession.getState().accountFor(CAP.filenode)!;
const json = JSON.stringify(body, null, 2);
// Byte length, not character count: a template or a signature with any
// non-ASCII in it would otherwise be reported shorter than it is.
const blob = new Blob([json], { type: TYPE });
const up = await client.upload(accountId, blob, { type: TYPE });
const folderId = await ensureFolder(accountId);
const existing = await findInFolder(accountId, folderId, FILE);
if (existing) {
const res = await client.call<SetResponse<FileNode>>("FileNode/set", {
accountId,
update: { [existing.id]: { blobId: up.blobId, type: TYPE, size: blob.size } },
});
const err = res.notUpdated?.[existing.id];
if (err) throw new Error(setErrorMessage(err));
return;
}
const res = await client.call<SetResponse<FileNode>>("FileNode/set", {
accountId,
create: { s: fileCreate(folderId, FILE, up.blobId, TYPE) },
});
const err = res.notCreated?.s;
if (err) throw new Error(setErrorMessage(err));
// Some servers hand back no blobId on create; ask, so the next read finds it.
await nodeBlobId(accountId, (res.created?.s as Partial<FileNode> | undefined)?.id as Id | undefined);
}
/**
* A debounce that outlives the page helps no one, so a tab going away writes
* first. `visibilitychange` is the one that fires reliably on mobile; `pagehide`
* covers the desktop close.
*/
function bindFlushListeners(): void {
if (listenersBound || typeof window === "undefined") return;
listenersBound = true;
const flush = () => {
if (pending) void flushSettingsPush();
};
window.addEventListener("pagehide", flush);
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") flush();
});
}
+3 -48
View File
@@ -5,47 +5,12 @@
* turns such references into inline cid: parts when sending. * turns such references into inline cid: parts when sending.
*/ */
import { CAP, client, setErrorMessage } from "@/jmap/client"; import { CAP, client, setErrorMessage } from "@/jmap/client";
import type { FileNode, GetResponse, QueryResponse, SetResponse } from "@/jmap/types"; import type { FileNode, QueryResponse, SetResponse } from "@/jmap/types";
import { directoryCreate, fileCreate, normalizeFileNodes, queryOmitsDirectories, supportsNodeType } from "@/lib/filenode"; import { fileCreate } from "@/lib/filenode";
import { ensureFolder, nodeBlobId } from "@/lib/appFolder";
import { useSession } from "@/store/session"; import { useSession } from "@/store/session";
import { toast } from "@/ui/toast"; import { toast } from "@/ui/toast";
const FOLDER = "ihasmail";
/** Just enough to find the folder, asking for nodeType only where it exists. */
const folderProps = () => (supportsNodeType() ? ["id", "name", "nodeType", "parentId"] : ["id", "name", "parentId", "blobId", "size", "type"]);
async function ensureFolder(accountId: string): Promise<string> {
let list: FileNode[] = [];
if (queryOmitsDirectories()) {
// Query cannot see a directory on these servers, so it would never find the
// folder and we would make a fresh one on every save. Ask get for the lot.
const res = await client.call<GetResponse<FileNode>>("FileNode/get", { accountId, ids: null, properties: folderProps() });
list = normalizeFileNodes(res.list);
} else {
try {
const res = await client.chain([
["FileNode/query", { accountId, filter: { isTopLevel: true, nodeType: "directory", name: FOLDER }, limit: 5 }, "q"],
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: folderProps() }, "g"],
]);
list = normalizeFileNodes((res.get("g")?.[0] as unknown as GetResponse<FileNode>).list);
} catch {
// Filters unsupported: scan everything and pick it out here.
const res = await client.chain([
["FileNode/query", { accountId, limit: 1000 }, "q"],
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: folderProps() }, "g"],
]);
list = normalizeFileNodes((res.get("g")?.[0] as unknown as GetResponse<FileNode>).list);
}
}
const existing = list.find((n) => n.name === FOLDER && n.nodeType === "directory" && !n.parentId);
if (existing) return existing.id;
const set = await client.call<SetResponse<FileNode>>("FileNode/set", { accountId, create: { d: directoryCreate(null, FOLDER) } });
const err = set.notCreated?.d;
if (err) throw new Error(setErrorMessage(err));
return set.created!.d!.id;
}
/** Upload an image for use in a signature; returns a same-origin blob URL. */ /** Upload an image for use in a signature; returns a same-origin blob URL. */
export async function uploadSignatureImage(file: File): Promise<string> { export async function uploadSignatureImage(file: File): Promise<string> {
const accountId = useSession.getState().accountFor(CAP.filenode); const accountId = useSession.getState().accountFor(CAP.filenode);
@@ -117,14 +82,4 @@ export async function loadStoredSignature(blobId: string, type = "text/html"): P
return client.fetchBlobText(accountId, blobId, type); return client.fetchBlobText(accountId, blobId, type);
} }
async function nodeBlobId(accountId: string, id?: string): Promise<string | undefined> {
if (!id) return undefined;
try {
const res = await client.call<GetResponse<FileNode>>("FileNode/get", { accountId, ids: [id], properties: ["id", "blobId"] });
return res.list[0]?.blobId ?? undefined;
} catch {
return undefined;
}
}
export type { QueryResponse }; export type { QueryResponse };
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { withoutAppFolder } from "../files";
import type { FileNode } from "@/jmap/types";
/**
* The `ihasmail` folder holds signature images and the synced settings file.
* They are real nodes in the account — that is what makes them travel — but
* they are the client's housekeeping, so Files does not show them.
*
* Hiding the folder alone is worse than showing it: the tree attaches a node
* whose parent is missing to the root, so the signature images would spill out
* into the top level looking like the user's own files.
*/
const node = (id: string, name: string, parentId: string | null, nodeType: "file" | "directory"): FileNode =>
({ id, name, parentId, nodeType, size: null, blobId: null, type: null }) as unknown as FileNode;
describe("hiding the client's folder", () => {
it("removes the folder and everything in it", () => {
const nodes = [
node("f1", "ihasmail", null, "directory"),
node("f2", "signature-1.html", "f1", "file"),
node("f3", "settings.json", "f1", "file"),
node("d1", "Documents", null, "directory"),
node("d2", "notes.txt", "d1", "file"),
];
expect(withoutAppFolder(nodes).map((n) => n.id)).toEqual(["d1", "d2"]);
});
it("removes nested contents, not just direct children", () => {
const nodes = [
node("f1", "ihasmail", null, "directory"),
node("f2", "images", "f1", "directory"),
node("f3", "logo.png", "f2", "file"),
];
expect(withoutAppFolder(nodes)).toEqual([]);
});
it("leaves a folder of the same name that the user made inside another", () => {
const nodes = [node("d1", "Projects", null, "directory"), node("d2", "ihasmail", "d1", "directory")];
expect(withoutAppFolder(nodes).map((n) => n.id)).toEqual(["d1", "d2"]);
});
it("leaves a top-level file that happens to be called ihasmail", () => {
const nodes = [node("x1", "ihasmail", null, "file")];
expect(withoutAppFolder(nodes).map((n) => n.id)).toEqual(["x1"]);
});
it("returns the list untouched when there is no such folder", () => {
const nodes = [node("d1", "Documents", null, "directory")];
expect(withoutAppFolder(nodes)).toBe(nodes);
});
});
+36 -5
View File
@@ -1,6 +1,7 @@
import { create } from "zustand"; import { create } from "zustand";
import { CAP, JmapMethodError, client, setErrorMessage } from "@/jmap/client"; import { CAP, JmapMethodError, client, setErrorMessage } from "@/jmap/client";
import { directoryCreate, fileCreate, fileNodeProps, normalizeFileNodes, queryOmitsDirectories } from "@/lib/filenode"; import { directoryCreate, fileCreate, fileNodeProps, normalizeFileNodes, queryOmitsDirectories } from "@/lib/filenode";
import { isAppFolder } from "@/lib/appFolder";
import type { FileNode, GetResponse, Id, QueryResponse, SetResponse } from "@/jmap/types"; import type { FileNode, GetResponse, Id, QueryResponse, SetResponse } from "@/jmap/types";
import { useSession } from "./session"; import { useSession } from "./session";
@@ -30,6 +31,31 @@ let filtersSupported = true;
const byName = (a: FileNode, b: FileNode) => (a.nodeType === b.nodeType ? a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }) : a.nodeType === "directory" ? -1 : 1); const byName = (a: FileNode, b: FileNode) => (a.nodeType === b.nodeType ? a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }) : a.nodeType === "directory" ? -1 : 1);
/**
* Drop the client's own `ihasmail` folder, and everything inside it, from a
* listing. It holds signature images and the synced settings file — real nodes
* in the account, but housekeeping rather than anything the user filed.
*
* The contents have to go too: the tree attaches a node whose parent is missing
* to the root, so hiding the folder alone would spill its files into the top
* level, which is worse than showing the folder.
*/
export function withoutAppFolder(nodes: FileNode[]): FileNode[] {
const hidden = new Set<Id>();
for (const n of nodes) if (isAppFolder(n)) hidden.add(n.id);
if (!hidden.size) return nodes;
for (let grew = true; grew; ) {
grew = false;
for (const n of nodes) {
if (!hidden.has(n.id) && n.parentId && hidden.has(n.parentId)) {
hidden.add(n.id);
grew = true;
}
}
}
return nodes.filter((n) => !hidden.has(n.id));
}
/** Fetch all nodes (paged, no filter) and rebuild the full children map. */ /** Fetch all nodes (paged, no filter) and rebuild the full children map. */
async function loadAllNodes(accountId: Id, set: (fn: (s: FilesState) => Partial<FilesState>) => void): Promise<void> { async function loadAllNodes(accountId: Id, set: (fn: (s: FilesState) => Partial<FilesState>) => void): Promise<void> {
const all: FileNode[] = []; const all: FileNode[] = [];
@@ -52,14 +78,17 @@ async function loadAllNodes(accountId: Id, set: (fn: (s: FilesState) => Partial<
if (!q.ids.length || (q.total != null && position >= q.total)) break; if (!q.ids.length || (q.total != null && position >= q.total)) break;
} }
} }
// After the whole collection, not per page: the folder and its contents can
// land in different pages, and a half-filtered pass would spill the rest.
const visible = withoutAppFolder(all);
const nodes: Record<Id, FileNode> = {}; const nodes: Record<Id, FileNode> = {};
const children: Record<string, Id[]> = { root: [] }; const children: Record<string, Id[]> = { root: [] };
for (const n of all) nodes[n.id] = n; for (const n of visible) nodes[n.id] = n;
for (const n of all.sort(byName)) { for (const n of visible.sort(byName)) {
const key = n.parentId && nodes[n.parentId] ? n.parentId : "root"; const key = n.parentId && nodes[n.parentId] ? n.parentId : "root";
(children[key] ??= []).push(n.id); (children[key] ??= []).push(n.id);
} }
for (const n of all) children[n.id] ??= []; for (const n of visible) children[n.id] ??= [];
set(() => ({ nodes, children, loading: false, error: null })); set(() => ({ nodes, children, loading: false, error: null }));
} }
@@ -95,10 +124,12 @@ export const useFiles = create<FilesState>((set, get) => ({
]); ]);
const q = res.get("q")?.[0] as unknown as QueryResponse; const q = res.get("q")?.[0] as unknown as QueryResponse;
const g = res.get("g")?.[0] as unknown as GetResponse<FileNode>; const g = res.get("g")?.[0] as unknown as GetResponse<FileNode>;
const listed = withoutAppFolder(normalizeFileNodes(g.list));
const keep = new Set(listed.map((n) => n.id));
set((s) => { set((s) => {
const nodes = { ...s.nodes }; const nodes = { ...s.nodes };
for (const n of normalizeFileNodes(g.list)) nodes[n.id] = n; for (const n of listed) nodes[n.id] = n;
return { nodes, children: { ...s.children, [parentId ?? "root"]: q.ids }, loading: false, error: null }; return { nodes, children: { ...s.children, [parentId ?? "root"]: q.ids.filter((id) => keep.has(id)) }, loading: false, error: null };
}); });
} catch (err) { } catch (err) {
// Older Stalwart releases don't support parentId / isTopLevel filters: fall back to // Older Stalwart releases don't support parentId / isTopLevel filters: fall back to
+10
View File
@@ -3,6 +3,7 @@ import { apiFetch, ApiError, CAP, client } from "@/jmap/client";
import type { Id, JmapSession } from "@/jmap/types"; import type { Id, JmapSession } from "@/jmap/types";
import { push, type PushState } from "@/jmap/push"; import { push, type PushState } from "@/jmap/push";
import { setServerLocale } from "@/lib/datetime"; import { setServerLocale } from "@/lib/datetime";
import { flushSettingsPush, stopSettingsSync } from "@/lib/settingsSync";
export type AuthStatus = "loading" | "anonymous" | "authenticated"; export type AuthStatus = "loading" | "anonymous" | "authenticated";
@@ -54,6 +55,14 @@ export const useSession = create<SessionState>((set, get) => ({
async logout() { async logout() {
push.stop(); push.stop();
setServerLocale(null); setServerLocale(null);
// Anything still sitting in the debounce is written while the session can
// still write it; a setting changed seconds before signing out is not lost.
try {
await flushSettingsPush();
} catch {
/* ignore */
}
stopSettingsSync();
try { try {
await apiFetch("/api/auth/logout", { method: "POST" }); await apiFetch("/api/auth/logout", { method: "POST" });
} catch { } catch {
@@ -96,6 +105,7 @@ function applySession(s: JmapSession, set: (p: Partial<SessionState>) => void) {
client.onUnauthenticated(() => { client.onUnauthenticated(() => {
push.stop(); push.stop();
stopSettingsSync();
client.session = null; client.session = null;
useSession.setState({ status: "anonymous", session: null, accountId: null }); useSession.setState({ status: "anonymous", session: null, accountId: null });
}); });
+62
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react"; 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 { queueSettingsPush } from "@/lib/settingsSync";
import { setDateTimePrefs, type DateFormat, type TimeFormat } from "@/lib/datetime"; import { setDateTimePrefs, type DateFormat, type TimeFormat } from "@/lib/datetime";
export type Theme = "system" | "light" | "dark"; export type Theme = "system" | "light" | "dark";
@@ -141,12 +142,59 @@ export const DEFAULT_SETTINGS: Settings = {
defaultIdentityByAccount: {}, defaultIdentityByAccount: {},
}; };
/**
* Settings that describe *this screen or this browser*, and so stay in
* localStorage: a list-pane width picked on a 27" monitor is wrong on a
* laptop, and the notification toggles track a permission the browser grants
* per-device, so syncing them would claim something untrue elsewhere.
*
* Everything else follows the account (issue #54). The list is written as the
* exceptions rather than the rule so that a setting added later syncs by
* default, which is what someone adding one almost always wants.
*/
export const DEVICE_KEYS: ReadonlySet<keyof Settings> = new Set<keyof Settings>([
"density",
"fontSize",
"sidebarCollapsed",
"desktopNotifications",
"notificationSound",
"listPaneWidth",
"listPaneHeight",
]);
/** The part of the settings that is written to the account's settings file. */
export function syncedPart(s: Settings): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const key of Object.keys(s) as Array<keyof Settings>) {
if (!DEVICE_KEYS.has(key)) out[key] = s[key];
}
return out;
}
/**
* What of a settings file we are willing to apply: known keys only, and never
* a device one — an older ihasmail wrote the whole object up, and that file
* should not now drag another machine's pane width across.
*/
export function acceptRemote(remote: Record<string, unknown>): Partial<Settings> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(remote)) {
if (!(key in DEFAULT_SETTINGS)) continue;
if (DEVICE_KEYS.has(key as keyof Settings)) continue;
if (value === undefined) continue;
out[key] = value;
}
return out as Partial<Settings>;
}
interface SettingsState { interface SettingsState {
settings: Settings; settings: Settings;
update(patch: Partial<Settings>): void; update(patch: Partial<Settings>): void;
reset(): void; reset(): void;
exportJson(): string; exportJson(): string;
importJson(json: string): boolean; importJson(json: string): boolean;
/** Apply the account's settings file over the cached ones. */
hydrate(remote: Record<string, unknown>): void;
} }
const initialSettings = loadJson<Settings>("settings", DEFAULT_SETTINGS); const initialSettings = loadJson<Settings>("settings", DEFAULT_SETTINGS);
@@ -160,12 +208,18 @@ export const useSettings = create<SettingsState>((set, get) => ({
set({ settings }); set({ settings });
applyTheme(settings); applyTheme(settings);
applyDateTimePrefs(settings); applyDateTimePrefs(settings);
// Dragging a splitter changes a device key on every frame and must not put
// a request in the air; anything else is queued and coalesced.
if (Object.keys(patch).some((k) => !DEVICE_KEYS.has(k as keyof Settings))) {
queueSettingsPush(syncedPart(settings));
}
}, },
reset() { reset() {
saveJson("settings", DEFAULT_SETTINGS); saveJson("settings", DEFAULT_SETTINGS);
set({ settings: DEFAULT_SETTINGS }); set({ settings: DEFAULT_SETTINGS });
applyTheme(DEFAULT_SETTINGS); applyTheme(DEFAULT_SETTINGS);
applyDateTimePrefs(DEFAULT_SETTINGS); applyDateTimePrefs(DEFAULT_SETTINGS);
queueSettingsPush(syncedPart(DEFAULT_SETTINGS));
}, },
exportJson() { exportJson() {
return JSON.stringify(get().settings, null, 2); return JSON.stringify(get().settings, null, 2);
@@ -179,6 +233,14 @@ export const useSettings = create<SettingsState>((set, get) => ({
return false; return false;
} }
}, },
hydrate(remote) {
const settings = { ...get().settings, ...acceptRemote(remote) };
// Cache it, so the next first frame on this browser is already right.
saveJson("settings", settings);
set({ settings });
applyTheme(settings);
applyDateTimePrefs(settings);
},
})); }));
function applyDateTimePrefs(s: Settings): void { function applyDateTimePrefs(s: Settings): void {