fix: keep emoji and organizer names with a colon intact in exported events

This commit is contained in:
Linus Rath
2026-10-26 12:25:32 +01:00
parent 292ccce9ee
commit 5b4db7f1d8
2 changed files with 83 additions and 9 deletions
+55
View File
@@ -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> = {}): 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');
});
});
+28 -9
View File
@@ -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";