diff --git a/src/api/__tests__/calendar.test.ts b/src/api/__tests__/calendar.test.ts index d43ecd19..b0baa5d5 100644 --- a/src/api/__tests__/calendar.test.ts +++ b/src/api/__tests__/calendar.test.ts @@ -476,6 +476,25 @@ describe('calendar operations', () => { ev1: { recurrenceRule: null }, }); }); + + it('keeps the SetError type and description of a refused update (Stalwart 1.0 override rule)', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['CalendarEvent/set', { + notUpdated: { + ev1: { + type: 'invalidProperties', + description: 'organizerCalendarAddress cannot be set inside recurrenceOverrides.', + properties: ['recurrenceOverrides'], + }, + }, + }, '0']], + }); + + await expect(updateEvent('ev1', { title: 'x' })).rejects.toMatchObject({ + type: 'invalidProperties', + message: 'organizerCalendarAddress cannot be set inside recurrenceOverrides.', + }); + }); }); describe('findEventsByUid', () => { diff --git a/src/api/calendar.ts b/src/api/calendar.ts index 318720e3..333ab376 100644 --- a/src/api/calendar.ts +++ b/src/api/calendar.ts @@ -1,7 +1,7 @@ import { jmapClient } from './jmap-client'; import { CAPABILITIES } from './types'; import type { Calendar, CalendarEvent, CalendarRights } from './types'; -import { assertSetResult, batched, jmapErrorType, requireMethodResult } from './jmap-result'; +import { JMAPMethodError, assertSetResult, batched, jmapErrorType, requireMethodResult } from './jmap-result'; import { getEffectiveTimeZone } from '../lib/calendar-timezone'; import { SCAN_PROPERTIES, type ScannedCalendarObject } from '../lib/calendar-component-detection'; import { @@ -910,7 +910,9 @@ export async function updateEvent( ); const result = methodResult<{ notUpdated?: Record }>(res); const err = result.notUpdated?.[id]; - if (err) throw new Error(err.description || err.type || 'Failed to update event'); + // Keep the SetError type: the store retries some refusals differently + // (isOverrideSeriesKeyRejection). + if (err) throw new JMAPMethodError(err.type ?? 'setError', err.description || err.type || 'Failed to update event'); } export async function deleteEvents( diff --git a/src/components/calendar/EventModal.tsx b/src/components/calendar/EventModal.tsx index 9ab782b4..c9acce60 100644 --- a/src/components/calendar/EventModal.tsx +++ b/src/components/calendar/EventModal.tsx @@ -58,8 +58,7 @@ import { } from '../../lib/calendar-alerts'; import { buildRecurrenceSummary, isSimpleRecurrenceRule } from '../../lib/recurrence'; import { - buildParticipantMap, - getParticipantList, + participantFieldsToSave, seedAttendees, type Attendee, } from '../../lib/calendar-participants'; @@ -327,28 +326,12 @@ export function EventModal({ locations: clearedOr(buildLocations(location), 'locations'), virtualLocations: clearedOr(buildVirtualLocations(videoUrl), 'virtualLocations'), }; - const organizerEmail = currentUserEmails[0]; - if (attendees.length > 0 && organizerEmail) { - // A new event has no participants yet, so it would store an empty - // organizer name; fall back to the contact card / account name. - const organizerName = - (event ? getParticipantList(event).find((p) => p.isOrganizer)?.name : '') - || resolveContactName(organizerEmail) - || ''; - data.participants = buildParticipantMap( - { name: organizerName, email: organizerEmail }, - attendees, - ); - // Stalwart (calcard) derives the iCalendar ORGANIZER property solely from - // organizerCalendarAddress; without it no ORGANIZER is emitted and iTIP - // scheduling is silently skipped, so no invites are sent. - data.organizerCalendarAddress = `mailto:${organizerEmail}`; - } else if (attendees.length === 0 && isEdit && had('participants')) { - data.participants = null; - // Also clear the retired replyTo that older releases wrote. - if (event?.replyTo) data.replyTo = null; - if (event?.organizerCalendarAddress) data.organizerCalendarAddress = null; - } + // An invitation the user received keeps its organizer; only an event + // without one is organized by the user. + Object.assign(data, participantFieldsToSave(event, attendees, { + userEmail: currentUserEmails[0], + resolveName: resolveContactName, + })); if (data.recurrenceRules === null) { // Dropping the rule leaves per-occurrence data dangling. if (had('recurrenceOverrides')) data.recurrenceOverrides = null; diff --git a/src/lib/__tests__/calendar-participants.test.ts b/src/lib/__tests__/calendar-participants.test.ts index a5819ec1..2b6a6b86 100644 --- a/src/lib/__tests__/calendar-participants.test.ts +++ b/src/lib/__tests__/calendar-participants.test.ts @@ -8,6 +8,7 @@ import { getStatusCounts, getUserParticipantId, isOrganizer, + participantFieldsToSave, seedAttendees, } from '../calendar-participants'; @@ -171,3 +172,84 @@ describe('getParticipantList dedupe, names and organizer status', () => { })).toBe(3); }); }); + +// The editor rebuilds the participants on every save. It used to make the +// user the organizer of every event with attendees, invitations they only +// received included, and to write their first address as +// organizerCalendarAddress - which Stalwart 1.0 also refuses on one +// occurrence of a series organized under another address or letter case. +describe('participantFieldsToSave', () => { + const attendees = [{ name: 'Bob', email: 'bob@example.com' }]; + const owner = (fields: ReturnType) => + Object.values(fields.participants ?? {}).find((p) => p.roles?.owner); + + it('makes the user the organizer of a new event and writes the address', () => { + const fields = participantFieldsToSave(null, attendees, { + userEmail: 'me@example.com', + resolveName: (email) => (email === 'me@example.com' ? 'Me' : undefined), + }); + + expect(owner(fields)).toMatchObject({ email: 'me@example.com', name: 'Me' }); + expect(fields.organizerCalendarAddress).toBe('mailto:me@example.com'); + }); + + it('keeps the organizer of an invitation the user received', () => { + const invitation: Partial = { + organizerCalendarAddress: 'mailto:boss@example.com', + participants: { + boss: { calendarAddress: 'mailto:boss@example.com', name: 'Boss' }, + me: { calendarAddress: 'mailto:me@example.com', roles: { attendee: true }, participationStatus: 'accepted' }, + }, + }; + const rows = seedAttendees(invitation, ['me@example.com']); + + const fields = participantFieldsToSave(invitation, rows, { userEmail: 'me@example.com' }); + + expect(owner(fields)).toMatchObject({ email: 'boss@example.com', name: 'Boss', calendarAddress: 'mailto:boss@example.com' }); + expect(Object.values(fields.participants ?? {}).map((p) => p.email).sort()).toEqual(['boss@example.com', 'me@example.com']); + expect(fields).not.toHaveProperty('organizerCalendarAddress'); + }); + + it('keeps the exact address a series is organized under', () => { + const series: Partial = { + organizerCalendarAddress: 'mailto:Team@Example.com', + participants: { org: { calendarAddress: 'mailto:Team@Example.com' }, bob: { email: 'bob@example.com' } }, + }; + + const fields = participantFieldsToSave(series, attendees, { userEmail: 'me@example.com' }); + + expect(owner(fields)?.email).toBe('Team@Example.com'); + expect(fields).not.toHaveProperty('organizerCalendarAddress'); + }); + + it('takes the organizer from organizerCalendarAddress when no participant names it', () => { + const fields = participantFieldsToSave( + { organizerCalendarAddress: 'mailto:boss@example.com', participants: {} }, + attendees, + { userEmail: 'me@example.com' }, + ); + + expect(owner(fields)?.email).toBe('boss@example.com'); + expect(fields).not.toHaveProperty('organizerCalendarAddress'); + }); + + it('makes the user the organizer of an event that had none and writes the address', () => { + const fields = participantFieldsToSave({ title: 'Lunch' }, attendees, { userEmail: 'me@example.com' }); + + expect(owner(fields)?.email).toBe('me@example.com'); + expect(fields.organizerCalendarAddress).toBe('mailto:me@example.com'); + }); + + it('clears the participants and the organizer when every attendee is removed', () => { + expect(participantFieldsToSave(event, [], { userEmail: 'alice@example.com' })).toEqual({ + participants: null, + organizerCalendarAddress: null, + }); + expect(participantFieldsToSave( + { participants: event.participants, replyTo: { imip: 'mailto:alice@example.com' } }, + [], + { userEmail: 'alice@example.com' }, + )).toEqual({ participants: null, replyTo: null }); + expect(participantFieldsToSave({ title: 'Lunch' }, [], { userEmail: 'me@example.com' })).toEqual({}); + }); +}); diff --git a/src/lib/__tests__/recurrence-instances.test.ts b/src/lib/__tests__/recurrence-instances.test.ts index 19059d14..534a2238 100644 --- a/src/lib/__tests__/recurrence-instances.test.ts +++ b/src/lib/__tests__/recurrence-instances.test.ts @@ -7,6 +7,7 @@ import { buildOccurrenceRsvpPatch, hydrateRecurrenceInstances, isBrowserExpandedOccurrence, + isOverrideSeriesKeyRejection, isServerRecurrenceInstance, isSyntheticIdMutationUnsupported, mergeOverrideParticipants, @@ -14,8 +15,10 @@ import { seriesIdOf, stableOccurrenceKey, withNewOverrideDetails, + withoutOverrideSeriesKeys, } from '../recurrence-instances'; import { expandRecurringEvents } from '../recurrence-expansion'; +import { JMAPMethodError } from '../../api/jmap-result'; import type { CalendarEvent } from '../../api/types'; function ev(partial: Partial): CalendarEvent { @@ -231,7 +234,7 @@ describe('changing one occurrence', () => { }); }); - it('answers one occurrence with the whole participant map and the organizer', () => { + it('answers one occurrence with the whole participant map', () => { const occurrence = ev({ id: 's1', baseEventId: 'm', recurrenceId: '2026-03-02T09:00:00', title: 'Standup', organizerCalendarAddress: 'mailto:org@x', sequence: 1, @@ -241,15 +244,130 @@ describe('changing one occurrence', () => { me: { calendarAddress: 'mailto:me@x', participationStatus: 'needs-action' }, } as any, }); + const participants = { + org: { calendarAddress: 'mailto:org@x', participationStatus: 'accepted' }, + me: { calendarAddress: 'mailto:me@x', participationStatus: 'accepted' }, + }; - expect(buildOccurrenceRsvpPatch(occurrence, 'me', 'accepted')).toEqual({ + // Through a synthetic id: the organizer is the series', which Stalwart + // 1.0 checks against the base event. + expect(buildOccurrenceRsvpPatch(occurrence, 'me', 'accepted')).toEqual({ sequence: 1, participants }); + // In an override on the base event: 0.16.19 needs the organizer there. + expect(buildOccurrenceRsvpPatch(occurrence, 'me', 'accepted', { seriesKeys: true })).toEqual({ sequence: 1, organizerCalendarAddress: 'mailto:org@x', - participants: { - org: { calendarAddress: 'mailto:org@x', participationStatus: 'accepted' }, - me: { calendarAddress: 'mailto:me@x', participationStatus: 'accepted' }, - }, + participants, }); expect(buildOccurrenceRsvpPatch(occurrence, 'nobody', 'accepted')).toBeNull(); }); }); + +// 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 on the base event it is refused whatever +// its value. 0.16.20-0.16.23 drop such keys silently; older 0.16 store them. +describe('series keys on one occurrence', () => { + it('leaves the series keys and pointers at an address out of an occurrence patch', () => { + 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', + accountId: 'acc-2', + } 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', + }); + }); + + it('keeps the organizer in an override on the base event, as 0.16 has always been sent it', () => { + const occurrence = ev({ + id: 'm:2026-03-03T09:00:00', originalId: 'm', recurrenceId: '2026-03-03T09:00:00', + start: '2026-03-03T09:00:00', duration: 'PT1H', recurrenceRules: [{ frequency: 'daily' } as any], + recurrenceOverrides: null, + }); + + expect(buildFallbackOverridePatch(occurrence, { + title: 'x', organizerCalendarAddress: 'mailto:org@example.com', uid: 'u', calendarIds: { c: true }, + })).toEqual({ + recurrenceOverrides: { + '2026-03-03T09:00:00': { + start: '2026-03-03T09:00:00', duration: 'PT1H', title: 'x', organizerCalendarAddress: 'mailto:org@example.com', + }, + }, + }); + }); + + it('copies the organizer into a new override only when it goes on the base event', () => { + const occurrence = ev({ + id: 's1', baseEventId: 'm', recurrenceId: '2026-03-02T09:00:00', title: 'Standup', + organizerCalendarAddress: 'mailto:org@example.com', recurrenceOverrides: null, + }); + + expect(withNewOverrideDetails(occurrence, { start: 'x' })).not.toHaveProperty('organizerCalendarAddress'); + expect(withNewOverrideDetails(occurrence, { start: 'x' }, { seriesKeys: true })).toMatchObject({ + organizerCalendarAddress: 'mailto:org@example.com', + }); + }); + + it('takes the series and per-event keys out of every override an update writes', () => { + expect(withoutOverrideSeriesKeys({ + title: 'Series title', + organizerCalendarAddress: 'mailto:org@example.com', + recurrenceOverrides: { + '2026-09-08T10:00:00': { title: 'Moved', privacy: 'public', organizerCalendarAddress: 'mailto:o@example.com' }, + '2026-09-09T10:00:00': { excluded: true }, + }, + })).toEqual({ + title: 'Series title', + organizerCalendarAddress: 'mailto:org@example.com', + recurrenceOverrides: { + '2026-09-08T10:00:00': { title: 'Moved' }, + '2026-09-09T10:00:00': { excluded: true }, + }, + }); + }); + + it('takes them out of an override entry and of 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, + })).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, + }); + }); + + it('recognises Stalwart 1.0 refusing a series key inside an override', () => { + expect(isOverrideSeriesKeyRejection( + new JMAPMethodError('invalidProperties', 'organizerCalendarAddress cannot be set inside recurrenceOverrides.'), + )).toBe(true); + expect(isOverrideSeriesKeyRejection( + new JMAPMethodError('invalidProperties', 'This property cannot be modified on a single occurrence.'), + )).toBe(false); + // Without the SetError type it is not recognised. + expect(isOverrideSeriesKeyRejection(new Error('privacy cannot be set inside recurrenceOverrides.'))).toBe(false); + }); +}); diff --git a/src/lib/calendar-participants.ts b/src/lib/calendar-participants.ts index ec7842ab..826fbeaf 100644 --- a/src/lib/calendar-participants.ts +++ b/src/lib/calendar-participants.ts @@ -296,6 +296,68 @@ export function buildParticipantMap( return participants; } +/** + * The event's own organizer: the organizer participant, else the address in + * `organizerCalendarAddress` or the retired `replyTo`. `null` for an event + * without one. + */ +function eventOrganizer(event: Partial): { name: string; email: string } | null { + const participant = getParticipantList(event).find((p) => p.isOrganizer && p.email.trim()); + if (participant) return { name: participant.name, email: participant.email.trim() }; + const address = event.organizerCalendarAddress || Object.values(event.replyTo ?? {})[0]; + const email = address?.replace(/^mailto:/i, '').trim(); + return email ? { name: '', email } : null; +} + +export interface ParticipantSaveContext { + /** The user's first calendar address: the organizer of invitations sent from the editor. */ + userEmail: string | undefined; + /** A name for an organizer the event data carries none for (the contact card, the account). */ + resolveName?: (email: string) => string | undefined; +} + +/** + * The participant fields an editor save writes. + * + * With attendees, the participants are rebuilt around the event's own + * organizer: an invitation keeps whoever sent it, and a series the exact + * address it is organized under (Stalwart 1.0 checks it on single + * occurrences). Only an event without an organizer gets the user, and only + * then `organizerCalendarAddress` is written: Stalwart derives the iCalendar + * ORGANIZER solely from it, so without one no invitations go out. + * + * Without attendees, an event that had participants loses them, its + * organizer and the retired `replyTo` older releases wrote. + */ +export function participantFieldsToSave( + event: Partial | null | undefined, + attendees: Attendee[], + context: ParticipantSaveContext, +): Partial> { + const { userEmail, resolveName } = context; + if (attendees.length > 0 && userEmail) { + const organizer = (event ? eventOrganizer(event) : null) ?? { name: '', email: userEmail }; + const fields: Partial> = { + participants: buildParticipantMap( + // A new event has no participants yet, so it would store an empty + // organizer name. + { name: organizer.name || resolveName?.(organizer.email) || '', email: organizer.email }, + attendees, + ), + }; + if (!event?.organizerCalendarAddress) fields.organizerCalendarAddress = `mailto:${organizer.email}`; + return fields; + } + if (attendees.length === 0 && event?.participants && Object.keys(event.participants).length > 0) { + return { + participants: null, + ...(event.replyTo ? { replyTo: null } : {}), + ...(event.organizerCalendarAddress ? { organizerCalendarAddress: null } : {}), + }; + } + return {}; +} + /** * The attendee rows an editor should start from: every participant except the * organizer (who is re-added by buildParticipantMap on save), without diff --git a/src/lib/recurrence-instances.ts b/src/lib/recurrence-instances.ts index d5521333..8575b316 100644 --- a/src/lib/recurrence-instances.ts +++ b/src/lib/recurrence-instances.ts @@ -21,6 +21,7 @@ */ import type { CalendarEvent, Participant } from '../api/types'; +import { jmapErrorType } from '../api/jmap-result'; import { RECURRENCE_OVERRIDE_IMMUTABLE_KEYS } from './recurrence-overrides'; /** @@ -282,6 +283,35 @@ export const OCCURRENCE_REJECTED_KEYS = [ 'hideAttendees', ] as const; +/** + * Properties of the whole series, plus the app'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; + +// Fields the app keeps on its events that no server knows. +const CLIENT_ONLY_KEYS = ['originalId', 'originalCalendarIds', 'accountId', 'isShared', 'localAccountId'] as const; + /** * Whether a `CalendarEvent/set` failure is a pre-0.16.20 server refusing a * synthetic id ("Updating/Deleting synthetic ids is not yet supported."). @@ -293,39 +323,95 @@ export function isSyntheticIdMutationUnsupported(error: unknown): boolean { return /synthetic ids?\b.*\bnot (?:yet )?supported/i.test(message); } +/** + * 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 : ''); +} + function firstPointerSegment(key: string): string { const slash = key.indexOf('/'); return slash === -1 ? key : key.slice(0, slash); } +const NOT_PER_OCCURRENCE = new Set([...SERIES_KEYS, ...EVENT_METADATA_KEYS, ...CLIENT_ONLY_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', - // Client-only fields. - 'originalId', - 'originalCalendarIds', - 'accountId', - 'isShared', - 'localAccountId', - ]); 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', + ...CLIENT_ONLY_KEYS, +]); + +/** + * 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 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: Record): Record { + 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; +} + /** * An occurrence of a series that the device expanded itself * (lib/recurrence-expansion.ts), as opposed to one the server handed out. @@ -397,7 +483,7 @@ export function buildFallbackOverridePatch( ...kept, start: instance.start, duration: instance.duration, - ...buildOccurrencePatch(patch), + ...overrideEntryFields(patch), }); } @@ -433,13 +519,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 @@ -449,11 +546,15 @@ const NEW_OVERRIDE_COPIED_KEYS = [ export function withNewOverrideDetails( occurrence: Partial, patch: Partial, + options: OverridePatchOptions = {}, ): Partial { if (!createsOverride(occurrence)) return patch; + const names: readonly string[] = options.seriesKeys + ? [...NEW_OVERRIDE_COPIED_KEYS, ...NEW_OVERRIDE_SERIES_KEYS] + : NEW_OVERRIDE_COPIED_KEYS; const source = occurrence as Record; const details: Record = {}; - for (const name of NEW_OVERRIDE_COPIED_KEYS) { + for (const name of names) { if (!(name in patch) && source[name] != null) details[name] = source[name]; } return { ...details, ...patch } as Partial; @@ -467,7 +568,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`). */ @@ -475,17 +578,18 @@ export function buildOccurrenceRsvpPatch( occurrence: Partial, participantId: string, status: Participant['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/src/stores/__tests__/calendar-store.test.ts b/src/stores/__tests__/calendar-store.test.ts index 80477afd..49f98407 100644 --- a/src/stores/__tests__/calendar-store.test.ts +++ b/src/stores/__tests__/calendar-store.test.ts @@ -37,6 +37,7 @@ vi.mock('../../api/jmap-client', () => ({ })); import * as calendarApi from '../../api/calendar'; +import { JMAPMethodError } from '../../api/jmap-result'; import { useCalendarStore, selectVisibleCalendars, @@ -928,11 +929,11 @@ describe('calendar-store', () => { await useCalendarStore.getState().rsvpEvent('s1', 'me', 'declined', undefined, undefined, 'occurrence'); expect(mockRsvpEvent).not.toHaveBeenCalled(); - // The whole participants map with the organizer, as Stalwart needs it - // on one occurrence; sent with scheduling so the organizer hears back. + // The whole participants map, as Stalwart needs it on one occurrence, + // without the organizer (the series'); sent with scheduling so the + // organizer hears back. expect(mockUpdateEvent).toHaveBeenCalledWith('s1', { sequence: 4, - organizerCalendarAddress: 'mailto:org@x', participants: { ...participants, me: { ...participants.me, participationStatus: 'declined' } }, }, true, undefined); expect(useCalendarStore.getState().events[0].participants?.me.participationStatus).toBe('declined'); @@ -1142,3 +1143,161 @@ describe('calendar-store', () => { }); }); }); + +// 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 of a series', () => { + const SERIES_PROPERTIES = [ + '@type', 'method', 'organizerCalendarAddress', 'privacy', 'prodId', 'recurrenceId', + 'recurrenceIdTimeZone', 'sentBy', 'uid', 'recurrenceOverrides', 'recurrenceRule', + ]; + // The stored series, organized as Apple Calendar writes it (no CLASS). + const BASE: Record = { id: 'base', uid: 'series@example.com', organizerCalendarAddress: 'mailto:org@example.com' }; + const participants = { + org: { calendarAddress: 'mailto:org@example.com', roles: { owner: true }, participationStatus: 'accepted' }, + me: { calendarAddress: 'mailto:me@example.com', participationStatus: 'needs-action' }, + }; + + const seriesKeysIn = (entry: unknown) => Object.keys((entry ?? {}) as object) + .filter((key) => SERIES_PROPERTIES.includes(key.split('/')[0])); + + /** CalendarEvent/set updates of a Stalwart 1.0 server holding BASE. */ + function stalwart10Update() { + return 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 new JMAPMethodError('invalidProperties', 'This property cannot be modified on a single occurrence.'); + } + } + 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 new JMAPMethodError('invalidProperties', `${refused} cannot be set inside recurrenceOverrides.`); + } + }; + } + + const serverOccurrence = (partial: Record = {}) => ({ + id: 's1', baseEventId: 'base', uid: 'series@example.com', recurrenceId: '2026-03-02T09:00:00', + start: '2026-03-02T09:00:00', duration: 'PT1H', title: 'Standup', recurrenceRules: [{ frequency: 'weekly' }], + recurrenceOverrides: { '2026-03-02T09:00:00': { title: 'Standup' } }, calendarIds: { 'cal-1': true }, + organizerCalendarAddress: 'mailto:org@example.com', participants, sequence: 2, + ...partial, + }); + // Expanded on the device (a server without synthetic ids, or a range the + // server would not expand): changes become overrides on the base event. + const deviceOccurrence = (partial: Record = {}) => ({ + id: 'base:2026-03-02T09:00:00', originalId: 'base', uid: 'series@example.com', recurrenceId: '2026-03-02T09:00:00', + start: '2026-03-02T09:00:00', duration: 'PT1H', title: 'Standup', recurrenceRules: [{ frequency: 'weekly' }], + recurrenceOverrides: null, calendarIds: { 'cal-1': true }, + organizerCalendarAddress: 'mailto:org@example.com', participants, sequence: 2, + ...partial, + }); + + beforeEach(() => { + useCalendarStore.getState().reset(); + useCalendarStore.setState({ calendars: [{ id: 'cal-1' }] as any }); + mockQueryEvents.mockResolvedValue([]); + }); + + it('sends one occurrence nothing of the series through its synthetic id on Stalwart 1.0', async () => { + mockUpdateEvent.mockImplementation(stalwart10Update()); + useCalendarStore.setState({ events: [serverOccurrence() as any] }); + + // What the editor sent before: the organizer in another spelling, which + // an alias or a letter case gives. + await useCalendarStore.getState().updateEvent('s1', { + title: 'Renamed', organizerCalendarAddress: 'mailto:ORG@example.com', calendarIds: { 'cal-1': true }, + }); + + expect(mockUpdateEvent).toHaveBeenCalledTimes(1); + const [id, patch] = mockUpdateEvent.mock.calls[0]; + expect(id).toBe('s1'); + expect(seriesKeysIn(patch)).toEqual([]); + expect(patch.title).toBe('Renamed'); + }); + + it('answers one occurrence without the organizer through its synthetic id on Stalwart 1.0', async () => { + mockUpdateEvent.mockImplementation(stalwart10Update()); + useCalendarStore.setState({ events: [serverOccurrence() as any] }); + + await useCalendarStore.getState().rsvpEvent('s1', 'me', 'declined', undefined, undefined, 'occurrence'); + + expect(mockUpdateEvent).toHaveBeenCalledTimes(1); + expect(mockUpdateEvent.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 () => { + mockUpdateEvent.mockImplementation(stalwart10Update()); + useCalendarStore.setState({ events: [deviceOccurrence() as any] }); + + await useCalendarStore.getState().updateEvent('base:2026-03-02T09:00:00', { title: 'Moved' }); + + expect(mockUpdateEvent).toHaveBeenCalledTimes(2); + const [first, second] = mockUpdateEvent.mock.calls.map((call) => call[1].recurrenceOverrides['2026-03-02T09:00:00']); + expect(first).toMatchObject({ organizerCalendarAddress: 'mailto:org@example.com' }); + expect(second).toEqual({ + start: '2026-03-02T09:00:00', duration: 'PT1H', title: 'Moved', participants, sequence: 2, + }); + + // The next override goes out without them straight away. + mockUpdateEvent.mockClear(); + await useCalendarStore.getState().rsvpEvent('base:2026-03-02T09:00:00', 'me', 'accepted', undefined, undefined, 'occurrence'); + expect(mockUpdateEvent).toHaveBeenCalledTimes(1); + expect(seriesKeysIn(mockUpdateEvent.mock.calls[0][1].recurrenceOverrides['2026-03-02T09:00:00'])).toEqual([]); + }); + + it('keeps the override payload on 0.16: the organizer, in one write', async () => { + mockUpdateEvent.mockImplementation(async () => undefined); + useCalendarStore.setState({ events: [deviceOccurrence() as any] }); + + await useCalendarStore.getState().rsvpEvent('base:2026-03-02T09:00:00', 'me', 'tentative', undefined, undefined, 'occurrence'); + + expect(mockUpdateEvent).toHaveBeenCalledTimes(1); + expect(mockUpdateEvent).toHaveBeenCalledWith('base', { + recurrenceOverrides: { + '2026-03-02T09:00:00': { + start: '2026-03-02T09:00:00', + duration: 'PT1H', + title: 'Standup', + sequence: 2, + organizerCalendarAddress: 'mailto:org@example.com', + participants: { ...participants, me: { ...participants.me, participationStatus: 'tentative' } }, + }, + }, + }, true, undefined); + }); + + it('keeps the 0.16.19 fallback payload for a server that rejects synthetic ids', async () => { + mockUpdateEvent + .mockRejectedValueOnce(new Error('Updating synthetic ids is not yet supported.')) + .mockImplementation(async () => undefined); + useCalendarStore.setState({ events: [serverOccurrence({ recurrenceOverrides: { '2026-03-09T09:00:00': {} } }) as any] }); + + await useCalendarStore.getState().rsvpEvent('s1', 'me', 'accepted', undefined, undefined, 'occurrence'); + + // The synthetic attempt leaves the organizer out, the override has it. + expect(mockUpdateEvent.mock.calls[0][1]).not.toHaveProperty('organizerCalendarAddress'); + expect(mockUpdateEvent.mock.calls[1][0]).toBe('base'); + expect(mockUpdateEvent.mock.calls[1][1]['recurrenceOverrides/2026-03-02T09:00:00']) + .toMatchObject({ organizerCalendarAddress: 'mailto:org@example.com' }); + }); + + it('passes other refusals of an override on the base event through on Stalwart 1.0', async () => { + mockUpdateEvent.mockImplementation(async () => { + throw new JMAPMethodError('forbidden', 'You do not have access to this calendar.'); + }); + useCalendarStore.setState({ events: [deviceOccurrence() as any] }); + + await expect(useCalendarStore.getState().updateEvent('base:2026-03-02T09:00:00', { title: 'Moved' })) + .rejects.toThrow('You do not have access to this calendar.'); + expect(mockUpdateEvent).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/stores/calendar-store.ts b/src/stores/calendar-store.ts index 5c1e2561..2a2c4b8f 100644 --- a/src/stores/calendar-store.ts +++ b/src/stores/calendar-store.ts @@ -42,10 +42,13 @@ import { buildOccurrencePatch, buildOccurrenceRsvpPatch, isBrowserExpandedOccurrence, + isOverrideSeriesKeyRejection, isServerRecurrenceInstance, isSyntheticIdMutationUnsupported, seriesIdOf, withNewOverrideDetails, + withoutOverrideSeriesKeys, + type OverridePatchOptions, } from '../lib/recurrence-instances'; import { findTasksOnlyCalendarIds, isTaskLikeObject } from '../lib/calendar-component-detection'; import { mergeRangeEvents, mergedSpanLimit, planRangeLoad, sameRange } from '../lib/calendar-range-cache'; @@ -443,33 +446,64 @@ export function resolveMutationTarget( // said it would take them: go straight to the base-event override then. let syntheticIdRejected = false; +// Set once the server refused series keys inside a recurrence override +// (Stalwart 1.0): override writes leave them out from then on. +let overrideSeriesKeysRejected = false; + /** - * Patch one server-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, and that is - * remembered. 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 (the organizer; see buildFallbackOverridePatch). + * Stalwart 1.0 refuses those inside an override ("organizerCalendarAddress + * cannot be set inside recurrenceOverrides."): the update then goes out once + * more without them, and so does every later one. + */ +async function updateBaseEvent( + id: string, + updates: Record, + sendSchedulingMessages: boolean | undefined, + accountId: string | undefined, +): Promise { + const writesOverrides = Object.keys(updates).some((key) => key.split('/', 1)[0] === 'recurrenceOverrides'); + if (writesOverrides && overrideSeriesKeysRejected) { + await apiUpdateEvent(id, withoutOverrideSeriesKeys(updates), sendSchedulingMessages, accountId); + return; + } + try { + await apiUpdateEvent(id, updates, sendSchedulingMessages, accountId); + } catch (err) { + if (!writesOverrides || !isOverrideSeriesKeyRejection(err)) throw err; + overrideSeriesKeysRejected = true; + await apiUpdateEvent(id, withoutOverrideSeriesKeys(updates), sendSchedulingMessages, accountId); + } +} + +/** + * Patch one server-expanded occurrence through its synthetic id, with only + * what one occurrence may carry (`buildOccurrencePatch`). A server that + * predates synthetic-id writes rejects it; the same change is then written + * as a recurrence override on the base event instead, and that is + * remembered. `patchFor` builds the change; the base-event override asks it + * for the series keys as well. */ async function updateOccurrence( instance: CalendarEvent, syntheticId: string, - updates: Partial, + patchFor: (options: OverridePatchOptions) => Partial, sendSchedulingMessages: boolean | undefined, accountId: string | undefined, ): Promise { - const patch = withNewOverrideDetails(instance, buildOccurrencePatch(updates)); if (!syntheticIdRejected) { try { - await apiUpdateEvent(syntheticId, patch, sendSchedulingMessages, accountId); + await apiUpdateEvent(syntheticId, buildOccurrencePatch(patchFor({})), sendSchedulingMessages, accountId); return; } catch (err) { if (!isSyntheticIdMutationUnsupported(err)) throw err; syntheticIdRejected = true; } } - 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 apiUpdateEvent(instance.baseEventId, fallback, sendSchedulingMessages, accountId); + await updateBaseEvent(instance.baseEventId, fallback, sendSchedulingMessages, accountId); } /** Destroy one server-expanded occurrence; falls back to excluding it on the base event. */ @@ -504,9 +538,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 apiUpdateEvent(target.realId, patch, sendSchedulingMessages, target.accountId); + await updateBaseEvent(target.realId, patch, sendSchedulingMessages, target.accountId); } /** Delete one occurrence the device expanded by excluding it on its base event. */ @@ -812,11 +846,17 @@ export const useCalendarStore = create()( const touchesSeries = (!!storeEvent && isRecurringSeriesMember(storeEvent)) || hasServerOccurrencesOf(get().events, realId, accountId); if (target.isOccurrence && storeEvent) { - await updateOccurrence(storeEvent, realId, patch as Partial, schedule, accountId); + await updateOccurrence( + storeEvent, + realId, + (options) => withNewOverrideDetails(storeEvent, patch as Partial, options), + schedule, + accountId, + ); } else if (target.isBrowserOccurrence) { await updateBrowserOccurrence(target, patch as Partial, schedule); } else { - await apiUpdateEvent(realId, patch, schedule, accountId); + await updateBaseEvent(realId, patch, schedule, accountId); } set({ events: get().events.map((e) => (e.id === id ? { ...e, ...(changes as Partial) } : e)), @@ -902,10 +942,14 @@ export const useCalendarStore = create()( const touchesSeries = (!!storeEvent && isRecurringSeriesMember(storeEvent)) || hasServerOccurrencesOf(get().events, seriesIdOf(storeEvent ?? { id: realId }), accountId); if (occurrence) { - const patch = buildOccurrenceRsvpPatch(occurrence, participantId, status); + // Through a synthetic id the answer leaves the organizer out (it is the + // series'); an override on the base event names it (0.16.19 needs it). + 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(occurrence, target.realId, patch, true, accountId); + await updateOccurrence(occurrence, target.realId, (options) => patchFor(options) ?? {}, true, accountId); } else { await updateBrowserOccurrence(target, patch, true); } @@ -1144,6 +1188,7 @@ export const useCalendarStore = create()( resetCalendarAccessDenied(); resetSyntheticIdSupport(); syntheticIdRejected = false; + overrideSeriesKeysRejected = false; knownMasters.clear(); set({ calendars: [],