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.