Fixed email designs
Deploy / deploy-dev (push) Successful in 2m44s
Deploy / deploy-prod (push) Skipped

This commit is contained in:
blaisadmin
2026-09-08 20:49:59 -04:00
parent 856753b012
commit b1b89ad334
15 changed files with 302 additions and 188 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

@@ -0,0 +1,13 @@
// Rebuild the email-safe PNG from the app's SVG tracks and background palette.
// Run from backend: node scripts/generate-email-background.mjs
import { readFile, writeFile } from 'node:fs/promises';
import sharp from 'sharp';
const css = await readFile(new URL('../../frontend/src/index.css', import.meta.url), 'utf8');
const encoded = css.match(/background-image: url\("data:image\/svg\+xml,([^"\n]+)"\)/)?.[1];
if (!encoded) throw new Error('App bird-track background was not found');
const tracks = decodeURIComponent(encoded);
const gradients = `<defs><linearGradient id="wash" x2="0" y2="1"><stop stop-color="#fef5e7"/><stop offset=".46" stop-color="#e9ddba"/><stop offset="1" stop-color="#d9eadf"/></linearGradient>${[[14,10,22,'#de7c3a',.28],[82,12,20,'#35886e',.26],[24,84,22,'#ddb34e',.2],[86,78,24,'#2b765c',.24],[62,54,16,'#3072a0',.14]].map(([x,y,r,color,opacity],i)=>`<radialGradient id="glow${i}" cx="${x}%" cy="${y}%" r="${r}%"><stop stop-color="${color}" stop-opacity="${opacity}"/><stop offset="1" stop-color="${color}" stop-opacity="0"/></radialGradient>`).join('')}</defs>`;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="800" height="1100" viewBox="0 0 1600 2200">${gradients}<rect width="1600" height="2200" fill="url(#wash)"/>${[0,1,2,3,4].map(i=>`<rect width="1600" height="2200" fill="url(#glow${i})"/>`).join('')}<g opacity=".42">${tracks.replace(/^<svg[^>]*>/,'').replace(/<\/svg>$/,'')}</g></svg>`;
await writeFile(new URL('../assets/email-background.png', import.meta.url), await sharp(Buffer.from(svg)).png().toBuffer());
await writeFile(new URL('../../frontend/public/email-background.png', import.meta.url), await readFile(new URL('../assets/email-background.png', import.meta.url)));
+50 -185
View File
@@ -1,3 +1,5 @@
import { buildEmailLayout } from './emails/emailLayout.js';
import { buildWeightReminderEmail } from './emails/weightReminderEmail.js';
import type { WeightReminder } from './reminders/weightReminders.js';
import crypto from 'crypto';
import { existsSync } from 'fs';
@@ -9,7 +11,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, { type SendMailOptions } from 'nodemailer';
import nodemailer from 'nodemailer';
import Stripe from 'stripe';
import { z } from 'zod';
@@ -1395,12 +1397,14 @@ const sendMagicLink = async ({
'',
'This link expires in 15 minutes and can only be used once.',
].join('\n'),
html: `
<p>Hi ${name || 'there'},</p>
...await buildEmailLayout({ eyebrow: 'Sign in', headline: 'Your FlockPal sign-in link',
contentHtml: `
<p>Hi ${escapeHtml(name || 'there')},</p>
<p>Use this secure link to sign in to FlockPal:</p>
<p><a href="${magicLinkUrl}">Sign in to FlockPal</a></p>
<p>This link expires in 15 minutes and can only be used once.</p>
`,
`,
action: { url: magicLinkUrl, label: 'Sign in to FlockPal' },
}),
});
return {
@@ -1471,27 +1475,6 @@ const getMilestoneYearCount = (reminder: BirdMilestoneReminderCandidateRow) => {
return Number.isFinite(sourceYear) ? Math.max(0, reminder.reminder_year - sourceYear) : 0;
};
const getFlockPalLogoAttachment = () => {
const logoPath = path.join(process.cwd(), 'assets', 'flockpal-logo.png');
if (!existsSync(logoPath)) {
console.warn(`Unable to load FlockPal email logo from ${logoPath}`);
return null;
}
return {
filename: 'flockpal-logo.png',
path: logoPath,
cid: 'flockpal-logo',
contentDisposition: 'inline' as const,
};
};
const getEmailTrackPatternDataUrl = () => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="680" height="188" viewBox="0 0 680 188"><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#fef5e7"/><stop offset=".52" stop-color="#e9ddba"/><stop offset="1" stop-color="#d9eadf"/></linearGradient><symbol id="track" viewBox="0 0 160 160"><rect x="66" y="12" width="28" height="136" rx="14" transform="rotate(30 80 80)"/><rect x="66" y="12" width="28" height="136" rx="14" transform="rotate(-30 80 80)"/></symbol></defs><rect width="680" height="188" fill="url(#wash)"/><g opacity=".68"><use href="#track" x="20" y="16" width="88" height="88" fill="#5bb3b7" transform="rotate(-12 64 60)"/><use href="#track" x="126" y="74" width="78" height="78" fill="#7eb773" transform="rotate(18 165 113)"/><use href="#track" x="232" y="20" width="104" height="104" fill="#f3a24a" transform="rotate(-26 284 72)"/><use href="#track" x="378" y="72" width="86" height="86" fill="#898b93" transform="rotate(28 421 115)"/><use href="#track" x="492" y="18" width="98" height="98" fill="#b9c945" transform="rotate(-18 541 67)"/><use href="#track" x="592" y="84" width="66" height="66" fill="#5bb3b7" transform="rotate(34 625 117)"/></g><g opacity=".32"><use href="#track" x="66" y="112" width="46" height="46" fill="#f3a24a" transform="rotate(36 89 135)"/><use href="#track" x="190" y="122" width="42" height="42" fill="#5bb3b7" transform="rotate(-20 211 143)"/><use href="#track" x="344" y="18" width="44" height="44" fill="#7eb773" transform="rotate(18 366 40)"/><use href="#track" x="474" y="126" width="48" height="48" fill="#f3a24a" transform="rotate(-34 498 150)"/><use href="#track" x="626" y="18" width="42" height="42" fill="#898b93" transform="rotate(22 647 39)"/></g></svg>`;
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
};
const parseDataImage = (dataUrl: string) => {
const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/.exec(dataUrl);
if (!match) {
@@ -1636,22 +1619,6 @@ const loadBirdReportPhotoBuffer = async (bird: BirdRow) => {
return Buffer.from(await imageResponse.arrayBuffer());
};
const getDefaultBirdPhotoAttachment = () => {
const defaultPhotoPath = path.join(process.cwd(), 'assets', 'yoda-default.png');
if (!existsSync(defaultPhotoPath)) {
console.warn(`Unable to load default bird photo from ${defaultPhotoPath}`);
return null;
}
return {
filename: 'yoda-default.png',
path: defaultPhotoPath,
cid: 'flockpal-default-bird-photo',
contentDisposition: 'inline' as const,
};
};
const sendRescueStatusNotification = async ({
workspace,
ownerEmail,
@@ -1701,7 +1668,8 @@ const sendRescueStatusNotification = async ({
to: rescueStatusNotificationEmail,
subject,
text: lines.join('\n'),
html: `
...await buildEmailLayout({ eyebrow: 'Rescue status', headline: 'Rescue flock update',
contentHtml: `
<p>A rescue flock was ${eventLabel}.</p>
<ul>
<li><strong>Rescue flock:</strong> ${escapedWorkspaceName}</li>
@@ -1711,7 +1679,9 @@ const sendRescueStatusNotification = async ({
<li><strong>Flock ID:</strong> ${workspace.id}</li>
</ul>
${escapedNote ? `<p><strong>Note:</strong> ${escapedNote}</p>` : ''}
`,
`,
action: { url: frontendBaseUrl, label: 'Open FlockPal' },
}),
});
return { delivered: true };
@@ -1850,13 +1820,15 @@ const issueBirdTransferInvite = async ({
to: email,
subject,
text,
html: `
...await buildEmailLayout({ eyebrow: 'Bird transfer', headline: 'A bird is joining your flock',
contentHtml: `
<p>Hi there,</p>
<p><strong>${escapeHtml(sourceWorkspaceName)}</strong> wants to transfer <strong>${escapeHtml(birdName)}</strong> to your FlockPal account.</p>
<p>Use this secure invite link to sign in or create your account. FlockPal will automatically create your receiving flock and complete any pending bird transfers for this email.</p>
<p><a href="${magicLinkUrl}">Accept bird transfer in FlockPal</a></p>
<p>This link expires in 15 minutes and can only be used once.</p>
`,
`,
action: { url: magicLinkUrl, label: 'Accept bird transfer' },
}),
});
return {
@@ -1912,7 +1884,8 @@ const sendLostBirdReportNotification = async ({
replyTo: emptyToNull(report.finderEmail) ?? undefined,
subject,
text: lines.join('\n'),
html: `
...await buildEmailLayout({ eyebrow: 'Found bird report', headline: 'A possible match for your bird',
contentHtml: `
<p>A possible found bird report was submitted for <strong>${escapeHtml(bird.name)}</strong>.</p>
<ul>
<li><strong>Band ID:</strong> ${escapeHtml(bird.tag_id ?? 'Not recorded')}</li>
@@ -1924,7 +1897,9 @@ const sendLostBirdReportNotification = async ({
<li><strong>Message:</strong> ${escapeHtml(message)}</li>
</ul>
<p>FlockPal does not verify found bird reports. Please use care before sharing personal information or arranging a pickup.</p>
`,
`,
action: { url: frontendBaseUrl, label: 'Open FlockPal' },
}),
});
return { delivered: true };
@@ -2000,7 +1975,7 @@ const buildMedicationReminderCopy = (reminder: MedicationReminderCandidateRow) =
return {
subject: `${reminder.medication_name} reminder for ${reminder.name}`,
eyebrow: 'Medication Reminder',
headline: `${slotLabel} time for ${reminder.name}`,
headline: `Medication time for ${reminder.name}`,
intro: `${reminder.name} is due for ${reminder.medication_name} at ${doseTime}.`,
body: `Dose: ${reminder.dosage}${route}.`,
detailLabel: `${slotLabel} at ${doseTime}`,
@@ -2021,32 +1996,6 @@ const sendBirdMilestoneReminderNotification = async ({
}
const copy = buildBirdMilestoneReminderCopy(reminder);
const attachments: NonNullable<SendMailOptions['attachments']> = [];
const logoAttachment = getFlockPalLogoAttachment();
const trackPatternDataUrl = getEmailTrackPatternDataUrl();
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 (logoAttachment) {
attachments.push(logoAttachment);
}
if (uploadedBirdPhoto) {
attachments.push({
filename: `${reminder.name.replace(/[^a-z0-9_-]+/gi, '-').toLowerCase() || 'bird'}-photo`,
content: uploadedBirdPhoto.content,
contentType: uploadedBirdPhoto.contentType,
cid: birdPhotoCid,
contentDisposition: 'inline',
});
} else if (defaultBirdPhoto) {
attachments.push(defaultBirdPhoto);
}
const birdPhotoHtml = birdPhotoCid
? `<img src="cid:${birdPhotoCid}" alt="${escapeHtml(reminder.name)}" style="display: block; width: 148px; height: 148px; border-radius: 28px; object-fit: cover; border: 4px solid #fff8ef; box-shadow: 0 14px 30px rgba(38, 51, 49, 0.18);" />`
: `<div style="display: grid; place-items: center; width: 148px; height: 148px; border-radius: 28px; background: linear-gradient(135deg, #fff8ef, #eaf7ef); border: 4px solid #fff8ef; box-shadow: 0 14px 30px rgba(38, 51, 49, 0.18); color: #238a5a; font-size: 64px; font-weight: 800;">${escapeHtml(reminder.name.slice(0, 1).toUpperCase())}</div>`;
const lines = [
copy.headline,
'',
@@ -2071,43 +2020,18 @@ const sendBirdMilestoneReminderNotification = async ({
bcc: uniqueRecipients,
subject: copy.subject,
text: lines.join('\n'),
attachments,
html: `
<div style="margin: 0; padding: 28px; background-color: #fef5e7; background-image: url('${trackPatternDataUrl}'), radial-gradient(circle at 14% 10%, rgba(222, 124, 58, 0.24), transparent 22%), radial-gradient(circle at 82% 12%, rgba(53, 136, 110, 0.22), transparent 20%), linear-gradient(180deg, #fef5e7 0%, #e9ddba 46%, #d9eadf 100%); background-repeat: repeat, no-repeat, no-repeat, no-repeat; font-family: Arial, sans-serif; color: #1f2a2a; line-height: 1.6;">
<div style="max-width: 680px; margin: 0 auto 18px;">
<img src="${trackPatternDataUrl}" alt="" style="display: block; width: 100%; max-width: 680px; height: auto; border-radius: 26px;" />
</div>
<div style="max-width: 680px; margin: 0 auto; overflow: hidden; border-radius: 30px; background-color: #e7f4e9; background-image: linear-gradient(135deg, rgba(255, 255, 255, 0.44), transparent 42%), linear-gradient(180deg, rgba(235, 247, 237, 0.98), rgba(211, 235, 220, 0.96)); border: 1px solid rgba(53, 129, 98, 0.34); box-shadow: 0 22px 44px rgba(89, 48, 42, 0.14);">
<div style="padding: 24px 28px; background-color: #edf8ef; background-image: linear-gradient(135deg, rgba(255, 255, 255, 0.46), transparent 46%), linear-gradient(180deg, rgba(242, 250, 243, 0.98), rgba(220, 241, 226, 0.94)); border-bottom: 1px solid rgba(53, 129, 98, 0.18);">
${
logoAttachment
? '<img src="cid:flockpal-logo" alt="FlockPal" style="display: block; width: 180px; max-width: 72%; height: auto;" />'
: '<strong style="display: block; color: #238a5a; font-size: 22px;">FlockPal</strong>'
}
</div>
<div style="padding: 30px 28px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse: collapse;">
<tr>
<td style="vertical-align: top; padding: 0 24px 20px 0; width: 160px;">
${birdPhotoHtml}
</td>
<td style="vertical-align: top; padding: 0 0 20px;">
<h1 style="margin: 0 0 12px; color: #1f2a2a; font-size: 30px; line-height: 1.12;">${escapeHtml(copy.headline)}</h1>
<p style="margin: 0; color: #63562d; font-size: 17px;">${escapeHtml(copy.intro)}</p>
</td>
</tr>
</table>
<p style="margin: 4px 0 18px; font-size: 16px;">${escapeHtml(copy.body)}</p>
<p style="margin: 0;">
<a href="${frontendBaseUrl}" style="display: inline-block; padding: 12px 18px; border-radius: 999px; background: linear-gradient(135deg, #238a5a, #2f8f98); color: #ffffff; text-decoration: none; font-weight: 700; box-shadow: 0 12px 24px rgba(72, 97, 62, 0.16);">Open FlockPal</a>
</p>
</div>
</div>
<div style="max-width: 680px; margin: 18px auto 0;">
<img src="${trackPatternDataUrl}" alt="" style="display: block; width: 100%; max-width: 680px; height: auto; border-radius: 26px;" />
</div>
</div>
`,
...await buildEmailLayout({
bird: reminder,
eyebrow: copy.eyebrow,
headline: copy.headline,
preheader: copy.intro,
contentHtml: `<p style="margin:0 0 16px;font-size:16px;">${escapeHtml(copy.intro)}</p>
<p>${escapeHtml(copy.body)}</p>
<p><strong>${escapeHtml(reminder.name)}</strong> · ${escapeHtml(reminder.species)}</p>
<p>${escapeHtml(`${copy.eventName}: ${copy.milestoneLabel}`)}</p>
`,
action: { url: frontendBaseUrl, label: 'Open FlockPal' },
}),
});
return { delivered: true };
@@ -2127,35 +2051,6 @@ const sendMedicationReminderNotification = async ({
}
const copy = buildMedicationReminderCopy(reminder);
const attachments: NonNullable<SendMailOptions['attachments']> = [];
const logoAttachment = getFlockPalLogoAttachment();
const trackPatternDataUrl = getEmailTrackPatternDataUrl();
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 (logoAttachment) {
attachments.push(logoAttachment);
}
if (uploadedBirdPhoto) {
attachments.push({
filename: `${reminder.name.replace(/[^a-z0-9_-]+/gi, '-').toLowerCase() || 'bird'}-photo`,
content: uploadedBirdPhoto.content,
contentType: uploadedBirdPhoto.contentType,
cid: birdPhotoCid,
contentDisposition: 'inline',
});
} else if (defaultBirdPhoto) {
attachments.push(defaultBirdPhoto);
}
const birdPhotoHtml = birdPhotoCid
? `<img src="cid:${birdPhotoCid}" alt="${escapeHtml(reminder.name)}" style="display: block; width: 148px; height: 148px; border-radius: 28px; object-fit: cover; border: 4px solid #fff8ef; box-shadow: 0 14px 30px rgba(38, 51, 49, 0.18);" />`
: `<div style="display: grid; place-items: center; width: 148px; height: 148px; border-radius: 28px; background: linear-gradient(135deg, #fff8ef, #eaf7ef); border: 4px solid #fff8ef; box-shadow: 0 14px 30px rgba(38, 51, 49, 0.18); color: #238a5a; font-size: 64px; font-weight: 800;">${escapeHtml(reminder.name.slice(0, 1).toUpperCase())}</div>`;
const medicationNotesHtml = reminder.medication_notes
? `<p style="margin: 0 0 18px; font-size: 15px; color: #63562d;"><strong>Medication notes:</strong> ${escapeHtml(reminder.medication_notes)}</p>`
: '';
const lines = [
copy.headline,
'',
@@ -2182,46 +2077,18 @@ const sendMedicationReminderNotification = async ({
bcc: uniqueRecipients,
subject: copy.subject,
text: lines.join('\n'),
attachments,
html: `
<div style="margin: 0; padding: 28px; background-color: #fef5e7; background-image: url('${trackPatternDataUrl}'), radial-gradient(circle at 14% 10%, rgba(222, 124, 58, 0.24), transparent 22%), radial-gradient(circle at 82% 12%, rgba(53, 136, 110, 0.22), transparent 20%), linear-gradient(180deg, #fef5e7 0%, #e9ddba 46%, #d9eadf 100%); background-repeat: repeat, no-repeat, no-repeat, no-repeat; font-family: Arial, sans-serif; color: #1f2a2a; line-height: 1.6;">
<div style="max-width: 680px; margin: 0 auto 18px;">
<img src="${trackPatternDataUrl}" alt="" style="display: block; width: 100%; max-width: 680px; height: auto; border-radius: 26px;" />
</div>
<div style="max-width: 680px; margin: 0 auto; overflow: hidden; border-radius: 30px; background-color: #e7f4e9; background-image: linear-gradient(135deg, rgba(255, 255, 255, 0.44), transparent 42%), linear-gradient(180deg, rgba(235, 247, 237, 0.98), rgba(211, 235, 220, 0.96)); border: 1px solid rgba(53, 129, 98, 0.34); box-shadow: 0 22px 44px rgba(89, 48, 42, 0.14);">
<div style="padding: 24px 28px; background-color: #edf8ef; background-image: linear-gradient(135deg, rgba(255, 255, 255, 0.46), transparent 46%), linear-gradient(180deg, rgba(242, 250, 243, 0.98), rgba(220, 241, 226, 0.94)); border-bottom: 1px solid rgba(53, 129, 98, 0.18);">
${
logoAttachment
? '<img src="cid:flockpal-logo" alt="FlockPal" style="display: block; width: 180px; max-width: 72%; height: auto;" />'
: '<strong style="display: block; color: #238a5a; font-size: 22px;">FlockPal</strong>'
}
</div>
<div style="padding: 30px 28px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse: collapse;">
<tr>
<td style="vertical-align: top; padding: 0 24px 20px 0; width: 160px;">
${birdPhotoHtml}
</td>
<td style="vertical-align: top; padding: 0 0 20px;">
<p style="margin: 0 0 8px; color: #238a5a; font-size: 13px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em;">${escapeHtml(copy.eyebrow)}</p>
<h1 style="margin: 0 0 12px; color: #1f2a2a; font-size: 30px; line-height: 1.12;">${escapeHtml(copy.headline)}</h1>
<p style="margin: 0; color: #63562d; font-size: 17px;">${escapeHtml(copy.intro)}</p>
</td>
</tr>
</table>
<p style="margin: 4px 0 10px; font-size: 16px;">${escapeHtml(copy.body)}</p>
<p style="margin: 0 0 18px; font-size: 15px; color: #63562d;"><strong>Schedule:</strong> ${escapeHtml(copy.detailLabel)}</p>
${medicationNotesHtml}
<p style="margin: 0;">
<a href="${frontendBaseUrl}" style="display: inline-block; padding: 12px 18px; border-radius: 999px; background: linear-gradient(135deg, #238a5a, #2f8f98); color: #ffffff; text-decoration: none; font-weight: 700; box-shadow: 0 12px 24px rgba(72, 97, 62, 0.16);">Open FlockPal</a>
</p>
</div>
</div>
<div style="max-width: 680px; margin: 18px auto 0;">
<img src="${trackPatternDataUrl}" alt="" style="display: block; width: 100%; max-width: 680px; height: auto; border-radius: 26px;" />
</div>
</div>
`,
...await buildEmailLayout({
bird: reminder,
eyebrow: copy.eyebrow,
headline: copy.headline,
preheader: copy.intro,
contentHtml: `<p style="margin:0 0 16px;font-size:16px;">${escapeHtml(copy.intro)}</p>
<p>${escapeHtml(copy.body)}</p>
<p><strong>${escapeHtml(reminder.name)}</strong> · ${escapeHtml(reminder.species)}</p>
<p>${escapeHtml(copy.detailLabel)}</p>
${reminder.medication_notes ? `<p><strong>Medication notes:</strong> ${escapeHtml(reminder.medication_notes)}</p>` : ''}`,
action: { url: frontendBaseUrl, label: 'Open FlockPal' },
}),
});
return { delivered: true };
@@ -2277,9 +2144,7 @@ export const sendWeightReminderNotification = async (reminders: WeightReminder[]
const result = await mailTransport.sendMail({
from: smtpFromName ? `"${smtpFromName}" <${smtpFromEmail}>` : smtpFromEmail,
to: first.recipient,
subject: `Weight reminders for ${first.workspace_name}`,
text: `These birds in ${first.workspace_name} haven't had a new weight entry in at least 48 hours:\n\n${reminders.map((bird) => `- ${bird.bird_name}`).join('\n')}\n\nOpen FlockPal to record their weights: ${frontendBaseUrl}`,
html: `<p>These birds in ${escapeHtml(first.workspace_name)} haven't had a new weight entry in at least 48 hours:</p><ul>${reminders.map((bird) => `<li>${escapeHtml(bird.bird_name)}</li>`).join('')}</ul><p><a href="${escapeHtml(frontendBaseUrl)}">Open FlockPal to record their weights</a></p>`,
...await buildWeightReminderEmail(reminders, frontendBaseUrl),
});
return result.accepted.length > 0;
};
+29
View File
@@ -0,0 +1,29 @@
import { readFile } from 'node:fs/promises';
import sharp from 'sharp';
import type { WeightReminder } from '../reminders/weightReminders.js';
import { getS3ImageStorageConfig } from '../storage/imageStorageConfig.js';
import { getSignedS3ObjectUrl } from '../storage/s3Client.js';
const asset = (name: string) => new URL(`../../assets/${name}`, import.meta.url);
const thumbnail = (content: Buffer) => sharp(content).rotate().resize(192, 192, { fit: 'cover' }).jpeg({ quality: 80 }).toBuffer();
export const loadWeightReminderPortrait = async (bird: Pick<WeightReminder, 'bird_id' | 'photo_object_key' | 'photo_data_url'>): Promise<Buffer> => {
try {
if (bird.photo_object_key) {
const config = getS3ImageStorageConfig();
if (config) {
const response = await fetch(getSignedS3ObjectUrl({ config, objectKey: bird.photo_object_key, expiresInSeconds: 300 }), {
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) throw new Error(`Photo storage returned ${response.status}`);
return await thumbnail(Buffer.from(await response.arrayBuffer()));
}
}
const match = bird.photo_data_url?.match(/^data:image\/(?:png|jpe?g|webp|gif);base64,(.+)$/);
if (match) return await thumbnail(Buffer.from(match[1], 'base64'));
} catch {
console.warn(`Unable to load weight reminder portrait for bird ${bird.bird_id}; using default portrait.`);
}
return thumbnail(await readFile(asset('yoda-default.png')));
};
+36
View File
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { buildEmailLayout } from './emailLayout.js';
test('shared layout escapes copy and embeds the app background and bird portrait', async () => {
const mail = await buildEmailLayout({
backgroundUrl: 'https://flockpal.test/email-background.png',
eyebrow: 'Medication <reminder>', headline: 'Medication time for Kiwi & Peep',
contentHtml: '<p>Morning at 8:00 AM</p>',
action: { url: 'https://flockpal.test/?a=1&b=2', label: 'Open <FlockPal>' },
bird: { id: 'bird-1', name: 'Kiwi & Peep', photo_data_url: null, photo_object_key: null },
});
const html = String(mail.html);
assert.match(html, /Medication &lt;reminder&gt;/);
assert.match(html, /Medication time for Kiwi &amp; Peep/);
assert.match(html, /a=1&amp;b=2/);
assert.match(html, /Open &lt;FlockPal&gt;/);
assert.match(html, /Morning at 8:00 AM/);
assert.match(html, /background="https:\/\/flockpal.test\/email-background.png"/);
assert.match(html, /background-image:url\('https:\/\/flockpal.test\/email-background.png'\)/);
assert.doesNotMatch(html, /flockpal-pattern|cid:flockpal-background/);
assert.doesNotMatch(html, /48-hour|weights are overdue|data:image/);
for (const attachment of mail.attachments ?? []) {
assert.ok(html.includes(`cid:${attachment.cid}`));
assert.equal(attachment.contentDisposition, 'inline');
assert.ok(Buffer.isBuffer(attachment.content));
}
assert.equal((html.match(/<table\b/g) ?? []).length, (html.match(/<\/table>/g) ?? []).length);
assert.equal((html.match(/<td\b/g) ?? []).length, (html.match(/<\/td>/g) ?? []).length);
});
test('transactional layout omits optional portrait, action and reminder footer', async () => {
const mail = await buildEmailLayout({ eyebrow: 'Rescue status', headline: 'Flock update', contentHtml: '<p>Status changed</p>' });
assert.equal(mail.attachments?.length, 1);
assert.doesNotMatch(String(mail.html), /bird-portrait|<a |48-hour/);
});
+48
View File
@@ -0,0 +1,48 @@
import { readFile } from 'node:fs/promises';
import type { SendMailOptions } from 'nodemailer';
import sharp from 'sharp';
import { loadWeightReminderPortrait } from './birdPortrait.js';
export const escapeHtml = (value: string) => value.replace(/[&<>"']/g, char => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
})[char]!);
const asset = (name: string) => new URL(`../../assets/${name}`, import.meta.url);
export const buildEmailLayout = async ({ eyebrow, headline, preheader = headline, contentHtml, action, footer, bird, backgroundUrl = process.env.EMAIL_BACKGROUND_URL || new URL('/email-background.png', process.env.FRONTEND_URL || 'http://localhost:3000').href }: {
eyebrow: string;
headline: string;
preheader?: string;
/** Trusted markup; escape all dynamic values before passing them. */
contentHtml: string;
action?: { url: string; label: string };
footer?: string;
backgroundUrl?: string;
bird?: { id: string; name: string; photo_data_url: string | null; photo_object_key: string | null };
}): Promise<Pick<SendMailOptions, 'html' | 'attachments'>> => ({
attachments: [
{ filename: 'flockpal-logo.png', cid: 'flockpal-logo', contentType: 'image/png', contentDisposition: 'inline', content: await sharp(await readFile(asset('flockpal-logo.png'))).resize({ width: 480 }).png().toBuffer() },
...(bird ? [{ filename: 'bird-portrait.jpg', cid: 'bird-portrait', contentType: 'image/jpeg', contentDisposition: 'inline' as const, content: await loadWeightReminderPortrait({ bird_id: bird.id, photo_data_url: bird.photo_data_url, photo_object_key: bird.photo_object_key }) }] : []),
],
html: `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head>
<body style="margin:0;padding:0;background-color:#fef5e7;">
<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;">${escapeHtml(preheader)}</div>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="width:100%;background-color:#fef5e7;font-family:Arial,sans-serif;color:#1f2a2a;line-height:1.5;">
<tr><td align="center" background="${escapeHtml(backgroundUrl)}" style="padding:24px 12px;background-color:#fef5e7;background-image:url('${escapeHtml(backgroundUrl)}');background-position:center top;background-repeat:repeat;background-size:100% auto;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:600px;background-color:#edf8ef;border:1px solid #d3e5d8;border-radius:24px;">
<tr><td align="center" style="padding:24px;border-bottom:1px solid #d3e5d8;">
<img src="cid:flockpal-logo" width="220" alt="FlockPal" style="display:block;width:220px;max-width:100%;height:auto;border:0;" />
</td></tr>
<tr><td style="padding:24px;">
<p style="margin:0 0 8px;font-size:12px;font-weight:bold;letter-spacing:1.5px;color:#238a5a;text-transform:uppercase;">${escapeHtml(eyebrow)}</p>
<h1 style="margin:0 0 12px;font-size:26px;line-height:1.2;word-break:break-word;">${escapeHtml(headline)}</h1>
${bird ? `<img src="cid:bird-portrait" width="76" height="76" alt="${escapeHtml(bird.name)}" style="display:block;border:0;border-radius:16px;margin:0 0 16px;" />` : ''}
${contentHtml}
${action ? `<table role="presentation" cellspacing="0" cellpadding="0" style="margin-top:24px;"><tr><td bgcolor="#238a5a" style="border-radius:24px;text-align:center;"><a href="${escapeHtml(action.url)}" style="display:inline-block;padding:13px 22px;border:1px solid #238a5a;border-radius:24px;color:#ffffff;font-size:16px;font-weight:bold;text-decoration:none;">${escapeHtml(action.label)}</a></td></tr></table>` : ''}
${footer ? `<p style="margin:20px 0 0;font-size:13px;color:#52645b;">${escapeHtml(footer)}</p>` : ''}
</td></tr>
</table>
<p style="margin:16px 0 0;color:#52645b;font-size:12px;">FlockPal · A little care, every day.</p>
</td></tr>
</table>
</body></html>`,
});
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { test } from 'node:test';
import sharp from 'sharp';
import { buildWeightReminderEmail, loadWeightReminderPortrait } from './weightReminderEmail.js';
import type { WeightReminder } from '../reminders/weightReminders.js';
const bird: WeightReminder = {
bird_id: 'bird-1', workspace_id: 1, bird_name: 'Kiwi & <Peep>', species: 'Cockatiel',
workspace_name: 'Our <Flock>', activity_at: '2026-09-01T12:00:00Z', recipient: 'owner@example.test',
photo_data_url: null, photo_object_key: null,
};
test('branded grouped email embeds distinct portraits and escapes dynamic content', async () => {
const mail = await buildWeightReminderEmail([bird, { ...bird, bird_id: 'bird-2', bird_name: 'Yoda' }], 'https://flockpal.test/?a=1&b=2');
assert.equal(mail.attachments?.length, 3);
assert.deepEqual(mail.attachments?.map(a => a.cid), ['flockpal-logo', 'weight-bird-0', 'weight-bird-1']);
for (const attachment of mail.attachments ?? []) {
assert.equal(attachment.contentDisposition, 'inline');
assert.ok(Buffer.isBuffer(attachment.content));
assert.ok(String(mail.html).includes(`cid:${attachment.cid}`));
}
assert.match(String(mail.html), /Kiwi &amp; &lt;Peep&gt;/);
assert.match(String(mail.html), /Our &lt;Flock&gt;/);
assert.match(String(mail.html), /a=1&amp;b=2/);
assert.match(String(mail.text), /Kiwi & <Peep>/);
});
test('database portrait is resized and invalid photos fall back to the default', async () => {
const source = await readFile(new URL('../../assets/yoda.png', import.meta.url));
const photo = await loadWeightReminderPortrait({ ...bird, photo_data_url: `data:image/png;base64,${source.toString('base64')}` });
const metadata = await sharp(photo).metadata();
assert.equal(metadata.width, 192);
assert.equal(metadata.height, 192);
assert.equal(metadata.format, 'jpeg');
assert.deepEqual(await loadWeightReminderPortrait({ ...bird, photo_data_url: 'data:image/png;base64,broken' }), await loadWeightReminderPortrait(bird));
});
test('private S3 portrait is downloaded and embedded rather than linked', async (t) => {
const env = { IMAGE_STORAGE_PROVIDER: 's3', S3_ENDPOINT: 'https://storage.example.test', S3_REGION: 'us-east-1', S3_BUCKET: 'portraits', S3_ACCESS_KEY_ID: 'test', S3_SECRET_ACCESS_KEY: 'test' };
const previous = Object.fromEntries(Object.keys(env).map(key => [key, process.env[key]]));
Object.assign(process.env, env);
try {
const source = await readFile(new URL('../../assets/yoda.png', import.meta.url));
t.mock.method(globalThis, 'fetch', async (url: string) => {
assert.match(String(url), /private-bird.png/);
return new Response(source);
});
const photo = await loadWeightReminderPortrait({ ...bird, photo_object_key: 'private-bird.png' });
assert.equal((await sharp(photo).metadata()).width, 192);
t.mock.restoreAll();
t.mock.method(globalThis, 'fetch', async () => new Response('', { status: 404 }));
assert.deepEqual(await loadWeightReminderPortrait({ ...bird, photo_object_key: 'missing.png' }), await loadWeightReminderPortrait(bird));
} finally {
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
});
+44
View File
@@ -0,0 +1,44 @@
import { buildEmailLayout, escapeHtml } from './emailLayout.js';
import type { SendMailOptions } from 'nodemailer';
import type { WeightReminder } from '../reminders/weightReminders.js';
import { loadWeightReminderPortrait } from './birdPortrait.js';
export { loadWeightReminderPortrait } from './birdPortrait.js';
export const buildWeightReminderEmail = async (
reminders: WeightReminder[],
frontendUrl: string,
loadPortrait = loadWeightReminderPortrait,
): Promise<Pick<SendMailOptions, 'subject' | 'text' | 'html' | 'attachments'>> => {
if (!reminders.length) throw new Error('A weight reminder email requires at least one bird');
const flock = reminders[0].workspace_name;
const attachments: NonNullable<SendMailOptions['attachments']> = [];
const rows: string[] = [];
for (const [index, bird] of reminders.entries()) {
const cid = `weight-bird-${index}`;
attachments.push({ filename: `${cid}.jpg`, cid, content: await loadPortrait(bird), contentType: 'image/jpeg', contentDisposition: 'inline' });
rows.push(`<tr>
<td width="88" style="padding:16px 12px 16px 0;border-bottom:1px solid #d3e5d8;vertical-align:middle;">
<img src="cid:${cid}" width="76" height="76" alt="${escapeHtml(bird.bird_name)}" style="display:block;border:0;border-radius:16px;" />
</td>
<td style="padding:16px 0;border-bottom:1px solid #d3e5d8;vertical-align:middle;word-break:break-word;">
<p style="margin:0 0 4px;font-size:18px;font-weight:bold;color:#1f2a2a;">${escapeHtml(bird.bird_name)}</p>
<p style="margin:0;font-size:14px;color:#52645b;">${escapeHtml(bird.species)}</p>
<p style="margin:6px 0 0;font-size:13px;color:#63562d;">Ready for a weight check</p>
</td>
</tr>`);
}
const layout = await buildEmailLayout({
eyebrow: 'Daily weight reminder',
headline: 'A little check-in for your flock',
preheader: `A little check-in for ${flock}: it's time to log your birds' weights.`,
contentHtml: `<p style="margin:0 0 16px;font-size:16px;">These birds in <strong>${escapeHtml(flock)}</strong> haven't had a new weight entry in at least 48 hours.</p><table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse:collapse;table-layout:fixed;">${rows.join('')}</table>`,
action: { url: frontendUrl, label: 'Record their weights' },
footer: "You'll receive a daily reminder while weights are overdue. A new weight entry restarts the 48-hour clock.",
});
return {
subject: `Weight reminders for ${flock}`,
text: `Daily weight reminder — ${flock}\n\nThese birds haven't had a new weight entry in at least 48 hours:\n\n${reminders.map(bird => `- ${bird.bird_name} (${bird.species})`).join('\n')}\n\nOpen FlockPal to record their weights: ${frontendUrl}\n\nYou'll receive a daily reminder while weights are overdue.`,
...layout,
attachments: [...(layout.attachments ?? []), ...attachments],
};
};
@@ -3,6 +3,7 @@ import { test } from 'node:test';
import { runWeightReminders, type WeightReminder } from './weightReminders.js';
const bird = (id: string, workspace = 1, recipient = 'owner@example.com'): WeightReminder => ({
species: 'Cockatiel', photo_data_url: null, photo_object_key: null,
bird_id: id, workspace_id: workspace, recipient,
bird_name: id, workspace_name: `Flock ${workspace}`, activity_at: '2026-09-01T12:00:00Z',
});
+5 -1
View File
@@ -6,6 +6,9 @@ export type WeightReminder = {
bird_id: string;
workspace_id: number;
bird_name: string;
species: string;
photo_data_url: string | null;
photo_object_key: string | null;
workspace_name: string;
activity_at: string;
recipient: string;
@@ -32,9 +35,10 @@ export const listDueWeightReminders = async () => {
AND role IN ('owner', 'assistant', 'caregiver')
)
SELECT activity.bird_id, activity.workspace_id, activity.bird_name, activity.workspace_name,
activity.activity_at::text, recipients.recipient
activity.activity_at::text, recipients.recipient, birds.species, birds.photo_data_url, birds.photo_object_key
FROM activity
JOIN recipients USING (workspace_id)
JOIN birds ON birds.id = activity.bird_id
WHERE activity_at <= CURRENT_TIMESTAMP - INTERVAL '48 hours'
AND recipients.recipient <> ''
AND NOT EXISTS (
@@ -1,6 +1,7 @@
// Run only against disposable local Postgres, Redis, and Mailpit services.
// See docs/WEIGHT_REMINDER_TESTING.md for the isolated Compose setup.
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { spawn } from 'node:child_process';
import { once } from 'node:events';
@@ -83,6 +84,8 @@ try {
VALUES ($1, $2, $3, 'Cockatiel', CURRENT_TIMESTAMP - INTERVAL '100 hours', ${memorial ? 'CURRENT_TIMESTAMP' : 'NULL'})`, [id, workspace, name]);
if (age !== null) await db.query("INSERT INTO weight_records (bird_id, weight_grams, recorded_on, created_at) VALUES ($1, 90, CURRENT_DATE - 3, CURRENT_TIMESTAMP - $2 * INTERVAL '1 hour')", [id, age]);
}
const portrait = await readFile(new URL('../assets/yoda.png', import.meta.url));
await db.query('UPDATE birds SET photo_data_url = $1 WHERE id = $2', [`data:image/png;base64,${portrait.toString('base64')}`, birds['Kiwi & <Peep>']]);
const due = await listDueWeightReminders();
assert.equal(due.length, 10);
assert.deepEqual(new Set(due.map(b => b.recipient)), new Set(['owner@example.test', 'assistant@example.test', 'caregiver@example.test']));
@@ -113,6 +116,11 @@ try {
assert.ok(!body.Text.includes('Boundary'));
}
assert.equal(body.To.length, 1);
const inline = body.Inline ?? [];
assert.ok(inline.some(attachment => attachment.ContentID === 'flockpal-logo'));
assert.equal(inline.filter(attachment => attachment.ContentID.startsWith('weight-bird-')).length, body.Subject === 'Weight reminders for Test Flock' ? 3 : 1);
assert.ok(body.HTML.includes('Daily weight reminder'));
assert.ok(body.HTML.includes('Record their weights'));
}
assert.equal((await weightReminderQueue.getJobSchedulers()).length, 0);
await poll(async () => Boolean(await weightReminderQueue.getJob(`weight-reminders-${getReminderDate()}`)), 'startup daily scheduler');