From f83284d7e509b37ec9ab80752807fb2a4c92de42 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:30:14 +0100 Subject: [PATCH] fix: fold exported .ics lines by octets and quote attendee names --- src/lib/__tests__/calendar-ics-export.test.ts | 29 +++++++++++++- src/lib/calendar-ics-export.ts | 39 ++++++++++++++----- 2 files changed, 57 insertions(+), 11 deletions(-) diff --git a/src/lib/__tests__/calendar-ics-export.test.ts b/src/lib/__tests__/calendar-ics-export.test.ts index 9bde1dbc..ab13137e 100644 --- a/src/lib/__tests__/calendar-ics-export.test.ts +++ b/src/lib/__tests__/calendar-ics-export.test.ts @@ -48,11 +48,38 @@ describe('eventToICS', () => { expect(ics).not.toContain('DTEND'); }); - it('folds long lines at 74 octets', () => { + it('folds long lines at 75 octets', () => { const ics = eventToICS({ ...event, description: 'x'.repeat(200) }); const long = ics.split('\r\n').filter((l) => l.length > 75); expect(long).toHaveLength(0); }); + + it('folds by octets between characters, so umlauts and emoji survive', () => { + const utf8 = new TextEncoder(); + const text = 'Grüße 🎉 '.repeat(30); + const ics = eventToICS({ ...event, description: text }); + const lines = ics.split('\r\n'); + expect(lines.every((l) => utf8.encode(l).length <= 75)).toBe(true); + // Unfolded, the description reads back whole: no emoji cut in half. + const unfolded = ics.replace(/\r\n /g, ''); + expect(unfolded).toContain(`DESCRIPTION:${text}`); + expect(unfolded).not.toContain('�'); + expect(lines.some((l) => /[\uD800-\uDBFF]$/.test(l))).toBe(false); + }); + + it('quotes a name holding a colon, semicolon or comma, and writes no blank one', () => { + const ics = eventToICS({ + ...event, + participants: { + a: { calendarAddress: 'mailto:alice@example.com', name: 'Ops: Alice' }, + b: { calendarAddress: 'mailto:bob@example.com', name: 'Smith, Bob "BB"', roles: { attendee: true } }, + c: { calendarAddress: 'mailto:carol@example.com', name: ' ', roles: { attendee: true } }, + }, + }); + expect(ics).toContain('ORGANIZER;CN="Ops: Alice":mailto:alice@example.com'); + expect(ics).toContain('ATTENDEE;CN="Smith, Bob \'BB\'";PARTSTAT=NEEDS-ACTION:mailto:bob@example.com'); + expect(ics).toContain('ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:carol@example.com'); + }); }); describe('helpers', () => { diff --git a/src/lib/calendar-ics-export.ts b/src/lib/calendar-ics-export.ts index 2d29a5d3..b3819313 100644 --- a/src/lib/calendar-ics-export.ts +++ b/src/lib/calendar-ics-export.ts @@ -4,19 +4,38 @@ import { t } from '../stores/locale-store'; // Port of the webmail's lib/calendar-ics-export.ts: serialise one event as // an RFC 5545 VCALENDAR so it can be shared as a .ics file. -const MAX_LINE_OCTETS = 74; +const MAX_LINE_OCTETS = 75; +const utf8 = new TextEncoder(); -function foldLine(line: string): string { - if (line.length <= MAX_LINE_OCTETS) return line; - const chunks: string[] = [line.slice(0, MAX_LINE_OCTETS)]; - let pos = MAX_LINE_OCTETS; - while (pos < line.length) { - chunks.push(' ' + line.slice(pos, pos + MAX_LINE_OCTETS - 1)); - pos += MAX_LINE_OCTETS - 1; +// RFC 5545 §3.1: lines of at most 75 octets, folded between characters. Cut +// by UTF-16 units, a line with umlauts ran past 75 octets and a fold could +// split an emoji, which the file then held as two U+FFFD. +export function foldLine(line: string): string { + const chunks: string[] = []; + let chunk = ''; + let octets = 0; + for (const char of line) { + const size = utf8.encode(char).length; + if (octets + size > MAX_LINE_OCTETS) { + chunks.push(chunk); + chunk = ' '; + octets = 1; + } + chunk += char; + octets += size; } + chunks.push(chunk); return chunks.join('\r\n'); } +// RFC 5545 §3.2: a parameter value holding ":", ";" or "," is quoted, and +// can't hold a DQUOTE or a line break. Escaped like TEXT, "CN=Ops: Alice" +// ended the parameters at the colon and the backslashes stayed in the name. +export function paramValue(value: string): string { + const clean = value.replace(/"/g, "'").replace(/[\r\n]+/g, ' '); + return /[:;,]/.test(clean) ? `"${clean}"` : clean; +} + // RFC 5545 §3.3.11 - escape backslash, semicolon, comma, and newline in TEXT values. export function escapeText(value: string): string { return value @@ -157,14 +176,14 @@ export function eventToICS(event: CalendarEvent): string { ); const orgEmail = organizer ? participantEmail(organizer) : organizerEmail; if (orgEmail) { - const cn = organizer?.name ? `;CN=${escapeText(organizer.name)}` : ''; + const cn = organizer?.name?.trim() ? `;CN=${paramValue(organizer.name.trim())}` : ''; lines.push(`ORGANIZER${cn}:mailto:${orgEmail}`); } for (const p of Object.values(event.participants)) { if (p === organizer) continue; const email = participantEmail(p); if (!email) continue; - const cn = p.name ? `;CN=${escapeText(p.name)}` : ''; + const cn = p.name?.trim() ? `;CN=${paramValue(p.name.trim())}` : ''; const partstat = p.participationStatus ? `;PARTSTAT=${p.participationStatus.toUpperCase()}` : ';PARTSTAT=NEEDS-ACTION';