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 }; },