fix: fold exported .ics lines by octets and quote attendee names

This commit is contained in:
Linus Rath
2026-10-29 03:30:14 +01:00
parent 59c3278259
commit f83284d7e5
2 changed files with 57 additions and 11 deletions
+28 -1
View File
@@ -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', () => {
+29 -10
View File
@@ -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';