diff --git a/backend/src/app.ts b/backend/src/app.ts index 4e609af..190a411 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,5 +1,5 @@ import crypto from 'crypto'; -import { readFileSync } from 'fs'; +import { existsSync } from 'fs'; import path from 'path'; import cors from 'cors'; import dotenv from 'dotenv'; @@ -7,7 +7,7 @@ import express, { type NextFunction, type Request, type Response } from 'express import rateLimit from 'express-rate-limit'; import helmet from 'helmet'; import morgan from 'morgan'; -import nodemailer from 'nodemailer'; +import nodemailer, { type SendMailOptions } from 'nodemailer'; import Stripe from 'stripe'; import { z } from 'zod'; @@ -838,25 +838,34 @@ const getMilestoneYearCount = (reminder: BirdMilestoneReminderCandidateRow) => { return Number.isFinite(sourceYear) ? Math.max(0, reminder.reminder_year - sourceYear) : 0; }; -const getFlockPalLogoDataUrl = () => - 'data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 64 64%22%3E%3Cdefs%3E%3ClinearGradient id=%22featherFill%22 x1=%220%25%22 y1=%220%25%22 x2=%22100%25%22 y2=%22100%25%22%3E%3Cstop offset=%220%25%22 stop-color=%22%23cb3a35%22/%3E%3Cstop offset=%2230%25%22 stop-color=%22%23f0b63f%22/%3E%3Cstop offset=%2258%25%22 stop-color=%22%23238a5a%22/%3E%3Cstop offset=%22100%25%22 stop-color=%22%232769b3%22/%3E%3C/linearGradient%3E%3C/defs%3E%3Cpath d=%22M50.8 10.4C37.9 10.3 27 18.5 22.7 31.1c-3.1 9.1-2.1 18.5-8.6 24.8c-1.5 1.5-0.2 4 1.9 3.6c8.4-1.5 14.6-6.7 18.6-13.7c1 0.5 2.2 0.8 3.4 0.8c3.5 0 6.5-2.3 7.5-5.4c1.9-0.4 3.7-1.3 5.1-2.7c2-2 3-4.6 3.1-7.2c3.3-5.8 4.9-12.9 1.4-20.2c-0.7-1.3-2-0.7-4.3-0.7Z%22 fill=%22url(%23featherFill)%22/%3E%3Cpath d=%22M18 56c8.5-3.4 14.2-9.8 18.1-17.8M26.9 48.9c6.9-7.2 13.5-14.8 20.3-22.1M31.8 41.2c6.4-1.3 12.1-4.6 16.5-9.4M36.8 33.8c4.9-0.9 9.2-3.4 12.6-7.1%22 fill=%22none%22 stroke=%22%23fff8ef%22 stroke-linecap=%22round%22 stroke-width=%222.6%22/%3E%3Cpath d=%22M18 56c8.5-3.4 14.2-9.8 18.1-17.8%22 fill=%22none%22 stroke=%22%2363562d%22 stroke-linecap=%22round%22 stroke-width=%222.2%22/%3E%3C/svg%3E'; +const getFlockPalLogoSvg = () => + ``; -let defaultBirdPhotoDataUrl: string | null = null; - -const getDefaultBirdPhotoDataUrl = () => { - if (defaultBirdPhotoDataUrl) { - return defaultBirdPhotoDataUrl; - } - - const defaultPhotoPath = process.env.DEFAULT_BIRD_PHOTO_PATH?.trim() || path.join(process.cwd(), 'assets', 'yoda.png'); - - try { - defaultBirdPhotoDataUrl = `data:image/png;base64,${readFileSync(defaultPhotoPath).toString('base64')}`; - return defaultBirdPhotoDataUrl; - } catch (error) { - console.warn(`Unable to load default bird photo from ${defaultPhotoPath}`, error); +const parseDataImage = (dataUrl: string) => { + const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/.exec(dataUrl); + if (!match) { return null; } + + return { + contentType: match[1], + content: Buffer.from(match[2], 'base64'), + }; +}; + +const getDefaultBirdPhotoAttachment = () => { + const defaultPhotoPath = process.env.DEFAULT_BIRD_PHOTO_PATH?.trim() || path.join(process.cwd(), 'assets', 'yoda.png'); + + if (!existsSync(defaultPhotoPath)) { + console.warn(`Unable to load default bird photo from ${defaultPhotoPath}`); + return null; + } + + return { + filename: 'yoda.png', + path: defaultPhotoPath, + cid: 'flockpal-default-bird-photo', + }; }; const sendRescueStatusNotification = async ({ @@ -1086,7 +1095,6 @@ const buildBirdMilestoneReminderCopy = (reminder: BirdMilestoneReminderCandidate : `${reminder.name} has a Hatch Day today.`, body: 'Cue the favorite snacks, extra head scritches if approved, and one tiny spotlight for the bird of the day.', milestoneLabel: yearCount > 0 ? `${yearCount} year${yearCount === 1 ? '' : 's'} old` : 'Hatch Day on file', - signoff: 'FlockPal is keeping the milestone warm in the flock record.', }; } @@ -1099,9 +1107,8 @@ const buildBirdMilestoneReminderCopy = (reminder: BirdMilestoneReminderCandidate yearCount > 0 ? `From our flock to yours, wishing ${reminder.name} a happy Gotcha Day! ${reminder.name} joined the flock ${yearCount} year${yearCount === 1 ? '' : 's'} ago today.` : `${reminder.name} has a Gotcha Day today.`, - body: 'A good day to remember the first ride home, the first brave perch, and every little routine built since.', + body: 'A good day to remember the first ride home, the first brave step up, and every happy moment spent together.', milestoneLabel: yearCount > 0 ? `${formatOrdinal(yearCount)} Gotcha Day` : 'Gotcha Day on file', - signoff: 'FlockPal saved the date so the flock can celebrate the story.', }; }; @@ -1119,13 +1126,32 @@ const sendBirdMilestoneReminderNotification = async ({ } const copy = buildBirdMilestoneReminderCopy(reminder); - const yearCount = getMilestoneYearCount(reminder); - const logoDataUrl = getFlockPalLogoDataUrl(); - const birdPhotoSrc = reminder.photo_data_url || getDefaultBirdPhotoDataUrl(); - const birdPhotoHtml = birdPhotoSrc - ? `` + const attachments: NonNullable = [ + { + filename: 'flockpal-logo.svg', + content: getFlockPalLogoSvg(), + contentType: 'image/svg+xml', + cid: 'flockpal-logo', + }, + ]; + const uploadedBirdPhoto = reminder.photo_data_url ? parseDataImage(reminder.photo_data_url) : null; + const defaultBirdPhoto = uploadedBirdPhoto ? null : getDefaultBirdPhotoAttachment(); + const birdPhotoCid = uploadedBirdPhoto ? 'bird-photo' : defaultBirdPhoto ? defaultBirdPhoto.cid : ''; + + if (uploadedBirdPhoto) { + attachments.push({ + filename: `${reminder.name.replace(/[^a-z0-9_-]+/gi, '-').toLowerCase() || 'bird'}-photo`, + content: uploadedBirdPhoto.content, + contentType: uploadedBirdPhoto.contentType, + cid: birdPhotoCid, + }); + } else if (defaultBirdPhoto) { + attachments.push(defaultBirdPhoto); + } + + const birdPhotoHtml = birdPhotoCid + ? `` : `${escapeHtml(reminder.name.slice(0, 1).toUpperCase())}`; - const milestoneCountLine = `${copy.eyebrow}: ${copy.milestoneLabel}`; const lines = [ copy.headline, '', @@ -1134,8 +1160,7 @@ const sendBirdMilestoneReminderNotification = async ({ '', `Bird: ${reminder.name}`, `Species: ${reminder.species}`, - `Flock: ${reminder.workspace_name}`, - milestoneCountLine, + `${copy.eventName}: ${copy.milestoneLabel}`, '', `Open FlockPal: ${frontendBaseUrl}`, ]; @@ -1151,11 +1176,12 @@ const sendBirdMilestoneReminderNotification = async ({ bcc: uniqueRecipients, subject: copy.subject, text: lines.join('\n'), + attachments, html: ` - + @@ -1164,20 +1190,12 @@ const sendBirdMilestoneReminderNotification = async ({ ${birdPhotoHtml} - ${escapeHtml(copy.eyebrow)} ${escapeHtml(copy.headline)} ${escapeHtml(copy.intro)} ${escapeHtml(copy.body)} - - Bird: ${escapeHtml(reminder.name)} - Species: ${escapeHtml(reminder.species)} - Flock: ${escapeHtml(reminder.workspace_name)} - ${escapeHtml(copy.eventName)}: ${escapeHtml(copy.milestoneLabel)} - - ${escapeHtml(copy.signoff)} Open FlockPal
${escapeHtml(copy.eyebrow)}
${escapeHtml(copy.intro)}
${escapeHtml(copy.body)}
Bird: ${escapeHtml(reminder.name)}
Species: ${escapeHtml(reminder.species)}
Flock: ${escapeHtml(reminder.workspace_name)}
${escapeHtml(copy.eventName)}: ${escapeHtml(copy.milestoneLabel)}
${escapeHtml(copy.signoff)}
Open FlockPal