mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 16:12:02 +00:00
fix: stop re-adding the organizer to the attendee list on every save #731
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user