rspace-online/modules/rschedule/lib/emails.ts

268 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* rSchedule emails — confirmation, cancel, reminder, attendee invite.
*
* Reuses the same SMTP transport pattern as rMinders (nodemailer → postfix-mailcow
* internal, or external SMTP with auth). All emails are fire-and-forget —
* failures log but don't roll back the booking write.
*/
import { createTransport, type Transporter } from "nodemailer";
import {
getGoogleCalendarUrl,
getOutlookCalendarUrl,
getYahooCalendarUrl,
generateIcsContent,
type IcsParams,
} from "./calendar-links";
import type { Booking, ScheduleSettings } from "../schemas";
let _transport: Transporter | null = null;
function getTransport(): Transporter | null {
if (_transport) return _transport;
const host = process.env.SMTP_HOST || "mail.rmail.online";
const isInternal = host.includes("mailcow") || host.includes("postfix");
if (!process.env.SMTP_PASS && !isInternal) return null;
_transport = createTransport({
host,
port: isInternal ? 25 : Number(process.env.SMTP_PORT) || 587,
secure: !isInternal && Number(process.env.SMTP_PORT) === 465,
...(isInternal ? {} : { auth: { user: process.env.SMTP_USER || "noreply@rmail.online", pass: process.env.SMTP_PASS! } }),
tls: { rejectUnauthorized: false },
});
return _transport;
}
const FROM = process.env.SMTP_FROM || "rSchedule <noreply@rmail.online>";
// ── Formatting helpers ──
function fmtDate(ms: number, tz: string): string {
return new Intl.DateTimeFormat("en-US", { timeZone: tz, weekday: "long", month: "long", day: "numeric", year: "numeric" }).format(new Date(ms));
}
function fmtTime(ms: number, tz: string): string {
return new Intl.DateTimeFormat("en-US", { timeZone: tz, hour: "numeric", minute: "2-digit", hour12: true }).format(new Date(ms));
}
function escapeHtml(s: string): string {
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// ── Confirmation ──
export interface ConfirmationCtx {
booking: Booking;
settings: ScheduleSettings;
hostDisplayName: string;
/** Absolute URL where guest can self-cancel. */
cancelUrl: string;
/** Optional list of extra attendee emails (beyond primary guest). */
extraAttendeeEmails?: string[];
}
export async function sendBookingConfirmation(ctx: ConfirmationCtx): Promise<{ ok: boolean; error?: string }> {
const t = getTransport();
if (!t) return { ok: false, error: "SMTP transport not configured" };
const { booking, settings, hostDisplayName, cancelUrl, extraAttendeeEmails = [] } = ctx;
const title = `${booking.guestName}${hostDisplayName}`;
const dateStr = fmtDate(booking.startTime, booking.timezone);
const startStr = fmtTime(booking.startTime, booking.timezone);
const endStr = fmtTime(booking.endTime, booking.timezone);
const attendees = [booking.guestEmail, ...extraAttendeeEmails].filter(Boolean);
const organizerEmail = settings.email || "noreply@rmail.online";
const calParams = {
startUtc: new Date(booking.startTime).toISOString(),
endUtc: new Date(booking.endTime).toISOString(),
title,
meetingLink: booking.meetingLink,
guestNote: booking.guestNote,
};
const icsParams: IcsParams = {
...calParams,
attendees,
organizerEmail,
organizerName: hostDisplayName,
};
const ics = generateIcsContent(icsParams);
const meetLink = booking.meetingLink
? `<p style="margin:16px 0"><a href="${escapeHtml(booking.meetingLink)}" style="display:inline-block;padding:10px 20px;background:#6366f1;color:#fff;border-radius:6px;text-decoration:none;">Join Meeting</a></p>`
: "";
const noteBlock = booking.guestNote ? `<p><strong>Your note:</strong> ${escapeHtml(booking.guestNote)}</p>` : "";
const attendeesBlock = attendees.length > 1 ? `<p style="font-size:13px;color:#666"><strong>Attendees:</strong> ${attendees.map(escapeHtml).join(", ")}</p>` : "";
const btnStyle = "display:inline-block;padding:8px 16px;border-radius:6px;text-decoration:none;font-size:13px;color:#fff;margin-right:8px;margin-bottom:8px;";
const calendarButtons = `
<p style="margin:16px 0 4px 0;font-size:13px;color:#888;">Add to your calendar:</p>
<p style="margin:0 0 16px 0">
<a href="${getGoogleCalendarUrl(calParams)}" style="${btnStyle}background:#4285f4;">Google</a>
<a href="${getOutlookCalendarUrl(calParams)}" style="${btnStyle}background:#0078d4;">Outlook</a>
<a href="${getYahooCalendarUrl(calParams)}" style="${btnStyle}background:#6001d2;">Yahoo</a>
</p>
<p style="font-size:12px;color:#999;">An .ics file is attached for Apple Calendar and other apps.</p>`;
const guestHtml = `
<h2>Meeting Confirmed</h2>
<p>Hi ${escapeHtml(booking.guestName)},</p>
<p>Your meeting with ${escapeHtml(hostDisplayName)} is confirmed:</p>
<ul>
<li><strong>Date:</strong> ${escapeHtml(dateStr)}</li>
<li><strong>Time:</strong> ${escapeHtml(startStr)} ${escapeHtml(endStr)} (${escapeHtml(booking.timezone)})</li>
</ul>
${meetLink}
${attendeesBlock}
${calendarButtons}
${noteBlock}
<p><a href="${escapeHtml(cancelUrl)}">Cancel or reschedule</a></p>`;
const hostHtml = `
<h2>New Booking</h2>
<p><strong>${escapeHtml(booking.guestName)}</strong> (${escapeHtml(booking.guestEmail)}) booked a meeting with you.</p>
<ul>
<li><strong>Date:</strong> ${escapeHtml(dateStr)}</li>
<li><strong>Time:</strong> ${escapeHtml(startStr)} ${escapeHtml(endStr)} (${escapeHtml(booking.timezone)})</li>
${attendees.length > 1 ? `<li><strong>Attendees:</strong> ${attendees.map(escapeHtml).join(", ")}</li>` : ""}
</ul>
${noteBlock}`;
try {
await t.sendMail({
from: FROM,
to: booking.guestEmail,
subject: `Meeting confirmed: ${dateStr} at ${startStr}`,
html: guestHtml,
attachments: [{ filename: "meeting.ics", content: Buffer.from(ics, "utf-8"), contentType: "text/calendar; method=REQUEST" }],
});
if (settings.email) {
await t.sendMail({
from: FROM,
to: settings.email,
subject: `New booking: ${booking.guestName}`,
html: hostHtml,
});
}
for (const email of extraAttendeeEmails) {
await t.sendMail({
from: FROM,
to: email,
subject: `Meeting invite: ${dateStr} at ${startStr}`,
html: guestHtml.replace("Your meeting", "You've been added to a meeting"),
attachments: [{ filename: "meeting.ics", content: Buffer.from(ics, "utf-8"), contentType: "text/calendar; method=REQUEST" }],
});
}
return { ok: true };
} catch (e: any) {
console.error("[rSchedule] confirmation send error:", e?.message);
return { ok: false, error: e?.message || String(e) };
}
}
// ── Cancellation ──
export interface CancellationCtx {
booking: Booking;
settings: ScheduleSettings;
hostDisplayName: string;
/** URL to the booking page (for rebooking). */
bookingPageUrl: string;
/** Up to 3 upcoming suggested slot labels for convenience. */
suggestedSlots?: Array<{ date: string; time: string; link: string }>;
}
export async function sendCancellationEmail(ctx: CancellationCtx): Promise<{ ok: boolean; error?: string }> {
const t = getTransport();
if (!t) return { ok: false, error: "SMTP transport not configured" };
const { booking, settings, hostDisplayName, bookingPageUrl, suggestedSlots = [] } = ctx;
const dateStr = fmtDate(booking.startTime, booking.timezone);
const startStr = fmtTime(booking.startTime, booking.timezone);
const reasonBlock = booking.cancellationReason ? `<p><strong>Reason:</strong> ${escapeHtml(booking.cancellationReason)}</p>` : "";
const suggestBlock = suggestedSlots.length ? `
<p>Here are a few open times if you'd like to rebook:</p>
<ul>${suggestedSlots.map((s) => `<li><a href="${escapeHtml(s.link)}">${escapeHtml(s.date)} at ${escapeHtml(s.time)}</a></li>`).join("")}</ul>
<p>Or <a href="${escapeHtml(bookingPageUrl)}">pick a different time</a>.</p>` : `<p>You can <a href="${escapeHtml(bookingPageUrl)}">pick a new time</a> any time.</p>`;
const html = `
<h2>Meeting cancelled</h2>
<p>Hi ${escapeHtml(booking.guestName)},</p>
<p>Your meeting with ${escapeHtml(hostDisplayName)} on <strong>${escapeHtml(dateStr)} at ${escapeHtml(startStr)}</strong> has been cancelled.</p>
${reasonBlock}
${suggestBlock}`;
try {
await t.sendMail({
from: FROM,
to: booking.guestEmail,
subject: `Cancelled: ${dateStr} at ${startStr}`,
html,
});
if (settings.email) {
await t.sendMail({
from: FROM,
to: settings.email,
subject: `Cancelled: ${booking.guestName} · ${dateStr} at ${startStr}`,
html: `<h2>Booking cancelled</h2><p>${escapeHtml(booking.guestName)} (${escapeHtml(booking.guestEmail)})</p>${reasonBlock}`,
});
}
return { ok: true };
} catch (e: any) {
console.error("[rSchedule] cancel send error:", e?.message);
return { ok: false, error: e?.message || String(e) };
}
}
// ── 24h reminder ──
export async function sendBookingReminder(ctx: {
booking: Booking;
settings: ScheduleSettings;
hostDisplayName: string;
cancelUrl: string;
}): Promise<{ ok: boolean; error?: string }> {
const t = getTransport();
if (!t) return { ok: false, error: "SMTP transport not configured" };
const { booking, settings, hostDisplayName, cancelUrl } = ctx;
const dateStr = fmtDate(booking.startTime, booking.timezone);
const startStr = fmtTime(booking.startTime, booking.timezone);
const meetLink = booking.meetingLink ? `<p><a href="${escapeHtml(booking.meetingLink)}">Join Meeting</a></p>` : "";
const html = `
<h2>Reminder: meeting tomorrow</h2>
<p>Hi ${escapeHtml(booking.guestName)}, your meeting with ${escapeHtml(hostDisplayName)} is coming up.</p>
<ul>
<li><strong>${escapeHtml(dateStr)} at ${escapeHtml(startStr)}</strong> (${escapeHtml(booking.timezone)})</li>
</ul>
${meetLink}
<p>Need to cancel? <a href="${escapeHtml(cancelUrl)}">Use this link</a>.</p>`;
try {
await t.sendMail({
from: FROM,
to: booking.guestEmail,
subject: `Reminder: ${hostDisplayName} tomorrow at ${startStr}`,
html,
});
// Notify admin too so they don't no-show
if (settings.email) {
await t.sendMail({
from: FROM,
to: settings.email,
subject: `Reminder: ${booking.guestName} tomorrow at ${startStr}`,
html: `<p>Your meeting with <strong>${escapeHtml(booking.guestName)}</strong> is tomorrow at ${escapeHtml(startStr)}.</p>`,
});
}
return { ok: true };
} catch (e: any) {
console.error("[rSchedule] reminder send error:", e?.message);
return { ok: false, error: e?.message || String(e) };
}
}