From c0582ab9508bcd3081825f4723a3ff79de5dea42 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:56:08 +0200 Subject: [PATCH] fix: send the RSVP for invitations outside the loaded calendar window Accepting an invitation looked the event up in the calendar store, which only holds the window the calendar last showed. For an event further out the lookup came back empty, no response was sent, and the banner still said "Added to calendar". - find the event on the server with a `uid` filter, like webmail's `queryCalendarEvents({ uid })`, both when the banner opens (so it shows as already in the calendar) and after importing it - let `rsvpEvent` take the event when it isn't in the store, so the organizer repair still applies - show "Could not send your response" whenever no response went out --- src/api/__tests__/calendar.test.ts | 30 ++++++++++++ src/api/calendar.ts | 15 ++++++ .../email/CalendarInvitationBanner.tsx | 48 ++++++++++++------- src/stores/__tests__/calendar-store.test.ts | 35 ++++++++++++++ src/stores/calendar-store.ts | 8 +++- 5 files changed, 117 insertions(+), 19 deletions(-) diff --git a/src/api/__tests__/calendar.test.ts b/src/api/__tests__/calendar.test.ts index 9880b53e..eeeeed9d 100644 --- a/src/api/__tests__/calendar.test.ts +++ b/src/api/__tests__/calendar.test.ts @@ -16,6 +16,7 @@ import { createEvent, updateEvent, deleteEvents, + findEventsByUid, toLocalDateTime, } from '../calendar'; @@ -337,6 +338,35 @@ describe('calendar operations', () => { }); }); + describe('findEventsByUid', () => { + it('queries by uid, whatever the date, and fetches the matches', async () => { + mockRequest + .mockResolvedValueOnce({ + methodResponses: [['CalendarEvent/query', { ids: ['ev9'] }, '0']], + }) + .mockResolvedValueOnce({ + methodResponses: [['CalendarEvent/get', { list: [{ id: 'ev9', uid: 'inv@example.com' }] }, '0']], + }); + + const found = await findEventsByUid('inv@example.com'); + + const query = mockRequest.mock.calls[0][0][0]; + expect(query[0]).toBe('CalendarEvent/query'); + expect(query[1]).toEqual({ accountId: 'acc-1', filter: { uid: 'inv@example.com' } }); + expect(mockRequest.mock.calls[1][0][0][1].ids).toEqual(['ev9']); + expect(found.map((e) => e.id)).toEqual(['ev9']); + }); + + it('returns nothing without a second request when no event matches', async () => { + mockRequest.mockResolvedValueOnce({ + methodResponses: [['CalendarEvent/query', { ids: [] }, '0']], + }); + + expect(await findEventsByUid('missing@example.com')).toEqual([]); + expect(mockRequest).toHaveBeenCalledTimes(1); + }); + }); + describe('deleteEvents', () => { it('should destroy events by ids', async () => { mockRequest.mockResolvedValue({ diff --git a/src/api/calendar.ts b/src/api/calendar.ts index ede1875c..db2366b0 100644 --- a/src/api/calendar.ts +++ b/src/api/calendar.ts @@ -388,6 +388,21 @@ export async function getEvents(ids: string[], accountId?: string): Promise { + const account = accountId || jmapClient.accountId; + const res = await jmapClient.request( + [['CalendarEvent/query', { accountId: account, filter: { uid } }, '0']], + USING, + ); + const ids = methodResult<{ ids: string[] }>(res).ids ?? []; + return getEvents(ids, accountId); +} + // `sendSchedulingMessages` asks Stalwart to deliver iMIP (RFC 6047) invitation // / reply / cancellation emails to participants. We pass it whenever an event // has participants so creating/updating/deleting a meeting notifies attendees. diff --git a/src/components/email/CalendarInvitationBanner.tsx b/src/components/email/CalendarInvitationBanner.tsx index c34c6b97..c99e0c6d 100644 --- a/src/components/email/CalendarInvitationBanner.tsx +++ b/src/components/email/CalendarInvitationBanner.tsx @@ -9,7 +9,7 @@ import { format, parseISO } from 'date-fns'; import type { Calendar, Email, CalendarEvent } from '../../api/types'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; -import { fetchCalendarBlobText, parseCalendarBlob } from '../../api/calendar'; +import { fetchCalendarBlobText, findEventsByUid, parseCalendarBlob } from '../../api/calendar'; import { useCalendarStore } from '../../stores/calendar-store'; import { useSettingsStore } from '../../stores/settings-store'; import { useLocaleStore } from '../../stores/locale-store'; @@ -76,6 +76,9 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) { const [state, setState] = React.useState('loading'); const [event, setEvent] = React.useState | null>(null); + // The invitation's event as found on the server by UID, for one outside + // the calendar's loaded window. + const [serverMatch, setServerMatch] = React.useState(null); const [method, setMethod] = React.useState('unknown'); const [rsvpStatus, setRsvpStatus] = React.useState(null); const [notice, setNotice] = React.useState(null); @@ -87,6 +90,7 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) { let cancelled = false; if (!attachment || !enabled) return; setState('loading'); + setServerMatch(null); (async () => { try { const events = await parseCalendarBlob(attachment.blobId, jmapAccountId); @@ -97,6 +101,14 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) { } const parsed = events[0]; setEvent(parsed); + // The store only holds the calendar's loaded window; look the event + // up on the server so one already there counts as existing and the + // RSVP goes to it. Best-effort, must not block the banner. + if (parsed.uid && !useCalendarStore.getState().events.some((e) => e.uid === parsed.uid)) { + findEventsByUid(parsed.uid) + .then((found) => { if (!cancelled && found[0]) setServerMatch(found[0]); }) + .catch(() => undefined); + } // Explicit method (Content-Type params) first; JMAP usually strips // them, so fall back to the raw ICS METHOD line before guessing. let detected = getInvitationMethod(parsed, { email, attachment }); @@ -126,11 +138,12 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) { ?? candidates.find((cal) => cal.isDefault) ?? candidates[0]; - // Already imported? Look for the UID among the loaded events. + // Already imported? Look for the UID among the loaded events, then among + // what the server lookup found. const existing = React.useMemo(() => { if (!event?.uid) return null; - return storeEvents.find((e) => e.uid === event.uid) ?? null; - }, [storeEvents, event?.uid]); + return storeEvents.find((e) => e.uid === event.uid) ?? serverMatch; + }, [storeEvents, event?.uid, serverMatch]); const trust = React.useMemo( () => (event ? getInvitationTrustAssessment(event, email, method) : null), @@ -173,20 +186,21 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) { setBusy(true); setNotice(null); try { - // Make sure the event exists in a local calendar (dedupes by UID). - if (!existing) await importEvents([event], targetCalendar.id); - // Re-read from the store to get the server-assigned id + participant. - const stored = useCalendarStore.getState().events.find((e) => e.uid === event.uid); - const participant = stored - ? findParticipantByEmail(stored, currentUserEmails) - : me; - if (stored && participant) { - await rsvpEvent(stored.id, participant.id, status, buildReplyTo(event)); - setRsvpStatus(status); - setNotice(t('calendar.invitation.response_sent', 'Response sent')); - } else { - setNotice(t('calendar.invitation.added', 'Added to calendar')); + let target = existing; + if (!target) { + // Make sure the event exists in a local calendar (dedupes by UID), + // then find it on the server for its id and participant: the store + // never sees an event outside the loaded window. + await importEvents([event], targetCalendar.id); + target = event.uid ? (await findEventsByUid(event.uid))[0] ?? null : null; + if (target) setServerMatch(target); } + const participant = target ? findParticipantByEmail(target, currentUserEmails) : null; + // Never claim success when no response went out. + if (!target || !participant) throw new Error('No event to respond to'); + await rsvpEvent(target.id, participant.id, status, buildReplyTo(event), target); + setRsvpStatus(status); + setNotice(t('calendar.invitation.response_sent', 'Response sent')); setState('done'); } catch { setNotice(t('calendar.invitation.response_error', 'Could not send your response')); diff --git a/src/stores/__tests__/calendar-store.test.ts b/src/stores/__tests__/calendar-store.test.ts index 1843f3cb..8aa162ac 100644 --- a/src/stores/__tests__/calendar-store.test.ts +++ b/src/stores/__tests__/calendar-store.test.ts @@ -13,6 +13,7 @@ vi.mock('../../api/calendar', () => ({ deleteEvents: vi.fn(), batchCreateEvents: vi.fn(), setDefaultCalendar: vi.fn(), + rsvpEvent: vi.fn(), })); vi.mock('@react-native-async-storage/async-storage', () => ({ @@ -45,6 +46,7 @@ const mockUpdateEvent = calendarApi.updateEvent as ReturnType; const mockDeleteEvents = calendarApi.deleteEvents as ReturnType; const mockSetDefaultCalendar = calendarApi.setDefaultCalendar as ReturnType; const mockScan = calendarApi.scanCalendarObjects as ReturnType; +const mockRsvpEvent = calendarApi.rsvpEvent as ReturnType; beforeEach(() => { vi.clearAllMocks(); @@ -639,6 +641,39 @@ describe('calendar-store', () => { }); }); + describe('rsvpEvent', () => { + it('answers an event outside the loaded window that the caller hands over (B22)', async () => { + useCalendarStore.setState({ events: [] }); + mockRsvpEvent.mockResolvedValue(undefined); + const found = { id: 'ev9', uid: 'inv@example.com', start: '2027-01-10T09:00:00' } as any; + + await useCalendarStore.getState().rsvpEvent( + 'ev9', 'p1', 'accepted', { imip: 'mailto:boss@example.com' }, found, + ); + + // No organizer on the event: repaired from the invitation's replyTo. + expect(mockRsvpEvent).toHaveBeenCalledWith( + 'ev9', 'p1', 'accepted', 'mailto:boss@example.com', undefined, + ); + }); + + it('prefers the loaded copy of the event', async () => { + useCalendarStore.setState({ + events: [{ + id: 'acc-2:ev1', originalId: 'ev1', accountId: 'acc-2', + organizerCalendarAddress: 'mailto:boss@example.com', + participants: { p1: { participationStatus: 'needs-action' } }, + } as any], + }); + mockRsvpEvent.mockResolvedValue(undefined); + + await useCalendarStore.getState().rsvpEvent('acc-2:ev1', 'p1', 'declined', { imip: 'mailto:x@example.com' }); + + expect(mockRsvpEvent).toHaveBeenCalledWith('ev1', 'p1', 'declined', undefined, 'acc-2'); + expect(useCalendarStore.getState().events[0].participants?.p1.participationStatus).toBe('declined'); + }); + }); + describe('toggleTaskComplete', () => { it('completes a task without sending progressUpdated, which Stalwart rejects (#958)', async () => { useCalendarStore.setState({ diff --git a/src/stores/calendar-store.ts b/src/stores/calendar-store.ts index 98626ac4..381a1ca0 100644 --- a/src/stores/calendar-store.ts +++ b/src/stores/calendar-store.ts @@ -190,6 +190,8 @@ export interface CalendarState { participantId: string, status: 'accepted' | 'declined' | 'tentative', replyTo?: Record | null, + // The event itself, for one that isn't in the loaded window. + event?: CalendarEvent, ) => Promise; importEvents: (events: Partial[], calendarId: string) => Promise; createCalendar: (name: string, color?: string, description?: string) => Promise; @@ -561,13 +563,15 @@ export const useCalendarStore = create()( return mapServerEventToStoreEvent(master, get().calendars, event.accountId); }, - rsvpEvent: async (eventId, participantId, status, replyTo) => { + rsvpEvent: async (eventId, participantId, status, replyTo, event) => { // JMAP participant ids are opaque strings (they can contain @, ., :, /); // the api layer RFC 6901-escapes them, so only reject empty values. if (!participantId) { throw new Error('Invalid participant ID'); } - const storeEvent = get().events.find((e) => e.id === eventId); + // An event outside the loaded window (an invitation looked up by UID) + // isn't in the store; the caller hands it over instead. + const storeEvent = get().events.find((e) => e.id === eventId) ?? event; const realId = storeEvent?.originalId || eventId; // Repair events that are missing the organizer (e.g. imported ones) so // Stalwart can route the REPLY; never touch an existing one.