feat: show when someone invites you to, changes or cancels a calendar event

This commit is contained in:
Linus Rath
2026-10-31 14:28:18 +01:00
parent e6a89ad07a
commit d663a5e282
5 changed files with 588 additions and 0 deletions
@@ -0,0 +1,171 @@
/**
* Live CalendarEventNotification checks against a real Stalwart.
*
* Opt-in like live-stalwart-calendar.test.ts: skipped unless
* `BULWARK_LIVE_JMAP`, `BULWARK_LIVE_PASS` and `BULWARK_LIVE_USER2` (same
* password) are set. The second account invites the first one to an event in
* 2027 and then cancels it; the first one reads the notifications the server
* keeps for that and acknowledges them. Only the notifications of this run
* are destroyed, and the event carries the run's marker and is removed from
* both accounts afterwards.
*
* BULWARK_LIVE_JMAP=http://10.10.10.62:8016 \
* BULWARK_LIVE_USER=e2e-perf@example.org \
* BULWARK_LIVE_USER2=e2e-tablet-land@example.org \
* BULWARK_LIVE_PASS=… npx vitest run src/api/__tests__/live-stalwart-event-notifications.test.ts
*
* Verified against Stalwart 0.16.27 and 1.0.1.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { JMAPClient } from '../jmap-client';
import { CAPABILITIES } from '../types';
import {
destroyCalendarEventNotifications,
getCalendarEventNotifications,
type CalendarEventNotification,
} from '../calendar-event-notifications';
import { eventNotificationToast } from '../../lib/calendar-event-notifications';
const SERVER = process.env.BULWARK_LIVE_JMAP;
const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org';
const USER2 = process.env.BULWARK_LIVE_USER2;
const PASS = process.env.BULWARK_LIVE_PASS ?? '';
const liveTwo = SERVER && PASS && USER2 ? describe : describe.skip;
const USING = [CAPABILITIES.CORE, CAPABILITIES.CALENDARS];
const RUN = `live-cen-${Date.now().toString(36)}`;
type Response = [string, Record<string, any>, string];
async function raw(c: JMAPClient, calls: [string, Record<string, unknown>, string][]): Promise<Response[]> {
return (await c.request(calls, USING)).methodResponses as Response[];
}
async function waitFor<T>(read: () => Promise<T>, done: (value: T) => boolean, ms = 15_000): Promise<T> {
const until = Date.now() + ms;
let value = await read();
while (!done(value) && Date.now() < until) {
await new Promise((resolve) => setTimeout(resolve, 500));
value = await read();
}
return value;
}
/** Destroy every event of `c`'s account whose title carries the run's marker. */
async function removeRunEvents(c: JMAPClient): Promise<void> {
const res = await raw(c, [
['CalendarEvent/query', { accountId: c.accountId }, 'q'],
['CalendarEvent/get', {
accountId: c.accountId,
'#ids': { resultOf: 'q', name: 'CalendarEvent/query', path: '/ids' },
properties: ['id', 'title'],
}, 'g'],
]);
const ids = ((res.find((r) => r[2] === 'g')?.[1].list ?? []) as { id: string; title?: string }[])
.filter((e) => e.title?.includes(RUN))
.map((e) => e.id);
if (ids.length > 0) {
await raw(c, [['CalendarEvent/set', { accountId: c.accountId, destroy: ids, sendSchedulingMessages: false }, 'd']]);
}
}
const t = (_key: string, fallback?: string, params?: Record<string, string | number>) =>
(fallback ?? '').replace(/\{(\w+)\}/g, (_, k: string) => String(params?.[k] ?? ''));
liveTwo('live Stalwart calendar event notifications', () => {
let other: JMAPClient;
let before = new Set<string>();
const ours = new Set<string>();
let organizerEventId = '';
let attendeeEventId = '';
const fresh = async (): Promise<CalendarEventNotification[]> =>
(await getCalendarEventNotifications()).filter((n) => !before.has(n.id));
beforeAll(async () => {
const mod = await import('../jmap-client');
const client = new JMAPClient();
await client.connect(SERVER!, USER, PASS);
(mod.jmapClient as unknown as JMAPClient).restoreSnapshot(client.snapshot());
other = new JMAPClient();
await other.connect(SERVER!, USER2!, PASS);
// Whatever is pending already belongs to someone else's run.
before = new Set((await getCalendarEventNotifications()).map((n) => n.id));
}, 60_000);
afterAll(async () => {
const leftover = (await getCalendarEventNotifications().catch(() => []))
.filter((n) => ours.has(n.id) || (!before.has(n.id) && n.event?.title?.includes(RUN)));
await destroyCalendarEventNotifications(leftover.map((n) => n.id)).catch(() => undefined);
await removeRunEvents(other).catch(() => undefined);
const mod = await import('../jmap-client');
await removeRunEvents(mod.jmapClient as unknown as JMAPClient).catch(() => undefined);
}, 60_000);
it('lists the invitation another account sent, with who sent it and the event’s title', async () => {
const cals = await raw(other, [['Calendar/get', { accountId: other.accountId, properties: ['id', 'isDefault'] }, '0']]);
const list = cals[0][1].list as { id: string; isDefault?: boolean }[];
const calendarId = (list.find((c) => c.isDefault) ?? list[0]).id;
const created = await raw(other, [['CalendarEvent/set', {
accountId: other.accountId,
create: {
inv: {
'@type': 'Event',
title: `${RUN} review`,
calendarIds: { [calendarId]: true },
start: '2027-06-08T10:00:00',
timeZone: 'Europe/Berlin',
duration: 'PT1H',
organizerCalendarAddress: `mailto:${USER2}`,
participants: {
org: {
'@type': 'Participant', calendarAddress: `mailto:${USER2}`, email: USER2,
roles: { owner: true, attendee: true }, participationStatus: 'accepted', kind: 'individual',
},
att: {
'@type': 'Participant', calendarAddress: `mailto:${USER}`, email: USER,
roles: { attendee: true }, participationStatus: 'needs-action', expectReply: true, kind: 'individual',
},
},
},
},
sendSchedulingMessages: true,
}, '0']]);
organizerEventId = created[0][1].created?.inv?.id ?? '';
expect(organizerEventId, JSON.stringify(created[0][1].notCreated)).toBeTruthy();
const notes = await waitFor(fresh, (l) => l.some((n) => n.type === 'created'));
const invite = notes.find((n) => n.type === 'created');
expect(invite, JSON.stringify(notes)).toBeTruthy();
ours.add(invite!.id);
attendeeEventId = invite!.calendarEventId;
expect(invite!.changedBy?.email?.toLowerCase()).toBe(USER2!.toLowerCase());
expect(invite!.isDraft).toBe(false);
// 1.0 returns the event with the notification; 0.16 doesn't, and the
// title is read from the attendee's copy instead.
expect(invite!.event?.title).toBe(`${RUN} review`);
expect(eventNotificationToast(invite!, t)!.title).toContain(`invited you to "${RUN} review"`);
});
it('lists the cancellation, and forgets both once acknowledged', async () => {
expect(organizerEventId).toBeTruthy();
await raw(other, [['CalendarEvent/set', {
accountId: other.accountId,
destroy: [organizerEventId],
sendSchedulingMessages: true,
}, '0']]);
const notes = await waitFor(fresh, (l) => l.some((n) => n.type === 'destroyed' || (n.type === 'updated' && n.calendarEventId === attendeeEventId && !ours.has(n.id))));
const cancel = notes.find((n) => n.calendarEventId === attendeeEventId && !ours.has(n.id));
expect(cancel, JSON.stringify(notes)).toBeTruthy();
ours.add(cancel!.id);
const toast = eventNotificationToast(cancel!, t)!;
if (cancel!.type === 'destroyed') expect(toast.type).toBe('warning');
await destroyCalendarEventNotifications([...ours]);
const after = (await getCalendarEventNotifications()).map((n) => n.id);
for (const id of ours) expect(after).not.toContain(id);
// Nobody else's notifications went with them.
for (const id of before) expect(after).toContain(id);
});
});
+94
View File
@@ -0,0 +1,94 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import { batched } from './jmap-result';
// CalendarEventNotification (draft-ietf-jmap-calendars §7): the server
// records every invitation, update and cancellation another participant made
// to an event this user takes part in. Ported from the webmail's
// getCalendarEventNotifications / destroyCalendarEventNotifications in
// lib/jmap/client.ts.
export interface CalendarEventNotification {
id: string;
created: string;
changedBy?: {
name?: string | null;
email?: string | null;
principalId?: string | null;
} | null;
comment?: string | null;
type: 'created' | 'updated' | 'destroyed';
calendarEventId: string;
isDraft?: boolean;
/** The event as it is now; 1.0 returns it, 0.16 leaves it out. */
event?: { title?: string | null } | null;
}
const USING = [CAPABILITIES.CORE, CAPABILITIES.CALENDARS];
/**
* Pending event notifications, oldest first. `event` is asked for its title
* only; Stalwart 0.16 doesn't return it, so the titles it lacks are read from
* the events themselves (a cancelled event is gone and stays untitled).
* Throws when the server refuses the listing.
*/
export async function getCalendarEventNotifications(): Promise<CalendarEventNotification[]> {
const accountId = jmapClient.accountId;
const response = await jmapClient.request([
['CalendarEventNotification/query', {
accountId,
sort: [{ property: 'created', isAscending: true }],
}, '0'],
['CalendarEventNotification/get', {
accountId,
'#ids': { resultOf: '0', name: 'CalendarEventNotification/query', path: '/ids' },
properties: ['id', 'created', 'changedBy', 'comment', 'type', 'calendarEventId', 'isDraft', 'event'],
}, '1'],
], USING);
const responses = (response.methodResponses ?? []) as [string, any, string][];
const getResp = responses.find((r) => r[0] === 'CalendarEventNotification/get');
if (!getResp) {
const error = responses.find((r) => r[0] === 'error')?.[1] as { description?: string } | undefined;
throw new Error(error?.description || 'Failed to load calendar event notifications');
}
const list = (getResp[1]?.list ?? []) as CalendarEventNotification[];
await fillMissingTitles(accountId, list);
return list;
}
async function fillMissingTitles(accountId: string, list: CalendarEventNotification[]): Promise<void> {
const ids = Array.from(new Set(
list.filter((n) => !n.isDraft && n.type !== 'destroyed' && !n.event?.title && n.calendarEventId)
.map((n) => n.calendarEventId),
));
if (ids.length === 0) return;
try {
const response = await jmapClient.request(
[['CalendarEvent/get', { accountId, ids, properties: ['id', 'title'] }, '0']],
USING,
);
const [method, result] = response.methodResponses?.[0] ?? [];
if (method !== 'CalendarEvent/get') return;
const titles = new Map<string, string>();
for (const event of (result?.list ?? []) as { id: string; title?: string | null }[]) {
if (event.title) titles.set(event.id, event.title);
}
for (const n of list) {
const title = titles.get(n.calendarEventId);
if (title && !n.event?.title) n.event = { ...(n.event ?? {}), title };
}
} catch {
// Untitled it is.
}
}
/** Acknowledges (destroys) event notifications that have been shown. */
export async function destroyCalendarEventNotifications(ids: string[]): Promise<void> {
if (ids.length === 0) return;
const accountId = jmapClient.accountId;
for (const batch of batched(ids, jmapClient.getMaxObjectsInSet())) {
await jmapClient.request([
['CalendarEventNotification/set', { accountId, destroy: batch }, '0'],
], USING);
}
}