/** * 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 "; // ── 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, "'"); } // ── 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 ? `

Join Meeting

` : ""; const noteBlock = booking.guestNote ? `

Your note: ${escapeHtml(booking.guestNote)}

` : ""; const attendeesBlock = attendees.length > 1 ? `

Attendees: ${attendees.map(escapeHtml).join(", ")}

` : ""; 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 = `

Add to your calendar:

Google Outlook Yahoo

An .ics file is attached for Apple Calendar and other apps.

`; const guestHtml = `

Meeting Confirmed

Hi ${escapeHtml(booking.guestName)},

Your meeting with ${escapeHtml(hostDisplayName)} is confirmed:

${meetLink} ${attendeesBlock} ${calendarButtons} ${noteBlock}

Cancel or reschedule

`; const hostHtml = `

New Booking

${escapeHtml(booking.guestName)} (${escapeHtml(booking.guestEmail)}) booked a meeting with you.

${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 ? `

Reason: ${escapeHtml(booking.cancellationReason)}

` : ""; const suggestBlock = suggestedSlots.length ? `

Here are a few open times if you'd like to rebook:

Or pick a different time.

` : `

You can pick a new time any time.

`; const html = `

Meeting cancelled

Hi ${escapeHtml(booking.guestName)},

Your meeting with ${escapeHtml(hostDisplayName)} on ${escapeHtml(dateStr)} at ${escapeHtml(startStr)} has been cancelled.

${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: `

Booking cancelled

${escapeHtml(booking.guestName)} (${escapeHtml(booking.guestEmail)})

${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 ? `

Join Meeting

` : ""; const html = `

Reminder: meeting tomorrow

Hi ${escapeHtml(booking.guestName)}, your meeting with ${escapeHtml(hostDisplayName)} is coming up.

${meetLink}

Need to cancel? Use this link.

`; 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: `

Your meeting with ${escapeHtml(booking.guestName)} is tomorrow at ${escapeHtml(startStr)}.

`, }); } return { ok: true }; } catch (e: any) { console.error("[rSchedule] reminder send error:", e?.message); return { ok: false, error: e?.message || String(e) }; } }