/** * Email notifications via your own SMTP server (nodemailer). * * Required env vars: * SMTP_HOST — your mail server hostname (e.g. mail.beachpartyballoons.com) * SMTP_PORT — 587 (STARTTLS) or 465 (SSL), defaults to 587 * SMTP_USER — SMTP login username * SMTP_PASS — SMTP login password * ALERT_EMAIL_TO — address that receives alerts (e.g. chris@beachpartyballoons.com) * ALERT_EMAIL_FROM — sender address (e.g. shop@beachpartyballoons.com) */ import nodemailer from 'nodemailer' // ── Shared helpers ───────────────────────────────────────────────────────────── /** Format a slot as "4/14/26 2:00 PM" */ function fmtDate(iso: string): string { const d = new Date(iso) const { month, day, year, hour, minute, dayPeriod } = Object.fromEntries( new Intl.DateTimeFormat('en-US', { timeZone: 'America/New_York', month: 'numeric', day: 'numeric', year: '2-digit', hour: 'numeric', minute: '2-digit', hour12: true, }).formatToParts(d).map(({ type, value }) => [type, value]) ) return `${month}/${day}/${year} ${hour}:${minute} ${dayPeriod}` } /** Format a window as "4/14/26 2:00 – 5:00 PM" (shared date, shared AM/PM if same) */ function fmtWindow(startISO: string, endISO: string): string { const start = fmtDate(startISO) // "4/14/26 2:00 PM" const endTime = new Intl.DateTimeFormat('en-US', { timeZone: 'America/New_York', hour: 'numeric', minute: '2-digit', hour12: true, }).format(new Date(endISO)) // "5:00 PM" // Strip the time portion from start and append range const datePart = start.replace(/ \d+:\d+ [AP]M$/, '') return `${datePart} ${start.match(/\d+:\d+ [AP]M$/)?.[0]} – ${endTime}` } // ── Shared types ─────────────────────────────────────────────────────────────── export interface EmailLineItem { name: string quantity: number priceCents: number colors?: string[] note?: string modifiers?: Array<{ name: string }> } function formatLineItems(lineItems: EmailLineItem[]): string { return lineItems.map((li) => { const price = `$${(li.priceCents / 100).toFixed(2)}` const lines = [`${li.quantity}× ${li.name} — ${price}`] if (li.modifiers?.length) lines.push(` Add-ons: ${li.modifiers.map((m) => m.name).join(', ')}`) if (li.colors?.length) lines.push(` Colors: ${li.colors.join(', ')}`) if (li.note) lines.push(` Note: ${li.note}`) return lines.join('\n') }).join('\n') } function getTransporter() { const host = process.env.SMTP_HOST const port = parseInt(process.env.SMTP_PORT ?? '587', 10) const user = process.env.SMTP_USER const pass = process.env.SMTP_PASS if (!host || !user || !pass) return null return nodemailer.createTransport({ host, port, secure: port === 465, auth: { user, pass }, }) } async function send(params: { to: string subject: string text: string attachments?: Array<{ filename: string; content: string; contentType: string }> }): Promise { const from = process.env.ALERT_EMAIL_FROM ?? 'shop@beachpartyballoons.com' const transporter = getTransporter() if (!transporter) { const missing = ['SMTP_HOST', 'SMTP_USER', 'SMTP_PASS'].filter((k) => !process.env[k]) console.error('[notify] SMTP not configured — missing env vars:', missing.join(', '), '— email skipped:', params.subject) return } try { await transporter.sendMail({ from, to: params.to, subject: params.subject, text: params.text, attachments: params.attachments, }) console.log('[notify] Email sent:', params.subject, '→', params.to) } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err) console.error('[notify] SMTP send failed:', msg, '| subject:', params.subject, '| to:', params.to) throw err // re-throw so callers can handle/log it } } // ── ICS builder for customer calendar attachment ──────────────────────────── function buildCustomerICS(params: { uid: string startISO: string endISO: string summary: string description: string location?: string }): string { function toStamp(d: Date): string { return d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '') } function toET(d: Date): string { const parts = new Intl.DateTimeFormat('en-US', { timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }).formatToParts(d) const p: Record = {} for (const { type, value } of parts) p[type] = value const h = p.hour === '24' ? '00' : p.hour return `${p.year}${p.month}${p.day}T${h}${p.minute}${p.second}` } function fold(s: string): string { const out: string[] = [] while (s.length > 73) { out.push(s.slice(0, 73)); s = ' ' + s.slice(73) } out.push(s) return out.join('\r\n') } const start = new Date(params.startISO) const end = new Date(params.endISO) const lines = [ 'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//BeachPartyBalloons//Shop//EN', 'BEGIN:VEVENT', `UID:${params.uid}`, `DTSTAMP:${toStamp(new Date())}`, `DTSTART;TZID=America/New_York:${toET(start)}`, `DTEND;TZID=America/New_York:${toET(end)}`, fold(`SUMMARY:${params.summary}`), ...(params.location ? [fold(`LOCATION:${params.location}`)] : []), fold(`DESCRIPTION:${params.description}`), 'STATUS:CONFIRMED', 'END:VEVENT', 'END:VCALENDAR', ] return lines.join('\r\n') } // ── Public helpers ───────────────────────────────────────────────────────────── export async function sendOrderConfirmationEmail(params: { shortRef: string orderId: string customerName: string customerEmail: string fulfillment: 'delivery' | 'pickup' slotISO: string slotEndISO?: string address?: string lineItems: EmailLineItem[] colors: string[] // order-level color selection (when not per-item) subtotalCents?: number deliveryCents?: number totalCents: bigint }): Promise { const isDelivery = params.fulfillment === 'delivery' const slotStr = params.slotEndISO ? fmtWindow(params.slotISO, params.slotEndISO) : fmtDate(params.slotISO) const total = `$${(Number(params.totalCents) / 100).toFixed(2)}` // Charges breakdown (only shown when subtotal is provided) const chargesLines: string[] = [] if (params.subtotalCents != null) { chargesLines.push(`Subtotal: $${(params.subtotalCents / 100).toFixed(2)}`) if (params.deliveryCents) { chargesLines.push(`Delivery: $${(params.deliveryCents / 100).toFixed(2)}`) } const impliedTax = Number(params.totalCents) - (params.subtotalCents) - (params.deliveryCents ?? 0) if (impliedTax > 0) { chargesLines.push(`Tax: $${(impliedTax / 100).toFixed(2)}`) } chargesLines.push(`Total: ${total}`) } else { chargesLines.push(`Total: ${total}`) } const itemsBlock = formatLineItems(params.lineItems) // Order-level colors (when colors aren't set per-item) const hasPerItemColors = params.lineItems.some((li) => li.colors?.length) const orderColors = !hasPerItemColors && params.colors.length ? params.colors : [] const lines = [ `Hi ${params.customerName.split(' ')[0]},`, ``, `Your order is confirmed! Here's a summary:`, ``, `Order #: ${params.shortRef}`, ``, itemsBlock, ...(orderColors.length ? [``, `Colors: ${orderColors.join(', ')}`] : []), ``, isDelivery ? `Delivery: ${slotStr}` : `Pickup: ${slotStr}`, ...(params.address ? [`Address: ${params.address}`] : []), ``, ...chargesLines, ``, isDelivery ? `If you need to make any changes, please call or text us as soon as possible.` : [ `Pick up at 554 Boston Post Rd, Milford CT 06460.`, `Please bring this confirmation. If you need to reschedule, give us a call!`, ].join('\n'), ``, `Thank you for choosing Beach Party Balloons!`, ``, `— The Beach Party Balloons Team`, `beachpartyballoons.com`, ] // Build ICS calendar attachment const icsEndISO = params.slotEndISO ?? new Date(new Date(params.slotISO).getTime() + 60 * 60_000).toISOString() const itemsSummary = params.lineItems.map((li) => `${li.quantity}× ${li.name}`).join(', ') const icsDesc = [ `Order #${params.shortRef}`, `Items: ${itemsSummary}`, ...(params.colors.length ? [`Colors: ${params.colors.join(', ')}`] : []), ...(params.address ? [`Address: ${params.address}`] : []), ].join('\\n') const icsSummary = isDelivery ? 'Balloon Delivery — Beach Party Balloons' : 'Balloon Pickup — Beach Party Balloons' const icsContent = buildCustomerICS({ uid: `customer-${params.orderId}@beachpartyballoons.com`, startISO: params.slotISO, endISO: icsEndISO, summary: icsSummary, description: icsDesc, location: params.address ?? (isDelivery ? undefined : '554 Boston Post Rd, Milford CT 06460'), }) await send({ to: params.customerEmail, subject: `Your Beach Party Balloons order is confirmed! (#${params.shortRef})`, text: lines.join('\n'), attachments: [{ filename: 'appointment.ics', content: icsContent, contentType: 'text/calendar; method=REQUEST', }], }) } export async function sendSlotConflictAlert(params: { shortRef: string orderId: string customerName: string customerPhone: string slotISO: string address: string items: string }): Promise { const to = process.env.ALERT_EMAIL_TO if (!to) { console.warn('[notify] ALERT_EMAIL_TO not set'); return } await send({ to, subject: `🚨 ACTION REQUIRED: Slot not blocked — Order #${params.shortRef}`, text: [ `URGENT — Payment succeeded but the delivery slot was NOT written to your calendar.`, ``, `You must manually block this slot immediately to prevent a double-booking.`, ``, `Order: #${params.shortRef} (${params.orderId})`, `Customer: ${params.customerName} ${params.customerPhone}`, `Slot: ${fmtDate(params.slotISO)}`, `Address: ${params.address}`, `Items: ${params.items}`, ``, `Steps:`, ` 1. Block the slot in your calendar now.`, ` 2. Confirm the booking with the customer by phone.`, ` 3. Check Square dashboard for the full order details.`, ].join('\n'), }) } export async function sendNewOrderAlert(params: { shortRef: string orderId: string customerName: string customerPhone: string customerEmail: string fulfillment: 'delivery' | 'pickup' slotISO: string slotEndISO?: string address?: string lineItems: EmailLineItem[] colors: string[] subtotalCents?: number deliveryCents?: number totalCents: bigint }): Promise { const to = process.env.ALERT_EMAIL_TO if (!to) return const slotStr = params.slotEndISO ? fmtWindow(params.slotISO, params.slotEndISO) : fmtDate(params.slotISO) const total = `$${(Number(params.totalCents) / 100).toFixed(2)}` const chargesLines: string[] = [] if (params.subtotalCents != null) { chargesLines.push(`Subtotal: $${(params.subtotalCents / 100).toFixed(2)}`) if (params.deliveryCents) { chargesLines.push(`Delivery: $${(params.deliveryCents / 100).toFixed(2)}`) } const impliedTax = Number(params.totalCents) - params.subtotalCents - (params.deliveryCents ?? 0) if (impliedTax > 0) { chargesLines.push(`Tax: $${(impliedTax / 100).toFixed(2)}`) } chargesLines.push(`Total: ${total}`) } else { chargesLines.push(`Total: ${total}`) } const slotLine = params.fulfillment === 'delivery' ? `Delivery: ${slotStr}` : `Pickup: ${slotStr}` const itemsBlock = formatLineItems(params.lineItems) const hasPerItemColors = params.lineItems.some((li) => li.colors?.length) const orderColors = !hasPerItemColors && params.colors.length ? params.colors : [] const lines = [ `New ${params.fulfillment} order — #${params.shortRef}`, ``, `Customer: ${params.customerName}`, `Phone: ${params.customerPhone}`, `Email: ${params.customerEmail}`, ``, slotLine, ...(params.address ? [`Address: ${params.address}`] : []), ``, itemsBlock, ...(orderColors.length ? [``, `Colors: ${orderColors.join(', ')}`] : []), ``, ...chargesLines, ``, `View in Square: https://squareup.com/dashboard/orders`, ] await send({ to, subject: `🎈 New order #${params.shortRef} — ${params.customerName} (${params.fulfillment})`, text: lines.join('\n'), }) }