From 590edd50a14990b78037c4288bf46543506bfec1 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Fri, 30 Oct 2026 13:07:54 -0700 Subject: [PATCH] Stalwart 1.0: load calendars, and keep settings and Files working Two changes in Stalwart 1.0 broke ihasmail for its readers (#66). A CalendarEvent/get that names recurrenceOverrides together with utcStart or utcEnd now fails whole, even with no ids, so no calendar would load. The everyday property list no longer asks for the overrides; an occurrence's are null anyway. The export, which needs them, asks with SERIES_PROPS, without the times. FileNode renamed nodeType to @type ("Directory", "File") and type to mediaType, and each version refuses the other's names on create and update. ihasmail could make no folder on 1.0, including its own, so the settings file was never written and settings reset at every sign-in. The client now asks once per session which shape the server wants (a query sorted by type, which 1.0 refuses as unsupportedSort), asking the reader's own account, and translates FileNode calls in one place, jmap/fileNodeShape.ts, so the rest of the app keeps the shape it was written for. A filter by nodeType, which 1.0 answers with nothing, is dropped there; the Files tree already sorts folders from files itself. The compatibility test missed both because it never made these calls. It now reads events with both property lists, expands a weekly series, and writes, finds, updates and reads back the settings file through the client's own translation. Run against real 0.16.27 and 1.0.0 servers, and the app was checked by hand on 1.0: calendars load, and a setting survives signing out and back in. Fixes #66 --- KNOWN-ISSUES.md | 6 + web/src/jmap/__tests__/fileNodeShape.test.ts | 136 ++++++++++++++++++ .../jmap/__tests__/stalwart-compat.test.ts | 114 ++++++++++++++- web/src/jmap/client.ts | 39 ++++- web/src/jmap/fileNodeShape.ts | 106 ++++++++++++++ .../__tests__/shared-accounts-batched.test.ts | 9 +- web/src/store/calendar.ts | 22 ++- 7 files changed, 425 insertions(+), 7 deletions(-) create mode 100644 web/src/jmap/__tests__/fileNodeShape.test.ts create mode 100644 web/src/jmap/fileNodeShape.ts diff --git a/KNOWN-ISSUES.md b/KNOWN-ISSUES.md index f0177ec..92e4d07 100644 --- a/KNOWN-ISSUES.md +++ b/KNOWN-ISSUES.md @@ -48,6 +48,12 @@ works the same way — and dropped where 0.15 was the whole subject. Support for 0.15 was removed on 2026-08-26; the last release that runs on it is tagged [`stalwart-0.15-support`](https://git.coffeylabs.org/coffey-labs/ihasmail/releases/tag/stalwart-0.15-support). +- **Stalwart 1.0 renamed FileNode's `nodeType` and `type`, and refuses `recurrenceOverrides` beside `utcStart`.** Both **confirmed against real 0.16.27 and 1.0.0 servers (2026-10-30)**, from containers of the images CI pins, and both reached a reader as [#66](https://git.coffeylabs.org/coffey-labs/ihasmail/issues/66): calendars would not load, and settings reset at every sign-in. + - **Calendar.** A `CalendarEvent/get` naming `recurrenceOverrides` together with `utcStart` or `utcEnd` fails whole, *"recurrenceOverrides cannot be requested together with utcStart or utcEnd"*, even with `ids: []`. 0.16.27 answers it. The everyday property list no longer asks for the overrides (an occurrence's are null anyway), and the export, which needs them, asks without the times. + - **Files.** 1.0 wants `"@type": "Directory"` or `"File"` where 0.16 had `nodeType`, and `mediaType` where it had `type`. Each server refuses the other's names on create and update and ignores them on get, so ihasmail could make no folder on 1.0: not even its own, which is where the settings file lives. 1.0 also accepts `filter: { nodeType }` and answers it with no ids. The client asks which shape a server wants once per session (a query sorted by `type`, which 1.0 refuses as `unsupportedSort`) and translates at one place, `jmap/fileNodeShape.ts`. + - **Not yet handled:** 1.0 returns `myRights` and `shareWith` for folders only. A file read from 1.0 carries no rights, so rename, delete and editing are offered as unavailable on files there. + - **Recurrence, for the record.** Both versions take the singular `recurrenceRule` the editor writes. 0.16.27 refuses the RFC 8984 list `recurrenceRules`; 1.0 accepts it and drops it, leaving one occurrence. + - **`ContactCard/changes` works, and a download honors one byte range but does not say so.** Both **confirmed live (0.16.22, 2026-09-16)**, with objects on a throwaway account that were removed afterwards. `ContactCard/changes` reports a create, an update and a destroy exactly, nets a card created and destroyed since the given state out to nothing, and answers a state it does not recognize with `invalidArguments` rather than `cannotCalculateChanges`; the contacts store syncs from it and falls back to a full reload on any error. The download endpoint answers a single range (`bytes=0-9`, `bytes=-5`, `bytes=995-`) with `206` and a correct `Content-Range`, and anything else (several ranges, or a range past the end) with the whole file and `200`, never `416`. It sends no `Accept-Ranges`, so ihasmail's proxy advertises it: Chrome's PDF viewer reads a file in pieces only when told it can. The mock answers the same way. - **Push subscriptions are not replaced by a repeated `deviceClientId`, and an account holds fifteen.** ihasmail registered a new subscription on every renewal believing the old one would be replaced, as the mock did. **Confirmed live (0.16.22, 2026-09-16)**: a second create with the same `deviceClientId` leaves both in place, the sixteenth create is refused with `overQuota`, "There are too many subscriptions, please delete some before adding a new one.", and `update` of `expires` is accepted. `PushSubscription/get` does not return `url` (nor `keys`), so a subscription can only be matched by its `deviceClientId`. A `types` of `[]` or `null` is stored as *every* type, not none. Read from the 0.16.22 source: `EmailDelivery` changes only on delivery, a delivery reaches a subscription with an `emailPush` filter as an EmailPush alone, and the payload carries `id` and `threadId` only when they are named in `properties`. Browsers now subscribe to `EmailDelivery` only, extend rather than re-create, clear their own duplicates and make room on `overQuota`; the server removes what its previous process registered. The mock follows all of it ([#375](https://git.coffeylabs.org/coffey-labs/ihasmail-github-archive/issues/375)). diff --git a/web/src/jmap/__tests__/fileNodeShape.test.ts b/web/src/jmap/__tests__/fileNodeShape.test.ts new file mode 100644 index 0000000..4e05302 --- /dev/null +++ b/web/src/jmap/__tests__/fileNodeShape.test.ts @@ -0,0 +1,136 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { CAP, client } from "@/jmap/client"; +import { argsToServer, resultFromServer, shapeFromProbe } from "@/jmap/fileNodeShape"; +import type { JmapSession } from "@/jmap/types"; + +/** + * Stalwart 1.0 renamed FileNode's `nodeType` and `type` and refuses the old + * names, so nothing the client wrote for 0.16 could make a folder or a file + * there and the settings file was never written (Gitea issue #66). The shapes + * below are what real 0.16.27 and 1.0.0 servers sent on 2026-10-30; the + * server compatibility test sends the same calls to both. + */ + +describe("the probe", () => { + it("reads 1.0's refusal of a sort by `type`", () => { + expect(shapeFromProbe(["error", { type: "unsupportedSort", description: "type" }, "shape"])).toBe("1.0"); + }); + + it("reads 0.16's answer", () => { + expect(shapeFromProbe(["FileNode/query", { ids: [] }, "shape"])).toBe("0.16"); + }); + + it("decides nothing on any other answer", () => { + expect(shapeFromProbe(["error", { type: "forbidden" }, "shape"])).toBeNull(); + expect(shapeFromProbe(["error", { type: "accountNotFound" }, "shape"])).toBeNull(); + expect(shapeFromProbe(undefined)).toBeNull(); + }); +}); + +describe("calls to a 1.0 server", () => { + it("creates a folder and a file in 1.0's names", () => { + const args = argsToServer("1.0", "FileNode/set", { + accountId: "a", + create: { + d: { parentId: null, name: "ihasmail", nodeType: "directory" }, + f: { parentId: "#d", name: "settings.json", blobId: "B", type: "application/json", nodeType: "file" }, + }, + }); + expect(args.create).toEqual({ + d: { parentId: null, name: "ihasmail", "@type": "Directory" }, + f: { parentId: "#d", name: "settings.json", blobId: "B", mediaType: "application/json", "@type": "File" }, + }); + }); + + it("renames `type` in an update", () => { + const args = argsToServer("1.0", "FileNode/set", { accountId: "a", update: { n1: { blobId: "B", type: "application/json", size: 7 } } }); + expect(args.update).toEqual({ n1: { blobId: "B", mediaType: "application/json", size: 7 } }); + }); + + it("asks for the renamed properties", () => { + const args = argsToServer("1.0", "FileNode/get", { accountId: "a", ids: ["n1"], properties: ["id", "nodeType", "type", "name"] }); + expect(args.properties).toEqual(["id", "@type", "mediaType", "name"]); + }); + + it("drops a filter by kind, which 1.0 answers with nothing", () => { + expect(argsToServer("1.0", "FileNode/query", { accountId: "a", filter: { nodeType: "directory" } }).filter).toEqual({}); + expect(argsToServer("1.0", "FileNode/query", { accountId: "a", filter: { operator: "AND", conditions: [{ nodeType: "file" }, { parentId: "p" }] } }).filter) + .toEqual({ operator: "AND", conditions: [{}, { parentId: "p" }] }); + }); + + it("sorts by `mediaType` where it was `type`", () => { + const args = argsToServer("1.0", "FileNode/query", { accountId: "a", sort: [{ property: "type", isAscending: true }, { property: "name" }] }); + expect(args.sort).toEqual([{ property: "mediaType", isAscending: true }, { property: "name" }]); + }); + + it("leaves other methods alone", () => { + const args = { accountId: "a", properties: ["type"] }; + expect(argsToServer("1.0", "Email/get", args)).toBe(args); + }); +}); + +describe("answers from a 1.0 server", () => { + it("reads nodes back in the client's names", () => { + const res = resultFromServer("1.0", "FileNode/get", { + list: [ + { id: "b", parentId: null, "@type": "Directory", name: "ihasmail" }, + { id: "c", parentId: "b", "@type": "File", name: "settings.json", mediaType: "application/json", blobId: "B" }, + ], + }); + expect(res.list).toEqual([ + { id: "b", parentId: null, nodeType: "directory", name: "ihasmail" }, + { id: "c", parentId: "b", nodeType: "file", name: "settings.json", type: "application/json", blobId: "B" }, + ]); + }); + + it("reads what a set created and updated", () => { + const res = resultFromServer("1.0", "FileNode/set", { created: { f: { id: "c", mediaType: "application/octet-stream" } }, updated: { c: null } }); + expect(res.created).toEqual({ f: { id: "c", type: "application/octet-stream" } }); + expect(res.updated).toEqual({ c: null }); + }); +}); + +describe("0.16", () => { + it("is the client's own shape, passed through as is", () => { + const args = { accountId: "a", create: { d: { nodeType: "directory" } }, filter: { nodeType: "directory" } }; + expect(argsToServer("0.16", "FileNode/set", args)).toBe(args); + const res = { list: [{ nodeType: "file", type: "text/plain" }] }; + expect(resultFromServer("0.16", "FileNode/get", res)).toBe(res); + }); +}); + +describe("the client", () => { + afterEach(() => { + vi.unstubAllGlobals(); + client.session = null; + }); + + it("asks once per session, then sends and reads in 1.0's names", async () => { + client.session = { + capabilities: { [CAP.core]: {}, [CAP.filenode]: {} }, + accounts: {}, + primaryAccounts: { [CAP.filenode]: "own" }, + state: "s1", + } as unknown as JmapSession; + const sent: [string, Record, string][][] = []; + vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => { + const calls = (JSON.parse(init.body as string) as { methodCalls: [string, Record, string][] }).methodCalls; + sent.push(calls); + const methodResponses = calls.map(([m, a, id]) => + id === "shape" + ? ["error", { type: "unsupportedSort" }, id] + : [m, { list: [{ id: "b", "@type": "Directory", name: (a.properties as string[]).join(",") }] }, id], + ); + return { ok: true, status: 200, json: async () => ({ methodResponses }) } as Response; + })); + + const one = await client.request([["FileNode/get", { accountId: "shared", ids: ["b"], properties: ["id", "nodeType"] }, "g"]], [CAP.core, CAP.filenode]); + await client.request([["FileNode/get", { accountId: "a", ids: ["b"], properties: ["id"] }, "g"]], [CAP.core, CAP.filenode]); + + expect(sent.map((calls) => calls.map(([m]) => m))).toEqual([["FileNode/query"], ["FileNode/get"], ["FileNode/get"]]); + // Of the reader's own account, not the shared one asked about. + expect(sent[0]![0]![1].accountId).toBe("own"); + expect(sent[1]![0]![1].properties).toEqual(["id", "@type"]); + expect(one.methodResponses[0]![1].list).toEqual([{ id: "b", nodeType: "directory", name: "id,@type" }]); + }); +}); diff --git a/web/src/jmap/__tests__/stalwart-compat.test.ts b/web/src/jmap/__tests__/stalwart-compat.test.ts index 599e8a8..06b5aae 100644 --- a/web/src/jmap/__tests__/stalwart-compat.test.ts +++ b/web/src/jmap/__tests__/stalwart-compat.test.ts @@ -2,8 +2,12 @@ import { beforeAll, describe, expect, it } from "vitest"; import { CAP, STALWART_CAP, usingFor } from "@/jmap/client"; import { BODY_PROPS, FULL_PROPS, LIST_PROPS, MAILBOX_PROPS } from "@/store/mail/props"; -import { CALENDAR_PROPS } from "@/store/calendar"; +import { CALENDAR_PROPS, EVENT_PROPS, SERIES_PROPS } from "@/store/calendar"; import { ADDRESS_BOOK_PROPS } from "@/store/contacts"; +import { argsToServer, resultFromServer, shapeFromProbe, shapeProbe } from "@/jmap/fileNodeShape"; +import { APP_FOLDER, folderProps, isAppFolder } from "@/lib/appFolder"; +import { directoryCreate, fileCreate, fileNodeProps } from "@/lib/filenode"; +import type { FileNode } from "@/jmap/types"; /** * Runs against a real, empty Stalwart, not a mock. Skipped unless @@ -186,6 +190,114 @@ describe.skipIf(!BASE)("a real Stalwart server", () => { await expectAnswered(alice, calls); }); + it("reads events with every property list the calendar asks for", async () => { + // Stalwart 1.0 refuses recurrenceOverrides together with utcStart/utcEnd + // for the whole call, even with no ids (Gitea issue #66), so the empty + // gets matter as much as the ones that find something. + await expectAnswered(alice, [ + ["CalendarEvent/get", { accountId: aid, ids: [], properties: EVENT_PROPS }, "e"], + ["CalendarEvent/get", { accountId: aid, ids: [], properties: SERIES_PROPS }, "s"], + ]); + + const calendars = (await jmap(alice, [["Calendar/get", { accountId: aid, properties: CALENDAR_PROPS }, "c"]]))[0]![1].list as { id: string }[]; + let calendarId = calendars[0]?.id; + if (!calendarId) { + const made = await jmap(alice, [["Calendar/set", { accountId: aid, create: { c: { name: "Compat" } } }, "c"]]); + calendarId = (made[0]![1].created as Record).c!.id; + } + const event = { + "@type": "Event", + uid: crypto.randomUUID(), + calendarIds: { [calendarId]: true }, + title: "Compat weekly", + start: "2026-11-02T09:00:00", + timeZone: "Etc/UTC", + duration: "PT1H", + // The singular the editor writes: 0.16 refuses the RFC's list, and 1.0 + // takes the list but drops it, leaving one event. + recurrenceRule: { "@type": "RecurrenceRule", frequency: "weekly", count: 3 }, + }; + const made = await jmap(alice, [["CalendarEvent/set", { accountId: aid, create: { e: event } }, "e"]]); + const masterId = (made[0]![1].created as Record)?.e?.id; + expect(masterId, JSON.stringify(made[0]![1])).toBeTruthy(); + + // What the grid does: a window, expanded, read with EVENT_PROPS. + const grid = await jmap(alice, [ + ["CalendarEvent/query", { accountId: aid, filter: { after: "2026-11-01T00:00:00", before: "2026-11-30T00:00:00" }, timeZone: "Etc/UTC", sort: [{ property: "start", isAscending: true }], expandRecurrences: true, limit: 2000 }, "q"], + ["CalendarEvent/get", { accountId: aid, "#ids": { resultOf: "q", name: "CalendarEvent/query", path: "/ids" }, properties: EVENT_PROPS, timeZone: "Etc/UTC" }, "g"], + ]); + expect(grid[1]![0], JSON.stringify(grid[1]![1]).slice(0, 300)).toBe("CalendarEvent/get"); + const occurrences = grid[1]![1].list as { utcStart?: string }[]; + expect(occurrences, JSON.stringify(occurrences.map((o) => o.utcStart))).toHaveLength(3); + expect(occurrences.every((o) => typeof o.utcStart === "string")).toBe(true); + + // What the export does: the master, read with SERIES_PROPS. + const series = await jmap(alice, [["CalendarEvent/get", { accountId: aid, ids: [masterId], properties: SERIES_PROPS }, "g"]]); + expect(series[0]![0], JSON.stringify(series[0]![1]).slice(0, 300)).toBe("CalendarEvent/get"); + expect((series[0]![1].list as Record[])[0]!.recurrenceRule).toBeTruthy(); + }); + + it("keeps the settings file the way the app does", async () => { + // The client's own translation, against whichever shape this server wants + // (fileNodeShape.ts). Stalwart 1.0 renamed nodeType and type, and refused + // the old names on every create, so the settings were never written + // (Gitea issue #66). + const shape = shapeFromProbe((await jmap(alice, [shapeProbe(aid) as Call]))[0]); + expect(shape, "the probe decided nothing").not.toBeNull(); + const files = async (calls: Call[]): Promise => + (await jmap(alice, calls.map(([m, a, id]) => [m, argsToServer(shape!, m, a), id] as Call))).map(([m, a, id]) => [m, resultFromServer(shape!, m, a), id] as Response); + const topLevel = async () => + ((await files([ + ["FileNode/query", { accountId: aid, filter: { isTopLevel: true }, limit: 1000 }, "q"], + ["FileNode/get", { accountId: aid, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: folderProps() }, "g"], + ]))[1]![1].list as FileNode[]); + + // ensureFolder: found, or made and then found by the same test. + let folderId = (await topLevel()).find(isAppFolder)?.id; + if (!folderId) { + const made = await files([["FileNode/set", { accountId: aid, create: { d: directoryCreate(null, APP_FOLDER) } }, "s"]]); + expect(made[0]![1].notCreated, JSON.stringify(made[0]![1])).toBeFalsy(); + folderId = (await topLevel()).find(isAppFolder)?.id; + } + expect(folderId).toBeTruthy(); + + // The Files tree asks for folders by kind and sorts them out itself. + const tree = await files([ + ["FileNode/query", { accountId: aid, filter: { nodeType: "directory" }, sort: [{ property: "name", isAscending: true }], limit: 1000 }, "q"], + ["FileNode/get", { accountId: aid, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: fileNodeProps() }, "g"], + ]); + expect((tree[1]![1].list as FileNode[]).filter((n) => n.nodeType === "directory").map((n) => n.id)).toContain(folderId); + + // writeSettings: upload, then create the file or update the one there. + const upload = async (text: string) => { + const res = await fetch(`${BASE}/jmap/upload/${aid}/`, { method: "POST", headers: { "content-type": "application/json", authorization: auth(alice) }, body: text }); + expect(res.ok, `upload: HTTP ${res.status}`).toBe(true); + return ((await res.json()) as { blobId: string }).blobId; + }; + const inFolder = async () => + ((await files([ + ["FileNode/query", { accountId: aid, filter: { parentId: folderId }, limit: 1000 }, "q"], + ["FileNode/get", { accountId: aid, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: ["id", "name", "parentId", "blobId", "size", "type", "nodeType"] }, "g"], + ]))[1]![1].list as FileNode[]).find((n) => n.name === "settings.json"); + + const first = '{"theme":"dark"}'; + let existing = await inFolder(); + if (!existing) { + const made = await files([["FileNode/set", { accountId: aid, create: { s: fileCreate(folderId!, "settings.json", await upload(first), "application/json") } }, "s"]]); + expect(made[0]![1].notCreated, JSON.stringify(made[0]![1])).toBeFalsy(); + existing = await inFolder(); + } + expect(existing).toMatchObject({ nodeType: "file", type: "application/json" }); + + const second = '{"theme":"light"}'; + const updated = await files([["FileNode/set", { accountId: aid, update: { [existing!.id]: { blobId: await upload(second), type: "application/json", size: second.length } } }, "s"]]); + expect(updated[0]![1].notUpdated, JSON.stringify(updated[0]![1])).toBeFalsy(); + // A stored blob has an id of its own, so the content is what is compared. + const stored = (await inFolder())!.blobId!; + const read = await fetch(`${BASE}/jmap/download/${aid}/${stored}/settings.json?accept=application/json`, { headers: { authorization: auth(alice) } }); + expect(await read.text()).toBe(second); + }); + it("answers every read the administration pages make", async () => { const calls: Call[] = [ "x:Account/query", diff --git a/web/src/jmap/client.ts b/web/src/jmap/client.ts index bf0d630..45d9768 100644 --- a/web/src/jmap/client.ts +++ b/web/src/jmap/client.ts @@ -1,4 +1,5 @@ import type { Id, Invocation, JmapResponse, JmapSession, MethodError, UploadResponse } from "./types"; +import { argsToServer, resultFromServer, shapeFromProbe, shapeProbe, type FileNodeShape } from "./fileNodeShape"; import { withBase } from "@/lib/basePath"; import { reauthAtFrontDoor } from "@/lib/frontDoor"; @@ -118,6 +119,8 @@ export class JmapClient { private stateHandlers = new Set<(sessionState: string) => void>(); /** The last session state announced, so a burst of replies announces it once. */ private announcedState: string | null = null; + /** The FileNode shape this session's server wants, asked once (fileNodeShape.ts). */ + private fileShape: { session: JmapSession | null; shape: Promise } | null = null; get maxCallsInRequest(): number { const core = this.session?.capabilities[CAP.core] as { maxCallsInRequest?: number } | undefined; @@ -264,8 +267,42 @@ export class JmapClient { return using.filter((u) => u === CAP.core || this.hasCapabilityAnywhere(u)); } - /** Low-level request: send invocations verbatim, return raw response. */ + /** + * Low-level request: send invocations, return the raw response. + * + * Verbatim, except FileNode calls, which go out and come back through the + * translation in fileNodeShape.ts when the server is 1.0. Done here so that + * every caller keeps the one shape it was written for. + */ async request(methodCalls: Invocation[], using: string[] = [CAP.core, CAP.mail], createdIds?: Record): Promise { + const fileCall = methodCalls.find(([m]) => m.startsWith("FileNode/")); + const accountId = fileCall?.[1].accountId; + if (typeof accountId !== "string") return this.send(methodCalls, using, createdIds); + const shape = await this.fileNodeShape(accountId); + const res = await this.send(methodCalls.map(([m, a, id]) => [m, argsToServer(shape, m, a), id]), using, createdIds); + return { ...res, methodResponses: res.methodResponses.map(([m, a, id]) => [m, resultFromServer(shape, m, a), id]) }; + } + + /** + * Ask once per session which FileNode shape the server wants, of the + * reader's own Files account where there is one. Until an answer decides + * it, the shape this client was written for, and the question stays open. + */ + private fileNodeShape(accountId: Id): Promise { + if (this.fileShape && this.fileShape.session === this.session) return this.fileShape.shape; + const undecided = (): FileNodeShape => { + this.fileShape = null; + return "0.16"; + }; + const shape = this.send([shapeProbe(this.primaryAccount(CAP.filenode) ?? accountId)], [CAP.core, CAP.filenode]).then( + (res) => shapeFromProbe(res.methodResponses[0]) ?? undecided(), + undecided, + ); + this.fileShape = { session: this.session, shape }; + return shape; + } + + private async send(methodCalls: Invocation[], using: string[], createdIds?: Record): Promise { const body: Record = { using: this.supportedUsing(using), methodCalls }; if (createdIds) body.createdIds = createdIds; const res = await apiFetch("/api/jmap", { method: "POST", body: JSON.stringify(body) }); diff --git a/web/src/jmap/fileNodeShape.ts b/web/src/jmap/fileNodeShape.ts new file mode 100644 index 0000000..275fd52 --- /dev/null +++ b/web/src/jmap/fileNodeShape.ts @@ -0,0 +1,106 @@ +/** + * FileNode as Stalwart 1.0 spells it, translated to and from the 0.16 shape the + * rest of the client is written against. + * + * 1.0 renamed two properties and refuses the old names outright, so nothing + * written for 0.16 could make a folder or a file there (Gitea issue #66: the + * settings file never got written, so settings reset at every sign-in): + * + * | 0.16 | 1.0 | + * | ---------------------------------- | ----------------------------------- | + * | `nodeType: "directory" \| "file"` | `"@type": "Directory" \| "File"` | + * | `type` (a file's media type) | `mediaType` | + * + * Each server refuses the other's names on a create or an update, and ignores + * them on a get. 1.0 also accepts `filter: { nodeType }` and answers it with no + * ids at all, so that filter is dropped rather than translated: 1.0 offers no + * filter by kind, and the callers that use it sort folders from files + * themselves. + * + * Checked against real 0.16.27 and 1.0.0 servers on 2026-10-30; the server + * compatibility test sends these calls to both. + * + * Nothing in the session says which shape a server wants, so `PROBE` asks: a + * query sorted by `type`, which 0.16 answers and 1.0 refuses as + * `unsupportedSort` because the property is gone. It writes nothing and is + * sent once per session, before the first FileNode call, to the reader's own + * account: a shared one may refuse the reader outright, which says nothing + * about the server. An answer that is neither leaves the question open. + */ +import type { Invocation } from "./types"; + +export type FileNodeShape = "0.16" | "1.0"; + +/** The probe, for an account the caller already has. */ +export function shapeProbe(accountId: string): Invocation { + return ["FileNode/query", { accountId, sort: [{ property: "type", isAscending: true }], limit: 1 }, "shape"]; +} + +/** Read the probe's answer: 0.16 answers it, 1.0 refuses the sort, and anything else decides nothing. */ +export function shapeFromProbe(response: Invocation | undefined): FileNodeShape | null { + const [name, args] = response ?? []; + if (name === "FileNode/query") return "0.16"; + if (name === "error" && (args as { type?: string } | undefined)?.type === "unsupportedSort") return "1.0"; + return null; +} + +const TO_TYPE: Record = { directory: "Directory", file: "File", symlink: "Symlink" }; + +type Obj = Record; + +/** A node, or a patch to one, in 1.0's names. */ +function nodeToServer(node: Obj): Obj { + const { nodeType, type, ...rest } = node; + const out: Obj = { ...rest }; + if (typeof nodeType === "string") out["@type"] = TO_TYPE[nodeType] ?? nodeType; + if (type !== undefined) out.mediaType = type; + return out; +} + +/** A node from 1.0, in the names the client reads. */ +function nodeFromServer(node: unknown): unknown { + if (!node || typeof node !== "object") return node; + const { "@type": kind, mediaType, ...rest } = node as Obj; + const out: Obj = { ...rest }; + if (typeof kind === "string") out.nodeType = kind.toLowerCase(); + if (mediaType !== undefined) out.type = mediaType; + return out; +} + +function mapValues(o: unknown, fn: (v: Obj) => unknown): unknown { + if (!o || typeof o !== "object") return o; + return Object.fromEntries(Object.entries(o as Obj).map(([k, v]) => [k, v && typeof v === "object" ? fn(v as Obj) : v])); +} + +/** A filter without `nodeType`, through any operators. */ +function filterToServer(filter: unknown): unknown { + if (!filter || typeof filter !== "object") return filter; + const f = filter as Obj; + if (Array.isArray(f.conditions)) return { ...f, conditions: f.conditions.map(filterToServer) }; + const { nodeType: _n, ...rest } = f; + return rest; +} + +const RENAMED: Record = { nodeType: "@type", type: "mediaType" }; + +/** A call's arguments, as this shape of server expects them. */ +export function argsToServer(shape: FileNodeShape, method: string, args: Obj): Obj { + if (shape === "0.16" || !method.startsWith("FileNode/")) return args; + const out: Obj = { ...args }; + if (Array.isArray(out.properties)) out.properties = [...new Set((out.properties as string[]).map((p) => RENAMED[p] ?? p))]; + if (out.create) out.create = mapValues(out.create, nodeToServer); + if (out.update) out.update = mapValues(out.update, nodeToServer); + if (out.filter) out.filter = filterToServer(out.filter); + if (Array.isArray(out.sort)) out.sort = (out.sort as Obj[]).map((c) => (c.property === "type" ? { ...c, property: "mediaType" } : c)); + return out; +} + +/** A method's answer, in the names the client reads. */ +export function resultFromServer(shape: FileNodeShape, method: string, result: Obj): Obj { + if (shape === "0.16" || !method.startsWith("FileNode/")) return result; + const out: Obj = { ...result }; + if (Array.isArray(out.list)) out.list = out.list.map(nodeFromServer); + if (out.created) out.created = mapValues(out.created, (v) => nodeFromServer(v)); + if (out.updated) out.updated = mapValues(out.updated, (v) => nodeFromServer(v)); + return out; +} diff --git a/web/src/store/__tests__/shared-accounts-batched.test.ts b/web/src/store/__tests__/shared-accounts-batched.test.ts index 2001a03..18ad93a 100644 --- a/web/src/store/__tests__/shared-accounts-batched.test.ts +++ b/web/src/store/__tests__/shared-accounts-batched.test.ts @@ -62,8 +62,13 @@ describe("shared accounts", () => { it("are asked about files together when Files wants to know", async () => { await useFiles.getState().discoverShared(); - expect(requests).toHaveLength(1); - expect(requests[0]!.map(([n, a]) => `${n} ${a.accountId}`)).toEqual(SHARED.map((id) => `FileNode/query ${id}`)); + // The first FileNode call of a session asks the reader's own account which + // shape of FileNode the server wants (jmap/fileNodeShape.ts); the shared + // accounts are still asked in one request after it. + const [probe, ...rest] = requests; + expect(probe!.map(([n, a, id]) => `${n} ${a.accountId} ${id}`)).toEqual(["FileNode/query own shape"]); + expect(rest).toHaveLength(1); + expect(rest[0]!.map(([n, a]) => `${n} ${a.accountId}`)).toEqual(SHARED.map((id) => `FileNode/query ${id}`)); expect(useFiles.getState().sharedAccounts).toEqual([{ id: "s2", name: "s2@example.com" }]); }); diff --git a/web/src/store/calendar.ts b/web/src/store/calendar.ts index 6c4277a..0d29864 100644 --- a/web/src/store/calendar.ts +++ b/web/src/store/calendar.ts @@ -332,15 +332,31 @@ interface CalendarState { * Explicit property list: when `properties` is null Stalwart omits the JMAP-only * fields baseEventId / utcStart / utcEnd, and we need baseEventId to update * recurring instances (synthetic ids can't be patched directly). + * + * `recurrenceOverrides` is left out. Stalwart 1.0 refuses a get that asks for + * it together with `utcStart` or `utcEnd` -- the whole call, with no ids at + * all -- and every read here wants the times: an occurrence is placed on the + * grid by them, and an occurrence's overrides are null anyway. The one read + * that needs the overrides, the export, asks with `SERIES_PROPS` instead. */ -const EVENT_PROPS = [ +export const EVENT_PROPS = [ "id", "baseEventId", "calendarIds", "isDraft", "isOrigin", "utcStart", "utcEnd", "useDefaultAlerts", "mayInviteSelf", "mayInviteOthers", "hideAttendees", "uid", "relatedTo", "prodId", "created", "updated", "sequence", "title", "description", "descriptionContentType", "showWithoutTime", "locations", "virtualLocations", "links", "locale", "keywords", "categories", "color", "recurrenceId", "recurrenceIdTimeZone", - "recurrenceRules", "recurrenceRule", "excludedRecurrenceRules", "recurrenceOverrides", "excluded", "priority", "freeBusyStatus", "privacy", "replyTo", "organizerCalendarAddress", + "recurrenceRules", "recurrenceRule", "excludedRecurrenceRules", "excluded", "priority", "freeBusyStatus", "privacy", "replyTo", "organizerCalendarAddress", "sentBy", "participants", "requestStatus", "alerts", "timeZone", "start", "duration", "status", ]; +/** + * A stored event whole, for writing it out: its "just this date" edits + * included, its server-computed times not. A master's own times are in + * `start`, `duration` and `timeZone`, which is all an export writes. + */ +export const SERIES_PROPS = [ + ...EVENT_PROPS.filter((p) => p !== "utcStart" && p !== "utcEnd"), + "recurrenceOverrides", +]; + /** * An event as it arrived, minus everything that belonged to where it came from. * @@ -1029,7 +1045,7 @@ export const useCalendar = create((set, get) => ({ */ async exportIcs(calendarId) { const accountId = get().accountId!; - const events = await eventsInCalendar(accountId, calendarId, EVENT_PROPS); + const events = await eventsInCalendar(accountId, calendarId, SERIES_PROPS); if (!events.length) throw new Error("there is nothing in it to export"); return { text: toIcs(events, get().calendars[calendarId]?.name), count: events.length }; }, -- 2.54.0