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 0057fce558
commit 49ea07eeaa
4 changed files with 157 additions and 14 deletions
+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;
}