refactor: answer invitations from one place the reader's banner and plugins share

This commit is contained in:
Linus Rath
2026-11-02 23:14:21 +01:00
parent 2dbf565345
commit 7dd5fe41c3
3 changed files with 247 additions and 88 deletions
+21 -88
View File
@@ -1,6 +1,7 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { answerInvitation } from '@/lib/calendar-rsvp';
import { createPortal } from 'react-dom';
import {
ArrowRight,
@@ -342,26 +343,6 @@ function buildInvitationChangeItems(
return changes;
}
function buildParticipantsForRsvp(
event: Partial<CalendarEvent>,
participantId: string,
status: 'accepted' | 'tentative' | 'declined',
): Record<string, NonNullable<CalendarEvent['participants']>[string]> | null {
if (!event.participants) {
return null;
}
return Object.fromEntries(
Object.entries(event.participants).map(([id, participant]) => [
id,
{
...participant,
participationStatus: id === participantId ? status : participant.participationStatus,
},
]),
);
}
function getMethodIconTone(method: InvitationMethod, actorStatus?: string | null): string {
switch (method) {
case 'cancel':
@@ -733,78 +714,30 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
setActionNotice(null);
setActionError(null);
setIsProcessing(true);
const replyToForRsvp = parsedEvent.replyTo
|| (parsedEvent.organizerCalendarAddress ? { imip: parsedEvent.organizerCalendarAddress } : null);
// The iMIP REPLY to the organizer is sent by the server: rsvpEvent /
// updateEvent below pass sendSchedulingMessages=true to CalendarEvent/set
// and Stalwart queues the iTIP REPLY itself. A manual client-side reply
// here produced duplicate emails.
try {
// A copy in a shared or group account may not be loaded yet: look it up
// there before adding the invitation again.
const eventForRsvp = existingEvent
? await resolveExistingEventForRsvp()
: sharedMailAccount && parsedEvent.uid ? await loadSharedCopy(parsedEvent.uid) : null;
const existingEventParticipant = eventForRsvp
? findParticipantByEmails(eventForRsvp, actingEmails)
: null;
const canFallbackToParsedParticipant = Boolean(
existingEvent
&& myParticipant
&& (!eventForRsvp?.participants || Object.keys(eventForRsvp.participants).length === 0)
);
if (eventForRsvp && existingEventParticipant) {
// false: a plugin stopped it and said why; keep the old answer.
if (!(await (answerOptions ? rsvpEvent(client, eventForRsvp.id, existingEventParticipant.id, status, replyToForRsvp, undefined, answerOptions) : rsvpEvent(client, eventForRsvp.id, existingEventParticipant.id, status, replyToForRsvp)))) return;
const outcome = await answerInvitation({
client,
store: { rsvpEvent, updateEvent, importEvents, events: () => useCalendarStore.getState().events },
parsedEvent,
existingEvent,
myParticipant,
actingEmails,
calendarId: calId,
sharedMailAccount,
answerOptions,
resolveExisting: () => (existingEvent
? resolveExistingEventForRsvp()
: parsedEvent.uid ? loadSharedCopy(parsedEvent.uid) : Promise.resolve(null)),
}, status);
// 'stopped': a plugin stopped it and said why; keep the old answer.
if (outcome === 'answered') {
setRsvpStatus(status);
setActionNotice(t('rsvp_sent'));
setState('parsed');
} else if (eventForRsvp && canFallbackToParsedParticipant && myParticipant) {
const repairedParticipants = buildParticipantsForRsvp(parsedEvent, myParticipant.id, status);
if (!repairedParticipants) {
setActionError(t('action_failed'));
} else {
const updated = await updateEvent(client, eventForRsvp.id, {
participants: repairedParticipants,
// Stalwart routes the iTIP REPLY via the stored ORGANIZER
// (organizerCalendarAddress; the RFC 8984 replyTo is retired).
// Only repair a missing organizer - attendees may not modify it.
...(replyToForRsvp?.imip && !eventForRsvp.organizerCalendarAddress
? { organizerCalendarAddress: replyToForRsvp.imip }
: {}),
}, true);
if (!updated) return;
setRsvpStatus(status);
setActionNotice(t('rsvp_sent'));
setState('parsed');
}
} else if (eventForRsvp) {
setActionError(t('action_failed'));
} else if (calId) {
// A refusal (Stalwart 1.0's participant limit, say) lands in the
// catch below and is said as it is.
const imported = await importEvents(client, [parsedEvent], calId, { throwIfNoneImported: true });
if (imported === null) return;
if (imported > 0) {
const newEvent = useCalendarStore.getState().events.find(
(e) => e.uid === parsedEvent.uid && (sharedMailAccount ? e.accountId === sharedMailAccount : !e.isShared)
);
const participant = myParticipant
|| (newEvent ? findParticipantByEmails(newEvent, actingEmails) : null);
if (newEvent && participant) {
if (!(await (answerOptions ? rsvpEvent(client, newEvent.id, participant.id, status, replyToForRsvp, undefined, answerOptions) : rsvpEvent(client, newEvent.id, participant.id, status, replyToForRsvp)))) return;
setRsvpStatus(status);
setActionNotice(t('rsvp_sent'));
} else {
setActionNotice(t('added'));
}
setState('parsed');
} else {
setActionError(t('action_failed'));
}
} else {
} else if (outcome === 'added') {
setActionNotice(t('added'));
setState('parsed');
} else if (outcome === 'failed') {
setActionError(t('action_failed'));
}
} catch (err) {
+73
View File
@@ -0,0 +1,73 @@
import { describe, expect, it, vi } from 'vitest';
import type { IJMAPClient } from '../jmap/client-interface';
import type { CalendarEvent } from '../jmap/types';
import { answerInvitation, attendeeOf, type RsvpRequest } from '../calendar-rsvp';
// The banner's three ways of answering an invitation, now in one place that
// calendar.respond uses too.
const me = { email: 'bob@example.org', roles: { attendee: true }, participationStatus: 'needs-action' };
const organiser = { email: 'alice@example.org', roles: { owner: true } };
const parsed = {
uid: 'u1', title: 'Team offsite', organizerCalendarAddress: 'mailto:alice@example.org',
participants: { p1: organiser, p2: me },
} as unknown as Partial<CalendarEvent>;
function request(over: Partial<RsvpRequest> = {}): RsvpRequest & { store: { rsvpEvent: ReturnType<typeof vi.fn>; updateEvent: ReturnType<typeof vi.fn>; importEvents: ReturnType<typeof vi.fn> } } {
const imported = { id: 'new', uid: 'u1', participants: { p1: organiser, p2: me } } as unknown as CalendarEvent;
return {
client: {} as IJMAPClient,
store: {
rsvpEvent: vi.fn(async () => true),
updateEvent: vi.fn(async () => true),
importEvents: vi.fn(async () => 1),
events: () => [imported],
},
parsedEvent: parsed,
existingEvent: null,
myParticipant: { id: 'p2', participant: me as never },
actingEmails: ['bob@example.org'],
calendarId: 'cal1',
sharedMailAccount: null,
resolveExisting: async () => null,
...over,
} as never;
}
describe('answerInvitation', () => {
it('answers on the stored copy when the user is among its participants', async () => {
const stored = { id: 'e1', uid: 'u1', participants: { a: organiser, b: me } } as unknown as CalendarEvent;
const req = request({ existingEvent: stored, resolveExisting: async () => stored });
expect(await answerInvitation(req, 'accepted')).toBe('answered');
expect(req.store.rsvpEvent).toHaveBeenCalledWith(req.client, 'e1', 'b', 'accepted', { imip: 'mailto:alice@example.org' });
expect(req.store.importEvents).not.toHaveBeenCalled();
});
it('repairs a stored copy that lost its participants', async () => {
const stored = { id: 'e1', uid: 'u1', participants: null } as unknown as CalendarEvent;
const req = request({ existingEvent: stored, resolveExisting: async () => stored });
expect(await answerInvitation(req, 'tentative')).toBe('answered');
expect(req.store.updateEvent).toHaveBeenCalledWith(req.client, 'e1', expect.objectContaining({
participants: expect.objectContaining({ p2: expect.objectContaining({ participationStatus: 'tentative' }) }),
organizerCalendarAddress: 'mailto:alice@example.org',
}), true);
});
it('adds the invitation to the calendar first when it is not there, then answers', async () => {
const req = request();
expect(await answerInvitation(req, 'declined')).toBe('answered');
expect(req.store.importEvents).toHaveBeenCalledWith(req.client, [parsed], 'cal1', { throwIfNoneImported: true });
expect(req.store.rsvpEvent).toHaveBeenCalledWith(req.client, 'new', 'p2', 'declined', { imip: 'mailto:alice@example.org' });
});
it('says when a plugin stopped it, when it only added the event, and when it can\'t', async () => {
expect(await answerInvitation(request({ store: { ...request().store, importEvents: vi.fn(async () => null) } } as never), 'accepted')).toBe('stopped');
expect(await answerInvitation(request({ myParticipant: null, store: { ...request().store, events: () => [{ id: 'new', uid: 'u1', participants: {} } as never] } } as never), 'accepted')).toBe('added');
expect(await answerInvitation(request({ calendarId: undefined }), 'accepted')).toBe('failed');
});
it('treats an owner who isn\'t an attendee as the organiser', () => {
expect(attendeeOf({ id: 'p1', participant: organiser as never })).toEqual({ participant: null, isOrganizer: true });
expect(attendeeOf({ id: 'p2', participant: me as never }).isOrganizer).toBe(false);
});
});
+153
View File
@@ -0,0 +1,153 @@
// Answering a calendar invitation: the reader's invitation banner and the
// plugin call calendar.respond take the same steps, here.
//
// The iMIP REPLY to the organiser is sent by the server: rsvpEvent and
// updateEvent pass sendSchedulingMessages=true to CalendarEvent/set and
// Stalwart queues the iTIP REPLY itself. A reply sent from here as well
// produced duplicate emails.
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { CalendarEvent, CalendarParticipant, Email } from '@/lib/jmap/types';
import {
extractMethodFromRawIcs, findCalendarAttachment, findParticipantByEmails, getInvitationMethod,
type InvitationMethod,
} from '@/lib/calendar-invitation';
export type RsvpStatus = 'accepted' | 'tentative' | 'declined';
export type ParticipantMatch = { id: string; participant: CalendarParticipant };
/** The calendar store's calls the answer goes through (so plugin hooks run and the calendar shows it). */
export interface RsvpStore {
rsvpEvent: (client: IJMAPClient, eventId: string, participantId: string, status: string, replyTo?: Record<string, string> | null, scope?: 'occurrence' | 'series', options?: { sentBy?: string }) => Promise<boolean>;
updateEvent: (client: IJMAPClient, id: string, updates: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<boolean>;
importEvents: (client: IJMAPClient, events: Partial<CalendarEvent>[], calendarId: string, options?: { throwIfNoneImported?: boolean }) => Promise<number | null>;
/** The store's events right now (read after an import). */
events: () => CalendarEvent[];
}
export interface RsvpRequest {
client: IJMAPClient;
store: RsvpStore;
/** The invitation as parsed from the message. */
parsedEvent: Partial<CalendarEvent>;
/** The copy in the calendar, if it is there. */
existingEvent: CalendarEvent | null | undefined;
/** The user's participant as found before (stored copy first, else the invitation). */
myParticipant: ParticipantMatch | null;
/** The addresses that pick "your" participant. */
actingEmails: readonly string[];
/** The calendar a new copy goes into. */
calendarId: string | undefined;
/** The shared or group account the message belongs to, else null. */
sharedMailAccount: string | null;
/** Answering for a person as their delegate: SENT-BY names the user. */
answerOptions?: { sentBy: string };
/** The stored copy with its participants loaded (or the shared account's copy). */
resolveExisting: () => Promise<CalendarEvent | null | undefined>;
}
/**
* What came of an answer: sent; only added to the calendar (the user isn't
* in its participants); stopped by a plugin (which said why); or failed.
*/
export type RsvpOutcome = 'answered' | 'added' | 'stopped' | 'failed';
function buildParticipantsForRsvp(
event: Partial<CalendarEvent>,
participantId: string,
status: RsvpStatus,
): Record<string, NonNullable<CalendarEvent['participants']>[string]> | null {
if (!event.participants) return null;
return Object.fromEntries(
Object.entries(event.participants).map(([id, participant]) => [
id,
{ ...participant, participationStatus: id === participantId ? status : participant.participationStatus },
]),
);
}
/**
* Answer the invitation: on the stored copy when the user is among its
* participants; by repairing a stored copy that lost its participants; or by
* adding the invitation to the calendar first and answering on the new copy.
* Throws what the server refused (said to the user as it is).
*/
export async function answerInvitation(req: RsvpRequest, status: RsvpStatus): Promise<RsvpOutcome> {
const { client, store, parsedEvent, existingEvent, myParticipant, actingEmails, calendarId, sharedMailAccount, answerOptions } = req;
const replyTo = parsedEvent.replyTo
|| (parsedEvent.organizerCalendarAddress ? { imip: parsedEvent.organizerCalendarAddress } : null);
const rsvp = (eventId: string, participantId: string) => (answerOptions
? store.rsvpEvent(client, eventId, participantId, status, replyTo, undefined, answerOptions)
: store.rsvpEvent(client, eventId, participantId, status, replyTo));
// A copy in a shared or group account may not be loaded yet: look it up
// there before adding the invitation again.
const eventForRsvp = existingEvent || sharedMailAccount ? await req.resolveExisting() : null;
const existingParticipant = eventForRsvp ? findParticipantByEmails(eventForRsvp, actingEmails) : null;
const canFallbackToParsedParticipant = Boolean(
existingEvent && myParticipant
&& (!eventForRsvp?.participants || Object.keys(eventForRsvp.participants).length === 0),
);
if (eventForRsvp && existingParticipant) {
// false: a plugin stopped it and said why; keep the old answer.
return (await rsvp(eventForRsvp.id, existingParticipant.id)) ? 'answered' : 'stopped';
}
if (eventForRsvp && canFallbackToParsedParticipant && myParticipant) {
const repaired = buildParticipantsForRsvp(parsedEvent, myParticipant.id, status);
if (!repaired) return 'failed';
const updated = await store.updateEvent(client, eventForRsvp.id, {
participants: repaired,
// Stalwart routes the iTIP REPLY via the stored ORGANIZER
// (organizerCalendarAddress; the RFC 8984 replyTo is retired).
// Only repair a missing organizer - attendees may not modify it.
...(replyTo?.imip && !eventForRsvp.organizerCalendarAddress ? { organizerCalendarAddress: replyTo.imip } : {}),
}, true);
return updated ? 'answered' : 'stopped';
}
if (eventForRsvp || !calendarId) return 'failed';
// A refusal (Stalwart 1.0's participant limit, say) is thrown as it is.
const imported = await store.importEvents(client, [parsedEvent], calendarId, { throwIfNoneImported: true });
if (imported === null) return 'stopped';
if (imported <= 0) return 'failed';
const newEvent = store.events().find(
(e) => e.uid === parsedEvent.uid && (sharedMailAccount ? e.accountId === sharedMailAccount : !e.isShared),
);
const participant = myParticipant || (newEvent ? findParticipantByEmails(newEvent, actingEmails) : null);
if (!newEvent || !participant) return 'added';
return (await rsvp(newEvent.id, participant.id)) ? 'answered' : 'stopped';
}
/** The user's participant, unless the user only organises the event (then no answer is due). */
export function attendeeOf(match: ParticipantMatch | null): { participant: ParticipantMatch | null; isOrganizer: boolean } {
const roles = match?.participant.roles;
const isOrganizer = roles ? Boolean((roles.owner || roles.chair) && !roles.attendee) : false;
return { participant: match && !isOrganizer ? match : null, isOrganizer };
}
/** The invitation in a message of the user's own account, read without the banner. */
export interface OwnInvitation {
parsedEvent: Partial<CalendarEvent>;
method: InvitationMethod;
}
/**
* Parse the invitation a message of the signed-in account carries: its
* first event and its METHOD (from the message, else the raw ICS, since
* JMAP drops the Content-Type parameters). Null when it carries none.
*/
export async function readOwnInvitation(client: IJMAPClient, email: Email): Promise<OwnInvitation | null> {
const attachment = findCalendarAttachment(email);
if (!attachment) return null;
const accountId = client.getAccountId();
const [events, rawText] = await Promise.all([
client.parseCalendarEvents(accountId, attachment.blobId),
client.fetchBlob(attachment.blobId, 'invite.ics', 'text/calendar', accountId).then((b) => b.text()).catch(() => null),
]);
const parsedEvent = events[0];
if (!parsedEvent) return null;
let method = getInvitationMethod(parsedEvent, { email, attachment });
if (method === 'unknown' && rawText) method = extractMethodFromRawIcs(rawText);
return { parsedEvent, method };
}