Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions lib/__tests__/calendar-participants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
35 changes: 31 additions & 4 deletions lib/calendar-participants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down