fix: keep the calendar filled when scrolling past a year on Stalwart 1.0

Stalwart 1.0 refuses an expanded CalendarEvent/query longer than
maxExpandedQueryDuration (365 days by default) with
expandDurationTooLarge. The scrolling month view reaches 777 days and
search with after:/before: can span years, so the grid went blank.

Range queries are now split into chunks within the advertised limit and
merged. A chunk the server still refuses is halved; if a week still
expands to too many occurrences, the range is expanded in the browser
as before. 0.16 advertises the same limit without enforcing it and
returns the same events.
This commit is contained in:
Linus Rath
2026-09-25 17:38:49 +02:00
parent c23e306789
commit c236d828c1
4 changed files with 577 additions and 28 deletions
+73 -2
View File
@@ -59,7 +59,7 @@ export interface StalwartCalendarMock {
/** A session like Stalwart's, with the calendars capability on the account. */
export function stalwartCalendarSession(
version: StalwartVersion,
options: { maxExpandedQueryDuration?: string | null; accounts?: Record<string, unknown> } = {},
options: { maxExpandedQueryDuration?: string | null; maxCallsInRequest?: number; accounts?: Record<string, unknown> } = {},
) {
const calendarCapability: Record<string, unknown> = {
maxCalendarsPerEvent: version === '1.0' ? 10 : null,
@@ -72,7 +72,7 @@ export function stalwartCalendarSession(
if (duration !== null) calendarCapability.maxExpandedQueryDuration = duration;
return {
capabilities: {
'urn:ietf:params:jmap:core': { maxCallsInRequest: 16, maxObjectsInGet: 500, maxObjectsInSet: 500 },
'urn:ietf:params:jmap:core': { maxCallsInRequest: options.maxCallsInRequest ?? 16, maxObjectsInGet: 500, maxObjectsInSet: 500 },
'urn:ietf:params:jmap:calendars': {},
},
accounts: {
@@ -200,6 +200,77 @@ export function installStalwartCalendarMock(
};
}
/** Stalwart 1.0's refusal of an expanded range longer than maxExpandedQueryDuration. */
export const EXPAND_DURATION_TOO_LARGE = {
type: 'expandDurationTooLarge',
description: "The time span between 'after' and 'before' exceeds maxExpandedQueryDuration",
} as const;
/** Both versions' refusal of a query that expands to more occurrences than allowed. */
export function tooManyOccurrences(version: StalwartVersion) {
const description = 'The number of expanded recurrences exceeds the server limit';
return version === '1.0'
? { type: 'cannotCalculateOccurrences', description }
: { type: 'invalidArguments', description };
}
/** An occurrence of the series `seriesId`: a LocalDateTime start and a length in seconds. */
export interface RangeOccurrence {
id: string;
start: string;
seconds: number;
}
/** A LocalDateTime read as seconds (the mock has no time zones). */
export function localSeconds(value: string): number {
return Date.parse(`${value}Z`) / 1000;
}
/** Occurrence objects for `objects`/`occurrences` of installStalwartCalendarMock. */
export function occurrenceObjects(seriesId: string, occurrences: RangeOccurrence[]) {
return Object.fromEntries(occurrences.map((o) => [o.id, {
id: o.id, '@type': 'Event', baseEventId: seriesId, uid: `${seriesId}@example.com`, title: 'Standup',
start: o.start, duration: `PT${o.seconds}S`, recurrenceId: o.start, calendarIds: { cal: true },
}]));
}
/**
* Answers CalendarEvent/query the way Stalwart expands a range: occurrences
* that overlap (after, before) strictly, sorted by start when asked, paged.
* 1.0 refuses a span over `maxSpanSeconds` (0.16 never checks it), and both
* refuse a query that expands to more than `maxInstances` occurrences. A
* query without expansion finds the series itself.
*/
export function expandingQueryAnswer(
version: StalwartVersion,
options: { seriesId: string; occurrences: RangeOccurrence[]; maxSpanSeconds?: number; maxInstances?: number },
) {
return (call: RecordedCall): MethodAnswer | undefined => {
if (call.method !== 'CalendarEvent/query') return undefined;
const { args } = call;
if (!args.expandRecurrences) return ['CalendarEvent/query', { ids: [options.seriesId], position: 0 }];
const filter = args.filter as { after: string; before: string };
const after = localSeconds(filter.after);
const before = localSeconds(filter.before);
if (version === '1.0' && before - after > (options.maxSpanSeconds ?? 365 * 86_400)) {
return ['error', { ...EXPAND_DURATION_TOO_LARGE }];
}
let hits = options.occurrences.filter((o) => {
const start = localSeconds(o.start);
return start < before && start + o.seconds > after;
});
if (hits.length > (options.maxInstances ?? 3000)) return ['error', tooManyOccurrences(version)];
const [sort] = (args.sort as Array<{ property: string; isAscending?: boolean }> | undefined) ?? [];
if (sort?.property === 'start') {
hits = [...hits].sort((a, b) => localSeconds(a.start) - localSeconds(b.start));
if (sort.isAscending === false) hits.reverse();
}
const position = (args.position as number | undefined) ?? 0;
const limit = (args.limit as number | undefined) ?? hits.length;
return ['CalendarEvent/query', { ids: hits.slice(position, position + limit).map((o) => o.id), position }];
};
}
/** A JMAPClient connected to `session` through `fetchSpy`. */
export async function connectCalendarClient(
fetchSpy: ReturnType<typeof vi.spyOn>,
+271 -17
View File
@@ -1,7 +1,19 @@
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { JMAPClient } from '../jmap/client';
import { jmapErrorType } from '../jmap/jmap-error';
import { toLocalDateTime } from '../timezone';
import {
connectCalendarClient,
expandingQueryAnswer,
installStalwartCalendarMock,
localSeconds,
occurrenceObjects,
stalwartCalendarSession,
tooManyOccurrences,
type RangeOccurrence,
type StalwartCalendarMock,
type StalwartVersion,
} from './helpers/stalwart-calendar';
type Call = [string, Record<string, unknown>, string];
@@ -49,23 +61,30 @@ describe('queryCalendarEvents range handling', () => {
expect(filter.after.endsWith('Z')).toBe(false);
});
it('falls back to series when the range expands past the server limit on 0.16', async () => {
const { client, queries } = clientAnswering((args) => args.expandRecurrences
? ['error', { type: 'invalidArguments', description: 'The number of expanded recurrences exceeds the server limit' }]
: ['CalendarEvent/query', { ids: ['s1'] }]);
const events = await client.queryCalendarEvents({ after: '2020-01-01T00:00:00', before: '2030-01-01T00:00:00' });
expect(queries.map((q) => !!q.expandRecurrences)).toEqual([true, false]);
expect(events.map((e) => e.id)).toEqual(['s1']);
});
it.each<[StalwartVersion]>([['0.16'], ['1.0']])(
'falls back to series when even a week expands to too many occurrences on Stalwart %s',
async (version) => {
const { client, queries } = clientAnswering((args) => args.expandRecurrences
? ['error', tooManyOccurrences(version)]
: ['CalendarEvent/query', { ids: ['s1'] }]);
const events = await client.queryCalendarEvents({ after: '2026-09-01T00:00:00', before: '2026-09-30T23:59:59' });
it('falls back to series on Stalwart 1.0 cannotCalculateOccurrences', async () => {
const { client, queries } = clientAnswering((args) => args.expandRecurrences
? ['error', { type: 'cannotCalculateOccurrences', description: 'The number of expanded recurrences exceeds the server limit' }]
: ['CalendarEvent/query', { ids: ['s1'] }]);
const events = await client.queryCalendarEvents({ after: '2026-09-01T00:00:00', before: '2026-09-30T23:59:59' });
expect(queries.map((q) => !!q.expandRecurrences)).toEqual([true, false]);
expect(events.map((e) => e.id)).toEqual(['s1']);
});
// 30 days, halved to 15, 7.5 and 3.75: a chunk of a week or less that
// is still refused gives up on server expansion for the whole range.
const expanded = queries.filter((q) => q.expandRecurrences);
const days = expanded.map((q) => {
const filter = q.filter as { after: string; before: string };
return (localSeconds(filter.before) - localSeconds(filter.after)) / 86_400;
});
const levels = [30, 15, 15, 7.5, 7.5, 7.5, 7.5, ...Array(8).fill(3.75)];
expect(days).toHaveLength(levels.length);
days.forEach((span, i) => expect(span).toBeCloseTo(levels[i], 0));
expect(queries).toHaveLength(expanded.length + 1);
expect(queries.at(-1)).toMatchObject({ filter: { after: '2026-09-01T00:00:00', before: '2026-09-30T23:59:59' } });
expect(queries.at(-1)!.expandRecurrences).toBeUndefined();
expect(events.map((e) => e.id)).toEqual(['s1']);
},
);
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"' }]);
@@ -93,3 +112,238 @@ describe('queryCalendarEvents range handling', () => {
expect(queries[0].limit).toBe(2);
});
});
// An expanded range may span at most maxExpandedQueryDuration (the account's
// calendars capability, an iCalendar duration). 1.0 enforces it with
// expandDurationTooLarge; every 0.16 advertises 365 days and never checks.
// A longer range goes out in chunks a day shorter than the limit, sharing a
// second at each border.
describe('expanded ranges longer than maxExpandedQueryDuration', () => {
const DAY = 86_400;
// 777 days, the longest window the month view scrolls to.
const WINDOW = { after: '2025-01-01T00:00:00', before: '2027-02-16T23:59:59' };
const occurrences: RangeOccurrence[] = [
{ id: 'o-first', start: '2025-01-02T09:00:00', seconds: 3600 },
// Across the first border (2025-12-31T00:00:00): found by both chunks.
{ id: 'o-across', start: '2025-12-30T23:30:00', seconds: 3600 },
// Zero-length, exactly on that border: only the overlap finds it.
{ id: 'o-border', start: '2025-12-31T00:00:00', seconds: 0 },
{ id: 'o-middle', start: '2026-07-01T09:00:00', seconds: 3600 },
{ id: 'o-late', start: '2027-02-10T09:00:00', seconds: 3600 },
// Outside the window.
{ id: 'o-after', start: '2027-02-17T09:00:00', seconds: 3600 },
];
const IN_WINDOW = ['o-first', 'o-across', 'o-border', 'o-middle', 'o-late'];
const SERIES = {
id: 'series', '@type': 'Event', uid: 'series@example.com', title: 'Standup', baseEventId: 'series',
start: '2025-01-02T09:00:00', duration: 'PT1H', recurrenceRule: { frequency: 'daily' },
recurrenceOverrides: null, calendarIds: { cal: true },
};
let fetchSpy: ReturnType<typeof vi.spyOn>;
let client: JMAPClient;
let server: StalwartCalendarMock;
async function connect(
version: StalwartVersion,
options: {
maxExpandedQueryDuration?: string | null;
maxSpanSeconds?: number;
maxInstances?: number;
maxCallsInRequest?: number;
} = {},
) {
fetchSpy = vi.spyOn(globalThis, 'fetch');
client = await connectCalendarClient(fetchSpy, stalwartCalendarSession(version, options));
server = installStalwartCalendarMock(fetchSpy, {
version,
objects: { series: SERIES },
occurrences: occurrenceObjects('series', occurrences),
answer: expandingQueryAnswer(version, { seriesId: 'series', occurrences, ...options }),
});
}
const expandedQueries = () => server.queries().filter((q) => q.args.expandRecurrences);
const bounds = () => expandedQueries().map((q) => {
const filter = q.args.filter as { after: string; before: string };
return [filter.after, filter.before];
});
afterEach(() => {
client.disconnect();
fetchSpy.mockRestore();
});
describe.each(['P365D', 'P52W1D', 'PT8760H'])('with maxExpandedQueryDuration %s', (duration) => {
it(`splits a 777-day window into three chunks sent in one request on Stalwart 1.0`, async () => {
await connect('1.0', { maxExpandedQueryDuration: duration });
const events = await client.queryCalendarEvents(WINDOW);
expect(bounds()).toEqual([
['2025-01-01T00:00:00', '2025-12-31T00:00:00'],
['2025-12-30T23:59:59', '2026-12-29T23:59:59'],
['2026-12-29T23:59:58', '2027-02-16T23:59:59'],
]);
for (const [after, before] of bounds()) {
expect(localSeconds(before) - localSeconds(after)).toBeLessThanOrEqual(364 * DAY);
}
expect(new Set(expandedQueries().map((q) => q.request)).size).toBe(1);
// Each occurrence once, the border ones included.
expect(events.map((e) => e.id).sort()).toEqual([...IN_WINDOW].sort());
});
});
it('gets the same occurrences from 0.16, which advertises the limit but does not enforce it', async () => {
await connect('0.16');
const events = await client.queryCalendarEvents(WINDOW);
expect(bounds()).toHaveLength(3);
expect(events.map((e) => e.id).sort()).toEqual([...IN_WINDOW].sort());
// The occurrences are hydrated from their series, once.
expect(events.find((e) => e.id === 'o-middle')?.recurrenceRules).toEqual([{ frequency: 'daily' }]);
expect(server.gets().filter((g) => (g.args.ids as string[]).includes('series'))).toHaveLength(1);
});
it('halves a chunk the server finds too long on Stalwart 1.0', async () => {
// The server enforces less than it advertises.
await connect('1.0', { maxSpanSeconds: 200 * DAY });
const events = await client.queryCalendarEvents(WINDOW);
const spans = bounds().map(([after, before]) => (localSeconds(before) - localSeconds(after)) / DAY);
// The two 364-day chunks are refused and halved; the 49-day one is not.
expect(spans.filter((days) => days > 200)).toHaveLength(2);
expect(spans.filter((days) => days <= 200)).toHaveLength(5);
expect(events.map((e) => e.id).sort()).toEqual([...IN_WINDOW].sort());
});
it('splits when refused even without the capability on Stalwart 1.0', async () => {
await connect('1.0', { maxExpandedQueryDuration: null });
const events = await client.queryCalendarEvents(WINDOW);
// One query for the whole window, refused; its halves (388.5 days),
// refused; their halves.
expect(bounds()[0]).toEqual([WINDOW.after, WINDOW.before]);
expect(bounds()).toHaveLength(1 + 2 + 4);
expect(events.map((e) => e.id).sort()).toEqual([...IN_WINDOW].sort());
});
it.each<[StalwartVersion]>([['0.16'], ['1.0']])(
'halves chunks that expand to too many occurrences on Stalwart %s',
async (version) => {
// Only a chunk holding at most two occurrences is answered.
await connect(version, { maxInstances: 2 });
const events = await client.queryCalendarEvents(WINDOW);
expect(expandedQueries().length).toBeGreaterThan(3);
expect(server.queries().every((q) => q.args.expandRecurrences)).toBe(true);
expect(events.map((e) => e.id).sort()).toEqual([...IN_WINDOW].sort());
},
);
it.each<[StalwartVersion]>([['0.16'], ['1.0']])(
'expands the whole range on the client when a week is still too much on Stalwart %s',
async (version) => {
await connect(version, { maxInstances: 0 });
const events = await client.queryCalendarEvents(WINDOW);
// One query for the series over the whole window; none of the
// occurrences found by chunks before giving up are mixed in.
const plain = server.queries().filter((q) => !q.args.expandRecurrences);
expect(plain).toHaveLength(1);
expect(plain[0].args.filter).toMatchObject(WINDOW);
expect(events.map((e) => e.id)).toEqual(['series']);
expect(events[0].recurrenceRules).toEqual([{ frequency: 'daily' }]);
},
);
it('sends as many chunks per request as maxCallsInRequest allows', async () => {
await connect('1.0', { maxCallsInRequest: 2 });
await client.queryCalendarEvents(WINDOW);
expect(expandedQueries().map((q) => q.request)).toEqual([
expandedQueries()[0].request, expandedQueries()[0].request, expandedQueries()[0].request + 1,
]);
});
it('reads the chunks one at a time in start order and stops at the limit (contact activity) on Stalwart 1.0', async () => {
await connect('1.0');
const events = await client.queryCalendarEvents(WINDOW, [{ property: 'start', isAscending: true }], 2);
// The first chunk fills the limit: no other chunk is asked.
expect(bounds()).toEqual([['2025-01-01T00:00:00', '2025-12-31T00:00:00']]);
expect(expandedQueries()[0].args.limit).toBe(2);
expect(events.map((e) => e.id).sort()).toEqual(['o-across', 'o-first']);
});
it('reads the latest chunk first for a descending sort (search) on Stalwart 1.0', async () => {
await connect('1.0');
const events = await client.queryCalendarEvents(WINDOW, [{ property: 'start', isAscending: false }], 2);
// The last chunk holds one; the middle one supplies the second.
expect(bounds()).toEqual([
['2026-12-29T23:59:58', '2027-02-16T23:59:59'],
['2025-12-30T23:59:59', '2026-12-29T23:59:59'],
]);
expect(expandedQueries().map((q) => q.args.limit)).toEqual([2, 1]);
expect(expandedQueries()[0].request).not.toBe(expandedQueries()[1].request);
expect(events.map((e) => e.id).sort()).toEqual(['o-late', 'o-middle']);
});
it('pages on within a chunk when an occurrence both chunks share fills the page on Stalwart 1.0', async () => {
await connect('1.0');
// First chunk: o-first, o-across. Second chunk (by start): o-across,
// o-border, o-middle - the shared one comes back again and is skipped.
const events = await client.queryCalendarEvents(WINDOW, [{ property: 'start', isAscending: true }], 3);
expect(expandedQueries().map((q) => [q.args.position, q.args.limit])).toEqual([[0, 3], [0, 1], [1, 1]]);
expect(events.map((e) => e.id).sort()).toEqual(['o-across', 'o-border', 'o-first']);
});
it('loads the contact activity look-ahead (365 days of UTC bounds, by start, limited) on Stalwart 1.0', async () => {
await connect('1.0');
const now = new Date('2026-03-01T12:00:00Z');
const events = await client.queryAllCalendarEvents(
{ after: now.toISOString(), before: new Date(now.getTime() + 365 * DAY * 1000).toISOString() },
[{ property: 'start', isAscending: true }],
500,
);
expect(bounds()).toHaveLength(2);
expect(events.map((e) => e.id).sort()).toEqual(['o-after', 'o-late', 'o-middle']);
});
it('asks for two chunks for a 365-day look-ahead on Stalwart 1.0', async () => {
await connect('1.0');
await client.queryCalendarEvents({ after: '2026-01-01T00:00:00', before: '2027-01-01T00:00:00' });
expect(bounds()).toEqual([
['2026-01-01T00:00:00', '2026-12-31T00:00:00'],
['2026-12-30T23:59:59', '2027-01-01T00:00:00'],
]);
});
it('reports a refusal it cannot split as a failure on Stalwart 1.0', async () => {
fetchSpy = vi.spyOn(globalThis, 'fetch');
client = await connectCalendarClient(fetchSpy, stalwartCalendarSession('1.0'));
server = installStalwartCalendarMock(fetchSpy, {
version: '1.0',
answer: (call) => call.method === 'CalendarEvent/query'
? ['error', { type: 'invalidArguments', description: 'Invalid timeZone "Mars/Olympus"' }]
: undefined,
});
await expect(client.queryCalendarEvents(WINDOW)).rejects.toThrow(/Invalid timeZone/);
expect(server.queries()).toHaveLength(3);
});
});
@@ -1,6 +1,13 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { parseSearchQuery } from '../query-parser';
import type { SearchAccount } from '../types';
import {
connectCalendarClient,
expandingQueryAnswer,
installStalwartCalendarMock,
occurrenceObjects,
stalwartCalendarSession,
} from '../../__tests__/helpers/stalwart-calendar';
// ---------------------------------------------------------------------------
// Store mocks. Providers read caches via `useXStore.getState()` and resolve
@@ -196,6 +203,43 @@ describe('calendar provider', () => {
expect(hits).toHaveLength(1);
});
it('searches more than a year between after: and before: in chunks on Stalwart 1.0', async () => {
// Stalwart 1.0 refuses an expanded query over more than
// maxExpandedQueryDuration (365 days); the client splits the range.
const occurrences = [
{ id: 'o-2024', start: '2024-03-05T09:00:00', seconds: 3600 },
{ id: 'o-2025', start: '2025-06-10T09:00:00', seconds: 3600 },
{ id: 'o-2026', start: '2026-08-20T09:00:00', seconds: 3600 },
];
const fetchSpy = vi.spyOn(globalThis, 'fetch');
const client = await connectCalendarClient(fetchSpy, stalwartCalendarSession('1.0'));
const server = installStalwartCalendarMock(fetchSpy, {
version: '1.0',
objects: { series: { id: 'series', recurrenceRule: { frequency: 'yearly' }, showWithoutTime: false, duration: 'PT1H' } },
occurrences: occurrenceObjects('series', occurrences),
answer: expandingQueryAnswer('1.0', { seriesId: 'series', occurrences }),
});
try {
const result = await calendarProvider.remote(
parseSearchQuery('standup after:2024-01-01 before:2026-12-31'),
{ ...account('login-a'), client },
{ limit: 10, signal },
);
expect(result.hits.map((h) => h.id).sort()).toEqual(['o-2024', 'o-2025', 'o-2026']);
// Three years in chunks of 364 days, the latest first, each still
// filtered by the search text.
const expanded = server.queries().filter((q) => q.args.expandRecurrences);
expect(expanded).toHaveLength(4);
const bounds = expanded.map((q) => q.args.filter as { after: string; before: string; text: string });
expect(bounds.every((filter) => filter.text === 'standup')).toBe(true);
expect(bounds[0].before > bounds[1].before && bounds[1].before > bounds[2].before).toBe(true);
} finally {
client.disconnect();
fetchSpy.mockRestore();
}
});
it('remote queries every JMAP account of the login and flags recurring masters', async () => {
const queryAllCalendarEvents = vi.fn(async () => [
{ id: 'e9', title: 'Weekly sync', start: '2026-04-01T09:00:00', calendarIds: {}, accountId: 'jmap-a', recurrenceRules: [{}], recurrenceId: null },
+189 -9
View File
@@ -7,7 +7,7 @@ import { batched, itemsPerRequest } from "./request-limits";
import { keywordPointer } from "./patch-pointer";
import { FirstTouchGate } from "./first-touch-gate";
import { debug } from "@/lib/debug";
import { normalizeCalendarEventLike } from "@/lib/calendar-event-normalization";
import { normalizeCalendarEventLike, parseDurationSeconds } from "@/lib/calendar-event-normalization";
import { SYNTHETIC_ID_PROBE, RECURRENCE_BASE_PROPERTIES, hydrateRecurrenceInstances, isServerRecurrenceInstance } from "@/lib/recurrence-instances";
import { findTasksOnlyCalendarIds, isTaskLikeObject, type ScannedCalendarObject } from "@/lib/calendar-component-detection";
import { DEFAULT_CALENDAR_COMPONENTS, mkCalendarCollection, newCalendarCollectionName } from "@/lib/webdav/calendar-collection";
@@ -717,6 +717,85 @@ function isOccurrenceLimitError(error: unknown): boolean {
return type === 'invalidArguments' && /exceeds/i.test(error instanceof Error ? error.message : '');
}
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
// time zone, so a DST change or an ambiguous local time moves it.
const EXPANDED_CHUNK_SLACK = DAY_SECONDS;
// A chunk that still expands to too many occurrences is halved down to about
// a week, one the server finds too long down to an hour; past that, and past
// this many queries for one range, the range is expanded on the client.
const MIN_OCCURRENCE_CHUNK = 7 * DAY_SECONDS;
const MIN_DURATION_CHUNK = 3600;
const MAX_EXPANDED_CHUNKS = 64;
/**
* Part of an expanded CalendarEvent/query range, in seconds of the request
* time zone's wall clock (the LocalDateTime bounds counted as if UTC).
*/
interface RangeChunk {
start: number;
end: number;
/** Where the next page of this chunk starts. */
position: number;
}
const LOCAL_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/;
/** A LocalDateTime such as "2026-09-01T00:00:00" as wall-clock seconds, or null. */
function wallClockSeconds(value: unknown): number | null {
const match = typeof value === 'string' ? LOCAL_DATE_TIME.exec(value) : null;
if (!match) return null;
const [year, month, day, hour, minute, second] = match.slice(1).map(Number);
const date = new Date(0);
date.setUTCFullYear(year, month - 1, day);
date.setUTCHours(hour, minute, second, 0);
return date.getTime() / 1000;
}
function localDateTimeOf(seconds: number): string {
const date = new Date(seconds * 1000);
const pad = (n: number, length = 2) => String(n).padStart(length, '0');
return `${pad(date.getUTCFullYear(), 4)}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`
+ `T${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}`;
}
/**
* [start, end] cut into chunks of at most `span` seconds, or null when that
* takes more than MAX_EXPANDED_CHUNKS. Neighbours share a second: the server
* counts an occurrence in a range only when it overlaps it strictly, so one
* that sits exactly on a border would belong to neither chunk.
*/
function chunkRange(start: number, end: number, span: number): RangeChunk[] | null {
if (span < 2) return null;
const chunks: RangeChunk[] = [];
for (let from = start; chunks.length < MAX_EXPANDED_CHUNKS; from = chunks[chunks.length - 1].end - 1) {
const to = Math.min(from + span, end);
chunks.push({ start: from, end: to, position: 0 });
if (to >= end) return chunks;
}
return null;
}
/**
* What to query instead of a chunk the server refused: its two halves, or
* null when it is already as small as it gets and the range has to be
* expanded on the client. Any other refusal is rethrown.
*/
function splitRefusedChunk(chunk: RangeChunk, error: unknown): RangeChunk[] | null {
const length = chunk.end - chunk.start;
let minimum: number;
if (jmapErrorType(error) === 'expandDurationTooLarge') minimum = MIN_DURATION_CHUNK;
else if (isOccurrenceLimitError(error)) minimum = MIN_OCCURRENCE_CHUNK;
else throw error;
if (length <= minimum) return null;
const middle = Math.floor((chunk.start + chunk.end) / 2);
return [
{ start: chunk.start, end: middle, position: 0 },
{ start: middle - 1, end: chunk.end, position: 0 },
];
}
function hasSubmissionMethod(methodCalls: JMAPMethodCall[]): boolean {
return methodCalls.some(([method]) => method.startsWith('Identity/') || method.startsWith('EmailSubmission/'));
}
@@ -6834,14 +6913,12 @@ export class JMAPClient implements IJMAPClient {
let ids: string[] | null = null;
if (expandRecurrences) {
try {
ids = await this.queryCalendarEventIds({ ...queryArgs, expandRecurrences: true }, limit);
} catch (error) {
// Stalwart fails the whole query once a range expands to more
// occurrences than it allows. Ask for the series instead;
// lib/recurrence-expansion.ts expands them on the client.
if (!isOccurrenceLimitError(error)) throw error;
debug.log('calendar', 'Range expands past the server limit; expanding on the client instead');
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;
}
}
@@ -6913,6 +6990,109 @@ export class JMAPClient implements IJMAPClient {
}
return ids;
}
/**
* The longest range an expanded CalendarEvent/query may span, in seconds:
* `maxExpandedQueryDuration` from the account's calendars capability (the
* primary calendar account's when the account has none), an iCalendar
* duration such as "P365D". Every 0.16 advertises 365 days without
* enforcing it; 1.0 refuses a longer range with `expandDurationTooLarge`.
*/
private getMaxExpandedQuerySeconds(accountId: string): number | null {
for (const id of [accountId, this.getCalendarsAccountId()]) {
const capability = this.getAccountCapability("urn:ietf:params:jmap:calendars", id) as
{ maxExpandedQueryDuration?: unknown } | undefined;
const duration = capability?.maxExpandedQueryDuration;
const seconds = typeof duration === 'string' ? parseDurationSeconds(duration) : null;
if (seconds) return seconds;
}
return null;
}
/**
* The occurrence ids of an expanded range query, or null when the server
* will not expand the range and the client has to.
*
* The range is cut into chunks within the server's maxExpandedQueryDuration
* and the chunk queries go out together, as many per request as
* maxCallsInRequest allows. A synthetic id does not depend on the range it
* was found in, so an occurrence two chunks share is kept once. A chunk the
* server still refuses is halved: when it is too long
* (`expandDurationTooLarge`), or when it expands to too many occurrences,
* down to about a week. Past that the whole range is given up - mixing
* server- and client-expanded occurrences would show series twice.
*
* With a limit the chunks are queried one at a time in the sort order (by
* start), until the limit is reached.
*/
private async queryExpandedCalendarEventIds(
accountId: string,
queryArgs: Record<string, unknown>,
limit?: number,
): Promise<string[] | null> {
const expandedArgs = { ...queryArgs, expandRecurrences: true };
const filter = queryArgs.filter as Record<string, unknown>;
const start = wallClockSeconds(filter.after);
const end = wallClockSeconds(filter.before);
if (start === null || end === null || end <= start) {
try {
return await this.queryCalendarEventIds(expandedArgs, limit);
} catch (error) {
if (jmapErrorType(error) === 'expandDurationTooLarge' || isOccurrenceLimitError(error)) return null;
throw error;
}
}
const maxSeconds = this.getMaxExpandedQuerySeconds(accountId);
const span = maxSeconds === null
? end - start
: maxSeconds - Math.min(EXPANDED_CHUNK_SLACK, Math.floor(maxSeconds / 2));
const chunks = chunkRange(start, end, span);
if (!chunks) return null;
const [firstSort] = (queryArgs.sort as Array<{ property?: string; isAscending?: boolean }> | undefined) ?? [];
const latestFirst = firstSort?.property === 'start' && firstSort.isAscending === false;
if (latestFirst) chunks.reverse();
const pageSize = limit || 1000;
const maxIds = limit || 20000;
const ids = new Set<string>();
let pending = chunks;
let chunksSent = 0;
while (pending.length > 0 && ids.size < maxIds) {
const batch = pending.splice(0, limit ? 1 : this.getMaxCallsInRequest());
// Pages are bounded by maxIds; splitting is what has to stop somewhere.
chunksSent += batch.filter((chunk) => chunk.position === 0).length;
if (chunksSent > MAX_EXPANDED_CHUNKS) return null;
const size = limit ? limit - ids.size : pageSize;
const response = await this.request(batch.map((chunk, i): JMAPMethodCall => ["CalendarEvent/query", {
...expandedArgs,
filter: { ...filter, after: localDateTimeOf(chunk.start), before: localDateTimeOf(chunk.end) },
position: chunk.position,
limit: size,
}, String(i)]), this.calendarUsing());
// The halves of a refused chunk, and the next page of a full one, go
// to the front of the queue.
const again: RangeChunk[] = [];
for (const [i, chunk] of batch.entries()) {
let page: string[];
try {
page = methodResult(response, String(i), "CalendarEvent/query").ids || [];
} catch (error) {
const halves = splitRefusedChunk(chunk, error);
if (!halves) return null;
again.push(...(latestFirst ? halves.reverse() : halves));
continue;
}
for (const id of page) {
if (ids.size >= maxIds) break;
ids.add(id);
}
if (page.length === size) again.push({ ...chunk, position: chunk.position + page.length });
}
pending = [...again, ...pending];
}
return [...ids];
}
/**
* Server-expanded occurrences come back without their series' recurrence