From 49ea07eeaae60d620c4d64bdf7416f2dfdaf4634 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 24 Aug 2026 09:42:25 -0700 Subject: [PATCH] Stop sending nodeType to servers that have no such property MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uploading a file or creating a folder failed on the live 0.15.5 server with `invalidProperties (nodeType)`. The property arrived in Stalwart 0.16; before that a FileNode has no nodeType at all, and the create is refused outright. Older servers tell a file from a directory a different way: the node carries file properties or it does not. Setting blobId, size or type — even to null — makes it a file, so a directory there is exactly parentId plus name. 0.16 is also the first release to advertise urn:stalwart:jmap and no earlier one knows that capability, so its presence stands in for "has the newer FileNode shape". Creates, and the property lists we ask for, are shaped from that. The read side needed it too: a server that never reports nodeType would have had every folder drawn with a file icon, sorted among the files and opening as a download. Nodes are normalised as they arrive, so everything downstream can still just read nodeType. --- web/src/lib/__tests__/filenode.test.ts | 83 ++++++++++++++++++++++++++ web/src/lib/filenode.ts | 56 +++++++++++++++++ web/src/lib/signatureImages.ts | 18 +++--- web/src/store/files.ts | 14 ++--- 4 files changed, 157 insertions(+), 14 deletions(-) create mode 100644 web/src/lib/__tests__/filenode.test.ts create mode 100644 web/src/lib/filenode.ts 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));