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
+4
View File
@@ -42,3 +42,7 @@ STRIPE_PORTAL_RETURN_URL=http://localhost:3000/?billing=portal
# Daily email for birds without a new weight entry in 48 hours; uses the milestone time zone. # Daily email for birds without a new weight entry in 48 hours; uses the milestone time zone.
WEIGHT_REMINDERS_ENABLED=true WEIGHT_REMINDERS_ENABLED=true
# Optional absolute HTTPS image URL; defaults to FRONTEND_URL/email-background.png.
# Deploy frontend/public/email-background.png before sending the updated email templates.
EMAIL_BACKGROUND_URL=
+3 -1
View File
@@ -165,7 +165,9 @@ npm run worker
The worker checks once per calendar day for living birds with no new weight entry in The worker checks once per calendar day for living birds with no new weight entry in
48 hours. Each accepted owner, assistant, or caregiver receives one email per flock 48 hours. Each accepted owner, assistant, or caregiver receives one email per flock
listing all birds due for a reminder. Pending invitees and viewers are excluded. listing all birds due for a reminder. The branded email embeds the FlockPal logo
and each bird's portrait (including private S3 photos), with the default portrait
used when a photo is missing or unavailable. Pending invitees and viewers are excluded.
Reminders repeat daily while overdue; adding a new weight restarts the Reminders repeat daily while overdue; adding a new weight restarts the
48-hour clock. Birds without weights use their profile creation time. 48-hour clock. Birds without weights use their profile creation time.
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 type { WeightReminder } from './reminders/weightReminders.js';
import crypto from 'crypto'; import crypto from 'crypto';
import { existsSync } from 'fs'; 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 rateLimit from 'express-rate-limit';
import helmet from 'helmet'; import helmet from 'helmet';
import morgan from 'morgan'; import morgan from 'morgan';
import nodemailer, { type SendMailOptions } from 'nodemailer'; import nodemailer from 'nodemailer';
import Stripe from 'stripe'; import Stripe from 'stripe';
import { z } from 'zod'; import { z } from 'zod';
@@ -1395,12 +1397,14 @@ const sendMagicLink = async ({
'', '',
'This link expires in 15 minutes and can only be used once.', 'This link expires in 15 minutes and can only be used once.',
].join('\n'), ].join('\n'),
html: ` ...await buildEmailLayout({ eyebrow: 'Sign in', headline: 'Your FlockPal sign-in link',
<p>Hi ${name || 'there'},</p> contentHtml: `
<p>Hi ${escapeHtml(name || 'there')},</p>
<p>Use this secure link to sign in to FlockPal:</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> <p>This link expires in 15 minutes and can only be used once.</p>
`, `,
action: { url: magicLinkUrl, label: 'Sign in to FlockPal' },
}),
}); });
return { return {
@@ -1471,27 +1475,6 @@ const getMilestoneYearCount = (reminder: BirdMilestoneReminderCandidateRow) => {
return Number.isFinite(sourceYear) ? Math.max(0, reminder.reminder_year - sourceYear) : 0; 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 parseDataImage = (dataUrl: string) => {
const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/.exec(dataUrl); const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/.exec(dataUrl);
if (!match) { if (!match) {
@@ -1636,22 +1619,6 @@ const loadBirdReportPhotoBuffer = async (bird: BirdRow) => {
return Buffer.from(await imageResponse.arrayBuffer()); 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 ({ const sendRescueStatusNotification = async ({
workspace, workspace,
ownerEmail, ownerEmail,
@@ -1701,7 +1668,8 @@ const sendRescueStatusNotification = async ({
to: rescueStatusNotificationEmail, to: rescueStatusNotificationEmail,
subject, subject,
text: lines.join('\n'), text: lines.join('\n'),
html: ` ...await buildEmailLayout({ eyebrow: 'Rescue status', headline: 'Rescue flock update',
contentHtml: `
<p>A rescue flock was ${eventLabel}.</p> <p>A rescue flock was ${eventLabel}.</p>
<ul> <ul>
<li><strong>Rescue flock:</strong> ${escapedWorkspaceName}</li> <li><strong>Rescue flock:</strong> ${escapedWorkspaceName}</li>
@@ -1711,7 +1679,9 @@ const sendRescueStatusNotification = async ({
<li><strong>Flock ID:</strong> ${workspace.id}</li> <li><strong>Flock ID:</strong> ${workspace.id}</li>
</ul> </ul>
${escapedNote ? `<p><strong>Note:</strong> ${escapedNote}</p>` : ''} ${escapedNote ? `<p><strong>Note:</strong> ${escapedNote}</p>` : ''}
`, `,
action: { url: frontendBaseUrl, label: 'Open FlockPal' },
}),
}); });
return { delivered: true }; return { delivered: true };
@@ -1850,13 +1820,15 @@ const issueBirdTransferInvite = async ({
to: email, to: email,
subject, subject,
text, text,
html: ` ...await buildEmailLayout({ eyebrow: 'Bird transfer', headline: 'A bird is joining your flock',
contentHtml: `
<p>Hi there,</p> <p>Hi there,</p>
<p><strong>${escapeHtml(sourceWorkspaceName)}</strong> wants to transfer <strong>${escapeHtml(birdName)}</strong> to your FlockPal account.</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>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> <p>This link expires in 15 minutes and can only be used once.</p>
`, `,
action: { url: magicLinkUrl, label: 'Accept bird transfer' },
}),
}); });
return { return {
@@ -1912,7 +1884,8 @@ const sendLostBirdReportNotification = async ({
replyTo: emptyToNull(report.finderEmail) ?? undefined, replyTo: emptyToNull(report.finderEmail) ?? undefined,
subject, subject,
text: lines.join('\n'), 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> <p>A possible found bird report was submitted for <strong>${escapeHtml(bird.name)}</strong>.</p>
<ul> <ul>
<li><strong>Band ID:</strong> ${escapeHtml(bird.tag_id ?? 'Not recorded')}</li> <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> <li><strong>Message:</strong> ${escapeHtml(message)}</li>
</ul> </ul>
<p>FlockPal does not verify found bird reports. Please use care before sharing personal information or arranging a pickup.</p> <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 }; return { delivered: true };
@@ -2000,7 +1975,7 @@ const buildMedicationReminderCopy = (reminder: MedicationReminderCandidateRow) =
return { return {
subject: `${reminder.medication_name} reminder for ${reminder.name}`, subject: `${reminder.medication_name} reminder for ${reminder.name}`,
eyebrow: 'Medication Reminder', 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}.`, intro: `${reminder.name} is due for ${reminder.medication_name} at ${doseTime}.`,
body: `Dose: ${reminder.dosage}${route}.`, body: `Dose: ${reminder.dosage}${route}.`,
detailLabel: `${slotLabel} at ${doseTime}`, detailLabel: `${slotLabel} at ${doseTime}`,
@@ -2021,32 +1996,6 @@ const sendBirdMilestoneReminderNotification = async ({
} }
const copy = buildBirdMilestoneReminderCopy(reminder); 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 = [ const lines = [
copy.headline, copy.headline,
'', '',
@@ -2071,43 +2020,18 @@ const sendBirdMilestoneReminderNotification = async ({
bcc: uniqueRecipients, bcc: uniqueRecipients,
subject: copy.subject, subject: copy.subject,
text: lines.join('\n'), text: lines.join('\n'),
attachments, ...await buildEmailLayout({
html: ` bird: reminder,
<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;"> eyebrow: copy.eyebrow,
<div style="max-width: 680px; margin: 0 auto 18px;"> headline: copy.headline,
<img src="${trackPatternDataUrl}" alt="" style="display: block; width: 100%; max-width: 680px; height: auto; border-radius: 26px;" /> preheader: copy.intro,
</div> contentHtml: `<p style="margin:0 0 16px;font-size:16px;">${escapeHtml(copy.intro)}</p>
<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);"> <p>${escapeHtml(copy.body)}</p>
<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);"> <p><strong>${escapeHtml(reminder.name)}</strong> · ${escapeHtml(reminder.species)}</p>
${ <p>${escapeHtml(`${copy.eventName}: ${copy.milestoneLabel}`)}</p>
logoAttachment `,
? '<img src="cid:flockpal-logo" alt="FlockPal" style="display: block; width: 180px; max-width: 72%; height: auto;" />' action: { url: frontendBaseUrl, label: 'Open FlockPal' },
: '<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>
`,
}); });
return { delivered: true }; return { delivered: true };
@@ -2127,35 +2051,6 @@ const sendMedicationReminderNotification = async ({
} }
const copy = buildMedicationReminderCopy(reminder); 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 = [ const lines = [
copy.headline, copy.headline,
'', '',
@@ -2182,46 +2077,18 @@ const sendMedicationReminderNotification = async ({
bcc: uniqueRecipients, bcc: uniqueRecipients,
subject: copy.subject, subject: copy.subject,
text: lines.join('\n'), text: lines.join('\n'),
attachments, ...await buildEmailLayout({
html: ` bird: reminder,
<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;"> eyebrow: copy.eyebrow,
<div style="max-width: 680px; margin: 0 auto 18px;"> headline: copy.headline,
<img src="${trackPatternDataUrl}" alt="" style="display: block; width: 100%; max-width: 680px; height: auto; border-radius: 26px;" /> preheader: copy.intro,
</div> contentHtml: `<p style="margin:0 0 16px;font-size:16px;">${escapeHtml(copy.intro)}</p>
<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);"> <p>${escapeHtml(copy.body)}</p>
<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);"> <p><strong>${escapeHtml(reminder.name)}</strong> · ${escapeHtml(reminder.species)}</p>
${ <p>${escapeHtml(copy.detailLabel)}</p>
logoAttachment ${reminder.medication_notes ? `<p><strong>Medication notes:</strong> ${escapeHtml(reminder.medication_notes)}</p>` : ''}`,
? '<img src="cid:flockpal-logo" alt="FlockPal" style="display: block; width: 180px; max-width: 72%; height: auto;" />' action: { url: frontendBaseUrl, label: 'Open FlockPal' },
: '<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>
`,
}); });
return { delivered: true }; return { delivered: true };
@@ -2277,9 +2144,7 @@ export const sendWeightReminderNotification = async (reminders: WeightReminder[]
const result = await mailTransport.sendMail({ const result = await mailTransport.sendMail({
from: smtpFromName ? `"${smtpFromName}" <${smtpFromEmail}>` : smtpFromEmail, from: smtpFromName ? `"${smtpFromName}" <${smtpFromEmail}>` : smtpFromEmail,
to: first.recipient, to: first.recipient,
subject: `Weight reminders for ${first.workspace_name}`, ...await buildWeightReminderEmail(reminders, frontendBaseUrl),
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>`,
}); });
return result.accepted.length > 0; 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'; import { runWeightReminders, type WeightReminder } from './weightReminders.js';
const bird = (id: string, workspace = 1, recipient = 'owner@example.com'): WeightReminder => ({ 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_id: id, workspace_id: workspace, recipient,
bird_name: id, workspace_name: `Flock ${workspace}`, activity_at: '2026-09-01T12:00:00Z', 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; bird_id: string;
workspace_id: number; workspace_id: number;
bird_name: string; bird_name: string;
species: string;
photo_data_url: string | null;
photo_object_key: string | null;
workspace_name: string; workspace_name: string;
activity_at: string; activity_at: string;
recipient: string; recipient: string;
@@ -32,9 +35,10 @@ export const listDueWeightReminders = async () => {
AND role IN ('owner', 'assistant', 'caregiver') AND role IN ('owner', 'assistant', 'caregiver')
) )
SELECT activity.bird_id, activity.workspace_id, activity.bird_name, activity.workspace_name, 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 FROM activity
JOIN recipients USING (workspace_id) JOIN recipients USING (workspace_id)
JOIN birds ON birds.id = activity.bird_id
WHERE activity_at <= CURRENT_TIMESTAMP - INTERVAL '48 hours' WHERE activity_at <= CURRENT_TIMESTAMP - INTERVAL '48 hours'
AND recipients.recipient <> '' AND recipients.recipient <> ''
AND NOT EXISTS ( AND NOT EXISTS (
@@ -1,6 +1,7 @@
// Run only against disposable local Postgres, Redis, and Mailpit services. // Run only against disposable local Postgres, Redis, and Mailpit services.
// See docs/WEIGHT_REMINDER_TESTING.md for the isolated Compose setup. // See docs/WEIGHT_REMINDER_TESTING.md for the isolated Compose setup.
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { once } from 'node:events'; 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]); 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]); 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(); const due = await listDueWeightReminders();
assert.equal(due.length, 10); 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'])); 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.ok(!body.Text.includes('Boundary'));
} }
assert.equal(body.To.length, 1); 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); assert.equal((await weightReminderQueue.getJobSchedulers()).length, 0);
await poll(async () => Boolean(await weightReminderQueue.getJob(`weight-reminders-${getReminderDate()}`)), 'startup daily scheduler'); await poll(async () => Boolean(await weightReminderQueue.getJob(`weight-reminders-${getReminderDate()}`)), 'startup daily scheduler');
+1 -1
View File
@@ -28,7 +28,7 @@ these local services. It does not use the application's configured SMTP account.
Coverage includes repeatable schema initialization, overdue and recent weights, Coverage includes repeatable schema initialization, overdue and recent weights,
birds without weights, memorial exclusions, accepted care roles, concurrent birds without weights, memorial exclusions, accepted care roles, concurrent
claims, one daily job across two workers, removal of the old five-minute schedule, grouped emails, HTML escaping, flock claims, one daily job across two workers, removal of the old five-minute schedule, grouped emails, embedded branding and portraits, HTML escaping, flock
isolation, daily repeats, fresh weight resets, SMTP failures and recovery, isolation, daily repeats, fresh weight resets, SMTP failures and recovery,
worker shutdown, and disabling the scheduler. Repeat timing is tested by aging worker shutdown, and disabling the scheduler. Repeat timing is tested by aging
delivery timestamps rather than waiting two days. delivery timestamps rather than waiting two days.
Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB