Updated email template
This commit is contained in:
+56
-38
@@ -1,5 +1,5 @@
|
|||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { readFileSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import dotenv from 'dotenv';
|
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 rateLimit from 'express-rate-limit';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import morgan from 'morgan';
|
import morgan from 'morgan';
|
||||||
import nodemailer from 'nodemailer';
|
import nodemailer, { type SendMailOptions } from 'nodemailer';
|
||||||
import Stripe from 'stripe';
|
import Stripe from 'stripe';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@@ -838,25 +838,34 @@ 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 getFlockPalLogoDataUrl = () =>
|
const getFlockPalLogoSvg = () =>
|
||||||
'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';
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><defs><linearGradient id="featherFill" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#cb3a35"/><stop offset="30%" stop-color="#f0b63f"/><stop offset="58%" stop-color="#238a5a"/><stop offset="100%" stop-color="#2769b3"/></linearGradient></defs><path d="M50.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" fill="url(#featherFill)"/><path d="M18 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" fill="none" stroke="#fff8ef" stroke-linecap="round" stroke-width="2.6"/><path d="M18 56c8.5-3.4 14.2-9.8 18.1-17.8" fill="none" stroke="#63562d" stroke-linecap="round" stroke-width="2.2"/></svg>`;
|
||||||
|
|
||||||
let defaultBirdPhotoDataUrl: string | null = null;
|
const parseDataImage = (dataUrl: string) => {
|
||||||
|
const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/.exec(dataUrl);
|
||||||
const getDefaultBirdPhotoDataUrl = () => {
|
if (!match) {
|
||||||
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);
|
|
||||||
return null;
|
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 ({
|
const sendRescueStatusNotification = async ({
|
||||||
@@ -1086,7 +1095,6 @@ const buildBirdMilestoneReminderCopy = (reminder: BirdMilestoneReminderCandidate
|
|||||||
: `${reminder.name} has a Hatch Day today.`,
|
: `${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.',
|
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',
|
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
|
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.`
|
? `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.`,
|
: `${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',
|
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 copy = buildBirdMilestoneReminderCopy(reminder);
|
||||||
const yearCount = getMilestoneYearCount(reminder);
|
const attachments: NonNullable<SendMailOptions['attachments']> = [
|
||||||
const logoDataUrl = getFlockPalLogoDataUrl();
|
{
|
||||||
const birdPhotoSrc = reminder.photo_data_url || getDefaultBirdPhotoDataUrl();
|
filename: 'flockpal-logo.svg',
|
||||||
const birdPhotoHtml = birdPhotoSrc
|
content: getFlockPalLogoSvg(),
|
||||||
? `<img src="${escapeHtml(birdPhotoSrc)}" 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);" />`
|
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
|
||||||
|
? `<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>`;
|
: `<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 milestoneCountLine = `${copy.eyebrow}: ${copy.milestoneLabel}`;
|
|
||||||
const lines = [
|
const lines = [
|
||||||
copy.headline,
|
copy.headline,
|
||||||
'',
|
'',
|
||||||
@@ -1134,8 +1160,7 @@ const sendBirdMilestoneReminderNotification = async ({
|
|||||||
'',
|
'',
|
||||||
`Bird: ${reminder.name}`,
|
`Bird: ${reminder.name}`,
|
||||||
`Species: ${reminder.species}`,
|
`Species: ${reminder.species}`,
|
||||||
`Flock: ${reminder.workspace_name}`,
|
`${copy.eventName}: ${copy.milestoneLabel}`,
|
||||||
milestoneCountLine,
|
|
||||||
'',
|
'',
|
||||||
`Open FlockPal: ${frontendBaseUrl}`,
|
`Open FlockPal: ${frontendBaseUrl}`,
|
||||||
];
|
];
|
||||||
@@ -1151,11 +1176,12 @@ const sendBirdMilestoneReminderNotification = async ({
|
|||||||
bcc: uniqueRecipients,
|
bcc: uniqueRecipients,
|
||||||
subject: copy.subject,
|
subject: copy.subject,
|
||||||
text: lines.join('\n'),
|
text: lines.join('\n'),
|
||||||
|
attachments,
|
||||||
html: `
|
html: `
|
||||||
<div style="margin: 0; padding: 28px; background: #f4efe4; font-family: Arial, sans-serif; color: #263331; line-height: 1.6;">
|
<div style="margin: 0; padding: 28px; background: #f4efe4; font-family: Arial, sans-serif; color: #263331; line-height: 1.6;">
|
||||||
<div style="max-width: 680px; margin: 0 auto; overflow: hidden; border-radius: 30px; background: #fffdf7; border: 1px solid #eadfcd; box-shadow: 0 24px 60px rgba(38, 51, 49, 0.16);">
|
<div style="max-width: 680px; margin: 0 auto; overflow: hidden; border-radius: 30px; background: #fffdf7; border: 1px solid #eadfcd; box-shadow: 0 24px 60px rgba(38, 51, 49, 0.16);">
|
||||||
<div style="padding: 24px 28px; background: linear-gradient(135deg, #fff8ef, #eaf7ef);">
|
<div style="padding: 24px 28px; background: linear-gradient(135deg, #fff8ef, #eaf7ef);">
|
||||||
<img src="${logoDataUrl}" alt="FlockPal" style="display: block; width: 58px; height: 58px;" />
|
<img src="cid:flockpal-logo" alt="FlockPal" style="display: block; width: 58px; height: 58px;" />
|
||||||
</div>
|
</div>
|
||||||
<div style="padding: 30px 28px;">
|
<div style="padding: 30px 28px;">
|
||||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse: collapse;">
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse: collapse;">
|
||||||
@@ -1164,20 +1190,12 @@ const sendBirdMilestoneReminderNotification = async ({
|
|||||||
${birdPhotoHtml}
|
${birdPhotoHtml}
|
||||||
</td>
|
</td>
|
||||||
<td style="vertical-align: top; padding: 0 0 20px;">
|
<td style="vertical-align: top; padding: 0 0 20px;">
|
||||||
<p style="margin: 0 0 8px; letter-spacing: 0.16em; text-transform: uppercase; color: #238a5a; font-size: 12px; font-weight: 700;">${escapeHtml(copy.eyebrow)}</p>
|
|
||||||
<h1 style="margin: 0 0 12px; color: #263331; font-size: 30px; line-height: 1.12;">${escapeHtml(copy.headline)}</h1>
|
<h1 style="margin: 0 0 12px; color: #263331; font-size: 30px; line-height: 1.12;">${escapeHtml(copy.headline)}</h1>
|
||||||
<p style="margin: 0; color: #63562d; font-size: 17px;">${escapeHtml(copy.intro)}</p>
|
<p style="margin: 0; color: #63562d; font-size: 17px;">${escapeHtml(copy.intro)}</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
<p style="margin: 4px 0 18px; font-size: 16px;">${escapeHtml(copy.body)}</p>
|
<p style="margin: 4px 0 18px; font-size: 16px;">${escapeHtml(copy.body)}</p>
|
||||||
<div style="margin: 22px 0; padding: 18px; border-radius: 20px; background: #fff8ef; border: 1px solid #eadfcd;">
|
|
||||||
<p style="margin: 0;"><strong>Bird:</strong> ${escapeHtml(reminder.name)}</p>
|
|
||||||
<p style="margin: 0;"><strong>Species:</strong> ${escapeHtml(reminder.species)}</p>
|
|
||||||
<p style="margin: 0;"><strong>Flock:</strong> ${escapeHtml(reminder.workspace_name)}</p>
|
|
||||||
<p style="margin: 0;"><strong>${escapeHtml(copy.eventName)}:</strong> ${escapeHtml(copy.milestoneLabel)}</p>
|
|
||||||
</div>
|
|
||||||
<p style="margin: 0 0 18px;">${escapeHtml(copy.signoff)}</p>
|
|
||||||
<p style="margin: 0;">
|
<p style="margin: 0;">
|
||||||
<a href="${frontendBaseUrl}" style="display: inline-block; padding: 12px 18px; border-radius: 999px; background: #238a5a; color: #ffffff; text-decoration: none; font-weight: 700;">Open FlockPal</a>
|
<a href="${frontendBaseUrl}" style="display: inline-block; padding: 12px 18px; border-radius: 999px; background: #238a5a; color: #ffffff; text-decoration: none; font-weight: 700;">Open FlockPal</a>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user