mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
feat: offer to remove a cancelled meeting from the calendar in the cancellation mail (bulwarkmail/webmail#1425)
This commit is contained in:
@@ -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<T>(fn: () => Promise<T>): Promise<T> {
|
||||
(await singleton()).restoreSnapshot(other!.snapshot());
|
||||
useCalendarStore.getState().reset();
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
await connectSingleton();
|
||||
}
|
||||
}
|
||||
|
||||
function removeAsOther(cancel: Partial<CalendarEvent>) {
|
||||
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 = '';
|
||||
|
||||
@@ -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<string | null>(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<CancellationRemoval | null>(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' && (
|
||||
<Text style={styles.cancelled}>{t('calendar.invitation.cancelled', 'This event was cancelled')}</Text>
|
||||
)}
|
||||
{cancellationOutdated && (
|
||||
<Text style={styles.subtitle}>
|
||||
{t('calendar.invitation.cancel_outdated', 'Your calendar has a newer version of this event than this cancellation.')}
|
||||
</Text>
|
||||
)}
|
||||
{method === 'reply' && (
|
||||
<Text style={styles.subtitle}>{t('calendar.invitation.reply', 'A participant replied to this invitation')}</Text>
|
||||
)}
|
||||
@@ -360,7 +427,21 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) {
|
||||
icon={<X size={15} color={currentStatus === 'declined' ? c.textInverse : c.error} />}
|
||||
disabled={busy} onPress={() => ensureImportedAndRsvp('declined')} c={c} styles={styles} />
|
||||
</>
|
||||
) : !existing && method !== 'reply' ? (
|
||||
) : canRemoveCancelled ? (
|
||||
<Pressable
|
||||
style={[styles.removeBtn, busy && { opacity: 0.5 }]}
|
||||
onPress={() => { void handleRemoveCancelled(); }}
|
||||
disabled={busy}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
{busy ? (
|
||||
<ActivityIndicator size="small" color={c.error} />
|
||||
) : (
|
||||
<Trash2 size={16} color={c.error} />
|
||||
)}
|
||||
<Text style={styles.removeBtnText}>{t('calendar.invitation.remove_from_calendar', 'Remove from calendar')}</Text>
|
||||
</Pressable>
|
||||
) : !existing && method !== 'reply' && method !== 'cancel' ? (
|
||||
<Pressable
|
||||
style={[styles.importBtn, (busy || !targetCalendar) && { opacity: 0.5 }]}
|
||||
onPress={() => { 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 },
|
||||
});
|
||||
|
||||
@@ -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>): 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<CalendarEvent> },
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<CalendarEvent>, b: Partial<CalendarEvent>): 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<CalendarEvent>, b: Partial<CalendarEvent>): 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<string, unknown> }
|
||||
| { 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<CalendarEvent>,
|
||||
): 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<CalendarEvent> & { 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<CalendarEvent[]>;
|
||||
deleteEvent: (id: string, options: { sendSchedulingMessages: boolean }) => Promise<void>;
|
||||
updateEvent: (id: string, patch: Record<string, unknown>, options: { sendSchedulingMessages: boolean }) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<CalendarEvent>,
|
||||
actions: CancellationActions,
|
||||
): Promise<CancellationRemoval> {
|
||||
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;
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -263,7 +263,7 @@ export interface CalendarState {
|
||||
changes: Partial<CalendarEvent> | Record<string, unknown>,
|
||||
options?: { sendSchedulingMessages?: boolean },
|
||||
) => Promise<void>;
|
||||
deleteEvent: (id: string) => Promise<void>;
|
||||
deleteEvent: (id: string, options?: { sendSchedulingMessages?: boolean }) => Promise<void>;
|
||||
// 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<CalendarEvent | null>;
|
||||
@@ -1210,7 +1210,7 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
}
|
||||
},
|
||||
|
||||
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<CalendarState>()(
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user