ihasmail spoke to two generations of Stalwart that are less alike than their version numbers suggest: 0.16 replaced the REST management API with JMAP registry objects, changed the shape of FileNode, split its rights up, and moved configuration into the store. Carrying both meant 34 branch points across nine files, a 92-line compatibility shim whose only job was telling them apart, a parallel REST implementation of every credential operation, and a mock that had to model both. The branches were not the real cost. The cost was that a wrong answer about which generation had answered always had somewhere to fall back to, so it failed quietly rather than loudly: one capability looked for in the wrong place downgraded every real 0.16 server onto the 0.15 path, which posted the current password to an endpoint 0.16 had removed, reported the wrong generation on About, and ran Files on the older code. It reached production and was recorded as verified when it was not. The mock mirrored the same wrong placement, which is why the tests agreed. Removed: the filenode compatibility shim, the dual "registry" | "legacy" backend in account.ts, the pre-0.16 generation in AccountInfo and everything that read it, the mock's LEGACY mode and dev:mock:legacy, and the three test files that existed only to pin 0.15 behaviour. Sign-in now refuses an older server by name, once, rather than letting Files, the account locale and credentials each fail in their own way with nothing connecting them. It says the credentials were fine -- someone hitting this has typed a correct password, and telling them otherwise sends them round in circles -- and names the tag to build from. Four tests cover it, including that no session cookie is minted and that bad credentials on such a server are still a plain 401. Two fallbacks went that were not strictly about 0.15, and both for the same reason the removal is happening. Files no longer answers a refused filter or sort by fetching every node in the account, which would hide a real fault behind a performance cliff nobody would notice. And the app folder lookups now filter on parentId/isTopLevel alone and match names client-side, since `name` is not a filter Stalwart is known to implement and one it does not know fails the whole query rather than being ignored. The last release that runs on 0.15 is tagged stalwart-0.15-support. Verified against the mock end to end: sign-in, the Files tree on the 0.16 path with the app folder hidden, and self-service credentials over the registry. 226 web + 75 server tests pass; typecheck and build clean.
155 lines
5.9 KiB
TypeScript
155 lines
5.9 KiB
TypeScript
/**
|
|
* 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.
|
|
*/
|
|
import { CAP, client, setErrorMessage } from "@/jmap/client";
|
|
import type { FileNode, Id, SetResponse } from "@/jmap/types";
|
|
import { ensureFolder, findInFolder, nodeBlobId } from "@/lib/appFolder";
|
|
import { fileCreate } 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 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();
|
|
});
|
|
}
|