From 7dd5fe41c3bc8b28dd2bd828ffdede73a419ba02 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 2 Nov 2026 23:14:21 +0100 Subject: [PATCH 01/18] refactor: answer invitations from one place the reader's banner and plugins share --- .../email/calendar-invitation-banner.tsx | 109 +++---------- lib/__tests__/calendar-rsvp.test.ts | 73 +++++++++ lib/calendar-rsvp.ts | 153 ++++++++++++++++++ 3 files changed, 247 insertions(+), 88 deletions(-) create mode 100644 lib/__tests__/calendar-rsvp.test.ts create mode 100644 lib/calendar-rsvp.ts diff --git a/components/email/calendar-invitation-banner.tsx b/components/email/calendar-invitation-banner.tsx index 007880c61..75dd343bb 100644 --- a/components/email/calendar-invitation-banner.tsx +++ b/components/email/calendar-invitation-banner.tsx @@ -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, - participantId: string, - status: 'accepted' | 'tentative' | 'declined', -): Record[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) { diff --git a/lib/__tests__/calendar-rsvp.test.ts b/lib/__tests__/calendar-rsvp.test.ts new file mode 100644 index 000000000..0f395b720 --- /dev/null +++ b/lib/__tests__/calendar-rsvp.test.ts @@ -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; + +function request(over: Partial = {}): RsvpRequest & { store: { rsvpEvent: ReturnType; updateEvent: ReturnType; importEvents: ReturnType } } { + 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); + }); +}); diff --git a/lib/calendar-rsvp.ts b/lib/calendar-rsvp.ts new file mode 100644 index 000000000..b2d52c15f --- /dev/null +++ b/lib/calendar-rsvp.ts @@ -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 | null, scope?: 'occurrence' | 'series', options?: { sentBy?: string }) => Promise; + updateEvent: (client: IJMAPClient, id: string, updates: Partial, sendSchedulingMessages?: boolean) => Promise; + importEvents: (client: IJMAPClient, events: Partial[], calendarId: string, options?: { throwIfNoneImported?: boolean }) => Promise; + /** 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; + /** 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; +} + +/** + * 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, + participantId: string, + status: RsvpStatus, +): Record[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 { + 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; + 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 { + 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 }; +} From 8905d31b38be1af6d9431ff43b33ee23257c04f8 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 2 Nov 2026 23:14:21 +0100 Subject: [PATCH 02/18] fix: show the admin's locks for plain text and the reply identity on the composing page --- .../composing-settings-locks.test.tsx | 77 +++++++++++++++++++ components/settings/composing-settings.tsx | 14 +++- 2 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 components/settings/__tests__/composing-settings-locks.test.tsx diff --git a/components/settings/__tests__/composing-settings-locks.test.tsx b/components/settings/__tests__/composing-settings-locks.test.tsx new file mode 100644 index 000000000..9370e46f8 --- /dev/null +++ b/components/settings/__tests__/composing-settings-locks.test.tsx @@ -0,0 +1,77 @@ +import { render, screen, within } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ComposingSettings } from '../composing-settings'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { usePolicyStore } from '@/stores/policy-store'; + +// The admin panel offers locks for these three rows (restriction-keys.ts); +// the page has to show them, or the user changes a value the next policy +// load puts back. + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, +})); + +vi.mock('../settings-section', async (importOriginal) => ({ + ...(await importOriginal()), + SettingsSection: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +const ROWS = { + plainTextMode: 'plain_text_mode.label', + autoSelectReplyIdentity: 'auto_select_reply_identity.label', + replyIdentityMatch: 'reply_identity_match.label', +} as const; + +function setPolicy(locked: string[], hidden: string[] = []) { + usePolicyStore.setState({ + ...usePolicyStore.getState(), + policy: {}, + isSettingLocked: (key: string) => locked.includes(key), + isSettingHidden: (key: string) => hidden.includes(key), + isFeatureEnabled: (gate: string) => gate === 'customFromEnabled', + } as never); +} + +/** The SettingItem row of a label: the element that holds the control. */ +function row(labelKey: string): HTMLElement { + return screen.getByText(labelKey).closest('[data-search-label]') as HTMLElement; +} + +describe('ComposingSettings admin locks', () => { + beforeEach(() => { + useSettingsStore.getState().resetToDefaults(); + // The identity match row shows only with auto-select on. + useSettingsStore.setState({ autoSelectReplyIdentity: true }); + useAuthStore.setState({ client: null }); + }); + + it('shows the lock and makes the control inert for each locked row', () => { + setPolicy(Object.keys(ROWS)); + render(); + for (const label of Object.values(ROWS)) { + const item = row(label); + expect(within(item).getByText('org_locked'), label).toBeTruthy(); + const control = item.querySelector('[role="switch"], select'); + expect(control, label).not.toBeNull(); + expect(control!.closest('[inert]'), label).not.toBeNull(); + } + }); + + it('leaves the rows editable without a lock', () => { + setPolicy([]); + render(); + for (const label of Object.values(ROWS)) { + const item = row(label); + expect(within(item).queryByText('org_locked'), label).toBeNull(); + expect(item.querySelector('[inert]'), label).toBeNull(); + } + }); + + it('hides the rows the organisation hides', () => { + setPolicy([], Object.keys(ROWS)); + render(); + for (const label of Object.values(ROWS)) expect(screen.queryByText(label), label).toBeNull(); + }); +}); diff --git a/components/settings/composing-settings.tsx b/components/settings/composing-settings.tsx index becf7296b..d49f531ea 100644 --- a/components/settings/composing-settings.tsx +++ b/components/settings/composing-settings.tsx @@ -20,6 +20,8 @@ export function ComposingSettings() { const t = useTranslations('settings.email_behavior'); const tPage = useTranslations('settings.composing_page'); const templatesEnabled = usePolicyStore((s) => s.isFeatureEnabled('templatesEnabled')); + // The admin panel can lock or hide these three (restriction-keys.ts). + const { isSettingLocked, isSettingHidden } = usePolicyStore(); const [newKeyword, setNewKeyword] = useState(''); const { @@ -49,12 +51,14 @@ export function ComposingSettings() { return (
- + {!isSettingHidden('plainTextMode') && ( + updateSetting('plainTextMode', checked)} /> + )} - + {!isSettingHidden('autoSelectReplyIdentity') && ( + updateSetting('autoSelectReplyIdentity', checked)} /> + )} - {autoSelectReplyIdentity && customFromEnabled && ( - + {autoSelectReplyIdentity && customFromEnabled && !isSettingHidden('replyIdentityMatch') && ( +