diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index cd65425d6..5e0cfc145 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -329,9 +329,22 @@ export function EventModal({ const [attendees, setAttendees] = useState<{ name: string; email: string }[]>(() => { if (!event?.participants) return []; + // Never seed the invite list with the organizer or with a repeated address: + // handleSave writes the organizer separately, so either would round-trip + // into a duplicate entry every time the event is saved (#731). + const excluded = new Set(); + if (userIsOrganizer && currentUserEmails[0]) { + excluded.add(currentUserEmails[0].toLowerCase()); + } return existingParticipants .filter(p => !p.isOrganizer) - .map(p => ({ name: p.name, email: p.email })); + .reduce<{ name: string; email: string }[]>((acc, p) => { + const key = p.email.trim().toLowerCase(); + if (!key || excluded.has(key)) return acc; + excluded.add(key); + acc.push({ name: p.name, email: p.email.trim() }); + return acc; + }, []); }); const [sendInvitations, setSendInvitations] = useState(true); const participantInputRef = useRef(null); diff --git a/lib/__tests__/calendar-participants.test.ts b/lib/__tests__/calendar-participants.test.ts index b443ee5cd..341410a61 100644 --- a/lib/__tests__/calendar-participants.test.ts +++ b/lib/__tests__/calendar-participants.test.ts @@ -10,7 +10,10 @@ import { buildParticipantMap, } from '@/lib/calendar-participants'; -function makeEvent(participants: Record> | null = null): CalendarEvent { +function makeEvent( + participants: Record> | null = null, + overrides: Partial = {} +): CalendarEvent { return { '@type': 'Event', id: 'ev1', @@ -54,9 +57,17 @@ function makeEvent(participants: Record> | mayInviteSelf: false, mayInviteOthers: false, hideAttendees: false, + ...overrides, }; } +// What Stalwart hands back after an iCalendar round-trip: the ORGANIZER line is +// rebuilt as a participant carrying only calendarAddress — no roles, no status. +const stalwartOrganizerParticipant: Partial = { + '@type': 'Participant', + calendarAddress: 'mailto:alice@example.com', +}; + const orgParticipant: Partial = { '@type': 'Participant', name: 'Alice', @@ -258,6 +269,37 @@ describe('getParticipantList', () => { const list = getParticipantList(event); expect(list[0].status).toBe('needs-action'); }); + + // #731: without this the organizer is treated as a plain attendee, seeded + // into the invite list on reopen, and re-saved as a duplicate every time. + it('flags the roles-less organizer Stalwart derives from ORGANIZER', () => { + const event = makeEvent( + { org: stalwartOrganizerParticipant, att1: attendeeParticipant }, + { organizerCalendarAddress: 'mailto:alice@example.com' } + ); + const list = getParticipantList(event); + expect(list.find(p => p.id === 'org')).toMatchObject({ + email: 'alice@example.com', + isOrganizer: true, + }); + expect(list.find(p => p.id === 'att1')!.isOrganizer).toBe(false); + }); + + it('matches the organizer address case-insensitively', () => { + const event = makeEvent( + { org: { '@type': 'Participant', calendarAddress: 'mailto:Alice@Example.COM' } }, + { organizerCalendarAddress: 'mailto:alice@example.com' } + ); + expect(getParticipantList(event)[0].isOrganizer).toBe(true); + }); + + it('flags the organizer via the legacy replyTo address', () => { + const event = makeEvent( + { org: stalwartOrganizerParticipant }, + { replyTo: { imip: 'mailto:alice@example.com' } } + ); + expect(getParticipantList(event)[0].isOrganizer).toBe(true); + }); }); describe('getStatusCounts', () => { @@ -270,7 +312,8 @@ describe('getStatusCounts', () => { att4: attendeeParticipant, }); const counts = getStatusCounts(event); - expect(counts.accepted).toBe(2); + // The organizer is excluded: they are not awaiting their own reply. + expect(counts.accepted).toBe(1); expect(counts.declined).toBe(1); expect(counts.tentative).toBe(1); expect(counts['needs-action']).toBe(1); @@ -281,6 +324,18 @@ describe('getStatusCounts', () => { const counts = getStatusCounts(event); expect(counts).toEqual({ accepted: 0, declined: 0, tentative: 0, 'needs-action': 0 }); }); + + // #731: the statusless participant Stalwart rebuilds from ORGANIZER would + // otherwise show the organizer as one more attendee still owing a response. + it('does not count the roles-less organizer as pending', () => { + const event = makeEvent( + { org: stalwartOrganizerParticipant, att1: attendeeParticipant }, + { organizerCalendarAddress: 'mailto:alice@example.com' } + ); + const counts = getStatusCounts(event); + expect(counts['needs-action']).toBe(1); + expect(counts.accepted).toBe(0); + }); }); describe('getParticipantCount', () => { @@ -370,4 +425,54 @@ describe('buildParticipantMap', () => { expect(p.kind).toBe('individual'); }); }); + + // #731: the organizer must never also be written as an attendee, however + // they reached the invite list. + it('drops the organizer from the attendee list', () => { + const map = buildParticipantMap( + { name: 'Alice', email: 'alice@example.com' }, + [ + { name: 'Alice', email: 'alice@example.com' }, + { name: 'Bob', email: 'bob@example.com' }, + ] + ); + expect(Object.keys(map)).toHaveLength(2); + expect(Object.values(map).filter(p => p.roles?.attendee)).toHaveLength(1); + const alice = Object.values(map).filter(p => p.email === 'alice@example.com'); + expect(alice).toHaveLength(1); + expect(alice[0].roles).toEqual({ owner: true }); + }); + + it('drops the organizer regardless of case or surrounding whitespace', () => { + const map = buildParticipantMap( + { name: 'Alice', email: 'alice@example.com' }, + [{ name: '', email: ' ALICE@Example.com ' }] + ); + expect(Object.keys(map)).toHaveLength(1); + expect(Object.values(map)[0].roles).toEqual({ owner: true }); + }); + + it('deduplicates repeated attendee addresses case-insensitively', () => { + const map = buildParticipantMap( + { name: 'Alice', email: 'alice@example.com' }, + [ + { name: 'Bob', email: 'bob@example.com' }, + { name: 'Bobby', email: 'BOB@example.com' }, + { name: 'Carol', email: 'carol@example.com' }, + ] + ); + expect(Object.keys(map)).toHaveLength(3); + const bobs = Object.values(map).filter(p => p.email?.toLowerCase() === 'bob@example.com'); + expect(bobs).toHaveLength(1); + expect(bobs[0].name).toBe('Bob'); + }); + + it('skips blank attendee addresses', () => { + const map = buildParticipantMap( + { name: 'Alice', email: 'alice@example.com' }, + [{ name: 'Nobody', email: ' ' }, { name: 'Bob', email: 'bob@example.com' }] + ); + expect(Object.keys(map)).toHaveLength(2); + expect(Object.values(map).some(p => p.name === 'Nobody')).toBe(false); + }); }); diff --git a/lib/calendar-participants.ts b/lib/calendar-participants.ts index cd7aa4a42..fea07a6de 100644 --- a/lib/calendar-participants.ts +++ b/lib/calendar-participants.ts @@ -35,13 +35,21 @@ function participantMatchesEmail(p: CalendarParticipant, lowerEmails: string[]): return false; } +/** Best-effort scheduling address for a participant, without the mailto: scheme. */ +function getParticipantEmail(p: CalendarParticipant): 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. */ -function getEventOrganizerEmails(event: CalendarEvent): string[] { +export function getEventOrganizerEmails(event: CalendarEvent): string[] { const emails: string[] = []; if (event.organizerCalendarAddress) { emails.push(event.organizerCalendarAddress.replace(/^mailto:/i, '').toLowerCase()); @@ -93,20 +101,18 @@ export function getUserStatus( export function getParticipantList(event: CalendarEvent): 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]) => { - let email = p.email || ''; - if (!email && p.calendarAddress) { - email = p.calendarAddress.replace(/^mailto:/i, ''); - } - if (!email && p.sendTo?.imip) { - email = p.sendTo.imip.replace(/^mailto:/i, ''); - } + const email = getParticipantEmail(p); return { id, name: p.name || '', email, status: p.participationStatus || 'needs-action', - isOrganizer: !!p.roles?.owner, + isOrganizer: !!p.roles?.owner || (!!email && organizerEmails.includes(email.toLowerCase())), }; }); } @@ -114,7 +120,12 @@ export function getParticipantList(event: CalendarEvent): ParticipantInfo[] { export function getStatusCounts(event: CalendarEvent): 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]++; } @@ -152,12 +163,21 @@ export function buildParticipantMap( 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: a.email, - calendarAddress: `mailto:${a.email}`, + email, + calendarAddress: `mailto:${email}`, roles: { attendee: true }, participationStatus: 'needs-action', scheduleAgent: 'server',