From a19afe9b48e370faffc51ab81a8a5eccfca011cd Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 03:44:02 +0100 Subject: [PATCH] feat: offer to remove a cancelled meeting from the calendar in the cancellation mail (bulwarkmail/webmail#1425) --- .../__tests__/live-stalwart-calendar.test.ts | 85 ++++++++ .../email/CalendarInvitationBanner.tsx | 98 ++++++++- .../__tests__/calendar-cancellation.test.ts | 205 ++++++++++++++++++ src/lib/calendar-cancellation.ts | 129 +++++++++++ src/stores/__tests__/calendar-store.test.ts | 16 ++ src/stores/calendar-store.ts | 7 +- 6 files changed, 535 insertions(+), 5 deletions(-) create mode 100644 src/lib/__tests__/calendar-cancellation.test.ts create mode 100644 src/lib/calendar-cancellation.ts diff --git a/src/api/__tests__/live-stalwart-calendar.test.ts b/src/api/__tests__/live-stalwart-calendar.test.ts index 546507cd..da230dcd 100644 --- a/src/api/__tests__/live-stalwart-calendar.test.ts +++ b/src/api/__tests__/live-stalwart-calendar.test.ts @@ -29,6 +29,7 @@ import type { Calendar, CalendarEvent, Participant } from '../types'; import { createEvent, deleteEvents, + findEventsByUid, getCalendars, getEvents, queryEvents, @@ -43,6 +44,7 @@ import { expandRecurringEvents } from '../../lib/recurrence-expansion'; import { getEventStartDate } from '../../lib/calendar-utils'; import { alertsToReminders, calendarDefaultAlerts, defaultAlertIdPrefix, remindersToDefaultAlerts } from '../../lib/calendar-alerts'; import { getEffectiveAlerts } from '../../lib/calendar-alert-scheduler'; +import { removeCancelledMeeting } from '../../lib/calendar-cancellation'; const SERVER = process.env.BULWARK_LIVE_JMAP; const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org'; @@ -607,6 +609,89 @@ live('live Stalwart calendar', () => { }, 120_000); }); + liveTwo('a cancelled meeting (#1425)', () => { + const after = '2027-10-01T00:00:00.000Z'; + const before = '2027-10-31T00:00:00.000Z'; + const me = [USER]; + + /** Run `fn` as the second user, in the app's singleton. */ + async function asOther(fn: () => Promise): Promise { + (await singleton()).restoreSnapshot(other!.snapshot()); + useCalendarStore.getState().reset(); + try { + return await fn(); + } finally { + await connectSingleton(); + } + } + + function removeAsOther(cancel: Partial) { + return asOther(async () => { + await useCalendarStore.getState().fetchCalendars(); + const s = useCalendarStore.getState(); + return removeCancelledMeeting(cancel, { + findEventsByUid: (u) => findEventsByUid(u), + deleteEvent: s.deleteEvent, + updateEvent: s.updateEvent, + }); + }); + } + + it('takes one cancelled occurrence, then the cancelled series, out of the attendee\'s calendar', async () => { + await connectSingleton(); + // Four Mondays from 4 October 2027, the second user invited. + const invitation = editorSave( + { title: `${RUN} cancelled`, start: '2027-10-04T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin' } as CalendarEvent, + { attendees: [{ name: 'Land', email: USER2! }] }, + me, + ); + const created = await useCalendarStore.getState().createEvent({ + ...seriesData('cancelled', '2027-10-04T10:00:00', 4), + participants: invitation.updates.participants, + organizerCalendarAddress: invitation.updates.organizerCalendarAddress, + }, calendarId, { sendSchedulingMessages: true }); + const uid = created.uid!; + const copies = await waitFor(() => seriesByUid(other!, uid), (list) => list.length > 0); + expect(copies, 'the invitation reached the second user').toHaveLength(1); + + // The organizer cancels the second Monday. The attendee's 1.0 keeps + // it, marked cancelled; 0.16 takes it out itself. + const occurrences = await loadOccurrences(uid, after, before); + const second = occurrences.find((e) => (e.recurrenceId ?? '').startsWith('2027-10-11'))!; + expect(await deleteOccurrence('this', { event: second, userEmails: me }, storeActions())).toBe(true); + const key = '2027-10-11T10:00:00'; + const marked = await waitFor(() => seriesByUid(other!, uid), (list) => + /cancelled|excluded/.test(JSON.stringify(list[0]?.recurrenceOverrides?.[key] ?? null))); + const entry = marked[0].recurrenceOverrides?.[key] as { sequence?: number; excluded?: boolean } | undefined; + expect(JSON.stringify(entry ?? null)).toMatch(/cancelled|excluded/); + + const one = await removeAsOther({ + uid, + recurrenceId: key, + recurrenceIdTimeZone: 'Europe/Berlin', + sequence: entry?.sequence ?? 0, + }); + expect(one.kind).toBe(entry?.excluded ? 'none' : 'exclude'); + const [excluded] = await seriesByUid(other!, uid); + expect(excluded.recurrenceOverrides?.[key]).toEqual({ excluded: true }); + expect(excluded.status).not.toBe('cancelled'); + + // An older cancellation of the whole meeting is not applied. + const outdated = await removeAsOther({ uid, sequence: -1 }); + expect(outdated.kind).toBe('outdated'); + + // The organizer cancels the series. + const left = await loadOccurrences(uid, after, before); + expect(await deleteOccurrence('all', { event: left[0], userEmails: me }, storeActions())).toBe(true); + // Both servers keep it, marked cancelled. + const marked2 = await waitFor(() => seriesByUid(other!, uid), (list) => list[0]?.status === 'cancelled'); + expect(marked2[0]?.status).toBe('cancelled'); + const all = await removeAsOther({ uid, sequence: marked2[0].sequence ?? 0 }); + expect(all.kind).toBe('delete'); + expect(await seriesByUid(other!, uid)).toEqual([]); + }, 120_000); + }); + liveTwo('shares', () => { let mailboxId = ''; let sharedCalendarId = ''; diff --git a/src/components/email/CalendarInvitationBanner.tsx b/src/components/email/CalendarInvitationBanner.tsx index 52c94a1d..01f2fb1b 100644 --- a/src/components/email/CalendarInvitationBanner.tsx +++ b/src/components/email/CalendarInvitationBanner.tsx @@ -3,7 +3,7 @@ import { View, Text, StyleSheet, Pressable, ActivityIndicator } from 'react-nati import { openExternalUrl } from '../../lib/open-url'; import { CalendarPlus, Check, HelpCircle, X, MapPin, Video, Clock, CalendarDays, AlertTriangle, - ShieldCheck, ShieldAlert, ChevronDown, + ShieldCheck, ShieldAlert, ChevronDown, Trash2, } from '../icons'; import { format, isSameDay } from 'date-fns'; import type { Calendar, Email, CalendarEvent } from '../../api/types'; @@ -32,6 +32,13 @@ import { canCreateEventsIn } from '../../lib/calendar-editability'; import { getCalendarColor, timePattern } from '../../lib/calendar-utils'; import { getDateFnsLocale } from '../../lib/calendar-locale'; import { useCalendarSubscriptionsStore } from '../../stores/calendar-subscriptions-store'; +import { + canRemoveCancelled as canRemoveCancelledMeeting, + planCancellationRemoval, + removeCancelledMeeting, + sameRecurrenceId, + type CancellationRemoval, +} from '../../lib/calendar-cancellation'; type BannerState = 'loading' | 'parsed' | 'done' | 'error'; type RsvpStatus = 'accepted' | 'tentative' | 'declined'; @@ -76,6 +83,8 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) { const subscriptions = useCalendarSubscriptionsStore((s) => s.subscriptions); const importEvents = useCalendarStore((s) => s.importEvents); const rsvpEvent = useCalendarStore((s) => s.rsvpEvent); + const deleteEvent = useCalendarStore((s) => s.deleteEvent); + const updateEvent = useCalendarStore((s) => s.updateEvent); const attachment = React.useMemo(() => findCalendarAttachment(email), [email]); // Login address + identities + aliases, so invitations addressed to an // alias still show the RSVP buttons. Only an invitation looks the aliases up. @@ -97,6 +106,9 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) { const [busy, setBusy] = React.useState(false); const [calendarId, setCalendarId] = React.useState(null); const [pickerOpen, setPickerOpen] = React.useState(false); + // What removing a cancelled meeting from the calendar takes, read from the + // events stored with its uid; null until known (webmail #1425). + const [cancelRemoval, setCancelRemoval] = React.useState(null); React.useEffect(() => { let cancelled = false; @@ -139,6 +151,17 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) { return () => { cancelled = true; }; }, [invitationKey, enabled, jmapAccountId]); + const isCancellation = state !== 'loading' && method === 'cancel'; + React.useEffect(() => { + setCancelRemoval(null); + if (!isCancellation || !event?.uid) return; + let current = true; + findEventsByUid(event.uid) + .then((stored) => { if (current) setCancelRemoval(planCancellationRemoval(stored, event)); }) + .catch(() => { /* no remove action then */ }); + return () => { current = false; }; + }, [isCancellation, event]); + // Import into the account's default calendar; never into a shared calendar, // an iCal subscription (the next feed sync would delete the event) or a // read-only one. The user can still pick another writable calendar. @@ -255,6 +278,45 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) { } }; + // A cancellation that fails authentication, or comes from someone other + // than the organizer without passing it, does not get to remove anything. + const senderAddress = email.from?.[0]?.email?.toLowerCase() ?? ''; + const userIsSender = !!senderAddress && currentUserEmails.some((a) => a.toLowerCase() === senderAddress); + const canRemoveCancelled = canRemoveCancelledMeeting({ + isCancellation, userIsSender, trustLevel: trust?.level, removal: cancelRemoval, + }); + const cancellationOutdated = isCancellation && cancelRemoval?.kind === 'outdated'; + + const handleRemoveCancelled = async () => { + const cancel = event; + if (busy || !cancel.uid) return; + setBusy(true); + setNotice(null); + try { + const removal = await removeCancelledMeeting(cancel, { + findEventsByUid: (uid) => findEventsByUid(uid), + deleteEvent, + updateEvent, + }); + if (removal.kind !== 'delete' && removal.kind !== 'exclude') { + setCancelRemoval(removal); + return; + } + // Occurrences of it in view go as well. + useCalendarStore.setState((s) => ({ + events: s.events.filter((e) => e.uid !== cancel.uid + || (Boolean(cancel.recurrenceId) && !sameRecurrenceId(e, cancel))), + })); + setServerMatch(null); + setCancelRemoval({ kind: 'none' }); + setNotice(t('calendar.invitation.removed_from_calendar', 'Removed from your calendar.')); + } catch { + setNotice(t('calendar.invitation.remove_error', 'Could not remove the event')); + } finally { + setBusy(false); + } + }; + const trustColor = trust?.level === 'warning' ? c.error : trust?.level === 'caution' ? c.warning : c.success; return ( @@ -270,6 +332,11 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) { {method === 'cancel' && ( {t('calendar.invitation.cancelled', 'This event was cancelled')} )} + {cancellationOutdated && ( + + {t('calendar.invitation.cancel_outdated', 'Your calendar has a newer version of this event than this cancellation.')} + + )} {method === 'reply' && ( {t('calendar.invitation.reply', 'A participant replied to this invitation')} )} @@ -360,7 +427,21 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) { icon={} disabled={busy} onPress={() => ensureImportedAndRsvp('declined')} c={c} styles={styles} /> - ) : !existing && method !== 'reply' ? ( + ) : canRemoveCancelled ? ( + { void handleRemoveCancelled(); }} + disabled={busy} + accessibilityRole="button" + > + {busy ? ( + + ) : ( + + )} + {t('calendar.invitation.remove_from_calendar', 'Remove from calendar')} + + ) : !existing && method !== 'reply' && method !== 'cancel' ? ( { void handleImport(); }} @@ -521,6 +602,19 @@ function makeStyles(c: ThemePalette) { backgroundColor: c.primary, }, importBtnText: { ...typography.bodyMedium, color: c.primaryForeground }, + removeBtn: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.sm, + paddingVertical: spacing.sm, + borderRadius: radius.sm, + borderWidth: 1, + borderColor: c.errorBorder, + backgroundColor: c.background, + }, + removeBtnText: { ...typography.bodyMedium, color: c.error }, warnRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs, marginTop: spacing.xs }, warnText: { ...typography.caption, color: c.warning }, }); diff --git a/src/lib/__tests__/calendar-cancellation.test.ts b/src/lib/__tests__/calendar-cancellation.test.ts new file mode 100644 index 00000000..510a66e2 --- /dev/null +++ b/src/lib/__tests__/calendar-cancellation.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it, vi } from 'vitest'; +import { canRemoveCancelled, planCancellationRemoval, removeCancelledMeeting, type CancellationRemoval } from '../calendar-cancellation'; +import type { CalendarEvent } from '../../api/types'; + +function stored(overrides: Partial): CalendarEvent { + return { + id: 'ev-1', + uid: 'meeting@example.com', + title: 'Weekly sync', + start: '2026-11-02T10:00:00', + timeZone: 'Europe/Berlin', + duration: 'PT1H', + sequence: 1, + recurrenceId: null, + recurrenceRules: null, + recurrenceOverrides: null, + ...overrides, + } as CalendarEvent; +} + +const WEEKLY = [{ '@type': 'RecurrenceRule', frequency: 'weekly' }] as CalendarEvent['recurrenceRules']; + +describe('planCancellationRemoval', () => { + it('deletes every stored event of a cancelled meeting, and nothing else', () => { + const events = [ + stored({ id: 'ev-1' }), + stored({ id: 'ev-2', recurrenceId: '2026-11-09T10:00:00' }), + stored({ id: 'other', uid: 'other@example.com' }), + ]; + expect(planCancellationRemoval(events, { uid: 'meeting@example.com', sequence: 1 })) + .toEqual({ kind: 'delete', ids: ['ev-1', 'ev-2'] }); + }); + + it('removes nothing when the calendar does not hold the meeting', () => { + expect(planCancellationRemoval([stored({ uid: 'other@example.com' })], { uid: 'meeting@example.com' })) + .toEqual({ kind: 'none' }); + }); + + it('leaves the meeting when the calendar holds a newer version than the cancellation', () => { + expect(planCancellationRemoval([stored({ sequence: 3 })], { uid: 'meeting@example.com', sequence: 2 })) + .toEqual({ kind: 'outdated' }); + }); + + it('applies a cancellation with the same sequence, as Stalwart leaves it after marking it cancelled', () => { + expect(planCancellationRemoval([stored({ sequence: 2, status: 'cancelled' })], { uid: 'meeting@example.com', sequence: 2 })) + .toEqual({ kind: 'delete', ids: ['ev-1'] }); + }); + + it('excludes one cancelled occurrence on the series instead of deleting it', () => { + const series = stored({ recurrenceRules: WEEKLY, recurrenceOverrides: null }); + expect(planCancellationRemoval([series], { + uid: 'meeting@example.com', + recurrenceId: '2026-11-09T10:00:00', + recurrenceIdTimeZone: 'Europe/Berlin', + sequence: 1, + })).toEqual({ + kind: 'exclude', + id: 'ev-1', + patch: { recurrenceOverrides: { '2026-11-09T10:00:00': { excluded: true } } }, + }); + }); + + it('names the occurrence in the series time zone and keeps the other overrides', () => { + const series = stored({ + recurrenceRules: WEEKLY, + recurrenceOverrides: { '2026-11-16T10:00:00': { title: 'Moved sync' } }, + }); + // 09:00 UTC is 10:00 in Berlin on 9 November. + expect(planCancellationRemoval([series], { + uid: 'meeting@example.com', + recurrenceId: '2026-11-09T09:00:00', + recurrenceIdTimeZone: 'Etc/UTC', + })).toEqual({ + kind: 'exclude', + id: 'ev-1', + patch: { 'recurrenceOverrides/2026-11-09T10:00:00': { excluded: true } }, + }); + }); + + it('replaces the override Stalwart wrote for a cancelled occurrence', () => { + const series = stored({ + recurrenceRules: WEEKLY, + recurrenceOverrides: { '2026-11-09T10:00:00': { status: 'cancelled', sequence: 1 } }, + }); + expect(planCancellationRemoval([series], { + uid: 'meeting@example.com', + recurrenceId: '2026-11-09T10:00:00', + recurrenceIdTimeZone: 'Europe/Berlin', + sequence: 1, + })).toEqual({ + kind: 'exclude', + id: 'ev-1', + patch: { 'recurrenceOverrides/2026-11-09T10:00:00': { excluded: true } }, + }); + }); + + it('leaves an occurrence that was changed after the cancellation was sent', () => { + const series = stored({ + recurrenceRules: WEEKLY, + recurrenceOverrides: { '2026-11-09T10:00:00': { title: 'Back on', sequence: 4 } }, + }); + expect(planCancellationRemoval([series], { + uid: 'meeting@example.com', + recurrenceId: '2026-11-09T10:00:00', + recurrenceIdTimeZone: 'Europe/Berlin', + sequence: 3, + })).toEqual({ kind: 'outdated' }); + }); + + it('removes nothing for an occurrence already taken out of the series', () => { + const series = stored({ + recurrenceRules: WEEKLY, + recurrenceOverrides: { '2026-11-09T10:00:00': { excluded: true } as Partial }, + }); + expect(planCancellationRemoval([series], { + uid: 'meeting@example.com', + recurrenceId: '2026-11-09T10:00:00', + recurrenceIdTimeZone: 'Europe/Berlin', + })).toEqual({ kind: 'none' }); + }); + + it('does not write the whole overrides map when the series was read without it', () => { + const series = stored({ recurrenceRules: WEEKLY, recurrenceOverrides: undefined }); + expect(planCancellationRemoval([series], { + uid: 'meeting@example.com', + recurrenceId: '2026-11-09T10:00:00', + recurrenceIdTimeZone: 'Europe/Berlin', + })).toEqual({ kind: 'none' }); + }); + + it('deletes an occurrence the calendar holds on its own', () => { + const occurrence = stored({ id: 'occ', recurrenceId: '2026-11-09T10:00:00', recurrenceIdTimeZone: 'Europe/Berlin' }); + expect(planCancellationRemoval([occurrence], { + uid: 'meeting@example.com', + recurrenceId: '2026-11-09T10:00:00', + recurrenceIdTimeZone: 'Europe/Berlin', + sequence: 1, + })).toEqual({ kind: 'delete', ids: ['occ'] }); + }); +}); + +describe('canRemoveCancelled', () => { + const removal: CancellationRemoval = { kind: 'delete', ids: ['ev-1'] }; + const base = { isCancellation: true, userIsSender: false, trustLevel: 'trusted', removal }; + + it('offers removal for a trusted cancellation of a meeting the calendar holds', () => { + expect(canRemoveCancelled(base)).toBe(true); + expect(canRemoveCancelled({ ...base, trustLevel: 'caution' })).toBe(true); + expect(canRemoveCancelled({ ...base, removal: { kind: 'exclude', id: 'ev-1', patch: {} } })).toBe(true); + }); + + it('does not let a cancellation that fails the trust check, or the user\'s own, remove anything', () => { + expect(canRemoveCancelled({ ...base, trustLevel: 'warning' })).toBe(false); + expect(canRemoveCancelled({ ...base, userIsSender: true })).toBe(false); + expect(canRemoveCancelled({ ...base, isCancellation: false })).toBe(false); + }); + + it('offers nothing for an outdated cancellation or one with nothing left to remove', () => { + expect(canRemoveCancelled({ ...base, removal: { kind: 'outdated' } })).toBe(false); + expect(canRemoveCancelled({ ...base, removal: { kind: 'none' } })).toBe(false); + expect(canRemoveCancelled({ ...base, removal: null })).toBe(false); + }); +}); + +describe('removeCancelledMeeting', () => { + it('deletes the stored events without telling anyone, planned on the events as they are now', async () => { + const deleteEvent = vi.fn().mockResolvedValue(undefined); + const updateEvent = vi.fn().mockResolvedValue(undefined); + const findEventsByUid = vi.fn().mockResolvedValue([stored({ id: 'ev-1' }), stored({ id: 'ev-2', recurrenceId: '2026-11-09T10:00:00' })]); + const done = await removeCancelledMeeting({ uid: 'meeting@example.com', sequence: 1 }, { findEventsByUid, deleteEvent, updateEvent }); + expect(done).toEqual({ kind: 'delete', ids: ['ev-1', 'ev-2'] }); + expect(findEventsByUid).toHaveBeenCalledWith('meeting@example.com'); + expect(deleteEvent.mock.calls).toEqual([ + ['ev-1', { sendSchedulingMessages: false }], + ['ev-2', { sendSchedulingMessages: false }], + ]); + expect(updateEvent).not.toHaveBeenCalled(); + }); + + it('excludes one occurrence on the series', async () => { + const deleteEvent = vi.fn(); + const updateEvent = vi.fn().mockResolvedValue(undefined); + const findEventsByUid = vi.fn().mockResolvedValue([stored({ recurrenceRules: WEEKLY })]); + await removeCancelledMeeting( + { uid: 'meeting@example.com', recurrenceId: '2026-11-09T10:00:00', recurrenceIdTimeZone: 'Europe/Berlin' }, + { findEventsByUid, deleteEvent, updateEvent }, + ); + expect(updateEvent).toHaveBeenCalledWith( + 'ev-1', + { recurrenceOverrides: { '2026-11-09T10:00:00': { excluded: true } } }, + { sendSchedulingMessages: false }, + ); + expect(deleteEvent).not.toHaveBeenCalled(); + }); + + it('changes nothing when the calendar has a newer version by now', async () => { + const deleteEvent = vi.fn(); + const updateEvent = vi.fn(); + const findEventsByUid = vi.fn().mockResolvedValue([stored({ sequence: 5 })]); + expect(await removeCancelledMeeting({ uid: 'meeting@example.com', sequence: 2 }, { findEventsByUid, deleteEvent, updateEvent })) + .toEqual({ kind: 'outdated' }); + expect(deleteEvent).not.toHaveBeenCalled(); + expect(updateEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/calendar-cancellation.ts b/src/lib/calendar-cancellation.ts new file mode 100644 index 00000000..3ec71af7 --- /dev/null +++ b/src/lib/calendar-cancellation.ts @@ -0,0 +1,129 @@ +import type { CalendarEvent } from '../api/types'; +import { buildFallbackExcludePatch } from './recurrence-instances'; +import { formatWallClock, isValidTimeZone, localDateTimeToInstant } from './calendar-timezone'; + +// Ported from the webmail's lib/calendar-cancellation.ts (#1425). + +/** + * `b`'s recurrence id as a local date-time in the zone of `a`'s, each read in + * its own zone (`recurrenceIdTimeZone`, else the event's `timeZone`). Without + * a usable zone on either side it is taken as it is. + */ +function recurrenceIdInZoneOf(a: Partial, b: Partial): string | null { + if (!b.recurrenceId) return null; + const zoneA = a.recurrenceIdTimeZone ?? a.timeZone ?? null; + const zoneB = b.recurrenceIdTimeZone ?? b.timeZone ?? null; + const local = b.recurrenceId.slice(0, 19); + if (!zoneA || !zoneB || !isValidTimeZone(zoneA) || !isValidTimeZone(zoneB)) return local; + const instant = localDateTimeToInstant(local, zoneB); + return instant ? formatWallClock(instant, zoneA) : local; +} + +/** Whether two events name the same occurrence: their recurrence ids at the same moment, each in its own zone. */ +export function sameRecurrenceId(a: Partial, b: Partial): boolean { + if (!a.recurrenceId || !b.recurrenceId) return false; + return a.recurrenceId.slice(0, 19) === recurrenceIdInZoneOf(a, b); +} + +/** + * What removing a cancelled meeting from the calendar takes: + * - `delete`: destroy these stored events (the series, or an occurrence + * stored on its own). + * - `exclude`: write `patch` on the series `id`, taking one occurrence out. + * - `outdated`: the calendar holds a newer version than the cancellation. + * - `none`: nothing in the calendar to remove. + */ +export type CancellationRemoval = + | { kind: 'delete'; ids: string[] } + | { kind: 'exclude'; id: string; patch: Record } + | { kind: 'outdated' } + | { kind: 'none' }; + +/** + * How to remove what an iTIP CANCEL (RFC 5546 3.2.5) names from `stored`, + * the events stored with its uid. Stalwart applies a cancellation itself but + * only marks the event, or the occurrence, STATUS:CANCELLED and keeps it. + * A cancellation without a recurrence id is for the whole meeting; one with + * a recurrence id is for that occurrence only. A cancellation with a lower + * SEQUENCE than the event it names is older than the event and is not + * applied, as Stalwart does not apply it either. + */ +export function planCancellationRemoval( + stored: CalendarEvent[], + cancel: Partial, +): CancellationRemoval { + if (!cancel.uid) return { kind: 'none' }; + const sameUid = stored.filter((event) => event.uid === cancel.uid); + const cancelSequence = cancel.sequence ?? 0; + const isNewer = (sequence: number | null | undefined) => (sequence ?? 0) > cancelSequence; + + if (!cancel.recurrenceId) { + if (sameUid.length === 0) return { kind: 'none' }; + if (sameUid.some((event) => isNewer(event.sequence))) return { kind: 'outdated' }; + return { kind: 'delete', ids: sameUid.map((event) => event.id) }; + } + + const ownEvent = sameUid.find((event) => sameRecurrenceId(event, cancel)); + if (ownEvent) { + return isNewer(ownEvent.sequence) ? { kind: 'outdated' } : { kind: 'delete', ids: [ownEvent.id] }; + } + + const series = sameUid.find((event) => !event.recurrenceId && (event.recurrenceRules?.length ?? 0) > 0); + // Overrides not read: a whole-map write would drop the ones there are. + if (!series || series.recurrenceOverrides === undefined) return { kind: 'none' }; + const key = recurrenceIdInZoneOf(series, cancel); + if (!key) return { kind: 'none' }; + const override = series.recurrenceOverrides?.[key] as (Partial & { excluded?: boolean }) | undefined; + if (override?.excluded) return { kind: 'none' }; + if (override && isNewer(override.sequence)) return { kind: 'outdated' }; + const patch = buildFallbackExcludePatch({ + id: series.id, + start: '', + recurrenceId: key, + recurrenceOverrides: series.recurrenceOverrides, + }); + return patch ? { kind: 'exclude', id: series.id, patch } : { kind: 'none' }; +} + +/** + * Whether the banner offers "Remove from calendar": a cancellation the user + * didn't send, that doesn't fail the invitation trust check (authentication, + * sender not the organizer), for a meeting the calendar still holds. + */ +export function canRemoveCancelled(input: { + isCancellation: boolean; + userIsSender: boolean; + trustLevel: string | null | undefined; + removal: CancellationRemoval | null; +}): boolean { + return input.isCancellation + && !input.userIsSender + && input.trustLevel !== 'warning' + && (input.removal?.kind === 'delete' || input.removal?.kind === 'exclude'); +} + +export interface CancellationActions { + findEventsByUid: (uid: string) => Promise; + deleteEvent: (id: string, options: { sendSchedulingMessages: boolean }) => Promise; + updateEvent: (id: string, patch: Record, options: { sendSchedulingMessages: boolean }) => Promise; +} + +/** + * Remove what `cancel` names from the calendar, planned again on the events + * as they are now: the calendar may have changed since the mail was opened. + * No scheduling messages: the organizer cancelled it, there is no one to + * tell. Returns the plan it carried out (`none`/`outdated`: nothing done). + */ +export async function removeCancelledMeeting( + cancel: Partial, + actions: CancellationActions, +): Promise { + if (!cancel.uid) return { kind: 'none' }; + const removal = planCancellationRemoval(await actions.findEventsByUid(cancel.uid), cancel); + if (removal.kind === 'delete') { + for (const id of removal.ids) await actions.deleteEvent(id, { sendSchedulingMessages: false }); + } else if (removal.kind === 'exclude') { + await actions.updateEvent(removal.id, removal.patch, { sendSchedulingMessages: false }); + } + return removal; +} diff --git a/src/stores/__tests__/calendar-store.test.ts b/src/stores/__tests__/calendar-store.test.ts index ab33af99..9f3bbd3d 100644 --- a/src/stores/__tests__/calendar-store.test.ts +++ b/src/stores/__tests__/calendar-store.test.ts @@ -669,6 +669,22 @@ describe('calendar-store', () => { expect(useCalendarStore.getState().events[0].id).toBe('ev2'); }); + it('sends no scheduling message for a meeting with attendees when asked not to (a cancelled one)', async () => { + const participants = { + o: { roles: { owner: true }, calendarAddress: 'mailto:boss@example.com' }, + me: { roles: { attendee: true }, calendarAddress: 'mailto:me@example.com' }, + }; + useCalendarStore.setState({ events: [{ id: 'ev1', participants } as any] }); + mockDeleteEvents.mockResolvedValue(undefined); + + await useCalendarStore.getState().deleteEvent('ev1', { sendSchedulingMessages: false }); + expect(mockDeleteEvents).toHaveBeenLastCalledWith(['ev1'], false, 'acc-1'); + + useCalendarStore.setState({ events: [{ id: 'ev2', participants } as any] }); + await useCalendarStore.getState().deleteEvent('ev2'); + expect(mockDeleteEvents).toHaveBeenLastCalledWith(['ev2'], true, 'acc-1'); + }); + it('should drop every expanded occurrence of a destroyed master and refetch', async () => { useCalendarStore.setState({ calendars: [{ id: 'cal-1', name: 'Personal' } as any], diff --git a/src/stores/calendar-store.ts b/src/stores/calendar-store.ts index cfedee56..df9e4a15 100644 --- a/src/stores/calendar-store.ts +++ b/src/stores/calendar-store.ts @@ -263,7 +263,7 @@ export interface CalendarState { changes: Partial | Record, options?: { sendSchedulingMessages?: boolean }, ) => Promise; - deleteEvent: (id: string) => Promise; + deleteEvent: (id: string, options?: { sendSchedulingMessages?: boolean }) => Promise; // Resolve an expanded occurrence (or a master) to its master event, read // from the server: a copy the app holds may be older. getMasterEvent: (event: CalendarEvent) => Promise; @@ -1210,7 +1210,7 @@ export const useCalendarStore = create()( } }, - deleteEvent: async (id) => { + deleteEvent: async (id, options) => { // Deleting an occurrence removes just that one: through its synthetic // id, or by excluding it on the base event it was expanded from. const scope = writeScope(); @@ -1218,7 +1218,8 @@ export const useCalendarStore = create()( const { storeEvent, realId, accountId } = target; const touchesSeries = (!!storeEvent && isRecurringSeriesMember(storeEvent)) || hasServerOccurrencesOf(get().events, realId, accountId); - const schedule = hasSchedulingParticipants(storeEvent) ? true : undefined; + const schedule = options?.sendSchedulingMessages + ?? (hasSchedulingParticipants(storeEvent) ? true : undefined); if (target.isOccurrence && storeEvent) { await destroyOccurrence(storeEvent, realId, schedule, writeAccount(scope, accountId), scope); assertInScope(scope);