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:
Dorian
2026-06-16 14:53:35 +01:00
parent b0b4d41571
commit 7e4da32c0c
5 changed files with 382 additions and 38 deletions

1
.env.example Normal file
View File

@@ -0,0 +1 @@
VITE_WORKER_URL=https://planb-email.janwellmann.workers.dev/submit

View File

@@ -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
View 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
View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
export function renderEmailTemplate(data) {
return TEMPLATE.replace(/\{\{([a-zA-Z0-9_]+)\}\}/g, (_, key) => escapeHtml(data[key]));
}

14
worker/wrangler.toml Normal file
View 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