From f04b97d52ac9c6ec4109729bb3aaf401a6444375 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 21 Mar 2026 20:34:27 +0100 Subject: [PATCH] fix: handle updates and deletions for synthetic JMAP IDs in calendar events --- app/[locale]/calendar/page.tsx | 32 +++++++- stores/calendar-store.ts | 144 ++++++++++++++++++++++++--------- 2 files changed, 134 insertions(+), 42 deletions(-) diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx index c2c839e0..8b724919 100644 --- a/app/[locale]/calendar/page.tsx +++ b/app/[locale]/calendar/page.tsx @@ -470,9 +470,22 @@ export default function CalendarPage() { try { if (type === "edit" && updates) { switch (scope) { - case "this": - await updateEvent(client, event.id, updates, sendScheduling); + case "this": { + // Synthetic IDs (from expandRecurrences) can't be updated directly. + // Patch the master event's recurrenceOverrides instead. + const master = await findMasterEvent(event); + if (master && event.recurrenceId) { + const patchUpdates: Record = {}; + for (const [key, value] of Object.entries(updates)) { + if (['id', 'uid', '@type', 'calendarIds', 'recurrenceRules', 'recurrenceOverrides', 'excludedRecurrenceRules'].includes(key)) continue; + patchUpdates[`recurrenceOverrides/${event.recurrenceId}/${key}`] = value; + } + await updateEvent(client, master.id, patchUpdates as Partial, sendScheduling); + } else { + await updateEvent(client, event.id, updates, sendScheduling); + } break; + } case "this_and_future": { const result = await truncateRecurrenceAtEvent(event); if (!result) { @@ -530,9 +543,20 @@ export default function CalendarPage() { toast.success(t("notifications.event_updated")); } else { switch (scope) { - case "this": - await deleteEvent(client, event.id, sendScheduling); + case "this": { + // Synthetic IDs (from expandRecurrences) can't be destroyed directly. + // Exclude the instance via recurrenceOverrides on the master event. + const delMaster = await findMasterEvent(event); + if (delMaster && event.recurrenceId) { + await updateEvent( + client, delMaster.id, + { [`recurrenceOverrides/${event.recurrenceId}`]: { excluded: true } } as Partial, + ); + } else { + await deleteEvent(client, event.id, sendScheduling); + } break; + } case "this_and_future": { const result = await truncateRecurrenceAtEvent(event); if (!result) { diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index 7c6bde93..27f09431 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -172,24 +172,40 @@ export const useCalendarStore = create()( try { await client.updateCalendarEvent(realId, cleanUpdates, sendSchedulingMessages, targetAccountId); } catch (updateError) { - // Stalwart doesn't support updating events created via CalDAV (e.g. by Thunderbird). - // These events have "synthetic" JMAP IDs. Work around by destroying and recreating. + // Stalwart rejects updates to "synthetic" JMAP IDs (CalDAV-created events + // or expanded recurring-event instances returned by expandRecurrences). + // Resolve the real event via a UID query and retry. const message = updateError instanceof Error ? updateError.message : ''; if (message.toLowerCase().includes('synthetic') && storeEvent) { - debug.log('Event has synthetic ID, falling back to destroy+recreate'); - // Merge existing event with updates, strip server-computed fields - const { id: _id, originalId: _oi, originalCalendarIds: _oc, - accountId: _ai, accountName: _an, isShared: _is, - utcStart: _us, utcEnd: _ue, isDraft: _dr, isOrigin: _io, - created: _cr, updated: _up, ...baseEvent } = storeEvent; - const mergedEvent: Partial = { ...baseEvent, ...cleanUpdates }; - // Delete the old event without sending cancellations (we're recreating it) - await client.deleteCalendarEvent(realId, false, targetAccountId); - const created = await client.createCalendarEvent(mergedEvent, sendSchedulingMessages, targetAccountId); - set((state) => ({ - events: state.events.map(e => e.id === id ? created : e), - })); - return; + debug.log('Event has synthetic ID, resolving real ID via UID query'); + const queryResults = await client.queryCalendarEvents( + { uid: storeEvent.uid }, undefined, undefined, targetAccountId + ); + const realEvent = queryResults.find(e => !e.recurrenceId) || queryResults[0]; + if (realEvent) { + const resolvedId = realEvent.originalId || realEvent.id; + if (storeEvent.recurrenceId) { + // Recurring instance: patch the master event's recurrenceOverrides + const patchUpdates: Record = {}; + for (const [key, value] of Object.entries(cleanUpdates as Record)) { + if (['id', 'uid', '@type', 'calendarIds', 'recurrenceRules', 'recurrenceOverrides', 'excludedRecurrenceRules'].includes(key)) continue; + patchUpdates[`recurrenceOverrides/${storeEvent.recurrenceId}/${key}`] = value; + } + await client.updateCalendarEvent( + resolvedId, + patchUpdates as unknown as Partial, + sendSchedulingMessages, + targetAccountId + ); + } else { + // Non-recurring event with synthetic ID: retry with the real ID + await client.updateCalendarEvent(resolvedId, cleanUpdates, sendSchedulingMessages, targetAccountId); + } + set((state) => ({ + events: state.events.map(e => e.id === id ? { ...e, ...updates } : e), + })); + return; + } } throw updateError; } @@ -233,29 +249,49 @@ export const useCalendarStore = create()( targetAccountId ); } catch (updateError) { - // Fallback for CalDAV-created events with synthetic IDs + // Stalwart rejects updates to synthetic IDs. Resolve real ID via UID query. const message = updateError instanceof Error ? updateError.message : ''; if (message.toLowerCase().includes('synthetic') && storeEvent) { - debug.log('RSVP: Event has synthetic ID, falling back to destroy+recreate'); - const { id: _id, originalId: _oi, originalCalendarIds: _oc, - accountId: _ai, accountName: _an, isShared: _is, - utcStart: _us, utcEnd: _ue, isDraft: _dr, isOrigin: _io, - created: _cr, updated: _up, ...baseEvent } = storeEvent; - const updatedParticipants = storeEvent.participants ? { - ...storeEvent.participants, - [participantId]: { ...storeEvent.participants[participantId], participationStatus: status }, - } : storeEvent.participants; - const mergedEvent: Partial = { - ...baseEvent, - participants: updatedParticipants as Record | null, - ...(replyTo ? { replyTo } : {}), - }; - await client.deleteCalendarEvent(realId, false, targetAccountId); - const created = await client.createCalendarEvent(mergedEvent, true, targetAccountId); - set((state) => ({ - events: state.events.map(e => e.id === eventId ? created : e), - })); - return; + debug.log('RSVP: Event has synthetic ID, resolving real ID via UID query'); + const queryResults = await client.queryCalendarEvents( + { uid: storeEvent.uid }, undefined, undefined, targetAccountId + ); + const realEvent = queryResults.find(e => !e.recurrenceId) || queryResults[0]; + if (realEvent) { + const resolvedId = realEvent.originalId || realEvent.id; + if (storeEvent.recurrenceId) { + // Recurring instance: patch RSVP as recurrence override on master + const overridePatch: Record = { + [`recurrenceOverrides/${storeEvent.recurrenceId}/${patchKey}`]: status, + }; + if (replyTo) { + overridePatch[`recurrenceOverrides/${storeEvent.recurrenceId}/replyTo`] = replyTo; + } + await client.updateCalendarEvent( + resolvedId, + overridePatch as unknown as Partial, + true, + targetAccountId + ); + } else { + // Non-recurring event: retry RSVP with real ID + await client.updateCalendarEvent( + resolvedId, + patch as unknown as Partial, + true, + targetAccountId + ); + } + set((state) => ({ + events: state.events.map(e => e.id === eventId ? { ...e, participants: { + ...e.participants, + ...(e.participants?.[participantId] ? { + [participantId]: { ...e.participants[participantId], participationStatus: status as CalendarParticipant['participationStatus'] }, + } : {}), + }} : e), + })); + return; + } } throw updateError; } @@ -402,7 +438,39 @@ export const useCalendarStore = create()( debug.error('Failed to send cancellation emails:', e); } } - await client.deleteCalendarEvent(realId, sendSchedulingMessages, targetAccountId); + try { + await client.deleteCalendarEvent(realId, sendSchedulingMessages, targetAccountId); + } catch (deleteError) { + // Stalwart rejects deletes on synthetic IDs (CalDAV-created events or + // expanded recurring instances). Resolve the real ID via UID query. + const message = deleteError instanceof Error ? deleteError.message : ''; + if (message.toLowerCase().includes('synthetic') && storeEvent) { + debug.log('Event has synthetic ID, resolving real ID via UID query for delete'); + const queryResults = await client.queryCalendarEvents( + { uid: storeEvent.uid }, undefined, undefined, targetAccountId + ); + const realEvent = queryResults.find(e => !e.recurrenceId) || queryResults[0]; + if (realEvent) { + const resolvedId = realEvent.originalId || realEvent.id; + if (storeEvent.recurrenceId) { + // Recurring instance: exclude via recurrenceOverrides on master + await client.updateCalendarEvent( + resolvedId, + { [`recurrenceOverrides/${storeEvent.recurrenceId}`]: { excluded: true } } as unknown as Partial, + false, + targetAccountId + ); + } else { + // Non-recurring event: delete using the real ID + await client.deleteCalendarEvent(resolvedId, sendSchedulingMessages, targetAccountId); + } + } else { + throw deleteError; + } + } else { + throw deleteError; + } + } set((state) => ({ events: state.events.filter(e => e.id !== id), selectedEventId: state.selectedEventId === id ? null : state.selectedEventId,