diff --git a/app/api/calendar-agenda/route.ts b/app/api/calendar-agenda/route.ts index 65e480714..f03df7394 100644 --- a/app/api/calendar-agenda/route.ts +++ b/app/api/calendar-agenda/route.ts @@ -37,6 +37,8 @@ const EVENT_PROPERTIES = [ const DAY_MS = 24 * 60 * 60 * 1000; const GET_BATCH = 100; +// Stalwart's default maxCallsInRequest, for a session that names none. +const MAX_CALLS_FALLBACK = 16; interface AgendaEvent { id: string; @@ -154,10 +156,11 @@ export async function POST(request: NextRequest) { windowStart.setHours(0, 0, 0, 0); // ── 1) Query the occurrences in range + load calendars (colours) ── - // expandRecurrences returns one id per occurrence (it needs both bounds). - // A range that expands to more occurrences than Stalwart allows in one - // query is refused; the agenda then looks less far ahead instead of - // failing. + // expandRecurrences returns one id per occurrence (it needs both bounds), + // the earliest first so the limit keeps the next ones. A range the server + // will not expand - more occurrences than it allows in one query, or + // longer than its maxExpandedQueryDuration - is refused; the agenda then + // looks less far ahead instead of failing. let queryRes: unknown; let queryResp: Record | null; for (;;) { @@ -170,6 +173,7 @@ export async function POST(request: NextRequest) { accountId, filter: { after: windowStart.toISOString(), before: horizon.toISOString() }, expandRecurrences: true, + sort: [{ property: 'start', isAscending: true }], limit: 1000, }, '0', @@ -185,7 +189,7 @@ export async function POST(request: NextRequest) { if (queryResp) break; const err = findResponse(queryRes, 'error', '0'); const span = horizon.getTime() - now.getTime(); - if (isOccurrenceLimit(err) && span > DAY_MS) { + if ((isOccurrenceLimit(err) || err?.type === 'expandDurationTooLarge') && span > DAY_MS) { horizon = new Date(now.getTime() + Math.max(DAY_MS, Math.floor(span / 2))); continue; } @@ -211,22 +215,29 @@ export async function POST(request: NextRequest) { return NextResponse.json({ events: [], generatedAt: now.toISOString() }); } - // ── 2) Fetch the occurrences (batched) ── + // ── 2) Fetch the occurrences (batched, several gets per request) ── + const coreCapability = session.capabilities?.['urn:ietf:params:jmap:core'] as { maxCallsInRequest?: unknown } | undefined; + const maxCalls = typeof coreCapability?.maxCallsInRequest === 'number' && coreCapability.maxCallsInRequest > 0 + ? coreCapability.maxCallsInRequest + : MAX_CALLS_FALLBACK; const getEvents = async (eventIds: string[], properties: readonly string[]) => { const list: Array> = []; - for (let i = 0; i < eventIds.length; i += GET_BATCH) { + const batches: string[][] = []; + for (let i = 0; i < eventIds.length; i += GET_BATCH) batches.push(eventIds.slice(i, i + GET_BATCH)); + for (let first = 0; first < batches.length; first += maxCalls) { + const group = batches.slice(first, first + maxCalls); const getRes = await jmapPost(apiUrl, creds.authHeader, { using, - methodCalls: [ - ['CalendarEvent/get', { accountId, ids: eventIds.slice(i, i + GET_BATCH), properties }, '0'], - ], + methodCalls: group.map((ids, i) => ['CalendarEvent/get', { accountId, ids, properties }, String(i)]), }, fetchOptions); - const getResp = findResponse(getRes, 'CalendarEvent/get', '0'); - if (!getResp) { - const err = findResponse(getRes, 'error', '0'); - throw new AgendaFetchError((err?.description as string) || 'CalendarEvent/get failed'); + for (const [i] of group.entries()) { + const getResp = findResponse(getRes, 'CalendarEvent/get', String(i)); + if (!getResp) { + const err = findResponse(getRes, 'error', String(i)); + throw new AgendaFetchError((err?.description as string) || 'CalendarEvent/get failed'); + } + list.push(...((getResp.list as Array>) || [])); } - list.push(...((getResp.list as Array>) || [])); } return list; }; diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 3699acac4..f8b2066a9 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -8,7 +8,7 @@ import { addMonths, subMonths, addWeeks, subWeeks, addDays, subDays, format, parseISO, } from "date-fns"; -import { EVENTS_LOAD_ERROR, useCalendarStore } from "@/stores/calendar-store"; +import { useCalendarStore } from "@/stores/calendar-store"; import { isCalendarViewMode } from "@/stores/calendar-store"; import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/auth-store'; import { useEmailStore } from "@/stores/email-store"; @@ -129,7 +129,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = const { quota, isPushConnected } = useEmailStore(); const { calendars, events, selectedDate, viewMode, selectedCalendarIds, - isLoading, isLoadingEvents, supportsCalendar, error, + isLoading, isLoadingEvents, supportsCalendar, error, eventsLoadError, fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent, setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar, shareCalendar, removeCalendar, clearCalendarEvents, @@ -246,8 +246,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = }, [initialCheckDone, isAuthenticated, authLoading, client, calendarEnabled, supportsCalendar, pendingWebcalAccountChoice, isProtocolAccountSwitching, pendingSubscription, showWebcalActionChoice, router]); useEffect(() => { - // A failed range load gets the notice above the grid instead. - if (error && error !== EVENTS_LOAD_ERROR) { + if (error) { toast.error(error); } }, [error]); @@ -1574,7 +1573,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = return (
- {error === EVENTS_LOAD_ERROR && normalizedViewMode !== "tasks" && ( + {eventsLoadError && normalizedViewMode !== "tasks" && ( )} {viewContent} diff --git a/lib/__tests__/calendar-agenda-route.test.ts b/lib/__tests__/calendar-agenda-route.test.ts index a2b88ea33..0bd9b1cf1 100644 --- a/lib/__tests__/calendar-agenda-route.test.ts +++ b/lib/__tests__/calendar-agenda-route.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; -import { OVERRIDES_WITH_UTC_ERROR, asksForOverridesWithUtc, type StalwartVersion } from './helpers/stalwart-calendar'; +import { + EXPAND_DURATION_TOO_LARGE, + OVERRIDES_WITH_UTC_ERROR, + asksForOverridesWithUtc, + type StalwartVersion, +} from './helpers/stalwart-calendar'; // ── module mocks (hoisted) ─────────────────────────────────────────────────── vi.mock('next/server', () => { @@ -92,8 +97,9 @@ describe('calendar-agenda route (principals:owner declaration)', () => { // fills in the series context from the base events, so it never needs // `recurrenceOverrides` next to utcStart/utcEnd - a get Stalwart 1.0 refuses. describe.each(['0.16', '1.0'])('calendar-agenda route on Stalwart %s', (version) => { - type Call = { method: string; args: Record }; + type Call = { method: string; args: Record; request: number }; let calls: Call[]; + let requests: number; let answer: (call: Call) => [string, Record] | undefined; const OCCURRENCES: Record> = { @@ -123,7 +129,7 @@ describe.each(['0.16', '1.0'])('calendar-agenda route on Stalwa if (version === '1.0' && asksForOverridesWithUtc(args)) return ['error', { ...OVERRIDES_WITH_UTC_ERROR }]; const properties = args.properties as string[]; const list = (args.ids as string[]).map((id) => { - const source = OCCURRENCES[id] ?? BASES[id]; + const source = OCCURRENCES[id] ?? BASES[id] ?? { id }; return Object.fromEntries(Object.entries(source).filter(([key]) => key === 'id' || properties.includes(key))); }); return ['CalendarEvent/get', { list }]; @@ -134,12 +140,14 @@ describe.each(['0.16', '1.0'])('calendar-agenda route on Stalwa vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(new Date('2026-09-25T08:00:00Z')); calls = []; + requests = 0; answer = () => undefined; mockCreds.mockResolvedValue({ serverUrl: 'https://mail.example.com', username: 'user@example.com', authHeader: 'Basic abc' }); mockSession.mockResolvedValue(makeSession({ 'urn:ietf:params:jmap:core': {}, [CALENDAR_CAP]: {} })); mockPost.mockImplementation(async (_url: string, _auth: string, body: string) => { + const request = requests++; const methodResponses = JSON.parse(body).methodCalls.map(([method, args, callId]: [string, Record, string]) => { - const call = { method, args }; + const call = { method, args, request }; calls.push(call); return [...(answer(call) ?? defaultAnswer(call)), callId]; }); @@ -160,6 +168,8 @@ describe.each(['0.16', '1.0'])('calendar-agenda route on Stalwa const query = calls.find((c) => c.method === 'CalendarEvent/query')!; expect(query.args.expandRecurrences).toBe(true); + // The earliest first, so the query's limit keeps the next occurrences. + expect(query.args.sort).toEqual([{ property: 'start', isAscending: true }]); expect(gets().filter((g) => asksForOverridesWithUtc(g.args))).toEqual([]); const [instanceGet, baseGet] = gets(); expect(instanceGet.args.properties).not.toContain('recurrenceOverrides'); @@ -200,4 +210,53 @@ describe.each(['0.16', '1.0'])('calendar-agenda route on Stalwa expect(spans).toEqual([80, 40, 20]); expect(((await res.json()) as { events: unknown[] }).events).toHaveLength(2); }); + + it(`looks less far ahead when the range is longer than the server expands on Stalwart ${version}`, async () => { + // An administrator set maxExpandedQueryDuration to 30 days. 1.0 refuses a + // longer expanded range; 0.16 never checks. + answer = ({ method, args }) => { + if (method !== 'CalendarEvent/query' || version !== '1.0') return undefined; + const filter = args.filter as { after: string; before: string }; + return Date.parse(filter.before) - Date.parse(filter.after) > 30 * 86_400_000 + ? ['error', { ...EXPAND_DURATION_TOO_LARGE }] + : undefined; + }; + + const res = await POST({ json: async () => ({ days: 80 }) } as unknown as Parameters[0]); + + expect(res.status).toBe(200); + const spans = calls.filter((c) => c.method === 'CalendarEvent/query').map((c) => { + const filter = c.args.filter as { after: string; before: string }; + return (Date.parse(filter.before) - Date.parse('2026-09-25T08:00:00Z')) / 86_400_000; + }); + expect(spans).toEqual(version === '1.0' ? [80, 40, 20] : [80]); + expect(((await res.json()) as { events: unknown[] }).events).toHaveLength(2); + }); + + it(`fetches many occurrences with several gets per request on Stalwart ${version}`, async () => { + const many = [...Object.keys(OCCURRENCES), ...Array.from({ length: 248 }, (_, i) => `x${i}`)]; + answer = ({ method }) => method === 'CalendarEvent/query' ? ['CalendarEvent/query', { ids: many }] : undefined; + + const res = await POST(makeReq()); + + expect(res.status).toBe(200); + const occurrenceGets = gets().filter((g) => !(g.args.ids as string[]).includes('b')); + expect(occurrenceGets.map((g) => (g.args.ids as string[]).length)).toEqual([100, 100, 50]); + expect(new Set(occurrenceGets.map((g) => g.request)).size).toBe(1); + expect(((await res.json()) as { events: unknown[] }).events).toHaveLength(2); + }); + + it(`keeps to the session's maxCallsInRequest when fetching occurrences on Stalwart ${version}`, async () => { + mockSession.mockResolvedValue(makeSession({ 'urn:ietf:params:jmap:core': { maxCallsInRequest: 2 }, [CALENDAR_CAP]: {} })); + const many = [...Object.keys(OCCURRENCES), ...Array.from({ length: 248 }, (_, i) => `x${i}`)]; + answer = ({ method }) => method === 'CalendarEvent/query' ? ['CalendarEvent/query', { ids: many }] : undefined; + + const res = await POST(makeReq()); + + expect(res.status).toBe(200); + const occurrenceGets = gets().filter((g) => !(g.args.ids as string[]).includes('b')); + const [first, , last] = occurrenceGets; + expect(occurrenceGets.map((g) => g.request)).toEqual([first.request, first.request, first.request + 1]); + expect((last.args.ids as string[]).length).toBe(50); + }); }); diff --git a/lib/__tests__/helpers/stalwart-calendar.ts b/lib/__tests__/helpers/stalwart-calendar.ts index 67fd09f42..116126ee4 100644 --- a/lib/__tests__/helpers/stalwart-calendar.ts +++ b/lib/__tests__/helpers/stalwart-calendar.ts @@ -248,12 +248,52 @@ export function occurrenceObjects(seriesId: string, occurrences: RangeOccurrence }])); } +type FilterNode = Record & { operator?: string; conditions?: FilterNode[] }; + +/** Stalwart 1.0's refusal of an expanded query whose filter is more than one FilterCondition. */ +export const NOT_A_SINGLE_CONDITION = { + type: 'invalidArguments', + description: 'The filter must be a single FilterCondition when expanding recurrences', +} as const; + +/** + * Whether Stalwart 1.0 expands recurrences for `filter`: no OR or NOT, and at + * most one AND - a condition of two keys counts as an AND of its own + * (jmap-proto/src/method/query.rs; calendar_event/query.rs). + */ +export function isSingleFilterCondition(filter: FilterNode): boolean { + let ands = 0; + const walk = (node: FilterNode): boolean => { + if (node.operator) { + if (node.operator !== 'AND') return false; + ands++; + return (node.conditions ?? []).every(walk); + } + if (Object.keys(node).length > 1) ands++; + return true; + }; + return walk(filter) && ands <= 1; +} + +/** The after/before of a filter: keys of its own, or conditions anywhere in it. */ +export function boundsOf(filter: FilterNode): { after?: string; before?: string } { + const bounds: { after?: string; before?: string } = {}; + const walk = (node: FilterNode) => { + if (typeof node.after === 'string') bounds.after = node.after; + if (typeof node.before === 'string') bounds.before = node.before; + for (const condition of node.conditions ?? []) walk(condition); + }; + walk(filter); + return bounds; +} + /** * 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. + * 1.0 refuses a span over `maxSpanSeconds` (0.16 never checks it) and a + * filter that is more than a single FilterCondition, 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, @@ -263,9 +303,11 @@ export function expandingQueryAnswer( 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); + const filter = args.filter as FilterNode; + if (version === '1.0' && !isSingleFilterCondition(filter)) return ['error', { ...NOT_A_SINGLE_CONDITION }]; + const bounds = boundsOf(filter); + const after = localSeconds(bounds.after ?? ''); + const before = localSeconds(bounds.before ?? ''); if (version === '1.0' && before - after > (options.maxSpanSeconds ?? 365 * 86_400)) { return ['error', { ...EXPAND_DURATION_TOO_LARGE }]; } diff --git a/lib/__tests__/jmap-calendar-query-range.test.ts b/lib/__tests__/jmap-calendar-query-range.test.ts index e9ae3d95b..e2a5f8f5e 100644 --- a/lib/__tests__/jmap-calendar-query-range.test.ts +++ b/lib/__tests__/jmap-calendar-query-range.test.ts @@ -272,14 +272,30 @@ describe('expanded ranges longer than maxExpandedQueryDuration', () => { ]); }); - it('reads the chunks one at a time in start order and stops at the limit (contact activity) on Stalwart 1.0', async () => { + it('reads the chunks in start order, a few per request, and keeps what fits 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); + // One request asks all three chunks; the first fills the limit, and + // what the others returned is dropped. + 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'], + ]); + expect(new Set(expandedQueries().map((q) => q.request)).size).toBe(1); + expect(events.map((e) => e.id).sort()).toEqual(['o-across', 'o-first']); + }); + + it('asks the next few chunks only when the first ones fall short of the limit on Stalwart 1.0', async () => { + // Chunks of 99 days: eight for the window, four per request. + await connect('1.0', { maxExpandedQueryDuration: 'P100D' }); + + const events = await client.queryCalendarEvents(WINDOW, [{ property: 'start', isAscending: true }], 2); + + expect(expandedQueries()).toHaveLength(4); + expect(new Set(expandedQueries().map((q) => q.request)).size).toBe(1); expect(events.map((e) => e.id).sort()).toEqual(['o-across', 'o-first']); }); @@ -292,14 +308,15 @@ describe('expanded ranges longer than maxExpandedQueryDuration', () => { expect(bounds()).toEqual([ ['2026-12-29T23:59:58', '2027-02-16T23:59:59'], ['2025-12-30T23:59:59', '2026-12-29T23:59:59'], + ['2025-01-01T00:00:00', '2025-12-31T00:00:00'], ]); - expect(expandedQueries().map((q) => q.args.limit)).toEqual([2, 1]); - expect(expandedQueries()[0].request).not.toBe(expandedQueries()[1].request); + expect(new Set(expandedQueries().map((q) => q.request)).size).toBe(1); 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'); + // One chunk per request, so the paging is what the requests show. + await connect('1.0', { maxCallsInRequest: 1 }); // 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. @@ -323,6 +340,23 @@ describe('expanded ranges longer than maxExpandedQueryDuration', () => { expect(events.map((e) => e.id).sort()).toEqual(['o-after', 'o-late', 'o-middle']); }); + it('stops between requests when the caller aborts on Stalwart 1.0', async () => { + await connect('1.0', { maxCallsInRequest: 1 }); + const controller = new AbortController(); + const answering = fetchSpy.getMockImplementation()!; + fetchSpy.mockImplementation(async (url: RequestInfo | URL, init?: RequestInit) => { + const response = await answering(url, init); + if (String(init?.body).includes('"expandRecurrences":true')) controller.abort(); + return response; + }); + + const error = await client.queryCalendarEvents(WINDOW, undefined, undefined, undefined, { signal: controller.signal }) + .then(() => null, (e: unknown) => e); + + expect((error as Error).name).toBe('AbortError'); + expect(expandedQueries()).toHaveLength(1); + }); + it('asks for two chunks for a 365-day look-ahead on Stalwart 1.0', async () => { await connect('1.0'); @@ -334,6 +368,35 @@ describe('expanded ranges longer than maxExpandedQueryDuration', () => { ]); }); + // Global search sends a query of several text parts as a flat AND of + // single-key conditions, the range among them. 1.0 expands recurrences for + // that shape (one AND), not for one where a condition holds both bounds. + it.each<[StalwartVersion]>([['0.16'], ['1.0']])( + 'expands and chunks a range given as conditions of an AND on Stalwart %s', + async (version) => { + await connect(version); + + const events = await client.queryCalendarEvents({ + operator: 'AND', + conditions: [{ after: WINDOW.after }, { before: WINDOW.before }, { text: 'budget' }, { text: 'review' }], + }); + + expect(expandedQueries()).toHaveLength(3); + for (const query of expandedQueries()) { + const filter = query.args.filter as { operator: string; conditions: Array> }; + expect(filter.operator).toBe('AND'); + expect(filter.conditions.slice(2)).toEqual([{ text: 'budget' }, { text: 'review' }]); + expect(filter.conditions.every((condition) => Object.keys(condition).length === 1)).toBe(true); + } + expect(expandedQueries().map((q) => (q.args.filter as { conditions: unknown[] }).conditions.slice(0, 2))).toEqual([ + [{ after: '2025-01-01T00:00:00' }, { before: '2025-12-31T00:00:00' }], + [{ after: '2025-12-30T23:59:59' }, { before: '2026-12-29T23:59:59' }], + [{ after: '2026-12-29T23:59:58' }, { before: '2027-02-16T23:59:59' }], + ]); + expect(events.map((e) => e.id).sort()).toEqual([...IN_WINDOW].sort()); + }, + ); + 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')); diff --git a/lib/global-search/__tests__/providers.test.ts b/lib/global-search/__tests__/providers.test.ts index 13c8c17a6..5c7594453 100644 --- a/lib/global-search/__tests__/providers.test.ts +++ b/lib/global-search/__tests__/providers.test.ts @@ -270,10 +270,11 @@ describe('calendar provider', () => { ); 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. + // Three years in chunks of 364 days, the latest first, sent together, + // each still filtered by the search text. const expanded = server.queries().filter((q) => q.args.expandRecurrences); expect(expanded).toHaveLength(4); + expect(new Set(expanded.map((q) => q.request)).size).toBe(1); 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); @@ -283,12 +284,86 @@ describe('calendar provider', () => { } }); + it('searches several words over more than a year in chunks on Stalwart 1.0', async () => { + // The words go out as an AND of one-key conditions with the bounds among + // them. Each chunk keeps that one flat AND with its own bounds: 1.0 + // expands recurrences for a single AND of single conditions only. + const occurrences = [ + { id: 'o-2024', start: '2024-03-05T09: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('"team sync" budget 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-2026']); + const expanded = server.queries().filter((q) => q.args.expandRecurrences); + expect(expanded).toHaveLength(4); + for (const query of expanded) { + const filter = query.args.filter as { operator: string; conditions: Array> }; + expect(filter.operator).toBe('AND'); + expect(filter.conditions.map((condition) => Object.keys(condition))).toEqual([['after'], ['before'], ['text'], ['text']]); + expect(filter.conditions.slice(2)).toEqual([{ text: '"team sync"' }, { text: 'budget' }]); + // Wall clock of the request's time zone, as for a filter of bounds. + expect(filter.conditions[0].after.endsWith('Z')).toBe(false); + } + expect(server.queries().filter((q) => !q.args.expandRecurrences)).toHaveLength(0); + } finally { + client.disconnect(); + fetchSpy.mockRestore(); + } + }); + + it('stops a search the user replaced before its next request on Stalwart 1.0', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + const client = await connectCalendarClient(fetchSpy, stalwartCalendarSession('1.0')); + const occurrences = [{ id: 'o-2025', start: '2025-06-10T09:00:00', seconds: 3600 }]; + 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 }), + }); + const controller = new AbortController(); + const answering = fetchSpy.getMockImplementation()!; + fetchSpy.mockImplementation(async (url: RequestInfo | URL, init?: RequestInit) => { + const response = await answering(url, init); + if (String(init?.body).includes('"expandRecurrences":true')) controller.abort(); + return response; + }); + try { + const error = await calendarProvider.remote( + parseSearchQuery('standup after:2024-01-01 before:2026-12-31'), + { ...account('login-a'), client }, + { limit: 10, signal: controller.signal }, + ).then(() => null, (e: unknown) => e); + + expect((error as Error).name).toBe('AbortError'); + // The chunk queries went out; the occurrences were not fetched. + expect(server.gets().filter((g) => (g.args.ids as string[]).includes('o-2025'))).toHaveLength(0); + } 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 }, ]); const result = await calendarProvider.remote(parseSearchQuery('sync'), account('login-a', { queryAllCalendarEvents }), { limit: 10, signal }); - expect(queryAllCalendarEvents).toHaveBeenCalledWith({ text: 'sync' }, [{ property: 'start', isAscending: false }], 10); + expect(queryAllCalendarEvents).toHaveBeenCalledWith({ text: 'sync' }, [{ property: 'start', isAscending: false }], 10, { signal }); expect(result.hits[0]).toMatchObject({ id: 'e9', isRecurring: true, localAccountId: 'login-a' }); await calendarProvider.remote(parseSearchQuery('"weekly sync" plan*'), account('login-a', { queryAllCalendarEvents }), { limit: 10, signal }); @@ -296,6 +371,7 @@ describe('calendar provider', () => { { operator: 'AND', conditions: [{ text: '"weekly sync"' }, { text: 'plan*' }] }, [{ property: 'start', isAscending: false }], 10, + { signal }, ); }); }); diff --git a/lib/global-search/providers/calendar.ts b/lib/global-search/providers/calendar.ts index 492b36ece..2a1df87ba 100644 --- a/lib/global-search/providers/calendar.ts +++ b/lib/global-search/providers/calendar.ts @@ -110,6 +110,7 @@ export const calendarProvider: SearchProvider = { calendarFilterFor(parsed), [{ property: 'start', isAscending: false }], limit, + { signal }, ); if (signal.aborted) throw Object.assign(new Error('aborted'), { name: 'AbortError' }); const { calendars } = useCalendarStore.getState(); diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index b68bcbbf8..899d31bc2 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -49,6 +49,11 @@ export interface CalendarEventUpdateOptions { keepSequence?: boolean; } +export interface CalendarQueryOptions { + /** Stops a query of several requests (a long range, several accounts) between them. */ + signal?: AbortSignal; +} + export interface IJMAPClient { // ── Connection lifecycle ────────────────────────────────────── connect(): Promise; @@ -474,8 +479,8 @@ export interface IJMAPClient { ): Promise; deleteCalendarEvent(eventId: string, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise; batchDeleteCalendarEvents(eventIds: string[], targetAccountId?: string): Promise<{ destroyed: string[]; notDestroyed: Record }>; - queryCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number, targetAccountId?: string): Promise; - queryAllCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number): Promise; + queryCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number, targetAccountId?: string, options?: CalendarQueryOptions): Promise; + queryAllCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number, options?: CalendarQueryOptions): Promise; parseCalendarEvents(accountId: string, blobId: string): Promise[]>; // ── Calendar Tasks ──────────────────────────────────────────── diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index d4814c6f5..fe14160d4 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1,7 +1,7 @@ import { generateUUID } from '@/lib/utils'; import type { Attachment, Email, Mailbox, MailboxRights, StateChange, AccountStates, CollectionChanges, ShareNotification, BusyPeriod, CalendarParticipantIdentity, CalendarEventNotification, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarComponentType, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, CreateCalendarOptions, FileNode, FileNodeFilter, FileNodeRights, Principal, PrincipalAvailability, PrincipalListing, PushSubscription, EmailPushConfig, EmailSubmission, ScheduledEmail, SendEmailResult, SharedAccount } from "./types"; import type { SieveScript, SieveCapabilities } from "./sieve-types"; -import type { CalendarEventUpdateOptions, IJMAPClient, KeywordDiscoveryResult, KeywordInfo, KeywordMigration } from "./client-interface"; +import type { CalendarEventUpdateOptions, CalendarQueryOptions, IJMAPClient, KeywordDiscoveryResult, KeywordInfo, KeywordMigration } from "./client-interface"; import { attachSearchSnippets, filterHasSnippetTerms, snippetFilterFor, type SearchSnippetResult } from "@/lib/search-snippet"; import { textConditions, textFilter } from "./search-utils"; import { batched, itemsPerRequest } from "./request-limits"; @@ -791,6 +791,66 @@ const EXPANDED_CHUNK_SLACK = DAY_SECONDS; const MIN_OCCURRENCE_CHUNK = 7 * DAY_SECONDS; const MIN_DURATION_CHUNK = 3600; const MAX_EXPANDED_CHUNKS = 64; +// With a limit the chunks are read in the sort order, a few per request: +// what later chunks return past the limit is dropped. +const LIMITED_CHUNKS_PER_REQUEST = 4; + +function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) throw Object.assign(new Error('aborted'), { name: 'AbortError' }); +} + +function isAbortError(error: unknown): boolean { + return error instanceof Error && error.name === 'AbortError'; +} + +/** The range of an expanded CalendarEvent/query, and its filter for other bounds. */ +interface QueryRange { + after?: string; + before?: string; + withBounds: (after?: string, before?: string) => Record; +} + +/** + * The after/before bounds of a CalendarEvent filter, and the filter rebuilt + * for other bounds. The bounds are keys of the filter itself, or single-key + * conditions of a top-level AND - how global search sends a range with + * several text parts. Rebuilt, such an AND stays flat: Stalwart 1.0 expands + * recurrences only for a single FilterCondition, and reads a condition of + * two keys as an AND of its own. + */ +function rangeOf(filter: CalendarEventFilter): QueryRange { + if (filter.operator === 'AND' && Array.isArray(filter.conditions)) { + let after: string | undefined; + let before: string | undefined; + const rest = filter.conditions.filter((condition) => { + const keys = Object.keys(condition); + if (keys.length !== 1) return true; + if (keys[0] === 'after' && typeof condition.after === 'string') { + after = condition.after; + return false; + } + if (keys[0] === 'before' && typeof condition.before === 'string') { + before = condition.before; + return false; + } + return true; + }); + return { + after, + before, + withBounds: (from, to) => ({ + operator: 'AND', + conditions: [...(from ? [{ after: from }] : []), ...(to ? [{ before: to }] : []), ...rest], + }), + }; + } + const { after, before, ...rest } = filter; + return { + after, + before, + withBounds: (from, to) => ({ ...rest, ...(from ? { after: from } : {}), ...(to ? { before: to } : {}) }), + }; +} /** * Part of an expanded CalendarEvent/query range, in seconds of the request @@ -7169,7 +7229,8 @@ export class JMAPClient implements IJMAPClient { async queryAllCalendarEvents( filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, - limit?: number + limit?: number, + options?: CalendarQueryOptions, ): Promise { const allEvents: CalendarEvent[] = []; const primaryId = this.getCalendarsAccountId(); @@ -7179,15 +7240,16 @@ export class JMAPClient implements IJMAPClient { const isPrimary = accountId === primaryId; if (!isPrimary && this.calendarAccessDenied.has(accountId)) continue; const account = this.accounts[accountId]; + throwIfAborted(options?.signal); let events: CalendarEvent[]; try { - events = await this.queryCalendarEvents(filter, sort, limit, accountId); + events = await this.queryCalendarEvents(filter, sort, limit, accountId, options); } catch (error) { // The user's own calendars failing fails the whole range - an empty // answer would read as "no events". A shared account that fails is // left out of the result. - if (isPrimary) throw error; + if (isPrimary || isAbortError(error)) throw error; console.error(`Failed to query calendar events for account ${accountId}:`, error); continue; } @@ -7212,12 +7274,13 @@ export class JMAPClient implements IJMAPClient { filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number, - targetAccountId?: string + targetAccountId?: string, + options?: CalendarQueryOptions, ): Promise { const accountId = targetAccountId || this.getCalendarsAccountId(); try { return await this.withCalendarTimeZone((timeZone) => - this.queryCalendarEventsIn(accountId, filter, sort, limit, timeZone)); + this.queryCalendarEventsIn(accountId, filter, sort, limit, timeZone, options?.signal)); } catch (error) { // The fan-out over shared accounts probes on suspicion (see // getCalendarCapableAccountIds) and may hit accounts that grant no @@ -7243,6 +7306,7 @@ export class JMAPClient implements IJMAPClient { sort: Array<{ property: string; isAscending: boolean }> | undefined, limit: number | undefined, timeZone: string | undefined, + signal?: AbortSignal, ): Promise { // Stalwart reads the LocalDateTime after/before filter values in the // request's time zone (UTC without one, shifting range boundaries), and @@ -7255,14 +7319,10 @@ export class JMAPClient implements IJMAPClient { userTimeZone && timeZone ? moveLocalDateTime(value, userTimeZone, timeZone) : value, timeZone, ); - const queryArgs: Record = { - accountId, - filter: { - ...filter, - ...(filter.after ? { after: bound(filter.after) } : {}), - ...(filter.before ? { before: bound(filter.before) } : {}), - }, - }; + const range = rangeOf(filter); + const after = range.after ? bound(range.after) : undefined; + const before = range.before ? bound(range.before) : undefined; + const queryArgs: Record = { accountId, filter: range.withBounds(after, before) }; if (timeZone) { queryArgs.timeZone = timeZone; } @@ -7271,7 +7331,7 @@ export class JMAPClient implements IJMAPClient { // (Stalwart >= 0.16.20; the server needs both range bounds for it). // Older servers keep the client-side expansion in // lib/recurrence-expansion.ts, which runs on whatever comes back. - let expandRecurrences = Boolean(filter.after && filter.before) + let expandRecurrences = Boolean(after && before) && await this.supportsSyntheticCalendarIds(); if (sort) { queryArgs.sort = sort; @@ -7279,7 +7339,7 @@ export class JMAPClient implements IJMAPClient { let ids: string[] | null = null; if (expandRecurrences) { - ids = await this.queryExpandedCalendarEventIds(accountId, queryArgs, limit); + ids = await this.queryExpandedCalendarEventIds(accountId, queryArgs, { ...range, after, before }, limit, signal); if (!ids) { // The server will not expand this range within its limits. Ask // for the series instead; lib/recurrence-expansion.ts expands @@ -7290,6 +7350,7 @@ export class JMAPClient implements IJMAPClient { } ids ??= await this.queryCalendarEventIds(queryArgs, limit); if (ids.length === 0) return []; + throwIfAborted(signal); // Only series the client expands itself need their overrides here; a // server-expanded occurrence gets them from its base event below. @@ -7371,18 +7432,19 @@ export class JMAPClient implements IJMAPClient { * 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. + * With a limit the chunks are read in the sort order (by start), a few + * per request, until the limit is reached. */ private async queryExpandedCalendarEventIds( accountId: string, queryArgs: Record, + range: QueryRange, limit?: number, + signal?: AbortSignal, ): Promise { const expandedArgs = { ...queryArgs, expandRecurrences: true }; - const filter = queryArgs.filter as Record; - const start = wallClockSeconds(filter.after); - const end = wallClockSeconds(filter.before); + const start = wallClockSeconds(range.after); + const end = wallClockSeconds(range.before); if (start === null || end === null || end <= start) { try { return await this.queryCalendarEventIds(expandedArgs, limit); @@ -7406,22 +7468,27 @@ export class JMAPClient implements IJMAPClient { const maxIds = limit || 20000; const ids = new Set(); let pending = chunks; - let chunksSent = 0; + // Pages are bounded by maxIds; splitting is what has to stop somewhere. + const chunksSent = new Set(); 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; + throwIfAborted(signal); + const batch = pending.splice(0, limit + ? Math.min(LIMITED_CHUNKS_PER_REQUEST, this.getMaxCallsInRequest()) + : this.getMaxCallsInRequest()); + for (const chunk of batch) if (chunk.position === 0) chunksSent.add(chunk); + if (chunksSent.size > 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) }, + filter: range.withBounds(localDateTimeOf(chunk.start), 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. + // to the front of the queue. With a limit, a chunk that needs another + // query comes before the rest of its batch in the sort order: what the + // later chunks returned is dropped and asked for again after it. const again: RangeChunk[] = []; for (const [i, chunk] of batch.entries()) { let page: string[]; @@ -7431,13 +7498,24 @@ export class JMAPClient implements IJMAPClient { const halves = splitRefusedChunk(chunk, error); if (!halves) return null; again.push(...(latestFirst ? halves.reverse() : halves)); + if (limit) { + again.push(...batch.slice(i + 1)); + break; + } 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 }); + if (ids.size >= maxIds) break; + if (page.length === size) { + again.push({ ...chunk, position: chunk.position + page.length }); + if (limit) { + again.push(...batch.slice(i + 1)); + break; + } + } } pending = [...again, ...pending]; } diff --git a/stores/__tests__/calendar-store-synthetic-ids.test.ts b/stores/__tests__/calendar-store-synthetic-ids.test.ts index 630d33f56..69136e4d4 100644 --- a/stores/__tests__/calendar-store-synthetic-ids.test.ts +++ b/stores/__tests__/calendar-store-synthetic-ids.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { EVENTS_LOAD_ERROR, useCalendarStore, resolveMutationTarget } from '../calendar-store'; +import { useCalendarStore, resolveMutationTarget } from '../calendar-store'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; import type { CalendarEvent } from '@/lib/jmap/types'; import { jmapError, jmapSetError } from '@/lib/jmap/jmap-error'; @@ -389,17 +389,38 @@ describe('a range that fails to load', () => { const state = useCalendarStore.getState(); expect(state.events.map((e) => e.id)).toEqual(['maaaaab']); - expect(state.error).toBe(EVENTS_LOAD_ERROR); + expect(state.eventsLoadError).toBe(true); expect(state.isLoadingEvents).toBe(false); - // Still the range that is actually loaded. - expect(state.dateRange).toEqual({ start: RANGE[0], end: RANGE[1] }); + // The range asked for, so a push or a refresh reloads that one. + expect(state.dateRange).toEqual({ start: NEXT[0], end: NEXT[1] }); client.queryAllCalendarEvents.mockResolvedValueOnce([]); await useCalendarStore.getState().fetchEvents(client, NEXT[0], NEXT[1]); - expect(useCalendarStore.getState().error).toBeNull(); + expect(useCalendarStore.getState().eventsLoadError).toBe(false); expect(useCalendarStore.getState().events).toEqual([]); }); + it('keeps reporting the failure through other actions and reloads the range asked for on Stalwart 1.0', async () => { + const client = fakeClient({ getAllCalendars: vi.fn().mockResolvedValue([]) }); + client.queryAllCalendarEvents.mockResolvedValueOnce([occurrence()]); + await useCalendarStore.getState().fetchEvents(client, RANGE[0], RANGE[1]); + // From here on every range load fails. + client.queryAllCalendarEvents.mockRejectedValue(refused()); + await useCalendarStore.getState().fetchEvents(client, NEXT[0], NEXT[1]); + + // A change, and the calendar list reloading, reset `error`. + await useCalendarStore.getState().updateEvent(client, 'maaaaab', { title: 'Renamed' }); + await useCalendarStore.getState().fetchCalendars(client); + expect(useCalendarStore.getState().error).toBeNull(); + expect(useCalendarStore.getState().eventsLoadError).toBe(true); + + // What a push does: reload the store's range - the one that failed. + const { dateRange } = useCalendarStore.getState(); + await useCalendarStore.getState().fetchEvents(client, dateRange!.start, dateRange!.end); + expect(client.queryAllCalendarEvents).toHaveBeenLastCalledWith({ after: NEXT[0], before: NEXT[1] }); + expect(useCalendarStore.getState().eventsLoadError).toBe(true); + }); + it('keeps the events of an account that fails when loading every account', async () => { const first = fakeClient(); first.queryAllCalendarEvents.mockResolvedValue([occurrence({ title: 'First' })]); @@ -417,7 +438,8 @@ describe('a range that fails to load', () => { ['A@h::maaaaab', 'First'], ['B@h::maaaaab', 'Second, loaded before'], ]); - expect(state.error).toBe(EVENTS_LOAD_ERROR); + expect(state.eventsLoadError).toBe(true); + expect(state.dateRange).toEqual({ start: NEXT[0], end: NEXT[1] }); }); }); diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index edfa15545..2f1be34e0 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -518,12 +518,6 @@ export interface FetchEventsOptions { silent?: boolean; } -/** - * The `error` a failed range load leaves. The events loaded before stay in - * the store; the calendar shows a notice for it instead of a toast. - */ -export const EVENTS_LOAD_ERROR = 'Failed to load events'; - interface CalendarStore { calendars: Calendar[]; events: CalendarEvent[]; @@ -535,6 +529,13 @@ interface CalendarStore { isLoadingEvents: boolean; supportsCalendar: boolean; error: string | null; + /** + * The last range load failed. The events loaded before it stay, and the + * calendar says they may be out of date. Only a successful range load + * clears it, so other actions that reset `error` leave it alone. + */ + eventsLoadError: boolean; + /** The range last asked for, loaded or not: push and refresh reload it. */ dateRange: { start: string; end: string } | null; // The user's ParticipantIdentity list (calendar addresses to organise // as); the default one is used as organizer of new invitations. @@ -585,6 +586,7 @@ const initialState = { supportsCalendar: false, participantIdentities: [], error: null as string | null, + eventsLoadError: false, dateRange: null as { start: string; end: string } | null, icalSubscriptions: [] as ICalSubscription[], }; @@ -674,12 +676,13 @@ export const useCalendarStore = create()( if (droppedEvents > 0) { debug.warn('calendar', 'Calendar fetchEvents dropped malformed events without a start field', { droppedEvents }); } - set({ events, isLoadingEvents: false, dateRange: { start, end } }); + set({ events, isLoadingEvents: false, eventsLoadError: false, dateRange: { start, end } }); } catch (error) { - // Keep the events already on screen: an empty grid would read as - // "no events". + // Keep the events already on screen - an empty grid would read as + // "no events" - and take the range as asked for, so a push or a + // refresh reloads it rather than the one loaded before. debug.error('Failed to fetch events:', error); - set({ error: EVENTS_LOAD_ERROR, isLoadingEvents: false }); + set({ eventsLoadError: true, isLoadingEvents: false, dateRange: { start, end } }); } }, @@ -736,12 +739,12 @@ export const useCalendarStore = create()( set({ events: results.flat(), isLoadingEvents: false, + eventsLoadError: failed, dateRange: { start, end }, - ...(failed ? { error: EVENTS_LOAD_ERROR } : {}), }); } catch (error) { debug.error('Failed to fetch all-account events:', error); - set({ error: EVENTS_LOAD_ERROR, isLoadingEvents: false }); + set({ eventsLoadError: true, isLoadingEvents: false, dateRange: { start, end } }); } },