From 28ade775e37eb79ee850bc83ade183341cc6cd6f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:45:19 +0200 Subject: [PATCH] fix: load calendar events and tasks in the app on Stalwart 1.0 Stalwart 1.0 refuses a CalendarEvent/get that asks for recurrenceOverrides together with utcStart or utcEnd, and every event read of the app did. The calendar showed a raw error over stale events, and tasks, reminders, "all events" edits, contact activity, the invitation banner's UID look-up and iCal subscriptions stopped working. Subscriptions and imports also lost their UID check, so every sync tried to create the whole feed again and ended in refusals. - read a stored event's overrides with a second get in the same request; server-expanded occurrences get them from their base event as before - read only ids, UIDs and calendars for the import and subscription UID look-ups - fail a read when either get answers with an error, so the calendar keeps the events it has and shows the error - keep the JMAP error type on calendar method errors (JMAPMethodError, plus a shared jmapErrorType helper in jmap-result) 0.16 accepts the split reads and returns the same events. --- .../calendar-overrides-split.test.ts | 178 +++++++++++++++ src/api/__tests__/calendar.test.ts | 37 ++-- src/api/__tests__/stalwart-calendar-mock.ts | 206 +++++++++++++++++ src/api/calendar.ts | 143 +++++++----- src/api/jmap-result.ts | 9 + .../calendar-store-extend-range.test.ts | 1 + .../calendar-store-server-reads.test.ts | 207 ++++++++++++++++++ src/stores/__tests__/calendar-store.test.ts | 9 +- .../calendar-subscriptions-store.test.ts | 2 +- src/stores/calendar-store.ts | 6 +- src/stores/calendar-subscriptions-store.ts | 4 +- 11 files changed, 723 insertions(+), 79 deletions(-) create mode 100644 src/api/__tests__/calendar-overrides-split.test.ts create mode 100644 src/api/__tests__/stalwart-calendar-mock.ts create mode 100644 src/stores/__tests__/calendar-store-server-reads.test.ts diff --git a/src/api/__tests__/calendar-overrides-split.test.ts b/src/api/__tests__/calendar-overrides-split.test.ts new file mode 100644 index 00000000..c6e31394 --- /dev/null +++ b/src/api/__tests__/calendar-overrides-split.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('../jmap-client', () => ({ + jmapClient: { + accountId: 'acc-1', + request: vi.fn(), + getMaxObjectsInGet: () => 500, + getMaxCallsInRequest: () => 16, + }, +})); + +import { jmapClient } from '../jmap-client'; +import { JMAPMethodError } from '../jmap-result'; +import { + findEventsByUid, + getEventIdentities, + getEvents, + hydrateExpandedOccurrences, + resetSyntheticIdSupport, +} from '../calendar'; +import { + OVERRIDES_WITH_UTC_ERROR, + asksForOverridesWithUtc, + createStalwartCalendarMock, + type StalwartCalendarMock, + type StalwartVersion, +} from './stalwart-calendar-mock'; + +// Stalwart 1.0 fails a CalendarEvent/get that asks for recurrenceOverrides +// together with utcStart or utcEnd - the whole call, before any id is read. +// Every get the app sends must keep them apart: a stored event gets its +// overrides from a second get in the same request, a server-expanded +// occurrence from its base event. 0.16 accepts either, and must get the same +// events back as before. + +const mockRequest = jmapClient.request as ReturnType; + +const SERIES = { + id: 'b', '@type': 'Event', uid: 'series@example.com', title: 'Daily standup', baseEventId: 'b', + start: '2026-09-07T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin', + utcStart: '2026-09-07T08:00:00Z', utcEnd: '2026-09-07T09:00:00Z', showWithoutTime: false, + recurrenceRule: { frequency: 'daily', count: 5 }, + recurrenceOverrides: { '2026-09-10T10:00:00': { excluded: true } }, + participants: { org: { '@type': 'Participant', calendarAddress: 'mailto:org@example.com', roles: { owner: true } } }, + calendarIds: { cal: true }, +}; +const SINGLE = { + id: 'd', '@type': 'Event', uid: 'single@example.com', title: 'One off', baseEventId: 'd', + start: '2026-09-08T09:00:00', duration: 'PT30M', timeZone: 'Europe/Berlin', + utcStart: '2026-09-08T07:00:00Z', utcEnd: '2026-09-08T07:30:00Z', calendarIds: { other: true }, +}; +const OCCURRENCE = { + ...SERIES, id: 'maaaaab', baseEventId: 'b', recurrenceRule: null, recurrenceOverrides: null, + start: '2026-09-08T10:00:00', utcStart: '2026-09-08T08:00:00Z', utcEnd: '2026-09-08T09:00:00Z', + recurrenceId: '2026-09-08T10:00:00', recurrenceIdTimeZone: 'Europe/Berlin', +}; + +describe.each(['0.16', '1.0'])('calendar reads on Stalwart %s', (version) => { + let server: StalwartCalendarMock; + + beforeEach(() => { + vi.clearAllMocks(); + resetSyntheticIdSupport(); + server = createStalwartCalendarMock({ + version, + objects: { b: SERIES, d: SINGLE }, + occurrences: { maaaaab: OCCURRENCE }, + }); + mockRequest.mockImplementation(server.request); + }); + + afterEach(() => { + // The invariant: no get ever asks for both. + expect(server.gets().filter((call) => asksForOverridesWithUtc(call.args))).toEqual([]); + }); + + it(`reads a stored event with its overrides from a second get in the same request on Stalwart ${version}`, async () => { + const [series] = await getEvents(['b']); + + expect(series.utcStart).toBe('2026-09-07T08:00:00Z'); + expect(series.recurrenceRules).toEqual([{ frequency: 'daily', count: 5 }]); + expect(series.recurrenceOverrides).toEqual({ '2026-09-10T10:00:00': { excluded: true } }); + const [main, overrides] = server.gets(); + expect(main.args.properties).toContain('utcStart'); + expect(main.args.properties).not.toContain('recurrenceOverrides'); + expect(overrides.args).toMatchObject({ ids: ['b'], properties: ['id', 'recurrenceOverrides'] }); + expect(overrides.request).toBe(main.request); + expect(mockRequest).toHaveBeenCalledTimes(1); + }); + + it(`never asks for overrides of server-expanded occurrences on Stalwart ${version}`, async () => { + const [occurrence] = await getEvents(['maaaaab'], undefined, { expanded: true }); + + expect(occurrence.utcStart).toBe('2026-09-08T08:00:00Z'); + expect(server.gets()).toHaveLength(1); + expect(server.gets()[0].args.properties).toEqual(expect.arrayContaining(['baseEventId', 'recurrenceIdTimeZone'])); + expect(server.gets()[0].args.properties).not.toContain('recurrenceOverrides'); + }); + + it(`gives an expanded occurrence its series' overrides from a get without utc* on Stalwart ${version}`, async () => { + const occurrences = await getEvents(['maaaaab'], undefined, { expanded: true }); + const [hydrated] = await hydrateExpandedOccurrences(occurrences); + + expect(hydrated.recurrenceOverrides).toEqual({ '2026-09-10T10:00:00': { excluded: true } }); + expect(hydrated.recurrenceRules).toEqual([{ frequency: 'daily', count: 5 }]); + const hydration = server.gets()[1]; + expect(hydration.args.ids).toEqual(['b']); + expect(hydration.args.properties).not.toContain('utcStart'); + }); + + it(`finds an invitation outside the loaded window by UID on Stalwart ${version}`, async () => { + const [found] = await findEventsByUid('series@example.com'); + + expect(found.id).toBe('b'); + expect(found.participants?.org.calendarAddress).toBe('mailto:org@example.com'); + expect(found.recurrenceOverrides).toEqual({ '2026-09-10T10:00:00': { excluded: true } }); + }); + + it(`reads only ids, UIDs and calendars for a UID look-up, in one get, on Stalwart ${version}`, async () => { + const identities = await getEventIdentities(['b', 'd']); + + expect(identities).toEqual([ + { id: 'b', uid: 'series@example.com', calendarIds: { cal: true } }, + { id: 'd', uid: 'single@example.com', calendarIds: { other: true } }, + ]); + expect(server.gets()).toHaveLength(1); + expect(server.gets()[0].args.properties).toEqual(['id', 'uid', 'calendarIds']); + expect(await getEventIdentities([])).toEqual([]); + expect(mockRequest).toHaveBeenCalledTimes(1); + }); + + it(`reads every batch of maxObjectsInGet with its own overrides on Stalwart ${version}`, async () => { + const many = Object.fromEntries(Array.from({ length: 501 }, (_, i) => [`e${i}`, { + ...SINGLE, id: `e${i}`, uid: `e${i}@example.com`, + recurrenceOverrides: i === 500 ? { '2026-09-09T09:00:00': { title: 'Last' } } : null, + }])); + server = createStalwartCalendarMock({ version, objects: many }); + mockRequest.mockImplementation(server.request); + + const events = await getEvents(Object.keys(many)); + + expect(events).toHaveLength(501); + expect(mockRequest).toHaveBeenCalledTimes(2); + expect(events[500].recurrenceOverrides).toEqual({ '2026-09-09T09:00:00': { title: 'Last' } }); + }); +}); + +describe('a refused calendar read', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('fails the read instead of passing an error answer for no events on Stalwart 1.0', async () => { + // What every read got on 1.0 before the split. + mockRequest.mockResolvedValue({ methodResponses: [['error', { ...OVERRIDES_WITH_UTC_ERROR }, '0']] }); + + const failure = getEvents(['b']); + await expect(failure).rejects.toBeInstanceOf(JMAPMethodError); + await expect(failure).rejects.toMatchObject({ type: 'invalidArguments', message: OVERRIDES_WITH_UTC_ERROR.description }); + }); + + it('fails the read when only the overrides get is refused on Stalwart 1.0', async () => { + const server = createStalwartCalendarMock({ + version: '1.0', + objects: { b: SERIES }, + answer: (call) => (call.callId === '1' ? ['error', { type: 'serverFail' }] : undefined), + }); + mockRequest.mockImplementation(server.request); + + await expect(getEvents(['b'])).rejects.toMatchObject({ type: 'serverFail' }); + }); + + it('fails the read when the server leaves the overrides get unanswered', async () => { + mockRequest.mockResolvedValue({ methodResponses: [['CalendarEvent/get', { list: [SERIES] }, '0']] }); + + await expect(getEvents(['b'])).rejects.toMatchObject({ type: 'missingResponse' }); + }); +}); diff --git a/src/api/__tests__/calendar.test.ts b/src/api/__tests__/calendar.test.ts index cd188a27..221ba6f0 100644 --- a/src/api/__tests__/calendar.test.ts +++ b/src/api/__tests__/calendar.test.ts @@ -40,6 +40,17 @@ describe('toLocalDateTime', () => { const mockRequest = jmapClient.request as ReturnType; +// A stored event is read with a second get for its overrides in the same +// request (Stalwart 1.0 refuses them next to utcStart/utcEnd). +function getAnswer(list: unknown[]) { + return { + methodResponses: [ + ['CalendarEvent/get', { list }, '0'], + ['CalendarEvent/get', { list: [] }, '1'], + ], + }; +} + beforeEach(() => { vi.clearAllMocks(); resetSyntheticIdSupport(); @@ -182,7 +193,7 @@ describe('calendar operations', () => { describe('getEvents for expanded occurrences', () => { it('asks for the base event id only then', async () => { - mockRequest.mockResolvedValue({ methodResponses: [['CalendarEvent/get', { list: [] }, '0']] }); + mockRequest.mockResolvedValue(getAnswer([])); await getEvents(['a'], undefined, { expanded: true }); await getEvents(['a']); @@ -294,9 +305,7 @@ describe('calendar operations', () => { // single object) — requesting the RFC 8984 plural form returns no // recurrence data and repeating events vanish from the grid (#13). const events = [{ id: 'ev1', title: 'Meeting', start: '2026-03-29T10:00:00' }]; - mockRequest.mockResolvedValue({ - methodResponses: [['CalendarEvent/get', { list: events }, '0']], - }); + mockRequest.mockResolvedValue(getAnswer(events)); const result = await getEvents(['ev1']); expect(result).toEqual(events); @@ -318,9 +327,7 @@ describe('calendar operations', () => { recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly', interval: 5 }, excludedRecurrenceRule: { frequency: 'weekly', interval: 10 }, }]; - mockRequest.mockResolvedValue({ - methodResponses: [['CalendarEvent/get', { list: events }, '0']], - }); + mockRequest.mockResolvedValue(getAnswer(events)); const [event] = await getEvents(['ev1']); expect(event.recurrenceRules).toEqual([ @@ -340,9 +347,7 @@ describe('calendar operations', () => { start: '2026-03-02T10:00:00', recurrenceRule: [{ frequency: 'weekly', interval: 2 }], }]; - mockRequest.mockResolvedValue({ - methodResponses: [['CalendarEvent/get', { list: events }, '0']], - }); + mockRequest.mockResolvedValue(getAnswer(events)); const [event] = await getEvents(['ev1']); expect(event.recurrenceRules).toEqual([{ frequency: 'weekly', interval: 2 }]); @@ -357,9 +362,7 @@ describe('calendar operations', () => { { id: 't5', progress: 'CANCELLED' }, { id: 't6', progress: 'needs-action' }, ]; - mockRequest.mockResolvedValue({ - methodResponses: [['CalendarEvent/get', { list }, '0']], - }); + mockRequest.mockResolvedValue(getAnswer(list)); const result = await getEvents(list.map((t) => t.id)); expect(result.map((t) => t.progress)).toEqual([ @@ -381,9 +384,7 @@ describe('calendar operations', () => { { id: 't5', progress: null }, { id: 'ev1', title: 'Event' }, ]; - mockRequest.mockResolvedValue({ - methodResponses: [['CalendarEvent/get', { list }, '0']], - }); + mockRequest.mockResolvedValue(getAnswer(list)); const result = await getEvents(list.map((t) => t.id)); expect(result.map((t) => t.progress as unknown)).toEqual([ @@ -481,9 +482,7 @@ describe('calendar operations', () => { .mockResolvedValueOnce({ methodResponses: [['CalendarEvent/query', { ids: ['ev9'] }, '0']], }) - .mockResolvedValueOnce({ - methodResponses: [['CalendarEvent/get', { list: [{ id: 'ev9', uid: 'inv@example.com' }] }, '0']], - }); + .mockResolvedValueOnce(getAnswer([{ id: 'ev9', uid: 'inv@example.com' }])); const found = await findEventsByUid('inv@example.com'); diff --git a/src/api/__tests__/stalwart-calendar-mock.ts b/src/api/__tests__/stalwart-calendar-mock.ts new file mode 100644 index 00000000..e2eeb17f --- /dev/null +++ b/src/api/__tests__/stalwart-calendar-mock.ts @@ -0,0 +1,206 @@ +// A mock Stalwart calendar server for tests that run the real calendar api +// (src/api/calendar.ts) against `jmapClient.request`. What differs between +// the versions, as far as these tests go: +// - 1.0 refuses a CalendarEvent/get that asks for `recurrenceOverrides` +// together with `utcStart`/`utcEnd` (jmap/src/calendar_event/get.rs, +// ProjectionRequest::new), before reading any id; 0.16 answers any list. +// - 1.0 returns `recurrenceOverrides: null` for a synthetic occurrence id. +// Every method call is recorded with the request it came in. + +import type { JMAPMethodCall } from '../types'; +import { SYNTHETIC_ID_PROBE } from '../../lib/recurrence-instances'; + +export type StalwartVersion = '0.16' | '1.0'; + +/** Stalwart 1.0's answer to a CalendarEvent/get that asks for both. */ +export const OVERRIDES_WITH_UTC_ERROR = { + type: 'invalidArguments', + description: 'recurrenceOverrides cannot be requested together with utcStart or utcEnd', +} as const; + +/** True for a CalendarEvent/get Stalwart 1.0 refuses outright. */ +export function asksForOverridesWithUtc(args: Record): boolean { + const properties = args.properties; + if (!Array.isArray(properties)) return false; + return properties.includes('recurrenceOverrides') + && (properties.includes('utcStart') || properties.includes('utcEnd')); +} + +export interface RecordedCall { + method: string; + args: Record; + callId: string; + /** Index of the request the call was part of. */ + request: number; +} + +export type MethodAnswer = [string, Record]; + +export interface StalwartCalendarMockOptions { + version: StalwartVersion; + /** Stored events and tasks, by id. */ + objects?: Record>; + /** What CalendarEvent/query with expandRecurrences hands out, by synthetic id. */ + occurrences?: Record>; + /** Calendars Calendar/get lists. */ + calendars?: Record[]; + /** Whether CalendarEvent/set takes synthetic ids (the probe answers notFound). Default true. */ + syntheticIds?: boolean; + /** Answer a call yourself; return undefined for the default answer. */ + answer?: (call: RecordedCall) => MethodAnswer | undefined; +} + +export interface StalwartCalendarMock { + /** Stands in for `jmapClient.request`. */ + request: (calls: JMAPMethodCall[], using?: string[]) => Promise<{ methodResponses: [string, Record, string][] }>; + calls: RecordedCall[]; + /** Every CalendarEvent/get sent so far. */ + gets(): RecordedCall[]; + queries(): RecordedCall[]; + sets(): RecordedCall[]; + objects: Record>; +} + +function project(object: Record, properties: unknown): Record { + if (!Array.isArray(properties)) return { ...object }; + const out: Record = { id: object.id }; + for (const key of properties as string[]) { + if (key in object) out[key] = object[key]; + } + return out; +} + +/** The calendar ids an `inCalendar` filter (one, or an OR of them) names; null without one. */ +function calendarsOf(filter: Record | undefined): Set | null { + if (!filter) return null; + if (typeof filter.inCalendar === 'string') return new Set([filter.inCalendar]); + if (Array.isArray(filter.conditions)) { + const ids = new Set(); + let any = false; + for (const condition of filter.conditions as Record[]) { + const inner = calendarsOf(condition); + if (!inner) continue; + any = true; + for (const id of inner) ids.add(id); + } + return any ? ids : null; + } + return null; +} + +/** Point `jmapClient.request` at a mock Stalwart calendar server. */ +export function createStalwartCalendarMock(options: StalwartCalendarMockOptions): StalwartCalendarMock { + const calls: RecordedCall[] = []; + const objects: Record> = { ...options.objects }; + const occurrences = { ...options.occurrences }; + let requests = 0; + let created = 0; + + const defaultAnswer = (call: RecordedCall): MethodAnswer => { + const { method, args } = call; + if (method === 'Calendar/get') { + return ['Calendar/get', { accountId: args.accountId, list: options.calendars ?? [{ id: 'cal', name: 'Personal', isDefault: true }], notFound: [] }]; + } + if (method === 'CalendarEvent/query') { + const filter = args.filter as Record | undefined; + let ids = args.expandRecurrences ? Object.keys(occurrences) : Object.keys(objects); + if (typeof filter?.uid === 'string') ids = ids.filter((id) => objects[id]?.uid === filter.uid); + const wanted = calendarsOf(filter); + if (wanted) { + const source = args.expandRecurrences ? occurrences : objects; + ids = ids.filter((id) => Object.keys((source[id]?.calendarIds ?? {}) as object).some((c) => wanted.has(c))); + } + const position = (args.position as number | undefined) ?? 0; + const limit = (args.limit as number | undefined) ?? ids.length; + return ['CalendarEvent/query', { accountId: args.accountId, ids: ids.slice(position, position + limit), position }]; + } + if (method === 'CalendarEvent/get') { + if (options.version === '1.0' && asksForOverridesWithUtc(args)) return ['error', { ...OVERRIDES_WITH_UTC_ERROR }]; + const list: Record[] = []; + const notFound: string[] = []; + for (const id of (args.ids as string[] | null) ?? Object.keys(objects)) { + const occurrence = occurrences[id]; + const object = occurrence ?? objects[id]; + if (!object) { + notFound.push(id); + continue; + } + const projected = project(object, args.properties); + if (options.version === '1.0' && occurrence && Array.isArray(args.properties) + && (args.properties as string[]).includes('recurrenceOverrides')) { + projected.recurrenceOverrides = null; + } + list.push(projected); + } + return ['CalendarEvent/get', { accountId: args.accountId, list, notFound }]; + } + if (method === 'CalendarEvent/set') { + const update = args.update as Record> | undefined; + if (update && SYNTHETIC_ID_PROBE in update) { + const failure = options.syntheticIds === false + ? { type: 'invalidProperties', description: 'Updating synthetic ids is not yet supported.', properties: ['id'] } + : { type: 'notFound' }; + return ['CalendarEvent/set', { notUpdated: { [SYNTHETIC_ID_PROBE]: failure } }]; + } + const result: Record = {}; + const create = args.create as Record> | undefined; + if (create) { + const createdMap: Record = {}; + for (const [key, value] of Object.entries(create)) { + const id = `created-${++created}`; + objects[id] = { ...value, id }; + createdMap[key] = { id }; + } + result.created = createdMap; + } + if (update) { + for (const [id, patch] of Object.entries(update)) objects[id] = { ...objects[id], ...patch }; + result.updated = Object.fromEntries(Object.keys(update).map((id) => [id, null])); + } + const destroy = args.destroy as string[] | undefined; + if (destroy) { + for (const id of destroy) delete objects[id]; + result.destroyed = destroy; + } + return ['CalendarEvent/set', result]; + } + return ['error', { type: 'unknownMethod' }]; + }; + + const request = async (methodCalls: JMAPMethodCall[]) => { + const index = requests++; + const methodResponses: [string, Record, string][] = []; + for (const [method, args, callId] of methodCalls) { + const call: RecordedCall = { method, args: args as Record, callId, request: index }; + calls.push(call); + const [name, body] = options.answer?.(call) ?? defaultAnswer(call); + methodResponses.push([name, body, callId]); + } + return { methodResponses }; + }; + + return { + request, + calls, + gets: () => calls.filter((c) => c.method === 'CalendarEvent/get'), + queries: () => calls.filter((c) => c.method === 'CalendarEvent/query'), + sets: () => calls.filter((c) => c.method === 'CalendarEvent/set'), + objects, + }; +} + +/** The calendars account capability both versions advertise (0.16 hardcodes 365 days and never enforces it). */ +export function calendarAccountCapability( + version: StalwartVersion, + maxExpandedQueryDuration: string | null = 'P52W1D', +): Record { + const capability: Record = { + maxCalendarsPerEvent: version === '1.0' ? 10 : null, + minDateTime: '0001-01-01T00:00:00Z', + maxDateTime: '9999-12-31T23:59:59Z', + maxParticipantsPerEvent: 20, + mayCreateCalendar: true, + }; + if (maxExpandedQueryDuration !== null) capability.maxExpandedQueryDuration = maxExpandedQueryDuration; + return capability; +} diff --git a/src/api/calendar.ts b/src/api/calendar.ts index 195d610a..18b256d7 100644 --- a/src/api/calendar.ts +++ b/src/api/calendar.ts @@ -1,7 +1,7 @@ import { jmapClient } from './jmap-client'; import { CAPABILITIES } from './types'; import type { Calendar, CalendarEvent, CalendarRights } from './types'; -import { assertSetResult } from './jmap-result'; +import { assertSetResult, batched, jmapErrorType, requireMethodResult } from './jmap-result'; import { getEffectiveTimeZone } from '../lib/calendar-timezone'; import { SCAN_PROPERTIES, type ScannedCalendarObject } from '../lib/calendar-component-detection'; import { @@ -70,7 +70,7 @@ export function resetCalendarAccessDenied(): void { } function isAccessDeniedError(err: unknown): boolean { - const type = (err as { jmapErrorType?: string } | null)?.jmapErrorType; + const type = jmapErrorType(err); return type === 'forbidden' || type === 'accountNotFound'; } @@ -103,22 +103,34 @@ function buildInCalendarFilter(calendarIds: string[]): Record { // object, not RFC 8984's plural array forms. Requesting the plural names // returns no recurrence data at all, so recurring events silently render as // one-off events (#13). Request the singular names and normalize below. +// +// No `recurrenceOverrides`: Stalwart 1.0 fails a CalendarEvent/get that asks +// for them together with `utcStart` or `utcEnd` (invalidArguments, before any +// id is read). getEvents reads them with a get of their own. const CALENDAR_EVENT_PROPERTIES = [ 'id', '@type', 'uid', 'calendarIds', 'title', 'description', 'start', 'duration', 'timeZone', 'showWithoutTime', 'utcStart', 'utcEnd', 'status', 'freeBusyStatus', 'participants', 'alerts', 'useDefaultAlerts', 'recurrenceRule', - 'recurrenceOverrides', 'excludedRecurrenceRule', 'recurrenceId', + 'excludedRecurrenceRule', 'recurrenceId', 'replyTo', 'organizerCalendarAddress', 'sequence', 'locations', 'virtualLocations', 'progress', 'due', 'priority', 'percentComplete', 'links', 'created', 'updated', ]; +/** The second get of getEvents: the overrides, by id. */ +const OVERRIDE_PROPERTIES = ['id', 'recurrenceOverrides']; + // Asked for only on server-expanded ranges: servers that expand recurrences -// for the app (see supportsSyntheticCalendarIds) know these. +// for the app (see supportsSyntheticCalendarIds) know these. An occurrence +// gets its series' overrides from hydrateExpandedOccurrences instead. const EXPANDED_EVENT_PROPERTIES = [...CALENDAR_EVENT_PROPERTIES, 'baseEventId', 'recurrenceIdTimeZone']; +// What a UID look-up compares (import de-duplication, iCal subscription sync): +// reading whole events of a whole account for it would be wasted. +const EVENT_IDENTITY_PROPERTIES = ['id', 'uid', 'calendarIds']; + // Client-side fields that must never reach CalendarEvent/set. const CLIENT_ONLY_EVENT_KEYS = [ 'originalId', 'baseEventId', 'originalCalendarIds', 'accountId', 'isShared', 'localAccountId', @@ -225,20 +237,14 @@ function cleanRecurrenceRules(event: Record): void { } } -function methodResult(res: any, index = 0): T { - const entry = res?.methodResponses?.[index]; - if (!entry) throw new Error('JMAP: empty method response'); - if (entry[0] === 'error') { - const err = entry[1] || {}; - // Keep the JMAP error type so callers can tell an expected access - // rejection (forbidden / accountNotFound on a shared account) apart from - // a genuine failure. - throw Object.assign( - new Error(err.description || err.type || 'JMAP method error'), - { jmapErrorType: err.type as string | undefined }, - ); - } - return entry[1] as T; +/** + * The answer to call `callId` (the first call when omitted). A method error + * throws a JMAPMethodError with the JMAP type, so callers can tell an + * expected access rejection (forbidden / accountNotFound on a shared + * account) apart from a genuine failure. + */ +function methodResult(res: any, callId?: string): T { + return requireMethodResult(res, callId); } // Other session accounts that expose the calendars capability hold calendars @@ -485,26 +491,56 @@ export async function hydrateExpandedOccurrences( )); if (baseIds.length === 0) return events; const account = accountId || jmapClient.accountId; - const timeZone = getUserTimeZone(); - const batchSize = jmapClient.getMaxObjectsInGet(); const bases = new Map>(); - for (let i = 0; i < baseIds.length; i += batchSize) { - const res = await jmapClient.request( - [['CalendarEvent/get', { - accountId: account, - ids: baseIds.slice(i, i + batchSize), - properties: [...RECURRENCE_BASE_PROPERTIES], - ...(timeZone ? { timeZone } : {}), - }, '0']], - USING, - ); - for (const base of methodResult<{ list: Partial[] }>(res).list ?? []) { - if (base.id) bases.set(base.id, normalizeRecurrenceProperties(base)); - } + const list = await fetchEventsById(account, baseIds, RECURRENCE_BASE_PROPERTIES, { + timeZone: getUserTimeZone(), + }); + for (const base of list as Partial[]) { + if (base.id) bases.set(base.id, normalizeRecurrenceProperties(base)); } return hydrateRecurrenceInstances(events, bases); } +/** + * CalendarEvent/get of `ids`, batched by maxObjectsInGet. With + * `withOverrides`, every request carries a second get of OVERRIDE_PROPERTIES + * and its overrides are merged in by id: Stalwart 1.0 refuses + * `recurrenceOverrides` in the same get as `utcStart`/`utcEnd`. An error + * answer to either get fails the whole read - taken as an empty list, it + * would pass for "no events". + */ +async function fetchEventsById( + accountId: string, + ids: string[], + properties: readonly string[], + options: { withOverrides?: boolean; timeZone?: string } = {}, +): Promise[]> { + const { withOverrides = false, timeZone } = options; + const objects: Record[] = []; + for (const batch of batched(ids, jmapClient.getMaxObjectsInGet())) { + const args = { accountId, ids: batch, ...(timeZone ? { timeZone } : {}) }; + const calls: [string, Record, string][] = [ + ['CalendarEvent/get', { ...args, properties: [...properties] }, '0'], + ]; + if (withOverrides) { + calls.push(['CalendarEvent/get', { ...args, properties: [...OVERRIDE_PROPERTIES] }, '1']); + } + const res = await jmapClient.request(calls, USING); + const list = methodResult<{ list?: Record[] }>(res, '0').list ?? []; + if (withOverrides) { + const overrides = new Map(); + for (const entry of methodResult<{ list?: Record[] }>(res, '1').list ?? []) { + if ('recurrenceOverrides' in entry) overrides.set(entry.id, entry.recurrenceOverrides); + } + for (const object of list) { + if (overrides.has(object.id)) object.recurrenceOverrides = overrides.get(object.id); + } + } + objects.push(...list); + } + return objects; +} + /** * Every calendar object of an account with just the properties needed to tell * tasks from events (see lib/calendar-component-detection). Used to find @@ -541,32 +577,35 @@ export async function scanCalendarObjects(accountId?: string): Promise { if (ids.length === 0) return []; const account = accountId || jmapClient.accountId; - const timeZone = getUserTimeZone(); - const batchSize = jmapClient.getMaxObjectsInGet(); - const all: CalendarEvent[] = []; - for (let i = 0; i < ids.length; i += batchSize) { - const batch = ids.slice(i, i + batchSize); - const res = await jmapClient.request( - [['CalendarEvent/get', { - accountId: account, - ids: batch, - properties: options?.expanded ? EXPANDED_EVENT_PROPERTIES : CALENDAR_EVENT_PROPERTIES, - ...(timeZone ? { timeZone } : {}), - }, '0']], - USING, - ); - const list = methodResult<{ list: CalendarEvent[] }>(res).list ?? []; - all.push(...list.map((e) => normalizeTaskProgress(normalizeRecurrenceProperties(e)))); - } - return all; + const list = await fetchEventsById( + account, + ids, + options?.expanded ? EXPANDED_EVENT_PROPERTIES : CALENDAR_EVENT_PROPERTIES, + { withOverrides: !options?.expanded, timeZone: getUserTimeZone() }, + ); + return (list as unknown as CalendarEvent[]).map((e) => normalizeTaskProgress(normalizeRecurrenceProperties(e))); +} + +export type EventIdentityFields = Pick; + +/** Just the id, uid and calendars of `ids`: what a UID look-up compares. */ +export async function getEventIdentities(ids: string[], accountId?: string): Promise { + if (ids.length === 0) return []; + const account = accountId || jmapClient.accountId; + return (await fetchEventsById(account, ids, EVENT_IDENTITY_PROPERTIES)) as unknown as EventIdentityFields[]; } /** diff --git a/src/api/jmap-result.ts b/src/api/jmap-result.ts index 9398583c..59b62be0 100644 --- a/src/api/jmap-result.ts +++ b/src/api/jmap-result.ts @@ -20,6 +20,15 @@ export class JMAPMethodError extends Error { } } +/** + * The JMAP error type a thrown value carries: a method error, or a SetError + * thrown as a `JMAPMethodError`. Callers branch on it instead of the + * description, which differs between servers and Stalwart releases. + */ +export function jmapErrorType(err: unknown): string | undefined { + return err instanceof JMAPMethodError ? err.type : undefined; +} + /** * Pull the response body for `callId` (or the first response when omitted) * and throw a `JMAPMethodError` when the server answered with the error diff --git a/src/stores/__tests__/calendar-store-extend-range.test.ts b/src/stores/__tests__/calendar-store-extend-range.test.ts index 7f3ba09e..ba780ed5 100644 --- a/src/stores/__tests__/calendar-store-extend-range.test.ts +++ b/src/stores/__tests__/calendar-store-extend-range.test.ts @@ -4,6 +4,7 @@ vi.mock('../../api/calendar', () => ({ getCalendars: vi.fn(), queryEvents: vi.fn(), getEvents: vi.fn(), + getEventIdentities: vi.fn(async () => []), scanCalendarObjects: vi.fn(), isCalendarAccessDenied: vi.fn(() => false), noteCalendarAccessError: vi.fn(() => false), diff --git a/src/stores/__tests__/calendar-store-server-reads.test.ts b/src/stores/__tests__/calendar-store-server-reads.test.ts new file mode 100644 index 00000000..3015e35d --- /dev/null +++ b/src/stores/__tests__/calendar-store-server-reads.test.ts @@ -0,0 +1,207 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// The real calendar api and store, against a mock Stalwart server +// (src/api/__tests__/stalwart-calendar-mock.ts). Stalwart 1.0 refuses a +// CalendarEvent/get that asks for recurrenceOverrides next to utcStart or +// utcEnd, and every read of the app did: the grid, tasks, reminders, the +// master of a series, the invitation look-up and the iCal subscription sync. + +vi.mock('../../api/jmap-client', () => ({ + jmapClient: { + accountId: 'acc-1', + isConnected: true, + request: vi.fn(), + getMaxObjectsInGet: () => 500, + getMaxCallsInRequest: () => 16, + }, +})); +vi.mock('../../api/blob', () => ({ uploadBytes: vi.fn(async () => ({ blobId: 'feed-blob' })) })); + +import { jmapClient } from '../../api/jmap-client'; +import type { CalendarEvent } from '../../api/types'; +import { loadEventsInRange, useCalendarStore } from '../calendar-store'; +import { useCalendarSubscriptionsStore } from '../calendar-subscriptions-store'; +import { + asksForOverridesWithUtc, + createStalwartCalendarMock, + type StalwartCalendarMock, + type StalwartCalendarMockOptions, + type StalwartVersion, +} from '../../api/__tests__/stalwart-calendar-mock'; + +const mockRequest = jmapClient.request as ReturnType; + +const RANGE = { after: '2026-09-01T00:00:00.000Z', before: '2026-10-01T00:00:00.000Z' }; +const CALENDARS = [{ id: 'cal', name: 'Personal', isDefault: true }, { id: 'work', name: 'Work' }]; + +const SERIES = { + id: 'b', '@type': 'Event', uid: 'series@example.com', title: 'Daily standup', baseEventId: 'b', + start: '2026-09-07T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin', + utcStart: '2026-09-07T08:00:00Z', utcEnd: '2026-09-07T09:00:00Z', showWithoutTime: false, + recurrenceRule: { frequency: 'daily', count: 5 }, + recurrenceOverrides: { '2026-09-10T10:00:00': { excluded: true } }, + calendarIds: { cal: true }, +}; +const SINGLE = { + id: 'd', '@type': 'Event', uid: 'single@example.com', title: 'One off', baseEventId: 'd', + start: '2026-09-08T09:00:00', duration: 'PT30M', timeZone: 'Europe/Berlin', + utcStart: '2026-09-08T07:00:00Z', utcEnd: '2026-09-08T07:30:00Z', calendarIds: { work: true }, +}; +const TASK = { + id: 't', '@type': 'Task', uid: 'task@example.com', title: 'Pay rent', due: '2026-09-10T00:00:00', + progress: 'needs-action', calendarIds: { cal: true }, +}; +const occurrenceOf = (id: string, day: string) => ({ + ...SERIES, id, baseEventId: 'b', recurrenceRule: null, recurrenceOverrides: null, + start: `2026-09-${day}T10:00:00`, utcStart: `2026-09-${day}T08:00:00Z`, utcEnd: `2026-09-${day}T09:00:00Z`, + recurrenceId: `2026-09-${day}T10:00:00`, recurrenceIdTimeZone: 'Europe/Berlin', +}); +const OCCURRENCES = { + maaaaab: occurrenceOf('maaaaab', '07'), + maaaaac: occurrenceOf('maaaaac', '08'), + eaaaaad: { ...SINGLE, id: 'eaaaaad', baseEventId: 'd' }, +}; + +describe.each(['0.16', '1.0'])('calendar reads through the store on Stalwart %s', (version) => { + let server: StalwartCalendarMock; + + function serve(options: Partial = {}): void { + server = createStalwartCalendarMock({ + version, + calendars: CALENDARS, + objects: { b: SERIES, d: SINGLE, t: TASK }, + occurrences: OCCURRENCES, + ...options, + }); + mockRequest.mockImplementation(server.request); + } + + beforeEach(() => { + vi.clearAllMocks(); + useCalendarStore.getState().reset(); + useCalendarStore.setState({ calendars: CALENDARS as never, hiddenCalendarIds: [] }); + serve(); + }); + + afterEach(() => { + // The invariant: no get ever asks for both. + expect(server.gets().filter((call) => asksForOverridesWithUtc(call.args))).toEqual([]); + }); + + it(`loads a range the server expands, with the series context of its occurrences, on Stalwart ${version}`, async () => { + await useCalendarStore.getState().fetchEvents(['cal', 'work'], RANGE.after, RANGE.before); + + const { events, error } = useCalendarStore.getState(); + expect(error).toBeNull(); + expect(events.map((e) => e.id)).toEqual(['maaaaab', 'maaaaac', 'eaaaaad']); + expect(events[0].recurrenceRules).toEqual([{ frequency: 'daily', count: 5 }]); + expect(events[0].recurrenceOverrides).toEqual({ '2026-09-10T10:00:00': { excluded: true } }); + }); + + it(`expands a series on the device from a read that carries its overrides on Stalwart ${version}`, async () => { + serve({ syntheticIds: false }); + + await useCalendarStore.getState().fetchEvents(['cal', 'work'], RANGE.after, RANGE.before); + + const { events, error } = useCalendarStore.getState(); + expect(error).toBeNull(); + const standups = events.filter((e) => e.originalId === 'b').map((e) => e.recurrenceId); + // The override excludes the fourth day. + expect(standups).toEqual([ + '2026-09-07T10:00:00', '2026-09-08T10:00:00', '2026-09-09T10:00:00', '2026-09-11T10:00:00', + ]); + expect(events.some((e) => e.id === 'd')).toBe(true); + }); + + it(`loads the reminders' window on Stalwart ${version}`, async () => { + const upcoming = await loadEventsInRange(CALENDARS as never, ['cal', 'work'], RANGE.after, RANGE.before); + expect(upcoming.map((e) => e.id)).toEqual(['maaaaab', 'maaaaac', 'eaaaaad']); + }); + + it(`loads tasks on Stalwart ${version}`, async () => { + await useCalendarStore.getState().fetchTasks(); + + const { tasks, error } = useCalendarStore.getState(); + expect(error).toBeNull(); + expect(tasks.map((task) => [task.id, task.due])).toEqual([['t', '2026-09-10T00:00:00']]); + }); + + it(`fetches the master of an occurrence for an edit of all events on Stalwart ${version}`, async () => { + const occurrence = { ...OCCURRENCES.maaaaac, recurrenceRules: [{ frequency: 'daily' }] } as unknown as CalendarEvent; + + const master = await useCalendarStore.getState().getMasterEvent(occurrence); + + expect(master?.id).toBe('b'); + expect(master?.start).toBe('2026-09-07T10:00:00'); + expect(master?.recurrenceOverrides).toEqual({ '2026-09-10T10:00:00': { excluded: true } }); + }); + + it(`imports events without creating the ones already there on Stalwart ${version}`, async () => { + const result = await useCalendarStore.getState().importEvents([ + // Already in this calendar: skipped. + { uid: 'series@example.com', title: 'Daily standup', start: '2026-09-07T10:00:00' }, + // In another calendar: linked into this one. + { uid: 'single@example.com', title: 'One off', start: '2026-09-08T09:00:00' }, + { uid: 'new@example.com', title: 'New', start: '2026-09-12T09:00:00' }, + ], 'cal'); + + expect(result).toEqual({ imported: 2, refused: [] }); + const writes = server.sets().filter((call) => !(call.args.update as Record | undefined)?.h333333); + const created = writes.flatMap((call) => Object.values((call.args.create ?? {}) as Record)); + expect(created.map((event) => event.uid)).toEqual(['new@example.com']); + expect(server.objects.d.calendarIds).toEqual({ work: true, cal: true }); + // The UID look-up reads ids, UIDs and calendars only. + expect(server.gets()[0].args.properties).toEqual(['id', 'uid', 'calendarIds']); + }); + + it(`removes what a subscribed feed dropped and adds only what is new on Stalwart ${version}`, async () => { + serve({ + objects: { + old: { id: 'old', uid: 'old@feed', title: 'Gone upstream', start: '2026-09-02T09:00:00', calendarIds: { feed: true } }, + kept: { id: 'kept', uid: 'kept@feed', title: 'Still there', start: '2026-09-03T09:00:00', calendarIds: { feed: true } }, + }, + answer: (call) => (call.method === 'CalendarEvent/parse' + ? ['CalendarEvent/parse', { parsed: { 'feed-blob': [ + { uid: 'kept@feed', title: 'Still there', start: '2026-09-03T09:00:00' }, + { uid: 'new@feed', title: 'New upstream', start: '2026-09-04T09:00:00' }, + ] } }] + : undefined), + }); + useCalendarStore.setState({ calendars: [...CALENDARS, { id: 'feed', name: 'Feed' }] as never }); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + headers: { get: () => null }, + text: async () => 'BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n', + } as unknown as Response); + useCalendarSubscriptionsStore.setState({ + subscriptions: [{ id: 'sub', name: 'Feed', url: 'https://example.com/feed.ics', calendarId: 'feed', lastSyncAt: null, lastError: null }], + }); + + try { + await useCalendarSubscriptionsStore.getState().syncSubscription('sub'); + } finally { + fetchSpy.mockRestore(); + } + + expect(useCalendarSubscriptionsStore.getState().subscriptions[0].lastError).toBeNull(); + expect(Object.keys(server.objects).sort()).toEqual(['created-1', 'kept']); + expect(server.objects['created-1'].uid).toBe('new@feed'); + }); + + it(`keeps the loaded events and reports the error when the server refuses a range on Stalwart ${version}`, async () => { + await useCalendarStore.getState().fetchEvents(['cal', 'work'], RANGE.after, RANGE.before); + const loaded = useCalendarStore.getState().events; + serve({ + answer: (call) => (call.method === 'CalendarEvent/get' + ? ['error', { type: 'serverFail', description: 'Internal server error' }] + : undefined), + }); + + await useCalendarStore.getState().refresh(); + + const { events, error } = useCalendarStore.getState(); + expect(events).toEqual(loaded); + expect(error).toBe('Internal server error'); + }); +}); diff --git a/src/stores/__tests__/calendar-store.test.ts b/src/stores/__tests__/calendar-store.test.ts index 69be0890..80477afd 100644 --- a/src/stores/__tests__/calendar-store.test.ts +++ b/src/stores/__tests__/calendar-store.test.ts @@ -4,6 +4,7 @@ vi.mock('../../api/calendar', () => ({ getCalendars: vi.fn(), queryEvents: vi.fn(), getEvents: vi.fn(), + getEventIdentities: vi.fn(), scanCalendarObjects: vi.fn(), isCalendarAccessDenied: vi.fn(() => false), noteCalendarAccessError: vi.fn(() => false), @@ -46,6 +47,7 @@ import { const mockGetCalendars = calendarApi.getCalendars as ReturnType; const mockQueryEvents = calendarApi.queryEvents as ReturnType; const mockGetEvents = calendarApi.getEvents as ReturnType; +const mockGetIdentities = calendarApi.getEventIdentities as ReturnType; const mockCreateEvent = calendarApi.createEvent as ReturnType; const mockUpdateEvent = calendarApi.updateEvent as ReturnType; const mockDeleteEvents = calendarApi.deleteEvents as ReturnType; @@ -59,6 +61,7 @@ const mockHydrate = calendarApi.hydrateExpandedOccurrences as ReturnType { vi.clearAllMocks(); mockScan.mockResolvedValue([]); + mockGetIdentities.mockResolvedValue([]); useCalendarStore.setState({ calendars: [], events: [], @@ -595,7 +598,7 @@ describe('calendar-store', () => { loadedRange: { after: '2026-03-01T00:00:00Z', before: '2026-03-31T00:00:00Z' }, }); mockQueryEvents.mockResolvedValue(['e1', 'e2']); - mockGetEvents.mockResolvedValue([ + mockGetIdentities.mockResolvedValue([ { id: 'e1', uid: 'uid-elsewhere', calendarIds: { 'cal-1': true } }, { id: 'e2', uid: 'uid-here', calendarIds: { 'cal-2': true } }, ]); @@ -677,7 +680,7 @@ describe('calendar-store', () => { it('reports a link into the target calendar that the server refused', async () => { mockQueryEvents.mockResolvedValue(['e1']); - mockGetEvents.mockResolvedValue([{ id: 'e1', uid: 'shared', calendarIds: { 'cal-1': true } }]); + mockGetIdentities.mockResolvedValue([{ id: 'e1', uid: 'shared', calendarIds: { 'cal-1': true } }]); mockUpdateEvent.mockRejectedValue(new Error('forbidden')); await expect( @@ -712,7 +715,7 @@ describe('calendar-store', () => { it('resolves with nothing imported and nothing refused when every event is already there', async () => { mockQueryEvents.mockResolvedValue(['e1']); - mockGetEvents.mockResolvedValue([{ id: 'e1', uid: 'dup', calendarIds: { 'cal-1': true } }]); + mockGetIdentities.mockResolvedValue([{ id: 'e1', uid: 'dup', calendarIds: { 'cal-1': true } }]); await expect( useCalendarStore.getState().importEvents([{ uid: 'dup', title: 'Dup' }], 'cal-1'), diff --git a/src/stores/__tests__/calendar-subscriptions-store.test.ts b/src/stores/__tests__/calendar-subscriptions-store.test.ts index d3afab79..96fd409a 100644 --- a/src/stores/__tests__/calendar-subscriptions-store.test.ts +++ b/src/stores/__tests__/calendar-subscriptions-store.test.ts @@ -5,7 +5,7 @@ vi.mock('../../api/calendar', () => ({ deleteCalendar: vi.fn(), parseCalendarBlob: vi.fn(), queryEvents: vi.fn(), - getEvents: vi.fn(), + getEventIdentities: vi.fn(), deleteEvents: vi.fn(), updateEvent: vi.fn(), updateCalendar: vi.fn(), diff --git a/src/stores/calendar-store.ts b/src/stores/calendar-store.ts index bfac097a..d6c55f97 100644 --- a/src/stores/calendar-store.ts +++ b/src/stores/calendar-store.ts @@ -7,6 +7,7 @@ import type { Calendar, CalendarEvent, CalendarRights, Participant, StateChange import { normalizeAllDayDuration } from '../lib/calendar-utils'; import { type CalendarUpdates, + type EventIdentityFields, updateCalendar as apiUpdateCalendar, deleteCalendar as apiDeleteCalendar, clearCalendarEvents as apiClearCalendarEvents, @@ -14,6 +15,7 @@ import { getCalendars as fetchCalendars, queryEvents, getEvents as fetchEvents, + getEventIdentities, scanCalendarObjects, isCalendarAccessDenied, noteCalendarAccessError, @@ -948,8 +950,8 @@ export const useCalendarStore = create()( let linked = 0; try { const existingIds = await queryEvents([], '', '', accountId); - const existing = existingIds.length > 0 ? await fetchEvents(existingIds, accountId) : []; - const byUid = new Map(); + const existing = await getEventIdentities(existingIds, accountId); + const byUid = new Map(); for (const e of existing) if (e.uid) byUid.set(e.uid, e); const fresh: Partial[] = []; for (const e of events) { diff --git a/src/stores/calendar-subscriptions-store.ts b/src/stores/calendar-subscriptions-store.ts index 8853757a..78b933a5 100644 --- a/src/stores/calendar-subscriptions-store.ts +++ b/src/stores/calendar-subscriptions-store.ts @@ -7,7 +7,7 @@ import { deleteCalendar, parseCalendarBlob, queryEvents, - getEvents, + getEventIdentities, deleteEvents, updateEvent, updateCalendar, @@ -128,7 +128,7 @@ async function syncFeedIntoCalendar(calendarId: string, url: string): Promise 0 ? await getEvents(ids) : []; + const existing = await getEventIdentities(ids); const stale = existing.filter((e) => e.uid && !parsedUids.has(e.uid)); const toDelete: string[] = []; for (const e of stale) {