feat(events): add calendar email support

Help registrants add events to their calendars and keep waitlisted places distinct from confirmed attendance.
This commit is contained in:
2026-09-27 15:57:43 +02:00
parent 8e502b4a01
commit 5310272715
6 changed files with 388 additions and 18 deletions
+111
View File
@@ -0,0 +1,111 @@
const CRLF = '\r\n';
const MAX_OCTETS = 75;
const CONTROL_CHARS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g;
export type CalendarEvent = {
uid: string;
start: string | Date;
end?: string | Date | null;
allDay?: boolean;
summary: string;
description?: string;
location?: string;
url?: string;
status?: 'CONFIRMED' | 'TENTATIVE' | 'CANCELLED';
};
function escapeText(value: string): string {
return value
.replace(/\\/g, '\\\\')
.replace(/;/g, '\\;')
.replace(/,/g, '\\,')
.replace(/\r\n|\r|\n/g, '\\n')
.replace(CONTROL_CHARS, '');
}
function foldLine(line: string): string {
if (Buffer.byteLength(line, 'utf8') <= MAX_OCTETS) return line;
const folded: string[] = [];
let current = '';
let octets = 0;
for (const character of line) {
const size = Buffer.byteLength(character, 'utf8');
if (octets + size > MAX_OCTETS) {
folded.push(current);
current = ' ';
octets = 1;
}
current += character;
octets += size;
}
folded.push(current);
return folded.join(CRLF);
}
function property(name: string, value: string): string {
return foldLine(`${name}:${value}`);
}
function parseDate(value: string | Date | null | undefined): Date | undefined {
if (!value) return undefined;
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? undefined : date;
}
function toUtcStamp(date: Date): string {
return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
}
function toDateValue(date: Date): string {
return date.toISOString().slice(0, 10).replace(/-/g, '');
}
export function buildEventCalendar(event: CalendarEvent): string {
const start = parseDate(event.start);
const end = parseDate(event.end);
const lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
property('PRODID', '-//SHARP-ER//Events Calendar//EN'),
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
property('X-WR-CALNAME', 'SHARP-ER'),
];
if (start) {
lines.push('BEGIN:VEVENT');
lines.push(property('UID', escapeText(event.uid)));
lines.push(property('DTSTAMP', toUtcStamp(new Date())));
if (event.allDay) {
lines.push(`DTSTART;VALUE=DATE:${toDateValue(start)}`);
const exclusiveEnd = new Date((end ?? start).getTime() + 86_400_000);
lines.push(`DTEND;VALUE=DATE:${toDateValue(exclusiveEnd)}`);
} else {
lines.push(property('DTSTART', toUtcStamp(start)));
if (end && end.getTime() > start.getTime()) {
lines.push(property('DTEND', toUtcStamp(end)));
}
}
lines.push(property('SUMMARY', escapeText(event.summary)));
if (event.description) {
lines.push(property('DESCRIPTION', escapeText(event.description)));
}
if (event.location) {
lines.push(property('LOCATION', escapeText(event.location)));
}
if (event.url && /^https?:\/\//i.test(event.url)) {
lines.push(property('URL', event.url));
}
lines.push(property('STATUS', event.status ?? 'CONFIRMED'));
lines.push('END:VEVENT');
}
lines.push('END:VCALENDAR');
return `${lines.join(CRLF)}${CRLF}`;
}
+213 -5
View File
@@ -25,6 +25,7 @@
import type { Core } from '@strapi/strapi';
import { buildEventCalendar } from './event-calendar';
import {
readEmailBranding,
resolveEmailTemplate,
@@ -44,12 +45,15 @@ type Locale = 'en' | 'ar';
type EventRow = {
documentId: string;
title?: string | null;
description?: string | null;
/** Strapi returns datetime attributes as ISO strings (or Dates); accept both. */
startDateTime?: string | Date | null;
endDateTime?: string | Date | null;
timezone?: string | null;
format?: 'in-person' | 'online' | 'hybrid' | null;
venue?: string | null;
onlineUrl?: string | null;
region?: { name?: string | null } | null;
};
/** An event looked up in both locales; missing locales are allowed. */
@@ -65,6 +69,174 @@ const EMAIL_TAG = {
const DEFAULT_TIMEZONE = 'Africa/Tripoli';
type ReservationIdentity = {
id?: string;
name?: string;
};
type MailAttachment = {
filename: string;
content: string;
contentType: string;
};
type JsonLd = Readonly<Record<string, unknown>>;
function validWebUrl(value: string | null | undefined): string | undefined {
const candidate = value?.trim();
if (!candidate) return undefined;
try {
const url = new URL(candidate);
return url.protocol === 'http:' || url.protocol === 'https:'
? url.toString()
: undefined;
} catch {
return undefined;
}
}
function isoDateTime(value: string | Date | null | undefined): string | undefined {
if (!value) return undefined;
const date = typeof value === 'string' ? new Date(value) : value;
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
}
function eventLocationForJsonLd(event: BilingualEvent): JsonLd | undefined {
const venue = event.en?.venue || event.ar?.venue;
const region = event.en?.region?.name || event.ar?.region?.name;
const format = event.en?.format || event.ar?.format;
const onlineUrl = validWebUrl(event.en?.onlineUrl || event.ar?.onlineUrl);
if ((format === 'online' || (!venue && !region)) && onlineUrl) {
return { '@type': 'VirtualLocation', url: onlineUrl };
}
if (!venue && !region) return undefined;
return {
'@type': 'Place',
name: venue || region,
address: {
'@type': 'PostalAddress',
...(region ? { addressLocality: region } : {}),
addressCountry: 'LY',
},
};
}
function buildEventReservationJsonLd(
event: BilingualEvent,
state: RegistrationState,
reservation: ReservationIdentity,
): JsonLd | undefined {
const name = event.en?.title || event.ar?.title;
const startDate = isoDateTime(
event.en?.startDateTime ?? event.ar?.startDateTime,
);
const reservationNumber = reservation.id?.trim();
const attendeeName = reservation.name?.trim();
const location = eventLocationForJsonLd(event);
if (!name || !startDate || !reservationNumber || !attendeeName || !location) {
return undefined;
}
const format = event.en?.format || event.ar?.format;
const endDate = isoDateTime(event.en?.endDateTime ?? event.ar?.endDateTime);
const description = event.en?.description || event.ar?.description;
return {
'@context': 'https://schema.org',
'@type': 'EventReservation',
reservationNumber,
reservationStatus:
state === 'confirmed'
? 'http://schema.org/Confirmed'
: 'https://schema.org/ReservationPending',
underName: { '@type': 'Person', name: attendeeName },
reservationFor: {
'@type': 'Event',
name,
startDate,
...(endDate ? { endDate } : {}),
...(description ? { description } : {}),
location,
eventAttendanceMode:
format === 'online'
? 'https://schema.org/OnlineEventAttendanceMode'
: format === 'hybrid'
? 'https://schema.org/MixedEventAttendanceMode'
: 'https://schema.org/OfflineEventAttendanceMode',
eventStatus: 'https://schema.org/EventScheduled',
},
};
}
function eventCalendarFilename(title: string): string {
const safeTitle = title
.replace(/[<>:"/\\|?*\u0000-\u001f\u007f-\u009f]/g, '-')
.replace(/\s+/g, ' ')
.replace(/^[. ]+|[. ]+$/g, '')
.trim();
const limitedTitle = Array.from(safeTitle).slice(0, 120).join('').replace(/[. ]+$/g, '');
return `${limitedTitle || 'event'}.ics`;
}
function buildEventCalendarAttachment(
event: BilingualEvent,
state: RegistrationState,
): MailAttachment | undefined {
const eventId = event.en?.documentId || event.ar?.documentId;
const start = event.en?.startDateTime ?? event.ar?.startDateTime;
const summary = event.en?.title || event.ar?.title;
if (!eventId || !start || !summary || !isoDateTime(start)) return undefined;
const onlineUrl = validWebUrl(event.en?.onlineUrl || event.ar?.onlineUrl);
const descriptions = [
event.en?.description || event.ar?.description,
event.ar?.description && event.ar.description !== event.en?.description
? event.ar.description
: undefined,
].filter((value): value is string => Boolean(value));
const location =
event.en?.venue ||
event.ar?.venue ||
event.en?.region?.name ||
event.ar?.region?.name ||
onlineUrl;
return {
filename: eventCalendarFilename(summary),
content: buildEventCalendar({
uid: `${eventId}@sharper.ly`,
start,
end: event.en?.endDateTime ?? event.ar?.endDateTime,
allDay: typeof start === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(start),
summary,
...(descriptions.length > 0 ? { description: descriptions.join('\n\n') } : {}),
...(location ? { location } : {}),
...(onlineUrl ? { url: onlineUrl } : {}),
status: state === 'confirmed' ? 'CONFIRMED' : 'TENTATIVE',
}),
contentType: 'text/calendar; charset=utf-8; method=PUBLISH',
};
}
function renderJsonLd(jsonLd?: JsonLd): string {
if (!jsonLd) return '';
const serialized = JSON.stringify(jsonLd);
if (!serialized) return '';
const safeJson = serialized
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026');
return `<script type="application/ld+json">${safeJson}</script>`;
}
/**
* Date plus time of day, mirroring the web's `formatDateTime`: Arabic uses the
* latn numbering system, so digits never switch to Eastern Arabic numerals.
@@ -217,8 +389,18 @@ export function buildBilingualMail(opts: {
vars?: { ar: PlaceholderValues; en: PlaceholderValues };
locationLine?: { ar: string; en: string };
branding?: EmailBranding;
jsonLd?: JsonLd;
}): { subject: string; text: string; html: string } {
const { subjectAr, subjectEn, bodyAr, bodyEn, vars, locationLine, branding } = opts;
const {
subjectAr,
subjectEn,
bodyAr,
bodyEn,
vars,
locationLine,
branding,
jsonLd,
} = opts;
const varsAr = vars?.ar ?? {};
const varsEn = vars?.en ?? {};
@@ -229,6 +411,7 @@ export function buildBilingualMail(opts: {
);
const html = `
${renderJsonLd(jsonLd)}
${renderBrandHeader(branding)}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="${BASE_FONT}"><tr><td style="padding:0; line-height:1.6; color:#222;">
<div dir="rtl" lang="ar">
@@ -292,8 +475,16 @@ export async function fetchBilingualEvent(
if (!filters) return {};
const [en, ar] = await Promise.all([
strapi.documents(EVENT_UID).findFirst({ filters, locale: 'en' }),
strapi.documents(EVENT_UID).findFirst({ filters, locale: 'ar' }),
strapi.documents(EVENT_UID).findFirst({
filters,
locale: 'en',
populate: { region: { fields: ['name'] } },
}),
strapi.documents(EVENT_UID).findFirst({
filters,
locale: 'ar',
populate: { region: { fields: ['name'] } },
}),
]);
return { en: en ?? undefined, ar: ar ?? undefined };
@@ -308,6 +499,7 @@ type MailOptions = {
subject: string;
text: string;
html?: string;
attachments?: MailAttachment[];
};
type EmailServiceShape = { send: (options: MailOptions) => Promise<unknown> };
@@ -359,11 +551,13 @@ export async function sendRegistrationConfirmation({
to,
state,
eventReference,
reservation,
}: {
strapi: Core.Strapi;
to: string;
state: RegistrationState;
eventReference: EventReference;
reservation: ReservationIdentity;
}): Promise<boolean> {
try {
const isWaitlist = state === 'waitlisted';
@@ -408,9 +602,15 @@ export async function sendRegistrationConfirmation({
},
locationLine: locationLineFor(event),
branding,
jsonLd: buildEventReservationJsonLd(event, state, reservation),
});
return trySendMail(strapi, { to, ...mail });
const attachment = buildEventCalendarAttachment(event, state);
return trySendMail(strapi, {
to,
...mail,
...(attachment ? { attachments: [attachment] } : {}),
});
} catch (error) {
strapi.log.error(`[event-mail] confirmation failed: ${String(error)}`);
return false;
@@ -426,10 +626,12 @@ export async function sendEventReminderMail({
strapi,
to,
eventReference,
reservation,
}: {
strapi: Core.Strapi;
to: string;
eventReference: EventReference;
reservation: ReservationIdentity;
}): Promise<boolean> {
try {
const [event, template, branding] = await Promise.all([
@@ -468,9 +670,15 @@ export async function sendEventReminderMail({
},
locationLine: locationLineFor(event),
branding,
jsonLd: buildEventReservationJsonLd(event, 'confirmed', reservation),
});
return trySendMail(strapi, { to, ...mail });
const attachment = buildEventCalendarAttachment(event, 'confirmed');
return trySendMail(strapi, {
to,
...mail,
...(attachment ? { attachments: [attachment] } : {}),
});
} catch (error) {
strapi.log.error(`[event-mail] reminder failed: ${String(error)}`);
return false;
+7
View File
@@ -126,6 +126,13 @@ export async function runEventReminders({
strapi,
to: registration.email as string,
eventReference: reference,
reservation: {
id: registration.documentId as string,
name:
typeof registration.fullName === 'string'
? registration.fullName
: undefined,
},
});
if (!ok) {
+5
View File
@@ -110,6 +110,7 @@ async function promoteEvent(
if (slots <= 0) break;
const email = (registration as { email?: unknown }).email;
const fullName = (registration as { fullName?: unknown }).fullName;
if (typeof email !== 'string' || email.length === 0) {
strapi.log.warn(
`${LOG_PREFIX} registration ${registration.documentId} has no email; left waitlisted.`,
@@ -122,6 +123,10 @@ async function promoteEvent(
to: email,
state: 'confirmed',
eventReference: { documentId: eventDocumentId },
reservation: {
id: registration.documentId as string,
name: typeof fullName === 'string' ? fullName : undefined,
},
});
if (!ok) {