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
This commit is contained in:
Linus Rath
2026-09-25 17:40:49 +02:00
parent c236d828c1
commit 990c5e0f77
8 changed files with 465 additions and 46 deletions
+1 -1
View File
@@ -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 {
+15 -3
View File
@@ -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<Pick<CalendarEvent, "status" | "freeBusyStatus" | "privacy">> {
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) {
@@ -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({});
});
});
+111 -3
View File
@@ -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<CalendarEvent>);
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<CalendarEvent>)).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<CalendarEvent>)).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' });
+4 -2
View File
@@ -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 });
+124 -20
View File
@@ -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<CalendarEvent, 'id'> & Partial<Pick<CalendarEvent, 'originalId' | 'baseEventId'>>;
/**
@@ -104,28 +131,89 @@ function firstPointerSegment(key: string): string {
return slash === -1 ? key : key.slice(0, slash);
}
const NOT_PER_OCCURRENCE = new Set<string>([...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<CalendarEvent>): Partial<CalendarEvent> {
const dropped = new Set<string>([
...RECURRENCE_OVERRIDE_IMMUTABLE_KEYS,
...OCCURRENCE_REJECTED_KEYS,
'recurrenceId',
'recurrenceIdTimeZone',
]);
const patch: Record<string, unknown> = {};
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<CalendarEvent>;
}
const NOT_IN_OVERRIDE_ENTRIES = new Set<string>([
...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<CalendarEvent>): Record<string, unknown> {
const fields: Record<string, unknown> = {};
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<CalendarEvent>): Partial<CalendarEvent> {
const entry = (value: unknown) => value && typeof value === 'object'
? buildOccurrencePatch(value as Partial<CalendarEvent>)
: value;
const result: Record<string, unknown> = {};
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<CalendarEvent>;
}
/**
* 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<CalendarEvent['recurrenceOverrides']>;
/**
@@ -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<CalendarEvent>,
patch: Partial<CalendarEvent>,
options: OverridePatchOptions = {},
): Partial<CalendarEvent> {
if (!createsOverride(occurrence)) return patch;
const names = options.seriesKeys ? [...NEW_OVERRIDE_COPIED_KEYS, ...NEW_OVERRIDE_SERIES_KEYS] : NEW_OVERRIDE_COPIED_KEYS;
const details: Record<string, unknown> = {};
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<CalendarEvent>,
participantId: string,
status: CalendarParticipant['participationStatus'],
options: OverridePatchOptions = {},
): Partial<CalendarEvent> | null {
const participant = occurrence.participants?.[participantId];
if (!participant) return null;
const patch: Record<string, unknown> = {};
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<CalendarEvent>);
return withNewOverrideDetails(occurrence, patch as Partial<CalendarEvent>, options);
}
@@ -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<string, unknown> = { 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<string, unknown>) => {
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' });
});
});
+59 -17
View File
@@ -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<object>();
// 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<object>();
/**
* 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<CalendarEvent>,
sendSchedulingMessages: boolean | undefined,
targetAccountId: string | undefined,
): Promise<void> {
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<CalendarEvent>,
patchFor: (options: OverridePatchOptions) => Partial<CalendarEvent>,
sendSchedulingMessages: boolean | undefined,
targetAccountId: string | undefined,
): Promise<void> {
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<void> {
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<void> {
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<CalendarStore>()(
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 => {