diff --git a/lib/__tests__/calendar-participants.test.ts b/lib/__tests__/calendar-participants.test.ts index e67503cc..3b4e5f7c 100644 --- a/lib/__tests__/calendar-participants.test.ts +++ b/lib/__tests__/calendar-participants.test.ts @@ -132,6 +132,42 @@ describe('isOrganizer', () => { const event = makeEvent({ org: orgParticipant }); expect(isOrganizer(event, [])).toBe(false); }); + + it('matches the event-level organizerCalendarAddress when no owner role is set', () => { + // Stalwart / imported self-organized events: the user's participant only + // carries `attendee`, the organizer lives in organizerCalendarAddress. + const event = makeEvent({ + self: { + '@type': 'Participant', + name: 'Alice', + email: '', + roles: { attendee: true }, + participationStatus: 'accepted', + sendTo: { imip: 'mailto:alice@example.com' }, + kind: 'individual', + }, + }); + event.organizerCalendarAddress = 'mailto:alice@example.com'; + expect(isOrganizer(event, ['alice@example.com'])).toBe(true); + }); + + it('matches the event-level organizerCalendarAddress case-insensitively', () => { + const event = makeEvent({ att1: attendeeParticipant }); + event.organizerCalendarAddress = 'mailto:Alice@Example.com'; + expect(isOrganizer(event, ['alice@example.com'])).toBe(true); + }); + + it('falls back to replyTo when organizerCalendarAddress is absent', () => { + const event = makeEvent({ att1: attendeeParticipant }); + event.replyTo = { imip: 'mailto:alice@example.com' }; + expect(isOrganizer(event, ['alice@example.com'])).toBe(true); + }); + + it('returns false when the event organizer is someone else', () => { + const event = makeEvent({ att1: attendeeParticipant }); + event.organizerCalendarAddress = 'mailto:someoneelse@example.com'; + expect(isOrganizer(event, ['alice@example.com'])).toBe(false); + }); }); describe('getUserParticipantId', () => { diff --git a/lib/calendar-participants.ts b/lib/calendar-participants.ts index c94c9d99..0a349fcd 100644 --- a/lib/calendar-participants.ts +++ b/lib/calendar-participants.ts @@ -35,12 +35,39 @@ function participantMatchesEmail(p: CalendarParticipant, lowerEmails: string[]): return false; } +/** + * 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. + */ +function getEventOrganizerEmails(event: CalendarEvent): 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); +} + export function isOrganizer(event: CalendarEvent, userEmails: string[]): boolean { - if (!event.participants) return false; + if (userEmails.length === 0) return false; const lower = userEmails.map(e => e.toLowerCase()); - return Object.values(event.participants).some(p => - p.roles?.owner && participantMatchesEmail(p, lower) - ); + + 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: CalendarEvent, userEmails: string[]): string | null {