Files
T
2026-07-08 16:15:37 +02:00

81 lines
2.8 KiB
TypeScript

// AK-P16b · minimal RFC-5545 .ics builder (PURE). The booking confirmation mail
// attaches an "add to calendar" file; rather than pull a dependency we hand-roll the
// tiny VEVENT subset the spec needs (EU/supply-chain clean, unit-tested). Only the
// fields the confirmation needs: UID, DTSTAMP, DTSTART, DTEND, SUMMARY, DESCRIPTION,
// LOCATION, optional ORGANIZER + ATTENDEE.
export interface IcsEventInput {
readonly uid: string;
readonly start: Date;
readonly end: Date;
readonly dtstamp: Date;
readonly summary: string;
readonly description?: string;
readonly location?: string;
readonly organizerEmail?: string;
readonly organizerName?: string;
readonly attendeeEmail?: string;
}
/** Format a Date as an RFC-5545 UTC timestamp `YYYYMMDDTHHMMSSZ`. */
function toIcsUtc(date: Date): string {
return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
}
/** Escape TEXT values per RFC 5545 §3.3.11 (backslash, semicolon, comma, newline). */
function escapeText(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\r?\n/g, "\\n");
}
/** Fold a content line to <=75 octets per RFC 5545 §3.1 (CRLF + leading space). */
function foldLine(line: string): string {
if (line.length <= 75) {
return line;
}
const parts: string[] = [line.slice(0, 75)];
let rest = line.slice(75);
while (rest.length > 74) {
parts.push(` ${rest.slice(0, 74)}`);
rest = rest.slice(74);
}
parts.push(` ${rest}`);
return parts.join("\r\n");
}
/** Build a single-VEVENT VCALENDAR string (CRLF line endings, as the spec mandates). */
export function buildIcs(input: IcsEventInput): string {
const lines: string[] = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//HiveGrid//Booking//EN",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
"BEGIN:VEVENT",
`UID:${input.uid}`,
`DTSTAMP:${toIcsUtc(input.dtstamp)}`,
`DTSTART:${toIcsUtc(input.start)}`,
`DTEND:${toIcsUtc(input.end)}`,
`SUMMARY:${escapeText(input.summary)}`,
];
if (input.description) {
lines.push(`DESCRIPTION:${escapeText(input.description)}`);
}
if (input.location) {
lines.push(`LOCATION:${escapeText(input.location)}`);
}
if (input.organizerEmail) {
const cn = input.organizerName ? `;CN=${escapeText(input.organizerName)}` : "";
lines.push(`ORGANIZER${cn}:mailto:${input.organizerEmail}`);
}
if (input.attendeeEmail) {
lines.push(`ATTENDEE;ROLE=REQ-PARTICIPANT:mailto:${input.attendeeEmail}`);
}
lines.push("STATUS:CONFIRMED", "END:VEVENT", "END:VCALENDAR");
return lines.map(foldLine).join("\r\n");
}
/** Base64-encode an .ics string for use as a mail attachment `content`. */
export function icsToBase64(ics: string): string {
return Buffer.from(ics, "utf8").toString("base64");
}