fix: stop re-adding the organizer to the attendee list on every save #731

This commit is contained in:
Linus Rath
2026-08-06 18:29:34 +02:00
parent 11b6bdd8a6
commit cfe2af4d8a
3 changed files with 152 additions and 14 deletions
+14 -1
View File
@@ -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<string>();
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<ParticipantInputHandle>(null);
+107 -2
View File
@@ -10,7 +10,10 @@ import {
buildParticipantMap,
} from '@/lib/calendar-participants';
function makeEvent(participants: Record<string, Partial<CalendarParticipant>> | null = null): CalendarEvent {
function makeEvent(
participants: Record<string, Partial<CalendarParticipant>> | null = null,
overrides: Partial<CalendarEvent> = {}
): CalendarEvent {
return {
'@type': 'Event',
id: 'ev1',
@@ -54,9 +57,17 @@ function makeEvent(participants: Record<string, Partial<CalendarParticipant>> |
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<CalendarParticipant> = {
'@type': 'Participant',
calendarAddress: 'mailto:alice@example.com',
};
const orgParticipant: Partial<CalendarParticipant> = {
'@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);
});
});
+31 -11
View File
@@ -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<string>([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',