268 lines
10 KiB
TypeScript
268 lines
10 KiB
TypeScript
/**
|
||
* 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, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
// ── 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) };
|
||
}
|
||
}
|