From d3262ff387fd38a322575ce07974e6e615a039f6 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 27 Sep 2026 06:59:48 +0200 Subject: [PATCH] fix: send an answer to one occurrence in an RSVP-only calendar #34 In a calendar the user may only answer (mayRSVP without write rights), an answer to the whole series was uploaded, but an answer to a single occurrence goes into the occurrence's override and counted as an edit: it was undone on the next sync without a word. A change that holds nothing but the user's own answer, to the series or to occurrences, now counts as an RSVP and is uploaded. Stalwart 0.16.23 refuses every update in a shared calendar without write rights, answers included; the engine then puts the rows back from the server and reports the refusal, as it already did for an answer to the series, so the answer is no longer lost silently. Checked with a planner test (a new and an existing override, and an extra edit that is still reverted) and a run through the engine and the fake server. --- .../__tests__/calendar/engine.test.ts | 37 +++++++++++++++++++ .../__tests__/calendar/exceptions.test.ts | 27 ++++++++++++++ src/device-sync/calendar/planner.ts | 5 +-- src/device-sync/calendar/upload.ts | 15 ++++++-- 4 files changed, 77 insertions(+), 7 deletions(-) diff --git a/src/device-sync/__tests__/calendar/engine.test.ts b/src/device-sync/__tests__/calendar/engine.test.ts index e24bef26..836abbb3 100644 --- a/src/device-sync/__tests__/calendar/engine.test.ts +++ b/src/device-sync/__tests__/calendar/engine.test.ts @@ -94,6 +94,43 @@ describe('calendar device edits through the engine', () => { expect(h.batches.log.slice(before).filter((ops) => ops.some((op) => op.op !== 'syncState' && op.op !== 'assert'))).toEqual([]); }); + it('sends an answer to one occurrence in a calendar that grants only RSVP, and reports a refusal', async () => { + const h = real({ accounts: 'withShared' }); + const team = h.server.addCalendar('team', { + name: 'Team events', + myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, mayUpdatePrivate: false, mayRSVP: true, mayShare: false, mayDelete: false }, + }); + h.prefs.calendarSelection[`team/${team}`] = true; + const id = h.server.addEvent('team', { + uid: 'team-weekly', + title: 'Team weekly', + start: '2026-10-05T09:00:00', + duration: 'PT1H', + timeZone: 'Europe/Berlin', + recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly', count: 5 }, + organizerCalendarAddress: 'mailto:boss@example.net', + participants: { + boss: { '@type': 'Participant', calendarAddress: 'mailto:boss@example.net', roles: { owner: true }, participationStatus: 'accepted' }, + me: { '@type': 'Participant', calendarAddress: 'mailto:alice@example.com', roles: { attendee: true }, participationStatus: 'needs-action' }, + }, + calendarIds: { [team]: true }, + }); + await h.run(CALENDAR_AUTHORITY); + const master = h.events().find((e) => e[Events._SYNC_ID] === `team/${id}`)!; + + // Etar answers the Oct 12 occurrence through CONTENT_EXCEPTION_URI. + h.device.user.exceptionViaUri(Number(master._id), Date.UTC(2026, 9, 12, 7), { [Events.SELF_ATTENDEE_STATUS]: 2, [Events.STATUS]: 1 }); + const sets = () => h.server.requests.filter((r) => r.methods.includes('CalendarEvent/set')).length; + const before = sets(); + const report = await h.run(CALENDAR_AUTHORITY); + + // The answer was sent (this server takes no RSVP without write rights), the refusal is reported and the row put back. + expect(sets()).toBeGreaterThan(before); + expect(report.itemErrors).toEqual([expect.objectContaining({ ref: `team/${id}`, side: 'upload', type: 'forbidden' })]); + expect(h.events().filter((e) => e[Events.ORIGINAL_SYNC_ID] === `team/${id}`)).toEqual([]); + expect(h.events().every((e) => Number(e[Events.DIRTY] ?? 0) === 0)).toBe(true); + }); + it('keeps a description and location cleared on one occurrence cleared', async () => { const h = real(); const id = h.server.addEvent('a', { diff --git a/src/device-sync/__tests__/calendar/exceptions.test.ts b/src/device-sync/__tests__/calendar/exceptions.test.ts index 7e371a89..965bbf38 100644 --- a/src/device-sync/__tests__/calendar/exceptions.test.ts +++ b/src/device-sync/__tests__/calendar/exceptions.test.ts @@ -225,6 +225,33 @@ describe('calendar planner: recurrence edits made on the device', () => { expect(action.sendSchedulingMessages).toBe(true); }); + it('lets an answer to one occurrence through in a calendar that grants only RSVP, and nothing more', async () => { + const invite = weekly(); + invite.calendarIds = { r: true }; + invite.organizerCalendarAddress = 'mailto:boss@example.net'; + invite.participants = { + boss: { calendarAddress: 'mailto:boss@example.net', roles: { owner: true }, participationStatus: 'accepted' }, + me: { calendarAddress: 'mailto:usera@example.org', roles: { attendee: true }, participationStatus: 'needs-action' }, + }; + const { h, id } = await synced(invite); + // Etar answers Oct 19 through CONTENT_EXCEPTION_URI (a new override)... + const added = h.fake.user.exceptionViaUri(id, utc('2026-10-19T13:00:00Z'), { [Events.SELF_ATTENDEE_STATUS]: 2, [Events.STATUS]: 1 }); + // ... and another app answers the existing override of Oct 7 on its row. + const existing = (await h.local('bl'))!.exceptions.find((x) => x.recurrenceId === '2026-10-07T09:00:00')!; + h.fake.table('attendees').forEach((a) => { + if (a.event_id === existing.eventId && a[Attendees.ATTENDEE_EMAIL] === ME) a[Attendees.ATTENDEE_STATUS] = 1; + }); + h.fake.user.updateEvent(existing.eventId, {}); + const action = patchOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx)); + type Answer = { participants: Record }; + expect((action.patch['recurrenceOverrides/2026-10-19T09:00:00'] as Answer).participants.me.participationStatus).toBe('declined'); + expect((action.patch['recurrenceOverrides/2026-10-07T09:00:00/participants'] as Answer['participants']).me.participationStatus).toBe('accepted'); + expect(action.sendSchedulingMessages).toBe(true); + // Anything more than the answer is still the calendar owner's to change. + h.fake.user.updateEvent(added, { [Events.TITLE]: 'Mine now' }); + expect(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx).kind).toBe('revert'); + }); + it('turns EXDATE entries an app adds or removes into exclusions and re-inclusions', async () => { const { h, id } = await synced(weekly()); h.fake.user.updateEvent(id, { [Events.EXDATE]: '20261012T130000Z\nAmerica/New_York;20261021T090000' }); diff --git a/src/device-sync/calendar/planner.ts b/src/device-sync/calendar/planner.ts index 31e11f68..4d6157ed 100644 --- a/src/device-sync/calendar/planner.ts +++ b/src/device-sync/calendar/planner.ts @@ -308,9 +308,8 @@ function planUpload(local: LocalEvent, ctx: CalendarContext): Plan { return m && computation.patch[k] === true && ctx.isReadOnly(m[1]); }); if ((readOnly && keys.length) || movedToReadOnly) { - // RSVP is the one change a calendar without write rights may take. - const rsvpOnly = keys.length > 0 && keys.every((k) => /^participants\/[^/]+\/participationStatus$/.test(k)); - if (!(rsvpOnly && ctx.calendar(calendar.calendarId)?.myRights?.mayRSVP && !movedToReadOnly)) { + // RSVP, to the series or to occurrences, is the one change a calendar without write rights may take. + if (!(computation.rsvpOnly && ctx.calendar(calendar.calendarId)?.myRights?.mayRSVP && !movedToReadOnly)) { const ops = revertGroup(local, ctx); return ops ? { kind: 'revert', ops } : { kind: 'skip', reason: 'readOnly' }; } diff --git a/src/device-sync/calendar/upload.ts b/src/device-sync/calendar/upload.ts index 4476719e..269924f9 100644 --- a/src/device-sync/calendar/upload.ts +++ b/src/device-sync/calendar/upload.ts @@ -169,6 +169,8 @@ export interface UploadComputation { /** Exception rows whose instance is excluded by the patch, or already was. */ removedExceptions: Set; sendSchedulingMessages: boolean; + /** The patch holds nothing but the user's own answer, to the series or to occurrences (what `mayRSVP` allows). */ + rsvpOnly: boolean; /** Units changed on the device that can't be uploaded (reported when nothing else is). */ dropped: string[]; } @@ -362,6 +364,7 @@ export function computeUpdate(local: LocalEvent, ctx: CalendarContext): UploadCo } // Exception rows. + let answersOnly = true; if (!ruleRemoved && shadow.recurrenceRule) { for (const x of local.exceptions) { const units = exceptionChanges(x, local, shadow, calendarId, zone, overrides, removedExceptions, ctx, lossy); @@ -369,6 +372,7 @@ export function computeUpdate(local: LocalEvent, ctx: CalendarContext): UploadCo exceptionUnits.set(x.eventId, units.units); overridesChanged = true; rsvp = rsvp || units.rsvp; + answersOnly &&= units.rsvp && !units.others && units.units.size === 1 && units.units.has('attendees'); } } } @@ -377,8 +381,9 @@ export function computeUpdate(local: LocalEvent, ctx: CalendarContext): UploadCo const notifying = NOTIFYING_UNITS.some((u) => masterUnits.has(u)) || overridesChanged || attendeeOthers; const sendSchedulingMessages = sendsSchedulingMessages(shadow, { kind: 'update', notifying, rsvp, addsAttendees: attendeeOthers }, ctx); + const rsvpOnly = rsvp && answersOnly && !attendeeOthers && [...masterUnits].every((u) => u.startsWith('attendee:')); - return { patch, masterUnits, exceptionUnits, removedExceptions, sendSchedulingMessages, dropped }; + return { patch, masterUnits, exceptionUnits, removedExceptions, sendSchedulingMessages, dropped, rsvpOnly }; } /** Timing properties that differ from the server's. */ @@ -413,7 +418,7 @@ function exceptionChanges( removed: Set, ctx: CalendarContext, lossy: boolean, -): { units: Set; rsvp: boolean } { +): { units: Set; rsvp: boolean; others?: boolean } { const units = new Set(); const key = x.recurrenceId; if (!key) return { units, rsvp: false }; @@ -439,6 +444,7 @@ function exceptionChanges( const cur = sideOfRow(x); const values: Record = {}; let rsvp = false; + let others = false; const organizer = userIsOrganizer(shadow, ctx); for (const unit of EXCEPTION_COLUMN_UNITS) { @@ -504,6 +510,7 @@ function exceptionChanges( if (result.changes.length) { values.participants = result.participants; rsvp = result.rsvp; + others = result.others; units.add('attendees'); } } @@ -517,12 +524,12 @@ function exceptionChanges( if (existing) { if (units.size) work.props.set(key, { ...(work.props.get(key) ?? {}), ...values }); - return { units, rsvp }; + return { units, rsvp, others }; } // A new override: nothing but its pinned time is still an override (an app saving the instance unchanged). if (!units.size) units.add('new'); work.whole.set(key, newOverride(shadow, key, values)); - return { units, rsvp }; + return { units, rsvp, others }; } /** Units a new exception row inherits from its series when it leaves them empty. */