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:
+23
-45
@@ -7,53 +7,39 @@
|
||||
* is what makes this state travel between devices without ihasmail storing
|
||||
* anything server-side of its own — but it is housekeeping rather than
|
||||
* something anyone filed there, so the Files view hides it. See `isAppFolder`.
|
||||
*
|
||||
* Both lookups below filter on `parentId`/`isTopLevel` alone and match the name
|
||||
* here rather than asking the server to. Those are the filters Files itself
|
||||
* relies on; `name` is not one Stalwart is known to implement, and a filter it
|
||||
* does not know fails the whole query rather than being ignored.
|
||||
*/
|
||||
import { client, setErrorMessage } from "@/jmap/client";
|
||||
import type { FileNode, GetResponse, Id, SetResponse } from "@/jmap/types";
|
||||
import { directoryCreate, normalizeFileNodes, queryOmitsDirectories, supportsNodeType } from "@/lib/filenode";
|
||||
import { directoryCreate } from "@/lib/filenode";
|
||||
|
||||
export const APP_FOLDER = "ihasmail";
|
||||
|
||||
/** Just enough to find the folder, asking for nodeType only where it exists. */
|
||||
export const folderProps = (): string[] =>
|
||||
supportsNodeType() ? ["id", "name", "nodeType", "parentId"] : ["id", "name", "parentId", "blobId", "size", "type"];
|
||||
/** Just enough to find the folder. */
|
||||
export const folderProps = (): string[] => ["id", "name", "nodeType", "parentId"];
|
||||
|
||||
/** The client's own folder, which the Files view does not show. */
|
||||
export function isAppFolder(n: Pick<FileNode, "name" | "parentId" | "nodeType">): boolean {
|
||||
return n.name === APP_FOLDER && !n.parentId && n.nodeType === "directory";
|
||||
}
|
||||
|
||||
/** Every node in the account, for servers whose query cannot see directories. */
|
||||
async function allNodes(accountId: Id, properties: string[]): Promise<FileNode[]> {
|
||||
const res = await client.call<GetResponse<FileNode>>("FileNode/get", { accountId, ids: null, properties });
|
||||
return normalizeFileNodes(res.list);
|
||||
/** List one level of the tree: the top level, or the children of a folder. */
|
||||
async function children(accountId: Id, parentId: Id | null, properties: string[]): Promise<FileNode[]> {
|
||||
const filter = parentId ? { parentId } : { isTopLevel: true };
|
||||
const res = await client.chain([
|
||||
["FileNode/query", { accountId, filter, limit: 1000 }, "q"],
|
||||
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties }, "g"],
|
||||
]);
|
||||
return (res.get("g")?.[0] as unknown as GetResponse<FileNode>).list;
|
||||
}
|
||||
|
||||
/** Find the app folder, or make it. Returns its node id. */
|
||||
export async function ensureFolder(accountId: Id): Promise<Id> {
|
||||
const props = folderProps();
|
||||
let list: FileNode[] = [];
|
||||
if (queryOmitsDirectories()) {
|
||||
// Query cannot see a directory on these servers, so it would never find the
|
||||
// folder and we would make a fresh one on every save. Ask get for the lot.
|
||||
list = await allNodes(accountId, props);
|
||||
} else {
|
||||
try {
|
||||
const res = await client.chain([
|
||||
["FileNode/query", { accountId, filter: { isTopLevel: true, nodeType: "directory", name: APP_FOLDER }, limit: 5 }, "q"],
|
||||
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: props }, "g"],
|
||||
]);
|
||||
list = normalizeFileNodes((res.get("g")?.[0] as unknown as GetResponse<FileNode>).list);
|
||||
} catch {
|
||||
// Filters unsupported: scan everything and pick it out here.
|
||||
const res = await client.chain([
|
||||
["FileNode/query", { accountId, limit: 1000 }, "q"],
|
||||
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: props }, "g"],
|
||||
]);
|
||||
list = normalizeFileNodes((res.get("g")?.[0] as unknown as GetResponse<FileNode>).list);
|
||||
}
|
||||
}
|
||||
const existing = list.find(isAppFolder);
|
||||
const existing = (await children(accountId, null, folderProps())).find(isAppFolder);
|
||||
if (existing) return existing.id;
|
||||
const set = await client.call<SetResponse<FileNode>>("FileNode/set", { accountId, create: { d: directoryCreate(null, APP_FOLDER) } });
|
||||
const err = set.notCreated?.d;
|
||||
@@ -61,7 +47,10 @@ export async function ensureFolder(accountId: Id): Promise<Id> {
|
||||
return set.created!.d!.id;
|
||||
}
|
||||
|
||||
/** A node's persistent blobId, for servers that do not return one on create. */
|
||||
/**
|
||||
* A node's persistent blobId. `FileNode/set` does not return one on create, so
|
||||
* anything that needs the blob straight after making the node has to ask.
|
||||
*/
|
||||
export async function nodeBlobId(accountId: Id, id?: Id): Promise<Id | undefined> {
|
||||
if (!id) return undefined;
|
||||
try {
|
||||
@@ -74,18 +63,7 @@ export async function nodeBlobId(accountId: Id, id?: Id): Promise<Id | undefined
|
||||
|
||||
/** Find a file by name inside the app folder. */
|
||||
export async function findInFolder(accountId: Id, folderId: Id, name: string): Promise<FileNode | undefined> {
|
||||
const props = ["id", "name", "parentId", "blobId", "size", "type", ...(supportsNodeType() ? ["nodeType"] : [])];
|
||||
try {
|
||||
const res = await client.chain([
|
||||
["FileNode/query", { accountId, filter: { parentId: folderId, name }, limit: 5 }, "q"],
|
||||
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: props }, "g"],
|
||||
]);
|
||||
const list = normalizeFileNodes((res.get("g")?.[0] as unknown as GetResponse<FileNode>).list);
|
||||
const hit = list.find((n) => n.name === name && n.parentId === folderId);
|
||||
if (hit) return hit;
|
||||
} catch {
|
||||
/* filters unsupported: fall through to the full scan */
|
||||
}
|
||||
const list = await allNodes(accountId, props);
|
||||
const props = ["id", "name", "parentId", "blobId", "size", "type", "nodeType"];
|
||||
const list = await children(accountId, folderId, props);
|
||||
return list.find((n) => n.name === name && n.parentId === folderId);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user