mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 08:16:22 +00:00
refactor: answer invitations from one place the reader's banner and plugins share
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user