fix: keep the calendar working on Stalwart 1.0 with an unknown time zone

Stalwart 1.0 fails every CalendarEvent get and query whose `timeZone`
argument names a zone missing from its time zone database, with
invalidArguments "Invalid timeZone". 0.16 reads such a zone as UTC, so
the calendar kept working there; on 1.0 it would stay empty.

- check the user's zone with a get of no events that rides along with
  the session's first calendar list
- once the server refuses the zone, ask in UTC, move the grid's
  wall-clock range bounds to UTC as well, and log one console warning
- repeat a read that was refused before the check answered, once, in
  UTC
This commit is contained in:
Linus Rath
2026-09-25 19:35:08 +02:00
parent fe61bd8e88
commit 19c2dfb2b4
8 changed files with 418 additions and 126 deletions
+15 -1
View File
@@ -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 });
+130 -5
View File
@@ -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<typeof vi.spyOn>;
let warn: ReturnType<typeof vi.spyOn>;
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();
});
});
@@ -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']);
});
+3 -3
View File
@@ -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/);
});
});
@@ -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([]);
+13
View File
@@ -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);
+229 -112
View File
@@ -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<JMAPResponse> {
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<CalendarEvent[]> {
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<CalendarEvent[]> {
// 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<string, Promise<void>>();
private refusedTimeZones = new Set<string>();
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<JMAPResponse>): 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<string | undefined> {
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<T>(read: (timeZone: string | undefined) => Promise<T>): Promise<T> {
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<CalendarEvent[]> {
const accountId = targetAccountId || this.getCalendarsAccountId();
try {
const timeZone = getUserTimeZone();
const queryArgs: Record<string, unknown> = {
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<CalendarEvent[]> {
// 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<string, unknown> = {
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<CalendarEvent | null> {
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<string, unknown>[] = [];
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<string, unknown> = { 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<Record<string, unknown>[]> {
// 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<string, unknown> = { 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<CalendarTask>, targetAccountId?: string): Promise<CalendarTask> {
const accountId = targetAccountId || this.getCalendarsAccountId();
const { '@type': _type, progressUpdated: _pu, ...taskData } = task as Partial<CalendarTask> & { 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<string, unknown> | 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);
}
+21
View File
@@ -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);