From 357b6af3c9686be18cd3d0205116e4dae85de637 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:15:50 +0100 Subject: [PATCH] feat: offer to remove a cancelled meeting from the calendar in the cancellation mail #1425 --- .../calendar-invitation-banner.test.tsx | 99 ++++++++++++- .../email/calendar-invitation-banner.tsx | 97 ++++++++++-- lib/__tests__/calendar-cancellation.test.ts | 140 ++++++++++++++++++ lib/calendar-cancellation.ts | 84 +++++++++++ locales/ar/common.json | 3 + locales/ca/common.json | 3 + locales/cs/common.json | 3 + locales/da/common.json | 3 + locales/de/common.json | 3 + locales/en/common.json | 3 + locales/es/common.json | 3 + locales/et/common.json | 3 + locales/fa/common.json | 3 + locales/fr/common.json | 3 + locales/he/common.json | 3 + locales/hu/common.json | 3 + locales/it/common.json | 3 + locales/ja/common.json | 3 + locales/ko/common.json | 3 + locales/lv/common.json | 3 + locales/mn/common.json | 3 + locales/nb/common.json | 3 + locales/nl/common.json | 3 + locales/pl/common.json | 3 + locales/pt/common.json | 3 + locales/ro/common.json | 3 + locales/ru/common.json | 3 + locales/sk/common.json | 3 + locales/th/common.json | 3 + locales/tr/common.json | 3 + locales/uk/common.json | 3 + locales/zh-TW/common.json | 3 + locales/zh/common.json | 3 + 33 files changed, 490 insertions(+), 17 deletions(-) create mode 100644 lib/__tests__/calendar-cancellation.test.ts create mode 100644 lib/calendar-cancellation.ts diff --git a/components/email/__tests__/calendar-invitation-banner.test.tsx b/components/email/__tests__/calendar-invitation-banner.test.tsx index 41672acdd..b6561856a 100644 --- a/components/email/__tests__/calendar-invitation-banner.test.tsx +++ b/components/email/__tests__/calendar-invitation-banner.test.tsx @@ -11,7 +11,7 @@ const mocks = vi.hoisted(() => { getAccountId: vi.fn(() => 'mail-account'), getCalendarsAccountId: vi.fn(() => 'calendar-account'), getCalendarEvent: vi.fn(), - queryCalendarEvents: vi.fn(async () => []), + queryCalendarEvents: vi.fn(async (): Promise => []), }; const sourceClientMock = { @@ -45,6 +45,7 @@ const mocks = vi.hoisted(() => { importEvents: vi.fn(), rsvpEvent: vi.fn(), updateEvent: vi.fn(), + deleteEvent: vi.fn(), events: [] as Array>, setSelectedDate: vi.fn(), }; @@ -186,6 +187,7 @@ describe('CalendarInvitationBanner', () => { mocks.calendarState.importEvents.mockReset(); mocks.calendarState.rsvpEvent.mockReset(); mocks.calendarState.updateEvent.mockReset(); + mocks.calendarState.deleteEvent.mockReset(); mocks.calendarState.setSelectedDate.mockReset(); mocks.calendarState.events = []; mocks.sourceClientMock.parseCalendarEvents.mockReset(); @@ -1094,4 +1096,99 @@ describe('CalendarInvitationBanner', () => { }); }); }); + describe('cancellations (#1425)', () => { + const ORGANIZER = { + '@type': 'Participant', email: 'boss@example.com', calendarAddress: 'mailto:boss@example.com', + roles: { owner: true }, participationStatus: 'accepted', + }; + const ME = { + '@type': 'Participant', email: 'user@example.com', calendarAddress: 'mailto:user@example.com', + roles: { attendee: true }, participationStatus: 'accepted', + }; + const cancelEmail = (overrides: Partial = {}) => makeEmail({ + id: 'email-cancel', + from: [{ email: 'boss@example.com', name: 'Boss' }], + attachments: [{ partId: '1', blobId: 'blob-c', size: 256, type: 'text/calendar; method=CANCEL', name: 'cancel.ics' }], + ...overrides, + }); + const parsedCancel = (overrides: Record = {}) => ({ + uid: 'uid-c', title: 'Planning', start: '2026-11-02T10:00:00', timeZone: 'Europe/Berlin', duration: 'PT1H', + status: 'cancelled', sequence: 2, organizerCalendarAddress: 'mailto:boss@example.com', + participants: { organizer: ORGANIZER, me: ME }, + ...overrides, + }); + const storedEvent = (overrides: Record = {}) => ({ + id: 'ev-c', uid: 'uid-c', title: 'Planning', start: '2026-11-02T10:00:00', timeZone: 'Europe/Berlin', + duration: 'PT1H', sequence: 2, status: 'cancelled', recurrenceId: null, recurrenceRules: null, + recurrenceOverrides: null, participants: { organizer: ORGANIZER, me: ME }, + ...overrides, + }); + + it('removes the cancelled meeting from the calendar without telling anyone', async () => { + mocks.clientMock.parseCalendarEvents.mockResolvedValue([parsedCancel()]); + mocks.clientMock.queryCalendarEvents.mockResolvedValue([storedEvent()]); + mocks.calendarState.events = [storedEvent()]; + mocks.calendarState.deleteEvent.mockResolvedValue(true); + + render(); + fireEvent.click(await screen.findByText('remove_from_calendar')); + + await waitFor(() => expect(screen.getByText('removed_from_calendar')).toBeInTheDocument()); + expect(mocks.clientMock.queryCalendarEvents).toHaveBeenCalledWith({ uid: 'uid-c' }); + expect(mocks.calendarState.deleteEvent).toHaveBeenCalledWith(mocks.clientMock, 'ev-c', false); + expect(mocks.calendarState.events).toEqual([]); + expect(screen.queryByText('remove_from_calendar')).not.toBeInTheDocument(); + }); + + it('takes only the cancelled occurrence out of a series', async () => { + const series = storedEvent({ recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' }], status: 'confirmed' }); + mocks.clientMock.parseCalendarEvents.mockResolvedValue([parsedCancel({ + start: '2026-11-09T10:00:00', recurrenceId: '2026-11-09T10:00:00', recurrenceIdTimeZone: 'Europe/Berlin', + })]); + mocks.clientMock.queryCalendarEvents.mockResolvedValue([series]); + mocks.calendarState.events = [series]; + mocks.calendarState.updateEvent.mockResolvedValue(true); + + render(); + fireEvent.click(await screen.findByText('remove_from_calendar')); + + await waitFor(() => expect(screen.getByText('removed_from_calendar')).toBeInTheDocument()); + expect(mocks.calendarState.updateEvent).toHaveBeenCalledWith( + mocks.clientMock, 'ev-c', { recurrenceOverrides: { '2026-11-09T10:00:00': { excluded: true } } }, false, + ); + expect(mocks.calendarState.deleteEvent).not.toHaveBeenCalled(); + }); + + it('does not offer to remove a meeting the calendar holds a newer version of', async () => { + mocks.clientMock.parseCalendarEvents.mockResolvedValue([parsedCancel({ sequence: 1 })]); + mocks.clientMock.queryCalendarEvents.mockResolvedValue([storedEvent({ sequence: 3, status: 'confirmed' })]); + + render(); + + expect(await screen.findByText('cancel_outdated')).toBeInTheDocument(); + expect(screen.queryByText('remove_from_calendar')).not.toBeInTheDocument(); + }); + + it('does not offer it for an unverified cancellation from someone other than the organizer', async () => { + mocks.clientMock.parseCalendarEvents.mockResolvedValue([parsedCancel()]); + mocks.clientMock.queryCalendarEvents.mockResolvedValue([storedEvent()]); + + render(); + + await screen.findByText(/could not be verified/); + await waitFor(() => expect(mocks.clientMock.queryCalendarEvents).toHaveBeenCalledWith({ uid: 'uid-c' })); + expect(screen.queryByText('remove_from_calendar')).not.toBeInTheDocument(); + }); + + it('does not offer it when the meeting is not in the calendar', async () => { + mocks.clientMock.parseCalendarEvents.mockResolvedValue([parsedCancel()]); + mocks.clientMock.queryCalendarEvents.mockResolvedValue([]); + + render(); + + await screen.findByRole('heading', { name: 'Planning' }); + await waitFor(() => expect(mocks.clientMock.queryCalendarEvents).toHaveBeenCalledWith({ uid: 'uid-c' })); + expect(screen.queryByText('remove_from_calendar')).not.toBeInTheDocument(); + }); + }); }); diff --git a/components/email/calendar-invitation-banner.tsx b/components/email/calendar-invitation-banner.tsx index e85e48e01..c0e3df709 100644 --- a/components/email/calendar-invitation-banner.tsx +++ b/components/email/calendar-invitation-banner.tsx @@ -41,7 +41,7 @@ import { type InvitationTrustAssessment, } from '@/lib/calendar-invitation'; import { participantsAskedAgain } from '@/lib/calendar-participants'; -import { isValidTimeZone, moveLocalDateTime } from '@/lib/timezone'; +import { planCancellationRemoval, sameRecurrenceId, type CancellationRemoval } from '@/lib/calendar-cancellation'; import { cn } from '@/lib/utils'; import { getEventStartDate } from '@/lib/calendar-utils'; import { sanitizeColor } from '@/components/calendar/event-card'; @@ -223,17 +223,6 @@ function getViewActionLabel( return t('view_in_calendar'); } -/** Whether two events name the same occurrence: their recurrence ids at the same moment, each in its own zone. */ -function sameRecurrenceId(a: Partial, b: Partial): boolean { - if (!a.recurrenceId || !b.recurrenceId) return false; - const zoneA = a.recurrenceIdTimeZone ?? a.timeZone ?? null; - const zoneB = b.recurrenceIdTimeZone ?? b.timeZone ?? null; - const bInA = zoneA && zoneB && isValidTimeZone(zoneA) && isValidTimeZone(zoneB) - ? moveLocalDateTime(b.recurrenceId.slice(0, 19), zoneB, zoneA) - : b.recurrenceId; - return a.recurrenceId.slice(0, 19) === bInA.slice(0, 19); -} - /** * The event in the calendar a counter proposal is about. An attendee's * proposal for one occurrence of a recurring meeting names that occurrence @@ -439,12 +428,13 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp })); const calendarInvitationParsingEnabled = useSettingsStore((s) => s.calendarInvitationParsingEnabled); const timeFormat = useSettingsStore((s) => s.timeFormat); - const { calendars, supportsCalendar, importEvents, rsvpEvent, updateEvent, events: storeEvents, setSelectedDate } = useCalendarStore(useShallow((state) => ({ + const { calendars, supportsCalendar, importEvents, rsvpEvent, updateEvent, deleteEvent, events: storeEvents, setSelectedDate } = useCalendarStore(useShallow((state) => ({ calendars: state.calendars, supportsCalendar: state.supportsCalendar, importEvents: state.importEvents, rsvpEvent: state.rsvpEvent, updateEvent: state.updateEvent, + deleteEvent: state.deleteEvent, events: state.events, setSelectedDate: state.setSelectedDate, }))); @@ -464,6 +454,9 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp // Underlying failure shown under the generic parse error, so "wrong account" // or a network failure is distinguishable from an unparsable file. const [parseError, setParseError] = useState(null); + // What removing a cancelled meeting from the calendar takes, read from the + // events stored with its uid; null until known. + const [cancelRemoval, setCancelRemoval] = useState(null); const attachment = findCalendarAttachment(email); @@ -555,12 +548,25 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp }; }, [showCalendarPicker]); + const detectedMethod = parsedEvent && attachment ? getInvitationMethod(parsedEvent, { email, attachment }) : 'unknown'; + const method = detectedMethod !== 'unknown' ? detectedMethod : rawIcsMethod; + const isCancellation = method === 'cancel'; + + useEffect(() => { + setCancelRemoval(null); + if (!client || !supportsCalendar || !isCancellation || !parsedEvent?.uid) return; + let current = true; + client.queryCalendarEvents({ uid: parsedEvent.uid }) + .then((stored) => { + if (current) setCancelRemoval(planCancellationRemoval(stored, parsedEvent)); + }) + .catch(() => { /* no remove action then */ }); + return () => { current = false; }; + }, [client, supportsCalendar, isCancellation, parsedEvent]); + if (!attachment || !calendarInvitationParsingEnabled) return null; - const detectedMethod = parsedEvent ? getInvitationMethod(parsedEvent, { email, attachment }) : 'unknown'; - const method = detectedMethod !== 'unknown' ? detectedMethod : rawIcsMethod; const summary = parsedEvent ? formatEventSummary(parsedEvent) : null; - const isCancellation = method === 'cancel'; const isResponseOnly = method === 'reply' || method === 'refresh' || method === 'counter' || method === 'declinecounter'; const showDetails = !isCollapsed; const allowsRsvp = method === 'request'; @@ -616,6 +622,17 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp const proposalFromParticipant = Boolean( proposalTarget && senderAddress && findParticipantByEmails(proposalTarget, [senderAddress]), ); + // A cancellation that fails authentication, or comes from someone other + // than the organizer without passing it, does not get to remove anything. + const canRemoveCancelled = Boolean( + client + && supportsCalendar + && isCancellation + && !userIsSender + && trustAssessment?.level !== 'warning' + && (cancelRemoval?.kind === 'delete' || cancelRemoval?.kind === 'exclude') + ); + const cancellationOutdated = isCancellation && cancelRemoval?.kind === 'outdated'; const resolveExistingEventForRsvp = async () => { if (!client || !existingEvent) { @@ -811,6 +828,42 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp } }; + const handleRemoveCancelled = async () => { + const cancel = parsedEvent; + if (!client || !cancel?.uid || isProcessing) return; + setActionNotice(null); + setActionError(null); + setIsProcessing(true); + try { + // Planned again on the events as they are now: the calendar may have + // changed since the mail was opened. + const removal = planCancellationRemoval(await client.queryCalendarEvents({ uid: cancel.uid }), cancel); + setCancelRemoval(removal); + // No scheduling messages: the organizer cancelled it, there is no one + // to tell. + if (removal.kind === 'delete') { + for (const id of removal.ids) { + if (!(await deleteEvent(client, id, false))) return; + } + } else if (removal.kind === 'exclude') { + if (!(await updateEvent(client, removal.id, removal.patch, false))) return; + } else { + return; + } + // Occurrences of it in view go as well. + useCalendarStore.setState((s) => ({ + events: s.events.filter((event) => event.uid !== cancel.uid + || (Boolean(cancel.recurrenceId) && !sameRecurrenceId(event, cancel))), + })); + setCancelRemoval({ kind: 'none' }); + setActionNotice(t('removed_from_calendar')); + } catch (err) { + setActionError(describeError(err, t('action_failed'))); + } finally { + setIsProcessing(false); + } + }; + if (state === 'loading') { return (
@@ -1010,6 +1063,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp {showDetails && (bannerInfo || actorMessage || actorSummary?.participationComment) && (
{bannerInfo &&

{bannerInfo}

} + {cancellationOutdated &&

{t('cancel_outdated')}

} {actorMessage &&

{actorMessage}

} {actorSummary?.participationComment && (

{t('actor_note', { comment: actorSummary.participationComment })}

@@ -1165,6 +1219,17 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp )} + {canRemoveCancelled && ( + + )} + {canApplyProposal && (