diff --git a/lib/__tests__/calendar-ics-export.test.ts b/lib/__tests__/calendar-ics-export.test.ts new file mode 100644 index 000000000..d684938c6 --- /dev/null +++ b/lib/__tests__/calendar-ics-export.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { eventToICS } from '@/lib/calendar-ics-export'; +import type { CalendarEvent } from '@/lib/jmap/types'; + +function event(overrides: Partial = {}): CalendarEvent { + return { + id: 'e1', uid: 'export-test@example.com', title: 'Sync', start: '2026-11-20T09:00:00', + timeZone: 'Europe/Berlin', duration: 'PT1H', showWithoutTime: false, calendarIds: { c: true }, + ...overrides, + } as CalendarEvent; +} + +// As the downloaded file holds it: UTF-8, where half an emoji on each side +// of a fold turns into two U+FFFD. +const asFile = (ics: string) => new TextDecoder().decode(new TextEncoder().encode(ics)); +const unfold = (ics: string) => asFile(ics).replace(/\r\n /g, ''); +const line = (ics: string, name: string) => unfold(ics).split('\r\n').find((l) => l.startsWith(name)) ?? ''; + +describe('eventToICS', () => { + // Stalwart 0.16 and 1.0 both read a title folded inside an emoji back + // with two U+FFFD in its place. + it('folds lines between characters, at most 75 octets long', () => { + const title = `${'A'.repeat(64)} 🎉🎉🎉 Launch party 🎉 ${'Ünïcödé '.repeat(12)}`; + const ics = eventToICS(event({ title })); + + expect(line(ics, 'SUMMARY:')).toBe(`SUMMARY:${title}`); + for (const physical of ics.split('\r\n')) { + expect(new TextEncoder().encode(physical).length).toBeLessThanOrEqual(75); + } + }); + + // A colon in an unquoted CN ended the parameters there: both servers read + // the organizer's address as " Alice:mailto:...". + it('quotes participant names that hold a colon, semicolon or comma', () => { + const ics = eventToICS(event({ + participants: { + o: { '@type': 'Participant', email: 'alice@example.com', name: 'Ops: Alice', roles: { owner: true } }, + p: { '@type': 'Participant', email: 'john@example.com', name: 'Doe, John; "JD"', roles: { attendee: true } }, + } as unknown as CalendarEvent['participants'], + })); + + expect(line(ics, 'ORGANIZER')).toBe('ORGANIZER;CN="Ops: Alice":mailto:alice@example.com'); + expect(line(ics, 'ATTENDEE')).toBe('ATTENDEE;CN="Doe, John; \'JD\'";PARTSTAT=NEEDS-ACTION:mailto:john@example.com'); + }); + + it('leaves a plain name unquoted', () => { + const ics = eventToICS(event({ + participants: { + o: { '@type': 'Participant', email: 'alice@example.com', name: 'Alice', roles: { owner: true } }, + } as unknown as CalendarEvent['participants'], + })); + + expect(line(ics, 'ORGANIZER')).toBe('ORGANIZER;CN=Alice:mailto:alice@example.com'); + }); +}); diff --git a/lib/calendar-ics-export.ts b/lib/calendar-ics-export.ts index 1f13e0bb1..83c1c0f2e 100644 --- a/lib/calendar-ics-export.ts +++ b/lib/calendar-ics-export.ts @@ -1,15 +1,26 @@ import type { CalendarEvent } from "@/lib/jmap/types"; -const MAX_LINE_OCTETS = 74; +const MAX_LINE_OCTETS = 75; +const utf8 = new TextEncoder(); +// RFC 5545 §3.1: lines of at most 75 octets, folded between characters. Cut +// by UTF-16 units, a fold could split an emoji, which the file then held as +// two U+FFFD. 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; + 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"); } @@ -22,6 +33,14 @@ function escapeText(value: string): string { .replace(/\r\n|\r|\n/g, "\\n"); } +// RFC 5545 §3.2: a parameter value holding ":", ";" or "," is quoted, and +// can't hold a DQUOTE or a line break. Unquoted, "CN=Ops: Alice" ended the +// parameters at the colon. +function paramValue(value: string): string { + const clean = value.replace(/"/g, "'").replace(/[\r\n]+/g, " "); + return /[:;,]/.test(clean) ? `"${clean}"` : clean; +} + function stripDateSeparators(value: string): string { return value.replace(/[-:]/g, "").replace(/\.\d{3}/, ""); } @@ -142,7 +161,7 @@ export function eventToICS(event: CalendarEvent): string { if (organizer) { const email = organizer.email || organizer.sendTo?.imip?.replace("mailto:", ""); if (email) { - const cn = organizer.name ? `;CN=${escapeText(organizer.name)}` : ""; + const cn = organizer.name ? `;CN=${paramValue(organizer.name)}` : ""; lines.push(`ORGANIZER${cn}:mailto:${email}`); } } @@ -150,7 +169,7 @@ export function eventToICS(event: CalendarEvent): string { if (p.roles?.owner) continue; const email = p.email || p.sendTo?.imip?.replace("mailto:", ""); if (!email) continue; - const cn = p.name ? `;CN=${escapeText(p.name)}` : ""; + const cn = p.name ? `;CN=${paramValue(p.name)}` : ""; const partstat = p.participationStatus ? `;PARTSTAT=${p.participationStatus.toUpperCase()}` : ";PARTSTAT=NEEDS-ACTION";