feat: add Cloudflare email worker and template updates
Worker (worker/) renders and sends the plan email via Resend, with the matching email-template.html and a VITE_WORKER_URL example env. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1
.env.example
Normal file
1
.env.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_WORKER_URL=https://planb-email.janwellmann.workers.dev/submit
|
||||||
@@ -10,63 +10,65 @@
|
|||||||
img { -ms-interpolation-mode: bicubic; border: 0; outline: none; text-decoration: none; }
|
img { -ms-interpolation-mode: bicubic; border: 0; outline: none; text-decoration: none; }
|
||||||
body { margin: 0; padding: 0; width: 100% !important; background: #FAFAFA; }
|
body { margin: 0; padding: 0; width: 100% !important; background: #FAFAFA; }
|
||||||
.body { background: #FAFAFA; color: #5A5A54; font-family: Arial, Helvetica, sans-serif; }
|
.body { background: #FAFAFA; color: #5A5A54; font-family: Arial, Helvetica, sans-serif; }
|
||||||
.wrap { width: 100%; max-width: 640px; margin: 0 auto; }
|
.wrap { width: 100%; max-width: 680px; margin: 0 auto; }
|
||||||
.panel { background: #FAFAFA; border: 1px solid #E4E4E0; border-radius: 16px; box-shadow: 0 7px 11px rgba(0,0,0,0.12); overflow: hidden; }
|
.panel { background: #FAFAFA; border: 1px solid #E4E4E0; overflow: hidden; }
|
||||||
.header { background: #F0F0F0; border-bottom: 1px solid #E4E4E0; padding: 24px; }
|
.brand { font-family: Georgia, 'Times New Roman', serif; font-size: 42px; line-height: 1; letter-spacing: .04em; color: #1A1A18; }
|
||||||
.brand { font-family: Georgia, 'Times New Roman', serif; font-size: 25px; line-height: 1; color: #1A1A18; }
|
.brand-b { color: #5A9A78; }
|
||||||
.kicker { margin-top: 14px; font-size: 10px; letter-spacing: 2px; text-transform: uppercase; color: #4A8A68; font-weight: 700; }
|
.eyebrow { font-size: 11px; line-height: 1.4; letter-spacing: 3px; text-transform: uppercase; color: #5A9A78; font-weight: 700; }
|
||||||
.title { margin: 8px 0 0; font-size: 30px; line-height: 1.08; letter-spacing: 1px; text-transform: uppercase; color: #3A3A34; font-weight: 800; }
|
.title { margin: 14px 0 0; font-size: 28px; line-height: 1.14; letter-spacing: 2px; text-transform: uppercase; color: #1A1A18; font-weight: 800; }
|
||||||
.copy { font-size: 15px; line-height: 1.7; color: #5A5A54; }
|
.copy { font-size: 15px; line-height: 1.72; color: #5A5A54; }
|
||||||
.sage { background: #E5F0E0; border: 1px solid #C7DDCE; border-radius: 10px; padding: 16px; }
|
.lead { font-size: 16px; line-height: 1.7; color: #3A3A34; }
|
||||||
.metric { background: #F0F0F0; border: 1px solid #E4E4E0; border-radius: 10px; padding: 14px; }
|
.surface { background: #F2F2F0; border: 1px solid #E4E4E0; }
|
||||||
.label { font-size: 10px; letter-spacing: 1.6px; text-transform: uppercase; color: #8A8A84; font-weight: 700; }
|
.accent { background: #E8F2EC; border-left: 4px solid #5A9A78; }
|
||||||
.value { margin-top: 5px; font-size: 18px; line-height: 1.25; color: #1A1A18; font-weight: 700; }
|
.label { font-size: 10px; line-height: 1.4; letter-spacing: 2px; text-transform: uppercase; color: #8A8A84; font-weight: 700; }
|
||||||
.button { display: inline-block; background: #2A3010; color: #F4ECD8 !important; text-decoration: none; padding: 15px 22px; border-radius: 2px; font-size: 13px; letter-spacing: 2px; text-transform: uppercase; }
|
.value { margin-top: 7px; font-size: 20px; line-height: 1.2; color: #1A1A18; font-weight: 800; }
|
||||||
.section-title { font-size: 16px; color: #1A1A18; font-weight: 800; letter-spacing: .3px; }
|
.section-title { font-size: 18px; line-height: 1.25; color: #1A1A18; font-weight: 800; letter-spacing: .4px; }
|
||||||
.item { border-top: 1px solid #E4E4E0; padding-top: 14px; margin-top: 14px; }
|
.rule { border-top: 1px solid #E4E4E0; }
|
||||||
|
.button { display: inline-block; background: #1A1A18; color: #FAFAFA !important; text-decoration: none; padding: 16px 24px; border-radius: 0; font-size: 12px; font-weight: 700; letter-spacing: 2.4px; text-transform: uppercase; }
|
||||||
.footer { color: #8A8A84; font-size: 12px; line-height: 1.7; }
|
.footer { color: #8A8A84; font-size: 12px; line-height: 1.7; }
|
||||||
@media screen and (max-width: 680px) {
|
@media screen and (max-width: 680px) {
|
||||||
.pad { padding-left: 16px !important; padding-right: 16px !important; }
|
.pad { padding-left: 18px !important; padding-right: 18px !important; }
|
||||||
.title { font-size: 24px !important; }
|
.brand { font-size: 34px !important; }
|
||||||
|
.title { font-size: 23px !important; }
|
||||||
.stack { display: block !important; width: 100% !important; }
|
.stack { display: block !important; width: 100% !important; }
|
||||||
.stack + .stack { margin-top: 10px !important; }
|
.stack-pad { padding-left: 0 !important; padding-right: 0 !important; padding-top: 10px !important; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<table role="presentation" width="100%" class="body" cellpadding="0" cellspacing="0">
|
<table role="presentation" width="100%" class="body" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" style="padding: 28px 12px;">
|
<td align="center" style="padding: 30px 12px;">
|
||||||
<table role="presentation" class="wrap" cellpadding="0" cellspacing="0">
|
<table role="presentation" class="wrap" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="panel">
|
<td class="panel">
|
||||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="header pad">
|
<td class="pad" style="padding: 34px 32px 28px; background: #FAFAFA; border-bottom: 1px solid #E4E4E0;">
|
||||||
<div class="brand">{{brand}}</div>
|
<div class="brand">Plan<span class="brand-b">-B</span></div>
|
||||||
<div class="kicker">Personal Preparedness Plan</div>
|
<div class="eyebrow" style="margin-top: 22px;">{{kicker}}</div>
|
||||||
<h1 class="title">{{risk_level}} Readiness Report</h1>
|
<h1 class="title">{{risk_level}} Readiness Report</h1>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="pad" style="padding: 24px;">
|
<td class="pad" style="padding: 30px 32px;">
|
||||||
<p class="copy" style="margin: 0 0 18px;">Hi {{first_name}}, your assessment is complete. Below is the compact version of your plan, built from your household details, location, priorities, and current supply buffer.</p>
|
<p class="lead" style="margin: 0 0 22px;">Hi {{first_name}}, your assessment is complete. Here is the compact field version of your preparedness plan, built from your household details, location, priorities, and current supply buffer.</p>
|
||||||
|
|
||||||
<div class="sage">
|
<div class="accent" style="padding: 18px 18px 18px 20px;">
|
||||||
<div class="label">Assessment Summary</div>
|
<div class="label">Assessment Summary</div>
|
||||||
<p class="copy" style="margin: 8px 0 0;">{{narrative_summary}}</p>
|
<p class="copy" style="margin: 9px 0 0;">{{narrative_summary}}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-top: 16px;">
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-top: 18px;">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="stack" width="50%" style="padding-right: 6px;">
|
<td class="stack" width="50%" style="padding-right: 6px;">
|
||||||
<div class="metric">
|
<div class="surface" style="padding: 16px;">
|
||||||
<div class="label">Risk Score</div>
|
<div class="label">Risk Score</div>
|
||||||
<div class="value">{{risk_score}}</div>
|
<div class="value">{{risk_score}}</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="stack" width="50%" style="padding-left: 6px;">
|
<td class="stack stack-pad" width="50%" style="padding-left: 6px;">
|
||||||
<div class="metric">
|
<div class="surface" style="padding: 16px;">
|
||||||
<div class="label">Priority Scenario</div>
|
<div class="label">Priority Scenario</div>
|
||||||
<div class="value">{{primary_scenario}}</div>
|
<div class="value">{{primary_scenario}}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -74,32 +76,32 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<div style="margin-top: 24px;">
|
<div style="margin-top: 30px;">
|
||||||
<div class="section-title">Immediate Actions</div>
|
<div class="section-title">Immediate Actions</div>
|
||||||
<div class="item">
|
<div class="rule" style="margin-top: 14px; padding-top: 16px;">
|
||||||
<div class="label">First 7 Days</div>
|
<div class="label">First 7 Days</div>
|
||||||
<p class="copy" style="margin: 6px 0 0;">{{action_week_1}}</p>
|
<p class="copy" style="margin: 7px 0 0;">{{action_week_1}}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="item">
|
<div class="rule" style="margin-top: 16px; padding-top: 16px;">
|
||||||
<div class="label">First 30 Days</div>
|
<div class="label">First 30 Days</div>
|
||||||
<p class="copy" style="margin: 6px 0 0;">{{action_month_1}}</p>
|
<p class="copy" style="margin: 7px 0 0;">{{action_month_1}}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-top: 24px;">
|
<div style="margin-top: 30px;">
|
||||||
<div class="section-title">Recommended Supplies</div>
|
<div class="section-title">Recommended Supplies</div>
|
||||||
<div class="item">
|
<div class="rule" style="margin-top: 14px; padding-top: 16px;">
|
||||||
<p class="copy" style="margin: 0;">{{recommendations}}</p>
|
<p class="copy" style="margin: 0;">{{recommendations}}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-top: 26px; text-align: center;">
|
<div style="margin-top: 32px;">
|
||||||
<a class="button" href="{{plan_url}}">Open Full Plan</a>
|
<a class="button" href="{{plan_url}}">Open Full Plan</a>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="pad" style="padding: 18px 24px 24px; background: #F0F0F0; border-top: 1px solid #E4E4E0;">
|
<td class="pad" style="padding: 20px 32px 26px; background: #F2F2F0; border-top: 1px solid #E4E4E0;">
|
||||||
<p class="footer" style="margin: 0;">You are receiving this because you requested your {{brand}} preparedness plan. Your data is never sold. <a href="{{unsubscribe_url}}" style="color: #4A8A68;">Unsubscribe</a></p>
|
<p class="footer" style="margin: 0;">You are receiving this because you requested your {{brand}} preparedness plan. Your data is never sold. <a href="{{unsubscribe_url}}" style="color: #4A8A68;">Unsubscribe</a></p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
198
worker/src/index.js
Normal file
198
worker/src/index.js
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import { renderEmailTemplate } from "./template.js";
|
||||||
|
|
||||||
|
const JSON_HEADERS = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type, Accept",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request, env) {
|
||||||
|
if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: JSON_HEADERS });
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (request.method === "POST" && url.pathname === "/submit") return submit(request, env);
|
||||||
|
if (request.method === "POST" && url.pathname === "/narrative") return narrative(request);
|
||||||
|
if (request.method === "GET" && url.pathname === "/health") return json({ ok: true });
|
||||||
|
|
||||||
|
return json({ ok: false, error: "Not found" }, 404);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function submit(request, env) {
|
||||||
|
const payload = await readJson(request);
|
||||||
|
const email = String(payload.email || "").trim();
|
||||||
|
if (!email || !email.includes("@")) return json({ ok: false, error: "Valid email is required" }, 400);
|
||||||
|
|
||||||
|
const model = buildEmailModel(payload, env);
|
||||||
|
const html = renderEmailTemplate(model);
|
||||||
|
const text = renderTextEmail(model);
|
||||||
|
|
||||||
|
if (!env.RESEND_API_KEY || !env.FROM_EMAIL) {
|
||||||
|
return json({
|
||||||
|
ok: true,
|
||||||
|
dry_run: true,
|
||||||
|
message: "Email rendered but not sent. Configure RESEND_API_KEY and FROM_EMAIL Worker secrets.",
|
||||||
|
preview: model,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await sendWithResend({
|
||||||
|
apiKey: env.RESEND_API_KEY,
|
||||||
|
from: env.FROM_EMAIL,
|
||||||
|
to: email,
|
||||||
|
replyTo: env.REPLY_TO_EMAIL || env.FROM_EMAIL,
|
||||||
|
subject: `${model.brand} - ${model.risk_level} readiness report`,
|
||||||
|
html,
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({ ok: true, provider: "resend", id: result.id || null });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function narrative(request) {
|
||||||
|
const payload = await readJson(request);
|
||||||
|
return json({ narrative: buildNarrative(payload) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEmailModel(payload, env) {
|
||||||
|
const brand = env.BRAND_NAME || "Plan-B";
|
||||||
|
const riskLevel = payload.risk_level || riskLevelFromScore(payload.risk_score);
|
||||||
|
const scenarios = splitList(payload.scenarios || payload.priorities);
|
||||||
|
const primaryScenario = scenarios[0] || "Personal preparedness";
|
||||||
|
|
||||||
|
return {
|
||||||
|
brand,
|
||||||
|
kicker: "Personal Preparedness Plan",
|
||||||
|
first_name: payload.first_name || "there",
|
||||||
|
risk_level: riskLevel,
|
||||||
|
risk_score: payload.risk_score ?? "Not scored",
|
||||||
|
primary_scenario: primaryScenario,
|
||||||
|
narrative_summary: buildSummary(payload, riskLevel, primaryScenario),
|
||||||
|
action_week_1: buildWeekOneAction(payload),
|
||||||
|
action_month_1: buildMonthOneAction(payload),
|
||||||
|
recommendations: buildRecommendations(payload),
|
||||||
|
plan_url: env.SITE_URL || "https://planb.example",
|
||||||
|
unsubscribe_url: env.UNSUBSCRIBE_URL || env.SITE_URL || "https://planb.example",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSummary(payload, riskLevel, primaryScenario) {
|
||||||
|
const household = payload.household_size ? ` for a household size of ${payload.household_size}` : "";
|
||||||
|
const city = payload.city ? ` in ${payload.city}` : "";
|
||||||
|
return `Your current profile is ${riskLevel}${household}${city}. The top planning focus is ${primaryScenario}, with water, food, energy, and medical continuity reviewed against your current supply buffer.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWeekOneAction(payload) {
|
||||||
|
const gaps = [];
|
||||||
|
if (payload.water_access === "none" || payload.water_access === "minimal") gaps.push("secure a drinking-water buffer and filtration path");
|
||||||
|
if (payload.food_reserves === "none" || payload.food_reserves === "week") gaps.push("build a two-week dry food reserve");
|
||||||
|
if (payload.medical_needs === "high") gaps.push("separate critical medication and first-aid reserves");
|
||||||
|
return sentence(gaps, "Audit your current supplies, buy the missing basics, and document a 7-day household continuity checklist.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMonthOneAction(payload) {
|
||||||
|
const budget = payload.budget_eur || 1500;
|
||||||
|
return `Use the first month to turn the initial buffer into a resilient system: water redundancy, shelf-stable calories, backup power, sanitation, and a prioritized supply budget of about EUR ${budget}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRecommendations(payload) {
|
||||||
|
const items = [
|
||||||
|
"water storage or filtration sized to the household",
|
||||||
|
"shelf-stable calorie base with simple rotation",
|
||||||
|
"backup power for phone, light, and critical devices",
|
||||||
|
"first-aid, ORS, hygiene, and sanitation supplies",
|
||||||
|
];
|
||||||
|
if (payload.protein_access || payload.protein_detail) items.push("protein strategy based on your local access notes");
|
||||||
|
return items.join("; ") + ".";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNarrative(payload) {
|
||||||
|
const riskLevel = payload.risk_level || riskLevelFromScore(payload.risk_score);
|
||||||
|
const primaryScenario = splitList(payload.scenarios || payload.priorities)[0] || "preparedness";
|
||||||
|
const isGerman = payload.language_used === "de" || payload.preferred_language === "de";
|
||||||
|
|
||||||
|
if (isGerman) {
|
||||||
|
return `**DEINE LAGE**\n\nDein aktuelles Profil liegt bei ${riskLevel}. Der wichtigste Fokus ist ${primaryScenario}.\n\n**NÄCHSTE SCHRITTE**\n\n• Wasserpuffer und Filter zuerst sichern\n• Lebensmittelvorrat auf mindestens zwei Wochen bringen\n• Energie, Medizin und Hygiene als zweite Redundanz planen`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `**YOUR SITUATION**\n\nYour current profile is ${riskLevel}. The most important focus is ${primaryScenario}.\n\n**NEXT STEPS**\n\n• Secure water buffer and filtration first\n• Build food reserves toward at least two weeks\n• Add redundancy for energy, medical needs, and sanitation`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sentence(items, fallback) {
|
||||||
|
if (!items.length) return fallback;
|
||||||
|
return `This week, ${items.join("; ")}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitList(value) {
|
||||||
|
if (Array.isArray(value)) return value.filter(Boolean);
|
||||||
|
return String(value || "").split(",").map((item) => item.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function riskLevelFromScore(score) {
|
||||||
|
const n = Number(score || 0);
|
||||||
|
if (n >= 8) return "CRITICAL";
|
||||||
|
if (n >= 5) return "HIGH RISK";
|
||||||
|
if (n >= 3) return "MODERATE";
|
||||||
|
return "PREPARED";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTextEmail(model) {
|
||||||
|
return [
|
||||||
|
`${model.brand} - ${model.risk_level} Readiness Report`,
|
||||||
|
"",
|
||||||
|
`Hi ${model.first_name},`,
|
||||||
|
"",
|
||||||
|
model.narrative_summary,
|
||||||
|
"",
|
||||||
|
`Risk score: ${model.risk_score}`,
|
||||||
|
`Priority scenario: ${model.primary_scenario}`,
|
||||||
|
"",
|
||||||
|
"First 7 days:",
|
||||||
|
model.action_week_1,
|
||||||
|
"",
|
||||||
|
"First 30 days:",
|
||||||
|
model.action_month_1,
|
||||||
|
"",
|
||||||
|
"Recommended supplies:",
|
||||||
|
model.recommendations,
|
||||||
|
"",
|
||||||
|
`Full plan: ${model.plan_url}`,
|
||||||
|
`Unsubscribe: ${model.unsubscribe_url}`,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendWithResend({ apiKey, from, to, replyTo, subject, html, text }) {
|
||||||
|
const response = await fetch("https://api.resend.com/emails", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
reply_to: replyTo,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
text,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) throw new Error(`Resend failed: ${response.status} ${JSON.stringify(body)}`);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJson(request) {
|
||||||
|
try {
|
||||||
|
return await request.json();
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function json(body, status = 200) {
|
||||||
|
return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS });
|
||||||
|
}
|
||||||
129
worker/src/template.js
Normal file
129
worker/src/template.js
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
const TEMPLATE = `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{brand}} Preparedness Plan</title>
|
||||||
|
<style>
|
||||||
|
body, table, td, p, a { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
|
||||||
|
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; border-collapse: collapse; }
|
||||||
|
img { -ms-interpolation-mode: bicubic; border: 0; outline: none; text-decoration: none; }
|
||||||
|
body { margin: 0; padding: 0; width: 100% !important; background: #FAFAFA; }
|
||||||
|
.body { background: #FAFAFA; color: #5A5A54; font-family: Arial, Helvetica, sans-serif; }
|
||||||
|
.wrap { width: 100%; max-width: 680px; margin: 0 auto; }
|
||||||
|
.panel { background: #FAFAFA; border: 1px solid #E4E4E0; overflow: hidden; }
|
||||||
|
.brand { font-family: Georgia, 'Times New Roman', serif; font-size: 42px; line-height: 1; letter-spacing: .04em; color: #1A1A18; }
|
||||||
|
.brand-b { color: #5A9A78; }
|
||||||
|
.eyebrow { font-size: 11px; line-height: 1.4; letter-spacing: 3px; text-transform: uppercase; color: #5A9A78; font-weight: 700; }
|
||||||
|
.title { margin: 14px 0 0; font-size: 28px; line-height: 1.14; letter-spacing: 2px; text-transform: uppercase; color: #1A1A18; font-weight: 800; }
|
||||||
|
.copy { font-size: 15px; line-height: 1.72; color: #5A5A54; }
|
||||||
|
.lead { font-size: 16px; line-height: 1.7; color: #3A3A34; }
|
||||||
|
.surface { background: #F2F2F0; border: 1px solid #E4E4E0; }
|
||||||
|
.accent { background: #E8F2EC; border-left: 4px solid #5A9A78; }
|
||||||
|
.label { font-size: 10px; line-height: 1.4; letter-spacing: 2px; text-transform: uppercase; color: #8A8A84; font-weight: 700; }
|
||||||
|
.value { margin-top: 7px; font-size: 20px; line-height: 1.2; color: #1A1A18; font-weight: 800; }
|
||||||
|
.section-title { font-size: 18px; line-height: 1.25; color: #1A1A18; font-weight: 800; letter-spacing: .4px; }
|
||||||
|
.rule { border-top: 1px solid #E4E4E0; }
|
||||||
|
.button { display: inline-block; background: #1A1A18; color: #FAFAFA !important; text-decoration: none; padding: 16px 24px; border-radius: 0; font-size: 12px; font-weight: 700; letter-spacing: 2.4px; text-transform: uppercase; }
|
||||||
|
.footer { color: #8A8A84; font-size: 12px; line-height: 1.7; }
|
||||||
|
@media screen and (max-width: 680px) {
|
||||||
|
.pad { padding-left: 18px !important; padding-right: 18px !important; }
|
||||||
|
.brand { font-size: 34px !important; }
|
||||||
|
.title { font-size: 23px !important; }
|
||||||
|
.stack { display: block !important; width: 100% !important; }
|
||||||
|
.stack-pad { padding-left: 0 !important; padding-right: 0 !important; padding-top: 10px !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<table role="presentation" width="100%" class="body" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 30px 12px;">
|
||||||
|
<table role="presentation" class="wrap" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td class="panel">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td class="pad" style="padding: 34px 32px 28px; background: #FAFAFA; border-bottom: 1px solid #E4E4E0;">
|
||||||
|
<div class="brand">Plan<span class="brand-b">-B</span></div>
|
||||||
|
<div class="eyebrow" style="margin-top: 22px;">{{kicker}}</div>
|
||||||
|
<h1 class="title">{{risk_level}} Readiness Report</h1>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="pad" style="padding: 30px 32px;">
|
||||||
|
<p class="lead" style="margin: 0 0 22px;">Hi {{first_name}}, your assessment is complete. Here is the compact field version of your preparedness plan, built from your household details, location, priorities, and current supply buffer.</p>
|
||||||
|
|
||||||
|
<div class="accent" style="padding: 18px 18px 18px 20px;">
|
||||||
|
<div class="label">Assessment Summary</div>
|
||||||
|
<p class="copy" style="margin: 9px 0 0;">{{narrative_summary}}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-top: 18px;">
|
||||||
|
<tr>
|
||||||
|
<td class="stack" width="50%" style="padding-right: 6px;">
|
||||||
|
<div class="surface" style="padding: 16px;">
|
||||||
|
<div class="label">Risk Score</div>
|
||||||
|
<div class="value">{{risk_score}}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="stack stack-pad" width="50%" style="padding-left: 6px;">
|
||||||
|
<div class="surface" style="padding: 16px;">
|
||||||
|
<div class="label">Priority Scenario</div>
|
||||||
|
<div class="value">{{primary_scenario}}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div style="margin-top: 30px;">
|
||||||
|
<div class="section-title">Immediate Actions</div>
|
||||||
|
<div class="rule" style="margin-top: 14px; padding-top: 16px;">
|
||||||
|
<div class="label">First 7 Days</div>
|
||||||
|
<p class="copy" style="margin: 7px 0 0;">{{action_week_1}}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rule" style="margin-top: 16px; padding-top: 16px;">
|
||||||
|
<div class="label">First 30 Days</div>
|
||||||
|
<p class="copy" style="margin: 7px 0 0;">{{action_month_1}}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 30px;">
|
||||||
|
<div class="section-title">Recommended Supplies</div>
|
||||||
|
<div class="rule" style="margin-top: 14px; padding-top: 16px;">
|
||||||
|
<p class="copy" style="margin: 0;">{{recommendations}}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 32px;">
|
||||||
|
<a class="button" href="{{plan_url}}">Open Full Plan</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="pad" style="padding: 20px 32px 26px; background: #F2F2F0; border-top: 1px solid #E4E4E0;">
|
||||||
|
<p class="footer" style="margin: 0;">You are receiving this because you requested your {{brand}} preparedness plan. Your data is never sold. <a href="{{unsubscribe_url}}" style="color: #4A8A68;">Unsubscribe</a></p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? "")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderEmailTemplate(data) {
|
||||||
|
return TEMPLATE.replace(/\{\{([a-zA-Z0-9_]+)\}\}/g, (_, key) => escapeHtml(data[key]));
|
||||||
|
}
|
||||||
14
worker/wrangler.toml
Normal file
14
worker/wrangler.toml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
name = "planb-email"
|
||||||
|
main = "src/index.js"
|
||||||
|
compatibility_date = "2026-06-03"
|
||||||
|
|
||||||
|
[vars]
|
||||||
|
BRAND_NAME = "Plan-B"
|
||||||
|
SITE_URL = "https://plan-b.kammergut.example"
|
||||||
|
UNSUBSCRIBE_URL = "https://plan-b.kammergut.example/unsubscribe"
|
||||||
|
|
||||||
|
# Set these as Worker secrets before deploying:
|
||||||
|
# npx wrangler secret put RESEND_API_KEY --config worker/wrangler.toml
|
||||||
|
# npx wrangler secret put FROM_EMAIL --config worker/wrangler.toml
|
||||||
|
# Optional:
|
||||||
|
# npx wrangler secret put REPLY_TO_EMAIL --config worker/wrangler.toml
|
||||||
Reference in New Issue
Block a user