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
+19 -11
View File
@@ -137,7 +137,12 @@ older mode is not a smaller mock — it reproduces the specific ways that
generation differs, none of which the server reports as an error:
- `urn:stalwart:jmap` is not a capability it knows, and naming one it cannot
parse fails the **whole request**, not the one call that wanted it
parse fails the **whole request**, not the one call that wanted it. On 0.16
it *is* known — but advertised per-account, in `primaryAccounts` and each
account's `accountCapabilities`, never in the session-level `capabilities`.
Stalwart validates `using` by parsing the urn rather than looking it up in
the session, so naming it works regardless; a client that tests for it in
the obvious place, though, mistakes every 0.16 server for an older one
- `x:` methods do not exist, so the registry — credentials, account settings —
is unreachable, and self-service credentials live at `POST /api/account/auth`
- `FileNode/query` masks its results to non-containers, so it returns files and
@@ -179,22 +184,25 @@ so both generations have been exercised against a real server. Everything
below says which.
Verified against a live **0.15.5**: the mail flows, self-service credentials
over the REST path, Files, and signatures. Verified against a live
**0.16.19** (2026-08-25): self-service credentials over the registry path —
app passwords created and revoked, password changed, 2FA switched on and off
with the browser session surviving — and Files end to end: folder creation,
upload, rename, move and delete, with folders sorting and rendering as
folders. Also on 0.16: server-generation detection, and the account locale,
which is unreadable on 0.15.
over the REST path, Files, and signatures.
Both backends have now met a real server of their own generation.
The 0.16 registry path was previously recorded here as verified live. That
was wrong, and the entry below says why: ihasmail looked for
`urn:stalwart:jmap` in the session-level capabilities, where Stalwart has
never put it, so **every** real 0.16 server was taken for a pre-0.16 one.
Self-service credentials went to a REST endpoint 0.16 had removed, About
reported the wrong generation, and Files ran on the older code path. The mock
advertised the capability in the wrong place too, which is why nothing caught
it. Fixed, and the mock now advertises it where the real server does — but
the registry path is **awaiting live re-verification**.
- **Where 0.16 advertises `urn:stalwart:jmap`** — not where a JMAP client would look. Stalwart builds the session-level `capabilities` from a fixed list (`Session::new`, plus WebSocket) that has never contained this capability, in any 0.16.x from 0.16.0 to 0.16.19. It hands it out per-account instead, so it appears in `primaryAccounts` and in each account's `accountCapabilities`. ihasmail tested for it in `capabilities` alone, which made every real 0.16 server read as pre-0.16 — and that one check drove three things: self-service credentials fell back to `POST /api/account/auth`, which 0.16 removed, so password changes, 2FA and app passwords all failed with "this mail server does not offer self-service credential management"; About reported the wrong generation; and Files took the pre-0.16 code path. It now looks in all three places. Two related soft spots went with it: a transport error while probing the registry no longer downgrades a server to the legacy REST 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.
- **HTML signatures** — Stalwart caps a signature at 2047 **bytes** (`value.len() < 2048` on a Rust string, so UTF-8 bytes, not characters). ihasmail compacts pasted HTML, moves images to Files and, if still too large, keeps the full signature in Files behind a short marker; other clients see a text fallback. Confirmed live on 0.15.5 (2026-08-24): oversized, non-ASCII and inline-image signatures all save, and a test message arrived intact at Gmail with the logo inline.
- **Files on Stalwart before 0.16** — three things differ there, none of which the server reports as an error. (Confirmed live on 0.15.5 before the upgrade. The live instance now runs 0.16.19, where folder creation, upload, rename, move and delete are also confirmed; the older path is kept for anyone still on 0.15.x and covered by `npm run dev:mock:legacy`.) `FileNode/query` masks its results to non-containers, so it returns files and **never folders**; `nodeType` does not exist, and sending it fails the create outright (a directory is instead a node with no file properties at all); and rights are only `mayRead`/`mayWrite`/`mayShare`, so the finer-grained `mayDelete`/`mayRename` the UI gates on are absent. ihasmail detects the older server by the absence of `urn:stalwart:jmap`, lists the tree through `FileNode/get` instead of query, shapes creates accordingly, and widens the old rights. Upload, folder creation, listing, rename, move and delete are all confirmed live on 0.15.5 (2026-08-24).
- **Files on Stalwart before 0.16** — three things differ there, none of which the server reports as an error. (Confirmed live on 0.15.5 before the upgrade. The live instance now runs 0.16.19, where folder creation, upload, rename, move and delete are also confirmed; the older path is kept for anyone still on 0.15.x and covered by `npm run dev:mock:legacy`.) `FileNode/query` masks its results to non-containers, so it returns files and **never folders**; `nodeType` does not exist, and sending it fails the create outright (a directory is instead a node with no file properties at all); and rights are only `mayRead`/`mayWrite`/`mayShare`, so the finer-grained `mayDelete`/`mayRename` the UI gates on are absent. ihasmail detects the older server by the absence of `urn:stalwart:jmap` — looked for in `primaryAccounts` and `accountCapabilities` as well as the session capabilities, since that is where 0.16 actually advertises it — lists the tree through `FileNode/get` instead of query, shapes creates accordingly, and widens the old rights. Upload, folder creation, listing, rename, move and delete are all confirmed live on 0.15.5 (2026-08-24).
- **Self-service credentials** — the **0.15.x REST path was confirmed live** against Stalwart 0.15.5 (2026-08-24): password change, app passwords, and enabling and disabling 2FA, on a real mailbox. The **0.16 registry path is confirmed live** against Stalwart 0.16.19 (2026-08-25): app passwords created and revoked, password changed, 2FA enabled and disabled, with the browser session surviving the switch to an app password. The mock enforces the same rules either way (current password required, password policy, a TOTP code on every request once 2FA is on, app passwords exempt from it). Password changes are refused by Stalwart for accounts backed by an external directory (LDAP/SQL/OIDC); the server's own message is shown when that happens.
- Recurring events: colour/category/edit/delete apply to the whole series (per-occurrence overrides aren't supported by the server yet).
- Editable date boxes are always Gregorian and in Latin digits, even for locales whose *display* uses another calendar or numbering system (`fa-IR`, `th-TH`, `ar-EG`) — they keep the locale's field order and separator, but a Buddhist-era year in a text box does not round-trip against the Gregorian calendar grid. Non-Gregorian calendar support is not implemented.
- The account locale is read from `x:AccountSettings/get`, whose permission the built-in user role has, falling back to `x:Account/get` (which needs the admin-only `sysAccountGet`). Both are Stalwart 0.16 methods: **on older servers neither is reachable** — they do not implement the registry and reject a request that so much as names the `urn:stalwart:jmap` capability — so there the locale still falls back to the browser's and can be chosen by hand. Confirmed working on the live 0.16.19 instance, where the account locale now resolves for an ordinary user.
- The account locale is read from `x:AccountSettings/get`, whose permission the built-in user role has, falling back to `x:Account/get` (which needs the admin-only `sysAccountGet`). Both are Stalwart 0.16 methods: **on older servers neither is reachable** — they do not implement the registry and reject a request that so much as names the `urn:stalwart:jmap` capability — so there the locale still falls back to the browser's and can be chosen by hand. Awaiting live re-verification, for the capability-placement reason above; a locale request that is merely refused no longer downgrades the detected generation.
## Roadmap / not yet
+15
View File
@@ -46,6 +46,21 @@ after(() => {
(mock as { server?: { close(): void } }).server?.close();
});
/**
* What the About page reads. Stalwart advertises `urn:stalwart:jmap` only
* per-account, so a session that looks for it at the top level reports a real
* 0.16 server as older than 0.16 — the same mistake that sent credentials to
* the removed REST endpoint.
*/
test("the session reports the 0.16 generation the server actually is", async () => {
const res = await call("/api/auth/session");
assert.equal(res.status, 200);
assert.equal(res.body.ihasmail.server.generation, "0.16+");
assert.equal(res.body.ihasmail.server.edition, "oss");
assert.equal(res.body.capabilities["urn:stalwart:jmap"], undefined, "not where a client would first look");
assert.ok("urn:stalwart:jmap" in res.body.primaryAccounts, "but here, as on a real server");
});
test("the 0.16 registry backend is detected and reported empty", async () => {
const res = await call("/api/account/security");
assert.equal(res.status, 200);
+9 -5
View File
@@ -1,5 +1,5 @@
import { config } from "./config.js";
import { absoluteUpstream, UpstreamError, type UpstreamSession } from "./upstream.js";
import { absoluteUpstream, hasStalwartRegistry, UpstreamError, type UpstreamSession } from "./upstream.js";
import { generateSecret, otpauthUrl, parseOtpauthUrl, verifyTotp } from "./totp.js";
import { randomBytes } from "node:crypto";
@@ -82,7 +82,7 @@ export async function detectBackend(sessionId: string, ctx: Ctx): Promise<Backen
async function probeBackend(ctx: Ctx): Promise<Backend> {
// A server with the registry answers x:AccountPassword/get; one without it
// fails to parse the method name at all and returns unknownMethod.
if (ctx.session.capabilities && STALWART_CAP in ctx.session.capabilities) {
if (hasStalwartRegistry(ctx.session)) {
try {
const res = await jmap(ctx, [["x:AccountPassword/get", { accountId: accountId(ctx), ids: [SINGLETON] }, "p"]]);
const [name, args] = res.methodResponses?.[0] ?? [];
@@ -90,10 +90,14 @@ async function probeBackend(ctx: Ctx): Promise<Backend> {
const type = (args as { type?: string } | undefined)?.type;
if (type && type !== "unknownMethod") return "registry"; // present, but refused us
} catch {
// Not an answer we can read - most likely a server too old to know the
// capability we named, which rejects the whole request rather than the
// one call. Fall through and try the endpoint such servers do have.
// The capability already told us this server has the registry, so a
// request we could not read is a fault to surface, not evidence of an
// older server. Falling back here would post the user's password to a
// REST endpoint 0.16 removed and report the feature as unsupported.
return "registry";
}
// It named the capability and then disowned the method: nothing else to try.
return "registry";
}
return "legacy";
}
+65 -1
View File
@@ -1,6 +1,6 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { getAccountInfo, interpretAccountInfo } from "./upstream.js";
import { getAccountInfo, hasStalwartRegistry, interpretAccountInfo } from "./upstream.js";
/**
* The account locale used to be read only from `x:Account/get`, which needs
@@ -65,3 +65,67 @@ test("no capabilities at all leaves the generation unknown", async () => {
const info = await getAccountInfo("session-no-caps", "Basic x", { accounts: {}, primaryAccounts: {} } as never);
assert.equal(info.generation, null);
});
/**
* Where Stalwart actually advertises `urn:stalwart:jmap`.
*
* Not in the session-level `capabilities`: `Session::new` builds those from a
* fixed list that has never carried this capability, in any 0.16.x. It is
* handed out per-account instead, so it lands in `primaryAccounts` and in each
* account's `accountCapabilities`. Looking only at the session level called
* every real 0.16 server pre-0.16, which sent self-service credentials to a
* REST endpoint 0.16 had removed and made the About page report the wrong
* generation.
*/
const STALWART = "urn:stalwart:jmap";
const baseCaps = { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} };
test("a 0.16 server is recognised from primaryAccounts, where it advertises itself", () => {
assert.equal(
hasStalwartRegistry({ capabilities: baseCaps, accounts: {}, primaryAccounts: { [STALWART]: "a1" } }),
true,
);
});
test("a 0.16 server is recognised from an account's capabilities", () => {
assert.equal(
hasStalwartRegistry({
capabilities: baseCaps,
accounts: { a1: { accountCapabilities: { "urn:ietf:params:jmap:mail": {}, [STALWART]: {} } } },
primaryAccounts: {},
}),
true,
);
});
test("the session level still counts, for a server that ever advertises it there", () => {
assert.equal(hasStalwartRegistry({ capabilities: { ...baseCaps, [STALWART]: {} }, accounts: {}, primaryAccounts: {} }), true);
});
test("a server that advertises it nowhere is pre-0.16", () => {
assert.equal(hasStalwartRegistry({ capabilities: baseCaps, accounts: { a1: { accountCapabilities: baseCaps } }, primaryAccounts: { "urn:ietf:params:jmap:mail": "a1" } }), false);
assert.equal(hasStalwartRegistry(undefined), false);
});
test("a shared account carrying the capability is enough to recognise the server", () => {
assert.equal(
hasStalwartRegistry({
capabilities: baseCaps,
accounts: { a1: { accountCapabilities: baseCaps }, a2: { accountCapabilities: { [STALWART]: {} } } },
primaryAccounts: {},
}),
true,
);
});
test("a locale request that fails does not talk us out of a generation we proved", () => {
// The capability settled it. A forbidden reply costs the locale, nothing more.
const info = interpretAccountInfo([failed("s", "forbidden"), failed("a", "forbidden")], "0.16+");
assert.equal(info.generation, "0.16+");
assert.equal(info.locale, null);
});
test("a server that disowns the method is still older, whatever we came in believing", () => {
const info = interpretAccountInfo([failed("s", "unknownMethod")], "0.16+");
assert.equal(info.generation, "pre-0.16");
});
+8 -3
View File
@@ -544,8 +544,8 @@ function readBody(req: IncomingMessage): Promise<Buffer> {
}
const session = () => ({
capabilities: { "urn:ietf:params:jmap:core": { maxSizeUpload: 50000000, maxConcurrentUpload: 4, maxSizeRequest: 10000000, maxConcurrentRequests: 4, maxCallsInRequest: 16, maxObjectsInGet: MAX_OBJECTS, maxObjectsInSet: MAX_OBJECTS, collationAlgorithms: ["i;ascii-casemap"] }, "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": { implementation: "mock" }, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:calendars:parse": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:contacts:parse": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:principals:availability": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:blob": {}, "urn:ietf:params:jmap:filenode": {}, ...(LEGACY ? {} : { "urn:stalwart:jmap": {} }) },
accounts: { [ACCOUNT]: { name: USER, isPersonal: true, isReadOnly: false, accountCapabilities: { "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {} } } },
capabilities: { "urn:ietf:params:jmap:core": { maxSizeUpload: 50000000, maxConcurrentUpload: 4, maxSizeRequest: 10000000, maxConcurrentRequests: 4, maxCallsInRequest: 16, maxObjectsInGet: MAX_OBJECTS, maxObjectsInSet: MAX_OBJECTS, collationAlgorithms: ["i;ascii-casemap"] }, "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": { implementation: "mock" }, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:calendars:parse": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:contacts:parse": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:principals:availability": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:blob": {}, "urn:ietf:params:jmap:filenode": {} },
accounts: { [ACCOUNT]: { name: USER, isPersonal: true, isReadOnly: false, accountCapabilities: { "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {}, ...(LEGACY ? {} : { "urn:stalwart:jmap": {} }) } } },
primaryAccounts: { ...Object.fromEntries(["mail", "submission", "vacationresponse", "sieve", "calendars", "contacts", "principals", "quota", "filenode", "blob"].map((c) => [`urn:ietf:params:jmap:${c}`, ACCOUNT])), ...(LEGACY ? {} : { "urn:stalwart:jmap": ACCOUNT }) },
username: USER,
apiUrl: `http://127.0.0.1:${PORT}/jmap/`,
@@ -607,7 +607,12 @@ export const server = createServer(async (req, res) => {
const body = JSON.parse((await readBody(req)).toString()) as { methodCalls: [string, Obj, string][]; using?: string[] };
// A capability the server cannot parse fails the whole request, not the one
// call that wanted it - which is why an over-eager `using` is so damaging.
const unknown = (body.using ?? []).find((u) => !(u in session().capabilities));
// Stalwart decides this by parsing the urn, not by looking it up in the
// session, so a capability it hands out per-account is still usable here:
// `urn:stalwart:jmap` never appears in the session-level capabilities and
// the registry calls that name it work all the same.
const known = new Set([...Object.keys(session().capabilities), ...Object.keys(session().accounts[ACCOUNT]?.accountCapabilities ?? {})]);
const unknown = (body.using ?? []).find((u) => !known.has(u));
if (unknown) {
res.writeHead(400, { "content-type": "application/json" });
return res.end(JSON.stringify({ type: "urn:ietf:params:jmap:error:unknownCapability", status: 400, detail: `Unknown capability: ${JSON.stringify(unknown)}` }));
+39 -5
View File
@@ -69,6 +69,30 @@ export function forgetUpstreamSession(sessionId: string): void {
const STALWART_CAP = "urn:stalwart:jmap";
const JMAP_CORE = "urn:ietf:params:jmap:core";
/**
* Whether this server has Stalwart's JMAP registry — the `x:` objects that
* carry credentials, account settings and the newer FileNode shape.
*
* `urn:stalwart:jmap` is the marker, but **not** in the session-level
* `capabilities`, which is where a JMAP client would naturally look. Stalwart
* builds that list from a fixed set that has never included this capability;
* it hands it out per-account instead, so it turns up in `primaryAccounts` and
* in each account's `accountCapabilities`. Checking only the session level
* therefore reports every real 0.16 server as pre-0.16 — which routed
* self-service credentials to a REST endpoint 0.16 had removed, and told the
* About page the wrong thing. The session level is still checked last, in case
* a later release advertises it there as well.
*/
export function hasStalwartRegistry(session: Pick<UpstreamSession, "capabilities" | "accounts" | "primaryAccounts"> | undefined): boolean {
if (!session) return false;
if (session.primaryAccounts && STALWART_CAP in session.primaryAccounts) return true;
for (const account of Object.values(session.accounts ?? {})) {
const caps = (account as { accountCapabilities?: Record<string, unknown> } | null)?.accountCapabilities;
if (caps && STALWART_CAP in caps) return true;
}
return Boolean(session.capabilities && STALWART_CAP in session.capabilities);
}
export interface AccountInfo {
/** BCP-47 tag configured for the account, or null if unreadable. */
locale: string | null;
@@ -87,6 +111,7 @@ const INFO_CACHE_MS = 30 * 60_000;
const EMPTY_INFO: AccountInfo = { locale: null, generation: null, edition: null };
/** A server that has never heard of the registry: nothing to read, but dated. */
const PRE_REGISTRY_INFO: AccountInfo = { locale: null, generation: "pre-0.16", edition: null };
const REGISTRY_INFO: AccountInfo = { locale: null, generation: "0.16+", edition: null };
/**
* glibc modifiers that name a script rather than a dialect or a currency:
@@ -144,8 +169,9 @@ async function fetchAccountInfo(authorization: string, session: UpstreamSession)
// at all, so its absence already answers the question — and asking anyway
// would fail the whole request, since those servers reject a `using` naming
// a capability they cannot parse.
// A session with no capabilities at all is not one we can read anything from.
if (!session.capabilities) return EMPTY_INFO;
if (!(STALWART_CAP in session.capabilities)) return PRE_REGISTRY_INFO;
if (!hasStalwartRegistry(session)) return PRE_REGISTRY_INFO;
const accountId =
session.primaryAccounts?.[STALWART_CAP] ??
session.primaryAccounts?.["urn:ietf:params:jmap:mail"] ??
@@ -163,9 +189,12 @@ async function fetchAccountInfo(authorization: string, session: UpstreamSession)
}),
signal: AbortSignal.timeout(config.upstreamTimeout),
});
if (!res.ok) return EMPTY_INFO;
// The registry capability already settled the generation. A locale request
// that fails — a permission we lack, a hiccup upstream — can only cost us the
// locale; it must not talk us out of what we know.
if (!res.ok) return REGISTRY_INFO;
const body = (await res.json()) as { methodResponses?: [string, Record<string, unknown>, string][] };
return interpretAccountInfo(body.methodResponses ?? []);
return interpretAccountInfo(body.methodResponses ?? [], "0.16+");
}
/**
@@ -173,16 +202,21 @@ async function fetchAccountInfo(authorization: string, session: UpstreamSession)
* back to `x:Account` for servers (or permissions) where only that one works,
* and note which generation answered.
*/
export function interpretAccountInfo(responses: [string, Record<string, unknown>, string][]): AccountInfo {
export function interpretAccountInfo(
responses: [string, Record<string, unknown>, string][],
known: AccountInfo["generation"] = null,
): AccountInfo {
const settings = responses.find((r) => r[2] === "s");
const account = responses.find((r) => r[2] === "a");
// Only 0.16+ knows the method at all; older builds cannot even parse the name.
// `known` is what the session capability already proved, and outranks a reply
// that merely refused us.
const generation: AccountInfo["generation"] =
settings && settings[0] !== "error"
? "0.16+"
: (settings?.[1] as { type?: string } | undefined)?.type === "unknownMethod"
? "pre-0.16"
: null;
: known;
return { locale: localeOf(settings) ?? localeOf(account), generation, edition: null };
}
+17 -1
View File
@@ -123,6 +123,20 @@ export class JmapClient {
return Boolean(acc && cap in acc.accountCapabilities);
}
/**
* Whether the server carries a capability at all, wherever it chose to
* advertise it.
*
* Stalwart hands `urn:stalwart:jmap` out per-account rather than putting it
* in the session-level `capabilities`, so `hasCapability` alone reports every
* real 0.16 server as though it were older. Look in all three places.
*/
hasCapabilityAnywhere(cap: string): boolean {
if (this.hasCapability(cap)) return true;
if (this.session?.primaryAccounts && cap in this.session.primaryAccounts) return true;
return Object.values(this.session?.accounts ?? {}).some((a) => cap in (a.accountCapabilities ?? {}));
}
primaryAccount(cap: string): Id | null {
return this.session?.primaryAccounts[cap] ?? null;
}
@@ -210,7 +224,9 @@ export class JmapClient {
*/
private supportedUsing(using: string[]): string[] {
if (!this.session?.capabilities) return using;
return using.filter((u) => u === CAP.core || this.hasCapability(u));
// Anywhere counts: a capability advertised per-account is one the server
// has, and Stalwart advertises its own that way and no other.
return using.filter((u) => u === CAP.core || this.hasCapabilityAnywhere(u));
}
/** Low-level request: send invocations verbatim, return raw response. */
+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"];