From 9dab8955dde8d186942a6cd4a975351dadb1dbcf Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 04:44:06 +0100 Subject: [PATCH] feat: confirm before inviting people outside the organisation to a meeting --- .../event-modal-external-invitees.test.tsx | 105 ++++++++++++++++++ components/calendar/event-modal.tsx | 9 ++ lib/outgoing/external-recipients.ts | 31 +++++- lib/outgoing/send-pipeline.ts | 2 +- 4 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 components/calendar/__tests__/event-modal-external-invitees.test.tsx diff --git a/components/calendar/__tests__/event-modal-external-invitees.test.tsx b/components/calendar/__tests__/event-modal-external-invitees.test.tsx new file mode 100644 index 000000000..0c3108d49 --- /dev/null +++ b/components/calendar/__tests__/event-modal-external-invitees.test.tsx @@ -0,0 +1,105 @@ +import { render, screen, fireEvent, waitFor, cleanup, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { EventModal } from '../event-modal'; +import { ExternalRecipientConfirmHost } from '@/components/email/external-recipient-confirm'; +import type { Calendar, CalendarEvent, Identity } from '@/lib/jmap/types'; +import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; +import { DEFAULT_POLICY } from '@/lib/admin/types'; +import { usePolicyStore } from '@/stores/policy-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { useContactStore } from '@/stores/contact-store'; +import { useExternalRecipientConfirmStore } from '@/stores/external-recipient-confirm-store'; +import { resetSendPipeline } from '@/lib/outgoing/send-pipeline'; + +// Saving a meeting sends its invitations (the server does, on save), so a +// newly invited person outside the organisation gets the same confirm as a +// message. People already invited aren't asked about again. + +vi.mock('../participant-availability', () => ({ ParticipantAvailability: () => null })); + +const calendars = [{ + id: 'cal', name: 'Personal', color: '#123456', isDefault: true, + myRights: { + mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, + mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true, + }, +}] as unknown as Calendar[]; + +const party = (address: string, owner: boolean) => ({ + '@type': 'Participant', calendarAddress: `mailto:${address}`, email: address, + roles: owner ? { owner: true } : { attendee: true }, + participationStatus: owner ? 'accepted' : 'needs-action', scheduleAgent: 'server', expectReply: !owner, kind: 'individual', +}); + +// The user's meeting, already sent to a partner outside. +const meeting = { + id: 'e1', uid: 'u1', '@type': 'Event', title: 'Planning', description: '', start: '2026-10-12T10:00:00', + duration: 'PT1H', timeZone: 'Europe/Berlin', showWithoutTime: false, calendarIds: { cal: true }, + organizerCalendarAddress: 'mailto:me@example.com', alerts: null, recurrenceRules: null, + participants: { org: party('me@example.com', true), partner: party('partner@partner.example', false) }, +} as unknown as CalendarEvent; + +function setWarning(on: boolean) { + usePolicyStore.setState({ + policy: { + ...DEFAULT_POLICY, + organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, recipientWarning: on } }, + }, + }); +} + +beforeEach(() => { + setWarning(true); + useAuthStore.setState({ identities: [{ id: 'i1', name: 'Me', email: 'me@example.com' } as unknown as Identity] }); + useContactStore.setState({ contacts: [], contactsLoaded: true }); + useExternalRecipientConfirmStore.setState({ pending: null, queue: [] }); +}); +afterEach(() => { + cleanup(); + resetSendPipeline(); +}); + +function editWithNewInvitee(onSave: ReturnType, invitee: string) { + render( + <> + + + , + ); + fireEvent.click(screen.getByText('events.edit')); + fireEvent.change(screen.getByPlaceholderText('email_placeholder'), { target: { value: invitee } }); + fireEvent.click(screen.getByText('form.save')); +} + +describe('invitations to people outside the organisation', () => { + it('asks about the new external invitee only, and Back saves nothing', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + editWithNewInvitee(onSave, 'org-dave@org-two.test'); + await waitFor(() => expect(useExternalRecipientConfirmStore.getState().pending).not.toBeNull()); + expect(useExternalRecipientConfirmStore.getState().pending?.recipients.map((r) => r.address)).toEqual(['org-dave@org-two.test']); + act(() => useExternalRecipientConfirmStore.getState().answer(false)); + await new Promise((r) => setTimeout(r, 20)); + expect(onSave).not.toHaveBeenCalled(); + }); + + it('saves and sends after Send anyway', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + editWithNewInvitee(onSave, 'org-dave@org-two.test'); + await waitFor(() => expect(useExternalRecipientConfirmStore.getState().pending).not.toBeNull()); + act(() => useExternalRecipientConfirmStore.getState().answer(true)); + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + expect(onSave.mock.calls[0][1]).toBe(true); + }); + + it('saves without asking for an internal invitee or with the warning off', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + editWithNewInvitee(onSave, 'colleague@example.com'); + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + cleanup(); + setWarning(false); + const second = vi.fn().mockResolvedValue(undefined); + editWithNewInvitee(second, 'org-dave@org-two.test'); + await waitFor(() => expect(second).toHaveBeenCalledTimes(1)); + expect(useExternalRecipientConfirmStore.getState().pending).toBeNull(); + }); +}); diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 3f45cc010..9a1a5096a 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -36,6 +36,7 @@ import { buildDuplicateEventData } from "@/lib/calendar-duplicate"; import { useFormatEventDate } from "@/hooks/use-format-event-date"; import { useIsPaneScoped } from "@/hooks/use-pane-context"; import { useContactNameResolver } from "@/hooks/use-contact-name-resolver"; +import { confirmInvitationRecipients } from "@/lib/outgoing/external-recipients"; import { calendarFormHooks, calendarHooks, @@ -836,6 +837,14 @@ export function EventModal({ } const shouldSendScheduling = userIsOrganizer && effectiveAttendees.length > 0 && sendInvitations; + // Invitations are mail too: newly invited people outside the + // organisation get the same confirm as a message (Back keeps the + // editor open, nothing saved). + if (shouldSendScheduling) { + const known = new Set(initialAttendees.map((a) => a.email.trim().toLowerCase())); + const invited = effectiveAttendees.map((a) => a.email).filter((email) => email && !known.has(email.trim().toLowerCase())); + if (!(await confirmInvitationRecipients(invited))) return; + } await onSave(data, shouldSendScheduling, { initialAttendees, attendees: effectiveAttendees, diff --git a/lib/outgoing/external-recipients.ts b/lib/outgoing/external-recipients.ts index aa48de4e4..ac64d68e9 100644 --- a/lib/outgoing/external-recipients.ts +++ b/lib/outgoing/external-recipients.ts @@ -80,7 +80,36 @@ export async function externalRecipientCheck( return ask(recipients); } +/** Dialog hosts mounted now; without one nobody could answer, so nothing asks. */ +let hosts = 0; + /** Called by the dialog host once it is mounted; returns the unregister function. */ export function registerExternalRecipientCheck(): () => void { - return registerPreSendCheck(EXTERNAL_RECIPIENT_CHECK_ID, ORDER, (context) => externalRecipientCheck(context)); + hosts += 1; + const unregister = registerPreSendCheck(EXTERNAL_RECIPIENT_CHECK_ID, ORDER, (context) => externalRecipientCheck(context)); + let done = false; + return () => { + if (done) return; + done = true; + hosts -= 1; + unregister(); + }; +} + +/** + * The same question for calendar invitations, which the server sends when an + * event is saved: true to save and send, false to go back to the event. + * Only the newly invited attendees are asked about; people already invited + * know of the event. + */ +export async function confirmInvitationRecipients(invited: string[]): Promise { + if (hosts === 0 || invited.length === 0) return true; + return externalRecipientCheck({ + surface: 'calendar-invitation', + to: invited, + cc: [], + bcc: [], + mode: 'compose', + plainText: false, + }); } diff --git a/lib/outgoing/send-pipeline.ts b/lib/outgoing/send-pipeline.ts index e2f772165..ab4c2ce54 100644 --- a/lib/outgoing/send-pipeline.ts +++ b/lib/outgoing/send-pipeline.ts @@ -14,7 +14,7 @@ import { debug } from '@/lib/debug'; -export type SendSurface = 'composer' | 'quick-reply'; +export type SendSurface = 'composer' | 'quick-reply' | 'calendar-invitation'; export interface OutgoingContext { surface: SendSurface;