fix: edit or answer one occurrence of a series in the app on Stalwart 1.0

Stalwart 1.0 refuses series properties such as 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 editor
wrote the user's first address as organizerCalendarAddress on every
save of an event with attendees, so editing one occurrence failed with
"This property cannot be modified on a single occurrence." whenever the
series was organized by someone else, under another alias or in
another letter case. Removing every attendee from one occurrence failed
the same way.

- leave series keys and per-event metadata out of changes to one
  occurrence, as Stalwart's validate.rs lists them, and pointers at a
  participant's calendarAddress
- write overrides on the base event as before (0.16.19 needs the
  organizer there) and, when the server refuses series keys inside
  them, repeat the write once without them and remember that
- answer one occurrence through its synthetic id without the organizer
- let the editor keep the organizer of an event: an invitation the
  user received is no longer rewritten with the user as organizer, and
  organizerCalendarAddress is only written for an event that has none
- keep the SetError type on a refused event update

0.16.20 to 0.16.23 dropped these keys on synthetic ids anyway; the
base-event override payload for older servers is unchanged.
This commit is contained in:
Linus Rath
2026-09-25 20:36:58 +02:00
parent ef6f1540dd
commit 2a3febcc79
9 changed files with 651 additions and 77 deletions
+19
View File
@@ -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', () => {
+4 -2
View File
@@ -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<string, { description?: string; type?: string }> }>(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(