From a002b09e02e037064cdad7cf16e95209e994da35 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:42:07 +0200 Subject: [PATCH] fix: query calendar ranges in the right time zone and past the limits CalendarEvent/query reads `after`/`before` as local date-times in the request's `timeZone`, and Stalwart silently drops a `Z` or offset. The proactive alert fetch and the contact activity panel sent UTC strings, so their windows were shifted by the user's UTC offset (two hours in Berlin in summer). UTC and offset bounds are now converted to the wall clock of the request time zone before the query is sent. With server-side expansion, a range with more than `maxRecurrenceExpansions` occurrences (a daily series over ten years) failed the whole query with `invalidArguments`, and the calendar showed nothing. The query now falls back to the unexpanded series, which the calendar expands on the client as it does for older servers. The query also stopped at a fixed 1000 ids. Without an explicit limit it now pages until the range is complete. --- .../jmap-calendar-query-range.test.ts | 78 +++++++++++++++++++ lib/jmap/client.ts | 61 +++++++++++---- lib/timezone.ts | 18 +++++ 3 files changed, 140 insertions(+), 17 deletions(-) create mode 100644 lib/__tests__/jmap-calendar-query-range.test.ts 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);