diff --git a/.env.example b/.env.example
index 44efc09..bf76da6 100644
--- a/.env.example
+++ b/.env.example
@@ -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.
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=
diff --git a/README.md b/README.md
index 8a9c7f3..4a5e872 100644
--- a/README.md
+++ b/README.md
@@ -165,7 +165,9 @@ npm run worker
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
-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
48-hour clock. Birds without weights use their profile creation time.
diff --git a/backend/assets/email-background.png b/backend/assets/email-background.png
new file mode 100644
index 0000000..f3b74ae
Binary files /dev/null and b/backend/assets/email-background.png differ
diff --git a/backend/scripts/generate-email-background.mjs b/backend/scripts/generate-email-background.mjs
new file mode 100644
index 0000000..4642d42
--- /dev/null
+++ b/backend/scripts/generate-email-background.mjs
@@ -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 = `${[[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)=>``).join('')}`;
+const 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)));
diff --git a/backend/src/app.ts b/backend/src/app.ts
index ce01685..959d9d2 100644
--- a/backend/src/app.ts
+++ b/backend/src/app.ts
@@ -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: `
-
${escapeHtml(sourceWorkspaceName)} wants to transfer ${escapeHtml(birdName)} to your FlockPal account.
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.
`);
+ }
+ 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: `
These birds in ${escapeHtml(flock)} haven't had a new weight entry in at least 48 hours.
${rows.join('')}
`,
+ 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],
+ };
+};
diff --git a/backend/src/reminders/weightReminders.test.ts b/backend/src/reminders/weightReminders.test.ts
index e3a73cd..f4af9ed 100644
--- a/backend/src/reminders/weightReminders.test.ts
+++ b/backend/src/reminders/weightReminders.test.ts
@@ -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',
});
diff --git a/backend/src/reminders/weightReminders.ts b/backend/src/reminders/weightReminders.ts
index da76e56..cf90f07 100644
--- a/backend/src/reminders/weightReminders.ts
+++ b/backend/src/reminders/weightReminders.ts
@@ -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 (
diff --git a/backend/test-integration/weightReminders.mjs b/backend/test-integration/weightReminders.mjs
index 13e6907..f907213 100644
--- a/backend/test-integration/weightReminders.mjs
+++ b/backend/test-integration/weightReminders.mjs
@@ -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 & ']]);
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');
diff --git a/docs/WEIGHT_REMINDER_TESTING.md b/docs/WEIGHT_REMINDER_TESTING.md
index ecbef01..c185a41 100644
--- a/docs/WEIGHT_REMINDER_TESTING.md
+++ b/docs/WEIGHT_REMINDER_TESTING.md
@@ -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,
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,
worker shutdown, and disabling the scheduler. Repeat timing is tested by aging
delivery timestamps rather than waiting two days.
diff --git a/frontend/public/email-background.png b/frontend/public/email-background.png
new file mode 100644
index 0000000..f3b74ae
Binary files /dev/null and b/frontend/public/email-background.png differ