diff --git a/lib/__tests__/jmap-calendar-query-range.test.ts b/lib/__tests__/jmap-calendar-query-range.test.ts new file mode 100644 index 000000000..c7d38606f --- /dev/null +++ b/lib/__tests__/jmap-calendar-query-range.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, vi } from 'vitest'; +import { JMAPClient } from '../jmap/client'; +import { toLocalDateTime } from '../timezone'; + +type Call = [string, Record, string]; + +function clientAnswering(answer: (args: Record) => [string, Record]) { + const client = new JMAPClient('https://jmap.example.com', 'user@example.com', 'pass'); + Object.assign(client, { accountId: 'a1', capabilities: {}, session: { accounts: {}, primaryAccounts: {} } }); + const queries: Record[] = []; + vi.spyOn(client as unknown as { supportsSyntheticCalendarIds: () => Promise }, 'supportsSyntheticCalendarIds') + .mockResolvedValue(true); + vi.spyOn(client as unknown as { request: (c: Call[]) => Promise }, 'request') + .mockImplementation(async (calls: Call[]) => { + const [method, args, id] = calls[0]; + if (method === 'CalendarEvent/query') { + queries.push(args); + return { methodResponses: [[...answer(args), id]] }; + } + const ids = (args.ids as string[]) ?? []; + return { + methodResponses: [['CalendarEvent/get', { + list: ids.map((eid) => ({ id: eid, '@type': 'Event', start: '2026-09-22T10:00:00', duration: 'PT1H', calendarIds: { c: true } })), + }, id]], + }; + }); + return { client, queries }; +} + +describe('toLocalDateTime', () => { + it('turns UTC and offset times into the wall clock of the zone', () => { + expect(toLocalDateTime('2026-09-22T00:10:00Z', 'Europe/Berlin')).toBe('2026-09-22T02:10:00'); + expect(toLocalDateTime('2026-09-22T00:10:00.000Z', 'Europe/Berlin')).toBe('2026-09-22T02:10:00'); + expect(toLocalDateTime('2026-09-22T02:10:00+02:00')).toBe('2026-09-22T00:10:00'); + }); + + it('leaves LocalDateTime values alone', () => { + expect(toLocalDateTime('2026-09-22T02:10:00', 'Europe/Berlin')).toBe('2026-09-22T02:10:00'); + }); +}); + +describe('queryCalendarEvents range handling', () => { + it('sends UTC bounds as wall clock in the request time zone', async () => { + const { client, queries } = clientAnswering(() => ['CalendarEvent/query', { ids: [] }]); + await client.queryCalendarEvents({ after: '2026-09-22T00:10:00Z', before: '2026-09-22T00:20:00Z' }); + const filter = queries[0].filter as { after: string; before: string }; + const tz = queries[0].timeZone as string | undefined; + expect(filter.after).toBe(toLocalDateTime('2026-09-22T00:10:00Z', tz)); + expect(filter.after.endsWith('Z')).toBe(false); + }); + + it('falls back to series when the range expands past the server limit', 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('pages through more than one page of ids', async () => { + const all = Array.from({ length: 1500 }, (_, i) => `e${i}`); + const { client, queries } = clientAnswering((args) => { + const position = args.position as number; + return ['CalendarEvent/query', { ids: all.slice(position, position + (args.limit as number)) }]; + }); + const events = await client.queryCalendarEvents({ after: '2026-09-01T00:00:00', before: '2026-10-01T00:00:00' }); + expect(queries.map((q) => q.position)).toEqual([0, 1000]); + expect(events).toHaveLength(1500); + }); + + it('keeps an explicit limit to one page', async () => { + const { client, queries } = clientAnswering(() => ['CalendarEvent/query', { ids: ['a', 'b'] }]); + await client.queryCalendarEvents({ after: '2026-09-01T00:00:00', before: '2026-10-01T00:00:00' }, undefined, 2); + expect(queries).toHaveLength(1); + expect(queries[0].limit).toBe(2); + }); +}); diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 4c8d13724..9faa9c883 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -15,7 +15,7 @@ import { DEFAULT_CALENDAR_COMPONENTS, mkCalendarCollection, newCalendarCollectio import { sanitizeDisplayName, splitMailbox } from "@/lib/rfc5322-mailbox"; import { decodeFileNodeName } from "./filenode-name"; import { contactFromWire, contactToWire } from "./contact-wire"; -import { getEffectiveTimeZone } from "@/lib/timezone"; +import { getEffectiveTimeZone, toLocalDateTime } from "@/lib/timezone"; import { buildEmailSort, compareEmails, hasKeywordLevels, type KeywordSortPolarity, type SortLevel } from "@/lib/message-list-order"; // Cap for the follow-up Email/get issued when a displayed body part comes @@ -6414,11 +6414,16 @@ export class JMAPClient implements IJMAPClient { const queryArgs: Record = { accountId, - filter, - limit: limit || 1000, + filter: { + ...filter, + ...(filter.after ? { after: toLocalDateTime(filter.after, timeZone) } : {}), + ...(filter.before ? { before: toLocalDateTime(filter.before, timeZone) } : {}), + }, }; // Interpret the LocalDateTime after/before filter values in the user's // time zone (Stalwart defaults to UTC, shifting range boundaries). + // UTC bounds are converted to that wall clock above, because Stalwart + // drops a `Z` or offset rather than honouring it. if (timeZone) { queryArgs.timeZone = timeZone; } @@ -6427,7 +6432,7 @@ export class JMAPClient implements IJMAPClient { // (Stalwart >= 0.16.20; the server needs both range bounds for it). // Older servers keep the client-side expansion in // lib/recurrence-expansion.ts, which runs on whatever comes back. - const expandRecurrences = Boolean(filter.after && filter.before) + let expandRecurrences = Boolean(filter.after && filter.before) && await this.supportsSyntheticCalendarIds(); if (expandRecurrences) { queryArgs.expandRecurrences = true; @@ -6437,23 +6442,45 @@ export class JMAPClient implements IJMAPClient { } const GET_BATCH_SIZE = this.getMaxObjectsInGet(); + // Without an explicit limit, page through the whole range: a busy + // calendar (or a long expanded range) holds more than one page. + const pageSize = limit || 1000; + const maxIds = limit || 20000; // First, query to get IDs - const queryResponse = await this.request([ - ["CalendarEvent/query", queryArgs, "0"], - ], this.calendarUsing()); + const ids: string[] = []; + for (let position = 0; ids.length < maxIds;) { + const queryResponse = await this.request([ + ["CalendarEvent/query", { ...queryArgs, limit: pageSize, position }, "0"], + ], this.calendarUsing()); + const [name, result] = queryResponse.methodResponses?.[0] ?? []; - if (queryResponse.methodResponses?.[0]?.[0] === "error") { - const error = queryResponse.methodResponses[0][1]; - // Keep the JMAP error type so the catch below can tell an expected - // access rejection apart from a genuine failure. - throw Object.assign( - new Error(error?.description || error?.type || "CalendarEvent/query failed"), - { jmapErrorType: error?.type }, - ); + if (name === "error") { + // Stalwart fails the whole query once a range expands to more than + // maxRecurrenceExpansions occurrences. Ask for the series instead; + // lib/recurrence-expansion.ts expands them on the client. + if (expandRecurrences && result?.type === "invalidArguments" && + /expanded recurrences exceeds/i.test(result?.description ?? "")) { + debug.log('calendar', 'Range expands past the server limit; expanding on the client instead'); + expandRecurrences = false; + delete queryArgs.expandRecurrences; + ids.length = 0; + position = 0; + continue; + } + // Keep the JMAP error type so the catch below can tell an expected + // access rejection apart from a genuine failure. + throw Object.assign( + new Error(result?.description || result?.type || "CalendarEvent/query failed"), + { jmapErrorType: result?.type }, + ); + } + + const page: string[] = result?.ids || []; + ids.push(...page); + if (limit || page.length < pageSize) break; + position += page.length; } - - const ids: string[] = queryResponse.methodResponses?.[0]?.[1]?.ids || []; if (ids.length === 0) return []; // Batch the /get calls to stay within server max-objects limit diff --git a/lib/timezone.ts b/lib/timezone.ts index 0394a88bd..220cb22c9 100644 --- a/lib/timezone.ts +++ b/lib/timezone.ts @@ -134,6 +134,24 @@ export function getWallClock(date: Date, timeZone: string): WallClock { }; } +/** + * A JMAP LocalDateTime ("YYYY-MM-DDTHH:mm:ss") for a date-time string, as the + * wall clock in `timeZone` (UTC when unset). Strings that already carry no + * zone are returned as they are; one with `Z` or an offset is converted. + * + * CalendarEvent/query reads `after`/`before` as LocalDateTime in the request's + * timeZone, and Stalwart silently drops a `Z` or offset instead of honouring + * it, which shifted every UTC range by the user's offset. + */ +export function toLocalDateTime(value: string, timeZone?: string): string { + if (!/(?:Z|[+-]\d{2}:?\d{2})$/i.test(value)) return value; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + const w = getWallClock(date, timeZone || "UTC"); + const pad = (n: number, len = 2) => String(n).padStart(len, "0"); + return `${pad(w.year, 4)}-${pad(w.month)}-${pad(w.day)}T${pad(w.hour)}:${pad(w.minute)}:${pad(w.second)}`; +} + /** UTC offset of `timeZone` at the given instant, in milliseconds (east = positive). */ export function getTimeZoneOffsetMs(date: Date, timeZone: string): number { const w = getWallClock(date, timeZone);