fix: use calendarAddress/organizerCalendarAddress for scheduling, drop retired sendTo/replyTo #500

This commit is contained in:
Linus Rath
2026-07-07 23:18:05 +02:00
parent 29283282d5
commit c3a97de62f
6 changed files with 34 additions and 13 deletions
+2 -1
View File
@@ -522,13 +522,14 @@ export function EventModal({
{ name: organizerName, email: organizerEmail }, { name: organizerName, email: organizerEmail },
effectiveAttendees effectiveAttendees
) as Record<string, CalendarParticipant>; ) as Record<string, CalendarParticipant>;
data.replyTo = { imip: `mailto:${organizerEmail}` };
// Stalwart (calcard) derives the iCalendar ORGANIZER property solely from // Stalwart (calcard) derives the iCalendar ORGANIZER property solely from
// organizerCalendarAddress; without it no ORGANIZER is emitted and iTIP // organizerCalendarAddress; without it no ORGANIZER is emitted and iTIP
// scheduling is silently skipped (NoSchedulingInfo), so no invites are sent. // 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}`; data.organizerCalendarAddress = `mailto:${organizerEmail}`;
} else if (effectiveAttendees.length === 0 && event?.participants) { } else if (effectiveAttendees.length === 0 && event?.participants) {
data.participants = null; data.participants = null;
// Also clear the retired replyTo that older releases (<= 1.7.6) wrote.
data.replyTo = null; data.replyTo = null;
data.organizerCalendarAddress = null; data.organizerCalendarAddress = null;
} }
@@ -540,7 +540,9 @@ describe('CalendarInvitationBanner', () => {
mocks.clientMock, mocks.clientMock,
'event-8', 'event-8',
expect.objectContaining({ 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({ participants: expect.objectContaining({
attendee: expect.objectContaining({ attendee: expect.objectContaining({
participationStatus: 'accepted', participationStatus: 'accepted',
@@ -582,7 +582,12 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
} else { } else {
await updateEvent(client, eventForRsvp.id, { await updateEvent(client, eventForRsvp.id, {
participants: repairedParticipants, 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); }, true);
setRsvpStatus(status); setRsvpStatus(status);
setActionNotice(t('rsvp_sent')); setActionNotice(t('rsvp_sent'));
+4 -1
View File
@@ -285,7 +285,10 @@ describe('buildParticipantMap', () => {
expect(org!.roles).toEqual({ owner: true, attendee: true }); expect(org!.roles).toEqual({ owner: true, attendee: true });
expect(org!.participationStatus).toBe('accepted'); expect(org!.participationStatus).toBe('accepted');
expect(org!.scheduleAgent).toBe('server'); 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); expect(org!.expectReply).toBe(false);
const att0 = entries.find(p => p.email === 'bob@example.com'); const att0 = entries.find(p => p.email === 'bob@example.com');
+3 -2
View File
@@ -107,6 +107,9 @@ export function buildParticipantMap(
const generateId = () => generateUUID(); 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()] = { participants[generateId()] = {
'@type': 'Participant', '@type': 'Participant',
name: organizer.name, name: organizer.name,
@@ -115,7 +118,6 @@ export function buildParticipantMap(
roles: { owner: true, attendee: true }, roles: { owner: true, attendee: true },
participationStatus: 'accepted', participationStatus: 'accepted',
scheduleAgent: 'server', scheduleAgent: 'server',
sendTo: { imip: `mailto:${organizer.email}` },
expectReply: false, expectReply: false,
kind: 'individual', kind: 'individual',
}; };
@@ -129,7 +131,6 @@ export function buildParticipantMap(
roles: { attendee: true }, roles: { attendee: true },
participationStatus: 'needs-action', participationStatus: 'needs-action',
scheduleAgent: 'server', scheduleAgent: 'server',
sendTo: { imip: `mailto:${a.email}` },
expectReply: true, expectReply: true,
kind: 'individual', kind: 'individual',
}; };
+16 -7
View File
@@ -567,10 +567,13 @@ export const useCalendarStore = create<CalendarStore>()(
const escapedId = participantId.replace(/~/g, '~0').replace(/\//g, '~1'); const escapedId = participantId.replace(/~/g, '~0').replace(/\//g, '~1');
const patchKey = `participants/${escapedId}/participationStatus`; const patchKey = `participants/${escapedId}/participationStatus`;
const patch: Record<string, unknown> = { [patchKey]: status }; const patch: Record<string, unknown> = { [patchKey]: status };
// Include replyTo so the server knows where to deliver the iTIP reply // Stalwart routes the iTIP REPLY to the stored ORGANIZER
// (may be missing if the event was imported or auto-created without it). // (organizerCalendarAddress); the RFC 8984 replyTo property is retired
if (replyTo) { // in jscalendarbis and ignored. Repair events that are missing the
patch.replyTo = replyTo; // 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( await client.updateCalendarEvent(
realId, realId,
@@ -677,9 +680,11 @@ export const useCalendarStore = create<CalendarStore>()(
'@type': 'Participant', '@type': 'Participant',
name: p.name, name: p.name,
email: p.email, 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, description: p.description,
sendTo: p.sendTo,
kind: p.kind, kind: p.kind,
roles: p.roles, roles: p.roles,
participationStatus: p.participationStatus, participationStatus: p.participationStatus,
@@ -719,7 +724,11 @@ export const useCalendarStore = create<CalendarStore>()(
keywords: src.keywords, keywords: src.keywords,
categories: src.categories, categories: src.categories,
locale: src.locale, 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, locations: src.locations,
virtualLocations: src.virtualLocations, virtualLocations: src.virtualLocations,
links: src.links, links: src.links,