fix: handle updates and deletions for synthetic JMAP IDs in calendar events

This commit is contained in:
Linus Rath
2026-03-21 20:45:23 +01:00
parent df272e38ef
commit f04b97d52a
2 changed files with 134 additions and 42 deletions
+28 -4
View File
@@ -470,9 +470,22 @@ export default function CalendarPage() {
try { try {
if (type === "edit" && updates) { if (type === "edit" && updates) {
switch (scope) { switch (scope) {
case "this": case "this": {
await updateEvent(client, event.id, updates, sendScheduling); // 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<string, unknown> = {};
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<CalendarEvent>, sendScheduling);
} else {
await updateEvent(client, event.id, updates, sendScheduling);
}
break; break;
}
case "this_and_future": { case "this_and_future": {
const result = await truncateRecurrenceAtEvent(event); const result = await truncateRecurrenceAtEvent(event);
if (!result) { if (!result) {
@@ -530,9 +543,20 @@ export default function CalendarPage() {
toast.success(t("notifications.event_updated")); toast.success(t("notifications.event_updated"));
} else { } else {
switch (scope) { switch (scope) {
case "this": case "this": {
await deleteEvent(client, event.id, sendScheduling); // 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<CalendarEvent>,
);
} else {
await deleteEvent(client, event.id, sendScheduling);
}
break; break;
}
case "this_and_future": { case "this_and_future": {
const result = await truncateRecurrenceAtEvent(event); const result = await truncateRecurrenceAtEvent(event);
if (!result) { if (!result) {
+106 -38
View File
@@ -172,24 +172,40 @@ export const useCalendarStore = create<CalendarStore>()(
try { try {
await client.updateCalendarEvent(realId, cleanUpdates, sendSchedulingMessages, targetAccountId); await client.updateCalendarEvent(realId, cleanUpdates, sendSchedulingMessages, targetAccountId);
} catch (updateError) { } catch (updateError) {
// Stalwart doesn't support updating events created via CalDAV (e.g. by Thunderbird). // Stalwart rejects updates to "synthetic" JMAP IDs (CalDAV-created events
// These events have "synthetic" JMAP IDs. Work around by destroying and recreating. // 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 : ''; const message = updateError instanceof Error ? updateError.message : '';
if (message.toLowerCase().includes('synthetic') && storeEvent) { if (message.toLowerCase().includes('synthetic') && storeEvent) {
debug.log('Event has synthetic ID, falling back to destroy+recreate'); debug.log('Event has synthetic ID, resolving real ID via UID query');
// Merge existing event with updates, strip server-computed fields const queryResults = await client.queryCalendarEvents(
const { id: _id, originalId: _oi, originalCalendarIds: _oc, { uid: storeEvent.uid }, undefined, undefined, targetAccountId
accountId: _ai, accountName: _an, isShared: _is, );
utcStart: _us, utcEnd: _ue, isDraft: _dr, isOrigin: _io, const realEvent = queryResults.find(e => !e.recurrenceId) || queryResults[0];
created: _cr, updated: _up, ...baseEvent } = storeEvent; if (realEvent) {
const mergedEvent: Partial<CalendarEvent> = { ...baseEvent, ...cleanUpdates }; const resolvedId = realEvent.originalId || realEvent.id;
// Delete the old event without sending cancellations (we're recreating it) if (storeEvent.recurrenceId) {
await client.deleteCalendarEvent(realId, false, targetAccountId); // Recurring instance: patch the master event's recurrenceOverrides
const created = await client.createCalendarEvent(mergedEvent, sendSchedulingMessages, targetAccountId); const patchUpdates: Record<string, unknown> = {};
set((state) => ({ for (const [key, value] of Object.entries(cleanUpdates as Record<string, unknown>)) {
events: state.events.map(e => e.id === id ? created : e), if (['id', 'uid', '@type', 'calendarIds', 'recurrenceRules', 'recurrenceOverrides', 'excludedRecurrenceRules'].includes(key)) continue;
})); patchUpdates[`recurrenceOverrides/${storeEvent.recurrenceId}/${key}`] = value;
return; }
await client.updateCalendarEvent(
resolvedId,
patchUpdates as unknown as Partial<CalendarEvent>,
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; throw updateError;
} }
@@ -233,29 +249,49 @@ export const useCalendarStore = create<CalendarStore>()(
targetAccountId targetAccountId
); );
} catch (updateError) { } 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 : ''; const message = updateError instanceof Error ? updateError.message : '';
if (message.toLowerCase().includes('synthetic') && storeEvent) { if (message.toLowerCase().includes('synthetic') && storeEvent) {
debug.log('RSVP: Event has synthetic ID, falling back to destroy+recreate'); debug.log('RSVP: Event has synthetic ID, resolving real ID via UID query');
const { id: _id, originalId: _oi, originalCalendarIds: _oc, const queryResults = await client.queryCalendarEvents(
accountId: _ai, accountName: _an, isShared: _is, { uid: storeEvent.uid }, undefined, undefined, targetAccountId
utcStart: _us, utcEnd: _ue, isDraft: _dr, isOrigin: _io, );
created: _cr, updated: _up, ...baseEvent } = storeEvent; const realEvent = queryResults.find(e => !e.recurrenceId) || queryResults[0];
const updatedParticipants = storeEvent.participants ? { if (realEvent) {
...storeEvent.participants, const resolvedId = realEvent.originalId || realEvent.id;
[participantId]: { ...storeEvent.participants[participantId], participationStatus: status }, if (storeEvent.recurrenceId) {
} : storeEvent.participants; // Recurring instance: patch RSVP as recurrence override on master
const mergedEvent: Partial<CalendarEvent> = { const overridePatch: Record<string, unknown> = {
...baseEvent, [`recurrenceOverrides/${storeEvent.recurrenceId}/${patchKey}`]: status,
participants: updatedParticipants as Record<string, CalendarParticipant> | null, };
...(replyTo ? { replyTo } : {}), if (replyTo) {
}; overridePatch[`recurrenceOverrides/${storeEvent.recurrenceId}/replyTo`] = replyTo;
await client.deleteCalendarEvent(realId, false, targetAccountId); }
const created = await client.createCalendarEvent(mergedEvent, true, targetAccountId); await client.updateCalendarEvent(
set((state) => ({ resolvedId,
events: state.events.map(e => e.id === eventId ? created : e), overridePatch as unknown as Partial<CalendarEvent>,
})); true,
return; targetAccountId
);
} else {
// Non-recurring event: retry RSVP with real ID
await client.updateCalendarEvent(
resolvedId,
patch as unknown as Partial<CalendarEvent>,
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; throw updateError;
} }
@@ -402,7 +438,39 @@ export const useCalendarStore = create<CalendarStore>()(
debug.error('Failed to send cancellation emails:', e); 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<CalendarEvent>,
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) => ({ set((state) => ({
events: state.events.filter(e => e.id !== id), events: state.events.filter(e => e.id !== id),
selectedEventId: state.selectedEventId === id ? null : state.selectedEventId, selectedEventId: state.selectedEventId === id ? null : state.selectedEventId,