feat: confirm before inviting people outside the organisation to a meeting

This commit is contained in:
Linus Rath
2026-10-28 05:12:24 +01:00
parent 1202a13720
commit 9dab8955dd
4 changed files with 145 additions and 2 deletions
@@ -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<typeof vi.fn>, invitee: string) {
render(
<>
<EventModal event={meeting} calendars={calendars} onSave={onSave as never} onClose={vi.fn()} currentUserEmails={["me@example.com"]} />
<ExternalRecipientConfirmHost />
</>,
);
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();
});
});
+9
View File
@@ -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,
+30 -1
View File
@@ -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<boolean> {
if (hosts === 0 || invited.length === 0) return true;
return externalRecipientCheck({
surface: 'calendar-invitation',
to: invited,
cc: [],
bcc: [],
mode: 'compose',
plainText: false,
});
}
+1 -1
View File
@@ -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;