mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
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:
@@ -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 });
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user