Stop sending nodeType to servers that have no such property

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.
This commit is contained in:
2026-08-24 09:42:25 -07:00
parent c92a68aba1
commit ee7542fd86
4 changed files with 157 additions and 14 deletions
+83
View File
@@ -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<FileNode>[];
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<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 = withNodeType([{ 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);
});
+56
View File
@@ -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<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 };
}
/** 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 `nodeType` where the server does not report it, so everything
* downstream — icons, sorting, "is this a folder" — can rely on it.
*/
export function withNodeType<T extends Partial<FileNode>>(nodes: T[]): T[] {
if (supportsNodeType()) return nodes;
return nodes.map((n) => (n.nodeType ? n : { ...n, nodeType: isFile(n) ? "file" : "directory" }));
}
function isFile(n: Partial<FileNode>): boolean {
return n.blobId != null || n.size != null || n.type != null;
}
+11 -7
View File
@@ -6,30 +6,34 @@
*/ */
import { CAP, client, setErrorMessage } from "@/jmap/client"; import { CAP, client, setErrorMessage } from "@/jmap/client";
import type { FileNode, GetResponse, QueryResponse, SetResponse } from "@/jmap/types"; import type { FileNode, GetResponse, QueryResponse, SetResponse } from "@/jmap/types";
import { directoryCreate, fileCreate, supportsNodeType, withNodeType } from "@/lib/filenode";
import { useSession } from "@/store/session"; import { useSession } from "@/store/session";
import { toast } from "@/ui/toast"; import { toast } from "@/ui/toast";
const FOLDER = "ihasmail"; 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<string> { async function ensureFolder(accountId: string): Promise<string> {
let list: FileNode[] = []; let list: FileNode[] = [];
try { try {
const res = await client.chain([ const res = await client.chain([
["FileNode/query", { accountId, filter: { isTopLevel: true, nodeType: "directory", name: FOLDER }, limit: 5 }, "q"], ["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<FileNode>).list; list = withNodeType((res.get("g")?.[0] as unknown as GetResponse<FileNode>).list);
} catch { } catch {
// Older servers: no filter support — scan everything. // Older servers: no filter support — scan everything.
const res = await client.chain([ const res = await client.chain([
["FileNode/query", { accountId, limit: 1000 }, "q"], ["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<FileNode>).list; list = withNodeType((res.get("g")?.[0] as unknown as GetResponse<FileNode>).list);
} }
const existing = list.find((n) => n.name === FOLDER && n.nodeType === "directory" && !n.parentId); const existing = list.find((n) => n.name === FOLDER && n.nodeType === "directory" && !n.parentId);
if (existing) return existing.id; if (existing) return existing.id;
const set = await client.call<SetResponse<FileNode>>("FileNode/set", { accountId, create: { d: { parentId: null, name: FOLDER, nodeType: "directory" } } }); const set = await client.call<SetResponse<FileNode>>("FileNode/set", { accountId, create: { d: directoryCreate(null, FOLDER) } });
const err = set.notCreated?.d; const err = set.notCreated?.d;
if (err) throw new Error(setErrorMessage(err)); if (err) throw new Error(setErrorMessage(err));
return set.created!.d!.id; return set.created!.d!.id;
@@ -51,7 +55,7 @@ export async function uploadSignatureImage(file: File): Promise<string> {
const up = await client.upload(accountId, file, { type }); const up = await client.upload(accountId, file, { type });
const folderId = await ensureFolder(accountId); const folderId = await ensureFolder(accountId);
const name = `${Date.now()}-${file.name.replace(/[^\w.-]+/g, "_")}`; const name = `${Date.now()}-${file.name.replace(/[^\w.-]+/g, "_")}`;
const res = await client.call<SetResponse<FileNode>>("FileNode/set", { accountId, create: { f: { parentId: folderId, name, nodeType: "file", blobId: up.blobId, type } } }); const res = await client.call<SetResponse<FileNode>>("FileNode/set", { accountId, create: { f: fileCreate(folderId, name, up.blobId, type) } });
const err = res.notCreated?.f; const err = res.notCreated?.f;
if (err) throw new Error(setErrorMessage(err)); if (err) throw new Error(setErrorMessage(err));
const created = res.created?.f as Partial<FileNode> | undefined; const created = res.created?.f as Partial<FileNode> | undefined;
@@ -71,7 +75,7 @@ export async function storeSignatureHtml(html: string): Promise<string> {
const up = await client.upload(accountId, new Blob([html], { type: "text/html" }), { type: "text/html" }); const up = await client.upload(accountId, new Blob([html], { type: "text/html" }), { type: "text/html" });
const folderId = await ensureFolder(accountId); const folderId = await ensureFolder(accountId);
const name = `signature-${Date.now()}.html`; const name = `signature-${Date.now()}.html`;
const res = await client.call<SetResponse<FileNode>>("FileNode/set", { accountId, create: { f: { parentId: folderId, name, nodeType: "file", blobId: up.blobId, type: "text/html" } } }); const res = await client.call<SetResponse<FileNode>>("FileNode/set", { accountId, create: { f: fileCreate(folderId, name, up.blobId, "text/html") } });
const err = res.notCreated?.f; const err = res.notCreated?.f;
if (err) throw new Error(setErrorMessage(err)); if (err) throw new Error(setErrorMessage(err));
const created = res.created?.f as Partial<FileNode> | undefined; const created = res.created?.f as Partial<FileNode> | undefined;
+7 -7
View File
@@ -1,5 +1,6 @@
import { create } from "zustand"; import { create } from "zustand";
import { CAP, JmapMethodError, client, setErrorMessage } from "@/jmap/client"; 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 type { FileNode, GetResponse, Id, QueryResponse, SetResponse } from "@/jmap/types";
import { useSession } from "./session"; import { useSession } from "./session";
@@ -23,7 +24,6 @@ interface FilesState {
applyChanges(types: Set<string>): void; applyChanges(types: Set<string>): 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). */ /** Whether the server supports parentId/isTopLevel query filters (detected at runtime). */
let filtersSupported = true; let filtersSupported = true;
@@ -37,11 +37,11 @@ async function loadAllNodes(accountId: Id, set: (fn: (s: FilesState) => Partial<
for (let guard = 0; guard < 100; guard++) { for (let guard = 0; guard < 100; guard++) {
const res = await client.chain([ const res = await client.chain([
["FileNode/query", { accountId, position, limit: 500, calculateTotal: true }, "q"], ["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 q = res.get("q")?.[0] as unknown as QueryResponse;
const g = res.get("g")?.[0] as unknown as GetResponse<FileNode>; const g = res.get("g")?.[0] as unknown as GetResponse<FileNode>;
all.push(...g.list); all.push(...withNodeType(g.list));
position += q.ids.length; position += q.ids.length;
if (!q.ids.length || (q.total != null && position >= q.total)) break; if (!q.ids.length || (q.total != null && position >= q.total)) break;
} }
@@ -84,13 +84,13 @@ export const useFiles = create<FilesState>((set, get) => ({
const filter = parentId ? { parentId } : { isTopLevel: true }; const filter = parentId ? { parentId } : { isTopLevel: true };
const res = await client.chain([ const res = await client.chain([
["FileNode/query", { accountId, filter, sort: [{ property: "nodeType", isAscending: false }, { property: "name", isAscending: true }], limit: 1000 }, "q"], ["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 q = res.get("q")?.[0] as unknown as QueryResponse;
const g = res.get("g")?.[0] as unknown as GetResponse<FileNode>; const g = res.get("g")?.[0] as unknown as GetResponse<FileNode>;
set((s) => { set((s) => {
const nodes = { ...s.nodes }; 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 }; return { nodes, children: { ...s.children, [parentId ?? "root"]: q.ids }, loading: false, error: null };
}); });
} catch (err) { } catch (err) {
@@ -112,7 +112,7 @@ export const useFiles = create<FilesState>((set, get) => ({
async mkdir(parentId, name) { async mkdir(parentId, name) {
const accountId = get().accountId!; const accountId = get().accountId!;
const res = await client.call<SetResponse<FileNode>>("FileNode/set", { accountId, create: { d: { parentId, name, nodeType: "directory" } } }); const res = await client.call<SetResponse<FileNode>>("FileNode/set", { accountId, create: { d: directoryCreate(parentId, name) } });
const err = res.notCreated?.d; const err = res.notCreated?.d;
if (err) throw new Error(setErrorMessage(err)); if (err) throw new Error(setErrorMessage(err));
await get().loadChildren(parentId); await get().loadChildren(parentId);
@@ -131,7 +131,7 @@ export const useFiles = create<FilesState>((set, get) => ({
}); });
const res = await client.call<SetResponse<FileNode>>("FileNode/set", { const res = await client.call<SetResponse<FileNode>>("FileNode/set", {
accountId, 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; const err = res.notCreated?.f;
if (err) throw new Error(setErrorMessage(err)); if (err) throw new Error(setErrorMessage(err));