From c236d828c1f0ce9252426f5cefdbea79070f2dff Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 25 Sep 2026 17:38:49 +0200 Subject: [PATCH] fix: keep the calendar filled when scrolling past a year on Stalwart 1.0 Stalwart 1.0 refuses an expanded CalendarEvent/query longer than maxExpandedQueryDuration (365 days by default) with expandDurationTooLarge. The scrolling month view reaches 777 days and search with after:/before: can span years, so the grid went blank. Range queries are now split into chunks within the advertised limit and merged. A chunk the server still refuses is halved; if a week still expands to too many occurrences, the range is expanded in the browser as before. 0.16 advertises the same limit without enforcing it and returns the same events. --- lib/__tests__/helpers/stalwart-calendar.ts | 75 ++++- .../jmap-calendar-query-range.test.ts | 288 ++++++++++++++++-- lib/global-search/__tests__/providers.test.ts | 44 +++ lib/jmap/client.ts | 198 +++++++++++- 4 files changed, 577 insertions(+), 28 deletions(-) diff --git a/lib/__tests__/helpers/stalwart-calendar.ts b/lib/__tests__/helpers/stalwart-calendar.ts index a8f865175..eba2420a5 100644 --- a/lib/__tests__/helpers/stalwart-calendar.ts +++ b/lib/__tests__/helpers/stalwart-calendar.ts @@ -59,7 +59,7 @@ export interface StalwartCalendarMock { /** A session like Stalwart's, with the calendars capability on the account. */ export function stalwartCalendarSession( version: StalwartVersion, - options: { maxExpandedQueryDuration?: string | null; accounts?: Record } = {}, + options: { maxExpandedQueryDuration?: string | null; maxCallsInRequest?: number; accounts?: Record } = {}, ) { const calendarCapability: Record = { maxCalendarsPerEvent: version === '1.0' ? 10 : null, @@ -72,7 +72,7 @@ export function stalwartCalendarSession( if (duration !== null) calendarCapability.maxExpandedQueryDuration = duration; return { capabilities: { - 'urn:ietf:params:jmap:core': { maxCallsInRequest: 16, maxObjectsInGet: 500, maxObjectsInSet: 500 }, + 'urn:ietf:params:jmap:core': { maxCallsInRequest: options.maxCallsInRequest ?? 16, maxObjectsInGet: 500, maxObjectsInSet: 500 }, 'urn:ietf:params:jmap:calendars': {}, }, accounts: { @@ -200,6 +200,77 @@ export function installStalwartCalendarMock( }; } +/** Stalwart 1.0's refusal of an expanded range longer than maxExpandedQueryDuration. */ +export const EXPAND_DURATION_TOO_LARGE = { + type: 'expandDurationTooLarge', + description: "The time span between 'after' and 'before' exceeds maxExpandedQueryDuration", +} as const; + +/** Both versions' refusal of a query that expands to more occurrences than allowed. */ +export function tooManyOccurrences(version: StalwartVersion) { + const description = 'The number of expanded recurrences exceeds the server limit'; + return version === '1.0' + ? { type: 'cannotCalculateOccurrences', description } + : { type: 'invalidArguments', description }; +} + +/** An occurrence of the series `seriesId`: a LocalDateTime start and a length in seconds. */ +export interface RangeOccurrence { + id: string; + start: string; + seconds: number; +} + +/** A LocalDateTime read as seconds (the mock has no time zones). */ +export function localSeconds(value: string): number { + return Date.parse(`${value}Z`) / 1000; +} + +/** Occurrence objects for `objects`/`occurrences` of installStalwartCalendarMock. */ +export function occurrenceObjects(seriesId: string, occurrences: RangeOccurrence[]) { + return Object.fromEntries(occurrences.map((o) => [o.id, { + id: o.id, '@type': 'Event', baseEventId: seriesId, uid: `${seriesId}@example.com`, title: 'Standup', + start: o.start, duration: `PT${o.seconds}S`, recurrenceId: o.start, calendarIds: { cal: true }, + }])); +} + +/** + * Answers CalendarEvent/query the way Stalwart expands a range: occurrences + * that overlap (after, before) strictly, sorted by start when asked, paged. + * 1.0 refuses a span over `maxSpanSeconds` (0.16 never checks it), and both + * refuse a query that expands to more than `maxInstances` occurrences. A + * query without expansion finds the series itself. + */ +export function expandingQueryAnswer( + version: StalwartVersion, + options: { seriesId: string; occurrences: RangeOccurrence[]; maxSpanSeconds?: number; maxInstances?: number }, +) { + return (call: RecordedCall): MethodAnswer | undefined => { + if (call.method !== 'CalendarEvent/query') return undefined; + const { args } = call; + if (!args.expandRecurrences) return ['CalendarEvent/query', { ids: [options.seriesId], position: 0 }]; + const filter = args.filter as { after: string; before: string }; + const after = localSeconds(filter.after); + const before = localSeconds(filter.before); + if (version === '1.0' && before - after > (options.maxSpanSeconds ?? 365 * 86_400)) { + return ['error', { ...EXPAND_DURATION_TOO_LARGE }]; + } + let hits = options.occurrences.filter((o) => { + const start = localSeconds(o.start); + return start < before && start + o.seconds > after; + }); + if (hits.length > (options.maxInstances ?? 3000)) return ['error', tooManyOccurrences(version)]; + const [sort] = (args.sort as Array<{ property: string; isAscending?: boolean }> | undefined) ?? []; + if (sort?.property === 'start') { + hits = [...hits].sort((a, b) => localSeconds(a.start) - localSeconds(b.start)); + if (sort.isAscending === false) hits.reverse(); + } + const position = (args.position as number | undefined) ?? 0; + const limit = (args.limit as number | undefined) ?? hits.length; + return ['CalendarEvent/query', { ids: hits.slice(position, position + limit).map((o) => o.id), position }]; + }; +} + /** A JMAPClient connected to `session` through `fetchSpy`. */ export async function connectCalendarClient( fetchSpy: ReturnType, diff --git a/lib/__tests__/jmap-calendar-query-range.test.ts b/lib/__tests__/jmap-calendar-query-range.test.ts index 5c11c7c0c..4bee3bde3 100644 --- a/lib/__tests__/jmap-calendar-query-range.test.ts +++ b/lib/__tests__/jmap-calendar-query-range.test.ts @@ -1,7 +1,19 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import { JMAPClient } from '../jmap/client'; import { jmapErrorType } from '../jmap/jmap-error'; import { toLocalDateTime } from '../timezone'; +import { + connectCalendarClient, + expandingQueryAnswer, + installStalwartCalendarMock, + localSeconds, + occurrenceObjects, + stalwartCalendarSession, + tooManyOccurrences, + type RangeOccurrence, + type StalwartCalendarMock, + type StalwartVersion, +} from './helpers/stalwart-calendar'; type Call = [string, Record, string]; @@ -49,23 +61,30 @@ describe('queryCalendarEvents range handling', () => { expect(filter.after.endsWith('Z')).toBe(false); }); - it('falls back to series when the range expands past the server limit on 0.16', async () => { - const { client, queries } = clientAnswering((args) => args.expandRecurrences - ? ['error', { type: 'invalidArguments', description: 'The number of expanded recurrences exceeds the server limit' }] - : ['CalendarEvent/query', { ids: ['s1'] }]); - const events = await client.queryCalendarEvents({ after: '2020-01-01T00:00:00', before: '2030-01-01T00:00:00' }); - expect(queries.map((q) => !!q.expandRecurrences)).toEqual([true, false]); - expect(events.map((e) => e.id)).toEqual(['s1']); - }); + it.each<[StalwartVersion]>([['0.16'], ['1.0']])( + 'falls back to series when even a week expands to too many occurrences on Stalwart %s', + async (version) => { + const { client, queries } = clientAnswering((args) => args.expandRecurrences + ? ['error', tooManyOccurrences(version)] + : ['CalendarEvent/query', { ids: ['s1'] }]); + const events = await client.queryCalendarEvents({ after: '2026-09-01T00:00:00', before: '2026-09-30T23:59:59' }); - it('falls back to series on Stalwart 1.0 cannotCalculateOccurrences', async () => { - const { client, queries } = clientAnswering((args) => args.expandRecurrences - ? ['error', { type: 'cannotCalculateOccurrences', description: 'The number of expanded recurrences exceeds the server limit' }] - : ['CalendarEvent/query', { ids: ['s1'] }]); - const events = await client.queryCalendarEvents({ after: '2026-09-01T00:00:00', before: '2026-09-30T23:59:59' }); - expect(queries.map((q) => !!q.expandRecurrences)).toEqual([true, false]); - expect(events.map((e) => e.id)).toEqual(['s1']); - }); + // 30 days, halved to 15, 7.5 and 3.75: a chunk of a week or less that + // is still refused gives up on server expansion for the whole range. + const expanded = queries.filter((q) => q.expandRecurrences); + const days = expanded.map((q) => { + const filter = q.filter as { after: string; before: string }; + return (localSeconds(filter.before) - localSeconds(filter.after)) / 86_400; + }); + const levels = [30, 15, 15, 7.5, 7.5, 7.5, 7.5, ...Array(8).fill(3.75)]; + expect(days).toHaveLength(levels.length); + days.forEach((span, i) => expect(span).toBeCloseTo(levels[i], 0)); + expect(queries).toHaveLength(expanded.length + 1); + expect(queries.at(-1)).toMatchObject({ filter: { after: '2026-09-01T00:00:00', before: '2026-09-30T23:59:59' } }); + expect(queries.at(-1)!.expandRecurrences).toBeUndefined(); + expect(events.map((e) => e.id)).toEqual(['s1']); + }, + ); it('fails the range when Stalwart 1.0 refuses the query, instead of returning no events', async () => { const { client } = clientAnswering(() => ['error', { type: 'invalidArguments', description: 'Invalid timeZone "Mars/Olympus"' }]); @@ -93,3 +112,238 @@ describe('queryCalendarEvents range handling', () => { expect(queries[0].limit).toBe(2); }); }); + +// An expanded range may span at most maxExpandedQueryDuration (the account's +// calendars capability, an iCalendar duration). 1.0 enforces it with +// expandDurationTooLarge; every 0.16 advertises 365 days and never checks. +// A longer range goes out in chunks a day shorter than the limit, sharing a +// second at each border. +describe('expanded ranges longer than maxExpandedQueryDuration', () => { + const DAY = 86_400; + // 777 days, the longest window the month view scrolls to. + const WINDOW = { after: '2025-01-01T00:00:00', before: '2027-02-16T23:59:59' }; + const occurrences: RangeOccurrence[] = [ + { id: 'o-first', start: '2025-01-02T09:00:00', seconds: 3600 }, + // Across the first border (2025-12-31T00:00:00): found by both chunks. + { id: 'o-across', start: '2025-12-30T23:30:00', seconds: 3600 }, + // Zero-length, exactly on that border: only the overlap finds it. + { id: 'o-border', start: '2025-12-31T00:00:00', seconds: 0 }, + { id: 'o-middle', start: '2026-07-01T09:00:00', seconds: 3600 }, + { id: 'o-late', start: '2027-02-10T09:00:00', seconds: 3600 }, + // Outside the window. + { id: 'o-after', start: '2027-02-17T09:00:00', seconds: 3600 }, + ]; + const IN_WINDOW = ['o-first', 'o-across', 'o-border', 'o-middle', 'o-late']; + const SERIES = { + id: 'series', '@type': 'Event', uid: 'series@example.com', title: 'Standup', baseEventId: 'series', + start: '2025-01-02T09:00:00', duration: 'PT1H', recurrenceRule: { frequency: 'daily' }, + recurrenceOverrides: null, calendarIds: { cal: true }, + }; + + let fetchSpy: ReturnType; + let client: JMAPClient; + let server: StalwartCalendarMock; + + async function connect( + version: StalwartVersion, + options: { + maxExpandedQueryDuration?: string | null; + maxSpanSeconds?: number; + maxInstances?: number; + maxCallsInRequest?: number; + } = {}, + ) { + fetchSpy = vi.spyOn(globalThis, 'fetch'); + client = await connectCalendarClient(fetchSpy, stalwartCalendarSession(version, options)); + server = installStalwartCalendarMock(fetchSpy, { + version, + objects: { series: SERIES }, + occurrences: occurrenceObjects('series', occurrences), + answer: expandingQueryAnswer(version, { seriesId: 'series', occurrences, ...options }), + }); + } + + const expandedQueries = () => server.queries().filter((q) => q.args.expandRecurrences); + const bounds = () => expandedQueries().map((q) => { + const filter = q.args.filter as { after: string; before: string }; + return [filter.after, filter.before]; + }); + + afterEach(() => { + client.disconnect(); + fetchSpy.mockRestore(); + }); + + describe.each(['P365D', 'P52W1D', 'PT8760H'])('with maxExpandedQueryDuration %s', (duration) => { + it(`splits a 777-day window into three chunks sent in one request on Stalwart 1.0`, async () => { + await connect('1.0', { maxExpandedQueryDuration: duration }); + + const events = await client.queryCalendarEvents(WINDOW); + + expect(bounds()).toEqual([ + ['2025-01-01T00:00:00', '2025-12-31T00:00:00'], + ['2025-12-30T23:59:59', '2026-12-29T23:59:59'], + ['2026-12-29T23:59:58', '2027-02-16T23:59:59'], + ]); + for (const [after, before] of bounds()) { + expect(localSeconds(before) - localSeconds(after)).toBeLessThanOrEqual(364 * DAY); + } + expect(new Set(expandedQueries().map((q) => q.request)).size).toBe(1); + // Each occurrence once, the border ones included. + expect(events.map((e) => e.id).sort()).toEqual([...IN_WINDOW].sort()); + }); + }); + + it('gets the same occurrences from 0.16, which advertises the limit but does not enforce it', async () => { + await connect('0.16'); + const events = await client.queryCalendarEvents(WINDOW); + + expect(bounds()).toHaveLength(3); + expect(events.map((e) => e.id).sort()).toEqual([...IN_WINDOW].sort()); + // The occurrences are hydrated from their series, once. + expect(events.find((e) => e.id === 'o-middle')?.recurrenceRules).toEqual([{ frequency: 'daily' }]); + expect(server.gets().filter((g) => (g.args.ids as string[]).includes('series'))).toHaveLength(1); + }); + + it('halves a chunk the server finds too long on Stalwart 1.0', async () => { + // The server enforces less than it advertises. + await connect('1.0', { maxSpanSeconds: 200 * DAY }); + + const events = await client.queryCalendarEvents(WINDOW); + + const spans = bounds().map(([after, before]) => (localSeconds(before) - localSeconds(after)) / DAY); + // The two 364-day chunks are refused and halved; the 49-day one is not. + expect(spans.filter((days) => days > 200)).toHaveLength(2); + expect(spans.filter((days) => days <= 200)).toHaveLength(5); + expect(events.map((e) => e.id).sort()).toEqual([...IN_WINDOW].sort()); + }); + + it('splits when refused even without the capability on Stalwart 1.0', async () => { + await connect('1.0', { maxExpandedQueryDuration: null }); + + const events = await client.queryCalendarEvents(WINDOW); + + // One query for the whole window, refused; its halves (388.5 days), + // refused; their halves. + expect(bounds()[0]).toEqual([WINDOW.after, WINDOW.before]); + expect(bounds()).toHaveLength(1 + 2 + 4); + expect(events.map((e) => e.id).sort()).toEqual([...IN_WINDOW].sort()); + }); + + it.each<[StalwartVersion]>([['0.16'], ['1.0']])( + 'halves chunks that expand to too many occurrences on Stalwart %s', + async (version) => { + // Only a chunk holding at most two occurrences is answered. + await connect(version, { maxInstances: 2 }); + + const events = await client.queryCalendarEvents(WINDOW); + + expect(expandedQueries().length).toBeGreaterThan(3); + expect(server.queries().every((q) => q.args.expandRecurrences)).toBe(true); + expect(events.map((e) => e.id).sort()).toEqual([...IN_WINDOW].sort()); + }, + ); + + it.each<[StalwartVersion]>([['0.16'], ['1.0']])( + 'expands the whole range on the client when a week is still too much on Stalwart %s', + async (version) => { + await connect(version, { maxInstances: 0 }); + + const events = await client.queryCalendarEvents(WINDOW); + + // One query for the series over the whole window; none of the + // occurrences found by chunks before giving up are mixed in. + const plain = server.queries().filter((q) => !q.args.expandRecurrences); + expect(plain).toHaveLength(1); + expect(plain[0].args.filter).toMatchObject(WINDOW); + expect(events.map((e) => e.id)).toEqual(['series']); + expect(events[0].recurrenceRules).toEqual([{ frequency: 'daily' }]); + }, + ); + + it('sends as many chunks per request as maxCallsInRequest allows', async () => { + await connect('1.0', { maxCallsInRequest: 2 }); + + await client.queryCalendarEvents(WINDOW); + + expect(expandedQueries().map((q) => q.request)).toEqual([ + expandedQueries()[0].request, expandedQueries()[0].request, expandedQueries()[0].request + 1, + ]); + }); + + it('reads the chunks one at a time in start order and stops at the limit (contact activity) on Stalwart 1.0', async () => { + await connect('1.0'); + + const events = await client.queryCalendarEvents(WINDOW, [{ property: 'start', isAscending: true }], 2); + + // The first chunk fills the limit: no other chunk is asked. + expect(bounds()).toEqual([['2025-01-01T00:00:00', '2025-12-31T00:00:00']]); + expect(expandedQueries()[0].args.limit).toBe(2); + expect(events.map((e) => e.id).sort()).toEqual(['o-across', 'o-first']); + }); + + it('reads the latest chunk first for a descending sort (search) on Stalwart 1.0', async () => { + await connect('1.0'); + + const events = await client.queryCalendarEvents(WINDOW, [{ property: 'start', isAscending: false }], 2); + + // The last chunk holds one; the middle one supplies the second. + expect(bounds()).toEqual([ + ['2026-12-29T23:59:58', '2027-02-16T23:59:59'], + ['2025-12-30T23:59:59', '2026-12-29T23:59:59'], + ]); + expect(expandedQueries().map((q) => q.args.limit)).toEqual([2, 1]); + expect(expandedQueries()[0].request).not.toBe(expandedQueries()[1].request); + expect(events.map((e) => e.id).sort()).toEqual(['o-late', 'o-middle']); + }); + + it('pages on within a chunk when an occurrence both chunks share fills the page on Stalwart 1.0', async () => { + await connect('1.0'); + + // First chunk: o-first, o-across. Second chunk (by start): o-across, + // o-border, o-middle - the shared one comes back again and is skipped. + const events = await client.queryCalendarEvents(WINDOW, [{ property: 'start', isAscending: true }], 3); + + expect(expandedQueries().map((q) => [q.args.position, q.args.limit])).toEqual([[0, 3], [0, 1], [1, 1]]); + expect(events.map((e) => e.id).sort()).toEqual(['o-across', 'o-border', 'o-first']); + }); + + it('loads the contact activity look-ahead (365 days of UTC bounds, by start, limited) on Stalwart 1.0', async () => { + await connect('1.0'); + const now = new Date('2026-03-01T12:00:00Z'); + + const events = await client.queryAllCalendarEvents( + { after: now.toISOString(), before: new Date(now.getTime() + 365 * DAY * 1000).toISOString() }, + [{ property: 'start', isAscending: true }], + 500, + ); + + expect(bounds()).toHaveLength(2); + expect(events.map((e) => e.id).sort()).toEqual(['o-after', 'o-late', 'o-middle']); + }); + + it('asks for two chunks for a 365-day look-ahead on Stalwart 1.0', async () => { + await connect('1.0'); + + await client.queryCalendarEvents({ after: '2026-01-01T00:00:00', before: '2027-01-01T00:00:00' }); + + expect(bounds()).toEqual([ + ['2026-01-01T00:00:00', '2026-12-31T00:00:00'], + ['2026-12-30T23:59:59', '2027-01-01T00:00:00'], + ]); + }); + + it('reports a refusal it cannot split as a failure on Stalwart 1.0', async () => { + fetchSpy = vi.spyOn(globalThis, 'fetch'); + client = await connectCalendarClient(fetchSpy, stalwartCalendarSession('1.0')); + server = installStalwartCalendarMock(fetchSpy, { + version: '1.0', + answer: (call) => call.method === 'CalendarEvent/query' + ? ['error', { type: 'invalidArguments', description: 'Invalid timeZone "Mars/Olympus"' }] + : undefined, + }); + + await expect(client.queryCalendarEvents(WINDOW)).rejects.toThrow(/Invalid timeZone/); + expect(server.queries()).toHaveLength(3); + }); +}); diff --git a/lib/global-search/__tests__/providers.test.ts b/lib/global-search/__tests__/providers.test.ts index 3335ae30f..d2b365dfb 100644 --- a/lib/global-search/__tests__/providers.test.ts +++ b/lib/global-search/__tests__/providers.test.ts @@ -1,6 +1,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { parseSearchQuery } from '../query-parser'; import type { SearchAccount } from '../types'; +import { + connectCalendarClient, + expandingQueryAnswer, + installStalwartCalendarMock, + occurrenceObjects, + stalwartCalendarSession, +} from '../../__tests__/helpers/stalwart-calendar'; // --------------------------------------------------------------------------- // Store mocks. Providers read caches via `useXStore.getState()` and resolve @@ -196,6 +203,43 @@ describe('calendar provider', () => { expect(hits).toHaveLength(1); }); + it('searches more than a year between after: and before: in chunks on Stalwart 1.0', async () => { + // Stalwart 1.0 refuses an expanded query over more than + // maxExpandedQueryDuration (365 days); the client splits the range. + const occurrences = [ + { id: 'o-2024', start: '2024-03-05T09:00:00', seconds: 3600 }, + { id: 'o-2025', start: '2025-06-10T09:00:00', seconds: 3600 }, + { id: 'o-2026', start: '2026-08-20T09:00:00', seconds: 3600 }, + ]; + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + const client = await connectCalendarClient(fetchSpy, stalwartCalendarSession('1.0')); + const server = installStalwartCalendarMock(fetchSpy, { + version: '1.0', + objects: { series: { id: 'series', recurrenceRule: { frequency: 'yearly' }, showWithoutTime: false, duration: 'PT1H' } }, + occurrences: occurrenceObjects('series', occurrences), + answer: expandingQueryAnswer('1.0', { seriesId: 'series', occurrences }), + }); + try { + const result = await calendarProvider.remote( + parseSearchQuery('standup after:2024-01-01 before:2026-12-31'), + { ...account('login-a'), client }, + { limit: 10, signal }, + ); + + expect(result.hits.map((h) => h.id).sort()).toEqual(['o-2024', 'o-2025', 'o-2026']); + // Three years in chunks of 364 days, the latest first, each still + // filtered by the search text. + const expanded = server.queries().filter((q) => q.args.expandRecurrences); + expect(expanded).toHaveLength(4); + const bounds = expanded.map((q) => q.args.filter as { after: string; before: string; text: string }); + expect(bounds.every((filter) => filter.text === 'standup')).toBe(true); + expect(bounds[0].before > bounds[1].before && bounds[1].before > bounds[2].before).toBe(true); + } finally { + client.disconnect(); + fetchSpy.mockRestore(); + } + }); + it('remote queries every JMAP account of the login and flags recurring masters', async () => { const queryAllCalendarEvents = vi.fn(async () => [ { id: 'e9', title: 'Weekly sync', start: '2026-04-01T09:00:00', calendarIds: {}, accountId: 'jmap-a', recurrenceRules: [{}], recurrenceId: null }, diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index e126a2b2b..85e35cbe4 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -7,7 +7,7 @@ import { batched, itemsPerRequest } from "./request-limits"; import { keywordPointer } from "./patch-pointer"; import { FirstTouchGate } from "./first-touch-gate"; import { debug } from "@/lib/debug"; -import { normalizeCalendarEventLike } from "@/lib/calendar-event-normalization"; +import { normalizeCalendarEventLike, parseDurationSeconds } from "@/lib/calendar-event-normalization"; import { SYNTHETIC_ID_PROBE, RECURRENCE_BASE_PROPERTIES, hydrateRecurrenceInstances, isServerRecurrenceInstance } from "@/lib/recurrence-instances"; import { findTasksOnlyCalendarIds, isTaskLikeObject, type ScannedCalendarObject } from "@/lib/calendar-component-detection"; import { DEFAULT_CALENDAR_COMPONENTS, mkCalendarCollection, newCalendarCollectionName } from "@/lib/webdav/calendar-collection"; @@ -717,6 +717,85 @@ function isOccurrenceLimitError(error: unknown): boolean { return type === 'invalidArguments' && /exceeds/i.test(error instanceof Error ? error.message : ''); } +const DAY_SECONDS = 86_400; +// Chunks of an expanded range stay a day below maxExpandedQueryDuration: the +// server measures the span in UTC after resolving the bounds in the request +// time zone, so a DST change or an ambiguous local time moves it. +const EXPANDED_CHUNK_SLACK = DAY_SECONDS; +// A chunk that still expands to too many occurrences is halved down to about +// a week, one the server finds too long down to an hour; past that, and past +// this many queries for one range, the range is expanded on the client. +const MIN_OCCURRENCE_CHUNK = 7 * DAY_SECONDS; +const MIN_DURATION_CHUNK = 3600; +const MAX_EXPANDED_CHUNKS = 64; + +/** + * Part of an expanded CalendarEvent/query range, in seconds of the request + * time zone's wall clock (the LocalDateTime bounds counted as if UTC). + */ +interface RangeChunk { + start: number; + end: number; + /** Where the next page of this chunk starts. */ + position: number; +} + +const LOCAL_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/; + +/** A LocalDateTime such as "2026-09-01T00:00:00" as wall-clock seconds, or null. */ +function wallClockSeconds(value: unknown): number | null { + const match = typeof value === 'string' ? LOCAL_DATE_TIME.exec(value) : null; + if (!match) return null; + const [year, month, day, hour, minute, second] = match.slice(1).map(Number); + const date = new Date(0); + date.setUTCFullYear(year, month - 1, day); + date.setUTCHours(hour, minute, second, 0); + return date.getTime() / 1000; +} + +function localDateTimeOf(seconds: number): string { + const date = new Date(seconds * 1000); + const pad = (n: number, length = 2) => String(n).padStart(length, '0'); + return `${pad(date.getUTCFullYear(), 4)}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}` + + `T${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}`; +} + +/** + * [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 + * counts an occurrence in a range only when it overlaps it strictly, so one + * that sits exactly on a border would belong to neither chunk. + */ +function chunkRange(start: number, end: number, span: number): RangeChunk[] | null { + if (span < 2) return null; + const chunks: RangeChunk[] = []; + for (let from = start; chunks.length < MAX_EXPANDED_CHUNKS; from = chunks[chunks.length - 1].end - 1) { + const to = Math.min(from + span, end); + chunks.push({ start: from, end: to, position: 0 }); + if (to >= end) return chunks; + } + return null; +} + +/** + * What to query instead of a chunk the server refused: its two halves, or + * null when it is already as small as it gets and the range has to be + * expanded on the client. Any other refusal is rethrown. + */ +function splitRefusedChunk(chunk: RangeChunk, error: unknown): RangeChunk[] | null { + const length = chunk.end - chunk.start; + let minimum: number; + if (jmapErrorType(error) === 'expandDurationTooLarge') minimum = MIN_DURATION_CHUNK; + else if (isOccurrenceLimitError(error)) minimum = MIN_OCCURRENCE_CHUNK; + else throw error; + if (length <= minimum) return null; + const middle = Math.floor((chunk.start + chunk.end) / 2); + return [ + { start: chunk.start, end: middle, position: 0 }, + { start: middle - 1, end: chunk.end, position: 0 }, + ]; +} + function hasSubmissionMethod(methodCalls: JMAPMethodCall[]): boolean { return methodCalls.some(([method]) => method.startsWith('Identity/') || method.startsWith('EmailSubmission/')); } @@ -6834,14 +6913,12 @@ export class JMAPClient implements IJMAPClient { let ids: string[] | null = null; if (expandRecurrences) { - try { - ids = await this.queryCalendarEventIds({ ...queryArgs, expandRecurrences: true }, limit); - } catch (error) { - // Stalwart fails the whole query once a range expands to more - // occurrences than it allows. Ask for the series instead; - // lib/recurrence-expansion.ts expands them on the client. - if (!isOccurrenceLimitError(error)) throw error; - debug.log('calendar', 'Range expands past the server limit; expanding on the client instead'); + ids = await this.queryExpandedCalendarEventIds(accountId, queryArgs, limit); + if (!ids) { + // The server will not expand this range within its limits. Ask + // for the series instead; lib/recurrence-expansion.ts expands + // them on the client. + debug.log('calendar', 'Range expands past the server limits; expanding on the client instead'); expandRecurrences = false; } } @@ -6913,6 +6990,109 @@ export class JMAPClient implements IJMAPClient { } return ids; } + /** + * The longest range an expanded CalendarEvent/query may span, in seconds: + * `maxExpandedQueryDuration` from the account's calendars capability (the + * primary calendar account's when the account has none), an iCalendar + * duration such as "P365D". Every 0.16 advertises 365 days without + * enforcing it; 1.0 refuses a longer range with `expandDurationTooLarge`. + */ + private getMaxExpandedQuerySeconds(accountId: string): number | null { + for (const id of [accountId, this.getCalendarsAccountId()]) { + const capability = this.getAccountCapability("urn:ietf:params:jmap:calendars", id) as + { maxExpandedQueryDuration?: unknown } | undefined; + const duration = capability?.maxExpandedQueryDuration; + const seconds = typeof duration === 'string' ? parseDurationSeconds(duration) : null; + if (seconds) return seconds; + } + return null; + } + + /** + * The occurrence ids of an expanded range query, or null when the server + * will not expand the range and the client has to. + * + * The range is cut into chunks within the server's maxExpandedQueryDuration + * and the chunk queries go out together, as many per request as + * maxCallsInRequest allows. A synthetic id does not depend on the range it + * was found in, so an occurrence two chunks share is kept once. A chunk the + * server still refuses is halved: when it is too long + * (`expandDurationTooLarge`), or when it expands to too many occurrences, + * down to about a week. Past that the whole range is given up - mixing + * server- and client-expanded occurrences would show series twice. + * + * With a limit the chunks are queried one at a time in the sort order (by + * start), until the limit is reached. + */ + private async queryExpandedCalendarEventIds( + accountId: string, + queryArgs: Record, + limit?: number, + ): Promise { + const expandedArgs = { ...queryArgs, expandRecurrences: true }; + const filter = queryArgs.filter as Record; + const start = wallClockSeconds(filter.after); + const end = wallClockSeconds(filter.before); + if (start === null || end === null || end <= start) { + try { + return await this.queryCalendarEventIds(expandedArgs, limit); + } catch (error) { + if (jmapErrorType(error) === 'expandDurationTooLarge' || isOccurrenceLimitError(error)) return null; + throw error; + } + } + + const maxSeconds = this.getMaxExpandedQuerySeconds(accountId); + const span = maxSeconds === null + ? end - start + : maxSeconds - Math.min(EXPANDED_CHUNK_SLACK, Math.floor(maxSeconds / 2)); + const chunks = chunkRange(start, end, span); + if (!chunks) return null; + const [firstSort] = (queryArgs.sort as Array<{ property?: string; isAscending?: boolean }> | undefined) ?? []; + const latestFirst = firstSort?.property === 'start' && firstSort.isAscending === false; + if (latestFirst) chunks.reverse(); + + const pageSize = limit || 1000; + const maxIds = limit || 20000; + const ids = new Set(); + let pending = chunks; + let chunksSent = 0; + while (pending.length > 0 && ids.size < maxIds) { + const batch = pending.splice(0, limit ? 1 : this.getMaxCallsInRequest()); + // Pages are bounded by maxIds; splitting is what has to stop somewhere. + chunksSent += batch.filter((chunk) => chunk.position === 0).length; + if (chunksSent > MAX_EXPANDED_CHUNKS) return null; + const size = limit ? limit - ids.size : pageSize; + const response = await this.request(batch.map((chunk, i): JMAPMethodCall => ["CalendarEvent/query", { + ...expandedArgs, + filter: { ...filter, after: localDateTimeOf(chunk.start), before: localDateTimeOf(chunk.end) }, + position: chunk.position, + limit: size, + }, String(i)]), this.calendarUsing()); + + // The halves of a refused chunk, and the next page of a full one, go + // to the front of the queue. + const again: RangeChunk[] = []; + for (const [i, chunk] of batch.entries()) { + let page: string[]; + try { + page = methodResult(response, String(i), "CalendarEvent/query").ids || []; + } catch (error) { + const halves = splitRefusedChunk(chunk, error); + if (!halves) return null; + again.push(...(latestFirst ? halves.reverse() : halves)); + continue; + } + for (const id of page) { + if (ids.size >= maxIds) break; + ids.add(id); + } + if (page.length === size) again.push({ ...chunk, position: chunk.position + page.length }); + } + pending = [...again, ...pending]; + } + return [...ids]; + } /** * Server-expanded occurrences come back without their series' recurrence