feat: offer to remove a cancelled meeting from the calendar in the cancellation mail (bulwarkmail/webmail#1425)

This commit is contained in:
Linus Rath
2026-10-31 03:44:02 +01:00
parent a2344ea0ae
commit a19afe9b48
6 changed files with 535 additions and 5 deletions
@@ -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();
});
});
+129
View File
@@ -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;
}