diff --git a/lib/__tests__/helpers/stalwart-calendar.ts b/lib/__tests__/helpers/stalwart-calendar.ts index eba2420a5..67fd09f42 100644 --- a/lib/__tests__/helpers/stalwart-calendar.ts +++ b/lib/__tests__/helpers/stalwart-calendar.ts @@ -3,6 +3,9 @@ // - 1.0 refuses a CalendarEvent/get that asks for `recurrenceOverrides` // together with `utcStart`/`utcEnd` (jmap/src/calendar_event/get.rs, // ProjectionRequest::new), before reading any id; +// - 1.0 refuses a CalendarEvent/get or /query whose `timeZone` its time zone +// database lacks (jmap-proto/src/object/calendar_event.rs, +// resolved_time_zone); 0.16 reads it as UTC; // - 0.16 answers any property list. // Both advertise `maxExpandedQueryDuration` in the account's calendars // capability (0.16 hardcodes 365 days and never enforces it). @@ -46,6 +49,13 @@ export interface StalwartCalendarMockOptions { syntheticIds?: boolean; /** Answer a call yourself; return undefined for the default answer. */ answer?: (call: RecordedCall) => MethodAnswer | undefined; + /** The time zones the server's database has; 1.0 refuses any other (all known when unset). */ + knownTimeZones?: string[]; +} + +/** Stalwart 1.0's answer to a calendar get or query naming a zone it does not know. */ +export function invalidTimeZone(timeZone: string): MethodAnswer { + return ['error', { type: 'invalidArguments', description: `Invalid timeZone ${JSON.stringify(timeZone)}` }]; } export interface StalwartCalendarMock { @@ -186,7 +196,11 @@ export function installStalwartCalendarMock( for (const [method, args, callId] of body.methodCalls ?? []) { const call: RecordedCall = { method, args, callId, request }; calls.push(call); - const answer = options.answer?.(call) ?? defaultAnswer(call); + const timeZone = args?.timeZone; + const refusesTimeZone = options.version === '1.0' && typeof timeZone === 'string' + && !!options.knownTimeZones && !options.knownTimeZones.includes(timeZone) + && (method === 'CalendarEvent/get' || method === 'CalendarEvent/query'); + const answer = refusesTimeZone ? invalidTimeZone(timeZone) : options.answer?.(call) ?? defaultAnswer(call); methodResponses.push([...answer, callId]); } return jsonResponse({ methodResponses }); diff --git a/lib/__tests__/jmap-calendar-query-range.test.ts b/lib/__tests__/jmap-calendar-query-range.test.ts index 4bee3bde3..e9ae3d95b 100644 --- a/lib/__tests__/jmap-calendar-query-range.test.ts +++ b/lib/__tests__/jmap-calendar-query-range.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { JMAPClient } from '../jmap/client'; import { jmapErrorType } from '../jmap/jmap-error'; -import { toLocalDateTime } from '../timezone'; +import { AUTO_TIME_ZONE, toLocalDateTime } from '../timezone'; +import { useSettingsStore } from '@/stores/settings-store'; import { connectCalendarClient, expandingQueryAnswer, @@ -87,11 +88,11 @@ describe('queryCalendarEvents range handling', () => { ); 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"' }]); + const { client } = clientAnswering(() => ['error', { type: 'invalidArguments', description: 'The filter must be a single FilterCondition when expanding recurrences' }]); const error = await client.queryCalendarEvents({ after: '2026-09-01T00:00:00', before: '2026-09-30T23:59:59' }) .then(() => null, (e: unknown) => e); expect(jmapErrorType(error)).toBe('invalidArguments'); - expect((error as Error).message).toContain('Invalid timeZone'); + expect((error as Error).message).toContain('single FilterCondition'); }); it('pages through more than one page of ids', async () => { @@ -339,11 +340,135 @@ describe('expanded ranges longer than maxExpandedQueryDuration', () => { server = installStalwartCalendarMock(fetchSpy, { version: '1.0', answer: (call) => call.method === 'CalendarEvent/query' - ? ['error', { type: 'invalidArguments', description: 'Invalid timeZone "Mars/Olympus"' }] + ? ['error', { type: 'invalidArguments', description: 'The filter must be a single FilterCondition when expanding recurrences' }] : undefined, }); - await expect(client.queryCalendarEvents(WINDOW)).rejects.toThrow(/Invalid timeZone/); + await expect(client.queryCalendarEvents(WINDOW)).rejects.toThrow(/single FilterCondition/); expect(server.queries()).toHaveLength(3); }); }); + +// Stalwart 1.0 fails every CalendarEvent get and query whose `timeZone` its +// time zone database lacks, with invalidArguments "Invalid timeZone \"X\""; +// 0.16 reads such a zone as UTC. The session's first calendar list checks the +// user's zone on the way. Once refused, the zone is replaced by UTC, range +// bounds included, with one console warning. +describe('a time zone the server does not know', () => { + // UTC+9 all year, so the expected bounds do not depend on DST. + const ZONE = 'Asia/Tokyo'; + const GRID = { after: '2026-09-01T00:00:00', before: '2026-09-30T23:59:59' }; + const GRID_IN_UTC = { after: '2026-08-31T15:00:00', before: '2026-09-30T14:59:59' }; + const SERIES = { + id: 'b', '@type': 'Event', uid: 'series@example.com', title: 'Standup', baseEventId: 'b', + start: '2026-09-07T10:00:00', duration: 'PT1H', recurrenceRule: { frequency: 'daily' }, + recurrenceOverrides: null, calendarIds: { cal: true }, + }; + const TASK = { id: 't', '@type': 'Task', uid: 'task@example.com', title: 'Pay rent', due: '2026-09-10T00:00:00', calendarIds: { cal: true } }; + const OCCURRENCE = { + ...SERIES, id: 'maaaaab', recurrenceRule: null, start: '2026-09-08T10:00:00', recurrenceId: '2026-09-08T10:00:00', + }; + + let fetchSpy: ReturnType; + let warn: ReturnType; + let client: JMAPClient; + let server: StalwartCalendarMock; + + async function connect(version: StalwartVersion) { + useSettingsStore.setState({ timeZone: ZONE }); + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + fetchSpy = vi.spyOn(globalThis, 'fetch'); + client = await connectCalendarClient(fetchSpy, stalwartCalendarSession(version)); + server = installStalwartCalendarMock(fetchSpy, { + version, + objects: { b: SERIES, t: TASK }, + occurrences: { maaaaab: OCCURRENCE }, + knownTimeZones: ['UTC', 'Europe/Berlin'], + }); + } + + afterEach(() => { + useSettingsStore.setState({ timeZone: AUTO_TIME_ZONE }); + client.disconnect(); + fetchSpy.mockRestore(); + warn.mockRestore(); + }); + + const zones = () => server.calls + .filter((c) => c.method === 'CalendarEvent/get' || c.method === 'CalendarEvent/query') + .map((c) => c.args.timeZone); + + it('checks the zone with the first calendar list and asks in UTC from then on on Stalwart 1.0', async () => { + await connect('1.0'); + + await client.getAllCalendars(); + const events = await client.queryCalendarEvents(GRID); + + // The check rode along with Calendar/get: a get of no events. + const probe = server.calls.find((c) => c.method === 'CalendarEvent/get' && (c.args.ids as string[]).length === 0)!; + expect(probe.request).toBe(server.calls.find((c) => c.method === 'Calendar/get')!.request); + expect(probe.args).toMatchObject({ properties: ['id'], timeZone: ZONE }); + // Everything after it asks in UTC, with the grid's wall-clock bounds moved along. + expect(zones()).toEqual([ZONE, ...zones().slice(1).map(() => 'UTC')]); + const query = server.queries().find((q) => q.args.expandRecurrences)!; + expect(query.args.filter).toEqual(GRID_IN_UTC); + expect(events.map((e) => e.id)).toEqual(['maaaaab']); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0][0])).toContain(ZONE); + }); + + it('asks once more in UTC when a read comes before any check on Stalwart 1.0', async () => { + await connect('1.0'); + + const events = await client.queryCalendarEvents(GRID); + + const [refused, ...rest] = server.queries(); + expect(refused.args).toMatchObject({ timeZone: ZONE, filter: GRID }); + expect(rest.map((q) => [q.args.timeZone, q.args.filter])).toEqual([['UTC', GRID_IN_UTC]]); + expect(events.map((e) => e.id)).toEqual(['maaaaab']); + + // Later reads go out in UTC straight away. + server.calls.length = 0; + await expect(client.getCalendarEvent('b')).resolves.toMatchObject({ title: 'Standup' }); + await expect(client.getCalendarTasks()).resolves.toMatchObject([{ id: 't' }]); + await expect(client.getCalendarEvents()).resolves.toMatchObject([{ id: 'b' }]); + expect(new Set(zones())).toEqual(new Set(['UTC'])); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it('moves UTC bounds (search, reminders) to the UTC wall clock after a refusal on Stalwart 1.0', async () => { + await connect('1.0'); + + await client.queryCalendarEvents({ after: '2026-09-01T00:00:00Z', before: '2026-09-02T00:00:00Z' }); + + expect(server.queries().map((q) => [q.args.timeZone, q.args.filter])).toEqual([ + [ZONE, { after: '2026-09-01T09:00:00', before: '2026-09-02T09:00:00' }], + ['UTC', { after: '2026-09-01T00:00:00', before: '2026-09-02T00:00:00' }], + ]); + }); + + it('keeps the user’s zone on 0.16, which reads an unknown zone as UTC without complaint', async () => { + await connect('0.16'); + + await client.getAllCalendars(); + const events = await client.queryCalendarEvents(GRID); + await client.getCalendarTasks(); + + expect(new Set(zones())).toEqual(new Set([ZONE])); + expect(server.queries().find((q) => q.args.expandRecurrences)!.args.filter).toEqual(GRID); + expect(events.map((e) => e.id)).toEqual(['maaaaab']); + expect(warn).not.toHaveBeenCalled(); + }); + + it('sends a zone Stalwart 1.0 knows as it is', async () => { + await connect('1.0'); + useSettingsStore.setState({ timeZone: 'Europe/Berlin' }); + + await client.getAllCalendars(); + await client.queryCalendarEvents(GRID); + + expect(new Set(zones())).toEqual(new Set(['Europe/Berlin'])); + expect(server.queries().find((q) => q.args.expandRecurrences)!.args.filter).toEqual(GRID); + expect(warn).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/__tests__/jmap-calendar-synthetic-ids.test.ts b/lib/__tests__/jmap-calendar-synthetic-ids.test.ts index 1db840e92..d3a07bbe0 100644 --- a/lib/__tests__/jmap-calendar-synthetic-ids.test.ts +++ b/lib/__tests__/jmap-calendar-synthetic-ids.test.ts @@ -139,11 +139,12 @@ describe('server-side recurrence expansion with synthetic ids', () => { await client.getAllCalendars(); const events = await client.queryCalendarEvents(RANGE); - // One request carried both Calendar/get and the probe; the range query - // then already knew the verdict and asked for server-side expansion. + // One request carried both Calendar/get and the probe (and the time + // zone check, unless the zone is UTC); the range query then already knew + // the verdict and asked for server-side expansion. expect(probes()).toHaveLength(1); const firstRequest = JSON.parse(String((fetchSpy.mock.calls[0][1] as RequestInit).body)); - expect(firstRequest.methodCalls.map((c: unknown[]) => c[0])).toEqual(['Calendar/get', 'CalendarEvent/set']); + expect(firstRequest.methodCalls.map((c: unknown[]) => c[0]).slice(0, 2)).toEqual(['Calendar/get', 'CalendarEvent/set']); expect(queries()[0].args.expandRecurrences).toBe(true); expect(events.map((e) => e.id)).toEqual(['maaaaab', 'eaaaaad']); }); diff --git a/lib/__tests__/jmap-calendar-tasks.test.ts b/lib/__tests__/jmap-calendar-tasks.test.ts index 9ac3a2425..9be08d8e9 100644 --- a/lib/__tests__/jmap-calendar-tasks.test.ts +++ b/lib/__tests__/jmap-calendar-tasks.test.ts @@ -151,11 +151,11 @@ describe('JMAPClient task operations (#958)', () => { expect(sentCalls.filter((c) => c.method === 'CalendarEvent/set')).toHaveLength(1); }); - it('getCalendarTasks fails instead of returning no tasks when Stalwart 1.0 refuses the query', async () => { + it('getCalendarTasks fails instead of returning no tasks when the server refuses the query', async () => { vi.mocked(globalThis.fetch).mockImplementation(async () => jsonResponse({ - methodResponses: [['error', { type: 'invalidArguments', description: 'Invalid timeZone "Mars/Olympus"' }, '0']], + methodResponses: [['error', { type: 'serverFail', description: 'Store unavailable' }, '0']], })); - await expect(client.getCalendarTasks()).rejects.toThrow(/Invalid timeZone/); + await expect(client.getCalendarTasks()).rejects.toThrow(/Store unavailable/); }); }); diff --git a/lib/__tests__/jmap-first-touch-ordering.test.ts b/lib/__tests__/jmap-first-touch-ordering.test.ts index 5e6aa1586..beb93f637 100644 --- a/lib/__tests__/jmap-first-touch-ordering.test.ts +++ b/lib/__tests__/jmap-first-touch-ordering.test.ts @@ -100,8 +100,9 @@ describe('JMAPClient first-touch ordering (#907)', () => { await flush(); // The synthetic-id probe (lib/recurrence-instances.ts) rides along in - // the Calendar/get request, so the query needs no request of its own first. - expect(onWire[0].methods).toEqual(['Calendar/get', 'CalendarEvent/set']); + // the Calendar/get request, and so does the time zone check unless the + // zone is UTC: the query needs no request of its own first. + expect(onWire[0].methods.slice(0, 2)).toEqual(['Calendar/get', 'CalendarEvent/set']); expect(onWire.map((p) => p.methods[0])).toEqual(['Calendar/get', 'CalendarEvent/query']); onWire[1].resolve(emptyResponse('CalendarEvent/query')); await expect(events).resolves.toEqual([]); diff --git a/lib/__tests__/timezone.test.ts b/lib/__tests__/timezone.test.ts index 5fcc01f18..54477f6c8 100644 --- a/lib/__tests__/timezone.test.ts +++ b/lib/__tests__/timezone.test.ts @@ -10,6 +10,7 @@ import { isDisplayToday, isValidTimeZone, listTimeZones, + moveLocalDateTime, resolveTimeZone, toDisplayDate, toZonedDisplayDate, @@ -109,6 +110,18 @@ describe('wall-clock helpers', () => { } }); + it('moves a LocalDateTime from one zone to another at the same instant', () => { + expect(moveLocalDateTime('2026-09-01T00:00:00', 'Europe/Berlin', 'UTC')).toBe('2026-08-31T22:00:00'); + expect(moveLocalDateTime('2026-09-30T23:59:59', 'Asia/Tokyo', 'UTC')).toBe('2026-09-30T14:59:59'); + // Just after the spring-forward gap in New York (EDT, UTC-4). + expect(moveLocalDateTime('2026-03-08T03:30:00', 'America/New_York', 'UTC')).toBe('2026-03-08T07:30:00'); + expect(moveLocalDateTime('2026-09-01T00:00:00', 'UTC', 'Asia/Tokyo')).toBe('2026-09-01T09:00:00'); + // Same zone, a value with an offset, and garbage stay as they are. + expect(moveLocalDateTime('2026-09-01T00:00:00', 'Asia/Tokyo', 'Asia/Tokyo')).toBe('2026-09-01T00:00:00'); + expect(moveLocalDateTime('2026-09-01T00:00:00Z', 'Asia/Tokyo', 'UTC')).toBe('2026-09-01T00:00:00Z'); + expect(moveLocalDateTime('soon', 'Asia/Tokyo', 'UTC')).toBe('soon'); + }); + it('keeps invalid dates invalid instead of throwing', () => { const bad = new Date('nope'); expect(isNaN(toZonedDisplayDate(bad, 'Europe/Berlin').getTime())).toBe(true); diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index e4bb3bd66..6aca0514e 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -25,7 +25,7 @@ export { SchedulingDeniedError }; import { acceptedFileName, fileNameRulesFrom, type FileNameRules } from "@/lib/file-name-rules"; import { folderDepthError } from "@/lib/file-errors"; import { mediaTypeEssence, OCTET_STREAM } from "@/lib/media-type"; -import { getEffectiveTimeZone, toLocalDateTime } from "@/lib/timezone"; +import { getEffectiveTimeZone, moveLocalDateTime, toLocalDateTime } from "@/lib/timezone"; import { buildEmailSort, compareEmails, hasKeywordLevels, hasTextLevels, type KeywordSortPolarity, type SortLevel } from "@/lib/message-list-order"; import type { AccountInfo } from "./types"; import { stalwartChangeId } from "./stalwart-state"; @@ -578,7 +578,9 @@ function normalizeCalendarTask(task: CalendarTask): CalendarTask { * LocalDateTime `after`/`before` filter values and computes utcStart/utcEnd * for floating events in this zone, defaulting to UTC when absent - which * shifts range boundaries and floating-event times for any user not in UTC. - * Stalwart ignores unparseable values, so sending it is always safe. + * A zone the server's time zone database lacks is read as UTC by 0.16 and + * refused by 1.0, which fails the whole call; JMAPClient.calendarTimeZone + * decides what is actually sent. */ function getUserTimeZone(): string | undefined { try { @@ -765,6 +767,19 @@ function isOccurrenceLimitError(error: unknown): boolean { return type === 'invalidArguments' && /exceeds/i.test(error instanceof Error ? error.message : ''); } +/** + * Whether a CalendarEvent get or query failed on its `timeZone` argument: + * Stalwart 1.0 answers a zone its time zone database lacks with + * `invalidArguments` "Invalid timeZone \"X\"" (0.16 reads it as UTC). + */ +function isTimeZoneRefusal(type: string | undefined, description: string | undefined): boolean { + return type === 'invalidArguments' && /invalid timeZone/i.test(description ?? ''); +} + +function isTimeZoneRefusalError(error: unknown): boolean { + return isTimeZoneRefusal(jmapErrorType(error), error instanceof Error ? error.message : undefined); +} + 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 @@ -6589,17 +6604,23 @@ export class JMAPClient implements IJMAPClient { /** * Calendar/get for one account. The session's first one on the primary * calendar account also carries the synthetic-id probe (see - * supportsSyntheticCalendarIds), so the range query that follows needs no - * round trip of its own for it. + * supportsSyntheticCalendarIds) and the time zone probe (see + * calendarTimeZone), so the reads that follow need no round trip of their + * own for them. */ private requestCalendars(accountId: string): Promise { const methodCalls: JMAPMethodCall[] = [ ["Calendar/get", { accountId, properties: CALENDAR_PROPERTIES }, "0"], ]; - const carriesProbe = !this.syntheticCalendarIdSupport && accountId === this.getCalendarsAccountId(); + const isPrimary = accountId === this.getCalendarsAccountId(); + const carriesProbe = !this.syntheticCalendarIdSupport && isPrimary; if (carriesProbe) methodCalls.push(this.syntheticIdProbeCall(accountId)); + const timeZone = getUserTimeZone(); + const checksTimeZone = isPrimary && !!timeZone && timeZone !== 'UTC' && !this.timeZoneProbes.has(timeZone); + if (checksTimeZone) methodCalls.push(this.timeZoneProbeCall(accountId, timeZone)); const pending = this.request(methodCalls, this.calendarUsing()); if (carriesProbe) this.rememberSyntheticIdProbe(pending); + if (checksTimeZone) this.rememberTimeZoneProbe(timeZone, pending); return pending; } @@ -6639,7 +6660,7 @@ export class JMAPClient implements IJMAPClient { const QUERY_PAGE = 500; const MAX_SCAN = 5000; // safety bound; beyond this we fail open const PROPERTIES = ['id', '@type', 'due', 'progress', 'percentComplete', 'calendarIds']; - const timeZone = getUserTimeZone(); + const timeZone = await this.calendarTimeZone(); const objects: ScannedCalendarObject[] = []; const remaining = new Set(rawCalendarIds); // calendars not yet known to hold an event @@ -6928,8 +6949,10 @@ export class JMAPClient implements IJMAPClient { */ async getCalendarEvents(calendarIds?: string[], targetAccountId?: string): Promise { const accountId = targetAccountId || this.getCalendarsAccountId(); - const timeZone = getUserTimeZone(); + return this.withCalendarTimeZone((timeZone) => this.listCalendarEvents(accountId, calendarIds, timeZone)); + } + private async listCalendarEvents(accountId: string, calendarIds: string[] | undefined, timeZone: string | undefined): Promise { // Page through the query to collect ALL ids. Callers (import UID // deduplication, clear-calendar, subscription refresh) rely on this being // the complete list; a single capped query silently hid everything past @@ -7057,6 +7080,73 @@ export class JMAPClient implements IJMAPClient { ); } + // Time zones checked with the server (see calendarTimeZone): the probe of + // each zone, settled or in flight, and the zones the server refused. + private timeZoneProbes = new Map>(); + private refusedTimeZones = new Set(); + + private static readonly TIME_ZONE_PROBE_CALL_ID = 'time-zone-probe'; + + /** A get of no events, which the server only answers if it knows `timeZone`. */ + private timeZoneProbeCall(accountId: string, timeZone: string): JMAPMethodCall { + return ["CalendarEvent/get", { accountId, ids: [], properties: ["id"], timeZone }, JMAPClient.TIME_ZONE_PROBE_CALL_ID]; + } + + /** Derive the verdict on `timeZone` from a response that carried its probe. */ + private rememberTimeZoneProbe(timeZone: string, pending: Promise): void { + this.timeZoneProbes.set(timeZone, pending.then( + (response) => { + const entry = response.methodResponses?.find((r) => r[2] === JMAPClient.TIME_ZONE_PROBE_CALL_ID); + if (entry?.[0] === 'error' && isTimeZoneRefusal(entry[1]?.type, entry[1]?.description)) { + this.refuseTimeZone(timeZone); + } + }, + // No verdict: the zone goes out, and a refusal is still caught + // (withCalendarTimeZone). + () => undefined, + )); + } + + private refuseTimeZone(timeZone: string): void { + if (this.refusedTimeZones.has(timeZone)) return; + this.refusedTimeZones.add(timeZone); + console.warn( + `Calendar: the server does not know the time zone "${timeZone}"; asking it for UTC times instead. ` + + 'Events without a time zone of their own may show shifted.', + ); + } + + /** + * The `timeZone` argument for CalendarEvent/get and /query: the user's zone + * (getUserTimeZone), or UTC once the server refused it. Stalwart 1.0 fails + * every calendar get and query that names a zone its time zone database + * lacks; 0.16 reads such a zone as UTC. The session's first calendar list + * checks the zone on the way (requestCalendars); a read that starts before + * that answer, or without one, sends the user's zone and is repeated with + * UTC if refused (withCalendarTimeZone). + */ + private async calendarTimeZone(): Promise { + const timeZone = getUserTimeZone(); + if (!timeZone) return undefined; + await this.timeZoneProbes.get(timeZone); + return this.refusedTimeZones.has(timeZone) ? 'UTC' : timeZone; + } + + /** + * Run a calendar read with calendarTimeZone, and once more with UTC when + * the server refuses that zone. + */ + private async withCalendarTimeZone(read: (timeZone: string | undefined) => Promise): Promise { + const timeZone = await this.calendarTimeZone(); + try { + return await read(timeZone); + } catch (error) { + if (!timeZone || timeZone === 'UTC' || !isTimeZoneRefusalError(error)) throw error; + this.refuseTimeZone(timeZone); + return read('UTC'); + } + } + async queryAllCalendarEvents( filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, @@ -7107,75 +7197,8 @@ export class JMAPClient implements IJMAPClient { ): Promise { const accountId = targetAccountId || this.getCalendarsAccountId(); try { - const timeZone = getUserTimeZone(); - - const queryArgs: Record = { - accountId, - 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; - } - // Let the server expand recurring series into one synthetic id per - // occurrence when it also accepts those ids in CalendarEvent/set - // (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. - let expandRecurrences = Boolean(filter.after && filter.before) - && await this.supportsSyntheticCalendarIds(); - if (sort) { - queryArgs.sort = sort; - } - - let ids: string[] | null = null; - if (expandRecurrences) { - 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; - } - } - ids ??= await this.queryCalendarEventIds(queryArgs, limit); - if (ids.length === 0) return []; - - // Only series the client expands itself need their overrides here; a - // server-expanded occurrence gets them from its base event below. - const allEvents = await this.fetchCalendarEventsById(accountId, ids, CALENDAR_EVENT_PROPERTIES, { - withOverrides: !expandRecurrences, - timeZone, - }); - const normalized = (allEvents as unknown as CalendarEvent[]) - .filter((event) => !isTaskObject(event)) - .map((event) => normalizeCalendarEventLike(event)); - const filtered = expandRecurrences - ? await this.hydrateExpandedOccurrences(normalized, accountId, timeZone) - : normalized; - - const eventsWithParticipants = filtered.filter(e => e.participants && Object.keys(e.participants).length > 0); - debug.log('calendar', 'queryCalendarEvents participant summary', { - totalEvents: filtered.length, - eventsWithParticipants: eventsWithParticipants.length, - details: eventsWithParticipants.map(e => ({ - id: e.id, - title: e.title, - participantCount: Object.keys(e.participants!).length, - participants: e.participants, - replyTo: e.replyTo, - })), - }); - - return filtered; + return await this.withCalendarTimeZone((timeZone) => + this.queryCalendarEventsIn(accountId, filter, sort, limit, timeZone)); } catch (error) { // The fan-out over shared accounts probes on suspicion (see // getCalendarCapableAccountIds) and may hit accounts that grant no @@ -7194,6 +7217,90 @@ export class JMAPClient implements IJMAPClient { } } + /** queryCalendarEvents with `timeZone` as the request's time zone. */ + private async queryCalendarEventsIn( + accountId: string, + filter: CalendarEventFilter, + sort: Array<{ property: string; isAscending: boolean }> | undefined, + limit: number | undefined, + timeZone: string | undefined, + ): Promise { + // Stalwart reads the LocalDateTime after/before filter values in the + // request's time zone (UTC without one, shifting range boundaries), and + // drops a `Z` or offset rather than honouring it: UTC bounds become that + // wall clock. A bound without offset is the user's wall clock (the + // grid's display dates); it moves along when the server refused the + // user's zone and the request is in UTC. + const userTimeZone = getUserTimeZone(); + const bound = (value: string) => toLocalDateTime( + userTimeZone && timeZone ? moveLocalDateTime(value, userTimeZone, timeZone) : value, + timeZone, + ); + const queryArgs: Record = { + accountId, + filter: { + ...filter, + ...(filter.after ? { after: bound(filter.after) } : {}), + ...(filter.before ? { before: bound(filter.before) } : {}), + }, + }; + if (timeZone) { + queryArgs.timeZone = timeZone; + } + // Let the server expand recurring series into one synthetic id per + // occurrence when it also accepts those ids in CalendarEvent/set + // (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. + let expandRecurrences = Boolean(filter.after && filter.before) + && await this.supportsSyntheticCalendarIds(); + if (sort) { + queryArgs.sort = sort; + } + + let ids: string[] | null = null; + if (expandRecurrences) { + 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; + } + } + ids ??= await this.queryCalendarEventIds(queryArgs, limit); + if (ids.length === 0) return []; + + // Only series the client expands itself need their overrides here; a + // server-expanded occurrence gets them from its base event below. + const allEvents = await this.fetchCalendarEventsById(accountId, ids, CALENDAR_EVENT_PROPERTIES, { + withOverrides: !expandRecurrences, + timeZone, + }); + const normalized = (allEvents as unknown as CalendarEvent[]) + .filter((event) => !isTaskObject(event)) + .map((event) => normalizeCalendarEventLike(event)); + const filtered = expandRecurrences + ? await this.hydrateExpandedOccurrences(normalized, accountId, timeZone) + : normalized; + + const eventsWithParticipants = filtered.filter(e => e.participants && Object.keys(e.participants).length > 0); + debug.log('calendar', 'queryCalendarEvents participant summary', { + totalEvents: filtered.length, + eventsWithParticipants: eventsWithParticipants.length, + details: eventsWithParticipants.map(e => ({ + id: e.id, + title: e.title, + participantCount: Object.keys(e.participants!).length, + participants: e.participants, + replyTo: e.replyTo, + })), + }); + + return filtered; + } + /** * The ids a CalendarEvent/query matches. Without `limit` it pages through * the whole result: a busy calendar (or a long expanded range) holds more @@ -7347,10 +7454,8 @@ export class JMAPClient implements IJMAPClient { /** One event with its recurrence overrides, or null when the server has no such id. */ async getCalendarEvent(id: string, targetAccountId?: string): Promise { const accountId = targetAccountId || this.getCalendarsAccountId(); - const [event] = await this.fetchCalendarEventsById(accountId, [id], CALENDAR_EVENT_PROPERTIES, { - withOverrides: true, - timeZone: getUserTimeZone(), - }); + const [event] = await this.withCalendarTimeZone((timeZone) => + this.fetchCalendarEventsById(accountId, [id], CALENDAR_EVENT_PROPERTIES, { withOverrides: true, timeZone })); return event ? normalizeCalendarEventLike(event as unknown as CalendarEvent) : null; } @@ -7508,7 +7613,8 @@ export class JMAPClient implements IJMAPClient { // any event it does not return. let fetched: Record[] = []; try { - fetched = await this.fetchCalendarEventsById(accountId, createdIds, CALENDAR_EVENT_PROPERTIES, { timeZone: getUserTimeZone() }); + fetched = await this.withCalendarTimeZone((timeZone) => + this.fetchCalendarEventsById(accountId, createdIds, CALENDAR_EVENT_PROPERTIES, { timeZone })); } catch (error) { debug.warn('calendar', 'CalendarEvent/batchCreate could not read the created events back', error); } @@ -7737,36 +7843,7 @@ export class JMAPClient implements IJMAPClient { debug.log('tasks', 'CalendarTask/fetch start', { accountId, calendarIds: calendarIds || 'all' }); try { - // Page through the query to collect all object ids. - const QUERY_PAGE = 1000; - const MAX_IDS = 50000; // safety bound - const timeZone = getUserTimeZone(); - const ids: string[] = []; - for (let position = 0; position < MAX_IDS;) { - const queryArgs: Record = { accountId, limit: QUERY_PAGE, position }; - if (timeZone) { - queryArgs.timeZone = timeZone; - } - if (calendarIds && calendarIds.length > 0) { - queryArgs.filter = buildInCalendarFilter(calendarIds); - } - const response = await this.request([ - ["CalendarEvent/query", queryArgs, "0"], - ], this.calendarUsing()); - const pageIds: string[] = methodResult(response, "0", "CalendarEvent/query").ids || []; - ids.push(...pageIds); - if (pageIds.length < QUERY_PAGE) break; - position += pageIds.length; - } - - debug.log('tasks', 'CalendarTask/fetch query returned', ids.length, 'object ids'); - if (ids.length === 0) { - debug.groupEnd(); - return []; - } - - // Fetch the objects in batches that respect the server's /get limit. - const allObjects = await this.fetchCalendarEventsById(accountId, ids, CALENDAR_TASK_PROPERTIES, { timeZone }); + const allObjects = await this.withCalendarTimeZone((timeZone) => this.fetchTaskObjects(accountId, calendarIds, timeZone)); const tasks: CalendarTask[] = []; for (const obj of allObjects) { @@ -7801,6 +7878,45 @@ export class JMAPClient implements IJMAPClient { } } + /** + * Every object of the account (or of `calendarIds`) with the task + * properties. Stalwart has no filter for tasks, so events come too. + */ + private async fetchTaskObjects( + accountId: string, + calendarIds: string[] | undefined, + timeZone: string | undefined, + ): Promise[]> { + // Page through the query to collect all object ids. + const QUERY_PAGE = 1000; + const MAX_IDS = 50000; // safety bound + const ids: string[] = []; + for (let position = 0; position < MAX_IDS;) { + const queryArgs: Record = { accountId, limit: QUERY_PAGE, position }; + if (timeZone) { + queryArgs.timeZone = timeZone; + } + if (calendarIds && calendarIds.length > 0) { + queryArgs.filter = buildInCalendarFilter(calendarIds); + } + const response = await this.request([ + ["CalendarEvent/query", queryArgs, "0"], + ], this.calendarUsing()); + const pageIds: string[] = methodResult(response, "0", "CalendarEvent/query").ids || []; + ids.push(...pageIds); + if (pageIds.length < QUERY_PAGE) break; + position += pageIds.length; + } + + debug.log('tasks', 'CalendarTask/fetch query returned', ids.length, 'object ids'); + if (ids.length === 0) { + return []; + } + + // Fetch the objects in batches that respect the server's /get limit. + return this.fetchCalendarEventsById(accountId, ids, CALENDAR_TASK_PROPERTIES, { timeZone }); + } + async createCalendarTask(task: Partial, targetAccountId?: string): Promise { const accountId = targetAccountId || this.getCalendarsAccountId(); const { '@type': _type, progressUpdated: _pu, ...taskData } = task as Partial & { progressUpdated?: string | null }; @@ -7844,7 +7960,8 @@ export class JMAPClient implements IJMAPClient { debug.log('calendar', 'CalendarTask/create re-fetching with task properties', { createdId, properties: [...CALENDAR_TASK_PROPERTIES] }); let fetched: Record | undefined; try { - [fetched] = await this.fetchCalendarEventsById(accountId, [createdId], CALENDAR_TASK_PROPERTIES, { timeZone: getUserTimeZone() }); + [fetched] = await this.withCalendarTimeZone((timeZone) => + this.fetchCalendarEventsById(accountId, [createdId], CALENDAR_TASK_PROPERTIES, { timeZone })); } catch (error) { debug.warn('tasks', 'CalendarTask/create could not read the created task back', error); } diff --git a/lib/timezone.ts b/lib/timezone.ts index 220cb22c9..733c14cff 100644 --- a/lib/timezone.ts +++ b/lib/timezone.ts @@ -152,6 +152,27 @@ export function toLocalDateTime(value: string, timeZone?: string): string { return `${pad(w.year, 4)}-${pad(w.month)}-${pad(w.day)}T${pad(w.hour)}:${pad(w.minute)}:${pad(w.second)}`; } +/** + * A LocalDateTime ("YYYY-MM-DDTHH:mm:ss") read as the wall clock in + * `fromZone`, as the wall clock in `toZone` at the same instant. Anything else + * (a value with `Z` or an offset, or one that does not parse) is returned as + * it is. + */ +export function moveLocalDateTime(value: string, fromZone: string, toZone: string): string { + const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/.exec(value); + if (!match || fromZone === toZone) return value; + const [year, month, day, hour, minute, second] = match.slice(1).map(Number); + const wall = new Date(0); + wall.setUTCFullYear(year, month - 1, day); + wall.setUTCHours(hour, minute, second, 0); + const asUtc = wall.getTime(); + // Two offset lookups, as in fromZonedDisplayDate, for wall clocks next to a + // DST transition. + const guess = asUtc - getTimeZoneOffsetMs(new Date(asUtc), fromZone); + const instant = new Date(asUtc - getTimeZoneOffsetMs(new Date(guess), fromZone)); + return toLocalDateTime(instant.toISOString(), toZone); +} + /** 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);