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
+11 -7
View File
@@ -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<string> {
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<FileNode>).list;
list = withNodeType((res.get("g")?.[0] as unknown as GetResponse<FileNode>).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<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);
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;
if (err) throw new Error(setErrorMessage(err));
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 folderId = await ensureFolder(accountId);
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;
if (err) throw new Error(setErrorMessage(err));
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 folderId = await ensureFolder(accountId);
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;
if (err) throw new Error(setErrorMessage(err));
const created = res.created?.f as Partial<FileNode> | undefined;