From f3e11d4c7c41366c0eab0eff03255501f3cbe8cf Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:54:55 +0100 Subject: [PATCH 1/5] fix: stop asking an account that shares only mail for its calendars on every load on Stalwart 1.0 --- src/api/__tests__/calendar.test.ts | 36 ++++++++++++++++++++++++++++++ src/api/calendar.ts | 4 +++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/api/__tests__/calendar.test.ts b/src/api/__tests__/calendar.test.ts index 645a2729..28b9d1d6 100644 --- a/src/api/__tests__/calendar.test.ts +++ b/src/api/__tests__/calendar.test.ts @@ -28,6 +28,8 @@ import { hydrateExpandedOccurrences, maxParticipantsPerEvent, rsvpEvent, + isCalendarAccessDenied, + resetCalendarAccessDenied, } from '../calendar'; import { JMAPSetError, jmapErrorType } from '../jmap-result'; import { useSettingsStore } from '../../stores/settings-store'; @@ -68,6 +70,40 @@ beforeEach(() => { describe('calendar operations', () => { describe('getCalendars', () => { + // Stalwart 1.0 lists an account that shares only a mail folder with the + // calendars capability and answers its Calendar/get with + // accountNotSupportedByMethod (0.16: forbidden). + it.each(['forbidden', 'accountNotSupportedByMethod'])( + 'asks an account that answers %s for its calendars once per session', + async (type) => { + const client = jmapClient as unknown as { currentSession?: unknown }; + client.currentSession = { + accounts: { + 'acc-1': { accountCapabilities: { 'urn:ietf:params:jmap:calendars': {} } }, + 'acc-mail': { accountCapabilities: { 'urn:ietf:params:jmap:calendars': {} } }, + }, + }; + try { + resetCalendarAccessDenied(); + mockRequest.mockImplementation(async (calls: [string, { accountId: string }, string][]) => ({ + methodResponses: calls.map(([name, args, id]) => (args.accountId === 'acc-mail' + ? ['error', { type }, id] + : [name, name === 'Calendar/get' ? { list: [] } : {}, id])), + })); + await getCalendars(); + await getCalendars(); + const asked = mockRequest.mock.calls.filter(([calls]) => + (calls as [string, { accountId: string }][]).some(([, args]) => args.accountId === 'acc-mail')); + expect(asked).toHaveLength(1); + expect(isCalendarAccessDenied('acc-mail')).toBe(true); + } finally { + client.currentSession = undefined; + mockRequest.mockReset(); + resetCalendarAccessDenied(); + } + }, + ); + it('should fetch calendars', async () => { const calendars = [{ id: 'cal-1', name: 'Personal', color: '#3b82f6' }]; mockRequest.mockResolvedValue({ diff --git a/src/api/calendar.ts b/src/api/calendar.ts index f763d3dd..f1b46df8 100644 --- a/src/api/calendar.ts +++ b/src/api/calendar.ts @@ -160,9 +160,11 @@ export function resetCalendarAccessDenied(): void { calendarAccessDenied.clear(); } +// Stalwart 1.0 answers `accountNotSupportedByMethod` for an account that +// shares only a mail folder with the user, where 0.16 says `forbidden`. function isAccessDeniedError(err: unknown): boolean { const type = jmapErrorType(err); - return type === 'forbidden' || type === 'accountNotFound'; + return type === 'forbidden' || type === 'accountNotFound' || type === 'accountNotSupportedByMethod'; } /** Remember an access rejection for a shared account; returns true when it was one. */ From 7c775571c9784ed97e5b090b3f1fb6532d1c3eb3 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:58:24 +0100 Subject: [PATCH 2/5] fix: load the calendar on Stalwart 0.16 when a range starts or ends in the hour the clocks change --- src/api/__tests__/calendar-dst-bounds.test.ts | 105 ++++++++++++++++++ src/api/calendar.ts | 57 +++++++++- 2 files changed, 158 insertions(+), 4 deletions(-) create mode 100644 src/api/__tests__/calendar-dst-bounds.test.ts diff --git a/src/api/__tests__/calendar-dst-bounds.test.ts b/src/api/__tests__/calendar-dst-bounds.test.ts new file mode 100644 index 00000000..640c0fd2 --- /dev/null +++ b/src/api/__tests__/calendar-dst-bounds.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const h = vi.hoisted(() => ({ session: null as unknown })); + +vi.mock('../jmap-client', () => ({ + jmapClient: { + accountId: 'acc-1', + request: vi.fn(), + getMaxObjectsInGet: () => 500, + getMaxCallsInRequest: () => 16, + get currentSession() { + return h.session; + }, + }, +})); + +import { jmapClient } from '../jmap-client'; +import { queryEvents, queryExpandedEvents, resetTimeZoneChecks } from '../calendar'; +import { useSettingsStore } from '../../stores/settings-store'; + +// Stalwart 0.16 cannot place a range bound whose wall clock names no instant +// in the request's zone (the hour the clocks skip) or two (the hour they +// repeat): an expanded query fails with "Both 'after' and 'before' filters +// are required when expanding recurrences", a plain one drops the bound +// (live on 0.16.27). Such a bound goes out moved out of the range. + +const mockRequest = jmapClient.request as ReturnType; + +function connect(maxExpandedQueryDuration = 'P52W1D'): void { + h.session = { + apiUrl: 'https://mail.example.org/jmap/', + accounts: { 'acc-1': { accountCapabilities: { 'urn:ietf:params:jmap:calendars': { maxExpandedQueryDuration } } } }, + }; + mockRequest.mockImplementation(async (calls: [string, unknown, string][]) => ({ + methodResponses: calls.map(([name, , id]) => [name, { ids: [] }, id]), + })); +} + +const bounds = () => mockRequest.mock.calls.flatMap(([calls]) => + (calls as [string, { filter?: Record }, string][]) + .filter(([name]) => name === 'CalendarEvent/query') + .map(([, args]) => { + const filter = args.filter as { after?: string; before?: string; conditions?: { after?: string; before?: string }[] }; + const range = filter.conditions?.find((c) => 'after' in c) ?? filter; + return [range.after, range.before]; + })); + +beforeEach(() => { + vi.clearAllMocks(); + resetTimeZoneChecks(); +}); + +afterEach(() => { + useSettingsStore.setState({ calendarTimeZone: 'auto' }); + h.session = null; +}); + +describe('range bounds next to a change of the clocks', () => { + // Havana turns the clocks back from 01:00 to 00:00 on 7 November 2027: + // the week from that Sunday starts at a midnight that happens twice. + const HAVANA_WEEK = { after: '2027-11-07T04:00:00.000Z', before: '2027-11-14T05:00:00.000Z' }; + + it('moves an expanded query\'s start out of the hour that repeats', async () => { + useSettingsStore.setState({ calendarTimeZone: 'America/Havana' }); + connect(); + + await queryExpandedEvents(HAVANA_WEEK.after, HAVANA_WEEK.before); + + expect(bounds()).toEqual([['2027-11-06T23:45:00', '2027-11-14T00:00:00']]); + }); + + it('moves a plain query\'s start out of the hour that repeats', async () => { + useSettingsStore.setState({ calendarTimeZone: 'America/Havana' }); + connect(); + + await queryEvents(['cal-1'], HAVANA_WEEK.after, HAVANA_WEEK.before); + + expect(bounds()).toEqual([['2027-11-06T23:45:00', '2027-11-14T00:00:00']]); + }); + + it('keeps chunk borders out of the hour the clocks skip, still overlapping', async () => { + // Santiago skips from 00:00 to 01:00 on 5 September 2027. With a limit + // of two days the chunks are a day long and their borders fall at 00:30. + useSettingsStore.setState({ calendarTimeZone: 'America/Santiago' }); + connect('P2D'); + + await queryExpandedEvents('2027-09-04T04:30:00.000Z', '2027-09-06T04:30:00.000Z'); + + expect(bounds()).toEqual([ + ['2027-09-04T00:30:00', '2027-09-05T01:00:00'], + ['2027-09-04T23:59:59', '2027-09-06T00:29:59'], + ['2027-09-06T00:29:58', '2027-09-06T01:30:00'], + ]); + }); + + it('leaves bounds that name one instant as they are', async () => { + useSettingsStore.setState({ calendarTimeZone: 'Europe/Berlin' }); + connect(); + + // Berlin turns the clocks back at 03:00; its midnights are unique. + await queryExpandedEvents('2027-10-30T22:00:00.000Z', '2027-11-06T23:00:00.000Z'); + + expect(bounds()).toEqual([['2027-10-31T00:00:00', '2027-11-07T00:00:00']]); + }); +}); diff --git a/src/api/calendar.ts b/src/api/calendar.ts index f1b46df8..2b174a14 100644 --- a/src/api/calendar.ts +++ b/src/api/calendar.ts @@ -2,7 +2,7 @@ import { jmapClient } from './jmap-client'; import { CAPABILITIES } from './types'; import type { Calendar, CalendarEvent, CalendarRights } from './types'; import { JMAPSetError, assertSetResult, batched, jmapErrorType, requireMethodResult } from './jmap-result'; -import { getEffectiveTimeZone } from '../lib/calendar-timezone'; +import { formatWallClock, getEffectiveTimeZone, getTimeZoneOffsetMs } from '../lib/calendar-timezone'; import { SCAN_PROPERTIES, type ScannedCalendarObject } from '../lib/calendar-component-detection'; import { RECURRENCE_BASE_PROPERTIES, @@ -509,8 +509,8 @@ export async function queryEvents( const conditions: Record[] = []; if (calendarIds.length > 0) conditions.push(buildInCalendarFilter(calendarIds)); const range: Record = {}; - if (after) range.after = toLocalDateTime(after, timeZone); - if (before) range.before = toLocalDateTime(before, timeZone); + if (after) range.after = placeableLocalBound(toLocalDateTime(after, timeZone), timeZone, -1); + if (before) range.before = placeableLocalBound(toLocalDateTime(before, timeZone), timeZone, 1); if (Object.keys(range).length > 0) conditions.push(range); const filter = conditions.length === 1 ? conditions[0] @@ -589,6 +589,52 @@ function localDateTimeOf(seconds: number): string { + `T${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}`; } +// Stalwart 0.16 cannot place a range bound whose wall clock names no instant +// in the request's time zone (in the hour the clocks skip) or two (in the +// hour they repeat): an expanded query fails with "Both 'after' and 'before' +// filters are required when expanding recurrences", and a plain one leaves +// the bound out (live on 0.16.27; 1.0 places both). Where the clocks change +// at midnight - Havana, the Azores, Santiago - the start of a day is such a +// time, and a chunk border can be one anywhere. Such a bound moves out of +// the range until it names one instant: `after` earlier, `before` later. +const BOUND_STEP = 15 * 60; +const MAX_BOUND_SHIFT = 3 * 3600; + +/** How many instants the wall clock `seconds` (wallClockSeconds) names in `timeZone`. */ +function instantsAt(seconds: number, timeZone: string): number { + const wall = localDateTimeOf(seconds); + // The offsets a day either side: a change of the clocks in between is + // the only one near. + const offsets = new Set([seconds - DAY_SECONDS, seconds + DAY_SECONDS].map( + (at) => getTimeZoneOffsetMs(new Date(at * 1000), timeZone) / 1000, + )); + let count = 0; + for (const offset of offsets) { + if (formatWallClock(new Date((seconds - offset) * 1000), timeZone) === wall) count++; + } + return count; +} + +/** The wall clock `seconds`, or the nearest one in `direction` that names one instant in `timeZone`. */ +function placeableBound(seconds: number, timeZone: string | undefined, direction: -1 | 1): number { + if (!timeZone || timeZone === 'UTC') return seconds; + try { + for (let shift = 0; shift <= MAX_BOUND_SHIFT; shift += BOUND_STEP) { + const candidate = seconds + direction * shift; + if (instantsAt(candidate, timeZone) === 1) return candidate; + } + } catch { + // A zone Intl does not know either: the server has the last word. + } + return seconds; +} + +/** placeableBound of a LocalDateTime; anything else as it is. */ +function placeableLocalBound(value: string, timeZone: string | undefined, direction: -1 | 1): string { + const seconds = wallClockSeconds(value); + return seconds === null ? value : localDateTimeOf(placeableBound(seconds, timeZone, direction)); +} + /** * [start, end] cut into chunks of at most `span` seconds, or null when that * takes more than MAX_EXPANDED_CHUNKS. Neighbours share a second: the server @@ -735,7 +781,10 @@ async function queryExpandedEventsIn( 'CalendarEvent/query', { ...baseArgs, - filter: { after: localDateTimeOf(chunk.start), before: localDateTimeOf(chunk.end) }, + filter: { + after: localDateTimeOf(placeableBound(chunk.start, timeZone, -1)), + before: localDateTimeOf(placeableBound(chunk.end, timeZone, 1)), + }, position: chunk.position, limit: QUERY_PAGE_SIZE, }, From 1f190c9402a4b6e303d38537b41b30b5d22b52b3 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:07:52 +0100 Subject: [PATCH 3/5] fix: keep one account's tasks and events out of another's calendar after an account switch --- src/stores/__tests__/calendar-store.test.ts | 47 +++++++++++++++++++ src/stores/calendar-store.ts | 52 +++++++++++++++++---- 2 files changed, 91 insertions(+), 8 deletions(-) diff --git a/src/stores/__tests__/calendar-store.test.ts b/src/stores/__tests__/calendar-store.test.ts index 8076f3da..be8465dc 100644 --- a/src/stores/__tests__/calendar-store.test.ts +++ b/src/stores/__tests__/calendar-store.test.ts @@ -168,6 +168,53 @@ describe('calendar-store', () => { }); }); + describe('loads a reset overtook (an account switch)', () => { + function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { resolve = r; }); + return { promise, resolve }; + } + + it('keeps the previous account\'s tasks out of the next account\'s list', async () => { + const scan = deferred(); + mockScan.mockReturnValueOnce(scan.promise); + mockGetEvents.mockImplementation(async (ids: string[]) => ids.map((id) => ({ id, title: 'Old account task' }))); + const outgoing = useCalendarStore.getState().fetchTasks(); + + useCalendarStore.getState().reset(); + // The next account's own load is not handed the one still under way. + mockScan.mockResolvedValueOnce([]); + await useCalendarStore.getState().fetchTasks(); + expect(mockScan).toHaveBeenCalledTimes(2); + + scan.resolve([{ id: 't-old', '@type': 'Task', calendarIds: { 'cal-1': true } }]); + await outgoing; + expect(useCalendarStore.getState().tasks).toEqual([]); + }); + + it('keeps the previous account\'s events and calendars out', async () => { + const calendars = deferred(); + mockGetCalendars.mockReturnValueOnce(calendars.promise); + const ids = deferred(); + mockQueryEvents.mockReturnValueOnce(ids.promise); + mockGetEvents.mockResolvedValue([{ id: 'e-old', title: 'Old account event', start: '2026-03-20T15:00:00', calendarIds: { 'cal-1': true } }]); + useCalendarStore.setState({ calendars: [{ id: 'cal-1' }] as never }); + const calendarLoad = useCalendarStore.getState().fetchCalendars(); + const eventLoad = useCalendarStore.getState().fetchEvents(['cal-1'], '2026-03-01T00:00:00Z', '2026-04-01T00:00:00Z'); + + useCalendarStore.getState().reset(); + calendars.resolve([{ id: 'cal-1', name: 'Old account calendar' }]); + ids.resolve(['e-old']); + await Promise.all([calendarLoad, eventLoad]); + + const state = useCalendarStore.getState(); + expect(state.calendars).toEqual([]); + expect(state.events).toEqual([]); + expect(state.loadedRange).toBeNull(); + expect(state.error).toBeNull(); + }); + }); + describe('fetchTasks', () => { it('classifies calendars whose contents are all tasks as task lists (#28)', async () => { // VTODO-only CalDAV collections (Todoist imports, Thunderbird task diff --git a/src/stores/calendar-store.ts b/src/stores/calendar-store.ts index 39eb4430..86df2b92 100644 --- a/src/stores/calendar-store.ts +++ b/src/stores/calendar-store.ts @@ -303,6 +303,21 @@ export interface CalendarState { let calendarsInFlight: Promise | null = null; let tasksInFlight: Promise | null = null; +// One up with every reset (an account switch, a sign-out). A load still under +// way from before keeps its answer to itself: landing after the reset, it put +// the previous account's calendars, events or tasks under the next account +// until the next refresh, and that account's own task load was handed the old +// one still in flight. +let storeGeneration = 0; + +/** A range load the store's reset overtook: what it read belongs to the account before. */ +export class CalendarAccountChangedError extends Error { + constructor() { + super('The calendar was reset while its events were loading'); + this.name = 'CalendarAccountChangedError'; + } +} + function persistHidden(ids: string[]): void { void AsyncStorage.setItem(HIDDEN_CALENDARS_STORAGE_KEY, JSON.stringify(ids)).catch( (err) => console.warn('[calendar-store] persist hidden failed', err), @@ -328,7 +343,8 @@ function unionRange(loaded: LoadedRange, after: string, before: string): LoadedR * (Stalwart >= 0.16.20, see lib/recurrence-instances), else on the device. * Doesn't touch the store: fetchEvents keeps the result as the visible * window, the reminders load their own upcoming window with it. Throws when - * the primary account fails; a failing shared account is skipped. + * the primary account fails, or when the store was reset meanwhile + * (CalendarAccountChangedError); a failing shared account is skipped. */ export async function loadEventsInRange( calendars: Calendar[], @@ -354,10 +370,13 @@ export async function loadEventsInRange( } if (groups.size === 0) groups.set(undefined, []); + const generation = storeGeneration; const expand = !!after && !!before && await supportsSyntheticCalendarIds().catch(() => false); const raw: CalendarEvent[] = []; for (const [accountId, ids] of groups) { + // The client serves another account now: the rest would be read there. + if (generation !== storeGeneration) throw new CalendarAccountChangedError(); if (accountId && isCalendarAccessDenied(accountId)) continue; try { let fetched: CalendarEvent[] | null = null; @@ -391,9 +410,11 @@ export async function loadEventsInRange( // A failing shared account must not hide the user's own events; // remember an access rejection so it isn't re-probed every fetch. if (!accountId) throw err; - noteCalendarAccessError(accountId, err); + // Not for the account the client serves since a reset. + if (generation === storeGeneration) noteCalendarAccessError(accountId, err); } } + if (generation !== storeGeneration) throw new CalendarAccountChangedError(); // Stalwart returns both Events and Tasks from CalendarEvent/query. Tasks // (also CalDAV ones without an `@type`) are surfaced by fetchTasks so // they don't pollute the grid. @@ -721,14 +742,16 @@ export const useCalendarStore = create()( // concurrent first Calendar/* requests can each lazily create a default // calendar. Share one in-flight request instead. if (calendarsInFlight) return calendarsInFlight; + const generation = storeGeneration; calendarsInFlight = (async () => { try { const calendars = sortCalendars((await fetchCalendars()) ?? []); - set({ calendars }); + if (generation === storeGeneration) set({ calendars }); } catch (err) { - set({ error: err instanceof Error ? err.message : 'Failed to load calendars' }); + if (generation === storeGeneration) set({ error: err instanceof Error ? err.message : 'Failed to load calendars' }); } finally { - calendarsInFlight = null; + // After a reset the slot belongs to the next account's load. + if (generation === storeGeneration) calendarsInFlight = null; } })(); return calendarsInFlight; @@ -736,11 +759,14 @@ export const useCalendarStore = create()( fetchEvents: async (calendarIds, after, before) => { if (!jmapClient.isConnected) return; + const generation = storeGeneration; set({ loading: true, error: null }); try { const events = await loadEventsInRange(get().calendars, calendarIds, after, before); + if (generation !== storeGeneration) return; set({ events, loadedRange: { after, before }, loading: false }); } catch (err) { + if (generation !== storeGeneration) return; set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load events' }); } }, @@ -748,6 +774,7 @@ export const useCalendarStore = create()( fetchTasks: async () => { if (!jmapClient.isConnected) return; if (tasksInFlight) return tasksInFlight; + const generation = storeGeneration; tasksInFlight = (async () => { try { const calendars = get().calendars; @@ -756,6 +783,8 @@ export const useCalendarStore = create()( const tasks: CalendarEvent[] = []; const taskOnly: string[] = []; for (const accountId of accountIds) { + // The client serves another account now: the rest would be read there. + if (generation !== storeGeneration) return; if (accountId && isCalendarAccessDenied(accountId)) continue; try { const scanned = await scanCalendarObjects(accountId); @@ -780,14 +809,15 @@ export const useCalendarStore = create()( tasks.push(...fetched.map((e) => mapServerEventToStoreEvent(e, calendars, accountId))); } catch (err) { if (!accountId) throw err; - noteCalendarAccessError(accountId, err); + if (generation === storeGeneration) noteCalendarAccessError(accountId, err); } } + if (generation !== storeGeneration) return; set({ tasks, taskOnlyCalendarIds: taskOnly }); } catch (err) { - set({ error: err instanceof Error ? err.message : 'Failed to load tasks' }); + if (generation === storeGeneration) set({ error: err instanceof Error ? err.message : 'Failed to load tasks' }); } finally { - tasksInFlight = null; + if (generation === storeGeneration) tasksInFlight = null; } })(); return tasksInFlight; @@ -836,12 +866,14 @@ export const useCalendarStore = create()( set({ loadedRange: plan.union }); return; } + const generation = storeGeneration; set({ loading: true, error: null }); try { const incoming: CalendarEvent[] = []; for (const piece of plan.pieces) { incoming.push(...(await loadEventsInRange(calendars, calendarIds, piece.after, piece.before))); } + if (generation !== storeGeneration) return; // A jump elsewhere replaced the window meanwhile: these pieces no // longer border it. (A refresh of the same window is fine.) if (!sameRange(get().loadedRange, loaded)) { @@ -854,6 +886,7 @@ export const useCalendarStore = create()( loading: false, }); } catch (err) { + if (generation !== storeGeneration) return; set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load events' }); } }, @@ -1375,6 +1408,9 @@ export const useCalendarStore = create()( reset: () => { // A new session may grant access the old one lacked, or reach an // upgraded server. + storeGeneration += 1; + calendarsInFlight = null; + tasksInFlight = null; resetCalendarAccessDenied(); resetSyntheticIdSupport(); resetTimeZoneChecks(); From b6c0cf1820b464a4525d80356813f85d04b05290 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:12:16 +0100 Subject: [PATCH 4/5] docs: say that Stalwart 1.0.0 answers the Metadata properties the calendar list leaves out --- src/api/calendar.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/api/calendar.ts b/src/api/calendar.ts index 2b174a14..1fcb97c6 100644 --- a/src/api/calendar.ts +++ b/src/api/calendar.ts @@ -420,10 +420,11 @@ export function resetSyntheticIdSupport(): void { } // The Calendar properties the app reads, which the default list on 0.16 and -// 1.0 covers. Stalwart 1.0 advertises JMAP Metadata but its handlers are -// still `todo!()`: a get that selects `metadata` or `privateMetadata` panics -// on the server. An explicit list keeps them out even if a later release -// adds them to the default. 0.16.6 knows every one of these. +// 1.0 covers. The pre-releases of Stalwart 1.0 advertised JMAP Metadata with +// handlers that were still `todo!()`: a get that selected `metadata` or +// `privateMetadata` panicked on the server (1.0.0 answers it). An explicit +// list keeps them, and whatever else a release adds to the default, out of +// every calendar load. 0.16.6 knows every one of these. const CALENDAR_PROPERTIES = [ 'id', 'name', 'description', 'color', 'sortOrder', 'isSubscribed', 'isVisible', 'isDefault', 'myRights', 'shareWith', 'defaultAlertsWithTime', 'defaultAlertsWithoutTime', From f733a5477758c49018c97ccbfc1d829c0d5ba8fb Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:13:04 +0100 Subject: [PATCH 5/5] test: check the calendar live on Stalwart 1.0.0 and 0.16 with series, overrides, invitations and shares #89 --- .../__tests__/live-stalwart-calendar.test.ts | 726 ++++++++++++++++++ 1 file changed, 726 insertions(+) create mode 100644 src/api/__tests__/live-stalwart-calendar.test.ts diff --git a/src/api/__tests__/live-stalwart-calendar.test.ts b/src/api/__tests__/live-stalwart-calendar.test.ts new file mode 100644 index 00000000..f112f0f5 --- /dev/null +++ b/src/api/__tests__/live-stalwart-calendar.test.ts @@ -0,0 +1,726 @@ +/** + * Live calendar checks against a real Stalwart server. + * + * Opt-in like live-stalwart.test.ts: skipped unless `BULWARK_LIVE_JMAP` and + * `BULWARK_LIVE_PASS` are set. `BULWARK_LIVE_USER2` (same password) is the + * attendee of the invitations and the account that shares a calendar and a + * mail folder with the first one; without it those checks are skipped. + * + * BULWARK_LIVE_JMAP=http://10.10.10.62:8120 \ + * BULWARK_LIVE_USER=e2e-tablet-light@example.org \ + * BULWARK_LIVE_USER2=e2e-tablet-land@example.org \ + * BULWARK_LIVE_PASS=… npx vitest run src/api/__tests__/live-stalwart-calendar.test.ts + * + * Everything it creates carries the run's marker in its title or name and is + * removed afterwards, also after a failed check. The events lie in 2027, away + * from anything a person keeps in the account. + * + * Verified against stalwartlabs/stalwart:v0.16.27 and v1.0.0. + */ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; + +vi.hoisted(() => { + process.env.TZ = 'Europe/Berlin'; +}); + +import { JMAPClient } from '../jmap-client'; +import { CAPABILITIES } from '../types'; +import type { Calendar, CalendarEvent, Participant } from '../types'; +import { + createEvent, + deleteEvents, + getCalendars, + getEvents, + queryEvents, + isCalendarAccessDenied, +} from '../calendar'; +import { useCalendarStore } from '../../stores/calendar-store'; +import { useSettingsStore } from '../../stores/settings-store'; +import { saveOccurrenceEdit, deleteOccurrence, type SeriesEdit } from '../../lib/recurrence-scope-edit'; +import { editorTimesChanged, editorTimesFromEvent, eventTimeFieldsToSave } from '../../lib/event-editor-times'; +import { participantFieldsToSave, participantLimitOf, seedAttendees, type Attendee } from '../../lib/calendar-participants'; +import { expandRecurringEvents } from '../../lib/recurrence-expansion'; +import { getEventStartDate } from '../../lib/calendar-utils'; + +const SERVER = process.env.BULWARK_LIVE_JMAP; +const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org'; +const USER2 = process.env.BULWARK_LIVE_USER2; +const PASS = process.env.BULWARK_LIVE_PASS ?? ''; +const live = SERVER && PASS ? describe : describe.skip; +const liveTwo = SERVER && PASS && USER2 ? describe : describe.skip; + +const USING = [CAPABILITIES.CORE, CAPABILITIES.CALENDARS]; +const MAIL_USING = [CAPABILITIES.CORE, CAPABILITIES.MAIL]; +const RUN = `live-cal-${Date.now().toString(36)}`; + +let client: JMAPClient; +let other: JMAPClient | null = null; + +async function connectSingleton(): Promise { + const mod = await import('../jmap-client'); + client = new JMAPClient(); + await client.connect(SERVER!, USER, PASS); + // The singleton is a const binding; copy the connected state onto it. + (mod.jmapClient as unknown as JMAPClient).restoreSnapshot(client.snapshot()); + useCalendarStore.getState().reset(); +} + +/** The singleton the api modules use. */ +async function singleton(): Promise { + return (await import('../jmap-client')).jmapClient as unknown as JMAPClient; +} + +type Response = [string, Record, string]; + +async function raw(c: JMAPClient, calls: [string, Record, string][], using: string[] = USING): Promise { + return (await c.request(calls, using)).methodResponses as Response[]; +} + +/** Stalwart 1.0 advertises JMAP Metadata; 0.16 does not. */ +function isStalwart10(): boolean { + return Object.values(client.currentSession?.accounts ?? {}).some( + (account) => 'urn:ietf:params:jmap:metadata' in (account.accountCapabilities ?? {}), + ); +} + +async function ownCalendarId(): Promise { + const calendars = await getCalendars(); + const own = calendars.filter((c) => !c.accountId); + const cal = own.find((c) => c.isDefault) ?? own[0]; + expect(cal, 'the account has a calendar').toBeTruthy(); + return cal.id; +} + +const SERIES_RULE = (count: number) => [{ '@type': 'RecurrenceRule', frequency: 'weekly', count }] as CalendarEvent['recurrenceRules']; + +function seriesData(title: string, start: string, count: number, extra: Partial = {}): Partial { + return { + '@type': 'Event', + title: `${RUN} ${title}`, + start, + duration: 'PT1H', + timeZone: 'Europe/Berlin', + recurrenceRules: SERIES_RULE(count), + ...extra, + } as Partial; +} + +/** Load [after, before] into the store and return the occurrences of `uid`, by start. */ +async function loadOccurrences(uid: string, after: string, before: string): Promise { + const store = useCalendarStore.getState(); + await store.fetchCalendars(); + const ids = useCalendarStore.getState().calendars.map((c) => c.id); + await useCalendarStore.getState().fetchEvents(ids, after, before); + expect(useCalendarStore.getState().error).toBeNull(); + return useCalendarStore.getState().events + .filter((e) => e.uid === uid) + .sort((a, b) => getEventStartDate(a).getTime() - getEventStartDate(b).getTime()); +} + +const startsOf = (events: CalendarEvent[]) => events.map((e) => getEventStartDate(e).toISOString()); + +/** + * What the event editor (EventModal.handleSave) writes for `event` when the + * user changes its title, moves it by `moveMinutes` or edits the attendees. + */ +function editorSave( + event: CalendarEvent, + change: { title?: string; moveMinutes?: number; attendees?: Attendee[] }, + userEmails: string[], +): { updates: Partial; edit: SeriesEdit; sendScheduling: boolean } { + const times = editorTimesFromEvent(event); + if (change.moveMinutes) { + const shift = change.moveMinutes * 60_000; + times.start = new Date(times.start.getTime() + shift); + times.end = new Date(times.end.getTime() + shift); + } + const attendees = change.attendees ?? seedAttendees(event, userEmails); + const updates: Partial = { + title: change.title ?? event.title, + ...eventTimeFieldsToSave(times, event), + recurrenceRules: event.recurrenceRules, + }; + Object.assign(updates, participantFieldsToSave(event, attendees, { + userEmail: userEmails[0], + userEmails, + rescheduled: editorTimesChanged(times, event), + })); + for (const key of Object.keys(updates) as (keyof CalendarEvent)[]) { + if (updates[key] === undefined) delete updates[key]; + } + return { updates, edit: { attendees, when: times, ruleChanged: false }, sendScheduling: attendees.length > 0 }; +} + +function storeActions() { + const s = useCalendarStore.getState(); + return { updateEvent: s.updateEvent, createEvent: s.createEvent, getMasterEvent: s.getMasterEvent, deleteEvent: s.deleteEvent }; +} + +/** The stored series (with its overrides) of `uid` in the account `c` serves. */ +async function seriesByUid(c: JMAPClient, uid: string): Promise[]> { + const res = await raw(c, [ + ['CalendarEvent/query', { accountId: c.accountId, filter: { uid } }, 'q'], + ['CalendarEvent/get', { + accountId: c.accountId, + '#ids': { resultOf: 'q', name: 'CalendarEvent/query', path: '/ids' }, + properties: ['id', 'uid', 'title', 'start', 'status', 'recurrenceRule', 'recurrenceOverrides', 'participants', 'useDefaultAlerts', 'alerts'], + }, 'g'], + ]); + return (res.find((r) => r[2] === 'g')?.[1].list ?? []) as Record[]; +} + +async function waitFor(read: () => Promise, done: (value: T) => boolean, ms = 15_000): Promise { + const until = Date.now() + ms; + let value = await read(); + while (!done(value) && Date.now() < until) { + await new Promise((resolve) => setTimeout(resolve, 500)); + value = await read(); + } + return value; +} + +function statusOf(participants: Record | undefined | null, email: string): string | undefined { + const p = Object.values(participants ?? {}).find((x) => + (x.calendarAddress ?? '').toLowerCase() === `mailto:${email.toLowerCase()}` + || (x.email ?? '').toLowerCase() === email.toLowerCase()); + return p?.participationStatus; +} + +/** Destroy every event of `c`'s account whose title carries the run's marker. */ +async function removeRunEvents(c: JMAPClient): Promise { + const res = await raw(c, [ + ['CalendarEvent/query', { accountId: c.accountId }, 'q'], + ['CalendarEvent/get', { + accountId: c.accountId, + '#ids': { resultOf: 'q', name: 'CalendarEvent/query', path: '/ids' }, + properties: ['id', 'title'], + }, 'g'], + ]); + const ids = ((res.find((r) => r[2] === 'g')?.[1].list ?? []) as { id: string; title?: string }[]) + .filter((e) => e.title?.includes(RUN)) + .map((e) => e.id); + if (ids.length > 0) { + await raw(c, [['CalendarEvent/set', { accountId: c.accountId, destroy: ids, sendSchedulingMessages: false }, 'd']]); + } +} + +live('live Stalwart calendar', () => { + let calendarId = ''; + + beforeAll(async () => { + await connectSingleton(); + if (USER2) { + other = new JMAPClient(); + await other.connect(SERVER!, USER2, PASS); + } + calendarId = await ownCalendarId(); + }, 60_000); + + afterAll(async () => { + useSettingsStore.setState({ calendarTimeZone: 'auto' }); + await removeRunEvents(await singleton()).catch(() => undefined); + if (other) await removeRunEvents(other).catch(() => undefined); + }, 60_000); + + describe('a series with overrides in its own time zone (native #89)', () => { + // Six Mondays at 10:00 in Berlin across the start of summer time on + // 28 March 2027; the second moved to 14:00 with a title of its own, the + // fourth excluded. + let uid = ''; + let id = ''; + + beforeAll(async () => { + const created = await createEvent(seriesData('series', '2027-03-15T10:00:00', 6, { + recurrenceOverrides: { + '2027-03-22T10:00:00': { start: '2027-03-22T14:00:00', title: `${RUN} moved` }, + '2027-04-05T10:00:00': { excluded: true }, + } as CalendarEvent['recurrenceOverrides'], + }), calendarId); + id = created.id; + uid = (await getEvents([id]))[0].uid!; + }, 30_000); + + it('cannot ask 1.0 for overrides and UTC times in one get, which is why the app reads them apart', async () => { + const [[name, result]] = await raw(client, [['CalendarEvent/get', { + accountId: client.accountId, ids: [id], properties: ['id', 'utcStart', 'recurrenceOverrides'], + }, '0']]); + if (isStalwart10()) { + expect(name).toBe('error'); + expect(result.type).toBe('invalidArguments'); + } else { + expect(name).toBe('CalendarEvent/get'); + } + }); + + it('reads the series with its overrides and its UTC times', async () => { + const [event] = await getEvents([id]); + expect(event.utcStart).toBe('2027-03-15T09:00:00Z'); + expect(Object.keys(event.recurrenceOverrides ?? {}).sort()).toEqual(['2027-03-22T10:00:00', '2027-04-05T10:00:00']); + expect(event.recurrenceRules?.[0]?.count).toBe(6); + }); + + it('shows the occurrences on the calendar at their Berlin clock time, moved and excluded ones included', async () => { + const occurrences = await loadOccurrences(uid, '2027-03-14T00:00:00.000Z', '2027-04-30T00:00:00.000Z'); + expect(startsOf(occurrences)).toEqual([ + '2027-03-15T09:00:00.000Z', + '2027-03-22T13:00:00.000Z', + '2027-03-29T08:00:00.000Z', + '2027-04-12T08:00:00.000Z', + '2027-04-19T08:00:00.000Z', + ]); + expect(occurrences[1].title).toBe(`${RUN} moved`); + expect(occurrences[2].title).toBe(`${RUN} series`); + }); + + it('expands the same occurrences on the device from the series it reads', async () => { + const [event] = await getEvents([id]); + const expanded = expandRecurringEvents([event], '2027-03-14T00:00:00.000Z', '2027-04-30T00:00:00.000Z') + .sort((a, b) => getEventStartDate(a).getTime() - getEventStartDate(b).getTime()); + expect(startsOf(expanded)).toEqual([ + '2027-03-15T09:00:00.000Z', + '2027-03-22T13:00:00.000Z', + '2027-03-29T08:00:00.000Z', + '2027-04-12T08:00:00.000Z', + '2027-04-19T08:00:00.000Z', + ]); + expect(expanded[1].title).toBe(`${RUN} moved`); + }); + + it('loads the calendar in a time zone the server does not know', async () => { + // Intl takes "europe/berlin"; Stalwart 1.0 refuses it (0.16 reads it as UTC). + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + useSettingsStore.setState({ calendarTimeZone: 'europe/berlin' }); + try { + useCalendarStore.getState().reset(); + const occurrences = await loadOccurrences(uid, '2027-03-14T00:00:00.000Z', '2027-04-30T00:00:00.000Z'); + expect(occurrences).toHaveLength(5); + // Events with a zone of their own keep their times whatever zone the query is in. + expect(startsOf(occurrences)[0]).toBe('2027-03-15T09:00:00.000Z'); + const refused = warn.mock.calls.some((args) => String(args[0]).includes('does not know the time zone')); + expect(refused).toBe(isStalwart10()); + // Remembered: the next reads go out in UTC without a refused request first. + expect((await queryEvents([], '2027-03-14T00:00:00.000Z', '2027-04-30T00:00:00.000Z')).length).toBeGreaterThan(0); + } finally { + warn.mockRestore(); + useSettingsStore.setState({ calendarTimeZone: 'auto' }); + useCalendarStore.getState().reset(); + } + }, 30_000); + + it('loads a week that starts at a midnight the clocks repeat', async () => { + // Havana turns its clocks back from 01:00 to 00:00 on 7 November 2027; + // 0.16 refused an expanded query from that midnight. + useSettingsStore.setState({ calendarTimeZone: 'America/Havana' }); + try { + useCalendarStore.getState().reset(); + await useCalendarStore.getState().fetchCalendars(); + await useCalendarStore.getState().fetchEvents( + useCalendarStore.getState().calendars.map((c) => c.id), + '2027-11-07T04:00:00.000Z', + '2027-11-14T05:00:00.000Z', + ); + expect(useCalendarStore.getState().error).toBeNull(); + expect(useCalendarStore.getState().loadedRange).toEqual({ + after: '2027-11-07T04:00:00.000Z', before: '2027-11-14T05:00:00.000Z', + }); + } finally { + useSettingsStore.setState({ calendarTimeZone: 'auto' }); + useCalendarStore.getState().reset(); + } + }, 30_000); + }); + + describe('series edits from an occurrence', () => { + // Six Mondays from 3 May 2027 at 10:00 in Berlin, no attendees. + const after = '2027-05-01T00:00:00.000Z'; + const before = '2027-06-30T00:00:00.000Z'; + let uid = ''; + let masterId = ''; + const me = [USER]; + + beforeAll(async () => { + const created = await createEvent(seriesData('edits', '2027-05-03T10:00:00', 6), calendarId); + masterId = created.id; + uid = (await getEvents([masterId]))[0].uid!; + }, 30_000); + + const at = (events: CalendarEvent[], day: string) => events.find((e) => (e.recurrenceId ?? '').startsWith(day)); + + it('changes the title of this occurrence only', async () => { + const occurrences = await loadOccurrences(uid, after, before); + expect(occurrences).toHaveLength(6); + const { updates, edit } = editorSave(at(occurrences, '2027-05-10')!, { title: `${RUN} this` }, me); + expect(await saveOccurrenceEdit('this', { event: at(occurrences, '2027-05-10')!, updates, editor: edit, userEmails: me }, storeActions())).toBe(true); + + const [master] = await seriesByUid(client, uid); + expect(master.title).toBe(`${RUN} edits`); + expect(master.recurrenceOverrides?.['2027-05-10T10:00:00']?.title).toBe(`${RUN} this`); + const after1 = await loadOccurrences(uid, after, before); + expect(after1.map((e) => e.title)).toEqual([ + `${RUN} edits`, `${RUN} this`, `${RUN} edits`, `${RUN} edits`, `${RUN} edits`, `${RUN} edits`, + ]); + }, 30_000); + + it('moves this occurrence only', async () => { + const occurrences = await loadOccurrences(uid, after, before); + const { updates, edit } = editorSave(at(occurrences, '2027-05-17')!, { moveMinutes: 120 }, me); + expect(await saveOccurrenceEdit('this', { event: at(occurrences, '2027-05-17')!, updates, editor: edit, userEmails: me }, storeActions())).toBe(true); + const moved = await loadOccurrences(uid, after, before); + expect(startsOf(moved)).toEqual([ + '2027-05-03T08:00:00.000Z', '2027-05-10T08:00:00.000Z', '2027-05-17T10:00:00.000Z', + '2027-05-24T08:00:00.000Z', '2027-05-31T08:00:00.000Z', '2027-06-07T08:00:00.000Z', + ]); + }, 30_000); + + it('deletes this occurrence only', async () => { + const occurrences = await loadOccurrences(uid, after, before); + expect(await deleteOccurrence('this', { event: at(occurrences, '2027-05-24')!, userEmails: me }, storeActions())).toBe(true); + const left = await loadOccurrences(uid, after, before); + expect(left.map((e) => e.recurrenceId?.slice(0, 10))).toEqual(['2027-05-03', '2027-05-10', '2027-05-17', '2027-05-31', '2027-06-07']); + const [master] = await seriesByUid(client, uid); + expect(master.recurrenceOverrides?.['2027-05-24T10:00:00']?.excluded).toBe(true); + }, 30_000); + + it('splits the series at this and the following occurrences', async () => { + const occurrences = await loadOccurrences(uid, after, before); + const { updates, edit } = editorSave(at(occurrences, '2027-05-31')!, { title: `${RUN} following` }, me); + expect(await saveOccurrenceEdit('this_and_future', { + event: at(occurrences, '2027-05-31')!, updates, editor: edit, calendarId, userEmails: me, + }, storeActions())).toBe(true); + + const [old] = await seriesByUid(client, uid); + expect(old.recurrenceRule?.until).toBeTruthy(); + expect(old.recurrenceRule?.count ?? null).toBeNull(); + const store = useCalendarStore.getState(); + await store.fetchEvents(store.calendars.map((c) => c.id), after, before); + const rest = useCalendarStore.getState().events + .filter((e) => e.title === `${RUN} following`) + .sort((a, b) => getEventStartDate(a).getTime() - getEventStartDate(b).getTime()); + // What was left of the count: two of six. + expect(startsOf(rest)).toEqual(['2027-05-31T08:00:00.000Z', '2027-06-07T08:00:00.000Z']); + expect(rest[0].uid).not.toBe(uid); + const kept = await loadOccurrences(uid, after, before); + expect(kept.map((e) => e.recurrenceId?.slice(0, 10))).toEqual(['2027-05-03', '2027-05-10', '2027-05-17']); + + // Deleting the rest from its second occurrence on ends it after its first. + const second = rest[1]; + expect(await deleteOccurrence('this_and_future', { event: second, userEmails: me }, storeActions())).toBe(true); + const [newSeries] = await seriesByUid(client, rest[0].uid!); + expect(newSeries.recurrenceRule?.until).toBeTruthy(); + await store.fetchEvents(store.calendars.map((c) => c.id), after, before); + expect(useCalendarStore.getState().events.filter((e) => e.uid === rest[0].uid)).toHaveLength(1); + }, 60_000); + + it('changes and moves all occurrences, keeping each one\'s own changes', async () => { + let occurrences = await loadOccurrences(uid, after, before); + const rename = editorSave(occurrences[0], { title: `${RUN} all` }, me); + expect(await saveOccurrenceEdit('all', { event: occurrences[0], updates: rename.updates, editor: rename.edit, userEmails: me }, storeActions())).toBe(true); + occurrences = await loadOccurrences(uid, after, before); + expect(occurrences.map((e) => e.title)).toEqual([`${RUN} all`, `${RUN} this`, `${RUN} all`]); + + // An hour later, all of them: the occurrence moved on its own keeps + // the time the user gave it, the one deleted stays deleted, none is + // listed twice. + const move = editorSave(occurrences[0], { moveMinutes: 60 }, me); + expect(await saveOccurrenceEdit('all', { event: occurrences[0], updates: move.updates, editor: move.edit, userEmails: me }, storeActions())).toBe(true); + occurrences = await loadOccurrences(uid, after, before); + expect(startsOf(occurrences)).toEqual(['2027-05-03T09:00:00.000Z', '2027-05-10T09:00:00.000Z', '2027-05-17T10:00:00.000Z']); + expect(occurrences.map((e) => e.title)).toEqual([`${RUN} all`, `${RUN} this`, `${RUN} all`]); + const [master] = await seriesByUid(client, uid); + expect(master.start).toBe('2027-05-03T11:00:00'); + expect(Object.keys(master.recurrenceOverrides ?? {}).sort()).toEqual([ + '2027-05-10T11:00:00', '2027-05-17T11:00:00', '2027-05-24T11:00:00', + ]); + expect(master.recurrenceOverrides['2027-05-24T11:00:00'].excluded).toBe(true); + }, 60_000); + + it('deletes the whole series from an occurrence', async () => { + const occurrences = await loadOccurrences(uid, after, before); + expect(await deleteOccurrence('all', { event: occurrences[1], userEmails: me }, storeActions())).toBe(true); + expect(await seriesByUid(client, uid)).toHaveLength(0); + expect(await loadOccurrences(uid, after, before)).toHaveLength(0); + }, 30_000); + }); + + describe('participant limit', () => { + it('names the server\'s participant limit when 1.0 refuses an event with too many', async () => { + const participants: Record = { + org: { '@type': 'Participant', calendarAddress: `mailto:${USER}`, roles: { owner: true }, participationStatus: 'accepted' } as Participant, + }; + for (let i = 0; i < 20; i++) { + participants[`a${i}`] = { '@type': 'Participant', calendarAddress: `mailto:guest${i}@example.net`, roles: { attendee: true } } as Participant; + } + const event = { + '@type': 'Event', title: `${RUN} crowd`, start: '2027-07-05T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin', + participants, organizerCalendarAddress: `mailto:${USER}`, + } as Partial; + let error: unknown = null; + try { + const created = await createEvent(event, calendarId, false); + await deleteEvents([created.id], false); + } catch (err) { + error = err; + } + if (isStalwart10()) { + expect(participantLimitOf(error)).toBe(20); + } else { + expect(error).toBeNull(); + } + }, 30_000); + }); + + describe('default alerts', () => { + let calendar: Calendar | undefined; + let before: Record | null = null; + + beforeAll(async () => { + calendar = (await getCalendars()).find((c) => c.id === calendarId); + before = (calendar?.defaultAlertsWithTime as Record | null | undefined) ?? null; + await raw(client, [['Calendar/set', { + accountId: client.accountId, + update: { [calendarId]: { defaultAlertsWithTime: { d1: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } } } }, + }, '0']]); + }, 30_000); + + afterAll(async () => { + await raw(client, [['Calendar/set', { + accountId: client.accountId, update: { [calendarId]: { defaultAlertsWithTime: before } }, + }, '0']]).catch(() => undefined); + }, 30_000); + + it('keeps the reminder changed on one occurrence of a series on the calendar\'s default reminders', async () => { + const created = await createEvent(seriesData('alerts', '2027-08-02T10:00:00', 3, { useDefaultAlerts: true }), calendarId); + const uid = (await getEvents([created.id]))[0].uid!; + const occurrences = await loadOccurrences(uid, '2027-08-01T00:00:00.000Z', '2027-08-31T00:00:00.000Z'); + expect(occurrences).toHaveLength(3); + const own = { a1: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT1H' } } }; + await useCalendarStore.getState().updateEvent(occurrences[1].id, { useDefaultAlerts: false, alerts: own } as Partial); + const [master] = await seriesByUid(client, uid); + expect(master.useDefaultAlerts).toBe(false); + const reloaded = await loadOccurrences(uid, '2027-08-01T00:00:00.000Z', '2027-08-31T00:00:00.000Z'); + const offsets = (e: CalendarEvent) => Object.values(e.alerts ?? {}).map((a) => (a.trigger as { offset?: string }).offset).sort(); + expect(offsets(reloaded[1])).toEqual(['-PT1H']); + // The others keep the reminder the calendar gave them. + expect(offsets(reloaded[0])).toEqual(['-PT15M']); + }, 60_000); + }); + + liveTwo('invitations', () => { + const after = '2027-09-01T00:00:00.000Z'; + const before = '2027-09-30T00:00:00.000Z'; + let uid = ''; + const me = [USER]; + + it('keeps the attendee\'s answer on a change and asks again when the organizer moves it', async () => { + // Four Mondays from 6 September 2027, the second user invited. + const invitation = editorSave( + { title: `${RUN} meeting`, start: '2027-09-06T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin' } as CalendarEvent, + { attendees: [{ name: 'Land', email: USER2! }] }, + me, + ); + const created = await useCalendarStore.getState().createEvent({ + ...seriesData('meeting', '2027-09-06T10:00:00', 4), + participants: invitation.updates.participants, + organizerCalendarAddress: invitation.updates.organizerCalendarAddress, + }, calendarId, { sendSchedulingMessages: true }); + uid = created.uid!; + + // The attendee's copy arrives through the server's own scheduling. + const copies = await waitFor(() => seriesByUid(other!, uid), (list) => list.length > 0); + expect(copies, 'the invitation reached the second user').toHaveLength(1); + const copy = copies[0]; + const myId = Object.entries(copy.participants ?? {}).find(([, p]) => + String((p as Participant).calendarAddress).toLowerCase() === `mailto:${USER2!.toLowerCase()}`)?.[0]; + expect(myId).toBeTruthy(); + await raw(other!, [['CalendarEvent/set', { + accountId: other!.accountId, + update: { [copy.id]: { [`participants/${myId}/participationStatus`]: 'accepted' } }, + sendSchedulingMessages: true, + }, '0']]); + let [mine] = await waitFor(() => seriesByUid(client, uid), (list) => statusOf(list[0]?.participants, USER2!) === 'accepted'); + expect(statusOf(mine.participants, USER2!)).toBe('accepted'); + + // The attendee declines the third occurrence alone, in the app. + (await singleton()).restoreSnapshot(other!.snapshot()); + try { + useCalendarStore.getState().reset(); + const theirs = await loadOccurrences(uid, after, before); + const third = theirs.find((e) => (e.recurrenceId ?? '').startsWith('2027-09-20'))!; + const participantId = Object.entries(third.participants ?? {}).find(([, p]) => + String(p.calendarAddress).toLowerCase() === `mailto:${USER2!.toLowerCase()}`)![0]; + await useCalendarStore.getState().rsvpEvent(third.id, participantId, 'declined', null, undefined, 'occurrence'); + } finally { + await connectSingleton(); + } + [mine] = await waitFor(() => seriesByUid(client, uid), (list) => { + const entry = list[0]?.recurrenceOverrides?.['2027-09-20T10:00:00'] as Record | undefined; + return !!entry && JSON.stringify(entry).includes('declined'); + }); + expect(JSON.stringify(mine.recurrenceOverrides?.['2027-09-20T10:00:00'] ?? null)).toContain('declined'); + expect(statusOf(mine.participants, USER2!)).toBe('accepted'); + + // A new title for all: the answer stays. + let occurrences = await loadOccurrences(uid, after, before); + const rename = editorSave(occurrences[0], { title: `${RUN} meeting renamed` }, me); + expect(await saveOccurrenceEdit('all', { + event: occurrences[0], updates: rename.updates, editor: rename.edit, sendScheduling: rename.sendScheduling, userEmails: me, + }, storeActions())).toBe(true); + [mine] = await seriesByUid(client, uid); + expect(mine.title).toBe(`${RUN} meeting renamed`); + expect(statusOf(mine.participants, USER2!)).toBe('accepted'); + expect(JSON.stringify(mine.recurrenceOverrides?.['2027-09-20T10:00:00'] ?? null)).toContain('declined'); + const renamed = await waitFor(() => seriesByUid(other!, uid), (list) => list[0]?.title === `${RUN} meeting renamed`); + expect(statusOf(renamed[0].participants, USER2!)).toBe('accepted'); + + // One occurrence an hour later: asked again for that one alone. + occurrences = await loadOccurrences(uid, after, before); + const moveOne = editorSave(occurrences[1], { moveMinutes: 60 }, me); + expect(await saveOccurrenceEdit('this', { + event: occurrences[1], updates: moveOne.updates, editor: moveOne.edit, sendScheduling: moveOne.sendScheduling, userEmails: me, + }, storeActions())).toBe(true); + [mine] = await seriesByUid(client, uid); + const override = mine.recurrenceOverrides?.['2027-09-13T10:00:00'] as Record | undefined; + expect(override?.start).toBe('2027-09-13T11:00:00'); + const overrideStatus = override?.participants + ? statusOf(override.participants, USER2!) + : Object.entries(override ?? {}).find(([k]) => k.endsWith('/participationStatus'))?.[1]; + expect(overrideStatus).toBe('needs-action'); + expect(statusOf(mine.participants, USER2!)).toBe('accepted'); + + // All of them an hour later: asked again for the series. + occurrences = await loadOccurrences(uid, after, before); + const moveAll = editorSave(occurrences[0], { moveMinutes: 60 }, me); + expect(await saveOccurrenceEdit('all', { + event: occurrences[0], updates: moveAll.updates, editor: moveAll.edit, sendScheduling: moveAll.sendScheduling, userEmails: me, + }, storeActions())).toBe(true); + [mine] = await seriesByUid(client, uid); + expect(mine.start).toBe('2027-09-06T11:00:00'); + expect(statusOf(mine.participants, USER2!)).toBe('needs-action'); + + // Cancelled for the attendee too. + occurrences = await loadOccurrences(uid, after, before); + expect(await deleteOccurrence('all', { event: occurrences[0], userEmails: me }, storeActions())).toBe(true); + const gone = await waitFor(() => seriesByUid(other!, uid), (list) => list.length === 0 || list[0]?.status === 'cancelled'); + expect(gone.length === 0 || gone[0].status === 'cancelled').toBe(true); + }, 120_000); + }); + + liveTwo('shares', () => { + let mailboxId = ''; + let sharedCalendarId = ''; + let principal = ''; + + afterAll(async () => { + if (!other) return; + if (sharedCalendarId) { + await raw(other, [['Calendar/set', { accountId: other.accountId, destroy: [sharedCalendarId], onDestroyRemoveEvents: true }, '0']]).catch(() => undefined); + } + if (mailboxId) { + await raw(other, [['Mailbox/set', { accountId: other.accountId, destroy: [mailboxId], onDestroyRemoveEmails: true }, '0']], MAIL_USING).catch(() => undefined); + } + await connectSingleton().catch(() => undefined); + }, 60_000); + + it('asks an account that shares only mail for its calendars once, without an error', async () => { + principal = client.accountId; + const [[, created]] = await raw(other!, [['Mailbox/set', { + accountId: other!.accountId, + create: { m: { name: `${RUN} shared`, shareWith: { [principal]: { mayReadItems: true } } } }, + }, '0']], [...MAIL_USING, 'urn:ietf:params:jmap:principals']); + mailboxId = created.created?.m?.id ?? ''; + expect(mailboxId, JSON.stringify(created.notCreated)).toBeTruthy(); + + await connectSingleton(); + const sharer = other!.accountId; + expect(client.currentSession?.accounts?.[sharer], 'the sharer\'s account is in the session').toBeTruthy(); + // What the server answers there: 0.16 forbidden, 1.0 accountNotSupportedByMethod. + const [[name, answer]] = await raw(client, [['Calendar/get', { accountId: sharer, ids: [] }, '0']]); + expect(name).toBe('error'); + expect(['forbidden', 'accountNotSupportedByMethod', 'accountNotFound']).toContain(answer.type); + const jmap = await singleton(); + const spy = vi.spyOn(jmap, 'request'); + try { + const store = useCalendarStore.getState(); + for (let i = 0; i < 3; i++) { + await store.fetchCalendars(); + await useCalendarStore.getState().fetchEvents(useCalendarStore.getState().calendars.map((c) => c.id), + '2027-09-01T00:00:00.000Z', '2027-09-30T00:00:00.000Z'); + await useCalendarStore.getState().fetchTasks(); + } + expect(useCalendarStore.getState().error).toBeNull(); + const toSharer = spy.mock.calls.filter(([calls]) => + (calls as unknown as [string, Record][]).some(([, args]) => args.accountId === sharer)); + // At most the one probe: no retry on every load. + expect(toSharer.length).toBeLessThanOrEqual(1); + if (toSharer.length === 1) expect(isCalendarAccessDenied(sharer)).toBe(true); + } finally { + spy.mockRestore(); + } + }, 60_000); + + it('shows a calendar another account shares and writes its events there', async () => { + const [[, created]] = await raw(other!, [['Calendar/set', { + accountId: other!.accountId, + create: { c: { name: `${RUN} team`, shareWith: { [principal]: { mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayReadFreeBusy: true } } } }, + }, '0']], [...USING, 'urn:ietf:params:jmap:principals']); + sharedCalendarId = created.created?.c?.id ?? ''; + expect(sharedCalendarId, JSON.stringify(created.notCreated)).toBeTruthy(); + const [[, made]] = await raw(other!, [['CalendarEvent/set', { + accountId: other!.accountId, + create: { e: { ...seriesData('owner series', '2027-10-04T10:00:00', 2), recurrenceRule: SERIES_RULE(2)![0], recurrenceRules: undefined, calendarIds: { [sharedCalendarId]: true } } }, + }, '0']]); + expect(made.created?.e?.id, JSON.stringify(made.notCreated)).toBeTruthy(); + + await connectSingleton(); + const store = useCalendarStore.getState(); + await store.fetchCalendars(); + const shared = useCalendarStore.getState().calendars.find((c) => c.name === `${RUN} team`); + expect(shared?.accountId).toBe(other!.accountId); + // The month as the app asks for it: from local midnight to local midnight. + await useCalendarStore.getState().fetchEvents(useCalendarStore.getState().calendars.map((c) => c.id), + '2027-09-30T22:00:00.000Z', '2027-10-30T22:00:00.000Z'); + const theirs = useCalendarStore.getState().events + .filter((e) => e.title === `${RUN} owner series`) + .sort((a, b) => getEventStartDate(a).getTime() - getEventStartDate(b).getTime()); + expect(theirs).toHaveLength(2); + expect(theirs.every((e) => e.accountId === other!.accountId && !!e.calendarIds?.[shared!.id])).toBe(true); + + // An edit of one of its occurrences goes to the owner's account. + const { updates, edit } = editorSave(theirs[1], { title: `${RUN} owner second` }, [USER]); + expect(await saveOccurrenceEdit('this', { event: theirs[1], updates, editor: edit, userEmails: [USER] }, storeActions())).toBe(true); + const list = (await raw(other!, [['CalendarEvent/query', { accountId: other!.accountId, filter: { inCalendar: sharedCalendarId } }, 'q'], + ['CalendarEvent/get', { accountId: other!.accountId, '#ids': { resultOf: 'q', name: 'CalendarEvent/query', path: '/ids' }, properties: ['id', 'recurrenceOverrides'] }, 'g']])) + .find((r) => r[2] === 'g')?.[1].list as Record[]; + expect(list).toHaveLength(1); + expect(list[0].recurrenceOverrides?.['2027-10-11T10:00:00']?.title).toBe(`${RUN} owner second`); + }, 60_000); + }); + + describe('tasks', () => { + it('lists the account\'s own tasks and none of another account\'s', async () => { + const [[, created]] = await raw(client, [['CalendarEvent/set', { + accountId: client.accountId, + create: { t: { '@type': 'Task', title: `${RUN} task`, calendarIds: { [calendarId]: true }, due: '2027-11-01T10:00:00', timeZone: 'Europe/Berlin' } }, + }, '0']]); + expect(created.created?.t?.id, JSON.stringify(created.notCreated)).toBeTruthy(); + useCalendarStore.getState().reset(); + await useCalendarStore.getState().fetchCalendars(); + await useCalendarStore.getState().fetchTasks(); + expect(useCalendarStore.getState().tasks.map((t) => t.title)).toContain(`${RUN} task`); + + if (!USER2) return; + const second = new JMAPClient(); + await second.connect(SERVER!, USER2, PASS); + const jmap = await singleton(); + jmap.restoreSnapshot(second.snapshot()); + try { + useCalendarStore.getState().reset(); + await useCalendarStore.getState().fetchCalendars(); + await useCalendarStore.getState().fetchTasks(); + expect(useCalendarStore.getState().tasks.map((t) => t.title)).not.toContain(`${RUN} task`); + } finally { + await connectSingleton(); + } + }, 60_000); + }); +});