From f8448c6a45ad674e8240f568ec4ed89de50fea46 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:56:06 +0200 Subject: [PATCH] fix: stop reporting calendar, task and contact changes as done when a plugin stopped them --- components/calendar/calendar-app.tsx | 53 +++++++------ components/calendar/ical-import-modal.tsx | 5 ++ components/calendar/task-modal.tsx | 5 +- components/contacts/contacts-app.tsx | 22 +++--- .../calendar-invitation-banner.test.tsx | 75 ++++++++++++++++++- .../email/calendar-invitation-banner.tsx | 12 ++- components/layout/mv-side-panel.tsx | 4 +- .../calendar-store-event-hooks.test.ts | 15 ++-- stores/__tests__/contact-store-hooks.test.ts | 4 +- stores/__tests__/task-store-hooks.test.ts | 8 +- stores/calendar-store.ts | 25 ++++--- stores/contact-store.ts | 8 +- stores/task-store.ts | 13 ++-- 13 files changed, 173 insertions(+), 76 deletions(-) diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index d3b17fe01..bd8119de6 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -667,20 +667,21 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = if (client) toggleTaskCompleteFn(client, task); }, [client, toggleTaskCompleteFn]); + // false: a plugin stopped it and said why, so the task form stays open. const handleSaveTask = useCallback(async (data: Partial) => { - if (!client) return; - if (editTask) { - await taskStore.updateTask(client, editTask.id, data); - } else { - await taskStore.createTask(client, data); - } + if (!client) return false; + const saved = editTask + ? await taskStore.updateTask(client, editTask.id, data) + : (await taskStore.createTask(client, data)) !== null; + if (!saved) return false; setShowTaskModal(false); setEditTask(null); + return true; }, [client, editTask, taskStore]); const handleDeleteTask = useCallback(async (id: string) => { if (!client) return; - await taskStore.deleteTask(client, id); + if (!(await taskStore.deleteTask(client, id))) return; setShowTaskModal(false); setEditTask(null); }, [client, taskStore]); @@ -959,7 +960,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = setEditEvent(null); return; } - await updateEvent(client, editEvent.id, data, sendSchedulingMessages); + // false: a plugin stopped it and said why; the form stays open. + if (!(await updateEvent(client, editEvent.id, data, sendSchedulingMessages))) return; if (data.start) { focusCalendarOnEvent({ start: data.start }); } @@ -1106,17 +1108,17 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = return; } try { - await deleteEvent(client, id, sendSchedulingMessages); - toast.success(t("notifications.event_deleted")); + if (await deleteEvent(client, id, sendSchedulingMessages)) toast.success(t("notifications.event_deleted")); } catch { toast.error(t("notifications.event_error")); } }, [client, deleteEvent, events, editEvent, t]); + // null: no series found; "stopped": a plugin refused to shorten it. const truncateRecurrenceAtEvent = useCallback(async (event: CalendarEvent): Promise<{ master: CalendarEvent; originalRules: CalendarEvent["recurrenceRules"]; - } | null> => { + } | null | "stopped"> => { const master = await findMasterEvent(event); if (!master) return null; const originalRules = master.recurrenceRules @@ -1131,7 +1133,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = until, count: null, })); - await updateEvent(client!, master.id, { recurrenceRules: truncatedRules }); + if (!(await updateEvent(client!, master.id, { recurrenceRules: truncatedRules }))) return "stopped"; return { master, originalRules }; }, [client, findMasterEvent, updateEvent]); @@ -1143,8 +1145,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = ) => { if (!client) return; try { - await rsvpEvent(client, eventId, participantId, status, undefined, scope); - toast.success(t("notifications.rsvp_updated")); + if (await rsvpEvent(client, eventId, participantId, status, undefined, scope)) toast.success(t("notifications.rsvp_updated")); } catch { toast.error(t("notifications.rsvp_error")); } @@ -1162,6 +1163,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = const updates = type === "edit" ? pendingScopeAction.updates : undefined; setPendingScopeAction(null); + // false when a plugin stopped the change (it says why): no "updated". + let done = true; try { if (type === "edit" && updates) { switch (scope) { @@ -1170,7 +1173,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = // The store writes one occurrence through its own (synthetic) // id, or as a recurrence override on the base event it was // expanded from. - await updateEvent(client, event.id, updates, sendScheduling); + done = await updateEvent(client, event.id, updates, sendScheduling); break; } // An override outside an expanded series: override it on the master. @@ -1179,14 +1182,15 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = const overridePatch = occurrence && buildFallbackOverridePatch(occurrence, withNewOverrideDetails(occurrence, updates)); if (master && overridePatch) { - await updateEvent(client, master.id, overridePatch, sendScheduling); + done = await updateEvent(client, master.id, overridePatch, sendScheduling); } else { - await updateEvent(client, event.id, updates, sendScheduling); + done = await updateEvent(client, event.id, updates, sendScheduling); } break; } case "this_and_future": { const result = await truncateRecurrenceAtEvent(event); + if (result === "stopped") return; if (!result) { toast.error(t("notifications.event_error")); return; @@ -1234,7 +1238,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = } const allUpdates = { ...updates }; delete (allUpdates as Record).recurrenceId; - await updateEvent(client, master.id, allUpdates, sendScheduling); + done = await updateEvent(client, master.id, allUpdates, sendScheduling); break; } default: { @@ -1242,6 +1246,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = throw new Error(`Unhandled scope: ${_exhaustive}`); } } + if (!done) return; toast.success(t("notifications.event_updated")); } else { switch (scope) { @@ -1249,21 +1254,22 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = if (isServerRecurrenceInstance(event) || isBrowserExpandedOccurrence(event)) { // The store destroys a server occurrence, or excludes a // browser-expanded one on its base event. - await deleteEvent(client, event.id, sendScheduling); + done = await deleteEvent(client, event.id, sendScheduling); break; } // An override outside an expanded series: exclude it on the master. const delMaster = await findMasterEvent(event); const excludePatch = delMaster && buildFallbackExcludePatch(overrideContextOf(event, delMaster)); if (delMaster && excludePatch) { - await updateEvent(client, delMaster.id, excludePatch, sendScheduling); + done = await updateEvent(client, delMaster.id, excludePatch, sendScheduling); } else { - await deleteEvent(client, event.id, sendScheduling); + done = await deleteEvent(client, event.id, sendScheduling); } break; } case "this_and_future": { const result = await truncateRecurrenceAtEvent(event); + if (result === "stopped") return; if (!result) { toast.error(t("notifications.event_error")); return; @@ -1276,7 +1282,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = toast.error(t("notifications.event_error")); return; } - await deleteEvent(client, master.id, sendScheduling); + done = await deleteEvent(client, master.id, sendScheduling); break; } default: { @@ -1284,6 +1290,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = throw new Error(`Unhandled scope: ${_exhaustive}`); } } + if (!done) return; toast.success(t("notifications.event_deleted")); } try { @@ -1338,7 +1345,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = ? `${detailEvent.description}${separator}${note}` : `--- ${timestamp} ---\n${note}`; try { - await updateEvent(client, detailEvent.id, { description: newDescription }); + if (!(await updateEvent(client, detailEvent.id, { description: newDescription }))) return; setDetailEvent({ ...detailEvent, description: newDescription }); toast.success(t("detail.note_saved")); } catch { diff --git a/components/calendar/ical-import-modal.tsx b/components/calendar/ical-import-modal.tsx index f133c49b9..7d7628b08 100644 --- a/components/calendar/ical-import-modal.tsx +++ b/components/calendar/ical-import-modal.tsx @@ -189,6 +189,11 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal setStep("importing"); try { const count = await importEvents(client, eventsToImport, calendarId); + if (count === null) { + // A plugin stopped the import and said why. + setStep("preview"); + return; + } toast.success(t("success", { count })); onClose(); } catch { diff --git a/components/calendar/task-modal.tsx b/components/calendar/task-modal.tsx index ad2d32052..6c4bf048c 100644 --- a/components/calendar/task-modal.tsx +++ b/components/calendar/task-modal.tsx @@ -14,7 +14,8 @@ import { taskToView } from "@/lib/plugin-projection"; interface TaskModalProps { task?: CalendarTask | null; calendars: Calendar[]; - onSave: (data: Partial) => void | Promise; + /** Resolving false keeps the form open (a plugin stopped the save). */ + onSave: (data: Partial) => void | boolean | Promise; onDelete?: (id: string) => void; onClose: () => void; isMobile?: boolean; @@ -145,7 +146,7 @@ export function TaskModal({ data.id = task.id; } - await onSave(data); + if ((await onSave(data)) === false) return; onClose(); } finally { setSaving(false); diff --git a/components/contacts/contacts-app.tsx b/components/contacts/contacts-app.tsx index 07899ff28..9ca42ffc3 100644 --- a/components/contacts/contacts-app.tsx +++ b/components/contacts/contacts-app.tsx @@ -338,21 +338,23 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = const handleDropContactsToCategory = useCallback(async (contactIds: string[], keyword: string) => { if (!client && supportsSync) return; try { + // Contacts in the category afterwards; a plugin may stop some updates. + let added = 0; for (const contactId of contactIds) { const contact = contacts.find(c => c.id === contactId); if (!contact) continue; const existingKeywords = contact.keywords || {}; - if (existingKeywords[keyword]) continue; // already has this keyword + if (existingKeywords[keyword]) { added++; continue; } // already has this keyword const updatedKeywords = { ...existingKeywords, [keyword]: true }; - if (supportsSync && client) { - await updateContact(client, contactId, { keywords: updatedKeywords }); - } else { - await updateLocalContact(contactId, { keywords: updatedKeywords }); - } + const updated = supportsSync && client + ? await updateContact(client, contactId, { keywords: updatedKeywords }) + : await updateLocalContact(contactId, { keywords: updatedKeywords }); + if (updated !== false) added++; } - const msg = contactIds.length === 1 + if (added === 0) return; + const msg = added === 1 ? t("category_added", { name: keyword }) - : t("category_added_plural", { count: contactIds.length, name: keyword }); + : t("category_added_plural", { count: added, name: keyword }); toast.success(msg); } catch (error) { console.error('Failed to add contacts to category:', error); @@ -694,10 +696,10 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = if (!confirmed) return; try { - await bulkDeleteContacts( + if (!(await bulkDeleteContacts( supportsSync && client ? client : null, Array.from(selectedContactIds) - ); + ))) return; toast.success(t("bulk.deleted", { count: selectedContactIds.size })); setView("list"); } catch (error) { diff --git a/components/email/__tests__/calendar-invitation-banner.test.tsx b/components/email/__tests__/calendar-invitation-banner.test.tsx index b45a8b883..37d9be2d5 100644 --- a/components/email/__tests__/calendar-invitation-banner.test.tsx +++ b/components/email/__tests__/calendar-invitation-banner.test.tsx @@ -522,7 +522,7 @@ describe('CalendarInvitationBanner', () => { }, }, }); - mocks.calendarState.rsvpEvent.mockResolvedValue(undefined); + mocks.calendarState.rsvpEvent.mockResolvedValue(true); render(); @@ -543,6 +543,73 @@ describe('CalendarInvitationBanner', () => { 'accepted', null, ); + expect(await screen.findByText('Response sent')).toBeInTheDocument(); + }); + + it('says nothing was sent when a plugin stops the answer', async () => { + mocks.calendarState.events = [ + { + id: 'event-6', + uid: 'uid-6', + start: '2026-03-28T09:00:00Z', + }, + ]; + + mocks.clientMock.parseCalendarEvents.mockResolvedValue([ + { + uid: 'uid-6', + title: 'Sparse Event', + start: '2026-03-28T09:00:00Z', + participants: { + parsedAttendee: { + '@type': 'Participant', + email: 'user@example.com', + name: 'User', + roles: { required: true }, + participationStatus: 'needs-action', + }, + }, + }, + ]); + + mocks.clientMock.getCalendarEvent.mockResolvedValue({ + id: 'event-6', + uid: 'uid-6', + start: '2026-03-28T09:00:00Z', + participants: { + canonicalAttendee: { + '@type': 'Participant', + email: 'user@example.com', + name: 'User', + roles: { attendee: true }, + participationStatus: 'needs-action', + }, + }, + }); + mocks.calendarState.rsvpEvent.mockResolvedValue(false); + + render(); + + await waitFor(() => { + expect(screen.getByText('Accept')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Accept')); + + await waitFor(() => { + expect(mocks.clientMock.getCalendarEvent).toHaveBeenCalledWith('event-6'); + }); + + expect(mocks.calendarState.rsvpEvent).toHaveBeenCalledWith( + mocks.clientMock, + 'event-6', + 'canonicalAttendee', + 'accepted', + null, + ); + // The plugin that stopped it says why; the banner must not claim it went. + await waitFor(() => expect(mocks.calendarState.rsvpEvent).toHaveBeenCalled()); + expect(screen.queryByText('Response sent')).not.toBeInTheDocument(); }); it('falls back to organizerCalendarAddress when replyTo is missing', async () => { @@ -580,7 +647,7 @@ describe('CalendarInvitationBanner', () => { }, }, ]); - mocks.calendarState.rsvpEvent.mockResolvedValue(undefined); + mocks.calendarState.rsvpEvent.mockResolvedValue(true); render(); @@ -639,7 +706,7 @@ describe('CalendarInvitationBanner', () => { }, }, ]); - mocks.calendarState.updateEvent.mockResolvedValue(undefined); + mocks.calendarState.updateEvent.mockResolvedValue(true); render(); @@ -671,7 +738,7 @@ describe('CalendarInvitationBanner', () => { }); it('shows counter changes and lets organizers apply the proposal', async () => { - mocks.calendarState.updateEvent = vi.fn().mockResolvedValue(undefined); + mocks.calendarState.updateEvent = vi.fn().mockResolvedValue(true); mocks.calendarState.events = [ { id: 'event-5', diff --git a/components/email/calendar-invitation-banner.tsx b/components/email/calendar-invitation-banner.tsx index 963a9e1ba..dc9cc7a12 100644 --- a/components/email/calendar-invitation-banner.tsx +++ b/components/email/calendar-invitation-banner.tsx @@ -599,7 +599,8 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp && (!eventForRsvp?.participants || Object.keys(eventForRsvp.participants).length === 0) ); if (eventForRsvp && existingEventParticipant) { - await rsvpEvent(client, eventForRsvp.id, existingEventParticipant.id, status, replyToForRsvp); + // false: a plugin stopped it and said why; keep the old answer. + if (!(await rsvpEvent(client, eventForRsvp.id, existingEventParticipant.id, status, replyToForRsvp))) return; setRsvpStatus(status); setActionNotice(t('rsvp_sent')); setState('parsed'); @@ -609,7 +610,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp if (!repairedParticipants) { setActionError(t('action_failed')); } else { - await updateEvent(client, eventForRsvp.id, { + const updated = await updateEvent(client, eventForRsvp.id, { participants: repairedParticipants, // Stalwart routes the iTIP REPLY via the stored ORGANIZER // (organizerCalendarAddress; the RFC 8984 replyTo is retired). @@ -618,6 +619,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp ? { organizerCalendarAddress: replyToForRsvp.imip } : {}), }, true); + if (!updated) return; setRsvpStatus(status); setActionNotice(t('rsvp_sent')); setState('parsed'); @@ -626,6 +628,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp setActionError(t('action_failed')); } else if (calId) { const imported = await importEvents(client, [parsedEvent], calId); + if (imported === null) return; if (imported > 0) { const newEvent = useCalendarStore.getState().events.find( (e) => e.uid === parsedEvent.uid @@ -633,7 +636,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp const participant = myParticipant || (newEvent && currentUserEmail ? findParticipantByEmail(newEvent, currentUserEmail) : null); if (newEvent && participant) { - await rsvpEvent(client, newEvent.id, participant.id, status, replyToForRsvp); + if (!(await rsvpEvent(client, newEvent.id, participant.id, status, replyToForRsvp))) return; setRsvpStatus(status); setActionNotice(t('rsvp_sent')); } else { @@ -682,6 +685,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp setIsProcessing(true); try { const count = await importEvents(client, [parsedEvent], calId); + if (count === null) return; if (count > 0) { setActionNotice(t('added')); setState('parsed'); @@ -740,7 +744,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp setIsProcessing(true); try { - await updateEvent(client, existingEvent.id, proposalPatch, true); + if (!(await updateEvent(client, existingEvent.id, proposalPatch, true))) return; setActionNotice(t('proposal_applied')); setState('parsed'); setRsvpStatus(null); diff --git a/components/layout/mv-side-panel.tsx b/components/layout/mv-side-panel.tsx index 2e197d88d..c741db90d 100644 --- a/components/layout/mv-side-panel.tsx +++ b/components/layout/mv-side-panel.tsx @@ -299,7 +299,9 @@ function TasksPanel({ onClose }: { onClose: () => void }) { const title = draft.trim(); if (!title) return; setDraft(""); - await createTask(client, { "@type": "Task", title, progress: "needs-action", calendarIds: { [taskCalendar.id]: true } }); + // null: a plugin stopped it; give the user their text back. + const created = await createTask(client, { "@type": "Task", title, progress: "needs-action", calendarIds: { [taskCalendar.id]: true } }); + if (!created) setDraft(title); }} >