Look for Stalwart's capability where Stalwart advertises it

Self-service credentials, the About page and Files all keyed off
`urn:stalwart:jmap`, and all three looked for it in the session-level
`capabilities`. Stalwart has never put it there. `Session::new` builds that
list from a fixed set the capability is not part of, in any 0.16.x from
0.16.0 to 0.16.19; it is handed out per-account instead, so it arrives in
`primaryAccounts` and in each account's `accountCapabilities`.

So every real 0.16 server read as pre-0.16. Password changes, 2FA and app
passwords fell back to `POST /api/account/auth`, which 0.16 removed, and
reported that the server offers no self-service credential management. About
named the wrong generation. Files ran the pre-0.16 path, omitting `nodeType`
and listing the tree through get.

Look in all three places, on both sides. Two nearby soft spots go with it: a
transport error while probing the registry no longer downgrades a server to
the legacy path -- which would have posted the current password to an
endpoint that is not there -- and a locale request that is merely refused no
longer discards a generation the capability had already settled.

The mock advertised the capability in the session, which is why no test ever
caught this; it now advertises it where the real server does, and validates
`using` by the urn rather than by the session, as Stalwart does. Put the old
lookup back and nine tests fail.

Stalwart still publishes no version number to clients -- VERSION_PUBLIC is a
fixed "1.0.0" -- so About continues to report the generation and edition,
which are now the right ones.
This commit is contained in:
2026-08-24 22:21:26 -07:00
parent 03b5a6c388
commit 14125a0799
9 changed files with 203 additions and 28 deletions
+24
View File
@@ -17,6 +17,20 @@ function session(caps: string[]): 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"];
/**
* The session a real Stalwart 0.16 sends: `urn:stalwart:jmap` is handed out
* per-account and never appears in the session-level capabilities, so a client
* that only checks there drops every 0.16 server onto the older code path.
*/
function realStalwartSession(): JmapSession {
return {
capabilities: Object.fromEntries(OLD_SERVER.map((c) => [c, {}])),
accounts: { a1: { accountCapabilities: { "urn:ietf:params:jmap:filenode": {}, "urn:stalwart:jmap": {} } } },
primaryAccounts: { "urn:stalwart:jmap": "a1" },
state: "s",
} as unknown as JmapSession;
}
afterEach(() => {
client.session = null;
});
@@ -37,6 +51,16 @@ describe("on Stalwart 0.16 and newer", () => {
});
});
describe("on a real 0.16 session, which advertises per-account only", () => {
it("is recognised as 0.16 even though the session capabilities do not say so", () => {
client.session = realStalwartSession();
expect(client.hasCapability("urn:stalwart:jmap")).toBe(false);
expect(supportsNodeType()).toBe(true);
expect(queryOmitsDirectories()).toBe(false);
expect(directoryCreate(null, "ihasmail")).toEqual({ parentId: null, name: "ihasmail", nodeType: "directory" });
});
});
describe("on Stalwart before 0.16", () => {
it("never mentions nodeType, in creates or in requested properties", () => {
client.session = session(OLD_SERVER);
+7 -2
View File
@@ -11,7 +11,9 @@
*
* 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".
* "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.
*/
import { client } from "@/jmap/client";
import type { FileNode, Id } from "@/jmap/types";
@@ -19,7 +21,10 @@ import type { FileNode, Id } from "@/jmap/types";
const STALWART_CAP = "urn:stalwart:jmap";
export function supportsNodeType(): boolean {
return client.hasCapability(STALWART_CAP);
// 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"];