From 990c5e0f772a3e59a2bb73efc0fa9ec5379843a1 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 25 Sep 2026 17:40:49 +0200 Subject: [PATCH] fix: edit or answer one occurrence of a series on Stalwart 1.0 Stalwart 1.0 refuses series properties such as privacy and the organizer on a single occurrence unless they repeat the series' value, and refuses them inside a recurrence override on the base event. The event modal sent privacy "public" on every save, so editing one occurrence of a series without CLASS failed, and so did every change to an occurrence the browser expanded. - leave series and per-event keys out of changes to one occurrence - write base-event overrides as before and, if the server refuses series keys there, repeat the write once without them and remember that - stop resetting status, free/busy and privacy when an event is edited --- components/calendar/calendar-app.tsx | 2 +- components/calendar/event-modal.tsx | 18 ++- .../event-modal-status-fields.test.ts | 19 +++ lib/__tests__/recurrence-instances.test.ts | 114 +++++++++++++- lib/jmap/client.ts | 6 +- lib/recurrence-instances.ts | 144 +++++++++++++++--- .../calendar-store-synthetic-ids.test.ts | 132 ++++++++++++++++ stores/calendar-store.ts | 76 ++++++--- 8 files changed, 465 insertions(+), 46 deletions(-) create mode 100644 lib/__tests__/event-modal-status-fields.test.ts diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index c0ace2be4..700eb3317 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -1022,7 +1022,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = const master = await findMasterEvent(event); const occurrence = master && overrideContextOf(event, master); const overridePatch = occurrence - && buildFallbackOverridePatch(occurrence, withNewOverrideDetails(occurrence, updates)); + && buildFallbackOverridePatch(occurrence, withNewOverrideDetails(occurrence, updates, { seriesKeys: true })); if (master && overridePatch) { await updateEvent(client, master.id, overridePatch, sendScheduling); } else { diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index a05c31f66..29b9c1cfd 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -72,6 +72,20 @@ export function shiftedEnd(oldStart: Date, oldEnd: Date, newStart: Date): Date | return new Date(newStart.getTime() + durationMs); } +/** + * Status, free/busy and privacy for a save. The editor has no controls for + * them, so only a new event gets them (the defaults) and an edit keeps what + * the event has. Every edit used to reset them to confirmed, busy and public, + * and Stalwart 1.0 refuses an edit of one occurrence whose privacy differs + * from its series'. Pure, for testing. + */ +export function savedStatusFields( + event: CalendarEvent | null | undefined, +): Partial> { + if (event) return {}; + return { status: "confirmed", freeBusyStatus: "busy", privacy: "public" }; +} + function formatDateInput(d: Date): string { return format(d, "yyyy-MM-dd"); } @@ -532,9 +546,7 @@ export function EventModal({ timeZone: allDay ? null : timeZone, showWithoutTime: allDay, calendarIds: { [calendarId]: true }, - status: "confirmed", - freeBusyStatus: "busy", - privacy: "public", + ...savedStatusFields(event), }; if (!event) { diff --git a/lib/__tests__/event-modal-status-fields.test.ts b/lib/__tests__/event-modal-status-fields.test.ts new file mode 100644 index 000000000..5b8f5a039 --- /dev/null +++ b/lib/__tests__/event-modal-status-fields.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest'; +import { savedStatusFields } from '@/components/calendar/event-modal'; +import type { CalendarEvent } from '@/lib/jmap/types'; + +// The event editor has no controls for status, free/busy or privacy. Saving +// used to reset every edited event to confirmed, busy and public - and on +// Stalwart 1.0 an edit of one occurrence fails when privacy differs from the +// series' (a series without CLASS has none at all). + +describe('savedStatusFields', () => { + it('gives a new event the defaults', () => { + expect(savedStatusFields(null)).toEqual({ status: 'confirmed', freeBusyStatus: 'busy', privacy: 'public' }); + }); + + it('leaves them out of an edit, keeping a tentative, free or private event as it is', () => { + const event = { id: 'e1', status: 'tentative', freeBusyStatus: 'free', privacy: 'private' } as CalendarEvent; + expect(savedStatusFields(event)).toEqual({}); + }); +}); diff --git a/lib/__tests__/recurrence-instances.test.ts b/lib/__tests__/recurrence-instances.test.ts index 6608e780b..ab6131258 100644 --- a/lib/__tests__/recurrence-instances.test.ts +++ b/lib/__tests__/recurrence-instances.test.ts @@ -9,11 +9,14 @@ import { buildOccurrenceRsvpPatch, hydrateRecurrenceInstances, isBrowserExpandedOccurrence, + isOverrideSeriesKeyRejection, isServerRecurrenceInstance, isSyntheticIdMutationUnsupported, resolveOverrideKey, withNewOverrideDetails, + withoutOverrideSeriesKeys, } from '../recurrence-instances'; +import { jmapError } from '../jmap/jmap-error'; // Shapes below mirror what Stalwart 0.16.19 returned for // CalendarEvent/query?expandRecurrences=true + CalendarEvent/get: the @@ -157,6 +160,86 @@ describe('buildOccurrencePatch', () => { 'participants/p1/participationStatus': 'accepted', }); }); + + it('drops the series keys Stalwart 1.0 checks against the base event, and pointers at an address', () => { + // jmap/src/calendar_event/validate.rs: a synthetic id may only repeat + // the series' own value for these; 0.16.20-0.16.23 drop them. + const patch = buildOccurrencePatch({ + privacy: 'public', + organizerCalendarAddress: 'mailto:me@example.com', + method: 'request', + prodId: '-//Bulwark//EN', + sentBy: 'mailto:assistant@example.com', + recurrenceRule: { frequency: 'daily' }, + excludedRecurrenceRule: null, + 'participants/p1/calendarAddress': 'mailto:other@example.com', + 'participants/p1/participationStatus': 'declined', + participants: { p1: { '@type': 'Participant', calendarAddress: 'mailto:p1@example.com' } }, + relatedTo: { r1: { '@type': 'Relation' } }, + sequence: 3, + excluded: false, + status: 'tentative', + freeBusyStatus: 'free', + } as unknown as Partial); + expect(patch).toEqual({ + 'participants/p1/participationStatus': 'declined', + participants: { p1: { '@type': 'Participant', calendarAddress: 'mailto:p1@example.com' } }, + relatedTo: { r1: { '@type': 'Relation' } }, + sequence: 3, + excluded: false, + status: 'tentative', + freeBusyStatus: 'free', + }); + }); +}); + +describe('withoutOverrideSeriesKeys', () => { + it('takes the series and per-event keys out of every override it writes', () => { + expect(withoutOverrideSeriesKeys({ + title: 'Series title', + privacy: 'private', + recurrenceOverrides: { + '2026-09-08T10:00:00': { title: 'Moved', privacy: 'public', organizerCalendarAddress: 'mailto:o@example.com' }, + '2026-09-09T10:00:00': { excluded: true }, + }, + } as unknown as Partial)).toEqual({ + title: 'Series title', + privacy: 'private', + recurrenceOverrides: { + '2026-09-08T10:00:00': { title: 'Moved' }, + '2026-09-09T10:00:00': { excluded: true }, + }, + }); + }); + + it('handles an override entry and pointers into one', () => { + expect(withoutOverrideSeriesKeys({ + 'recurrenceOverrides/2026-09-08T10:00:00': { start: '2026-09-08T12:00:00', privacy: 'public', uid: 'x' }, + 'recurrenceOverrides/2026-09-09T10:00:00/privacy': 'public', + 'recurrenceOverrides/2026-09-09T10:00:00/participants/p1/calendarAddress': 'mailto:x@example.com', + 'recurrenceOverrides/2026-09-09T10:00:00/participants/p1/participationStatus': 'accepted', + 'recurrenceOverrides/2026-09-10T10:00:00': null, + } as unknown as Partial)).toEqual({ + 'recurrenceOverrides/2026-09-08T10:00:00': { start: '2026-09-08T12:00:00' }, + 'recurrenceOverrides/2026-09-09T10:00:00/participants/p1/participationStatus': 'accepted', + 'recurrenceOverrides/2026-09-10T10:00:00': null, + }); + }); +}); + +describe('isOverrideSeriesKeyRejection', () => { + it('recognises Stalwart 1.0 refusing a series key inside an override', () => { + expect(isOverrideSeriesKeyRejection(jmapError('privacy cannot be set inside recurrenceOverrides.', { + type: 'invalidProperties', properties: ['recurrenceOverrides'], + }))).toBe(true); + }); + + it('does not match other failures', () => { + expect(isOverrideSeriesKeyRejection(jmapError('This property cannot be modified on a single occurrence.', { + type: 'invalidProperties', + }))).toBe(false); + expect(isOverrideSeriesKeyRejection(new Error('privacy cannot be set inside recurrenceOverrides.'))).toBe(false); + }); }); describe('resolveOverrideKey', () => { @@ -285,6 +368,19 @@ describe('buildFallbackOverridePatch', () => { it('is null for something that is not an occurrence of a series', () => { expect(buildFallbackOverridePatch(instance({ recurrenceId: null }), { title: 'x' })).toBeNull(); }); + + it('keeps privacy and the organizer in the override, as 0.16 has always been sent them', () => { + const patch = buildFallbackOverridePatch(instance({ recurrenceOverrides: null }), { + title: 'x', privacy: 'public', organizerCalendarAddress: 'mailto:org@example.com', + }); + expect(patch).toEqual({ + recurrenceOverrides: { + '2026-09-08T10:00:00': { + start: '2026-09-08T10:00:00', duration: 'PT1H', title: 'x', privacy: 'public', organizerCalendarAddress: 'mailto:org@example.com', + }, + }, + }); + }); }); describe('buildOccurrenceRsvpPatch', () => { @@ -302,13 +398,18 @@ describe('buildOccurrenceRsvpPatch', () => { }); }); - it('names the organizer, which 0.16.19 needs to accept the override', () => { + it('names the organizer in an override on the base event, which 0.16.19 needs to accept it', () => { const occ = { participants, organizerCalendarAddress: 'mailto:org@example.com' }; - expect(buildOccurrenceRsvpPatch(occ, 'me', 'tentative')).toMatchObject({ + expect(buildOccurrenceRsvpPatch(occ, 'me', 'tentative', { seriesKeys: true })).toMatchObject({ organizerCalendarAddress: 'mailto:org@example.com', }); }); + it('leaves the organizer out of an answer through a synthetic id, which Stalwart 1.0 checks against the series', () => { + const occ = { participants, organizerCalendarAddress: 'mailto:org@example.com' }; + expect(buildOccurrenceRsvpPatch(occ, 'me', 'tentative')).not.toHaveProperty('organizerCalendarAddress'); + }); + it('keeps the series sequence, so the override replaces its occurrence', () => { expect(buildOccurrenceRsvpPatch({ participants, sequence: 3 }, 'me', 'declined')).toMatchObject({ sequence: 3 }); }); @@ -362,6 +463,7 @@ describe('withNewOverrideDetails', () => { locations: { l1: { '@type': 'Location', name: 'Room 7' } } as unknown as CalendarEvent['locations'], participants: { p1: { '@type': 'Participant', participationStatus: 'accepted' } } as unknown as CalendarEvent['participants'], organizerCalendarAddress: 'mailto:org@example.com', + privacy: 'private', sequence: 2, recurrenceOverrides: null, }); @@ -372,11 +474,17 @@ describe('withNewOverrideDetails', () => { title: 'Moved', locations: occurrence.locations, participants: occurrence.participants, - organizerCalendarAddress: 'mailto:org@example.com', sequence: 2, }); }); + it('copies privacy and the organizer only into an override on the base event, as 0.16 has always had them', () => { + expect(withNewOverrideDetails(occurrence, { title: 'Moved' }, { seriesKeys: true })).toMatchObject({ + privacy: 'private', + organizerCalendarAddress: 'mailto:org@example.com', + }); + }); + it('adds nothing to an existing override or when that is unknown', () => { const overridden = { ...occurrence, recurrenceOverrides: { '2026-09-08T10:00:00': { title: 'Moved' } } }; expect(withNewOverrideDetails(overridden, { start: 'x' })).toEqual({ start: 'x' }); diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 85e35cbe4..37910a81d 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -7347,7 +7347,7 @@ export class JMAPClient implements IJMAPClient { const errorType = result?.type || 'unknown'; const errorDesc = result?.description || ''; debug.error('CalendarEvent/set update returned JMAP error', { type: errorType, description: errorDesc }); - throw new Error(`JMAP error (${errorType}): ${errorDesc}`); + throw jmapError(`JMAP error (${errorType}): ${errorDesc}`, result); } if (methodName === "CalendarEvent/set") { @@ -7357,7 +7357,9 @@ export class JMAPClient implements IJMAPClient { if (sendSchedulingMessages && error.type === 'forbidden') { throw new SchedulingDeniedError(error.description || 'forbidden'); } - throw new Error(error.description || "Failed to update calendar event"); + // Keep the SetError type: the store retries some refusals + // differently (isOverrideSeriesKeyRejection). + throw jmapError(error.description || "Failed to update calendar event", error); } debug.log('calendar', 'CalendarEvent/set update full response', { methodName, result }); debug.log('calendar', 'CalendarEvent/set update success', { eventId, updated: result.updated ? Object.keys(result.updated) : null }); diff --git a/lib/recurrence-instances.ts b/lib/recurrence-instances.ts index a2badc440..21f215c7d 100644 --- a/lib/recurrence-instances.ts +++ b/lib/recurrence-instances.ts @@ -23,6 +23,7 @@ import type { CalendarEvent, CalendarParticipant } from '@/lib/jmap/types'; import { parseDurationSeconds } from '@/lib/calendar-event-normalization'; +import { jmapErrorType } from '@/lib/jmap/jmap-error'; import { RECURRENCE_OVERRIDE_IMMUTABLE_KEYS } from '@/lib/recurrence-overrides'; /** @@ -63,6 +64,32 @@ export const OCCURRENCE_REJECTED_KEYS = [ 'hideAttendees', ] as const; +/** + * Properties of the whole series, plus the client's plural rule names. + * Stalwart 1.0 refuses them inside a recurrence override, and on a synthetic + * id unless they equal the base event's value; 0.16.20 to 0.16.23 drop them + * there (jmap/src/calendar_event/validate.rs, is_series_property). + */ +export const SERIES_KEYS = [ + '@type', + 'method', + 'organizerCalendarAddress', + 'privacy', + 'prodId', + 'recurrenceId', + 'recurrenceIdTimeZone', + 'sentBy', + 'uid', + 'recurrenceOverrides', + 'recurrenceRule', + 'recurrenceRules', + 'excludedRecurrenceRule', + 'excludedRecurrenceRules', +] as const; + +/** Per-event metadata (validate.rs, is_event_metadata): never part of one occurrence. */ +export const EVENT_METADATA_KEYS = ['id', ...OCCURRENCE_REJECTED_KEYS] as const; + type EventIdentity = Pick & Partial>; /** @@ -104,28 +131,89 @@ function firstPointerSegment(key: string): string { return slash === -1 ? key : key.slice(0, slash); } +const NOT_PER_OCCURRENCE = new Set([...SERIES_KEYS, ...EVENT_METADATA_KEYS]); + +// A participant's address is the series': 1.0 refuses a pointer at it inside +// an override, and on a synthetic id unless it is unchanged. +const PARTICIPANT_ADDRESS_POINTER = /^participants\/[^/]+\/calendarAddress$/; + +function isPerOccurrenceKey(key: string): boolean { + return !NOT_PER_OCCURRENCE.has(firstPointerSegment(key)) && !PARTICIPANT_ADDRESS_POINTER.test(key); +} + /** - * Reduce an event patch to what may be written to a single occurrence - * through its synthetic id: identity / whole-series keys and the per-event - * keys the server rejects are dropped (also when addressed through a JSON - * pointer such as `calendarIds/x`). Everything else - including pointer - * patches like `locations/loc1/name` - passes through unchanged. + * Reduce an event patch to what one occurrence may carry, through its + * synthetic id or inside its override: series keys and per-event metadata + * are dropped (also when addressed through a JSON pointer such as + * `calendarIds/x`), and so is a pointer at a participant's calendarAddress. + * Everything else - including pointer patches like `locations/loc1/name` and + * a whole `participants` map - passes through unchanged. */ export function buildOccurrencePatch(updates: Partial): Partial { - const dropped = new Set([ - ...RECURRENCE_OVERRIDE_IMMUTABLE_KEYS, - ...OCCURRENCE_REJECTED_KEYS, - 'recurrenceId', - 'recurrenceIdTimeZone', - ]); const patch: Record = {}; for (const [key, value] of Object.entries(updates)) { - if (dropped.has(firstPointerSegment(key))) continue; - patch[key] = value; + if (isPerOccurrenceKey(key)) patch[key] = value; } return patch as Partial; } +const NOT_IN_OVERRIDE_ENTRIES = new Set([ + ...RECURRENCE_OVERRIDE_IMMUTABLE_KEYS, + ...OCCURRENCE_REJECTED_KEYS, + 'recurrenceId', + 'recurrenceIdTimeZone', +]); + +/** + * The fields of an override entry on the base event, as 0.16 has always been + * sent them: identity and per-event keys dropped, series keys such as privacy + * and the organizer kept. 0.16.22 and older store them on the override, and + * 0.16.19 only accepts an answer to one occurrence that names the organizer. + * Stalwart 1.0 refuses them there; see withoutOverrideSeriesKeys. + */ +function overrideEntryFields(updates: Partial): Record { + const fields: Record = {}; + for (const [key, value] of Object.entries(updates)) { + if (!NOT_IN_OVERRIDE_ENTRIES.has(firstPointerSegment(key))) fields[key] = value; + } + return fields; +} + +/** + * `updates` of a base event with the series and per-event keys taken out of + * the recurrence overrides it writes, whole entries and pointers into one + * alike. What Stalwart 1.0 accepts inside an override; the rest of the + * update is left alone. + */ +export function withoutOverrideSeriesKeys(updates: Partial): Partial { + const entry = (value: unknown) => value && typeof value === 'object' + ? buildOccurrencePatch(value as Partial) + : value; + const result: Record = {}; + for (const [key, value] of Object.entries(updates)) { + if (key === 'recurrenceOverrides' && value && typeof value === 'object') { + result[key] = Object.fromEntries(Object.entries(value).map(([id, override]) => [id, entry(override)])); + } else if (key.startsWith('recurrenceOverrides/')) { + const inEntry = key.split('/').slice(2).join('/'); + if (!inEntry) result[key] = entry(value); + else if (isPerOccurrenceKey(inEntry)) result[key] = value; + } else { + result[key] = value; + } + } + return result as Partial; +} + +/** + * Whether a CalendarEvent/set failure is Stalwart 1.0 refusing a series key + * inside a recurrence override (invalidProperties, "privacy cannot be set + * inside recurrenceOverrides."). + */ +export function isOverrideSeriesKeyRejection(error: unknown): boolean { + return jmapErrorType(error) === 'invalidProperties' + && /cannot be set inside recurrenceOverrides/i.test(error instanceof Error ? error.message : ''); +} + type OverrideMap = NonNullable; /** @@ -372,7 +460,7 @@ export function buildFallbackOverridePatch( ...kept, start: instance.start, duration: instance.duration, - ...buildOccurrencePatch(patch), + ...overrideEntryFields(patch), }); } @@ -408,13 +496,24 @@ const NEW_OVERRIDE_COPIED_KEYS = [ 'locale', 'status', 'freeBusyStatus', - 'privacy', 'alerts', - 'organizerCalendarAddress', 'participants', 'sequence', ] as const; +/** + * Series keys that a new override on the base event carries as well, the way + * 0.16 has always been sent it (see overrideEntryFields). An override through + * a synthetic id never copies them: they are the series' own, 1.0 refuses a + * different value and 0.16.20 to 0.16.23 drop them. + */ +const NEW_OVERRIDE_SERIES_KEYS = ['privacy', 'organizerCalendarAddress'] as const; + +export interface OverridePatchOptions { + /** Include the series keys, for an override written on the base event. */ + seriesKeys?: boolean; +} + /** * `patch` plus the occurrence details a new override needs * (NEW_OVERRIDE_COPIED_KEYS). Nothing is added when the occurrence already @@ -424,10 +523,12 @@ const NEW_OVERRIDE_COPIED_KEYS = [ export function withNewOverrideDetails( occurrence: Partial, patch: Partial, + options: OverridePatchOptions = {}, ): Partial { if (!createsOverride(occurrence)) return patch; + const names = options.seriesKeys ? [...NEW_OVERRIDE_COPIED_KEYS, ...NEW_OVERRIDE_SERIES_KEYS] : NEW_OVERRIDE_COPIED_KEYS; const details: Record = {}; - for (const name of NEW_OVERRIDE_COPIED_KEYS) { + for (const name of names) { if (!(name in patch) && occurrence[name] != null) details[name] = occurrence[name]; } return { ...details, ...patch }; @@ -441,7 +542,9 @@ export function withNewOverrideDetails( * single occurrence ("Multiple organizers found in iCalendar object", * 0.16.23), and a pointer inside an override keeps just the patched * participant, dropping the organizer and everyone else from it. - * - The organizer: 0.16.19 rejects the override the same way without it. + * - For an override on the base event (`seriesKeys`), the organizer: 0.16.19 + * rejects the override the same way without it. Through a synthetic id it + * is left out - it is the series'. * - The sequence (see NEW_OVERRIDE_COPIED_KEYS). * - For a new override, the occurrence's details (`withNewOverrideDetails`). */ @@ -449,17 +552,18 @@ export function buildOccurrenceRsvpPatch( occurrence: Partial, participantId: string, status: CalendarParticipant['participationStatus'], + options: OverridePatchOptions = {}, ): Partial | null { const participant = occurrence.participants?.[participantId]; if (!participant) return null; const patch: Record = {}; if (occurrence.sequence != null) patch.sequence = occurrence.sequence; - if (occurrence.organizerCalendarAddress) { + if (options.seriesKeys && occurrence.organizerCalendarAddress) { patch.organizerCalendarAddress = occurrence.organizerCalendarAddress; } patch.participants = { ...occurrence.participants, [participantId]: { ...participant, participationStatus: status }, }; - return withNewOverrideDetails(occurrence, patch as Partial); + return withNewOverrideDetails(occurrence, patch as Partial, options); } diff --git a/stores/__tests__/calendar-store-synthetic-ids.test.ts b/stores/__tests__/calendar-store-synthetic-ids.test.ts index 83bca7a37..52bca208c 100644 --- a/stores/__tests__/calendar-store-synthetic-ids.test.ts +++ b/stores/__tests__/calendar-store-synthetic-ids.test.ts @@ -420,3 +420,135 @@ describe('a range that fails to load', () => { expect(state.error).toBe(EVENTS_LOAD_ERROR); }); }); + +// Stalwart 1.0 checks what one occurrence carries (jmap/src/calendar_event/ +// validate.rs). Through a synthetic id, a series property must repeat the +// base event's value; inside an override written on the base event it is +// refused whatever its value. 0.16.20-0.16.23 drop such keys silently, and +// older 0.16 store them on the override. +describe('editing one occurrence', () => { + const SERIES_PROPERTIES = [ + '@type', 'method', 'organizerCalendarAddress', 'privacy', 'prodId', 'recurrenceId', + 'recurrenceIdTimeZone', 'sentBy', 'uid', 'recurrenceOverrides', 'recurrenceRule', + ]; + // The stored series: no CLASS (privacy), as Apple Calendar and Thunderbird write it. + const BASE: Record = { id: 'b', uid: 'series@example.com', organizerCalendarAddress: 'mailto:org@example.com' }; + + const seriesKeysIn = (entry: unknown) => Object.keys((entry ?? {}) as object) + .filter((key) => SERIES_PROPERTIES.includes(key.split('/')[0])); + + /** updateCalendarEvent of a Stalwart 1.0 server holding BASE. */ + function stalwart10Update() { + return vi.fn(async (id: string, patch: Record) => { + if (id !== BASE.id) { + for (const key of seriesKeysIn(patch)) { + if (JSON.stringify(patch[key] ?? null) !== JSON.stringify(BASE[key] ?? null)) { + throw jmapError('This property cannot be modified on a single occurrence.', { type: 'invalidProperties', properties: [key] }); + } + } + return; + } + for (const [key, value] of Object.entries(patch)) { + if (key.split('/')[0] !== 'recurrenceOverrides') continue; + const entries = key === 'recurrenceOverrides' ? Object.values(value as object) : [value]; + const [refused] = entries.flatMap(seriesKeysIn); + if (refused) { + throw jmapError(`${refused} cannot be set inside recurrenceOverrides.`, { + type: 'invalidProperties', properties: ['recurrenceOverrides'], + }); + } + } + }); + } + + it('sends one occurrence nothing of the series through its synthetic id on Stalwart 1.0', async () => { + const client = fakeClient({ updateCalendarEvent: stalwart10Update() }); + useCalendarStore.setState({ events: [occurrence({ organizerCalendarAddress: 'mailto:org@example.com' })] }); + + // What the event modal sent before: privacy "public" (the series has + // none) and the organizer spelled differently. + await useCalendarStore.getState().updateEvent(client, 'maaaaab', { + title: 'Renamed', privacy: 'public', organizerCalendarAddress: 'mailto:ORG@example.com', calendarIds: { f: true }, + }); + + expect(client.updateCalendarEvent).toHaveBeenCalledTimes(1); + const [id, patch] = client.updateCalendarEvent.mock.calls[0]; + expect(id).toBe('maaaaab'); + expect(Object.keys(patch).sort()).toEqual(['participants', 'title']); + expect(useCalendarStore.getState().error).toBeNull(); + }); + + it('answers one occurrence without the organizer through its synthetic id on Stalwart 1.0', async () => { + const client = fakeClient({ updateCalendarEvent: stalwart10Update() }); + useCalendarStore.setState({ events: [occurrence({ organizerCalendarAddress: 'mailto:org@example.com', sequence: 1 })] }); + + await useCalendarStore.getState().rsvpEvent(client, 'maaaaab', 'p1', 'declined', undefined, 'occurrence'); + + expect(client.updateCalendarEvent).toHaveBeenCalledTimes(1); + expect(client.updateCalendarEvent.mock.calls[0][1]).not.toHaveProperty('organizerCalendarAddress'); + }); + + it('writes an override on the base event once more without series keys when Stalwart 1.0 refuses them, and remembers', async () => { + const client = fakeClient({ updateCalendarEvent: stalwart10Update() }); + // Expanded in the browser (the client-side fallback): the change becomes + // an override on the base event "b", carrying privacy and the organizer. + const browserOccurrence = occurrence({ + id: 'b:2026-09-08T10:00:00', originalId: 'b', baseEventId: undefined, recurrenceOverrides: null, + privacy: 'private', organizerCalendarAddress: 'mailto:org@example.com', + }); + useCalendarStore.setState({ events: [browserOccurrence] }); + + await useCalendarStore.getState().updateEvent(client, browserOccurrence.id, { title: 'Moved' }); + + expect(client.updateCalendarEvent).toHaveBeenCalledTimes(2); + const [first, second] = client.updateCalendarEvent.mock.calls.map((call) => call[1].recurrenceOverrides['2026-09-08T10:00:00']); + expect(first).toMatchObject({ privacy: 'private', organizerCalendarAddress: 'mailto:org@example.com' }); + expect(second).toEqual({ start: '2026-09-08T10:00:00', duration: 'PT1H', title: 'Moved', participants: browserOccurrence.participants }); + expect(useCalendarStore.getState().error).toBeNull(); + + // The next override goes out without them straight away. + client.updateCalendarEvent.mockClear(); + await useCalendarStore.getState().rsvpEvent(client, browserOccurrence.id, 'p1', 'accepted', undefined, 'occurrence'); + expect(client.updateCalendarEvent).toHaveBeenCalledTimes(1); + expect(seriesKeysIn(client.updateCalendarEvent.mock.calls[0][1].recurrenceOverrides['2026-09-08T10:00:00'])).toEqual([]); + }); + + it('keeps the override payload on 0.16: privacy and the organizer, in one write', async () => { + const client = fakeClient(); + const browserOccurrence = occurrence({ + id: 'b:2026-09-08T10:00:00', originalId: 'b', baseEventId: undefined, recurrenceOverrides: null, + privacy: 'private', organizerCalendarAddress: 'mailto:org@example.com', sequence: 2, + }); + useCalendarStore.setState({ events: [browserOccurrence] }); + + await useCalendarStore.getState().rsvpEvent(client, browserOccurrence.id, 'p1', 'tentative', undefined, 'occurrence'); + + expect(client.updateCalendarEvent).toHaveBeenCalledTimes(1); + expect(client.updateCalendarEvent).toHaveBeenCalledWith('b', { + recurrenceOverrides: { + '2026-09-08T10:00:00': { + start: '2026-09-08T10:00:00', + duration: 'PT1H', + title: 'Daily standup', + privacy: 'private', + sequence: 2, + organizerCalendarAddress: 'mailto:org@example.com', + participants: { p1: { ...browserOccurrence.participants!.p1, participationStatus: 'tentative' } }, + }, + }, + }, true, undefined); + }); + + it('keeps the 0.16.19 fallback payload for a server that rejects synthetic ids', async () => { + const client = fakeClient(); + client.updateCalendarEvent.mockRejectedValueOnce(new Error(UNSUPPORTED)); + useCalendarStore.setState({ events: [occurrence({ organizerCalendarAddress: 'mailto:org@example.com' })] }); + + await useCalendarStore.getState().rsvpEvent(client, 'maaaaab', 'p1', 'accepted', undefined, 'occurrence'); + + // The synthetic attempt leaves the organizer out, the override has it. + expect(client.updateCalendarEvent.mock.calls[0][1]).not.toHaveProperty('organizerCalendarAddress'); + expect(client.updateCalendarEvent.mock.calls[1][1]['recurrenceOverrides/2026-09-08T10:00:00']) + .toMatchObject({ organizerCalendarAddress: 'mailto:org@example.com' }); + }); +}); diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index 8978b7c3a..f62ac0beb 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -16,9 +16,12 @@ import { buildOccurrencePatch, buildOccurrenceRsvpPatch, isBrowserExpandedOccurrence, + isOverrideSeriesKeyRejection, withNewOverrideDetails, + withoutOverrideSeriesKeys, isServerRecurrenceInstance, isSyntheticIdMutationUnsupported, + type OverridePatchOptions, } from '@/lib/recurrence-instances'; import { parseISO } from 'date-fns'; import { generateUUID } from '@/lib/utils'; @@ -138,25 +141,59 @@ function hasExpandedOccurrencesOf(events: CalendarEvent[], baseId: string, targe // attempt and go straight to the base-event override for them. const syntheticIdRejected = new WeakSet(); +// Clients whose server refused series keys inside a recurrence override +// (Stalwart 1.0): their override writes leave them out from then on. +const overrideSeriesKeysRejected = new WeakSet(); + /** - * Patch one expanded occurrence through its synthetic id. A server that - * predates synthetic-id writes rejects it; the same change is then written - * as a recurrence override on the base event instead (the way Bulwark did it - * before), and that client is remembered as needing the fallback. A change - * that creates the override carries the occurrence's details along - * (`withNewOverrideDetails`). + * Update a stored event. Recurrence overrides it writes carry the series keys + * 0.16 has always been sent (privacy, the organizer; see + * buildFallbackOverridePatch). Stalwart 1.0 refuses those inside an override + * ("privacy cannot be set inside recurrenceOverrides."): the update then + * goes out once more without them, and so does every later one for that + * client. + */ +async function updateBaseEvent( + client: IJMAPClient, + id: string, + updates: Partial, + sendSchedulingMessages: boolean | undefined, + targetAccountId: string | undefined, +): Promise { + const writesOverrides = Object.keys(updates).some((key) => key.split('/', 1)[0] === 'recurrenceOverrides'); + if (writesOverrides && overrideSeriesKeysRejected.has(client)) { + await client.updateCalendarEvent(id, withoutOverrideSeriesKeys(updates), sendSchedulingMessages, targetAccountId); + return; + } + try { + await client.updateCalendarEvent(id, updates, sendSchedulingMessages, targetAccountId); + } catch (error) { + if (!writesOverrides || !isOverrideSeriesKeyRejection(error)) throw error; + overrideSeriesKeysRejected.add(client); + debug.log('calendar', 'Server refuses series keys inside recurrence overrides; writing them without'); + await client.updateCalendarEvent(id, withoutOverrideSeriesKeys(updates), sendSchedulingMessages, targetAccountId); + } +} + +/** + * Patch one expanded occurrence through its synthetic id, with only what one + * occurrence may carry (`buildOccurrencePatch`). A server that predates + * synthetic-id writes rejects it; the change is then written as a recurrence + * override on the base event instead (the way Bulwark did it before), and + * that client is remembered as needing the fallback. `patchFor` builds the + * change; the base-event override asks it for the series keys as well. */ async function updateOccurrence( client: IJMAPClient, instance: CalendarEvent, syntheticId: string, - updates: Partial, + patchFor: (options: OverridePatchOptions) => Partial, sendSchedulingMessages: boolean | undefined, targetAccountId: string | undefined, ): Promise { - const patch = withNewOverrideDetails(instance, buildOccurrencePatch(updates)); - const options: CalendarEventUpdateOptions | undefined = patch.sequence != null ? { keepSequence: true } : undefined; if (!syntheticIdRejected.has(client)) { + const patch = buildOccurrencePatch(patchFor({})); + const options: CalendarEventUpdateOptions | undefined = patch.sequence != null ? { keepSequence: true } : undefined; try { await client.updateCalendarEvent(syntheticId, patch, sendSchedulingMessages, targetAccountId, options); return; @@ -166,11 +203,11 @@ async function updateOccurrence( debug.log('calendar', 'Server rejects synthetic ids; writing a recurrence override on the base event instead'); } } - const fallback = buildFallbackOverridePatch(instance, patch); + const fallback = buildFallbackOverridePatch(instance, patchFor({ seriesKeys: true })); if (!fallback || !instance.baseEventId) { throw new Error('Cannot resolve the occurrence to override'); } - await client.updateCalendarEvent(instance.baseEventId, fallback, sendSchedulingMessages, targetAccountId); + await updateBaseEvent(client, instance.baseEventId, fallback, sendSchedulingMessages, targetAccountId); } /** Destroy one expanded occurrence; falls back to excluding it on the base event like `updateOccurrence`. */ @@ -210,10 +247,11 @@ async function rsvpOccurrence( participantId: string, status: CalendarParticipant['participationStatus'], ): Promise { - const patch = buildOccurrenceRsvpPatch(occurrence, participantId, status); + const patchFor = (options: OverridePatchOptions) => buildOccurrenceRsvpPatch(occurrence, participantId, status, options); + const patch = patchFor({ seriesKeys: true }); if (!patch) throw new Error('Participant not found on this occurrence'); if (target.isOccurrence) { - await updateOccurrence(client, occurrence, target.realId, patch, true, target.targetAccountId); + await updateOccurrence(client, occurrence, target.realId, (options) => patchFor(options) ?? {}, true, target.targetAccountId); return; } await updateBrowserOccurrence(client, target, patch, true); @@ -231,9 +269,9 @@ async function updateBrowserOccurrence( sendSchedulingMessages: boolean | undefined, ): Promise { const occurrence = target.storeEvent!; - const patch = buildFallbackOverridePatch(occurrence, withNewOverrideDetails(occurrence, updates)); + const patch = buildFallbackOverridePatch(occurrence, withNewOverrideDetails(occurrence, updates, { seriesKeys: true })); if (!patch) throw new Error('Cannot resolve the occurrence to override'); - await client.updateCalendarEvent(target.realId, patch, sendSchedulingMessages, target.targetAccountId); + await updateBaseEvent(client, target.realId, patch, sendSchedulingMessages, target.targetAccountId); } /** Delete one occurrence the browser expanded by excluding it on its base event. */ @@ -824,11 +862,15 @@ export const useCalendarStore = create()( cleanUpdates.calendarIds = remapped; } if (target.isOccurrence && storeEvent) { - await updateOccurrence(client, storeEvent, realId, cleanUpdates, sendSchedulingMessages, targetAccountId); + await updateOccurrence( + client, storeEvent, realId, + (options) => withNewOverrideDetails(storeEvent, cleanUpdates, options), + sendSchedulingMessages, targetAccountId, + ); } else if (target.isBrowserOccurrence) { await updateBrowserOccurrence(client, target, cleanUpdates, sendSchedulingMessages); } else { - await client.updateCalendarEvent(realId, cleanUpdates, sendSchedulingMessages, targetAccountId); + await updateBaseEvent(client, realId, cleanUpdates, sendSchedulingMessages, targetAccountId); } set((state) => ({ events: state.events.map(e => {