Files
ihasmail-inbuxa/web/src/lib/appFolder.ts
T
jcoffey-dev 0a9218f622 Keep settings with the account, not the browser
Every setting lived in localStorage, so none of them travelled between
devices. The sharpest edge 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 -- and someone who sets it at work finds it
unset at home, with nothing to say so. Reported in #54.

They now live in a settings.json in the account's own JMAP Files, beside
the signature images already kept there. ihasmail itself stays stateless:
no volume, no database, nothing to back up separately, and the settings
are covered by whatever backs up the mail store.

localStorage stays as a cache rather than the source of truth, so the
first frame is painted from it and the file corrects it a moment later.
A private window has no cache and shows defaults for that one frame,
which is the trade for not gating the whole app on a network round trip.

Not everything should follow the account. 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 claiming it elsewhere would
be a lie. Those stay local, written as a list of exceptions so that a
setting added later syncs by default -- which is what adding one almost
always means.

Writes are coalesced: update() fires on every frame of a splitter drag,
so a change waits 3s and the newest value wins. A tab going away flushes
first, as does signing out, so a setting changed seconds before either
is not lost.

The ihasmail folder is now hidden from the Files view, contents and all.
Hiding the folder alone would have been worse than showing it: the tree
attaches a node whose parent is missing to the root, so the signature
images would have spilled into the top level as if the user had put them
there. Those images have been visible since signatures shipped.

Requires 0.16 -- FileNode/query cannot see directories before that. On
0.15 settings stay local exactly as they were.

Verified against the mock end to end: folder create, blob upload, node
create, read back, update, re-read. Not yet exercised against the live
0.16.19.
2026-08-26 08:19:57 -07:00

92 lines
4.5 KiB
TypeScript

/**
* 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);
}