From 7037830ad9ea4ad1a386625aef279d6b23770a33 Mon Sep 17 00:00:00 2001 From: lucletoffe <15689941+lucletoffe@users.noreply.github.com> Date: Thu, 6 Aug 2026 09:21:31 +0200 Subject: [PATCH] fix(calendar): stop writing blank participant names and empty descriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both reach the recipient of an invitation. A participant sent with `name: ""` is serialized as a bare `CN=`, so the invitation e-mail shows a dangling "- Organizer" and lists guests with nothing before their address; omitting the name lets clients fall back to the address. An event created with an empty description writes `DESCRIPTION:` with nothing after it, which renders as a "Description" heading over blank space — the empty string is still sent when editing an existing event, since that is how a description gets cleared. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YS9FfeGXhm9HBwbRyvJ1db --- components/calendar/event-modal.tsx | 10 +++++++++- lib/__tests__/calendar-participants.test.ts | 17 +++++++++++++++++ lib/calendar-participants.ts | 9 +++++++-- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index cd65425d6..e5a8012e5 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -418,9 +418,10 @@ export function EventModal({ const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const trimmedDescription = description.trim(); + const data: Partial = { title: trimmedTitle, - description: description.trim(), start: startStr, duration, timeZone: allDay ? null : timeZone, @@ -431,6 +432,13 @@ export function EventModal({ privacy: "public", }; + // On an existing event the empty string is how a description gets cleared, but + // sending it on creation writes a `DESCRIPTION:` with nothing after it — which + // invitation e-mails then render as a "Description" heading over blank space. + if (trimmedDescription || event) { + data.description = trimmedDescription; + } + if (!event) { data.uid = generateUUID(); } diff --git a/lib/__tests__/calendar-participants.test.ts b/lib/__tests__/calendar-participants.test.ts index b443ee5cd..bb671a086 100644 --- a/lib/__tests__/calendar-participants.test.ts +++ b/lib/__tests__/calendar-participants.test.ts @@ -361,6 +361,23 @@ describe('buildParticipantMap', () => { }); }); + it('omits blank names rather than sending an empty one', () => { + const map = buildParticipantMap( + { name: '', email: 'alice@example.com' }, + [ + { name: ' ', email: 'bob@example.com' }, + { name: ' Carol ', email: 'carol@example.com' }, + ] + ); + const entries = Object.values(map); + + // An empty name becomes a bare `CN=` downstream, which renders as a dangling + // "- Organizer" and as guests listed with nothing before their address. + expect(entries.find(p => p.email === 'alice@example.com')).not.toHaveProperty('name'); + expect(entries.find(p => p.email === 'bob@example.com')).not.toHaveProperty('name'); + expect(entries.find(p => p.email === 'carol@example.com')!.name).toBe('Carol'); + }); + it('sets kind to individual for all entries', () => { const map = buildParticipantMap( { name: 'Alice', email: 'alice@example.com' }, diff --git a/lib/calendar-participants.ts b/lib/calendar-participants.ts index cd7aa4a42..6484555e7 100644 --- a/lib/calendar-participants.ts +++ b/lib/calendar-participants.ts @@ -134,12 +134,17 @@ export function buildParticipantMap( const generateId = () => generateUUID(); + // An empty name is not a name: sent as "", it reaches the iCalendar stream as a + // bare `CN=`, and recipients see a dangling "- Organizer" or a guest listed with + // nothing before their address. Omitted, clients fall back to the address. + const named = (name: string) => (name.trim() ? { name: name.trim() } : {}); + // calendarAddress is the scheduling address in draft-ietf-calext-jscalendarbis // (implemented by Stalwart); the RFC 8984 sendTo property is retired there and // stored as an inert JSPROP, so it is intentionally not sent. participants[generateId()] = { '@type': 'Participant', - name: organizer.name, + ...named(organizer.name), email: organizer.email, calendarAddress: `mailto:${organizer.email}`, // owner only, NOT attendee: with roles.attendee set, Stalwart's server-side @@ -155,7 +160,7 @@ export function buildParticipantMap( attendees.forEach((a) => { participants[generateId()] = { '@type': 'Participant', - name: a.name, + ...named(a.name), email: a.email, calendarAddress: `mailto:${a.email}`, roles: { attendee: true },