import {
emailStrong,
isEmailConfigured,
renderEmail,
sendEmail,
} from "@agent-native/core/server";
import type { CalendarEvent, DeleteEventScope } from "../../shared/api.js";
export interface GuestNotificationResult {
requested: boolean;
recipientCount: number;
sentCount: number;
skippedReason?: "empty-message" | "no-recipients" | "email-not-configured";
errors?: string[];
}
export type GuestNotificationKind = "update" | "cancellation";
function stripCrlf(value: string | undefined): string {
return (value ?? "").replace(/[\r\n]+/g, " ").trim();
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
function messageParagraph(message: string): string {
return escapeHtml(message.trim()).replace(/\r?\n/g, "
");
}
export function normalizeGuestNotificationMessage(
message: string | undefined,
): string | undefined {
const trimmed = message?.trim();
return trimmed ? trimmed.slice(0, 4000) : undefined;
}
function guestRecipients(event: CalendarEvent): string[] {
const recipients = new Set();
for (const attendee of event.attendees ?? []) {
const email = stripCrlf(attendee.email).toLowerCase();
if (!email || attendee.self || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
continue;
}
recipients.add(email);
}
return Array.from(recipients);
}
function safeTimeZone(event: CalendarEvent): string | undefined {
const timeZone = event.startTimeZone || event.endTimeZone;
if (!timeZone) return undefined;
try {
new Intl.DateTimeFormat("en", { timeZone }).format(new Date());
return timeZone;
} catch {
return undefined;
}
}
function formatWhen(event: CalendarEvent): string {
const zone = safeTimeZone(event);
const start = new Date(event.start);
const end = new Date(event.end);
const dateOptions: Intl.DateTimeFormatOptions = {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
...(zone ? { timeZone: zone } : {}),
};
if (event.allDay) {
return new Intl.DateTimeFormat("en", dateOptions).format(start);
}
const timeOptions: Intl.DateTimeFormatOptions = {
hour: "numeric",
minute: "2-digit",
timeZoneName: "short",
...(zone ? { timeZone: zone } : {}),
};
return `${new Intl.DateTimeFormat("en", dateOptions).format(start)}, ${new Intl.DateTimeFormat("en", timeOptions).format(start)} - ${new Intl.DateTimeFormat("en", timeOptions).format(end)}`;
}
function scopeLabel(scope: DeleteEventScope | undefined): string | undefined {
if (!scope || scope === "single") return undefined;
if (scope === "all") return "all events in the series";
return "this and following events";
}
export async function sendEventGuestNotificationNote({
event,
organizerEmail,
message,
kind,
scope,
}: {
event: CalendarEvent;
organizerEmail: string;
message?: string;
kind: GuestNotificationKind;
scope?: DeleteEventScope;
}): Promise {
const normalizedMessage = normalizeGuestNotificationMessage(message);
if (!normalizedMessage) {
return {
requested: false,
recipientCount: 0,
sentCount: 0,
skippedReason: "empty-message",
};
}
const recipients = guestRecipients(event);
if (recipients.length === 0) {
return {
requested: true,
recipientCount: 0,
sentCount: 0,
skippedReason: "no-recipients",
};
}
if (!(await isEmailConfigured())) {
return {
requested: true,
recipientCount: recipients.length,
sentCount: 0,
skippedReason: "email-not-configured",
};
}
const title = stripCrlf(event.title) || "Calendar event";
const organizer = stripCrlf(event.organizer?.displayName) || organizerEmail;
const heading =
kind === "cancellation" ? "Event cancellation note" : "Event update note";
const subjectPrefix =
kind === "cancellation" ? "Cancellation note" : "Update note";
const eventAction = kind === "cancellation" ? "cancelling" : "updating";
const paragraphs = [
`${emailStrong(organizer)} added this note while ${eventAction} ${emailStrong(title)}.`,
messageParagraph(normalizedMessage),
`When: ${emailStrong(formatWhen(event))}.`,
];
const appliesTo = scopeLabel(scope);
if (appliesTo) {
paragraphs.push(`Applies to: ${emailStrong(appliesTo)}.`);
}
const rendered = renderEmail({
preheader: `${subjectPrefix}: ${title}`,
heading,
paragraphs,
cta:
kind === "update" && event.htmlLink
? { label: "Open in Google Calendar", url: event.htmlLink }
: undefined,
footer:
"Google Calendar sends the calendar update separately. This message carries the organizer note.",
});
let sentCount = 0;
const errors: string[] = [];
await Promise.all(
recipients.map(async (to) => {
try {
await sendEmail({
to,
subject: `${subjectPrefix}: ${title}`,
html: rendered.html,
text: rendered.text,
replyTo: organizerEmail,
});
sentCount += 1;
} catch (error) {
errors.push(error instanceof Error ? error.message : String(error));
}
}),
);
return {
requested: true,
recipientCount: recipients.length,
sentCount,
...(errors.length > 0 ? { errors } : {}),
};
}