feat: automated appointment reminders via email (GRO-23) (#29)

Implements Phase 1 of groombook/groombook#4 — automated email reminders
for upcoming appointments, with booking confirmations sent immediately
on creation.

- **DB**: new `reminder_logs` table tracks sent reminders per appointment
  (unique on appointmentId+type prevents duplicates); `clients` gains
  `email_opt_out` boolean (migration 0004_reminder_logs)
- **Email service**: `apps/api/src/services/email.ts` — nodemailer SMTP
  transport (disabled when SMTP_HOST is unset, so self-hosted installs
  without email config are unaffected); confirmation and reminder email
  templates included
- **Reminder scheduler**: `apps/api/src/services/reminders.ts` — node-cron
  job runs every minute, checks for appointments in the upcoming reminder
  windows (default: 24 h and 2 h), sends emails for opted-in clients,
  and records sends in reminder_logs (idempotent via ON CONFLICT DO NOTHING)
- **Confirmation email**: sent fire-and-forget after successful appointment
  creation (both single and recurring); never blocks the API response
- **Config**: SMTP_HOST, SMTP_PORT, SMTP_SECURE, SMTP_USER, SMTP_PASS,
  SMTP_FROM, REMINDER_HOURS_EARLY, REMINDER_HOURS_LATE env vars documented
  in .env.example; all optional — feature is silently disabled without them
- **Types**: Client.emailOptOut field added to shared types package

Co-authored-by: Groom Book CTO <cto@groombook.app>
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit was merged in pull request #29.
This commit is contained in:
groombook-paperclip[bot]
2026-03-17 20:46:49 +00:00
committed by GitHub
parent e7cf185d8c
commit addcefe70b
11 changed files with 445 additions and 0 deletions
+128
View File
@@ -0,0 +1,128 @@
import nodemailer from "nodemailer";
import type Mail from "nodemailer/lib/mailer/index.js";
// Returns null when SMTP is not configured — callers skip sending silently.
function createTransport(): nodemailer.Transporter | null {
const host = process.env.SMTP_HOST;
if (!host) return null;
return nodemailer.createTransport({
host,
port: Number(process.env.SMTP_PORT ?? 587),
secure: process.env.SMTP_SECURE === "true",
auth:
process.env.SMTP_USER
? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }
: undefined,
});
}
let _transport: nodemailer.Transporter | null | undefined;
function getTransport(): nodemailer.Transporter | null {
if (_transport === undefined) _transport = createTransport();
return _transport;
}
const FROM = process.env.SMTP_FROM ?? "Groom Book <noreply@groombook.local>";
export async function sendEmail(opts: Mail.Options): Promise<boolean> {
const transport = getTransport();
if (!transport) return false; // SMTP not configured — skip silently
await transport.sendMail({ from: FROM, ...opts });
return true;
}
// ─── Email templates ──────────────────────────────────────────────────────────
interface AppointmentEmailData {
clientName: string;
petName: string;
serviceName: string;
groomerName: string | null;
startTime: Date;
}
function formatDateTime(d: Date): string {
return d.toLocaleString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
export function buildConfirmationEmail(
to: string,
data: AppointmentEmailData
): Mail.Options {
const time = formatDateTime(data.startTime);
const groomer = data.groomerName ? ` with ${data.groomerName}` : "";
return {
to,
subject: `Appointment Confirmed — ${data.petName} on ${data.startTime.toLocaleDateString()}`,
text: [
`Hi ${data.clientName},`,
``,
`Your appointment has been confirmed!`,
``,
` Pet: ${data.petName}`,
` Service: ${data.serviceName}`,
` When: ${time}${groomer}`,
``,
`We look forward to seeing you. If you need to reschedule, please contact us.`,
``,
`— Groom Book`,
].join("\n"),
html: `
<p>Hi ${data.clientName},</p>
<p>Your appointment has been confirmed!</p>
<table style="border-collapse:collapse;margin:1em 0">
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">Pet</td><td>${data.petName}</td></tr>
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">Service</td><td>${data.serviceName}</td></tr>
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">When</td><td>${time}${groomer}</td></tr>
</table>
<p>We look forward to seeing you. If you need to reschedule, please contact us.</p>
<p>— Groom Book</p>`,
};
}
export function buildReminderEmail(
to: string,
data: AppointmentEmailData,
hoursAhead: number
): Mail.Options {
const time = formatDateTime(data.startTime);
const groomer = data.groomerName ? ` with ${data.groomerName}` : "";
const when = hoursAhead >= 24 ? `tomorrow` : `in ${hoursAhead} hours`;
return {
to,
subject: `Reminder: ${data.petName}'s appointment is ${when}`,
text: [
`Hi ${data.clientName},`,
``,
`Just a reminder that ${data.petName}'s grooming appointment is ${when}.`,
``,
` Pet: ${data.petName}`,
` Service: ${data.serviceName}`,
` When: ${time}${groomer}`,
``,
`See you soon!`,
``,
`— Groom Book`,
].join("\n"),
html: `
<p>Hi ${data.clientName},</p>
<p>Just a reminder that <strong>${data.petName}</strong>'s grooming appointment is <strong>${when}</strong>.</p>
<table style="border-collapse:collapse;margin:1em 0">
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">Pet</td><td>${data.petName}</td></tr>
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">Service</td><td>${data.serviceName}</td></tr>
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">When</td><td>${time}${groomer}</td></tr>
</table>
<p>See you soon!</p>
<p>— Groom Book</p>`,
};
}