From 0845c93c4cfb22b672a5d0cd29d7d142730e9b83 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 24 Aug 2026 10:23:39 -0700 Subject: [PATCH] Teach the mock to impersonate Stalwart 0.15 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MOCK_STALWART=0.15 (or npm run mock:legacy) switches the mock to the generation before the registry. It is not a cut-down mock: it reproduces the specific ways that generation differs, and every one of them is a thing the server does not report as an error. - urn:stalwart:jmap is not a capability it knows, and naming one it cannot parse fails the whole request rather than the one call - x: methods do not exist; credentials live at POST /api/account/auth - FileNode/query masks out containers, so it returns files and never folders - FileNode has no nodeType, and rights are only mayRead/mayWrite/mayShare Both modes now also enforce the 2047-byte signature cap, and `using` is validated in both — the gap that let the Identity capability bug in #12 ship. This gives the legacy credential adapter its first automated coverage: it was the least-tested code here, checked only by hand against the live server. The new tests also pin the mock's own fidelity, so it cannot quietly drift back to being 0.16-shaped in the places that matter. --- README.md | 24 ++++ package.json | 3 +- server/package.json | 3 +- server/src/account-legacy.test.ts | 183 ++++++++++++++++++++++++++++++ server/src/mock/index.ts | 130 +++++++++++++++++++-- 5 files changed, 330 insertions(+), 13 deletions(-) create mode 100644 server/src/account-legacy.test.ts diff --git a/README.md b/README.md index fe38f0d..c4420af 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,9 @@ npm run dev # server on :8080 (tsx watch) + Vite dev server on :5173 # against the built-in mock Stalwart (demo@example.com / demo) — no real mailbox needed npm run dev:mock # mock on :8788, server on :8080, Vite on :5173 +# the same, with the mock impersonating Stalwart 0.15 instead of 0.16 +npm run dev:mock:legacy + npm run typecheck # tsc for both packages npm test # vitest (web) + node:test (server) npm run build # web/dist + server/dist @@ -108,6 +111,27 @@ npm start # serve the production build Open http://localhost:5173 in dev (or http://localhost:8080 for the production build). +### The mock, and which Stalwart it pretends to be + +`npm run mock` impersonates **0.16** by default; `MOCK_STALWART=0.15` (or +`npm run mock:legacy`) impersonates the generation before the registry. The +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 +- `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 + **never folders**, silently; `FileNode/get` has no such mask +- FileNode has no `nodeType` (a directory is a node with no file properties), + and rights are only `mayRead`/`mayWrite`/`mayShare` + +Both modes enforce the 2047-**byte** cap on identity signatures. Every one of +these cost a live debugging session against a real 0.15.5 server, because the +0.16-shaped mock could not express them; `server/src/account-legacy.test.ts` +now pins them. + ## Configuration All configuration is via environment variables (see `.env.example`): diff --git a/package.json b/package.json index 9c363e6..76d4c86 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "test": "npm run test -w web && npm run test -w server", "lint": "npm run typecheck", "mock": "npm run mock -w server", - "dev:mock": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"" + "dev:mock": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"", + "dev:mock:legacy": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock:legacy -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"" }, "devDependencies": { "concurrently": "^9.1.2", diff --git a/server/package.json b/server/package.json index a70f9d8..6e12842 100644 --- a/server/package.json +++ b/server/package.json @@ -10,7 +10,8 @@ "start": "node dist/index.js", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "tsx --test src/*.test.ts", - "mock": "tsx src/mock/index.ts" + "mock": "tsx src/mock/index.ts", + "mock:legacy": "MOCK_STALWART=0.15 tsx src/mock/index.ts" }, "dependencies": { "@hono/node-server": "^1.13.8", diff --git a/server/src/account-legacy.test.ts b/server/src/account-legacy.test.ts new file mode 100644 index 0000000..4f90dea --- /dev/null +++ b/server/src/account-legacy.test.ts @@ -0,0 +1,183 @@ +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; + +/** + * The same self-service flows, against a mock impersonating Stalwart 0.15. + * + * That generation has no registry: credentials live behind a REST endpoint, + * `urn:stalwart:jmap` is not a capability it knows, and naming one it cannot + * parse fails the whole request. Until now this adapter had no coverage at all + * — it was the least-tested code in the project, verified only by hand. + */ + +const PORT = 18799; +process.env.MOCK_PORT = String(PORT); +process.env.MOCK_STALWART = "0.15"; +process.env.MOCK_USER = "demo@example.com"; +process.env.MOCK_PASS = "demo-password"; +process.env.STALWART_URL = `http://127.0.0.1:${PORT}`; +process.env.APP_SECRET = "test-secret-for-legacy-flows"; + +const mock = await import("./mock/index.js"); +const { createApp } = await import("./app.js"); + +const app = createApp(); +let cookie = ""; +const HEADERS = { "content-type": "application/json", "x-requested-with": "ihasmail" }; + +async function call(path: string, init: RequestInit = {}): Promise<{ status: number; body: any }> { + const res = await app.request(path, { + ...init, + headers: { ...HEADERS, ...(init.headers as Record), ...(cookie ? { cookie } : {}) }, + }); + const setCookie = res.headers.get("set-cookie"); + if (setCookie) cookie = setCookie.split(";")[0]!; + const text = await res.text(); + return { status: res.status, body: text ? JSON.parse(text) : null }; +} + +const post = (path: string, body: unknown) => call(path, { method: "POST", body: JSON.stringify(body) }); + +before(async () => { + const res = await post("/api/auth/login", { username: "demo@example.com", password: "demo-password" }); + assert.equal(res.status, 200, "login should succeed against the legacy mock"); +}); + +after(() => { + (mock as { server?: { close(): void } }).server?.close(); +}); + +test("the older server is recognised, and reported as such", async () => { + const res = await call("/api/auth/session"); + assert.equal(res.status, 200); + assert.equal(res.body.ihasmail.server.generation, "pre-0.16"); + assert.equal(res.body.ihasmail.server.edition, null, "no edition is reported before 0.16"); + assert.equal(res.body.capabilities["urn:stalwart:jmap"], undefined, "the capability does not exist here"); +}); + +test("credentials fall back to the REST endpoint", async () => { + const res = await call("/api/account/security"); + assert.equal(res.status, 200); + assert.equal(res.body.backend, "legacy"); + assert.equal(res.body.otpEnabled, false); + assert.equal(res.body.appPasswordsKeyedByName, true, "this generation has only names to go on"); +}); + +test("app passwords round-trip, keyed by their name", async () => { + const created = await post("/api/account/app-passwords", { description: "Thunderbird" }); + assert.equal(created.status, 200); + assert.ok(created.body.secret, "a secret is generated for the user to copy"); + assert.equal(created.body.id, "Thunderbird", "the name is the identifier here"); + + const listed = await call("/api/account/security"); + assert.deepEqual(listed.body.appPasswords.map((a: { description: string }) => a.description), ["Thunderbird"]); + + await post("/api/account/app-passwords/revoke", { id: "Thunderbird" }); + assert.deepEqual((await call("/api/account/security")).body.appPasswords, []); +}); + +test("the current password is verified before it is changed", async () => { + // The REST endpoint would take our word for it, so ihasmail proves it first. + const wrong = await post("/api/account/password", { current: "not-my-password", next: "a-much-longer-password" }); + assert.equal(wrong.status, 403); + assert.match(wrong.body.message, /incorrect/i); + assert.equal((mock as { account: { password: string } }).account.password, "demo-password", "nothing was changed"); +}); + +test("changing the password keeps this session working", async () => { + const res = await post("/api/account/password", { current: "demo-password", next: "a-brand-new-password" }); + assert.equal(res.status, 200); + assert.equal((mock as { account: { password: string } }).account.password, "a-brand-new-password"); + assert.equal((await call("/api/auth/session")).status, 200, "the session was re-sealed"); +}); + +test("2FA is enabled with a code proved against the new secret", async () => { + const { parseOtpauthUrl, totpCode } = await import("./totp.js"); + const begin = await post("/api/account/2fa/begin", {}); + const params = parseOtpauthUrl(begin.body.url); + assert.ok(params); + + const bad = await post("/api/account/2fa/enable", { url: begin.body.url, code: "000000", current: "a-brand-new-password" }); + assert.equal(bad.status, 400); + assert.equal((mock as { account: { otpUrl: string | null } }).account.otpUrl, null, "nothing was stored"); + + const good = await post("/api/account/2fa/enable", { url: begin.body.url, code: totpCode(params), current: "a-brand-new-password" }); + assert.equal(good.status, 200); + assert.equal(good.body.sessionKept, true, "the session moved onto an app password"); + assert.equal((await call("/api/account/security")).body.otpEnabled, true); +}); + +test("2FA is switched off again", async () => { + const { parseOtpauthUrl, totpCode } = await import("./totp.js"); + const stored = (mock as { account: { otpUrl: string | null } }).account.otpUrl; + const params = parseOtpauthUrl(stored!); + assert.ok(params); + const res = await post("/api/account/2fa/disable", { current: "a-brand-new-password", code: totpCode(params) }); + assert.equal(res.status, 200); + assert.equal((await call("/api/account/security")).body.otpEnabled, false); +}); + +/** + * The mock is only worth having if it is faithful, so these pin the specific + * behaviours that cost us a live debugging session each. Every one of them was + * invisible to the 0.16 mock, which is how the bugs shipped. + */ + +const jmap = (using: string[], methodCalls: unknown[]) => post("/api/jmap", { using, methodCalls }); +const CORE = "urn:ietf:params:jmap:core"; +const MAIL = "urn:ietf:params:jmap:mail"; +const FILES = "urn:ietf:params:jmap:filenode"; + +test("naming a capability it cannot parse fails the whole request", async () => { + const res = await jmap([CORE, "urn:stalwart:jmap"], [["Mailbox/get", { accountId: "a1", ids: null }, "c0"]]); + assert.notEqual(res.status, 200, "not one failed call - the entire request"); +}); + +test("x: methods do not exist, so they come back unknownMethod", async () => { + const res = await jmap([CORE], [["x:AccountPassword/get", { accountId: "a1", ids: ["singleton"] }, "c0"]]); + assert.equal(res.status, 200); + assert.equal(res.body.methodResponses[0][0], "error"); + assert.equal(res.body.methodResponses[0][1].type, "unknownMethod"); +}); + +test("FileNode/set refuses nodeType by name", async () => { + const res = await jmap([CORE, FILES], [["FileNode/set", { accountId: "a1", create: { d: { parentId: null, name: "New", nodeType: "directory" } } }, "c0"]]); + const set = res.body.methodResponses[0][1]; + assert.equal(set.notCreated.d.type, "invalidProperties"); + assert.deepEqual(set.notCreated.d.properties, ["nodeType"]); +}); + +test("a directory is a node with no file properties, and query cannot see it", async () => { + const made = await jmap([CORE, FILES], [["FileNode/set", { accountId: "a1", create: { d: { parentId: null, name: "Reports" } } }, "c0"]]); + const id = made.body.methodResponses[0][1].created.d.id; + assert.ok(id); + + const queried = await jmap([CORE, FILES], [["FileNode/query", { accountId: "a1" }, "c0"]]); + assert.equal(queried.body.methodResponses[0][1].ids.includes(id), false, "query masks out containers"); + + // get carries no such mask, which is the only way to find a folder here. + const got = await jmap([CORE, FILES], [["FileNode/get", { accountId: "a1", ids: null }, "c0"]]); + const list = got.body.methodResponses[0][1].list as { id: string; nodeType?: string; myRights: Record }[]; + const dir = list.find((n) => n.id === id); + assert.ok(dir, "get returns the directory"); + assert.equal(dir!.nodeType, undefined, "nodeType is not a property here"); + assert.deepEqual(Object.keys(dir!.myRights).sort(), ["mayRead", "mayShare", "mayWrite"], "the coarser rights"); +}); + +test("FileNode/query refuses the filters and sorts this generation lacks", async () => { + const filtered = await jmap([CORE, FILES], [["FileNode/query", { accountId: "a1", filter: { isTopLevel: true } }, "c0"]]); + assert.equal(filtered.body.methodResponses[0][1].type, "unsupportedFilter"); + const sorted = await jmap([CORE, FILES], [["FileNode/query", { accountId: "a1", sort: [{ property: "nodeType" }] }, "c0"]]); + assert.equal(sorted.body.methodResponses[0][1].type, "unsupportedSort"); +}); + +test("an identity signature is capped in bytes, not characters", async () => { + // 1200 CJK characters: comfortably under 2047 counted as characters, and + // 3600 bytes once encoded. + const tooBig = "日".repeat(1200); + assert.ok(tooBig.length < 2047 && Buffer.byteLength(tooBig, "utf8") > 2047); + const res = await jmap([CORE, MAIL], [["Identity/set", { accountId: "a1", update: { i1: { htmlSignature: tooBig } } }, "c0"]]); + const set = res.body.methodResponses[0][1]; + assert.equal(set.notUpdated.i1.type, "invalidProperties"); + assert.deepEqual(set.notUpdated.i1.properties, ["htmlSignature"]); +}); diff --git a/server/src/mock/index.ts b/server/src/mock/index.ts index 3b11cb2..8a37405 100644 --- a/server/src/mock/index.ts +++ b/server/src/mock/index.ts @@ -8,6 +8,15 @@ import { randomUUID } from "node:crypto"; import { parseOtpauthUrl, verifyTotp } from "../totp.js"; const PORT = Number(process.env.MOCK_PORT ?? 8788); +/** + * Which Stalwart generation to impersonate. "0.16" (the default) has the + * registry — the `x:` methods, `nodeType` on FileNode, the finer-grained + * rights. "0.15" is the older shape, and differs in ways that mostly do not + * announce themselves: its FileNode/query cannot see directories at all, it + * refuses a `using` naming a capability it does not know, and self-service + * credentials live behind a REST endpoint instead. + */ +const LEGACY = process.env.MOCK_STALWART === "0.15"; const ACCOUNT = "a1"; const USER = process.env.MOCK_USER ?? "demo@example.com"; /** Locale the fake directory reports for the account (POSIX style, as Stalwart does). */ @@ -149,7 +158,12 @@ const fileNodes: Obj[] = [ { id: "f2", parentId: "f1", nodeType: "file", blobId: putBlob("hello world", "text/plain"), size: 11, name: "notes.txt", type: "text/plain", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr() }, { id: "f3", parentId: null, nodeType: "file", blobId: putBlob("%PDF-1.4 mock", "application/pdf"), size: 14, name: "report.pdf", type: "application/pdf", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr() }, ]; -function fr() { return { mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: true, mayModifyContent: true, mayShare: true }; } +function fr() { + // 0.16 split what used to be a single mayWrite into four. + return LEGACY + ? { mayRead: true, mayWrite: true, mayShare: true } + : { mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: true, mayModifyContent: true, mayShare: true }; +} function recount() { for (const m of mailboxes) { @@ -230,6 +244,16 @@ function applyPatch(obj: Obj, patch: Obj) { /* ---------- method handlers ---------- */ type Handler = (args: Obj) => Obj | [string, Obj][]; +/** A method-level failure, surfaced as ["error", {type, description}, id]. */ +class MethodError extends Error { + constructor( + public readonly type: string, + description?: string, + ) { + super(description ?? type); + } +} + const setResp = (extra: Obj = {}): Obj => ({ accountId: ACCOUNT, oldState: "1", newState: nextState(), created: {}, updated: {}, destroyed: [], ...extra }); function genericGet(list: Obj[]) { @@ -377,7 +401,20 @@ const handlers: Record = { return setResp({ created, destroyed }); }, "Identity/get": genericGet(identities), - "Identity/set": genericSet(identities, "i", (o) => Object.assign(o, { replyTo: null, bcc: null, textSignature: "", htmlSignature: "", mayDelete: true, ...o })), + "Identity/set": (a) => { + // Stalwart's cap is `value.len() < 2048` on a Rust string: 2047 bytes of + // UTF-8, not characters. Anything longer is refused by name. + for (const [where, entries] of [["notCreated", (a.create as Obj) ?? {}], ["notUpdated", (a.update as Obj) ?? {}]] as const) { + for (const [key, obj] of Object.entries(entries)) { + const over = ["htmlSignature", "textSignature"].find((prop) => { + const v = (obj as Obj)[prop]; + return typeof v === "string" && Buffer.byteLength(v, "utf8") > 2047; + }); + if (over) return setResp({ [where]: { [key]: { type: "invalidProperties", properties: [over], description: "Invalid property." } } }); + } + } + return genericSet(identities, "i", (o) => Object.assign(o, { replyTo: null, bcc: null, textSignature: "", htmlSignature: "", mayDelete: true, ...o }))(a); + }, "EmailSubmission/set": (a) => { const created: Obj = {}; for (const [cid, sub] of Object.entries((a.create as Obj) ?? {})) { @@ -413,9 +450,41 @@ const handlers: Record = { "ContactCard/get": genericGet(cards), "ContactCard/set": genericSet(cards, "cc"), "ContactCard/parse": (a) => { const parsed: Obj = {}; for (const b of a.blobIds as string[]) { const t = blobs.get(b)?.data.toString() ?? ""; const fn = /^FN:(.*)$/m.exec(t)?.[1]?.trim() ?? "Imported"; const em = /^EMAIL[^:]*:(.*)$/m.exec(t)?.[1]?.trim(); parsed[b] = [{ "@type": "Card", version: "1.0", uid: randomUUID(), kind: "individual", name: { full: fn }, emails: em ? { e1: { address: em } } : undefined }]; } return { accountId: ACCOUNT, parsed, notParsable: [] }; }, - "FileNode/query": (a) => { const f = (a.filter as Obj) ?? {}; const list = fileNodes.filter((n) => (f.isTopLevel ? n.parentId == null : f.parentId ? n.parentId === f.parentId : true)); return { accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.map((n) => n.id), total: list.length }; }, - "FileNode/get": genericGet(fileNodes), - "FileNode/set": genericSet(fileNodes, "f", (o) => Object.assign(o, { created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), size: o.blobId ? (blobs.get(o.blobId as string)?.data.length ?? 0) : null, type: o.type ?? null, blobId: o.blobId ?? null, ...o })), + "FileNode/query": (a) => { + const f = (a.filter as Obj) ?? {}; + if (LEGACY) { + // Sorting is refused outright, and isTopLevel / nodeType are not filters + // this generation knows. + if (a.sort) throw new MethodError("unsupportedSort", "Sorting is not supported on FileNode"); + if ("isTopLevel" in f || "nodeType" in f) throw new MethodError("unsupportedFilter", "Unsupported filter"); + } + let list = fileNodes.filter((n) => (f.isTopLevel ? n.parentId == null : f.parentId ? n.parentId === f.parentId : true)); + // The pre-0.16 query masks its results to non-containers, so a directory + // never comes back — with nothing to say it was left out. + if (LEGACY) list = list.filter((n) => n.nodeType !== "directory"); + return { accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.map((n) => n.id), total: list.length }; + }, + "FileNode/get": (a) => { + const res = genericGet(fileNodes)(a); + // nodeType does not exist before 0.16; the shape is all the client gets. + if (LEGACY) res.list = (res.list as Obj[]).map((n) => { const { nodeType: _drop, ...rest } = n; return rest; }); + return res; + }, + "FileNode/set": (a) => { + if (LEGACY) { + for (const obj of [...Object.values((a.create as Obj) ?? {}), ...Object.values((a.update as Obj) ?? {})]) { + if (obj && typeof obj === "object" && "nodeType" in (obj as Obj)) { + return setResp({ notCreated: Object.fromEntries(Object.keys((a.create as Obj) ?? {}).map((k) => [k, { type: "invalidProperties", properties: ["nodeType"], description: "Invalid property." }])), notUpdated: Object.fromEntries(Object.keys((a.update as Obj) ?? {}).map((k) => [k, { type: "invalidProperties", properties: ["nodeType"], description: "Invalid property." }])) }); + } + } + } + return genericSet(fileNodes, "f", (o) => { + Object.assign(o, { created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), size: o.blobId ? (blobs.get(o.blobId as string)?.data.length ?? 0) : null, type: o.type ?? null, blobId: o.blobId ?? null, ...o }); + // Without nodeType, a node is a directory precisely when it carries no + // file properties. Keep it internally so query and get stay consistent. + if (!o.nodeType) o.nodeType = o.blobId || o.size != null || o.type ? "file" : "directory"; + })(a); + }, }; /* ---------- http ---------- */ @@ -451,9 +520,9 @@ function readBody(req: IncomingMessage): Promise { } const session = () => ({ - capabilities: { "urn:ietf:params:jmap:core": { maxSizeUpload: 50000000, maxConcurrentUpload: 4, maxSizeRequest: 10000000, maxConcurrentRequests: 4, maxCallsInRequest: 16, maxObjectsInGet: 500, maxObjectsInSet: 500, 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": {}, "urn:stalwart:jmap": {} }, + capabilities: { "urn:ietf:params:jmap:core": { maxSizeUpload: 50000000, maxConcurrentUpload: 4, maxSizeRequest: 10000000, maxConcurrentRequests: 4, maxCallsInRequest: 16, maxObjectsInGet: 500, maxObjectsInSet: 500, 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": {} } } }, - primaryAccounts: { ...Object.fromEntries(["mail", "submission", "vacationresponse", "sieve", "calendars", "contacts", "principals", "quota", "filenode", "blob"].map((c) => [`urn:ietf:params:jmap:${c}`, ACCOUNT])), "urn:stalwart:jmap": ACCOUNT }, + 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/`, downloadUrl: `http://127.0.0.1:${PORT}/jmap/download/{accountId}/{blobId}/{name}?accept={type}`, @@ -476,25 +545,63 @@ export const server = createServer(async (req, res) => { res.writeHead(200, { "content-type": "application/json" }); return res.end(JSON.stringify(session())); } + // Before 0.16, self-service credentials are a REST endpoint rather than + // registry objects: GET reports the state, POST takes a list of actions. + if (LEGACY && url.pathname === "/api/account/auth") { + if (req.method === "GET") { + res.writeHead(200, { "content-type": "application/json" }); + return res.end(JSON.stringify({ data: { otpEnabled: Boolean(account.otpUrl), appPasswords: account.appPasswords.map((a) => a.description) } })); + } + if (req.method === "POST") { + const actions = JSON.parse((await readBody(req)).toString()) as { type: string; password?: string; url?: string | null; name?: string }[]; + // Password and OTP changes are only accepted over Basic auth. + if (actions.some((a) => ["setPassword", "enableOtpAuth", "disableOtpAuth"].includes(a.type)) && !(req.headers.authorization ?? "").startsWith("Basic ")) { + res.writeHead(400, { "content-type": "application/json" }); + return res.end(JSON.stringify({ error: "unauthorized", details: "Password changes only allowed using Basic auth" })); + } + for (const a of actions) { + if (a.type === "setPassword") account.password = a.password ?? account.password; + else if (a.type === "enableOtpAuth") account.otpUrl = a.url ?? null; + else if (a.type === "disableOtpAuth") account.otpUrl = null; + else if (a.type === "addAppPassword") account.appPasswords.push({ id: `ap${randomUUID().slice(0, 6)}`, description: a.name ?? "App password", secret: a.password ?? "", createdAt: new Date().toISOString(), expiresAt: null }); + else if (a.type === "removeAppPassword") { + const i = account.appPasswords.findIndex((p) => p.description === a.name); + if (i >= 0) account.appPasswords.splice(i, 1); + } + } + res.writeHead(200, { "content-type": "application/json" }); + return res.end(JSON.stringify({ data: null })); + } + } + // 0.16's account info endpoint; the only place a server reports its edition. - if (url.pathname === "/api/account" && req.method === "GET") { + if (!LEGACY && url.pathname === "/api/account" && req.method === "GET") { res.writeHead(200, { "content-type": "application/json" }); return res.end(JSON.stringify({ permissions: ["jmapEmailGet", "sysAccountSettingsGet"], edition: "oss", locale: MOCK_LOCALE })); } if (url.pathname === "/jmap/" && req.method === "POST") { - const body = JSON.parse((await readBody(req)).toString()) as { methodCalls: [string, Obj, string][] }; + 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)); + 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)}` })); + } const responses: [string, Obj, string][] = []; const touched = new Set(); for (const [name, rawArgs, id] of body.methodCalls) { const h = handlers[name]; - if (!h) { responses.push(["error", { type: "unknownMethod" }, id]); continue; } + // The registry, and every x: method with it, arrived in 0.16. + if (!h || (LEGACY && name.startsWith("x:"))) { responses.push(["error", { type: "unknownMethod" }, id]); continue; } try { const args = resolveRefs(rawArgs, responses); const r = h(args); responses.push([name, r as Obj, id]); if (name.endsWith("/set") || name.endsWith("/import")) touched.add(name.split("/")[0]!); } catch (err) { - responses.push(["error", { type: "serverFail", description: String(err) }, id]); + if (err instanceof MethodError) responses.push(["error", { type: err.type, description: err.message }, id]); + else responses.push(["error", { type: "serverFail", description: String(err) }, id]); } } if (touched.size) { nextState(); setTimeout(() => broadcast([...touched, ...(touched.has("Email") ? ["Mailbox", "Thread"] : [])]), 50); } @@ -528,6 +635,7 @@ export const server = createServer(async (req, res) => { res.end(JSON.stringify({ error: "not found" })); }).listen(PORT, "127.0.0.1", () => { console.log(`[mock-stalwart] listening on http://127.0.0.1:${PORT} (login: ${USER} / ${PASS})`); + console.log(`[mock-stalwart] impersonating Stalwart ${LEGACY ? "0.15 (pre-registry)" : "0.16+"}`); console.log(`[mock-stalwart] run the app with: STALWART_URL=http://127.0.0.1:${PORT} npm run dev`); });