diff --git a/app/api/calendar-agenda/route.ts b/app/api/calendar-agenda/route.ts index 4c345b958..65e480714 100644 --- a/app/api/calendar-agenda/route.ts +++ b/app/api/calendar-agenda/route.ts @@ -3,7 +3,7 @@ import { logger } from '@/lib/logger'; import { getStalwartCredentials } from '@/lib/stalwart/credentials'; import { fetchJmapSession, postJmap, rebaseApiUrl } from '@/lib/stalwart/jmap-api'; import { normalizeCalendarEventLike } from '@/lib/calendar-event-normalization'; -import { expandRecurringEvents } from '@/lib/recurrence-expansion'; +import { RECURRENCE_BASE_PROPERTIES, hydrateRecurrenceInstances, isServerRecurrenceInstance } from '@/lib/recurrence-instances'; import { parseISO } from 'date-fns'; import type { CalendarEvent } from '@/lib/jmap/types'; @@ -12,8 +12,9 @@ import type { CalendarEvent } from '@/lib/jmap/types'; * * Sidecar for the "Calendar Agenda" plugin. Resolves the caller's calendar * account from the stored Stalwart auth context, queries upcoming - * CalendarEvents over JMAP, expands recurring series server-side, and returns - * a slim, structured-cloneable agenda the plugin can render directly. + * CalendarEvents over JMAP with Stalwart expanding recurring series into + * occurrences, and returns a slim, structured-cloneable agenda the plugin can + * render directly. * * Credentials never leave the server — the sandboxed plugin only ever sees * the resulting agenda DTOs. @@ -25,14 +26,18 @@ const CALENDAR_CAP = 'urn:ietf:params:jmap:calendars'; const PRINCIPALS_OWNER_CAP = 'urn:ietf:params:jmap:principals:owner'; // Mirror of lib/jmap/client.ts CALENDAR_EVENT_PROPERTIES, trimmed to what the -// agenda actually needs (start/recurrence/display fields). +// agenda shows of an occurrence. The server expands the series, so no rule or +// overrides are needed - and Stalwart 1.0 refuses a get that asks for +// `recurrenceOverrides` together with utcStart/utcEnd. const EVENT_PROPERTIES = [ 'id', '@type', 'uid', 'calendarIds', 'title', 'start', 'duration', 'timeZone', 'showWithoutTime', 'utcStart', 'utcEnd', 'status', 'freeBusyStatus', 'color', - 'locations', 'recurrenceId', 'recurrenceIdTimeZone', 'recurrenceRule', - 'recurrenceOverrides', 'excludedRecurrenceRule', + 'locations', 'recurrenceId', 'recurrenceIdTimeZone', 'baseEventId', ] as const; +const DAY_MS = 24 * 60 * 60 * 1000; +const GET_BATCH = 100; + interface AgendaEvent { id: string; uid: string | null; @@ -142,40 +147,48 @@ export async function POST(request: NextRequest) { const apiUrl = rebaseApiUrl(session, creds.serverUrl) ?? `${creds.serverUrl}/jmap/`; const now = new Date(); - const horizon = new Date(now.getTime() + days * 24 * 60 * 60 * 1000); + let horizon = new Date(now.getTime() + days * DAY_MS); // Expand from the start of today so all-day / already-running events still // show in the agenda. const windowStart = new Date(now); windowStart.setHours(0, 0, 0, 0); - // ── 1) Query event IDs in range + load calendars (colours) ── - const queryReq = { - using, - methodCalls: [ - [ - 'CalendarEvent/query', - { - accountId, - // Mirror the app's calendar store: an { after, before } window lets - // Stalwart evaluate recurrence so masters with occurrences in range - // are returned (a `before`-only filter can drop unbounded series). - filter: { after: windowStart.toISOString(), before: horizon.toISOString() }, - limit: 1000, - }, - '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. + let queryRes: unknown; + let queryResp: Record | null; + for (;;) { + queryRes = await jmapPost(apiUrl, creds.authHeader, { + using, + methodCalls: [ + [ + 'CalendarEvent/query', + { + accountId, + filter: { after: windowStart.toISOString(), before: horizon.toISOString() }, + expandRecurrences: true, + limit: 1000, + }, + '0', + ], + [ + 'Calendar/get', + { accountId, ids: null, properties: ['id', 'name', 'color'] }, + 'c', + ], ], - [ - 'Calendar/get', - { accountId, ids: null, properties: ['id', 'name', 'color'] }, - 'c', - ], - ], - }; - - const queryRes = await jmapPost(apiUrl, creds.authHeader, queryReq, fetchOptions); - const queryResp = findResponse(queryRes, 'CalendarEvent/query', '0'); - if (!queryResp) { + }, fetchOptions); + queryResp = findResponse(queryRes, 'CalendarEvent/query', '0'); + if (queryResp) break; const err = findResponse(queryRes, 'error', '0'); + const span = horizon.getTime() - now.getTime(); + if (isOccurrenceLimit(err) && span > DAY_MS) { + horizon = new Date(now.getTime() + Math.max(DAY_MS, Math.floor(span / 2))); + continue; + } return NextResponse.json( { error: (err?.description as string) || 'CalendarEvent/query failed' }, { status: 502 }, @@ -198,34 +211,53 @@ export async function POST(request: NextRequest) { return NextResponse.json({ events: [], generatedAt: now.toISOString() }); } - // ── 2) Fetch full event objects (batched) ── - const raw: Array> = []; - const BATCH = 100; - for (let i = 0; i < ids.length; i += BATCH) { - const batch = ids.slice(i, i + BATCH); - const getRes = await jmapPost(apiUrl, creds.authHeader, { - using, - methodCalls: [ - ['CalendarEvent/get', { accountId, ids: batch, properties: EVENT_PROPERTIES }, '0'], - ], - }, fetchOptions); - const getResp = findResponse(getRes, 'CalendarEvent/get', '0'); - if (getResp?.list) raw.push(...(getResp.list as Array>)); + // ── 2) Fetch the occurrences (batched) ── + const getEvents = async (eventIds: string[], properties: readonly string[]) => { + const list: Array> = []; + for (let i = 0; i < eventIds.length; i += GET_BATCH) { + const getRes = await jmapPost(apiUrl, creds.authHeader, { + using, + methodCalls: [ + ['CalendarEvent/get', { accountId, ids: eventIds.slice(i, i + GET_BATCH), properties }, '0'], + ], + }, 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'); + } + list.push(...((getResp.list as Array>) || [])); + } + return list; + }; + + let occurrences: Partial[]; + const bases = new Map>(); + try { + occurrences = (await getEvents(ids, EVENT_PROPERTIES)) + .map((e) => normalizeCalendarEventLike(e as Partial)); + + // ── 3) Hydrate occurrences from their base events ── + // An expanded all-day occurrence comes back without showWithoutTime, + // and the base event has the duration an override leaves out. + const baseIds = Array.from(new Set(occurrences + .filter((e) => isServerRecurrenceInstance(e as CalendarEvent) && e.recurrenceId) + .map((e) => e.baseEventId as string))); + for (const base of await getEvents(baseIds, RECURRENCE_BASE_PROPERTIES)) { + if (typeof base.id === 'string') bases.set(base.id, normalizeCalendarEventLike(base as Partial)); + } + } catch (error) { + if (!(error instanceof AgendaFetchError)) throw error; + return NextResponse.json({ error: error.message }, { status: 502 }); } - // ── 3) Normalize + expand recurrences server-side ── - const normalized = raw - .map((e) => normalizeCalendarEventLike(e as Partial)) + const valid = occurrences .filter((e) => (e['@type'] ?? 'Event') === 'Event') // Drop malformed events without a parseable start (would crash format()/ // parseISO downstream) — mirrors the calendar store guard (#316). .filter((e) => typeof e.start === 'string' && e.start && !isNaN(parseISO(e.start).getTime())) as CalendarEvent[]; - const expanded = expandRecurringEvents( - normalized, - windowStart.toISOString(), - horizon.toISOString(), - ); + const expanded = hydrateRecurrenceInstances(valid, bases); // ── 4) Keep ongoing/upcoming, sort, slice, map to DTOs ── const agenda: AgendaEvent[] = expanded @@ -263,6 +295,19 @@ export async function POST(request: NextRequest) { } } +/** A method error on one of the agenda's gets, answered with 502. */ +class AgendaFetchError extends Error {} + +/** + * Whether the query failed on the server's cap on expanded occurrences: + * `cannotCalculateOccurrences` on Stalwart 1.0, `invalidArguments` ("The + * number of expanded recurrences exceeds the server limit") on 0.16. + */ +function isOccurrenceLimit(err: Record | null): boolean { + if (err?.type === 'cannotCalculateOccurrences') return true; + return err?.type === 'invalidArguments' && /exceeds/i.test(String(err.description ?? '')); +} + function clampInt(value: unknown, min: number, max: number, fallback: number): number { const n = typeof value === 'number' ? value : Number(value); if (!Number.isFinite(n)) return fallback; diff --git a/components/calendar/__tests__/calendar-load-error-notice.test.tsx b/components/calendar/__tests__/calendar-load-error-notice.test.tsx new file mode 100644 index 000000000..33523e071 --- /dev/null +++ b/components/calendar/__tests__/calendar-load-error-notice.test.tsx @@ -0,0 +1,17 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { CalendarLoadErrorNotice } from '../calendar-load-error-notice'; + +// A range that fails to load keeps the events it had; the notice says so and +// offers to load the range again. + +describe('CalendarLoadErrorNotice', () => { + it('says the events may be out of date and retries on request', () => { + const onRetry = vi.fn(); + render(); + + expect(screen.getByRole('alert')).toHaveTextContent('events_load_failed'); + fireEvent.click(screen.getByRole('button', { name: 'retry' })); + expect(onRetry).toHaveBeenCalledTimes(1); + }); +}); diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index ab2085588..c0ace2be4 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 { useCalendarStore } from "@/stores/calendar-store"; +import { EVENTS_LOAD_ERROR, 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"; @@ -26,6 +26,7 @@ import { CalendarWeekView } from "@/components/calendar/calendar-week-view"; import { CalendarDayView } from "@/components/calendar/calendar-day-view"; import { CalendarAgendaView } from "@/components/calendar/calendar-agenda-view"; import { TaskListView } from "@/components/calendar/task-list-view"; +import { CalendarLoadErrorNotice } from "@/components/calendar/calendar-load-error-notice"; import { TaskToolbar } from "@/components/calendar/task-toolbar"; import { TaskModal } from "@/components/calendar/task-modal"; import { MiniCalendar } from "@/components/calendar/mini-calendar"; @@ -236,7 +237,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = }, [initialCheckDone, isAuthenticated, authLoading, client, calendarEnabled, supportsCalendar, pendingWebcalAccountChoice, isProtocolAccountSwitching, pendingSubscription, showWebcalActionChoice, router]); useEffect(() => { - if (error) { + // A failed range load gets the notice above the grid instead. + if (error && error !== EVENTS_LOAD_ERROR) { toast.error(error); } }, [error]); @@ -470,6 +472,17 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = const fetchAllAccountsCalendarsFn = useCalendarStore((s) => s.fetchAllAccountsCalendars); const fetchAllAccountsEventsFn = useCalendarStore((s) => s.fetchAllAccountsEvents); + // Loads the range the view shows now - not the store's dateRange, which a + // failed load leaves at the range loaded before it. + const retryEventsLoad = useCallback(() => { + if (!client || !dateRange) return; + if (multiAccountEnabled && accountClients.length > 0) { + void fetchAllAccountsEventsFn(accountClients, dateRange.start, dateRange.end); + return; + } + void fetchEvents(client, dateRange.start, dateRange.end); + }, [client, dateRange, multiAccountEnabled, accountClients, fetchAllAccountsEventsFn, fetchEvents]); + // The arrows step from what is on screen, which may have been scrolled // away from the selected day. const navigatePrev = useCallback(() => { @@ -1549,6 +1562,9 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = return (
+ {error === EVENTS_LOAD_ERROR && normalizedViewMode !== "tasks" && ( + + )} {viewContent} {isLoadingEvents && calendars.length > 0 && events.length === 0 && (
diff --git a/components/calendar/calendar-load-error-notice.tsx b/components/calendar/calendar-load-error-notice.tsx new file mode 100644 index 000000000..db981e4fe --- /dev/null +++ b/components/calendar/calendar-load-error-notice.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { AlertTriangle } from "@/components/icons"; +import { Button } from "@/components/ui/button"; + +interface CalendarLoadErrorNoticeProps { + onRetry: () => void; +} + +/** + * Shown above the grid while the visible range could not be loaded. The grid + * keeps the events it had, so this says why they may be out of date. + */ +export function CalendarLoadErrorNotice({ onRetry }: CalendarLoadErrorNoticeProps) { + const t = useTranslations("calendar.status"); + return ( +
+ + {t("events_load_failed")} + +
+ ); +} diff --git a/lib/__tests__/calendar-agenda-route.test.ts b/lib/__tests__/calendar-agenda-route.test.ts index 9de5976a8..a2b88ea33 100644 --- a/lib/__tests__/calendar-agenda-route.test.ts +++ b/lib/__tests__/calendar-agenda-route.test.ts @@ -1,4 +1,5 @@ -import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; +import { OVERRIDES_WITH_UTC_ERROR, asksForOverridesWithUtc, type StalwartVersion } from './helpers/stalwart-calendar'; // ── module mocks (hoisted) ─────────────────────────────────────────────────── vi.mock('next/server', () => { @@ -86,3 +87,117 @@ describe('calendar-agenda route (principals:owner declaration)', () => { expect(using).toContain('urn:ietf:params:jmap:principals:owner'); }); }); + +// The agenda lets Stalwart expand recurring series (expandRecurrences) and +// 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 }; + let calls: Call[]; + let answer: (call: Call) => [string, Record] | undefined; + + const OCCURRENCES: Record> = { + // A timed occurrence of the weekly series "b". + maaaaab: { + id: 'maaaaab', baseEventId: 'b', '@type': 'Event', uid: 'b@example.com', title: 'Standup', + start: '2026-09-26T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin', + utcStart: '2026-09-26T08:00:00Z', utcEnd: '2026-09-26T09:00:00Z', + recurrenceId: '2026-09-26T10:00:00', calendarIds: { cal: true }, + }, + // An all-day occurrence of "c": the server drops showWithoutTime. + maaaaac: { + id: 'maaaaac', baseEventId: 'c', '@type': 'Event', uid: 'c@example.com', title: 'Holiday', + start: '2026-09-27T00:00:00', duration: 'PT24H', timeZone: 'UTC', + utcStart: '2026-09-27T00:00:00Z', utcEnd: '2026-09-28T00:00:00Z', + recurrenceId: '2026-09-27T00:00:00', calendarIds: { cal: true }, + }, + }; + const BASES: Record> = { + b: { id: 'b', recurrenceRule: { frequency: 'weekly' }, recurrenceOverrides: null, showWithoutTime: false, timeZone: 'Europe/Berlin', duration: 'PT1H' }, + c: { id: 'c', recurrenceRule: { frequency: 'yearly' }, recurrenceOverrides: null, showWithoutTime: true, timeZone: null, duration: 'P1D' }, + }; + + function defaultAnswer({ method, args }: Call): [string, Record] { + if (method === 'CalendarEvent/query') return ['CalendarEvent/query', { ids: Object.keys(OCCURRENCES) }]; + if (method === 'Calendar/get') return ['Calendar/get', { list: [{ id: 'cal', name: 'Personal', color: '#336699' }] }]; + 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]; + return Object.fromEntries(Object.entries(source).filter(([key]) => key === 'id' || properties.includes(key))); + }); + return ['CalendarEvent/get', { list }]; + } + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-09-25T08:00:00Z')); + calls = []; + 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 methodResponses = JSON.parse(body).methodCalls.map(([method, args, callId]: [string, Record, string]) => { + const call = { method, args }; + calls.push(call); + return [...(answer(call) ?? defaultAnswer(call)), callId]; + }); + return { ok: true, json: async () => ({ methodResponses }) }; + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + const gets = () => calls.filter((c) => c.method === 'CalendarEvent/get'); + + it(`lets the server expand series and hydrates the occurrences on Stalwart ${version}`, async () => { + const res = await POST(makeReq()); + expect(res.status).toBe(200); + const { events } = await res.json() as { events: Array> }; + + const query = calls.find((c) => c.method === 'CalendarEvent/query')!; + expect(query.args.expandRecurrences).toBe(true); + expect(gets().filter((g) => asksForOverridesWithUtc(g.args))).toEqual([]); + const [instanceGet, baseGet] = gets(); + expect(instanceGet.args.properties).not.toContain('recurrenceOverrides'); + expect(instanceGet.args.properties).not.toContain('recurrenceRule'); + expect(baseGet.args.ids).toEqual(['b', 'c']); + expect(baseGet.args.properties).not.toContain('utcStart'); + + expect(events.map((e) => [e.id, e.title, e.allDay, e.color])).toEqual([ + ['maaaaab', 'Standup', false, '#336699'], + ['maaaaac', 'Holiday', true, '#336699'], + ]); + expect(events[0]).toMatchObject({ start: '2026-09-26T08:00:00.000Z', end: '2026-09-26T09:00:00.000Z' }); + }); + + it(`answers 502 when a get fails on Stalwart ${version}`, async () => { + answer = ({ method }) => method === 'CalendarEvent/get' ? ['error', { type: 'serverFail', description: 'Store unavailable' }] : undefined; + + const res = await POST(makeReq()); + + expect(res.status).toBe(502); + expect(await res.json()).toEqual({ error: 'Store unavailable' }); + }); + + it(`looks less far ahead when the range expands to too many occurrences on Stalwart ${version}`, async () => { + const tooMany = version === '1.0' + ? { type: 'cannotCalculateOccurrences', description: 'The number of expanded recurrences exceeds the server limit' } + : { type: 'invalidArguments', description: 'The number of expanded recurrences exceeds the server limit' }; + let refusals = 2; + answer = ({ method }) => method === 'CalendarEvent/query' && refusals-- > 0 ? ['error', tooMany] : 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([80, 40, 20]); + expect(((await res.json()) as { events: unknown[] }).events).toHaveLength(2); + }); +}); diff --git a/lib/__tests__/helpers/stalwart-calendar.ts b/lib/__tests__/helpers/stalwart-calendar.ts new file mode 100644 index 000000000..a8f865175 --- /dev/null +++ b/lib/__tests__/helpers/stalwart-calendar.ts @@ -0,0 +1,216 @@ +// Mock Stalwart calendar servers for JMAPClient tests. What differs between +// the versions, as far as these tests go: +// - 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; +// - 0.16 answers any property list. +// Both advertise `maxExpandedQueryDuration` in the account's calendars +// capability (0.16 hardcodes 365 days and never enforces it). +import { vi } from 'vitest'; +import type { JMAPClient } from '@/lib/jmap/client'; +import { SYNTHETIC_ID_PROBE } from '@/lib/recurrence-instances'; + +export type StalwartVersion = '0.16' | '1.0'; + +/** Stalwart 1.0's answer to a CalendarEvent/get that asks for both. */ +export const OVERRIDES_WITH_UTC_ERROR = { + type: 'invalidArguments', + description: 'recurrenceOverrides cannot be requested together with utcStart or utcEnd', +} as const; + +/** True for a CalendarEvent/get Stalwart 1.0 refuses outright. */ +export function asksForOverridesWithUtc(args: Record): boolean { + const properties = args.properties; + if (!Array.isArray(properties)) return false; + return properties.includes('recurrenceOverrides') + && (properties.includes('utcStart') || properties.includes('utcEnd')); +} + +export interface RecordedCall { + method: string; + args: Record; + callId: string; + /** Index of the HTTP request the call was part of. */ + request: number; +} + +export type MethodAnswer = [string, Record]; + +export interface StalwartCalendarMockOptions { + version: StalwartVersion; + /** Stored events and tasks, by id. */ + objects?: Record>; + /** What CalendarEvent/query?expandRecurrences=true hands out, by synthetic id. */ + occurrences?: Record>; + /** Whether CalendarEvent/set accepts synthetic ids (the probe answers notFound). Default true. */ + syntheticIds?: boolean; + /** Answer a call yourself; return undefined for the default answer. */ + answer?: (call: RecordedCall) => MethodAnswer | undefined; +} + +export interface StalwartCalendarMock { + calls: RecordedCall[]; + /** Every CalendarEvent/get sent so far. */ + gets(): RecordedCall[]; + queries(): RecordedCall[]; + objects: Record>; +} + +/** A session like Stalwart's, with the calendars capability on the account. */ +export function stalwartCalendarSession( + version: StalwartVersion, + options: { maxExpandedQueryDuration?: string | null; accounts?: Record } = {}, +) { + const calendarCapability: Record = { + maxCalendarsPerEvent: version === '1.0' ? 10 : null, + minDateTime: '0001-01-01T00:00:00Z', + maxDateTime: '9999-12-31T23:59:59Z', + maxParticipantsPerEvent: 20, + mayCreateCalendar: true, + }; + const duration = options.maxExpandedQueryDuration === undefined ? 'P365D' : options.maxExpandedQueryDuration; + if (duration !== null) calendarCapability.maxExpandedQueryDuration = duration; + return { + capabilities: { + 'urn:ietf:params:jmap:core': { maxCallsInRequest: 16, maxObjectsInGet: 500, maxObjectsInSet: 500 }, + 'urn:ietf:params:jmap:calendars': {}, + }, + accounts: { + 'acct-1': { + name: 'user@example.com', + isPersonal: true, + accountCapabilities: { 'urn:ietf:params:jmap:calendars': calendarCapability }, + }, + ...options.accounts, + }, + primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acct-1', 'urn:ietf:params:jmap:calendars': 'acct-1' }, + apiUrl: 'https://mail.example.com/jmap/api', + downloadUrl: 'https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}', + uploadUrl: 'https://mail.example.com/jmap/upload/{accountId}/', + eventSourceUrl: '', + }; +} + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }); +} + +function project(object: Record, properties: unknown): Record { + if (!Array.isArray(properties)) return { ...object }; + const out: Record = { id: object.id }; + for (const key of properties as string[]) { + if (key in object) out[key] = object[key]; + } + return out; +} + +/** + * Point `fetchSpy` (a spy on globalThis.fetch) at a mock Stalwart calendar + * server. Every method call is recorded, including the request it came in. + */ +export function installStalwartCalendarMock( + fetchSpy: ReturnType, + options: StalwartCalendarMockOptions, +): StalwartCalendarMock { + const calls: RecordedCall[] = []; + const objects = { ...options.objects }; + const occurrences = { ...options.occurrences }; + let requests = 0; + let created = 0; + + const defaultAnswer = (call: RecordedCall): MethodAnswer => { + const { method, args } = call; + if (method === 'Calendar/get') { + return ['Calendar/get', { accountId: args.accountId, list: [{ id: 'cal', name: 'Personal', isDefault: true }], notFound: [] }]; + } + if (method === 'CalendarEvent/query') { + const filter = (args.filter ?? {}) as Record; + let ids = args.expandRecurrences ? Object.keys(occurrences) : Object.keys(objects); + if (typeof filter.uid === 'string') ids = ids.filter((id) => objects[id]?.uid === filter.uid); + const position = (args.position as number | undefined) ?? 0; + const limit = (args.limit as number | undefined) ?? ids.length; + return ['CalendarEvent/query', { accountId: args.accountId, ids: ids.slice(position, position + limit), position }]; + } + if (method === 'CalendarEvent/get') { + if (options.version === '1.0' && asksForOverridesWithUtc(args)) return ['error', { ...OVERRIDES_WITH_UTC_ERROR }]; + const list: Record[] = []; + const notFound: string[] = []; + for (const id of (args.ids as string[] | null) ?? Object.keys(objects)) { + const occurrence = occurrences[id]; + const object = occurrence ?? objects[id]; + if (!object) { + notFound.push(id); + continue; + } + const projected = project(object, args.properties); + // 1.0 answers recurrenceOverrides: null for a synthetic id. + if (occurrence && Array.isArray(args.properties) && (args.properties as string[]).includes('recurrenceOverrides')) { + projected.recurrenceOverrides = null; + } + list.push(projected); + } + return ['CalendarEvent/get', { accountId: args.accountId, list, notFound }]; + } + if (method === 'CalendarEvent/set') { + const update = args.update as Record> | undefined; + if (update && SYNTHETIC_ID_PROBE in update) { + const failure = options.syntheticIds === false + ? { type: 'invalidProperties', description: 'Updating synthetic ids is not yet supported.', properties: ['id'] } + : { type: 'notFound' }; + return ['CalendarEvent/set', { notUpdated: { [SYNTHETIC_ID_PROBE]: failure } }]; + } + const result: Record = {}; + const create = args.create as Record> | undefined; + if (create) { + const createdMap: Record = {}; + for (const [key, value] of Object.entries(create)) { + const id = `created-${++created}`; + objects[id] = { ...value, id }; + createdMap[key] = { id }; + } + result.created = createdMap; + } + if (update) { + for (const [id, patch] of Object.entries(update)) objects[id] = { ...objects[id], ...patch }; + result.updated = Object.fromEntries(Object.keys(update).map((id) => [id, null])); + } + return ['CalendarEvent/set', result]; + } + return ['error', { type: 'unknownMethod' }]; + }; + + fetchSpy.mockImplementation(async (_url: RequestInfo | URL, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? '{}')); + const request = requests++; + const methodResponses: unknown[] = []; + 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); + methodResponses.push([...answer, callId]); + } + return jsonResponse({ methodResponses }); + }); + + return { + calls, + gets: () => calls.filter((c) => c.method === 'CalendarEvent/get'), + queries: () => calls.filter((c) => c.method === 'CalendarEvent/query'), + objects, + }; +} + +/** A JMAPClient connected to `session` through `fetchSpy`. */ +export async function connectCalendarClient( + fetchSpy: ReturnType, + session: ReturnType, +): Promise { + // Loaded here so tests that only need the mock data (the agenda route) + // do not pull in the whole client. + const { JMAPClient } = await import('@/lib/jmap/client'); + fetchSpy.mockResolvedValueOnce(jsonResponse(session)); + const client = new JMAPClient('https://mail.example.com', 'user@example.com', 'pass123'); + await client.connect(); + fetchSpy.mockReset(); + return client; +} diff --git a/lib/__tests__/jmap-calendar-access.test.ts b/lib/__tests__/jmap-calendar-access.test.ts index beb8d6664..cde6d1a51 100644 --- a/lib/__tests__/jmap-calendar-access.test.ts +++ b/lib/__tests__/jmap-calendar-access.test.ts @@ -95,3 +95,64 @@ describe('calendar fan-out to shared accounts without access', () => { expect(callsForAccount('acct-1')).toHaveLength(1); }); }); + +describe('calendar fan-out when an account fails', () => { + let fetchSpy: ReturnType; + let consoleErrorSpy: ReturnType; + let client: JMAPClient; + let failing: Record; + + beforeEach(async () => { + failing = {}; + fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockResolvedValueOnce(jsonResponse(makeSession())); + client = new JMAPClient('https://mail.example.com', 'user@test.com', 'pass123'); + await client.connect(); + fetchSpy.mockReset(); + + fetchSpy.mockImplementation(async (_url: RequestInfo | URL, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? '{}')); + const methodResponses = (body.methodCalls ?? []).map(([method, args, callId]: [string, Record, string]) => { + const failure = failing[args.accountId as string]; + if (failure) return ['error', failure, callId]; + if (method === 'CalendarEvent/query') return ['CalendarEvent/query', { ids: ['e1'] }, callId]; + return ['CalendarEvent/get', { + list: [{ id: 'e1', '@type': 'Event', title: `Event of ${args.accountId}`, start: '2026-09-22T10:00:00', calendarIds: { c: true } }], + }, callId]; + }); + return jsonResponse({ methodResponses }); + }); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + client.disconnect(); + consoleErrorSpy.mockRestore(); + fetchSpy.mockRestore(); + }); + + it('fails the whole query when the user’s own calendars fail on Stalwart 1.0', async () => { + failing['acct-1'] = { type: 'invalidArguments', description: 'recurrenceOverrides cannot be requested together with utcStart or utcEnd' }; + + await expect(client.queryAllCalendarEvents({})).rejects.toThrow(/recurrenceOverrides cannot be requested/); + }); + + it('does not take a refusal of the user’s own account for a share without access on 0.16', async () => { + failing['acct-1'] = { type: 'forbidden', description: 'You do not have access to this account' }; + await expect(client.queryAllCalendarEvents({})).rejects.toThrow(/do not have access/); + + // Not remembered as "no access": the next load asks again. + delete failing['acct-1']; + const events = await client.queryAllCalendarEvents({}); + expect(events.map((e) => e.title)).toEqual(['Event of acct-1', 'Event of ev']); + }); + + it('leaves out a shared account that fails for another reason and keeps the rest on 0.16', async () => { + failing.ev = { type: 'serverFail', description: 'Temporary failure' }; + + const events = await client.queryAllCalendarEvents({}); + + expect(events.map((e) => e.title)).toEqual(['Event of acct-1']); + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/lib/__tests__/jmap-calendar-batch-failures.test.ts b/lib/__tests__/jmap-calendar-batch-failures.test.ts index fd0e22e68..666807af3 100644 --- a/lib/__tests__/jmap-calendar-batch-failures.test.ts +++ b/lib/__tests__/jmap-calendar-batch-failures.test.ts @@ -124,4 +124,47 @@ describe('calendar batch failures surface (#434)', () => { expect(result.failed).toEqual(['new-0']); expect(result.notCreated['new-0']).toMatchObject({ description: 'Missing start' }); }); + + it('batchCreateCalendarEvents reads the events back without their overrides', async () => { + const client = await connectedClient(); + fetchSpy + .mockResolvedValueOnce(jsonResponse({ + methodResponses: [['CalendarEvent/set', { created: { 'new-0': { id: 'ev1' } } }, '0']], + })) + .mockResolvedValueOnce(jsonResponse({ + methodResponses: [['CalendarEvent/get', { list: [{ id: 'ev1', title: 'A', start: '2026-09-22T10:00:00' }] }, '0']], + })); + + const result = await client.batchCreateCalendarEvents([{ title: 'A', start: '2026-09-22T10:00:00' }]); + + expect(result.created).toMatchObject([{ id: 'ev1', title: 'A' }]); + const readBack = JSON.parse(String((fetchSpy.mock.calls[1][1] as RequestInit).body)); + expect(readBack.methodCalls).toHaveLength(1); + expect(readBack.methodCalls[0][1].properties).toContain('utcStart'); + expect(readBack.methodCalls[0][1].properties).not.toContain('recurrenceOverrides'); + }); + + it('batchCreateCalendarEvents still counts the created events when Stalwart 1.0 refuses the read-back', async () => { + const client = await connectedClient(); + fetchSpy + .mockResolvedValueOnce(jsonResponse({ + methodResponses: [['CalendarEvent/set', { + created: { 'new-0': { id: 'ev1' }, 'new-1': { id: 'ev2' } }, + }, '0']], + })) + .mockResolvedValueOnce(jsonResponse({ + methodResponses: [['error', { + type: 'invalidArguments', + description: 'recurrenceOverrides cannot be requested together with utcStart or utcEnd', + }, '0']], + })); + + const result = await client.batchCreateCalendarEvents([ + { title: 'A', start: '2026-09-22T10:00:00' }, + { title: 'B', start: '2026-09-23T10:00:00' }, + ]); + + expect(result.created).toMatchObject([{ id: 'ev1', title: 'A' }, { id: 'ev2', title: 'B' }]); + expect(result.failed).toEqual([]); + }); }); diff --git a/lib/__tests__/jmap-calendar-get-properties.test.ts b/lib/__tests__/jmap-calendar-get-properties.test.ts new file mode 100644 index 000000000..b2abb445b --- /dev/null +++ b/lib/__tests__/jmap-calendar-get-properties.test.ts @@ -0,0 +1,221 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { JMAPClient } from '../jmap/client'; +import { jmapErrorType } from '../jmap/jmap-error'; +import { + OVERRIDES_WITH_UTC_ERROR, + asksForOverridesWithUtc, + connectCalendarClient, + installStalwartCalendarMock, + stalwartCalendarSession, + type StalwartCalendarMock, + type StalwartVersion, +} from './helpers/stalwart-calendar'; + +// Stalwart 1.0 fails a CalendarEvent/get that asks for recurrenceOverrides +// together with utcStart or utcEnd - the whole call, before any id is read. +// Every get the client sends must keep them apart; the reads that need the +// overrides get them from a second call in the same request. 0.16 accepts +// either, so the split must not change what callers get back there. + +const RANGE = { after: '2026-09-01T00:00:00', before: '2026-09-30T23:59:59' }; + +const SERIES = { + id: 'b', '@type': 'Event', uid: 'series@example.com', title: 'Daily standup', baseEventId: 'b', + start: '2026-09-07T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin', + utcStart: '2026-09-07T08:00:00Z', utcEnd: '2026-09-07T09:00:00Z', showWithoutTime: false, + recurrenceRule: { frequency: 'daily', count: 5 }, + recurrenceOverrides: { '2026-09-10T10:00:00': { excluded: true } }, + calendarIds: { cal: true }, +}; +const SINGLE = { + id: 'd', '@type': 'Event', uid: 'single@example.com', title: 'One off', baseEventId: 'd', + start: '2026-09-08T09:00:00', duration: 'PT30M', timeZone: 'Europe/Berlin', + utcStart: '2026-09-08T07:00:00Z', utcEnd: '2026-09-08T07:30:00Z', showWithoutTime: false, + calendarIds: { cal: true }, +}; +const TASK = { + id: 't', '@type': 'Task', uid: 'task@example.com', title: 'Pay rent', due: '2026-09-10T00:00:00', + progress: 'needs-action', calendarIds: { cal: true }, +}; +const OCCURRENCES = { + maaaaab: { + ...SERIES, id: 'maaaaab', baseEventId: 'b', recurrenceRule: null, recurrenceOverrides: null, + start: '2026-09-08T10:00:00', utcStart: '2026-09-08T08:00:00Z', utcEnd: '2026-09-08T09:00:00Z', + recurrenceId: '2026-09-08T10:00:00', recurrenceIdTimeZone: 'Europe/Berlin', + }, + eaaaaad: { ...SINGLE, id: 'eaaaaad', baseEventId: 'd' }, +}; + +describe.each(['0.16', '1.0'])('calendar reads on Stalwart %s', (version) => { + let fetchSpy: ReturnType; + let client: JMAPClient; + let server: StalwartCalendarMock; + + beforeEach(async () => { + fetchSpy = vi.spyOn(globalThis, 'fetch'); + client = await connectCalendarClient(fetchSpy, stalwartCalendarSession(version)); + server = installStalwartCalendarMock(fetchSpy, { + version, + objects: { b: SERIES, d: SINGLE, t: TASK }, + occurrences: OCCURRENCES, + }); + }); + + afterEach(() => { + // The invariant: no get ever asks for both. + expect(server.gets().filter((call) => asksForOverridesWithUtc(call.args))).toEqual([]); + client.disconnect(); + fetchSpy.mockRestore(); + }); + + it(`loads a server-expanded range with the series context of its occurrences on Stalwart ${version}`, async () => { + const events = await client.queryCalendarEvents(RANGE); + + expect(events.map((e) => e.id)).toEqual(['maaaaab', 'eaaaaad']); + const occurrence = events[0]; + expect(occurrence.recurrenceRules).toEqual([{ frequency: 'daily', count: 5 }]); + expect(occurrence.recurrenceOverrides).toEqual({ '2026-09-10T10:00:00': { excluded: true } }); + expect(occurrence.utcStart).toBe('2026-09-08T08:00:00Z'); + // Expanded occurrences never ask for overrides; the hydration get has no utc*. + const [instanceGet, hydrationGet] = server.gets(); + expect(instanceGet.args.properties).toContain('utcStart'); + expect(instanceGet.args.properties).not.toContain('recurrenceOverrides'); + expect(hydrationGet.args.properties).toContain('recurrenceOverrides'); + expect(hydrationGet.args.properties).not.toContain('utcStart'); + }); + + it(`reads a series with its overrides from a second get in the same request on Stalwart ${version}`, async () => { + const [series] = await client.queryCalendarEvents({ uid: 'series@example.com' }); + + expect(series.id).toBe('b'); + expect(series.utcStart).toBe('2026-09-07T08:00:00Z'); + expect(series.recurrenceOverrides).toEqual({ '2026-09-10T10:00:00': { excluded: true } }); + const gets = server.gets(); + expect(gets).toHaveLength(2); + expect(gets[0].request).toBe(gets[1].request); + expect(gets[1].args).toMatchObject({ ids: ['b'], properties: ['id', 'recurrenceOverrides'] }); + }); + + it(`reads one event with its overrides, and null for an unknown id, on Stalwart ${version}`, async () => { + const event = await client.getCalendarEvent('b'); + expect(event?.title).toBe('Daily standup'); + expect(event?.utcEnd).toBe('2026-09-07T09:00:00Z'); + expect(event?.recurrenceOverrides).toEqual({ '2026-09-10T10:00:00': { excluded: true } }); + expect(event?.recurrenceRules).toEqual([{ frequency: 'daily', count: 5 }]); + + await expect(client.getCalendarEvent('gone')).resolves.toBeNull(); + }); + + it(`lists every event by uid and calendar, without tasks, on Stalwart ${version}`, async () => { + const events = await client.getCalendarEvents(); + + expect(events.map((e) => [e.id, e.uid, e.calendarIds])).toEqual([ + ['b', 'series@example.com', { cal: true }], + ['d', 'single@example.com', { cal: true }], + ]); + expect(server.gets()[0].args.properties).toEqual(['id', '@type', 'uid', 'calendarIds', 'due', 'progress', 'percentComplete']); + }); + + it(`loads tasks on Stalwart ${version}`, async () => { + const tasks = await client.getCalendarTasks(); + + expect(tasks.map((t) => [t.id, t.title, t.due])).toEqual([['t', 'Pay rent', '2026-09-10T00:00:00']]); + expect(server.gets()[0].args.properties).not.toContain('utcStart'); + }); + + it(`reads created events and tasks back on Stalwart ${version}`, async () => { + const created = await client.createCalendarEvent({ + title: 'Lunch', start: '2026-09-09T12:00:00', duration: 'PT1H', calendarIds: { cal: true }, + }); + expect(created).toMatchObject({ id: 'created-1', title: 'Lunch' }); + + const batch = await client.batchCreateCalendarEvents([ + { title: 'A', start: '2026-09-11T10:00:00', calendarIds: { cal: true } }, + { title: 'B', start: '2026-09-12T10:00:00', calendarIds: { cal: true } }, + ]); + expect(batch.created.map((e) => e.title)).toEqual(['A', 'B']); + + const task = await client.createCalendarTask({ title: 'File taxes', calendarIds: { cal: true } }); + expect(task).toMatchObject({ id: 'created-4', title: 'File taxes', '@type': 'Task' }); + }); + + it(`scans calendars for tasks-only ones on Stalwart ${version}`, async () => { + const calendars = await client.getAllCalendars(); + expect(calendars.map((c) => [c.id, c.isTasksOnly])).toEqual([['cal', false]]); + }); +}); + +describe('a refused CalendarEvent/get', () => { + let fetchSpy: ReturnType; + let client: JMAPClient; + + // What every get looked like to the client before the split: Stalwart 1.0 + // refusing the whole call. + const refuseGets = { answer: (call: { method: string }) => + call.method === 'CalendarEvent/get' ? ['error', { ...OVERRIDES_WITH_UTC_ERROR }] as [string, Record] : undefined }; + + beforeEach(async () => { + fetchSpy = vi.spyOn(globalThis, 'fetch'); + client = await connectCalendarClient(fetchSpy, stalwartCalendarSession('1.0')); + }); + + afterEach(() => { + client.disconnect(); + fetchSpy.mockRestore(); + }); + + it('fails the read instead of returning an empty list on Stalwart 1.0', async () => { + installStalwartCalendarMock(fetchSpy, { + version: '1.0', objects: { b: SERIES, t: TASK }, occurrences: OCCURRENCES, ...refuseGets, + }); + + for (const read of [ + () => client.queryCalendarEvents(RANGE), + () => client.queryAllCalendarEvents(RANGE), + () => client.getCalendarEvent('b'), + () => client.getCalendarEvents(), + () => client.getCalendarTasks(), + ]) { + const error = await read().then(() => null, (e: unknown) => e); + expect(error).toBeInstanceOf(Error); + expect(jmapErrorType(error)).toBe('invalidArguments'); + expect((error as Error).message).toContain('recurrenceOverrides cannot be requested together with utcStart or utcEnd'); + } + }); + + it('still returns what was created when the read-back fails on Stalwart 1.0', async () => { + const server = installStalwartCalendarMock(fetchSpy, { version: '1.0', ...refuseGets }); + + const event = await client.createCalendarEvent({ + title: 'Lunch', start: '2026-09-09T12:00:00', duration: 'PT1H', calendarIds: { cal: true }, + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' }] as never, + }, true); + expect(event).toMatchObject({ id: 'created-1', title: 'Lunch', start: '2026-09-09T12:00:00' }); + expect(event.recurrenceRules).toEqual([{ '@type': 'RecurrenceRule', frequency: 'weekly' }]); + + const task = await client.createCalendarTask({ title: 'File taxes', calendarIds: { cal: true } }); + expect(task).toMatchObject({ id: 'created-2', title: 'File taxes', '@type': 'Task' }); + + const batch = await client.batchCreateCalendarEvents([{ title: 'A', start: '2026-09-11T10:00:00', calendarIds: { cal: true } }]); + expect(batch.created).toMatchObject([{ id: 'created-3', title: 'A' }]); + + // Each create went out once; nothing was retried or queried afterwards. + expect(server.calls.filter((c) => c.method === 'CalendarEvent/set')).toHaveLength(3); + expect(server.queries()).toHaveLength(0); + }); + + it('still returns what was created when the read-back fails in transit on Stalwart 0.16', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const server = installStalwartCalendarMock(fetchSpy, { version: '0.16' }); + const answering = fetchSpy.getMockImplementation()!; + fetchSpy.mockImplementation(async (url: RequestInfo | URL, init?: RequestInit) => { + if (String(init?.body).includes('"CalendarEvent/get"')) return new Response('Service Unavailable', { status: 503 }); + return answering(url, init); + }); + + const event = await client.createCalendarEvent({ title: 'Lunch', start: '2026-09-09T12:00:00', calendarIds: { cal: true } }); + expect(event).toMatchObject({ id: 'created-1', title: 'Lunch' }); + expect(server.calls.filter((c) => c.method === 'CalendarEvent/set')).toHaveLength(1); + consoleError.mockRestore(); + }); +}); diff --git a/lib/__tests__/jmap-calendar-query-range.test.ts b/lib/__tests__/jmap-calendar-query-range.test.ts index c7d38606f..5c11c7c0c 100644 --- a/lib/__tests__/jmap-calendar-query-range.test.ts +++ b/lib/__tests__/jmap-calendar-query-range.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import { JMAPClient } from '../jmap/client'; +import { jmapErrorType } from '../jmap/jmap-error'; import { toLocalDateTime } from '../timezone'; type Call = [string, Record, string]; @@ -11,19 +12,18 @@ function clientAnswering(answer: (args: Record) => [string, Rec vi.spyOn(client as unknown as { supportsSyntheticCalendarIds: () => Promise }, 'supportsSyntheticCalendarIds') .mockResolvedValue(true); vi.spyOn(client as unknown as { request: (c: Call[]) => Promise }, 'request') - .mockImplementation(async (calls: Call[]) => { - const [method, args, id] = calls[0]; - if (method === 'CalendarEvent/query') { - queries.push(args); - return { methodResponses: [[...answer(args), id]] }; - } - const ids = (args.ids as string[]) ?? []; - return { - methodResponses: [['CalendarEvent/get', { + .mockImplementation(async (calls: Call[]) => ({ + methodResponses: calls.map(([method, args, id]) => { + if (method === 'CalendarEvent/query') { + queries.push(args); + return [...answer(args), id]; + } + const ids = (args.ids as string[]) ?? []; + return ['CalendarEvent/get', { list: ids.map((eid) => ({ id: eid, '@type': 'Event', start: '2026-09-22T10:00:00', duration: 'PT1H', calendarIds: { c: true } })), - }, id]], - }; - }); + }, id]; + }), + })); return { client, queries }; } @@ -49,7 +49,7 @@ describe('queryCalendarEvents range handling', () => { expect(filter.after.endsWith('Z')).toBe(false); }); - it('falls back to series when the range expands past the server limit', async () => { + 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'] }]); @@ -58,6 +58,23 @@ describe('queryCalendarEvents range handling', () => { expect(events.map((e) => e.id)).toEqual(['s1']); }); + 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']); + }); + + 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 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'); + }); + it('pages through more than one page of ids', async () => { const all = Array.from({ length: 1500 }, (_, i) => `e${i}`); const { client, queries } = clientAnswering((args) => { diff --git a/lib/__tests__/jmap-calendar-synthetic-ids.test.ts b/lib/__tests__/jmap-calendar-synthetic-ids.test.ts index a6d8a619f..1db840e92 100644 --- a/lib/__tests__/jmap-calendar-synthetic-ids.test.ts +++ b/lib/__tests__/jmap-calendar-synthetic-ids.test.ts @@ -171,7 +171,7 @@ describe('server-side recurrence expansion with synthetic ids', () => { expect(single.recurrenceRules ?? null).toBeNull(); }); - it('keeps client-side expansion (no expandRecurrences) when the server rejects synthetic ids', async () => { + it('keeps client-side expansion (no expandRecurrences) when the server rejects synthetic ids on 0.16.19', async () => { serverSupportsSyntheticIds = false; const events = await client.queryCalendarEvents(RANGE); await client.queryCalendarEvents(RANGE); @@ -181,13 +181,16 @@ describe('server-side recurrence expansion with synthetic ids', () => { expect(query.args.expandRecurrences).toBeUndefined(); } // Raw base events with real ids come back for the browser to expand - - // one full get per query, no base-event hydration get. + // per query one full get plus a get of the overrides in the same + // request (Stalwart 1.0 refuses both in one get), no hydration get. expect(events.map((e) => e.id)).toEqual(['b', 'd']); expect(events[0].recurrenceRules).toEqual([{ frequency: 'daily', count: 5 }]); - expect(gets()).toHaveLength(2); - for (const get of gets()) { - expect(get.args.properties).toContain('title'); - } + expect(events[0].recurrenceOverrides).toEqual({ '2026-09-10T10:00:00': { excluded: true } }); + expect(gets().map((g) => g.args.properties)).toEqual([ + expect.arrayContaining(['title', 'utcStart']), ['id', 'recurrenceOverrides'], + expect.arrayContaining(['title', 'utcStart']), ['id', 'recurrenceOverrides'], + ]); + expect(gets()[0].args.properties).not.toContain('recurrenceOverrides'); }); it('treats a failed probe as unsupported', async () => { diff --git a/lib/__tests__/jmap-calendar-tasks.test.ts b/lib/__tests__/jmap-calendar-tasks.test.ts index 9657aef0d..9ac3a2425 100644 --- a/lib/__tests__/jmap-calendar-tasks.test.ts +++ b/lib/__tests__/jmap-calendar-tasks.test.ts @@ -121,4 +121,41 @@ describe('JMAPClient task operations (#958)', () => { expect(createPayload.title).toBe('New Task'); expect(createPayload).not.toHaveProperty('progressUpdated'); }); + + it('createCalendarTask reads the task back without utcStart/utcEnd', async () => { + await client.createCalendarTask({ title: 'New Task' }); + + const getCall = sentCalls.find((c) => c.method === 'CalendarEvent/get'); + expect(getCall?.args.properties).toEqual(expect.arrayContaining(['due', 'progress', 'recurrenceOverrides'])); + expect(getCall?.args.properties).not.toContain('utcStart'); + expect(getCall?.args.properties).not.toContain('utcEnd'); + }); + + it('createCalendarTask returns the created task when Stalwart 1.0 refuses the read-back, without creating it twice', async () => { + const answer = vi.mocked(globalThis.fetch).getMockImplementation()!; + vi.mocked(globalThis.fetch).mockImplementation(async (url, init) => { + const body = JSON.parse((init?.body as string) || '{}'); + if (body.methodCalls?.[0]?.[0] === 'CalendarEvent/get') { + sentCalls.push({ method: 'CalendarEvent/get', args: body.methodCalls[0][1] }); + return jsonResponse({ methodResponses: [['error', { + type: 'invalidArguments', + description: 'recurrenceOverrides cannot be requested together with utcStart or utcEnd', + }, '0']] }); + } + return answer(url, init); + }); + + const created = await client.createCalendarTask({ title: 'New Task', due: '2026-09-30T00:00:00', calendarIds: { 'cal-1': true } }); + + expect(created).toMatchObject({ id: 'created-id-123', '@type': 'Task', title: 'New Task', due: '2026-09-30T00:00:00' }); + 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 () => { + vi.mocked(globalThis.fetch).mockImplementation(async () => jsonResponse({ + methodResponses: [['error', { type: 'invalidArguments', description: 'Invalid timeZone "Mars/Olympus"' }, '0']], + })); + + await expect(client.getCalendarTasks()).rejects.toThrow(/Invalid timeZone/); + }); }); diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 23788efc2..de9aa7b60 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -15,7 +15,7 @@ import { sanitizeDisplayName, splitMailbox } from "@/lib/rfc5322-mailbox"; import { decodeFileNodeName } from "./filenode-name"; import { contactFromWire, contactToWire } from "./contact-wire"; import { SchedulingDeniedError } from "./scheduling-error"; -import { jmapError } from "./jmap-error"; +import { jmapError, jmapErrorType } from "./jmap-error"; export { SchedulingDeniedError }; import { acceptedFileName, fileNameRulesFrom, type FileNameRules } from "@/lib/file-name-rules"; @@ -381,6 +381,10 @@ function isTaskObject(obj: { '@type'?: string; progress?: unknown; due?: unknown return false; } +// Stalwart 1.0 fails a CalendarEvent/get that asks for `recurrenceOverrides` +// together with `utcStart` or `utcEnd` (invalidArguments, before any id is +// read), so no property list may hold both. Where a caller needs the +// overrides, fetchCalendarEventsById reads them with a get of their own. const CALENDAR_EVENT_PROPERTIES = [ 'id', '@type', @@ -414,7 +418,6 @@ const CALENDAR_EVENT_PROPERTIES = [ 'recurrenceId', 'recurrenceIdTimeZone', 'recurrenceRule', - 'recurrenceOverrides', 'excludedRecurrenceRule', 'useDefaultAlerts', 'alerts', @@ -427,7 +430,25 @@ const CALENDAR_EVENT_PROPERTIES = [ 'baseEventId', ] as const; -// Task-specific properties for CalendarEvent/get when fetching Task objects +/** The second get of fetchCalendarEventsById: the overrides, by id. */ +const OVERRIDE_PROPERTIES = ['id', 'recurrenceOverrides'] as const; + +// getCalendarEvents' callers (import de-duplication, clearing a calendar, +// iCal subscription refresh) match events by uid and calendar only; the task +// keys let isTaskObject keep tasks out. +const CALENDAR_EVENT_IDENTITY_PROPERTIES = [ + 'id', + '@type', + 'uid', + 'calendarIds', + 'due', + 'progress', + 'percentComplete', +] as const; + +// Task-specific properties for CalendarEvent/get when fetching Task objects. +// No utcStart/utcEnd: the task UI works from `due`, and asking for them next +// to recurrenceOverrides fails the get on Stalwart 1.0. const CALENDAR_TASK_PROPERTIES = [ 'id', '@type', @@ -443,8 +464,6 @@ const CALENDAR_TASK_PROPERTIES = [ 'duration', 'timeZone', 'showWithoutTime', - 'utcStart', - 'utcEnd', 'progress', 'priority', 'privacy', @@ -647,6 +666,33 @@ function assertQuerySucceeded(response: JMAPResponse, what: string): void { } } +/** + * The answer to call `callId` of a request. A method error throws with its + * JMAP type: read as an empty list, a refused calendar get or query used to + * show an empty calendar instead of a failure. + */ +function methodResult(response: JMAPResponse, callId: string, what: string): JMAPResponseResult { + const entry = response.methodResponses?.find((r) => r[2] === callId); + if (!entry) throw new Error(`${what} failed: no response`); + const [name, result] = entry; + if (name === 'error') { + throw jmapError(`${what} failed: ${result?.description || result?.type || 'unknown error'}`, result); + } + return result; +} + +/** + * Whether an expanded CalendarEvent/query failed on the server's cap on + * occurrences per query: `cannotCalculateOccurrences` on Stalwart 1.0, + * `invalidArguments` ("The number of expanded recurrences exceeds the server + * limit") on 0.16. + */ +function isOccurrenceLimitError(error: unknown): boolean { + const type = jmapErrorType(error); + if (type === 'cannotCalculateOccurrences') return true; + return type === 'invalidArguments' && /exceeds/i.test(error instanceof Error ? error.message : ''); +} + function hasSubmissionMethod(methodCalls: JMAPMethodCall[]): boolean { return methodCalls.some(([method]) => method.startsWith('Identity/') || method.startsWith('EmailSubmission/')); } @@ -6409,9 +6455,12 @@ export class JMAPClient implements IJMAPClient { throw new Error("Failed to delete calendar"); } + /** + * Every event of the account (or of `calendarIds`), with only the + * identity properties (CALENDAR_EVENT_IDENTITY_PROPERTIES) filled in. + */ async getCalendarEvents(calendarIds?: string[], targetAccountId?: string): Promise { const accountId = targetAccountId || this.getCalendarsAccountId(); - const GET_BATCH_SIZE = this.getMaxObjectsInGet(); const timeZone = getUserTimeZone(); // Page through the query to collect ALL ids. Callers (import UID @@ -6434,14 +6483,7 @@ export class JMAPClient implements IJMAPClient { const queryResponse = await this.request([ ["CalendarEvent/query", queryArgs, "0"], ], this.calendarUsing()); - - // Check for JMAP method-level errors - if (queryResponse.methodResponses?.[0]?.[0] === "error") { - const error = queryResponse.methodResponses[0][1]; - throw new Error(error?.description || error?.type || "CalendarEvent/query failed"); - } - - const pageIds: string[] = queryResponse.methodResponses?.[0]?.[1]?.ids || []; + const pageIds: string[] = methodResult(queryResponse, "0", "CalendarEvent/query").ids || []; ids.push(...pageIds); if (pageIds.length < QUERY_PAGE) break; position += pageIds.length; @@ -6449,30 +6491,49 @@ export class JMAPClient implements IJMAPClient { if (ids.length === 0) return []; - // Batch the /get calls to stay within server max-objects limit - const allEvents: CalendarEvent[] = []; - for (let i = 0; i < ids.length; i += GET_BATCH_SIZE) { - const batchIds = ids.slice(i, i + GET_BATCH_SIZE); - const getResponse = await this.request([ - ["CalendarEvent/get", { - accountId, - properties: [...CALENDAR_EVENT_PROPERTIES], - ids: batchIds, - ...(timeZone ? { timeZone } : {}), - }, "0"] - ], this.calendarUsing()); - - if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") { - const events = (getResponse.methodResponses[0][1].list || []) as CalendarEvent[]; - allEvents.push(...events); - } - } - - return allEvents + const allEvents = await this.fetchCalendarEventsById(accountId, ids, CALENDAR_EVENT_IDENTITY_PROPERTIES, { timeZone }); + return (allEvents as unknown as CalendarEvent[]) .filter((event) => !isTaskObject(event)) .map((event) => normalizeCalendarEventLike(event)); } + /** + * CalendarEvent/get of `ids` in batches of maxObjectsInGet. With + * `withOverrides`, each batch also asks for OVERRIDE_PROPERTIES in a second + * call of the same request, and the overrides are merged in by id: Stalwart + * 1.0 refuses `recurrenceOverrides` in the same get as `utcStart`/`utcEnd`. + * A method error on either call fails the whole fetch. + */ + private async fetchCalendarEventsById( + accountId: string, + ids: string[], + properties: readonly string[], + options: { withOverrides?: boolean; timeZone?: string } = {}, + ): Promise[]> { + const { withOverrides = false, timeZone } = options; + const objects: Record[] = []; + for (const batch of batched(ids, this.getMaxObjectsInGet())) { + const args = { accountId, ids: batch, ...(timeZone ? { timeZone } : {}) }; + const calls: JMAPMethodCall[] = [["CalendarEvent/get", { ...args, properties: [...properties] }, "0"]]; + if (withOverrides) { + calls.push(["CalendarEvent/get", { ...args, properties: [...OVERRIDE_PROPERTIES] }, "1"]); + } + const response = await this.request(calls, this.calendarUsing()); + const list = (methodResult(response, "0", "CalendarEvent/get").list || []) as Record[]; + if (withOverrides) { + const overrides = new Map(); + for (const entry of (methodResult(response, "1", "CalendarEvent/get").list || []) as Record[]) { + if ('recurrenceOverrides' in entry) overrides.set(entry.id, entry.recurrenceOverrides); + } + for (const object of list) { + if (overrides.has(object.id)) object.recurrenceOverrides = overrides.get(object.id); + } + } + objects.push(...list); + } + return objects; + } + // Shared accounts the server rejected calendar access for - probed once, // then skipped for the rest of the session (see getCalendarCapableAccountIds // for why the fan-out has to probe on suspicion). @@ -6534,41 +6595,41 @@ export class JMAPClient implements IJMAPClient { sort?: Array<{ property: string; isAscending: boolean }>, limit?: number ): Promise { - try { - const allEvents: CalendarEvent[] = []; - const primaryId = this.getCalendarsAccountId(); - const accountIds = this.getCalendarCapableAccountIds(); + const allEvents: CalendarEvent[] = []; + const primaryId = this.getCalendarsAccountId(); + const accountIds = this.getCalendarCapableAccountIds(); - for (const accountId of accountIds) { - const isPrimary = accountId === primaryId; - if (!isPrimary && this.calendarAccessDenied.has(accountId)) continue; - const account = this.accounts[accountId]; + for (const accountId of accountIds) { + const isPrimary = accountId === primaryId; + if (!isPrimary && this.calendarAccessDenied.has(accountId)) continue; + const account = this.accounts[accountId]; - try { - const events = await this.queryCalendarEvents(filter, sort, limit, accountId); - const mapped = events.map((event) => ({ - ...event, - id: isPrimary ? event.id : `${accountId}:${event.id}`, - originalId: event.id, - originalCalendarIds: event.calendarIds, - calendarIds: isPrimary ? (event.calendarIds || {}) : Object.fromEntries( - Object.entries(event.calendarIds || {}).map(([calId, v]) => [`${accountId}:${calId}`, v]) - ), - accountId, - accountName: account?.name || (isPrimary ? this.username : accountId), - isShared: !isPrimary, - })); - allEvents.push(...mapped); - } catch (error) { - console.error(`Failed to query calendar events for account ${accountId}:`, error); - } + let events: CalendarEvent[]; + try { + events = await this.queryCalendarEvents(filter, sort, limit, accountId); + } 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; + console.error(`Failed to query calendar events for account ${accountId}:`, error); + continue; } - - return allEvents; - } catch (error) { - console.error('Failed to query all calendar events:', error); - return this.queryCalendarEvents(filter, sort, limit); + allEvents.push(...events.map((event) => ({ + ...event, + id: isPrimary ? event.id : `${accountId}:${event.id}`, + originalId: event.id, + originalCalendarIds: event.calendarIds, + calendarIds: isPrimary ? (event.calendarIds || {}) : Object.fromEntries( + Object.entries(event.calendarIds || {}).map(([calId, v]) => [`${accountId}:${calId}`, v]) + ), + accountId, + accountName: account?.name || (isPrimary ? this.username : accountId), + isShared: !isPrimary, + }))); } + + return allEvents; } async queryCalendarEvents( @@ -6577,8 +6638,8 @@ export class JMAPClient implements IJMAPClient { limit?: number, targetAccountId?: string ): Promise { + const accountId = targetAccountId || this.getCalendarsAccountId(); try { - const accountId = targetAccountId || this.getCalendarsAccountId(); const timeZone = getUserTimeZone(); const queryArgs: Record = { @@ -6603,75 +6664,33 @@ export class JMAPClient implements IJMAPClient { // lib/recurrence-expansion.ts, which runs on whatever comes back. let expandRecurrences = Boolean(filter.after && filter.before) && await this.supportsSyntheticCalendarIds(); - if (expandRecurrences) { - queryArgs.expandRecurrences = true; - } if (sort) { queryArgs.sort = sort; } - const GET_BATCH_SIZE = this.getMaxObjectsInGet(); - // Without an explicit limit, page through the whole range: a busy - // calendar (or a long expanded range) holds more than one page. - const pageSize = limit || 1000; - const maxIds = limit || 20000; - - // First, query to get IDs - const ids: string[] = []; - for (let position = 0; ids.length < maxIds;) { - const queryResponse = await this.request([ - ["CalendarEvent/query", { ...queryArgs, limit: pageSize, position }, "0"], - ], this.calendarUsing()); - const [name, result] = queryResponse.methodResponses?.[0] ?? []; - - if (name === "error") { - // Stalwart fails the whole query once a range expands to more than - // maxRecurrenceExpansions occurrences. Ask for the series instead; + 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 (expandRecurrences && result?.type === "invalidArguments" && - /expanded recurrences exceeds/i.test(result?.description ?? "")) { - debug.log('calendar', 'Range expands past the server limit; expanding on the client instead'); - expandRecurrences = false; - delete queryArgs.expandRecurrences; - ids.length = 0; - position = 0; - continue; - } - // Keep the JMAP error type so the catch below can tell an expected - // access rejection apart from a genuine failure. - throw Object.assign( - new Error(result?.description || result?.type || "CalendarEvent/query failed"), - { jmapErrorType: result?.type }, - ); + if (!isOccurrenceLimitError(error)) throw error; + debug.log('calendar', 'Range expands past the server limit; expanding on the client instead'); + expandRecurrences = false; } - - const page: string[] = result?.ids || []; - ids.push(...page); - if (limit || page.length < pageSize) break; - position += page.length; } + ids ??= await this.queryCalendarEventIds(queryArgs, limit); if (ids.length === 0) return []; - // Batch the /get calls to stay within server max-objects limit - const allEvents: CalendarEvent[] = []; - for (let i = 0; i < ids.length; i += GET_BATCH_SIZE) { - const batchIds = ids.slice(i, i + GET_BATCH_SIZE); - const getResponse = await this.request([ - ["CalendarEvent/get", { - accountId, - properties: [...CALENDAR_EVENT_PROPERTIES], - ids: batchIds, - ...(timeZone ? { timeZone } : {}), - }, "0"] - ], this.calendarUsing()); - - if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") { - const events = (getResponse.methodResponses[0][1].list || []) as CalendarEvent[]; - allEvents.push(...events); - } - } - - const normalized = allEvents + // 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 @@ -6696,20 +6715,41 @@ export class JMAPClient implements IJMAPClient { // The fan-out over shared accounts probes on suspicion (see // getCalendarCapableAccountIds) and may hit accounts that grant no // calendar access at all. Remember the rejection and go quiet instead - // of re-probing - and re-logging - on every range change. - const type = (error as { jmapErrorType?: string } | null)?.jmapErrorType; + // of re-probing - and re-logging - on every range change. Anything + // else, and any failure of the user's own calendars, is the caller's. + const type = jmapErrorType(error); const denied = type === 'forbidden' || type === 'accountNotFound' || /not have access/i.test(error instanceof Error ? error.message : ''); - if (targetAccountId && denied) { + if (targetAccountId && accountId !== this.getCalendarsAccountId() && denied) { this.calendarAccessDenied.add(targetAccountId); debug.log('calendar', `No calendar access to account ${targetAccountId} - skipping it from now on`); return []; } - console.error('Failed to query calendar events:', error); - return []; + throw error; } } + /** + * The ids a CalendarEvent/query matches. Without `limit` it pages through + * the whole result: a busy calendar (or a long expanded range) holds more + * than one page. + */ + private async queryCalendarEventIds(queryArgs: Record, limit?: number): Promise { + const pageSize = limit || 1000; + const maxIds = limit || 20000; + const ids: string[] = []; + for (let position = 0; ids.length < maxIds;) { + const response = await this.request([ + ["CalendarEvent/query", { ...queryArgs, limit: pageSize, position }, "0"], + ], this.calendarUsing()); + const page: string[] = methodResult(response, "0", "CalendarEvent/query").ids || []; + ids.push(...page); + if (limit || page.length < pageSize) break; + position += page.length; + } + return ids; + } + /** * Server-expanded occurrences come back without their series' recurrence * rule / overrides (and all-day ones without showWithoutTime). Fetch those @@ -6730,45 +6770,20 @@ export class JMAPClient implements IJMAPClient { if (baseIds.length === 0) return events; const bases = new Map>(); - for (const batch of batched(baseIds, this.getMaxObjectsInGet())) { - const response = await this.request([ - ["CalendarEvent/get", { - accountId, - properties: [...RECURRENCE_BASE_PROPERTIES], - ids: batch, - ...(timeZone ? { timeZone } : {}), - }, "0"], - ], this.calendarUsing()); - if (response.methodResponses?.[0]?.[0] !== "CalendarEvent/get") continue; - for (const base of (response.methodResponses[0][1].list || []) as Partial[]) { - if (base.id) bases.set(base.id, normalizeCalendarEventLike(base)); - } + for (const base of await this.fetchCalendarEventsById(accountId, baseIds, RECURRENCE_BASE_PROPERTIES, { timeZone })) { + if (typeof base.id === 'string') bases.set(base.id, normalizeCalendarEventLike(base as Partial)); } return hydrateRecurrenceInstances(events, bases); } + /** One event with its recurrence overrides, or null when the server has no such id. */ async getCalendarEvent(id: string, targetAccountId?: string): Promise { - try { - const accountId = targetAccountId || this.getCalendarsAccountId(); - const timeZone = getUserTimeZone(); - const response = await this.request([ - ["CalendarEvent/get", { - accountId, - properties: [...CALENDAR_EVENT_PROPERTIES], - ids: [id], - ...(timeZone ? { timeZone } : {}), - }, "0"] - ], this.calendarUsing()); - - if (response.methodResponses?.[0]?.[0] === "CalendarEvent/get") { - const list = response.methodResponses[0][1].list || []; - return list[0] ? normalizeCalendarEventLike(list[0] as CalendarEvent) : null; - } - return null; - } catch (error) { - console.error('Failed to get calendar event:', error); - return null; - } + const accountId = targetAccountId || this.getCalendarsAccountId(); + const [event] = await this.fetchCalendarEventsById(accountId, [id], CALENDAR_EVENT_PROPERTIES, { + withOverrides: true, + timeZone: getUserTimeZone(), + }); + return event ? normalizeCalendarEventLike(event as unknown as CalendarEvent) : null; } async createCalendarEvent(event: Partial, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise { @@ -6827,37 +6842,28 @@ export class JMAPClient implements IJMAPClient { }); if (createdId) { - const created = await this.getCalendarEvent(createdId, targetAccountId); + // The event exists from here on, and its invitations are on their + // way: a failed read-back must not report a failed create, or a + // retry creates the event twice. Fall back to what was sent. + let created: CalendarEvent | null = null; + try { + created = await this.getCalendarEvent(createdId, targetAccountId); + } catch (refetchError) { + debug.warn('calendar', 'CalendarEvent/create could not read the created event back', refetchError); + } + if (!created) { + debug.warn('calendar', 'CalendarEvent/create returns the sent event for the created id', { createdId, targetAccountId }); + created = normalizeCalendarEventLike({ ...cleanEvent, ...result.created["new-event"], id: createdId } as CalendarEvent); + } debug.log('calendar', 'CalendarEvent/create fetched created event', { ...getCalendarEventDebugSnapshot(created), - hasParticipants: !!created?.participants, - participantCount: created?.participants ? Object.keys(created.participants).length : 0, - participants: created?.participants || null, - replyTo: created?.replyTo || null, - }); - - if (created?.uid) { - try { - const verificationMatches = await this.queryCalendarEvents({ uid: created.uid }, undefined, undefined, targetAccountId); - debug.log('calendar', 'CalendarEvent/create verification query by uid', { - uid: created.uid, - matchCount: verificationMatches.length, - matches: verificationMatches.map((match) => getCalendarEventDebugSnapshot(match)), - }); - } catch (verificationError) { - debug.warn('calendar', 'CalendarEvent/create verification query failed', verificationError); - } - } - - if (created) { - debug.groupEnd(); - return created; - } - - debug.warn('calendar', 'CalendarEvent/create server returned created id but CalendarEvent/get returned null', { - createdId, - targetAccountId, + hasParticipants: !!created.participants, + participantCount: created.participants ? Object.keys(created.participants).length : 0, + participants: created.participants || null, + replyTo: created.replyTo || null, }); + debug.groupEnd(); + return created; } } @@ -6881,6 +6887,7 @@ export class JMAPClient implements IJMAPClient { debug.log('calendar', 'CalendarEvent/batchCreate', { count: events.length, accountId }); const createdIds: string[] = []; + const sentById = new Map>(); const failed: string[] = []; const notCreated: Record = {}; const indexed = events.map((event, index) => ({ event, index })); @@ -6914,6 +6921,7 @@ export class JMAPClient implements IJMAPClient { const key = `new-${index}`; if (result.created?.[key]?.id) { createdIds.push(result.created[key].id); + sentById.set(result.created[key].id, { ...createMap[key], ...result.created[key] }); } else if (result.notCreated?.[key]) { debug.warn('calendar', `CalendarEvent/batchCreate failed for ${key}`, result.notCreated[key]); failed.push(key); @@ -6927,25 +6935,18 @@ export class JMAPClient implements IJMAPClient { return { created: [], failed, notCreated }; } - // Fetch the created events back for their server-assigned properties - const refetchTimeZone = getUserTimeZone(); - const createdEvents: CalendarEvent[] = []; - - for (const batchIds of batched(createdIds, this.getMaxObjectsInGet())) { - const getResponse = await this.request([ - ["CalendarEvent/get", { - accountId, - properties: [...CALENDAR_EVENT_PROPERTIES], - ids: batchIds, - ...(refetchTimeZone ? { timeZone: refetchTimeZone } : {}), - }, "0"] - ], this.calendarUsing()); - - if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") { - const list = getResponse.methodResponses[0][1].list || []; - createdEvents.push(...list.map((e: CalendarEvent) => normalizeCalendarEventLike(e))); - } + // Read the created events back for their server-assigned properties. + // They exist whatever the read-back says, so what was sent stands in for + // any event it does not return. + let fetched: Record[] = []; + try { + fetched = await this.fetchCalendarEventsById(accountId, createdIds, CALENDAR_EVENT_PROPERTIES, { timeZone: getUserTimeZone() }); + } catch (error) { + debug.warn('calendar', 'CalendarEvent/batchCreate could not read the created events back', error); } + const fetchedById = new Map(fetched.map((event) => [event.id, event])); + const createdEvents = createdIds.map((id) => + normalizeCalendarEventLike((fetchedById.get(id) ?? sentById.get(id)) as unknown as CalendarEvent)); debug.log('calendar', 'CalendarEvent/batchCreate result', { requested: events.length, @@ -7182,15 +7183,7 @@ export class JMAPClient implements IJMAPClient { const response = await this.request([ ["CalendarEvent/query", queryArgs, "0"], ], this.calendarUsing()); - - if (response.methodResponses?.[0]?.[0] === "error") { - const error = response.methodResponses[0][1]; - debug.warn('tasks', 'CalendarTask/fetch query failed', error); - debug.groupEnd(); - return []; - } - - const pageIds: string[] = response.methodResponses?.[0]?.[1]?.ids || []; + const pageIds: string[] = methodResult(response, "0", "CalendarEvent/query").ids || []; ids.push(...pageIds); if (pageIds.length < QUERY_PAGE) break; position += pageIds.length; @@ -7203,23 +7196,7 @@ export class JMAPClient implements IJMAPClient { } // Fetch the objects in batches that respect the server's /get limit. - const GET_BATCH_SIZE = this.getMaxObjectsInGet(); - const allObjects: Record[] = []; - for (let i = 0; i < ids.length; i += GET_BATCH_SIZE) { - const batchIds = ids.slice(i, i + GET_BATCH_SIZE); - const getResponse = await this.request([ - ["CalendarEvent/get", { - accountId, - properties: [...CALENDAR_TASK_PROPERTIES], - ids: batchIds, - ...(timeZone ? { timeZone } : {}), - }, "0"] - ], this.calendarUsing()); - - if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") { - allObjects.push(...(getResponse.methodResponses[0][1].list || [])); - } - } + const allObjects = await this.fetchCalendarEventsById(accountId, ids, CALENDAR_TASK_PROPERTIES, { timeZone }); const tasks: CalendarTask[] = []; for (const obj of allObjects) { @@ -7246,9 +7223,11 @@ export class JMAPClient implements IJMAPClient { debug.groupEnd(); return tasks; } catch (error) { + // A refused query or get is a failure, not an empty task list: the + // task store keeps showing what it has. debug.error('CalendarTask/fetch failed', error); debug.groupEnd(); - return []; + throw error; } } @@ -7289,43 +7268,34 @@ export class JMAPClient implements IJMAPClient { throw new Error("Failed to create task - no id returned"); } - // Fetch back with task-specific properties + // Read the task back with the task properties. It exists whatever the + // read-back says: fall back to what was sent rather than report a failed + // create, which a retry would duplicate. debug.log('calendar', 'CalendarTask/create re-fetching with task properties', { createdId, properties: [...CALENDAR_TASK_PROPERTIES] }); - const refetchTimeZone = getUserTimeZone(); - const getResponse = await this.request([ - ["CalendarEvent/get", { - accountId, - properties: [...CALENDAR_TASK_PROPERTIES], - ids: [createdId], - ...(refetchTimeZone ? { timeZone: refetchTimeZone } : {}), - }, "0"] - ], this.calendarUsing()); - - if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") { - const list = getResponse.methodResponses[0][1].list || []; - const notFound = getResponse.methodResponses[0][1].notFound || []; - debug.log('calendar', 'CalendarTask/create get response', { found: list.length, notFound }); - if (list[0]) { - const created = normalizeCalendarTask({ ...list[0], '@type': 'Task' as const } as CalendarTask); - debug.log('tasks', 'CalendarTask/create final task object', { - id: created.id, - uid: created.uid, - '@type': created['@type'], - title: created.title, - due: created.due, - start: created.start, - progress: created.progress, - showWithoutTime: created.showWithoutTime, - calendarIds: created.calendarIds, - }); - debug.groupEnd(); - return created; - } + let fetched: Record | undefined; + try { + [fetched] = await this.fetchCalendarEventsById(accountId, [createdId], CALENDAR_TASK_PROPERTIES, { timeZone: getUserTimeZone() }); + } catch (error) { + debug.warn('tasks', 'CalendarTask/create could not read the created task back', error); } - - debug.warn('tasks', 'CalendarTask/create re-fetch returned nothing for id', createdId); + if (!fetched) debug.warn('tasks', 'CalendarTask/create returns the sent task for id', createdId); + const created = normalizeCalendarTask({ + ...(fetched ?? { ...cleanTask, ...serverCreated, id: createdId }), + '@type': 'Task' as const, + } as CalendarTask); + debug.log('tasks', 'CalendarTask/create final task object', { + id: created.id, + uid: created.uid, + '@type': created['@type'], + title: created.title, + due: created.due, + start: created.start, + progress: created.progress, + showWithoutTime: created.showWithoutTime, + calendarIds: created.calendarIds, + }); debug.groupEnd(); - throw new Error("Failed to fetch created task"); + return created; } async updateCalendarTask(taskId: string, updates: Partial, targetAccountId?: string): Promise { diff --git a/locales/ar/common.json b/locales/ar/common.json index 1b9c2d4e2..279dc1e1c 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -3059,7 +3059,9 @@ }, "status": { "loading_calendars": "جارٍ تحميل التقويمات...", - "loading_events": "جارٍ تحميل الأحداث..." + "loading_events": "جارٍ تحميل الأحداث...", + "events_load_failed": "تعذّر تحميل الأحداث. قد لا يكون التقويم محدَّثًا.", + "retry": "حاول مرة أخرى" }, "quick_create": { "placeholder": "عنوان الحدث الجديد", diff --git a/locales/ca/common.json b/locales/ca/common.json index a98dd9045..ea7dca069 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -3059,7 +3059,9 @@ }, "status": { "loading_calendars": "Carregant els calendaris...", - "loading_events": "Carregant els esdeveniments..." + "loading_events": "Carregant els esdeveniments...", + "events_load_failed": "No s'han pogut carregar els esdeveniments. És possible que el calendari no estigui actualitzat.", + "retry": "Torna-ho a provar" }, "quick_create": { "placeholder": "Títol del nou esdeveniment", diff --git a/locales/cs/common.json b/locales/cs/common.json index bc6dce733..6a19611ff 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -3033,7 +3033,9 @@ }, "status": { "loading_calendars": "Načítání kalendářů...", - "loading_events": "Načítání událostí..." + "loading_events": "Načítání událostí...", + "events_load_failed": "Události se nepodařilo načíst. Kalendář nemusí být aktuální.", + "retry": "Zkusit znovu" }, "quick_create": { "placeholder": "Název nové události", diff --git a/locales/da/common.json b/locales/da/common.json index 9172193db..8229a9f76 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -3059,7 +3059,9 @@ }, "status": { "loading_calendars": "Indlæser kalendere...", - "loading_events": "Indlæser begivenheder..." + "loading_events": "Indlæser begivenheder...", + "events_load_failed": "Begivenhederne kunne ikke indlæses. Kalenderen er muligvis ikke opdateret.", + "retry": "Prøv igen" }, "quick_create": { "placeholder": "Ny begivenhedstitel", diff --git a/locales/de/common.json b/locales/de/common.json index 8f5b90468..43643001a 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -3033,7 +3033,9 @@ }, "status": { "loading_calendars": "Kalender werden geladen...", - "loading_events": "Termine werden geladen..." + "loading_events": "Termine werden geladen...", + "events_load_failed": "Termine konnten nicht geladen werden. Der Kalender ist möglicherweise nicht aktuell.", + "retry": "Erneut versuchen" }, "quick_create": { "placeholder": "Titel des neuen Termins", diff --git a/locales/en/common.json b/locales/en/common.json index b90d24d0d..7c1b80841 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -3059,7 +3059,9 @@ }, "status": { "loading_calendars": "Loading calendars...", - "loading_events": "Loading events..." + "loading_events": "Loading events...", + "events_load_failed": "Couldn't load events. The calendar may be out of date.", + "retry": "Try again" }, "quick_create": { "placeholder": "New event title", diff --git a/locales/es/common.json b/locales/es/common.json index dcb460f37..b40dd643a 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -3033,7 +3033,9 @@ }, "status": { "loading_calendars": "Cargando calendarios...", - "loading_events": "Cargando eventos..." + "loading_events": "Cargando eventos...", + "events_load_failed": "No se pudieron cargar los eventos. Es posible que el calendario no esté actualizado.", + "retry": "Intentar de nuevo" }, "quick_create": { "placeholder": "Título del nuevo evento", diff --git a/locales/fa/common.json b/locales/fa/common.json index 4693c60b9..39fea6b54 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -3059,7 +3059,9 @@ }, "status": { "loading_calendars": "در حال بارگذاری تقویم‌ها...", - "loading_events": "در حال بارگذاری رویدادها..." + "loading_events": "در حال بارگذاری رویدادها...", + "events_load_failed": "بارگذاری رویدادها ممکن نشد. ممکن است تقویم به‌روز نباشد.", + "retry": "دوباره تلاش کنید" }, "quick_create": { "placeholder": "عنوان رویداد جدید", diff --git a/locales/fr/common.json b/locales/fr/common.json index 9d5c85d2f..a46d5f864 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -3059,7 +3059,9 @@ }, "status": { "loading_calendars": "Chargement des calendriers...", - "loading_events": "Chargement des événements..." + "loading_events": "Chargement des événements...", + "events_load_failed": "Impossible de charger les événements. Le calendrier n'est peut-être pas à jour.", + "retry": "Réessayer" }, "quick_create": { "placeholder": "Titre du nouvel événement", diff --git a/locales/he/common.json b/locales/he/common.json index 6d6073bc2..dbda4df1b 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -2957,7 +2957,9 @@ }, "status": { "loading_calendars": "טוען יומנים...", - "loading_events": "טוען אירועים..." + "loading_events": "טוען אירועים...", + "events_load_failed": "לא ניתן לטעון את האירועים. ייתכן שהיומן אינו מעודכן.", + "retry": "נסה שוב" }, "quick_create": { "placeholder": "כותרת אירוע חדשה", diff --git a/locales/hu/common.json b/locales/hu/common.json index c50a4b9d4..6c656c400 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -3059,7 +3059,9 @@ }, "status": { "loading_calendars": "Naptárak betöltése...", - "loading_events": "Események betöltése..." + "loading_events": "Események betöltése...", + "events_load_failed": "Nem sikerült betölteni az eseményeket. Előfordulhat, hogy a naptár nem naprakész.", + "retry": "Próbáld újra" }, "quick_create": { "placeholder": "Új esemény címe", diff --git a/locales/it/common.json b/locales/it/common.json index 099959e29..939952e33 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -3033,7 +3033,9 @@ }, "status": { "loading_calendars": "Caricamento calendari...", - "loading_events": "Caricamento eventi..." + "loading_events": "Caricamento eventi...", + "events_load_failed": "Impossibile caricare gli eventi. Il calendario potrebbe non essere aggiornato.", + "retry": "Riprova" }, "quick_create": { "placeholder": "Titolo del nuovo evento", diff --git a/locales/ja/common.json b/locales/ja/common.json index c2b555b11..7759184ca 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -3033,7 +3033,9 @@ }, "status": { "loading_calendars": "カレンダーを読み込み中...", - "loading_events": "予定を読み込み中..." + "loading_events": "予定を読み込み中...", + "events_load_failed": "予定を読み込めませんでした。カレンダーが最新でない可能性があります。", + "retry": "再試行" }, "quick_create": { "placeholder": "新しい予定のタイトル", diff --git a/locales/ko/common.json b/locales/ko/common.json index b1c0817d4..d8645fdad 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -3033,7 +3033,9 @@ }, "status": { "loading_calendars": "캘린더를 불러오는 중...", - "loading_events": "일정을 불러오는 중..." + "loading_events": "일정을 불러오는 중...", + "events_load_failed": "일정을 불러오지 못했어요. 캘린더가 최신 상태가 아닐 수 있어요.", + "retry": "다시 시도" }, "quick_create": { "placeholder": "새 일정 제목", diff --git a/locales/lv/common.json b/locales/lv/common.json index 8899bae12..5079ab72e 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -3032,7 +3032,9 @@ }, "status": { "loading_calendars": "Ielādē kalendārus...", - "loading_events": "Ielādē pasākumus..." + "loading_events": "Ielādē pasākumus...", + "events_load_failed": "Neizdevās ielādēt pasākumus. Kalendārs var nebūt aktuāls.", + "retry": "Mēģināt vēlreiz" }, "quick_create": { "placeholder": "Jauna pasākuma nosaukums", diff --git a/locales/mn/common.json b/locales/mn/common.json index f348b48a4..ec90ba50f 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -3059,7 +3059,9 @@ }, "status": { "loading_calendars": "Хуанли ачаалж байна...", - "loading_events": "Арга хэмжээг ачаалж байна..." + "loading_events": "Арга хэмжээг ачаалж байна...", + "events_load_failed": "Арга хэмжээг ачаалж чадсангүй. Хуанли шинэчлэгдээгүй байж магадгүй.", + "retry": "Дахин оролдоно уу" }, "quick_create": { "placeholder": "Шинэ үйл явдлын гарчиг", diff --git a/locales/nb/common.json b/locales/nb/common.json index 67e2cf1be..02fe15e0f 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -3059,7 +3059,9 @@ }, "status": { "loading_calendars": "Laster inn kalendere...", - "loading_events": "Laster inn hendelser..." + "loading_events": "Laster inn hendelser...", + "events_load_failed": "Kunne ikke laste inn hendelser. Kalenderen er kanskje ikke oppdatert.", + "retry": "Prøv igjen" }, "quick_create": { "placeholder": "Tittel på ny hendelse", diff --git a/locales/nl/common.json b/locales/nl/common.json index c96c2b9ec..3f00f5fda 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -3033,7 +3033,9 @@ }, "status": { "loading_calendars": "Agenda's laden...", - "loading_events": "Evenementen laden..." + "loading_events": "Evenementen laden...", + "events_load_failed": "Evenementen konden niet worden geladen. De agenda is mogelijk niet actueel.", + "retry": "Opnieuw proberen" }, "quick_create": { "placeholder": "Titel nieuw evenement", diff --git a/locales/pl/common.json b/locales/pl/common.json index 3022543f7..94472bfd8 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -3033,7 +3033,9 @@ }, "status": { "loading_calendars": "Ładowanie kalendarzy...", - "loading_events": "Ładowanie wydarzeń..." + "loading_events": "Ładowanie wydarzeń...", + "events_load_failed": "Nie udało się wczytać wydarzeń. Kalendarz może być nieaktualny.", + "retry": "Spróbuj ponownie" }, "quick_create": { "placeholder": "Tytuł nowego wydarzenia", diff --git a/locales/pt/common.json b/locales/pt/common.json index d9938c0f9..7d10433e1 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -3059,7 +3059,9 @@ }, "status": { "loading_calendars": "Carregando calendários...", - "loading_events": "Carregando eventos..." + "loading_events": "Carregando eventos...", + "events_load_failed": "Não foi possível carregar os eventos. O calendário pode estar desatualizado.", + "retry": "Tentar novamente" }, "quick_create": { "placeholder": "Título do novo evento", diff --git a/locales/ro/common.json b/locales/ro/common.json index 60d58bd31..9886cb476 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -3059,7 +3059,9 @@ }, "status": { "loading_calendars": "Se încarcă calendarele...", - "loading_events": "Se încarcă evenimente..." + "loading_events": "Se încarcă evenimente...", + "events_load_failed": "Nu s-au putut încărca evenimentele. Este posibil ca calendarul să nu fie actualizat.", + "retry": "Încercați din nou" }, "quick_create": { "placeholder": "Titlul unui eveniment nou", diff --git a/locales/ru/common.json b/locales/ru/common.json index 3232e5c29..2c4a3100d 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -3033,7 +3033,9 @@ }, "status": { "loading_calendars": "Загрузка календарей...", - "loading_events": "Загрузка событий..." + "loading_events": "Загрузка событий...", + "events_load_failed": "Не удалось загрузить события. Календарь может быть неактуален.", + "retry": "Попробовать снова" }, "quick_create": { "placeholder": "Название нового события", diff --git a/locales/sk/common.json b/locales/sk/common.json index 2577ded34..5f0d53ecd 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -3059,7 +3059,9 @@ }, "status": { "loading_calendars": "Načítavanie kalendárov...", - "loading_events": "Načítavanie udalostí..." + "loading_events": "Načítavanie udalostí...", + "events_load_failed": "Udalosti sa nepodarilo načítať. Kalendár nemusí byť aktuálny.", + "retry": "Skúsiť znova" }, "quick_create": { "placeholder": "Názov novej udalosti", diff --git a/locales/tr/common.json b/locales/tr/common.json index 0ce9db316..d21c4cde8 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -3059,7 +3059,9 @@ }, "status": { "loading_calendars": "Takvimler yükleniyor...", - "loading_events": "Etkinlikler yükleniyor..." + "loading_events": "Etkinlikler yükleniyor...", + "events_load_failed": "Etkinlikler yüklenemedi. Takvim güncel olmayabilir.", + "retry": "Tekrar dene" }, "quick_create": { "placeholder": "Yeni etkinlik başlığı", diff --git a/locales/uk/common.json b/locales/uk/common.json index df85cb3a3..0869cf5ee 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -3033,7 +3033,9 @@ }, "status": { "loading_calendars": "Завантаження календарів...", - "loading_events": "Завантаження подій..." + "loading_events": "Завантаження подій...", + "events_load_failed": "Не вдалося завантажити події. Календар може бути неактуальним.", + "retry": "Спробуйте знову" }, "quick_create": { "placeholder": "Нова назва події", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 9514cbb22..ececfeb3a 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -3059,7 +3059,9 @@ }, "status": { "loading_calendars": "正在載入行事曆…", - "loading_events": "正在載入活動…" + "loading_events": "正在載入活動…", + "events_load_failed": "無法載入活動。行事曆可能不是最新狀態。", + "retry": "再試一次" }, "quick_create": { "placeholder": "新活動標題", diff --git a/locales/zh/common.json b/locales/zh/common.json index 5b2281191..200b64391 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -3033,7 +3033,9 @@ }, "status": { "loading_calendars": "正在加载日历...", - "loading_events": "正在加载事件..." + "loading_events": "正在加载事件...", + "events_load_failed": "无法加载事件。日历可能不是最新的。", + "retry": "再试一次" }, "quick_create": { "placeholder": "新活动标题", diff --git a/stores/__tests__/calendar-store-synthetic-ids.test.ts b/stores/__tests__/calendar-store-synthetic-ids.test.ts index f164788de..83bca7a37 100644 --- a/stores/__tests__/calendar-store-synthetic-ids.test.ts +++ b/stores/__tests__/calendar-store-synthetic-ids.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { useCalendarStore, resolveMutationTarget } from '../calendar-store'; +import { EVENTS_LOAD_ERROR, useCalendarStore, resolveMutationTarget } from '../calendar-store'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; import type { CalendarEvent } from '@/lib/jmap/types'; +import { jmapError } from '@/lib/jmap/jmap-error'; // Occurrences produced by server-side recurrence expansion carry synthetic // ids (lib/recurrence-instances.ts). The store writes "this occurrence only" @@ -369,3 +370,53 @@ describe('rsvpEvent on an expanded occurrence', () => { expect(client.updateCalendarEvent).not.toHaveBeenCalled(); }); }); + +describe('a range that fails to load', () => { + // What an unfixed client got from Stalwart 1.0 for every range. + const refused = () => jmapError( + 'CalendarEvent/get failed: recurrenceOverrides cannot be requested together with utcStart or utcEnd', + { type: 'invalidArguments' }, + ); + const NEXT = ['2026-10-01T00:00:00', '2026-10-31T23:59:59'] as const; + + it('keeps the events on screen and reports the failure on Stalwart 1.0', async () => { + const client = fakeClient(); + client.queryAllCalendarEvents.mockResolvedValueOnce([occurrence()]); + await useCalendarStore.getState().fetchEvents(client, RANGE[0], RANGE[1]); + + client.queryAllCalendarEvents.mockRejectedValueOnce(refused()); + await useCalendarStore.getState().fetchEvents(client, NEXT[0], NEXT[1]); + + const state = useCalendarStore.getState(); + expect(state.events.map((e) => e.id)).toEqual(['maaaaab']); + expect(state.error).toBe(EVENTS_LOAD_ERROR); + expect(state.isLoadingEvents).toBe(false); + // Still the range that is actually loaded. + expect(state.dateRange).toEqual({ start: RANGE[0], end: RANGE[1] }); + + client.queryAllCalendarEvents.mockResolvedValueOnce([]); + await useCalendarStore.getState().fetchEvents(client, NEXT[0], NEXT[1]); + expect(useCalendarStore.getState().error).toBeNull(); + expect(useCalendarStore.getState().events).toEqual([]); + }); + + it('keeps the events of an account that fails when loading every account', async () => { + const first = fakeClient(); + first.queryAllCalendarEvents.mockResolvedValue([occurrence({ title: 'First' })]); + const second = fakeClient(); + second.queryAllCalendarEvents + .mockResolvedValueOnce([occurrence({ title: 'Second, loaded before' })]) + .mockRejectedValueOnce(refused()); + const accounts = [{ localAccountId: 'A@h', client: first }, { localAccountId: 'B@h', client: second }]; + + await useCalendarStore.getState().fetchAllAccountsEvents(accounts, RANGE[0], RANGE[1]); + await useCalendarStore.getState().fetchAllAccountsEvents(accounts, NEXT[0], NEXT[1]); + + const state = useCalendarStore.getState(); + expect(state.events.map((e) => [e.id, e.title])).toEqual([ + ['A@h::maaaaab', 'First'], + ['B@h::maaaaab', 'Second, loaded before'], + ]); + expect(state.error).toBe(EVENTS_LOAD_ERROR); + }); +}); diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index 77338d1cf..8978b7c3a 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -479,6 +479,12 @@ 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[]; @@ -631,8 +637,10 @@ export const useCalendarStore = create()( } set({ events, isLoadingEvents: false, dateRange: { start, end } }); } catch (error) { + // Keep the events already on screen: an empty grid would read as + // "no events". debug.error('Failed to fetch events:', error); - set({ error: 'Failed to load events', isLoadingEvents: false }); + set({ error: EVENTS_LOAD_ERROR, isLoadingEvents: false }); } }, @@ -668,6 +676,7 @@ export const useCalendarStore = create()( refetchVisibleRange = () => get().fetchAllAccountsEvents(accounts, start, end, { silent: true }); set(options?.silent ? { error: null } : { isLoadingEvents: true, error: null }); try { + let failed = false; const results = await Promise.all( accounts.map(async ({ client, localAccountId }) => { try { @@ -679,14 +688,21 @@ export const useCalendarStore = create()( return prefixEventsWithLocalAccount(expanded, localAccountId); } catch (error) { debug.error(`Failed to fetch events for account ${localAccountId}:`, error); - return [] as CalendarEvent[]; + failed = true; + // Keep what this account showed instead of blanking it. + return get().events.filter(e => e.localAccountId === localAccountId); } }), ); - set({ events: results.flat(), isLoadingEvents: false, dateRange: { start, end } }); + set({ + events: results.flat(), + isLoadingEvents: false, + dateRange: { start, end }, + ...(failed ? { error: EVENTS_LOAD_ERROR } : {}), + }); } catch (error) { debug.error('Failed to fetch all-account events:', error); - set({ error: 'Failed to load events', isLoadingEvents: false }); + set({ error: EVENTS_LOAD_ERROR, isLoadingEvents: false }); } },