Drop Stalwart 0.15 support
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.
This commit is contained in:
@@ -36,8 +36,7 @@ export interface JmapSession {
|
||||
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;
|
||||
/** "oss" | "community" | "enterprise". Stalwart publishes no version. */
|
||||
edition?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { client } from "@/jmap/client";
|
||||
import { directoryCreate, fileCreate, fileNodeProps, normalizeFileNodes, queryOmitsDirectories, supportsNodeType } from "../filenode";
|
||||
import type { FileNode, JmapSession } from "@/jmap/types";
|
||||
|
||||
/**
|
||||
* `nodeType` arrived in Stalwart 0.16. Sending it to an older server fails the
|
||||
* whole create with `invalidProperties (nodeType)` — which is what uploading a
|
||||
* file or making a folder hit on the live 0.15.5 box. Those servers tell a file
|
||||
* from a directory by whether it carries file properties at all.
|
||||
*/
|
||||
|
||||
function session(caps: string[]): JmapSession {
|
||||
return { capabilities: Object.fromEntries(caps.map((c) => [c, {}])), accounts: {}, primaryAccounts: {}, state: "s" } as unknown as JmapSession;
|
||||
}
|
||||
|
||||
const NEW_SERVER = ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:filenode", "urn:stalwart:jmap"];
|
||||
const OLD_SERVER = ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:filenode"];
|
||||
|
||||
/**
|
||||
* The session a real Stalwart 0.16 sends: `urn:stalwart:jmap` is handed out
|
||||
* per-account and never appears in the session-level capabilities, so a client
|
||||
* that only checks there drops every 0.16 server onto the older code path.
|
||||
*/
|
||||
function realStalwartSession(): JmapSession {
|
||||
return {
|
||||
capabilities: Object.fromEntries(OLD_SERVER.map((c) => [c, {}])),
|
||||
accounts: { a1: { accountCapabilities: { "urn:ietf:params:jmap:filenode": {}, "urn:stalwart:jmap": {} } } },
|
||||
primaryAccounts: { "urn:stalwart:jmap": "a1" },
|
||||
state: "s",
|
||||
} as unknown as JmapSession;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
client.session = null;
|
||||
});
|
||||
|
||||
describe("on Stalwart 0.16 and newer", () => {
|
||||
it("uses nodeType everywhere", () => {
|
||||
client.session = session(NEW_SERVER);
|
||||
expect(supportsNodeType()).toBe(true);
|
||||
expect(fileNodeProps()).toContain("nodeType");
|
||||
expect(directoryCreate(null, "ihasmail")).toEqual({ parentId: null, name: "ihasmail", nodeType: "directory" });
|
||||
expect(fileCreate("d1", "logo.png", "b1", "image/png")).toEqual({ parentId: "d1", name: "logo.png", blobId: "b1", type: "image/png", nodeType: "file" });
|
||||
});
|
||||
|
||||
it("leaves what the server reported alone", () => {
|
||||
client.session = session(NEW_SERVER);
|
||||
const nodes = [{ id: "1", name: "x", nodeType: "directory" }] as Partial<FileNode>[];
|
||||
expect(normalizeFileNodes(nodes)).toEqual(nodes);
|
||||
});
|
||||
});
|
||||
|
||||
describe("on a real 0.16 session, which advertises per-account only", () => {
|
||||
it("is recognised as 0.16 even though the session capabilities do not say so", () => {
|
||||
client.session = realStalwartSession();
|
||||
expect(client.hasCapability("urn:stalwart:jmap")).toBe(false);
|
||||
expect(supportsNodeType()).toBe(true);
|
||||
expect(queryOmitsDirectories()).toBe(false);
|
||||
expect(directoryCreate(null, "ihasmail")).toEqual({ parentId: null, name: "ihasmail", nodeType: "directory" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("on Stalwart before 0.16", () => {
|
||||
it("never mentions nodeType, in creates or in requested properties", () => {
|
||||
client.session = session(OLD_SERVER);
|
||||
expect(supportsNodeType()).toBe(false);
|
||||
expect(fileNodeProps()).not.toContain("nodeType");
|
||||
expect(directoryCreate(null, "ihasmail")).toEqual({ parentId: null, name: "ihasmail" });
|
||||
expect(JSON.stringify(fileCreate("d1", "logo.png", "b1", "image/png"))).not.toContain("nodeType");
|
||||
});
|
||||
|
||||
it("keeps a directory free of file properties, which is what makes it one", () => {
|
||||
client.session = session(OLD_SERVER);
|
||||
const dir = directoryCreate(null, "ihasmail");
|
||||
// Setting blobId, size or type — even to null — would make this a file.
|
||||
expect(dir).not.toHaveProperty("blobId");
|
||||
expect(dir).not.toHaveProperty("size");
|
||||
expect(dir).not.toHaveProperty("type");
|
||||
});
|
||||
|
||||
it("still sends what a file needs", () => {
|
||||
client.session = session(OLD_SERVER);
|
||||
expect(fileCreate("d1", "logo.png", "b1", "image/png")).toEqual({ parentId: "d1", name: "logo.png", blobId: "b1", type: "image/png" });
|
||||
});
|
||||
|
||||
it("works out nodeType from the file properties, so folders stay folders", () => {
|
||||
client.session = session(OLD_SERVER);
|
||||
const out = normalizeFileNodes([
|
||||
{ id: "1", name: "Documents", blobId: null, size: null, type: null },
|
||||
{ id: "2", name: "notes.txt", blobId: "b1", size: 11, type: "text/plain" },
|
||||
{ id: "3", name: "empty.txt", blobId: "b2", size: 0, type: null },
|
||||
] as Partial<FileNode>[]);
|
||||
expect(out.map((n) => n.nodeType)).toEqual(["directory", "file", "file"]);
|
||||
});
|
||||
|
||||
it("does not overwrite a nodeType that did come back", () => {
|
||||
client.session = session(OLD_SERVER);
|
||||
const out = normalizeFileNodes([{ id: "1", name: "x", nodeType: "symlink", blobId: "b1" }] as Partial<FileNode>[]);
|
||||
expect(out[0]!.nodeType).toBe("symlink");
|
||||
});
|
||||
});
|
||||
|
||||
it("assumes the older shape when there is no session yet", () => {
|
||||
client.session = null;
|
||||
expect(supportsNodeType()).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* Rights were split up in 0.16. Before that a node carried mayRead / mayWrite /
|
||||
* mayShare, with mayWrite covering everything the newer release names
|
||||
* separately — so Rename and Delete sat permanently greyed out, doing nothing
|
||||
* and saying nothing.
|
||||
*/
|
||||
describe("rights on a pre-0.16 server", () => {
|
||||
const oldRights = (mayWrite: boolean) => ({ mayRead: true, mayWrite, mayShare: false });
|
||||
|
||||
it("widens mayWrite into the rights the UI gates on", () => {
|
||||
client.session = session(OLD_SERVER);
|
||||
const [node] = normalizeFileNodes([{ id: "1", name: "x", myRights: oldRights(true) }] as unknown as Partial<FileNode>[]);
|
||||
expect(node!.myRights).toMatchObject({ mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: true, mayModifyContent: true, mayShare: false });
|
||||
});
|
||||
|
||||
it("does not hand out rights the server withheld", () => {
|
||||
client.session = session(OLD_SERVER);
|
||||
const [node] = normalizeFileNodes([{ id: "1", name: "x", myRights: oldRights(false) }] as unknown as Partial<FileNode>[]);
|
||||
expect(node!.myRights).toMatchObject({ mayRename: false, mayDelete: false, mayModifyContent: false });
|
||||
});
|
||||
|
||||
it("leaves rights that already use the newer names untouched", () => {
|
||||
client.session = session(OLD_SERVER);
|
||||
const newer = { mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: false, mayModifyContent: true, mayShare: true };
|
||||
const [node] = normalizeFileNodes([{ id: "1", name: "x", myRights: newer }] as unknown as Partial<FileNode>[]);
|
||||
expect(node!.myRights).toEqual(newer);
|
||||
});
|
||||
|
||||
it("copes with a node that reported no rights at all", () => {
|
||||
client.session = session(OLD_SERVER);
|
||||
const [node] = normalizeFileNodes([{ id: "1", name: "x" }] as Partial<FileNode>[]);
|
||||
expect(node!.myRights).toBeUndefined();
|
||||
expect(node!.nodeType).toBe("directory");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Before 0.16, FileNode/query masks its results with `document_ids(false)` —
|
||||
* only resources that are *not* containers. It therefore returns files and
|
||||
* never folders, with no error to explain the omission: a folder created there
|
||||
* exists but never comes back in a listing. FileNode/get carries no such mask.
|
||||
*/
|
||||
describe("directory-blind query", () => {
|
||||
it("is worked around on older servers", () => {
|
||||
client.session = session(OLD_SERVER);
|
||||
expect(queryOmitsDirectories()).toBe(true);
|
||||
});
|
||||
|
||||
it("is not worked around where query can see folders", () => {
|
||||
client.session = session(NEW_SERVER);
|
||||
expect(queryOmitsDirectories()).toBe(false);
|
||||
});
|
||||
});
|
||||
+23
-45
@@ -7,53 +7,39 @@
|
||||
* 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`.
|
||||
*
|
||||
* Both lookups below filter on `parentId`/`isTopLevel` alone and match the name
|
||||
* here rather than asking the server to. Those are the filters Files itself
|
||||
* relies on; `name` is not one Stalwart is known to implement, and a filter it
|
||||
* does not know fails the whole query rather than being ignored.
|
||||
*/
|
||||
import { client, setErrorMessage } from "@/jmap/client";
|
||||
import type { FileNode, GetResponse, Id, SetResponse } from "@/jmap/types";
|
||||
import { directoryCreate, normalizeFileNodes, queryOmitsDirectories, supportsNodeType } from "@/lib/filenode";
|
||||
import { directoryCreate } 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"];
|
||||
/** Just enough to find the folder. */
|
||||
export const folderProps = (): string[] => ["id", "name", "nodeType", "parentId"];
|
||||
|
||||
/** 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);
|
||||
/** List one level of the tree: the top level, or the children of a folder. */
|
||||
async function children(accountId: Id, parentId: Id | null, properties: string[]): Promise<FileNode[]> {
|
||||
const filter = parentId ? { parentId } : { isTopLevel: true };
|
||||
const res = await client.chain([
|
||||
["FileNode/query", { accountId, filter, limit: 1000 }, "q"],
|
||||
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties }, "g"],
|
||||
]);
|
||||
return (res.get("g")?.[0] as unknown as GetResponse<FileNode>).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);
|
||||
const existing = (await children(accountId, null, folderProps())).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;
|
||||
@@ -61,7 +47,10 @@ export async function ensureFolder(accountId: Id): Promise<Id> {
|
||||
return set.created!.d!.id;
|
||||
}
|
||||
|
||||
/** A node's persistent blobId, for servers that do not return one on create. */
|
||||
/**
|
||||
* A node's persistent blobId. `FileNode/set` does not return one on create, so
|
||||
* anything that needs the blob straight after making the node has to ask.
|
||||
*/
|
||||
export async function nodeBlobId(accountId: Id, id?: Id): Promise<Id | undefined> {
|
||||
if (!id) return undefined;
|
||||
try {
|
||||
@@ -74,18 +63,7 @@ export async function nodeBlobId(accountId: Id, id?: Id): Promise<Id | 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);
|
||||
const props = ["id", "name", "parentId", "blobId", "size", "type", "nodeType"];
|
||||
const list = await children(accountId, folderId, props);
|
||||
return list.find((n) => n.name === name && n.parentId === folderId);
|
||||
}
|
||||
|
||||
+12
-78
@@ -1,92 +1,26 @@
|
||||
/**
|
||||
* FileNode compatibility across Stalwart releases.
|
||||
* FileNode shapes, as Stalwart 0.16 defines them.
|
||||
*
|
||||
* `nodeType` arrived in 0.16. Before that a FileNode had no such property at
|
||||
* all, and the server rejects the whole create with
|
||||
* `invalidProperties (nodeType)` — which is what uploading a file or making a
|
||||
* folder used to hit. Older servers instead tell a file from a directory by
|
||||
* whether it carries file properties at all: set `blobId`, `size` or `type`
|
||||
* (even to null) and the node becomes a file, leave them off and it is a
|
||||
* directory.
|
||||
*
|
||||
* 0.16 is also the first release to advertise `urn:stalwart:jmap`, and no
|
||||
* earlier one knows that capability, so its presence is a reliable stand-in for
|
||||
* "this server has the newer FileNode shape" — as long as it is looked for in
|
||||
* `primaryAccounts` and `accountCapabilities`, which is where Stalwart puts it,
|
||||
* and not only in the session-level `capabilities`, where it never appears.
|
||||
* This used to be a compatibility layer spanning 0.15 and 0.16, which differ
|
||||
* in ways the server does not report: `nodeType` did not exist and sending it
|
||||
* failed the create outright, `FileNode/query` masked directories out of its
|
||||
* own results, and rights were a single `mayWrite` rather than the four
|
||||
* separate ones. ihasmail requires 0.16 now — sign-in refuses anything older —
|
||||
* so a node has one shape and there is nothing left to detect.
|
||||
*/
|
||||
import { client } from "@/jmap/client";
|
||||
import type { FileNode, Id } from "@/jmap/types";
|
||||
import type { Id } from "@/jmap/types";
|
||||
|
||||
const STALWART_CAP = "urn:stalwart:jmap";
|
||||
|
||||
export function supportsNodeType(): boolean {
|
||||
// Not `hasCapability`: Stalwart advertises this per-account, never in the
|
||||
// session-level capabilities, so looking only there treats every real 0.16
|
||||
// server as pre-0.16 and drops Files onto the older code path.
|
||||
return client.hasCapabilityAnywhere(STALWART_CAP);
|
||||
}
|
||||
|
||||
const BASE_PROPS = ["id", "parentId", "blobId", "size", "name", "type", "created", "modified", "myRights", "role", "executable"];
|
||||
|
||||
/**
|
||||
* Whether `FileNode/query` is blind to directories.
|
||||
*
|
||||
* Before 0.16 the query masks its results with `document_ids(false)`, which
|
||||
* keeps only resources that are *not* containers — so it returns files and
|
||||
* never folders, with no error to say so. A folder created there is real, and
|
||||
* simply never comes back in a listing. `FileNode/get` has no such mask, so
|
||||
* asking it for every id is the only way to see the whole tree.
|
||||
*/
|
||||
export function queryOmitsDirectories(): boolean {
|
||||
return !supportsNodeType();
|
||||
}
|
||||
|
||||
/** Properties to request, asking for `nodeType` only where it exists. */
|
||||
/** Properties to request for a node. */
|
||||
export function fileNodeProps(): string[] {
|
||||
return supportsNodeType() ? [...BASE_PROPS, "nodeType"] : BASE_PROPS;
|
||||
return ["id", "parentId", "blobId", "size", "name", "type", "created", "modified", "myRights", "role", "executable", "nodeType"];
|
||||
}
|
||||
|
||||
/** Create-arguments for a directory. */
|
||||
export function directoryCreate(parentId: Id | null, name: string): Record<string, unknown> {
|
||||
// Any file property — blobId, size, type — would make this a file on an
|
||||
// older server, so a directory there is exactly parentId plus name.
|
||||
return supportsNodeType() ? { parentId, name, nodeType: "directory" } : { parentId, name };
|
||||
return { parentId, name, nodeType: "directory" };
|
||||
}
|
||||
|
||||
/** Create-arguments for a file with an already-uploaded blob. */
|
||||
export function fileCreate(parentId: Id | null, name: string, blobId: Id, type: string): Record<string, unknown> {
|
||||
const base = { parentId, name, blobId, type };
|
||||
return supportsNodeType() ? { ...base, nodeType: "file" } : base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill in what an older server does not report, so everything downstream —
|
||||
* icons, sorting, "may I delete this" — can read the 0.16 shape.
|
||||
*
|
||||
* Rights were split up in 0.16. Before that a node carried `mayRead`,
|
||||
* `mayWrite` and `mayShare`, with the one `mayWrite` covering everything the
|
||||
* newer release names separately. Without translating it, the Rename and
|
||||
* Delete menu items sit permanently greyed out: no error, just nothing.
|
||||
*/
|
||||
export function normalizeFileNodes<T extends Partial<FileNode>>(nodes: T[]): T[] {
|
||||
if (supportsNodeType()) return nodes;
|
||||
return nodes.map((n) => ({
|
||||
...n,
|
||||
nodeType: n.nodeType ?? (isFile(n) ? "file" : "directory"),
|
||||
myRights: widenRights(n.myRights),
|
||||
}));
|
||||
}
|
||||
|
||||
type Rights = FileNode["myRights"];
|
||||
|
||||
function widenRights(rights: Rights | undefined): Rights | undefined {
|
||||
if (!rights) return rights;
|
||||
const r = rights as Rights & { mayWrite?: boolean };
|
||||
if (r.mayDelete !== undefined || r.mayWrite === undefined) return rights; // already the newer shape
|
||||
return { ...r, mayAddChildren: r.mayWrite, mayRename: r.mayWrite, mayDelete: r.mayWrite, mayModifyContent: r.mayWrite };
|
||||
}
|
||||
|
||||
function isFile(n: Partial<FileNode>): boolean {
|
||||
return n.blobId != null || n.size != null || n.type != null;
|
||||
return { parentId, name, blobId, type, nodeType: "file" };
|
||||
}
|
||||
|
||||
@@ -15,15 +15,11 @@
|
||||
* 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 { fileCreate } from "@/lib/filenode";
|
||||
import { useSession } from "@/store/session";
|
||||
|
||||
const FILE = "settings.json";
|
||||
@@ -40,7 +36,7 @@ let armed = false;
|
||||
let listenersBound = false;
|
||||
|
||||
export function settingsSyncAvailable(): boolean {
|
||||
return supportsNodeType() && client.hasCapability(CAP.filenode) && Boolean(useSession.getState().accountFor(CAP.filenode));
|
||||
return client.hasCapability(CAP.filenode) && Boolean(useSession.getState().accountFor(CAP.filenode));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+7
-59
@@ -1,6 +1,6 @@
|
||||
import { create } from "zustand";
|
||||
import { CAP, JmapMethodError, client, setErrorMessage } from "@/jmap/client";
|
||||
import { directoryCreate, fileCreate, fileNodeProps, normalizeFileNodes, queryOmitsDirectories } from "@/lib/filenode";
|
||||
import { CAP, client, setErrorMessage } from "@/jmap/client";
|
||||
import { directoryCreate, fileCreate, fileNodeProps } from "@/lib/filenode";
|
||||
import { isAppFolder } from "@/lib/appFolder";
|
||||
import type { FileNode, GetResponse, Id, QueryResponse, SetResponse } from "@/jmap/types";
|
||||
import { useSession } from "./session";
|
||||
@@ -26,10 +26,6 @@ interface FilesState {
|
||||
}
|
||||
|
||||
|
||||
/** Whether the server supports parentId/isTopLevel query filters (detected at runtime). */
|
||||
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);
|
||||
|
||||
/**
|
||||
* Drop the client's own `ihasmail` folder, and everything inside it, from a
|
||||
@@ -56,42 +52,6 @@ export function withoutAppFolder(nodes: FileNode[]): FileNode[] {
|
||||
return nodes.filter((n) => !hidden.has(n.id));
|
||||
}
|
||||
|
||||
/** 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> {
|
||||
const all: FileNode[] = [];
|
||||
if (queryOmitsDirectories()) {
|
||||
// Query would hand back files only, so every folder — including one just
|
||||
// created — would be missing with nothing to say why. Ask get for the lot.
|
||||
const res = await client.call<GetResponse<FileNode>>("FileNode/get", { accountId, ids: null, properties: fileNodeProps() });
|
||||
all.push(...normalizeFileNodes(res.list));
|
||||
} else {
|
||||
let position = 0;
|
||||
for (let guard = 0; guard < 100; guard++) {
|
||||
const res = await client.chain([
|
||||
["FileNode/query", { accountId, position, limit: 500, calculateTotal: true }, "q"],
|
||||
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: fileNodeProps() }, "g"],
|
||||
]);
|
||||
const q = res.get("q")?.[0] as unknown as QueryResponse;
|
||||
const g = res.get("g")?.[0] as unknown as GetResponse<FileNode>;
|
||||
all.push(...normalizeFileNodes(g.list));
|
||||
position += q.ids.length;
|
||||
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 children: Record<string, Id[]> = { root: [] };
|
||||
for (const n of visible) nodes[n.id] = n;
|
||||
for (const n of visible.sort(byName)) {
|
||||
const key = n.parentId && nodes[n.parentId] ? n.parentId : "root";
|
||||
(children[key] ??= []).push(n.id);
|
||||
}
|
||||
for (const n of visible) children[n.id] ??= [];
|
||||
set(() => ({ nodes, children, loading: false, error: null }));
|
||||
}
|
||||
|
||||
export const useFiles = create<FilesState>((set, get) => ({
|
||||
accountId: null,
|
||||
available: false,
|
||||
@@ -113,10 +73,6 @@ export const useFiles = create<FilesState>((set, get) => ({
|
||||
if (!accountId) return;
|
||||
set({ loading: true });
|
||||
try {
|
||||
if (!filtersSupported || queryOmitsDirectories()) {
|
||||
await loadAllNodes(accountId, set);
|
||||
return;
|
||||
}
|
||||
const filter = parentId ? { parentId } : { isTopLevel: true };
|
||||
const res = await client.chain([
|
||||
["FileNode/query", { accountId, filter, sort: [{ property: "nodeType", isAscending: false }, { property: "name", isAscending: true }], limit: 1000 }, "q"],
|
||||
@@ -124,7 +80,7 @@ export const useFiles = create<FilesState>((set, get) => ({
|
||||
]);
|
||||
const q = res.get("q")?.[0] as unknown as QueryResponse;
|
||||
const g = res.get("g")?.[0] as unknown as GetResponse<FileNode>;
|
||||
const listed = withoutAppFolder(normalizeFileNodes(g.list));
|
||||
const listed = withoutAppFolder(g.list);
|
||||
const keep = new Set(listed.map((n) => n.id));
|
||||
set((s) => {
|
||||
const nodes = { ...s.nodes };
|
||||
@@ -132,18 +88,10 @@ export const useFiles = create<FilesState>((set, get) => ({
|
||||
return { nodes, children: { ...s.children, [parentId ?? "root"]: q.ids.filter((id) => keep.has(id)) }, loading: false, error: null };
|
||||
});
|
||||
} catch (err) {
|
||||
// Older Stalwart releases don't support parentId / isTopLevel filters: fall back to
|
||||
// fetching every node and building the tree client-side.
|
||||
if (err instanceof JmapMethodError && (err.type === "unsupportedFilter" || err.type === "unsupportedSort")) {
|
||||
filtersSupported = false;
|
||||
try {
|
||||
await loadAllNodes(accountId, set);
|
||||
return;
|
||||
} catch (err2) {
|
||||
set({ loading: false, error: (err2 as Error).message });
|
||||
return;
|
||||
}
|
||||
}
|
||||
// There used to be a fallback here that abandoned filters and fetched
|
||||
// every node in the account, because 0.15 refused parentId/isTopLevel.
|
||||
// 0.16 supports them, and quietly loading the whole tree instead would
|
||||
// hide a real fault behind a performance cliff nobody would notice.
|
||||
set({ loading: false, error: (err as Error).message });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -28,7 +28,7 @@ export function AboutSettings() {
|
||||
<tr><td>Image privacy proxy</td><td>{session?.ihasmail?.imageProxy ? "enabled" : "disabled"}</td></tr>
|
||||
</tbody>
|
||||
</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>
|
||||
<p className="hint" style={{ marginTop: 6 }}>Stalwart does not publish its version number to mail clients, so ihasmail reports the edition where the server gives one. ihasmail requires 0.16 or newer, and sign-in refuses anything older.</p>
|
||||
<h2>Server capabilities</h2>
|
||||
<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>)}
|
||||
@@ -39,12 +39,11 @@ export function AboutSettings() {
|
||||
|
||||
/**
|
||||
* Stalwart deliberately withholds its version from clients (it reports a fixed
|
||||
* "1.0.0" wherever it publishes one at all), so the most honest thing we can
|
||||
* show is which generation of its API answered us, plus the edition where the
|
||||
* server reports it.
|
||||
* "1.0.0" wherever it publishes one at all), so the edition is all there is to
|
||||
* show. The generation used to be reported here too, back when ihasmail spoke
|
||||
* to both 0.15 and 0.16; it requires 0.16 now, so signing in at all is the
|
||||
* answer to that question.
|
||||
*/
|
||||
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;
|
||||
function describeServer(server: { edition?: string | null } | undefined): string {
|
||||
return server?.edition ? `0.16 or newer (${server.edition})` : "0.16 or newer";
|
||||
}
|
||||
|
||||
@@ -26,17 +26,15 @@ interface AppPasswordRow {
|
||||
}
|
||||
|
||||
interface SecurityState {
|
||||
backend: "registry" | "legacy";
|
||||
otpEnabled: boolean;
|
||||
appPasswords: AppPasswordRow[];
|
||||
appPasswordsKeyedByName: boolean;
|
||||
}
|
||||
|
||||
export function SecuritySettings() {
|
||||
const [rows, setRows] = useState<SessionRow[] | null>(null);
|
||||
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). */
|
||||
/** Set when the server has no self-service API at all (a proxy, say). */
|
||||
const [unsupported, setUnsupported] = useState<string | null>(null);
|
||||
const session = useSession((s) => s.session);
|
||||
const logout = useSession((s) => s.logout);
|
||||
@@ -342,12 +340,12 @@ function AppPasswords({ state, reload }: { state: SecurityState | null; reload:
|
||||
</p>
|
||||
{state.appPasswords.length > 0 && (
|
||||
<table className="sessions-table">
|
||||
<thead><tr><th>Name</th>{!state.appPasswordsKeyedByName && <th>Created</th>}<th /></tr></thead>
|
||||
<thead><tr><th>Name</th><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>{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>
|
||||
))}
|
||||
@@ -361,7 +359,6 @@ function AppPasswords({ state, reload }: { state: SecurityState | null; reload:
|
||||
</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>}>
|
||||
|
||||
Reference in New Issue
Block a user