From 5dc40706fa68f5da115868e194f17d19bb3a8975 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 29 Aug 2026 17:52:26 +0200 Subject: [PATCH] fix: send invitations with a server-scheduled organizer and match RSVPs against identities and aliases (#731) --- docs/parity/05-calendar.md | 8 +- src/api/calendar.ts | 8 +- src/components/calendar/EventDetailSheet.tsx | 52 ++-- src/components/calendar/EventModal.tsx | 49 +++- src/components/calendar/ParticipantInput.tsx | 56 ++-- .../email/CalendarInvitationBanner.tsx | 12 +- .../__tests__/calendar-participants.test.ts | 91 +++++++ src/lib/calendar-participants.ts | 249 ++++++++++++++++++ src/lib/calendar-user-addresses.ts | 70 +++++ src/screens/CalendarScreen.tsx | 4 + src/stores/calendar-store.ts | 12 +- 11 files changed, 532 insertions(+), 79 deletions(-) create mode 100644 src/lib/__tests__/calendar-participants.test.ts create mode 100644 src/lib/calendar-participants.ts create mode 100644 src/lib/calendar-user-addresses.ts diff --git a/docs/parity/05-calendar.md b/docs/parity/05-calendar.md index 66941b29..9902d3a3 100644 --- a/docs/parity/05-calendar.md +++ b/docs/parity/05-calendar.md @@ -22,7 +22,7 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi - What RN does: `updateEvent` maps only `e.id === id` (`src/stores/calendar-store.ts:315-317`), so sibling occurrences keep the old title/time until a pull-to-refresh; `deleteEvent` removes only the tapped occurrence from state while the server destroyed the master (`:320-329`). - Fix hint: after mutating a `recurrenceId`/`recurrenceRules` event call `get().refresh()` (RN already has it) like WEB's `refetchAfterOccurrenceMutation`. -- [ ] **RSCALE=GREGORIAN;SKIP=OMIT still emitted by the custom recurrence editor (#805)** — `P2` — `bugfix-parity` +- [x] **RSCALE=GREGORIAN;SKIP=OMIT still emitted by the custom recurrence editor (#805)** — `P2` — `bugfix-parity` — fixed in 58a6f45 - What WEB does: `createRecurrenceRule` deliberately omits `rscale`/`skip` because Stalwart serialises them into the RRULE and DAVx5 rejects it (ref `lib/recurrence-rule.ts:5-33`, changelog 1.9.2 #805). - What RN does: `buildRuleFromEditorValue` sets `rscale: 'gregorian', skip: 'omit'` on every custom rule (ref `src/lib/recurrence.ts:220-221`). RN affected: any custom recurrence created on mobile breaks Android CalDAV sync. - Fix hint: delete the two properties in `buildRuleFromEditorValue`; the simple presets in `EventModal.tsx:143-146` are already clean. @@ -32,7 +32,7 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi - What RN does: always fetches raw events and expands on the client (`src/stores/calendar-store.ts:228`, `src/lib/recurrence-expansion.ts`). Functionally fine on all server versions; RN's expansion file is a line-for-line port of WEB's (diffed: only comments, formatting and the `isServerRecurrenceInstance` pass-through differ). - Fix hint: optional. If adopted, port `recurrence-instances.ts` wholesale and the `isServerRecurrenceInstance` guard in `expandRecurringEvents`. -- [ ] **Removing recurrence / participants / reminders / location on edit does not persist** — `P2` — `bug` +- [x] **Removing recurrence / participants / reminders / location on edit does not persist** — `P2` — `bug` — fixed in 58a6f45 - What WEB does: sends explicit `null` for `recurrenceRules` (+ `recurrenceOverrides`, `excludedRecurrenceRules`), `alerts`, `participants` (+ `replyTo`, `organizerCalendarAddress`), `locations`, `virtualLocations` when the field was cleared (ref `components/calendar/event-modal.tsx:528-549, 560-563, 588-592`). - What RN does: `handleSave` emits `undefined` for cleared fields (`recurrenceRules: undefined`, `participants: undefined`, `alerts: remindersToAlerts([]) === undefined`, `locations: undefined`) which JSON serialisation drops, so the server keeps the old value (ref `src/components/calendar/EventModal.tsx:262-281`, `src/lib/calendar-alerts.ts:72-73`). Setting "Does not repeat" on a recurring event, removing all attendees, deleting the last reminder or clearing the location silently does nothing. - Fix hint: in edit mode compare against `event` and send `null` for fields that existed and are now empty; RN's `cleanRecurrenceRules` already turns `[]`/`null` into `recurrenceRule: null` (`src/api/calendar.ts:115-118`), so `recurrenceRules: []` works today for recurrence. @@ -43,12 +43,12 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi ### Events: create / edit -- [ ] **All-day events are created and saved one day too long** — `P1` — `rn-only-bug` +- [x] **All-day events are created and saved one day too long** — `P1` — `rn-only-bug` — fixed in 58a6f45 - What WEB does: seeds the end field from `getEventDisplayEndDate` (inclusive, end - 1 ms) for all-day events and builds `buildAllDayDuration(start, inclusiveEnd)` (ref `components/calendar/event-modal.tsx:274-280, 471-477`). - What RN does: edit seeds `end` with `getEventEndDate(event)` which is the exclusive end (start + P1D = next day 00:00) (`src/components/calendar/EventModal.tsx:198`); the all-day toggle effect pushes `end` to the next day whenever `end <= start` (`:231-242`); `handleSave` then calls `buildAllDayDuration(start, end)` which is inclusive (`:269`, `src/lib/calendar-utils.ts:183-189`), yielding `P2D` for a one-day event. Every create-via-toggle and every re-save of an all-day event adds a day. - Fix hint: seed/display the inclusive end (`getEventDisplayEndDate`) for all-day events, and in the all-day effect set `e = s` (same day) when `end <= start`; keep `buildAllDayDuration` inclusive. -- [ ] **Events created without `timeZone` (floating time)** — `P1` — `bug` +- [x] **Events created without `timeZone` (floating time)** — `P1` — `bug` — fixed in 58a6f45 - What WEB does: every timed event carries `timeZone: getEffectiveTimeZone()` (all-day: `null`) so other clients and invitees resolve the wall-clock correctly (ref `components/calendar/event-modal.tsx:494-502`, `lib/timezone.ts:64-75`). - What RN does: `handleSave` never sets `timeZone` (`src/components/calendar/EventModal.tsx:262-281`) and `createEvent` does not add one (`src/api/calendar.ts:248-275`). Stalwart stores a floating DTSTART; attendees in other zones, CalDAV clients, and the user after travelling see wrong times; iMIP invites sent for such events are ambiguous. - Fix hint: set `timeZone: allDay ? null : Intl.DateTimeFormat().resolvedOptions().timeZone` (reuse `getUserTimeZone` from `src/api/calendar.ts:15`); once a custom time-zone setting exists (see below) use that. diff --git a/src/api/calendar.ts b/src/api/calendar.ts index 544ad177..22ec11d6 100644 --- a/src/api/calendar.ts +++ b/src/api/calendar.ts @@ -328,11 +328,15 @@ export async function deleteEvents( // RSVP to an invitation: patch the participant's participationStatus via a JSON // Pointer (RFC 6901) and let Stalwart send the iTIP REPLY (sendSchedulingMessages). +// Stalwart routes the REPLY to the stored ORGANIZER (organizerCalendarAddress); +// the RFC 8984 replyTo property is retired in jscalendarbis and ignored. The +// caller passes `repairOrganizerAddress` only for events that lack an +// organizer (e.g. imported ones) — attendees may not modify an existing one. export async function rsvpEvent( eventId: string, participantId: string, status: 'accepted' | 'declined' | 'tentative', - replyTo?: Record | null, + repairOrganizerAddress?: string | null, targetAccountId?: string, ): Promise { const accountId = targetAccountId || jmapClient.accountId; @@ -341,7 +345,7 @@ export async function rsvpEvent( const patch: Record = { [`participants/${escaped}/participationStatus`]: status, }; - if (replyTo) patch.replyTo = replyTo; + if (repairOrganizerAddress) patch.organizerCalendarAddress = repairOrganizerAddress; const res = await jmapClient.request( [['CalendarEvent/set', { accountId, update: { [eventId]: patch }, sendSchedulingMessages: true }, '0']], USING, diff --git a/src/components/calendar/EventDetailSheet.tsx b/src/components/calendar/EventDetailSheet.tsx index 71c9ebe1..63f0be06 100644 --- a/src/components/calendar/EventDetailSheet.tsx +++ b/src/components/calendar/EventDetailSheet.tsx @@ -35,16 +35,17 @@ import { useColors } from '../../theme/colors'; import { eventTimeRange, getEventColor, + getEventDisplayEndDate, getPrimaryCalendarId, timePattern, type TimeFormat, } from '../../lib/calendar-utils'; import { alertsToReminders, formatReminder } from '../../lib/calendar-alerts'; import { - findParticipantByEmail, - isOrganizerParticipant, -} from '../../lib/calendar-invitation'; -import { useAccountStore } from '../../stores/account-store'; + getParticipantList, + getUserParticipantId, + isOrganizer, +} from '../../lib/calendar-participants'; import { useSheetDrag } from '../../lib/use-sheet-drag'; type RsvpStatus = 'accepted' | 'declined' | 'tentative'; @@ -53,6 +54,8 @@ interface EventDetailSheetProps { event: CalendarEvent | null; calendars: Calendar[]; timeFormat?: TimeFormat; + // Login address + identities + aliases; finds "me" among the participants. + currentUserEmails?: string[]; onClose: () => void; onEdit?: (event: CalendarEvent) => void; onDelete?: (event: CalendarEvent) => void; @@ -61,7 +64,10 @@ interface EventDetailSheetProps { } function formatRange(event: CalendarEvent, timeFormat?: TimeFormat): string { - const { start, end, allDay } = eventTimeRange(event); + const { start, allDay } = eventTimeRange(event); + // All-day events store an exclusive end (next day 00:00); show the + // inclusive last day so a one-day event doesn't read as two (#318). + const end = allDay ? getEventDisplayEndDate(event) : eventTimeRange(event).end; if (allDay) { if ( start.getFullYear() === end.getFullYear() && @@ -105,6 +111,7 @@ export function EventDetailSheet({ event, calendars, timeFormat, + currentUserEmails = [], onClose, onEdit, onDelete, @@ -112,7 +119,6 @@ export function EventDetailSheet({ onRsvp, }: EventDetailSheetProps) { const c = useColors(); - const activeEmail = useAccountStore((s) => s.getActiveAccount()?.email ?? null); const [rsvpBusy, setRsvpBusy] = React.useState(false); const styles = React.useMemo(() => makeStyles(c), [c]); const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current; @@ -164,21 +170,24 @@ export function EventDetailSheet({ const range = formatRange(event, timeFormat); const recurrence = recurrenceLabel(event); const reminders = alertsToReminders(event.alerts); - const participants = event.participants ? Object.values(event.participants) : []; + const participants = getParticipantList(event); const location = event.locations ? Object.values(event.locations)[0]?.name : undefined; const videoUri = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri : undefined; + const isCancelled = event.status === 'cancelled'; // Can the signed-in user RSVP? Only when they appear as a non-organizer - // participant and the caller wired an onRsvp handler. - const me = activeEmail ? findParticipantByEmail(event, [activeEmail]) : null; - const canRsvp = Boolean(onRsvp && me && !isOrganizerParticipant(me.participant)); - const myStatus = me?.participant.participationStatus; + // participant (matched against every address of theirs: login, identities, + // aliases) and the caller wired an onRsvp handler. + const myParticipantId = getUserParticipantId(event, currentUserEmails); + const userIsOrganizer = isOrganizer(event, currentUserEmails); + const canRsvp = Boolean(onRsvp && myParticipantId && !userIsOrganizer); + const myStatus = myParticipantId ? event.participants?.[myParticipantId]?.participationStatus : undefined; const doRsvp = async (status: RsvpStatus) => { - if (!onRsvp || !me || rsvpBusy) return; + if (!onRsvp || !myParticipantId || rsvpBusy) return; setRsvpBusy(true); try { - await onRsvp(event, me.id, status); + await onRsvp(event, myParticipantId, status); } finally { setRsvpBusy(false); } @@ -206,7 +215,9 @@ export function EventDetailSheet({ - {event.title || 'Untitled'} + + {event.title || 'Untitled'} + {calendar && {calendar.name}} @@ -303,21 +314,24 @@ export function EventDetailSheet({ {participants.length} participants - {participants.map((p, idx) => ( - + {participants.map((p) => ( + {p.name || p.email || 'Unknown'} + {p.isOrganizer ? ( + (organizer) + ) : null} - {statusLabel(p.participationStatus)} + {p.isOrganizer ? '' : statusLabel(p.status)} ))} @@ -508,6 +522,7 @@ function makeStyles(c: ThemePalette) { colorBar: { width: 4, alignSelf: 'stretch', borderRadius: 2 }, headerText: { flex: 1 }, title: { ...typography.h3, color: c.text }, + titleCancelled: { textDecorationLine: 'line-through', color: c.textMuted }, subtitle: { ...typography.caption, color: c.textMuted, marginTop: 2 }, closeBtn: { padding: 4 }, @@ -567,6 +582,7 @@ function makeStyles(c: ThemePalette) { }, participantDot: { width: 8, height: 8, borderRadius: 4 }, participantName: { flex: 1, ...typography.body, color: c.text }, + participantOrganizer: { ...typography.caption, color: c.textMuted }, participantStatus: { ...typography.caption, color: c.textMuted }, actions: { diff --git a/src/components/calendar/EventModal.tsx b/src/components/calendar/EventModal.tsx index ee158a07..26876b18 100644 --- a/src/components/calendar/EventModal.tsx +++ b/src/components/calendar/EventModal.tsx @@ -29,7 +29,6 @@ import { addHours, addMinutes, format } from 'date-fns'; import type { Calendar, CalendarEvent, - Participant, EventLocation, RecurrenceRule, VirtualLocation, @@ -53,6 +52,12 @@ import { type Reminder, } from '../../lib/calendar-alerts'; import { buildRecurrenceSummary, isSimpleRecurrenceRule } from '../../lib/recurrence'; +import { + buildParticipantMap, + getParticipantList, + seedAttendees, + type Attendee, +} from '../../lib/calendar-participants'; import { Button } from '..'; import { ParticipantInput } from './ParticipantInput'; import { RecurrenceEditor } from './RecurrenceEditor'; @@ -74,6 +79,9 @@ interface EventModalProps { calendars: Calendar[]; defaultDate?: Date; defaultCalendarId?: string; + // Login address + identities + aliases; the first one is the organizer + // address of invitations sent from this editor. + currentUserEmails?: string[]; onSave: (data: Partial, calendarId: string) => void | Promise; onDelete?: (event: CalendarEvent) => void; onClose: () => void; @@ -153,6 +161,7 @@ export function EventModal({ calendars, defaultDate, defaultCalendarId, + currentUserEmails = [], onSave, onDelete, onClose, @@ -174,7 +183,8 @@ export function EventModal({ || calendars[0]?.id || ''; const [calendarId, setCalendarId] = React.useState(fallbackCalendarId); - const [participants, setParticipants] = React.useState>({}); + // Attendee rows only — the organizer participant is rebuilt on save. + const [attendees, setAttendees] = React.useState([]); const [recurrence, setRecurrence] = React.useState('none'); const [customRule, setCustomRule] = React.useState(null); const [recurrenceEditorOpen, setRecurrenceEditorOpen] = React.useState(false); @@ -202,7 +212,7 @@ export function EventModal({ // grows the event by one day. setEnd(event.showWithoutTime ? getEventDisplayEndDate(event) : getEventEndDate(event)); setCalendarId(getPrimaryCalendarId(event) || calendars[0]?.id || ''); - setParticipants(event.participants || {}); + setAttendees(seedAttendees(event, currentUserEmails)); const detected = detectRecurrence(event); setRecurrence(detected); setCustomRule(detected === 'custom' ? event.recurrenceRules?.[0] ?? null : null); @@ -223,7 +233,7 @@ export function EventModal({ || calendars[0]?.id || '', ); - setParticipants({}); + setAttendees([]); setRecurrence('none'); setCustomRule(null); setRecurrenceEditorOpen(false); @@ -231,7 +241,7 @@ export function EventModal({ setLocation(''); setVideoUrl(''); } - }, [visible, event, defaultDate, defaultCalendarId, calendars]); + }, [visible, event, defaultDate, defaultCalendarId, calendars, currentUserEmails]); React.useEffect(() => { if (allDay) { @@ -295,16 +305,30 @@ export function EventModal({ // every viewer; label the wall-clock with the zone it was entered in. // All-day events are date-only and carry no zone. timeZone: allDay ? null : getEffectiveTimeZone(), - participants: clearedOr( - Object.keys(participants).length > 0 ? participants : undefined, - 'participants', - ), recurrenceRules: clearedOr(recurrenceRules, 'recurrenceRules'), alerts: clearedOr(remindersToAlerts(reminders), 'alerts'), useDefaultAlerts: reminders.length > 0 ? false : undefined, locations: clearedOr(buildLocations(location), 'locations'), virtualLocations: clearedOr(buildVirtualLocations(videoUrl), 'virtualLocations'), }; + const organizerEmail = currentUserEmails[0]; + if (attendees.length > 0 && organizerEmail) { + const organizerName = + (event ? getParticipantList(event).find((p) => p.isOrganizer)?.name : '') || ''; + data.participants = buildParticipantMap( + { name: organizerName, email: organizerEmail }, + attendees, + ); + // Stalwart (calcard) derives the iCalendar ORGANIZER property solely from + // organizerCalendarAddress; without it no ORGANIZER is emitted and iTIP + // scheduling is silently skipped, so no invites are sent. + data.organizerCalendarAddress = `mailto:${organizerEmail}`; + } else if (attendees.length === 0 && isEdit && had('participants')) { + data.participants = null; + // Also clear the retired replyTo that older releases wrote. + if (event?.replyTo) data.replyTo = null; + if (event?.organizerCalendarAddress) data.organizerCalendarAddress = null; + } if (data.recurrenceRules === null) { // Dropping the rule leaves per-occurrence data dangling. if (had('recurrenceOverrides')) data.recurrenceOverrides = null; @@ -619,8 +643,11 @@ export function EventModal({ label="Participants" > setAttendees((prev) => [...prev, a])} + onRemove={(email) => + setAttendees((prev) => prev.filter((a) => a.email.toLowerCase() !== email.toLowerCase())) + } /> diff --git a/src/components/calendar/ParticipantInput.tsx b/src/components/calendar/ParticipantInput.tsx index 29ec6352..b4d8e612 100644 --- a/src/components/calendar/ParticipantInput.tsx +++ b/src/components/calendar/ParticipantInput.tsx @@ -11,11 +11,13 @@ import { X } from 'lucide-react-native'; import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; import { useContactsStore } from '../../stores/contacts-store'; -import type { Participant } from '../../api/types'; +import { useLocaleStore } from '../../stores/locale-store'; +import type { Attendee } from '../../lib/calendar-participants'; interface ParticipantInputProps { - participants: Record; - onChange: (next: Record) => void; + attendees: Attendee[]; + onAdd: (attendee: Attendee) => void; + onRemove: (email: string) => void; } interface Suggestion { @@ -27,10 +29,6 @@ function emailRegex(): RegExp { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/; } -function generateParticipantId(): string { - return `p-${Math.random().toString(36).slice(2, 10)}`; -} - function flattenContactEmails(): Suggestion[] { const contacts = useContactsStore.getState().contacts; const out: Suggestion[] = []; @@ -44,15 +42,18 @@ function flattenContactEmails(): Suggestion[] { return out; } -export function ParticipantInput({ participants, onChange }: ParticipantInputProps) { +// Attendee rows only: the organizer participant is added by +// buildParticipantMap on save (see lib/calendar-participants). +export function ParticipantInput({ attendees, onAdd, onRemove }: ParticipantInputProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); const [draft, setDraft] = React.useState(''); const [allSuggestions] = React.useState(() => flattenContactEmails()); const existingEmails = React.useMemo(() => { - return new Set(Object.values(participants).map((p) => p.email?.toLowerCase())); - }, [participants]); + return new Set(attendees.map((a) => a.email.toLowerCase())); + }, [attendees]); const filteredSuggestions = React.useMemo(() => { const q = draft.trim().toLowerCase(); @@ -75,44 +76,24 @@ export function ParticipantInput({ participants, onChange }: ParticipantInputPro setDraft(''); return; } - const id = generateParticipantId(); - const next: Record = { - ...participants, - [id]: { - email: trimmed, - name, - sendTo: { imip: `mailto:${trimmed}` }, - roles: { attendee: true }, - participationStatus: 'needs-action', - expectReply: true, - }, - }; - onChange(next); + onAdd({ name: name || '', email: trimmed }); setDraft(''); }; - const removeParticipant = (id: string) => { - const next = { ...participants }; - delete next[id]; - onChange(next); - }; - const handleSubmit = () => { if (draft.trim()) addParticipant(draft); }; - const entries = Object.entries(participants); - return ( - {entries.length > 0 && ( + {attendees.length > 0 && ( - {entries.map(([id, p]) => ( - + {attendees.map((a) => ( + - {p.name || p.email || 'Unknown'} + {a.name || a.email} - removeParticipant(id)} hitSlop={6}> + onRemove(a.email)} hitSlop={6}> @@ -123,12 +104,13 @@ export function ParticipantInput({ participants, onChange }: ParticipantInputPro diff --git a/src/components/email/CalendarInvitationBanner.tsx b/src/components/email/CalendarInvitationBanner.tsx index d6eb7024..7aaa24d0 100644 --- a/src/components/email/CalendarInvitationBanner.tsx +++ b/src/components/email/CalendarInvitationBanner.tsx @@ -9,7 +9,6 @@ import { spacing, radius, typography, type ThemePalette } from '../../theme/toke import { useColors } from '../../theme/colors'; import { parseCalendarBlob } from '../../api/calendar'; import { useCalendarStore } from '../../stores/calendar-store'; -import { useAccountStore } from '../../stores/account-store'; import { useSettingsStore } from '../../stores/settings-store'; import { findCalendarAttachment, @@ -19,6 +18,7 @@ import { inferInvitationMethod, type InvitationMethod, } from '../../lib/calendar-invitation'; +import { useUserCalendarAddresses } from '../../lib/calendar-user-addresses'; type BannerState = 'loading' | 'parsed' | 'done' | 'error'; type RsvpStatus = 'accepted' | 'tentative' | 'declined'; @@ -34,7 +34,9 @@ export function CalendarInvitationBanner({ email }: Props) { const calendars = useCalendarStore((s) => s.calendars); const importEvents = useCalendarStore((s) => s.importEvents); const rsvpEvent = useCalendarStore((s) => s.rsvpEvent); - const activeEmail = useAccountStore((s) => s.getActiveAccount()?.email ?? null); + // Login address + identities + aliases, so invitations addressed to an + // alias still show the RSVP buttons. + const currentUserEmails = useUserCalendarAddresses(); const attachment = React.useMemo(() => findCalendarAttachment(email), [email]); @@ -90,7 +92,7 @@ export function CalendarInvitationBanner({ email }: Props) { const organizer = getOrganizerName(event); const location = event.locations ? Object.values(event.locations)[0]?.name : undefined; const videoUri = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri : undefined; - const me = activeEmail ? findParticipantByEmail(event, [activeEmail]) : null; + const me = findParticipantByEmail(event, currentUserEmails); const canRsvp = method !== 'cancel' && method !== 'reply' && !!me; const writableCalendar = calendars.find((cal) => !cal.myRights || cal.myRights.mayWrite !== false); @@ -104,8 +106,8 @@ export function CalendarInvitationBanner({ email }: Props) { await importEvents([event], writableCalendar.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 && activeEmail - ? findParticipantByEmail(stored, [activeEmail]) + const participant = stored + ? findParticipantByEmail(stored, currentUserEmails) : me; if (stored && participant) { await rsvpEvent(stored.id, participant.id, status, buildReplyTo(event)); diff --git a/src/lib/__tests__/calendar-participants.test.ts b/src/lib/__tests__/calendar-participants.test.ts new file mode 100644 index 00000000..03157401 --- /dev/null +++ b/src/lib/__tests__/calendar-participants.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from 'vitest'; +import type { CalendarEvent } from '../../api/types'; +import { + buildParticipantMap, + collectUserCalendarAddresses, + getParticipantList, + getStatusCounts, + getUserParticipantId, + isOrganizer, + seedAttendees, +} from '../calendar-participants'; + +const event: Partial = { + organizerCalendarAddress: 'mailto:Alice@example.com', + participants: { + // Stalwart rebuilds ORGANIZER into a roles-less participant (#731). + org: { calendarAddress: 'mailto:alice@example.com', name: 'Alice' }, + bob: { email: 'bob@example.com', roles: { attendee: true }, participationStatus: 'accepted' }, + carol: { calendarAddress: 'mailto:carol@example.com', roles: { attendee: true } }, + }, +}; + +describe('buildParticipantMap', () => { + it('emits an owner-only organizer plus server-scheduled attendees, deduped (#731)', () => { + const map = buildParticipantMap({ name: 'Alice', email: 'alice@example.com' }, [ + { name: 'Bob', email: 'bob@example.com' }, + { name: 'Alice again', email: 'ALICE@example.com' }, + { name: 'Bob dup', email: 'Bob@example.com' }, + ]); + const list = Object.values(map); + expect(list).toHaveLength(2); + const organizer = list.find((p) => p.roles?.owner)!; + expect(organizer.roles).toEqual({ owner: true }); + expect(organizer.calendarAddress).toBe('mailto:alice@example.com'); + expect(organizer.scheduleAgent).toBe('server'); + expect(organizer.sendTo).toBeUndefined(); + const bob = list.find((p) => p.email === 'bob@example.com')!; + expect(bob.roles).toEqual({ attendee: true }); + expect(bob.calendarAddress).toBe('mailto:bob@example.com'); + expect(bob.scheduleAgent).toBe('server'); + expect(bob.expectReply).toBe(true); + expect(bob.participationStatus).toBe('needs-action'); + }); +}); + +describe('collectUserCalendarAddresses', () => { + it('merges groups, dedupes case-insensitively and drops blanks', () => { + expect( + collectUserCalendarAddresses(['Me@example.com'], ['me@example.com', ' ', null, 'alias@example.com']), + ).toEqual(['Me@example.com', 'alias@example.com']); + }); +}); + +describe('organizer / participant detection', () => { + it('recognises the organizer via organizerCalendarAddress and an alias', () => { + expect(isOrganizer(event, ['alice@example.com'])).toBe(true); + expect(isOrganizer(event, ['bob@example.com'])).toBe(false); + expect(isOrganizer(event, [])).toBe(false); + }); + + it('finds the user participant by calendarAddress', () => { + expect(getUserParticipantId(event, ['CAROL@example.com'])).toBe('carol'); + expect(getUserParticipantId(event, ['nobody@example.com'])).toBeNull(); + }); + + it('marks the roles-less organizer participant as organizer', () => { + const list = getParticipantList(event); + expect(list.find((p) => p.id === 'org')?.isOrganizer).toBe(true); + expect(list.find((p) => p.id === 'bob')?.isOrganizer).toBe(false); + expect(list.find((p) => p.id === 'carol')?.email).toBe('carol@example.com'); + }); + + it('does not count the organizer in status totals', () => { + expect(getStatusCounts(event)).toEqual({ accepted: 1, declined: 0, tentative: 0, 'needs-action': 1 }); + }); +}); + +describe('seedAttendees', () => { + it('never seeds the organizer or a repeated address (#731)', () => { + const attendees = seedAttendees(event, ['alice@example.com']); + expect(attendees.map((a) => a.email)).toEqual(['bob@example.com', 'carol@example.com']); + }); + + it('round-trips through buildParticipantMap without duplicating the organizer', () => { + const attendees = seedAttendees(event, ['alice@example.com']); + const map = buildParticipantMap({ name: 'Alice', email: 'alice@example.com' }, attendees); + const emails = Object.values(map).map((p) => p.email?.toLowerCase()); + expect(emails.filter((e) => e === 'alice@example.com')).toHaveLength(1); + expect(emails).toHaveLength(3); + }); +}); diff --git a/src/lib/calendar-participants.ts b/src/lib/calendar-participants.ts new file mode 100644 index 00000000..584a2152 --- /dev/null +++ b/src/lib/calendar-participants.ts @@ -0,0 +1,249 @@ +import type { CalendarEvent, Participant } from '../api/types'; +import { generateUUID } from './uuid'; + +// Port of the webmail's lib/calendar-participants.ts. + +export interface ParticipantInfo { + id: string; + name: string; + email: string; + status: Participant['participationStatus']; + isOrganizer: boolean; +} + +export interface StatusCounts { + accepted: number; + declined: number; + tentative: number; + 'needs-action': number; +} + +/** + * Check if a participant matches any of the given email addresses. + * Checks p.email, p.calendarAddress (mailto:...), and p.sendTo values. + */ +function participantMatchesEmail(p: Participant, lowerEmails: string[]): boolean { + if (p.email && lowerEmails.includes(p.email.toLowerCase())) return true; + if (p.calendarAddress) { + const addr = p.calendarAddress.replace(/^mailto:/i, '').toLowerCase(); + if (addr && lowerEmails.includes(addr)) return true; + } + if (p.sendTo) { + for (const addr of Object.values(p.sendTo)) { + const normalized = addr.replace(/^mailto:/i, '').toLowerCase(); + if (normalized && lowerEmails.includes(normalized)) return true; + } + } + return false; +} + +/** Best-effort scheduling address for a participant, without the mailto: scheme. */ +export function getParticipantEmail(p: Participant): string { + if (p.email) return p.email; + if (p.calendarAddress) return p.calendarAddress.replace(/^mailto:/i, ''); + if (p.sendTo?.imip) return p.sendTo.imip.replace(/^mailto:/i, ''); + return ''; +} + +/** + * Collects the event-level organizer calendar address(es). + * Stalwart conveys the organizer via `organizerCalendarAddress` / `replyTo` + * rather than a participant `roles.owner` flag, so self-organized events + * imported from another server have no owner participant to match against. + */ +export function getEventOrganizerEmails(event: Partial): string[] { + const emails: string[] = []; + if (event.organizerCalendarAddress) { + emails.push(event.organizerCalendarAddress.replace(/^mailto:/i, '').toLowerCase()); + } + if (event.replyTo) { + for (const addr of Object.values(event.replyTo)) { + emails.push(addr.replace(/^mailto:/i, '').toLowerCase()); + } + } + return emails.filter(Boolean); +} + +/** + * Merge the user's calendar addresses (login address, identities, account + * aliases) so isOrganizer() recognises alias-organized events as the user's + * own. De-duplicated case-insensitively (first casing kept); blanks dropped. + */ +export function collectUserCalendarAddresses( + ...groups: Array> +): string[] { + const seen = new Set(); + const out: string[] = []; + for (const group of groups) { + for (const raw of group) { + const trimmed = raw?.trim(); + if (!trimmed) continue; + const key = trimmed.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + out.push(trimmed); + } + } + return out; +} + +export function isOrganizer(event: Partial, userEmails: string[]): boolean { + if (userEmails.length === 0) return false; + const lower = userEmails.map((e) => e.toLowerCase()); + + if (event.participants) { + const ownerMatch = Object.values(event.participants).some( + (p) => p.roles?.owner && participantMatchesEmail(p, lower), + ); + if (ownerMatch) return true; + } + + // Fall back to the event-level organizer address (Stalwart / imported events + // mark the organizer here instead of via a participant `owner` role). + return getEventOrganizerEmails(event).some((email) => lower.includes(email)); +} + +export function getUserParticipantId( + event: Partial, + userEmails: string[], +): string | null { + if (!event.participants) return null; + const lower = userEmails.map((e) => e.toLowerCase()); + for (const [id, p] of Object.entries(event.participants)) { + if (participantMatchesEmail(p, lower)) return id; + } + return null; +} + +export function getUserStatus( + event: Partial, + userEmails: string[], +): Participant['participationStatus'] | null { + if (!event.participants) return null; + const lower = userEmails.map((e) => e.toLowerCase()); + for (const p of Object.values(event.participants)) { + if (participantMatchesEmail(p, lower)) return p.participationStatus; + } + return null; +} + +export function getParticipantList(event: Partial): ParticipantInfo[] { + if (!event.participants) return []; + // Stalwart rebuilds the ORGANIZER line into a participant that carries only + // `calendarAddress` — no `roles` at all — so `roles.owner` alone would treat + // the organizer as a plain attendee on every re-read (#731). + const organizerEmails = getEventOrganizerEmails(event); + return Object.entries(event.participants).map(([id, p]) => { + const email = getParticipantEmail(p); + return { + id, + name: p.name || '', + email, + status: p.participationStatus || 'needs-action', + isOrganizer: + !!p.roles?.owner || (!!email && organizerEmails.includes(email.toLowerCase())), + }; + }); +} + +export function getStatusCounts(event: Partial): StatusCounts { + const counts: StatusCounts = { accepted: 0, declined: 0, tentative: 0, 'needs-action': 0 }; + if (!event.participants) return counts; + const organizerEmails = getEventOrganizerEmails(event); + for (const p of Object.values(event.participants)) { + // The organizer is not awaiting their own reply; counting the roles-less + // participant Stalwart derives from ORGANIZER inflates the pending total. + const email = getParticipantEmail(p).toLowerCase(); + if (p.roles?.owner || (email && organizerEmails.includes(email))) continue; + const s = p.participationStatus || 'needs-action'; + if (s in counts) counts[s as keyof StatusCounts]++; + } + return counts; +} + +export function getParticipantCount(event: Partial): number { + if (!event.participants) return 0; + return Object.keys(event.participants).length; +} + +export interface Attendee { + name: string; + email: string; +} + +export function buildParticipantMap( + organizer: { name: string; email: string }, + attendees: Attendee[], +): Record { + const participants: Record = {}; + + 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, + email: organizer.email, + calendarAddress: `mailto:${organizer.email}`, + // owner only, NOT attendee: with roles.attendee set, Stalwart's server-side + // scheduling emits the organizer as an ATTENDEE line in addition to the + // ORGANIZER line, so the recipient sees the organizer listed twice. + roles: { owner: true }, + participationStatus: 'accepted', + scheduleAgent: 'server', + expectReply: false, + kind: 'individual', + }; + + // The organizer already has an entry above, and an address must not appear + // twice in the invite list, so drop both cases case-insensitively (#731). + const seen = new Set([organizer.email.trim().toLowerCase()]); + + attendees.forEach((a) => { + const email = a.email.trim(); + const key = email.toLowerCase(); + if (!key || seen.has(key)) return; + seen.add(key); + + participants[generateId()] = { + '@type': 'Participant', + name: a.name, + email, + calendarAddress: `mailto:${email}`, + roles: { attendee: true }, + participationStatus: 'needs-action', + scheduleAgent: 'server', + expectReply: true, + kind: 'individual', + }; + }); + + return participants; +} + +/** + * The attendee rows an editor should start from: every participant except the + * organizer (who is re-added by buildParticipantMap on save), without + * duplicate addresses (#731). + */ +export function seedAttendees( + event: Partial | null | undefined, + userEmails: string[], +): Attendee[] { + if (!event?.participants) return []; + const excluded = new Set(); + if (isOrganizer(event, userEmails) && userEmails[0]) { + excluded.add(userEmails[0].toLowerCase()); + } + const out: Attendee[] = []; + for (const p of getParticipantList(event)) { + if (p.isOrganizer) continue; + const key = p.email.trim().toLowerCase(); + if (!key || excluded.has(key)) continue; + excluded.add(key); + out.push({ name: p.name, email: p.email.trim() }); + } + return out; +} diff --git a/src/lib/calendar-user-addresses.ts b/src/lib/calendar-user-addresses.ts new file mode 100644 index 00000000..0d751913 --- /dev/null +++ b/src/lib/calendar-user-addresses.ts @@ -0,0 +1,70 @@ +import React from 'react'; +import { useAccountStore } from '../stores/account-store'; +import { useSettingsStore } from '../stores/settings-store'; +import { jmapClient } from '../api/jmap-client'; +import { fetchPrincipal } from '../api/account-security'; +import { collectUserCalendarAddresses } from './calendar-participants'; + +// Account aliases come from x:Account/get (Stalwart's principal object) and +// only change when an admin edits the account, so they're fetched once per +// JMAP account and remembered for the session. Failure (older server, no +// permission) simply leaves the list at login address + identities. +const aliasCache = new Map(); +const aliasInFlight = new Map>(); +const aliasListeners = new Set<() => void>(); + +function loadAliases(accountId: string): Promise { + const cached = aliasCache.get(accountId); + if (cached) return Promise.resolve(cached); + const pending = aliasInFlight.get(accountId); + if (pending) return pending; + const p = fetchPrincipal() + .then((info) => info.emails) + .catch(() => [] as string[]) + .then((emails) => { + aliasCache.set(accountId, emails); + aliasInFlight.delete(accountId); + for (const l of aliasListeners) l(); + return emails; + }); + aliasInFlight.set(accountId, p); + return p; +} + +/** Test hook: forget cached aliases (also useful after re-login). */ +export function resetUserCalendarAddressCache(): void { + aliasCache.clear(); + aliasInFlight.clear(); +} + +/** + * Every address the signed-in user can be addressed at for scheduling: the + * login address, the sending identities and the account aliases. Used to + * find "me" among an event's participants (RSVP), to detect alias-organized + * events as the user's own, and as the organizer address of new invites. + */ +export function useUserCalendarAddresses(): string[] { + const activeEmail = useAccountStore((s) => s.getActiveAccount()?.email ?? null); + const identities = useSettingsStore((s) => s.identities); + const [, bump] = React.useReducer((n: number) => n + 1, 0); + + const accountId = jmapClient.isConnected ? jmapClient.accountId : null; + React.useEffect(() => { + if (!accountId) return; + if (aliasCache.has(accountId)) return; + aliasListeners.add(bump); + void loadAliases(accountId); + return () => { aliasListeners.delete(bump); }; + }, [accountId]); + + const aliases = accountId ? aliasCache.get(accountId) ?? [] : []; + return React.useMemo( + () => collectUserCalendarAddresses( + [activeEmail], + identities.map((i) => i.email), + aliases, + ), + // eslint-disable-next-line react-hooks/exhaustive-deps + [activeEmail, identities, aliases.join('|')], + ); +} diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index a98bae7d..4135e6ff 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -73,6 +73,7 @@ import { import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from '../lib/birthday-calendar'; import { useContactsStore } from '../stores/contacts-store'; import { useLocaleStore } from '../stores/locale-store'; +import { useUserCalendarAddresses } from '../lib/calendar-user-addresses'; import type { Calendar, CalendarEvent, RecurrenceRule } from '../api/types'; type ViewMode = 'month' | 'week' | 'agenda'; @@ -126,6 +127,7 @@ export default function CalendarScreen() { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); + const currentUserEmails = useUserCalendarAddresses(); const calendarDefaultView = useSettingsStore((s) => s.calendarDefaultView); const calendarFirstDayOfWeek = useSettingsStore((s) => s.calendarFirstDayOfWeek); const calendarShowWeekNumbers = useSettingsStore((s) => s.calendarShowWeekNumbers); @@ -627,6 +629,7 @@ export default function CalendarScreen() { event={detailEvent} calendars={calendars} timeFormat={calendarTimeFormat} + currentUserEmails={currentUserEmails} onClose={() => setDetailEvent(null)} onEdit={handleEditFromDetail} onDelete={handleDeleteFromDetail} @@ -651,6 +654,7 @@ export default function CalendarScreen() { event={modalEvent} calendars={eventCalendars} defaultDate={modalDate} + currentUserEmails={currentUserEmails} onSave={handleSave} onDelete={handleDeleteFromModal} onClose={() => setModalVisible(false)} diff --git a/src/stores/calendar-store.ts b/src/stores/calendar-store.ts index 4f61dc0e..738cb609 100644 --- a/src/stores/calendar-store.ts +++ b/src/stores/calendar-store.ts @@ -375,12 +375,20 @@ export const useCalendarStore = create()( }, rsvpEvent: async (eventId, participantId, status, replyTo) => { - if (!participantId || participantId.includes('..')) { + // 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); const realId = storeEvent?.originalId || eventId; - await apiRsvpEvent(realId, participantId, status, replyTo, storeEvent?.accountId); + // Repair events that are missing the organizer (e.g. imported ones) so + // Stalwart can route the REPLY; never touch an existing one. + const repair = + replyTo?.imip && storeEvent && !storeEvent.organizerCalendarAddress + ? replyTo.imip + : undefined; + await apiRsvpEvent(realId, participantId, status, repair, storeEvent?.accountId); set({ events: get().events.map((e) => { if (e.id !== eventId || !e.participants?.[participantId]) return e;