Drop Stalwart 0.15 support

ihasmail spoke to two generations of Stalwart that are less alike than
their version numbers suggest: 0.16 replaced the REST management API with
JMAP registry objects, changed the shape of FileNode, split its rights
up, and moved configuration into the store. Carrying both meant 34 branch
points across nine files, a 92-line compatibility shim whose only job was
telling them apart, a parallel REST implementation of every credential
operation, and a mock that had to model both.

The branches were not the real cost. The cost was that a wrong answer
about which generation had answered always had somewhere to fall back to,
so it failed quietly rather than loudly: one capability looked for in the
wrong place downgraded every real 0.16 server onto the 0.15 path, which
posted the current password to an endpoint 0.16 had removed, reported the
wrong generation on About, and ran Files on the older code. It reached
production and was recorded as verified when it was not. The mock
mirrored the same wrong placement, which is why the tests agreed.

Removed: the filenode compatibility shim, the dual "registry" | "legacy"
backend in account.ts, the pre-0.16 generation in AccountInfo and
everything that read it, the mock's LEGACY mode and dev:mock:legacy, and
the three test files that existed only to pin 0.15 behaviour.

Sign-in now refuses an older server by name, once, rather than letting
Files, the account locale and credentials each fail in their own way with
nothing connecting them. It says the credentials were fine -- someone
hitting this has typed a correct password, and telling them otherwise
sends them round in circles -- and names the tag to build from. Four
tests cover it, including that no session cookie is minted and that bad
credentials on such a server are still a plain 401.

Two fallbacks went that were not strictly about 0.15, and both for the
same reason the removal is happening. Files no longer answers a refused
filter or sort by fetching every node in the account, which would hide a
real fault behind a performance cliff nobody would notice. And the app
folder lookups now filter on parentId/isTopLevel alone and match names
client-side, since `name` is not a filter Stalwart is known to implement
and one it does not know fails the whole query rather than being ignored.

The last release that runs on 0.15 is tagged stalwart-0.15-support.

Verified against the mock end to end: sign-in, the Files tree on the 0.16
path with the app folder hidden, and self-service credentials over the
registry. 226 web + 75 server tests pass; typecheck and build clean.
This commit is contained in:
2026-08-26 09:51:03 -07:00
parent f41e3d2631
commit 94bf42cfda
20 changed files with 315 additions and 1036 deletions
+12 -78
View File
@@ -1,92 +1,26 @@
/**
* FileNode compatibility across Stalwart releases.
* FileNode shapes, as Stalwart 0.16 defines them.
*
* `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" — as long as it is looked for in
* `primaryAccounts` and `accountCapabilities`, which is where Stalwart puts it,
* and not only in the session-level `capabilities`, where it never appears.
* This used to be a compatibility layer spanning 0.15 and 0.16, which differ
* in ways the server does not report: `nodeType` did not exist and sending it
* failed the create outright, `FileNode/query` masked directories out of its
* own results, and rights were a single `mayWrite` rather than the four
* separate ones. ihasmail requires 0.16 now — sign-in refuses anything older —
* so a node has one shape and there is nothing left to detect.
*/
import { client } from "@/jmap/client";
import type { FileNode, Id } from "@/jmap/types";
import type { Id } from "@/jmap/types";
const STALWART_CAP = "urn:stalwart:jmap";
export function supportsNodeType(): boolean {
// Not `hasCapability`: Stalwart advertises this per-account, never in the
// session-level capabilities, so looking only there treats every real 0.16
// server as pre-0.16 and drops Files onto the older code path.
return client.hasCapabilityAnywhere(STALWART_CAP);
}
const BASE_PROPS = ["id", "parentId", "blobId", "size", "name", "type", "created", "modified", "myRights", "role", "executable"];
/**
* Whether `FileNode/query` is blind to directories.
*
* Before 0.16 the query masks its results with `document_ids(false)`, which
* keeps only resources that are *not* containers — so it returns files and
* never folders, with no error to say so. A folder created there is real, and
* simply never comes back in a listing. `FileNode/get` has no such mask, so
* asking it for every id is the only way to see the whole tree.
*/
export function queryOmitsDirectories(): boolean {
return !supportsNodeType();
}
/** Properties to request, asking for `nodeType` only where it exists. */
/** Properties to request for a node. */
export function fileNodeProps(): string[] {
return supportsNodeType() ? [...BASE_PROPS, "nodeType"] : BASE_PROPS;
return ["id", "parentId", "blobId", "size", "name", "type", "created", "modified", "myRights", "role", "executable", "nodeType"];
}
/** 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 };
return { parentId, name, nodeType: "directory" };
}
/** 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 what an older server does not report, so everything downstream —
* icons, sorting, "may I delete this" — can read the 0.16 shape.
*
* Rights were split up in 0.16. Before that a node carried `mayRead`,
* `mayWrite` and `mayShare`, with the one `mayWrite` covering everything the
* newer release names separately. Without translating it, the Rename and
* Delete menu items sit permanently greyed out: no error, just nothing.
*/
export function normalizeFileNodes<T extends Partial<FileNode>>(nodes: T[]): T[] {
if (supportsNodeType()) return nodes;
return nodes.map((n) => ({
...n,
nodeType: n.nodeType ?? (isFile(n) ? "file" : "directory"),
myRights: widenRights(n.myRights),
}));
}
type Rights = FileNode["myRights"];
function widenRights(rights: Rights | undefined): Rights | undefined {
if (!rights) return rights;
const r = rights as Rights & { mayWrite?: boolean };
if (r.mayDelete !== undefined || r.mayWrite === undefined) return rights; // already the newer shape
return { ...r, mayAddChildren: r.mayWrite, mayRename: r.mayWrite, mayDelete: r.mayWrite, mayModifyContent: r.mayWrite };
}
function isFile(n: Partial<FileNode>): boolean {
return n.blobId != null || n.size != null || n.type != null;
return { parentId, name, blobId, type, nodeType: "file" };
}