From c3a97de62f6213de915c36046a4cee87eef83e81 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 7 Jul 2026 23:18:05 +0200 Subject: [PATCH] fix: use calendarAddress/organizerCalendarAddress for scheduling, drop retired sendTo/replyTo #500 --- components/calendar/event-modal.tsx | 3 ++- .../calendar-invitation-banner.test.tsx | 4 +++- .../email/calendar-invitation-banner.tsx | 7 +++++- lib/__tests__/calendar-participants.test.ts | 5 +++- lib/calendar-participants.ts | 5 ++-- stores/calendar-store.ts | 23 +++++++++++++------ 6 files changed, 34 insertions(+), 13 deletions(-) diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 4c2b3f28..d6a16c02 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -522,13 +522,14 @@ export function EventModal({ { name: organizerName, email: organizerEmail }, effectiveAttendees ) as Record; - data.replyTo = { imip: `mailto:${organizerEmail}` }; // Stalwart (calcard) derives the iCalendar ORGANIZER property solely from // organizerCalendarAddress; without it no ORGANIZER is emitted and iTIP // scheduling is silently skipped (NoSchedulingInfo), so no invites are sent. + // The RFC 8984 replyTo property is retired in jscalendarbis and ignored. data.organizerCalendarAddress = `mailto:${organizerEmail}`; } else if (effectiveAttendees.length === 0 && event?.participants) { data.participants = null; + // Also clear the retired replyTo that older releases (<= 1.7.6) wrote. data.replyTo = null; data.organizerCalendarAddress = null; } diff --git a/components/email/__tests__/calendar-invitation-banner.test.tsx b/components/email/__tests__/calendar-invitation-banner.test.tsx index fb08be4a..6e2c017a 100644 --- a/components/email/__tests__/calendar-invitation-banner.test.tsx +++ b/components/email/__tests__/calendar-invitation-banner.test.tsx @@ -540,7 +540,9 @@ describe('CalendarInvitationBanner', () => { mocks.clientMock, 'event-8', expect.objectContaining({ - replyTo: { imip: 'mailto:organizer@example.com' }, + // The stored event lacks an ORGANIZER, so the RSVP repair writes + // organizerCalendarAddress (replyTo is retired in jscalendarbis). + organizerCalendarAddress: 'mailto:organizer@example.com', participants: expect.objectContaining({ attendee: expect.objectContaining({ participationStatus: 'accepted', diff --git a/components/email/calendar-invitation-banner.tsx b/components/email/calendar-invitation-banner.tsx index bd21fc54..64359948 100644 --- a/components/email/calendar-invitation-banner.tsx +++ b/components/email/calendar-invitation-banner.tsx @@ -582,7 +582,12 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp } else { await updateEvent(client, eventForRsvp.id, { participants: repairedParticipants, - replyTo: replyToForRsvp ?? undefined, + // 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); setRsvpStatus(status); setActionNotice(t('rsvp_sent')); diff --git a/lib/__tests__/calendar-participants.test.ts b/lib/__tests__/calendar-participants.test.ts index e67503cc..af706eff 100644 --- a/lib/__tests__/calendar-participants.test.ts +++ b/lib/__tests__/calendar-participants.test.ts @@ -285,7 +285,10 @@ describe('buildParticipantMap', () => { expect(org!.roles).toEqual({ owner: true, attendee: true }); expect(org!.participationStatus).toBe('accepted'); expect(org!.scheduleAgent).toBe('server'); - expect(org!.sendTo).toEqual({ imip: 'mailto:alice@example.com' }); + // sendTo is retired in draft-ietf-calext-jscalendarbis; the scheduling + // address is carried by calendarAddress instead. + expect(org!.sendTo).toBeUndefined(); + expect(org!.calendarAddress).toBe('mailto:alice@example.com'); expect(org!.expectReply).toBe(false); const att0 = entries.find(p => p.email === 'bob@example.com'); diff --git a/lib/calendar-participants.ts b/lib/calendar-participants.ts index c94c9d99..7a71c034 100644 --- a/lib/calendar-participants.ts +++ b/lib/calendar-participants.ts @@ -107,6 +107,9 @@ export function buildParticipantMap( const generateId = () => generateUUID(); + // calendarAddress is the scheduling address in draft-ietf-calext-jscalendarbis + // (implemented by Stalwart); the RFC 8984 sendTo property is retired there and + // stored as an inert JSPROP, so it is intentionally not sent. participants[generateId()] = { '@type': 'Participant', name: organizer.name, @@ -115,7 +118,6 @@ export function buildParticipantMap( roles: { owner: true, attendee: true }, participationStatus: 'accepted', scheduleAgent: 'server', - sendTo: { imip: `mailto:${organizer.email}` }, expectReply: false, kind: 'individual', }; @@ -129,7 +131,6 @@ export function buildParticipantMap( roles: { attendee: true }, participationStatus: 'needs-action', scheduleAgent: 'server', - sendTo: { imip: `mailto:${a.email}` }, expectReply: true, kind: 'individual', }; diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index 5ccaf043..62e8a193 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -567,10 +567,13 @@ export const useCalendarStore = create()( const escapedId = participantId.replace(/~/g, '~0').replace(/\//g, '~1'); const patchKey = `participants/${escapedId}/participationStatus`; const patch: Record = { [patchKey]: status }; - // Include replyTo so the server knows where to deliver the iTIP reply - // (may be missing if the event was imported or auto-created without it). - if (replyTo) { - patch.replyTo = replyTo; + // Stalwart routes the iTIP REPLY to the stored ORGANIZER + // (organizerCalendarAddress); the RFC 8984 replyTo property is retired + // in jscalendarbis and ignored. Repair events that are missing the + // organizer (e.g. imported ones), but never touch an existing one - + // attendees may not modify the ORGANIZER. + if (replyTo?.imip && storeEvent && !storeEvent.organizerCalendarAddress) { + patch.organizerCalendarAddress = replyTo.imip; } await client.updateCalendarEvent( realId, @@ -677,9 +680,11 @@ export const useCalendarStore = create()( '@type': 'Participant', name: p.name, email: p.email, - calendarAddress: p.calendarAddress, + // calendarAddress carries the scheduling address in jscalendarbis + // (Stalwart); sendTo is retired there, so it only serves as a + // fallback source for events parsed from legacy RFC 8984 data. + calendarAddress: p.calendarAddress || p.sendTo?.imip, description: p.description, - sendTo: p.sendTo, kind: p.kind, roles: p.roles, participationStatus: p.participationStatus, @@ -719,7 +724,11 @@ export const useCalendarStore = create()( keywords: src.keywords, categories: src.categories, locale: src.locale, - replyTo: src.replyTo || (src.organizerCalendarAddress ? { imip: src.organizerCalendarAddress } : undefined), + // Stalwart derives the iCalendar ORGANIZER solely from + // organizerCalendarAddress (replyTo is retired in jscalendarbis); + // dropping it here would strip the ORGANIZER from imported invites + // and break RSVP replies afterwards. + organizerCalendarAddress: src.organizerCalendarAddress || src.replyTo?.imip, locations: src.locations, virtualLocations: src.virtualLocations, links: src.links,