mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
fix: keep emoji and organizer names with a colon intact in exported events
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user