diff --git a/web/src/lib/__tests__/filenode.test.ts b/web/src/lib/__tests__/filenode.test.ts new file mode 100644 index 0000000..c350d5e --- /dev/null +++ b/web/src/lib/__tests__/filenode.test.ts @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { client } from "@/jmap/client"; +import { directoryCreate, fileCreate, fileNodeProps, supportsNodeType, withNodeType } 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"]; + +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[]; + expect(withNodeType(nodes)).toEqual(nodes); + }); +}); + +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 = withNodeType([ + { 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[]); + 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 = withNodeType([{ id: "1", name: "x", nodeType: "symlink", blobId: "b1" }] as Partial[]); + expect(out[0]!.nodeType).toBe("symlink"); + }); +}); + +it("assumes the older shape when there is no session yet", () => { + client.session = null; + expect(supportsNodeType()).toBe(false); +}); diff --git a/web/src/lib/filenode.ts b/web/src/lib/filenode.ts new file mode 100644 index 0000000..39e38b6 --- /dev/null +++ b/web/src/lib/filenode.ts @@ -0,0 +1,56 @@ +/** + * FileNode compatibility across Stalwart releases. + * + * `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". + */ +import { client } from "@/jmap/client"; +import type { FileNode, Id } from "@/jmap/types"; + +const STALWART_CAP = "urn:stalwart:jmap"; + +export function supportsNodeType(): boolean { + return client.hasCapability(STALWART_CAP); +} + +const BASE_PROPS = ["id", "parentId", "blobId", "size", "name", "type", "created", "modified", "myRights", "role", "executable"]; + +/** Properties to request, asking for `nodeType` only where it exists. */ +export function fileNodeProps(): string[] { + return supportsNodeType() ? [...BASE_PROPS, "nodeType"] : BASE_PROPS; +} + +/** Create-arguments for a directory. */ +export function directoryCreate(parentId: Id | null, name: string): Record { + // 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 }; +} + +/** Create-arguments for a file with an already-uploaded blob. */ +export function fileCreate(parentId: Id | null, name: string, blobId: Id, type: string): Record { + const base = { parentId, name, blobId, type }; + return supportsNodeType() ? { ...base, nodeType: "file" } : base; +} + +/** + * Fill in `nodeType` where the server does not report it, so everything + * downstream — icons, sorting, "is this a folder" — can rely on it. + */ +export function withNodeType>(nodes: T[]): T[] { + if (supportsNodeType()) return nodes; + return nodes.map((n) => (n.nodeType ? n : { ...n, nodeType: isFile(n) ? "file" : "directory" })); +} + +function isFile(n: Partial): boolean { + return n.blobId != null || n.size != null || n.type != null; +} diff --git a/web/src/lib/signatureImages.ts b/web/src/lib/signatureImages.ts index 16eaef5..9f73fe2 100644 --- a/web/src/lib/signatureImages.ts +++ b/web/src/lib/signatureImages.ts @@ -6,30 +6,34 @@ */ import { CAP, client, setErrorMessage } from "@/jmap/client"; import type { FileNode, GetResponse, QueryResponse, SetResponse } from "@/jmap/types"; +import { directoryCreate, fileCreate, supportsNodeType, withNodeType } from "@/lib/filenode"; import { useSession } from "@/store/session"; 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 { let list: FileNode[] = []; 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: ["id", "name", "nodeType", "parentId"] }, "g"], + ["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: folderProps() }, "g"], ]); - list = (res.get("g")?.[0] as unknown as GetResponse).list; + list = withNodeType((res.get("g")?.[0] as unknown as GetResponse).list); } catch { // Older servers: no filter support — scan everything. const res = await client.chain([ ["FileNode/query", { accountId, limit: 1000 }, "q"], - ["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: ["id", "name", "nodeType", "parentId"] }, "g"], + ["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: folderProps() }, "g"], ]); - list = (res.get("g")?.[0] as unknown as GetResponse).list; + list = withNodeType((res.get("g")?.[0] as unknown as GetResponse).list); } const existing = list.find((n) => n.name === FOLDER && n.nodeType === "directory" && !n.parentId); if (existing) return existing.id; - const set = await client.call>("FileNode/set", { accountId, create: { d: { parentId: null, name: FOLDER, nodeType: "directory" } } }); + const set = await client.call>("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; @@ -51,7 +55,7 @@ export async function uploadSignatureImage(file: File): Promise { const up = await client.upload(accountId, file, { type }); const folderId = await ensureFolder(accountId); const name = `${Date.now()}-${file.name.replace(/[^\w.-]+/g, "_")}`; - const res = await client.call>("FileNode/set", { accountId, create: { f: { parentId: folderId, name, nodeType: "file", blobId: up.blobId, type } } }); + const res = await client.call>("FileNode/set", { accountId, create: { f: fileCreate(folderId, name, up.blobId, type) } }); const err = res.notCreated?.f; if (err) throw new Error(setErrorMessage(err)); const created = res.created?.f as Partial | undefined; @@ -71,7 +75,7 @@ export async function storeSignatureHtml(html: string): Promise { const up = await client.upload(accountId, new Blob([html], { type: "text/html" }), { type: "text/html" }); const folderId = await ensureFolder(accountId); const name = `signature-${Date.now()}.html`; - const res = await client.call>("FileNode/set", { accountId, create: { f: { parentId: folderId, name, nodeType: "file", blobId: up.blobId, type: "text/html" } } }); + const res = await client.call>("FileNode/set", { accountId, create: { f: fileCreate(folderId, name, up.blobId, "text/html") } }); const err = res.notCreated?.f; if (err) throw new Error(setErrorMessage(err)); const created = res.created?.f as Partial | undefined; diff --git a/web/src/store/files.ts b/web/src/store/files.ts index f5cb530..21ba126 100644 --- a/web/src/store/files.ts +++ b/web/src/store/files.ts @@ -1,5 +1,6 @@ import { create } from "zustand"; import { CAP, JmapMethodError, client, setErrorMessage } from "@/jmap/client"; +import { directoryCreate, fileCreate, fileNodeProps, withNodeType } from "@/lib/filenode"; import type { FileNode, GetResponse, Id, QueryResponse, SetResponse } from "@/jmap/types"; import { useSession } from "./session"; @@ -23,7 +24,6 @@ interface FilesState { applyChanges(types: Set): void; } -const PROPS = ["id", "parentId", "nodeType", "blobId", "size", "name", "type", "created", "modified", "myRights", "role", "executable"]; /** Whether the server supports parentId/isTopLevel query filters (detected at runtime). */ let filtersSupported = true; @@ -37,11 +37,11 @@ async function loadAllNodes(accountId: Id, set: (fn: (s: FilesState) => Partial< 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: PROPS }, "g"], + ["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; - all.push(...g.list); + all.push(...withNodeType(g.list)); position += q.ids.length; if (!q.ids.length || (q.total != null && position >= q.total)) break; } @@ -84,13 +84,13 @@ export const useFiles = create((set, get) => ({ 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"], - ["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: PROPS }, "g"], + ["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; set((s) => { const nodes = { ...s.nodes }; - for (const n of g.list) nodes[n.id] = n; + for (const n of withNodeType(g.list)) nodes[n.id] = n; return { nodes, children: { ...s.children, [parentId ?? "root"]: q.ids }, loading: false, error: null }; }); } catch (err) { @@ -112,7 +112,7 @@ export const useFiles = create((set, get) => ({ async mkdir(parentId, name) { const accountId = get().accountId!; - const res = await client.call>("FileNode/set", { accountId, create: { d: { parentId, name, nodeType: "directory" } } }); + const res = await client.call>("FileNode/set", { accountId, create: { d: directoryCreate(parentId, name) } }); const err = res.notCreated?.d; if (err) throw new Error(setErrorMessage(err)); await get().loadChildren(parentId); @@ -131,7 +131,7 @@ export const useFiles = create((set, get) => ({ }); const res = await client.call>("FileNode/set", { accountId, - create: { f: { parentId, name: f.name, nodeType: "file", blobId: up.blobId, type: f.type || "application/octet-stream" } }, + create: { f: fileCreate(parentId, f.name, up.blobId, f.type || "application/octet-stream") }, }); const err = res.notCreated?.f; if (err) throw new Error(setErrorMessage(err));