Files
Siteforge/server/email.js
T
nmemmert 6444d6e0e3 Add Q&A answered email template to admin Email Templates panel; v1.0.11
- content.ts: add qaAnsweredEmailSubject/Body/CtaLabel/Signoff fields + defaults
- email.js: buildQuestionAnsweredEmailTemplate reads from cachedSiteContent
- AdminPage.tsx: add EmailTemplateCard entry for Q&A answered notification

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 12:55:30 -04:00

585 lines
38 KiB
JavaScript

import { Resend } from 'resend'
import { escapeHtml, buildAbsoluteUrl, splitName } from './helpers.js'
import {
DEFAULT_RESEND_FROM,
DEFAULT_SEO,
} from './config.js'
import { state } from './state.js'
// ── Address helpers ────────────────────────────────────────────────────────
export function getResendFromAddress() {
return process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM
}
export function getResendReplyToAddress() {
return process.env.RESEND_REPLY_TO ?? 'hello@versebyversewithnate.us'
}
export function getResendInboxAddress() {
return process.env.RESEND_TO ?? 'hello@versebyversewithnate.us'
}
export function getCanonicalBaseUrl() {
const configured = state.cachedSiteContent?.seo?.canonicalUrl ?? DEFAULT_SEO.canonicalUrl
if (typeof configured !== 'string' || !configured.trim()) return DEFAULT_SEO.canonicalUrl
return configured.trim()
}
export function getAddressDomain(addressValue) {
const raw = String(addressValue || '').trim()
if (!raw) return ''
const candidate = raw.includes('<') && raw.includes('>')
? raw.slice(raw.lastIndexOf('<') + 1, raw.lastIndexOf('>')).trim()
: raw
const at = candidate.lastIndexOf('@')
if (at <= 0 || at === candidate.length - 1) return ''
return candidate.slice(at + 1).toLowerCase()
}
export function logResendEmailAlignmentWarnings() {
const warnings = []
const fromAddress = getResendFromAddress()
const replyToAddress = getResendReplyToAddress()
const fromDomain = getAddressDomain(fromAddress)
const replyDomain = getAddressDomain(replyToAddress)
const hasApiKey = Boolean(process.env.RESEND_API_KEY)
if (!hasApiKey) warnings.push('RESEND_API_KEY is missing. Contact and reply emails cannot send.')
if (!fromDomain) warnings.push('RESEND_FROM is missing or malformed. Use a verified domain sender identity.')
if (fromDomain.endsWith('resend.dev')) warnings.push('RESEND_FROM uses resend.dev. Move to your own verified domain for best deliverability.')
if (fromDomain && replyDomain && fromDomain !== replyDomain) warnings.push('RESEND_FROM and RESEND_REPLY_TO use different domains. This can weaken alignment.')
if (!process.env.RESEND_WEBHOOK_TOKEN) warnings.push('RESEND_WEBHOOK_TOKEN is not set. Delivery webhooks are not authenticated.')
if (hasApiKey) warnings.push('Verify SPF, DKIM, and DMARC for the sender domain to improve inbox placement.')
if (warnings.length > 0) {
console.warn('[email-health] Resend alignment checks:')
for (const warning of warnings) {
console.warn(`[email-health] - ${warning}`)
}
}
}
// ── Send helpers ───────────────────────────────────────────────────────────
export async function sendResendEmailWithRetry({ resend, payload, context, maxAttempts = 2 }) {
let lastError = null
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
const result = await resend.emails.send(payload)
if (!result?.error) return result
lastError = result.error
if (attempt < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, 250 * attempt))
}
} catch (err) {
lastError = err
if (attempt < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, 250 * attempt))
}
}
}
throw lastError ?? new Error(`[${context}] email send failed`)
}
export async function syncContactToResend(name, email) {
if (!process.env.RESEND_API_KEY) return
const { firstName, lastName } = splitName(name)
const contactResend = new Resend(process.env.RESEND_CONTACTS_API_KEY ?? process.env.RESEND_API_KEY)
try {
const { error: contactError } = await contactResend.contacts.create({
email,
firstName,
lastName,
unsubscribed: false,
...(process.env.RESEND_SEGMENT_ID ? { segments: [{ id: process.env.RESEND_SEGMENT_ID }] } : {}),
})
if (contactError) {
const { error: updateError } = await contactResend.contacts.update({ email, firstName, lastName, unsubscribed: false })
if (updateError) console.error('[resend] contact sync error:', updateError)
}
} catch (err) {
console.error('[resend] contact sync exception:', err)
}
}
// ── Email HTML builders ────────────────────────────────────────────────────
export function buildBrandedEmailHtml({ title, eyebrow, bodyHtml, ctaLabel, ctaUrl, footerHtml }) {
const ctaBlock = ctaLabel && ctaUrl
? `<p style="margin:24px 0 0;"><a href="${escapeHtml(ctaUrl)}" target="_blank" style="display:inline-block;padding:12px 22px;background:#c9a84c;border:1px solid #e0c070;border-radius:999px;color:#111111;font-family:Georgia,serif;font-size:13px;font-weight:700;letter-spacing:0.14em;text-decoration:none;text-transform:uppercase;">${escapeHtml(ctaLabel)}</a></p>`
: ''
return (
`<div style="margin:0;padding:0;background-color:#0a0a08;font-family:Georgia,serif;">` +
`<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="background-color:#0a0a08;">` +
`<tr><td align="center" style="padding:40px 20px;">` +
`<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="max-width:580px;margin:0 auto;background-color:#0f0f0c;border:1px solid #2a2518;">` +
`<tr><td align="center" style="background-color:#0d0d0a;padding:36px 40px 28px;border-bottom:1px solid #2a2518;">` +
`<p style="margin:0 0 6px;font-family:Georgia,serif;font-size:13px;font-weight:400;color:#7a7060;letter-spacing:0.12em;text-transform:uppercase;">${escapeHtml(eyebrow ?? 'Verse by Verse with Nate')}</p>` +
`<p style="margin:0;font-family:Georgia,serif;font-size:28px;font-weight:600;color:#e0c070;line-height:1.2;">${escapeHtml(title)}</p>` +
`</td></tr>` +
`<tr><td style="padding:40px 40px 0;">` +
`<div style="font-family:Georgia,serif;font-size:15px;line-height:1.8;color:#c8c0ac;">${bodyHtml}</div>` +
`${ctaBlock}` +
`</td></tr>` +
`<tr><td style="padding:28px 40px 40px;text-align:center;">${footerHtml ?? ''}</td></tr>` +
`</table>` +
`</td></tr></table>` +
`</div>`
)
}
export function buildContactWelcomeEmailTemplate({
greetingName,
welcomeIntro,
welcomeCurrentSeries,
welcomeStartHereTitle,
welcomeStartHereSummary,
welcomeExpect1,
welcomeExpect2,
welcomeExpect3,
welcomeScripture,
welcomeScriptureRef,
welcomeSignoff,
welcomeGreetingPrefix = "Glad you're here",
welcomeSpotifyUrl,
welcomeAppleUrl,
welcomeAmazonUrl,
welcomeWebsiteUrl,
welcomeEpisodeUrl,
welcomeImageUrl,
welcomeSpotifyBtnLabel = 'Listen on Spotify',
welcomeAppleBtnLabel = 'Apple Podcasts',
welcomeStartHereLinkLabel = 'Open Start Here page',
}) {
const heading = greetingName
? `${escapeHtml(welcomeGreetingPrefix)}, ${escapeHtml(greetingName)}.`
: `${escapeHtml(welcomeGreetingPrefix)}.`
return {
text:
`Welcome to Verse by Verse with Nate!\n\n` +
`${heading}\n\n` +
`${welcomeIntro}\n\n` +
`${welcomeCurrentSeries}\n\n` +
`Start here: ${welcomeEpisodeUrl}\n` +
`${welcomeStartHereTitle}\n` +
`${welcomeStartHereSummary}\n` +
`${welcomeSpotifyBtnLabel}: ${welcomeSpotifyUrl}\n` +
`${welcomeAppleBtnLabel}: ${welcomeAppleUrl}\n` +
`Amazon Music: ${welcomeAmazonUrl}\n` +
`Website: ${welcomeWebsiteUrl}\n\n` +
`What to expect:\n` +
`- ${welcomeExpect1}\n` +
`- ${welcomeExpect2}\n` +
`- ${welcomeExpect3}\n\n` +
`"${welcomeScripture}"\n${welcomeScriptureRef}\n\n` +
`${welcomeSignoff}`,
html: `<html dir="ltr" lang="en">
<head></head>
<body style="background-color:#ffffff">
<table border="0" width="100%" cellpadding="0" cellspacing="0" role="presentation" align="center">
<tbody>
<tr>
<td style="background-color:#ffffff">
<table align="left" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width:600px;align:left;width:100%;color:#000000;background-color:#ffffff;border-radius:0px;border-color:#000000">
<tbody>
<tr style="width:100%">
<td style="padding:0">
<table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background-color:#0a0a08">
<tbody>
<tr>
<td align="center" style="padding:40px 20px">
<table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="margin:0 auto;max-width:580px;background-color:#0f0f0c;border:1px solid #2a2518">
<tbody>
<tr>
<td align="center" style="padding:36px 40px 28px;background-color:#0d0d0a;border-bottom:1px solid #2a2518">
<img alt="Verse by Verse with Nate" src="${escapeHtml(welcomeImageUrl)}" style="display:block;outline:none;border:2px solid #2a2518;text-decoration:none;max-width:100%;margin:0 auto 20px;border-radius:12px;height:auto" width="468" />
<p style="margin:0 0 6px;padding:0;font-family:'Cormorant Garamond',Georgia,serif;font-size:22px;font-weight:600;color:#c9a84c;letter-spacing:0.04em">Verse by Verse with Nate</p>
<p style="margin:0;padding:0;font-family:'Crimson Pro',Georgia,serif;font-size:14px;font-weight:300;color:#7a7060;letter-spacing:0.08em;text-transform:uppercase">Verse by verse. Nugget by nugget.</p>
</td>
</tr>
<tr>
<td style="padding:40px 40px 0">
<p style="margin:0 0 8px;padding:0;font-family:'Cormorant Garamond',Georgia,serif;font-size:13px;font-weight:400;color:#7a7060;letter-spacing:0.12em;text-transform:uppercase">Welcome</p>
<h1 style="margin:0 0 20px;padding:0;font-family:'Cormorant Garamond',Georgia,serif;font-size:30px;font-weight:600;color:#f0ead8;line-height:1.2">${heading}</h1>
<div style="width:40px;height:2px;background-color:#c9a84c;margin-bottom:28px"></div>
</td>
</tr>
<tr>
<td style="padding:0 40px 32px">
<p style="margin:0 0 18px;padding:0;font-family:'Crimson Pro',Georgia,serif;font-size:18px;font-weight:300;color:#c8c0ac;line-height:1.75">${escapeHtml(welcomeIntro)}</p>
<p style="margin:0 0 18px;padding:0;font-family:'Crimson Pro',Georgia,serif;font-size:18px;font-weight:300;color:#c8c0ac;line-height:1.75">${escapeHtml(welcomeCurrentSeries)}</p>
<p style="margin:0;padding:0;font-family:'Crimson Pro',Georgia,serif;font-size:18px;font-weight:300;color:#c8c0ac;line-height:1.75">If you&#8217;re just joining us, the best place to start is Episode 1. It sets the table for everything that follows.</p>
</td>
</tr>
<tr><td style="padding:0 40px"><div style="height:1px;background-color:#2a2518;margin-bottom:32px"></div></td></tr>
<tr>
<td style="padding:0 40px 32px">
<p style="margin:0 0 6px;padding:0;font-family:'Cormorant Garamond',Georgia,serif;font-size:13px;font-weight:400;color:#7a7060;letter-spacing:0.12em;text-transform:uppercase">Start here</p>
<p style="margin:0 0 6px;padding:0;font-family:'Cormorant Garamond',Georgia,serif;font-size:22px;font-weight:600;color:#f0ead8">${escapeHtml(welcomeStartHereTitle)}</p>
<p style="margin:0 0 20px;padding:0;font-family:'Crimson Pro',Georgia,serif;font-size:16px;font-weight:300;color:#7a7060;line-height:1.6">${escapeHtml(welcomeStartHereSummary)}</p>
<table border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody>
<tr>
<td style="padding-right:12px">
<a href="${escapeHtml(welcomeSpotifyUrl)}" rel="noopener noreferrer nofollow" style="color:#0d0d0a;text-decoration:none;display:inline-block;padding:11px 22px;background-color:#c9a84c;font-family:'Cormorant Garamond',Georgia,serif;font-size:14px;font-weight:600;letter-spacing:0.06em;border-radius:3px" target="_blank">${escapeHtml(welcomeSpotifyBtnLabel)}</a>
</td>
<td>
<a href="${escapeHtml(welcomeAppleUrl)}" rel="noopener noreferrer nofollow" style="color:#c9a84c;text-decoration:none;display:inline-block;padding:11px 22px;background-color:transparent;font-family:'Cormorant Garamond',Georgia,serif;font-size:14px;font-weight:600;letter-spacing:0.06em;border-radius:3px;border:1px solid #c9a84c" target="_blank">${escapeHtml(welcomeAppleBtnLabel)}</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr><td style="padding:0 40px"><div style="height:1px;background-color:#2a2518;margin-bottom:32px"></div></td></tr>
<tr>
<td style="padding:0 40px 32px">
<p style="margin:0 0 20px;padding:0;font-family:'Cormorant Garamond',Georgia,serif;font-size:13px;font-weight:400;color:#7a7060;letter-spacing:0.12em;text-transform:uppercase">What to expect</p>
<table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="margin-bottom:18px">
<tbody><tr>
<td style="width:28px;vertical-align:top;padding-top:3px"><div style="width:6px;height:6px;background-color:#c9a84c;border-radius:50%;margin-top:7px"></div></td>
<td><p style="margin:0;padding:0;font-family:'Crimson Pro',Georgia,serif;font-size:17px;font-weight:400;color:#c8c0ac;line-height:1.65">${escapeHtml(welcomeExpect1)}</p></td>
</tr></tbody>
</table>
<table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="margin-bottom:18px">
<tbody><tr>
<td style="width:28px;vertical-align:top;padding-top:3px"><div style="width:6px;height:6px;background-color:#c9a84c;border-radius:50%;margin-top:7px"></div></td>
<td><p style="margin:0;padding:0;font-family:'Crimson Pro',Georgia,serif;font-size:17px;font-weight:400;color:#c8c0ac;line-height:1.65">${escapeHtml(welcomeExpect2)}</p></td>
</tr></tbody>
</table>
<table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody><tr>
<td style="width:28px;vertical-align:top;padding-top:3px"><div style="width:6px;height:6px;background-color:#c9a84c;border-radius:50%;margin-top:7px"></div></td>
<td><p style="margin:0;padding:0;font-family:'Crimson Pro',Georgia,serif;font-size:17px;font-weight:400;color:#c8c0ac;line-height:1.65">${escapeHtml(welcomeExpect3)}</p></td>
</tr></tbody>
</table>
</td>
</tr>
<tr><td style="padding:0 40px"><div style="height:1px;background-color:#2a2518;margin-bottom:32px"></div></td></tr>
<tr>
<td style="padding:0 40px 40px">
<table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-left:2px solid #c9a84c">
<tbody><tr>
<td style="padding:4px 0 4px 20px">
<p style="margin:0 0 8px;padding:0;font-family:'Cormorant Garamond',Georgia,serif;font-size:19px;font-style:italic;font-weight:400;color:#e0c070;line-height:1.6">&#8220;${escapeHtml(welcomeScripture)}&#8221;</p>
<p style="margin:0;padding:0;font-family:'Crimson Pro',Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;letter-spacing:0.06em;text-transform:uppercase">${escapeHtml(welcomeScriptureRef)}</p>
</td>
</tr></tbody>
</table>
</td>
</tr>
<tr>
<td align="center" style="padding:28px 40px;background-color:#0a0a08;border-top:1px solid #2a2518;text-align:center">
<p style="margin:0 0 14px;padding:0;font-family:'Crimson Pro',Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;letter-spacing:0.06em">Find the podcast on</p>
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="margin:0 auto 24px">
<tbody><tr>
<td style="padding:0 10px"><a href="${escapeHtml(welcomeSpotifyUrl)}" rel="noopener noreferrer nofollow" style="color:#7a7060;text-decoration:none;font-family:'Crimson Pro',Georgia,serif;font-size:13px;font-weight:300;letter-spacing:0.04em" target="_blank">Spotify</a></td>
<td style="color:#7a7060;font-size:12px">&#183;</td>
<td style="padding:0 10px"><a href="${escapeHtml(welcomeAppleUrl)}" rel="noopener noreferrer nofollow" style="color:#7a7060;text-decoration:none;font-family:'Crimson Pro',Georgia,serif;font-size:13px;font-weight:300;letter-spacing:0.04em" target="_blank">Apple Podcasts</a></td>
<td style="color:#7a7060;font-size:12px">&#183;</td>
<td style="padding:0 10px"><a href="${escapeHtml(welcomeAmazonUrl)}" rel="noopener noreferrer nofollow" style="color:#7a7060;text-decoration:none;font-family:'Crimson Pro',Georgia,serif;font-size:13px;font-weight:300;letter-spacing:0.04em" target="_blank">Amazon Music</a></td>
<td style="color:#7a7060;font-size:12px">&#183;</td>
<td style="padding:0 10px"><a href="${escapeHtml(welcomeWebsiteUrl)}" rel="noopener noreferrer nofollow" style="color:#7a7060;text-decoration:none;font-family:'Crimson Pro',Georgia,serif;font-size:13px;font-weight:300;letter-spacing:0.04em" target="_blank">Website</a></td>
</tr></tbody>
</table>
<p style="margin:0 0 6px;padding:0;font-family:'Crimson Pro',Georgia,serif;font-size:12px;font-weight:300;color:#6b6044;line-height:1.6">You&#8217;re receiving this because you subscribed to <strong>Verse by Verse with Nate</strong>.</p>
<p style="margin:0 0 18px;padding:0;font-family:'Crimson Pro',Georgia,serif;font-size:12px;font-weight:300;color:#6b6044"><a href="{{{RESEND_UNSUBSCRIBE_URL}}}" rel="noopener noreferrer nofollow" style="color:#6b6044;text-decoration:underline" target="_blank">Unsubscribe</a></p>
<p style="margin:0;padding:0;font-family:'Cormorant Garamond',Georgia,serif;font-size:13px;font-style:italic;font-weight:400;color:#6b6044">&#8220;Your word is a lamp to my feet and a light to my path.&#8221; &#8212; Psalm 119:105</p>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>`,
}
}
export function buildContactAdminNotificationTemplate({
normalizedMessageType,
trimmedName,
trimmedEmail,
submittedAt,
trimmedMessage,
}) {
return {
subject: `Verse by Verse contact (${normalizedMessageType}): ${trimmedName}`,
text:
`New contact form submission\n\n` +
`Message Type: ${normalizedMessageType}\n` +
`Name: ${trimmedName}\n` +
`Email: ${trimmedEmail}\n` +
`Submitted: ${submittedAt}\n\n` +
`Message:\n${trimmedMessage}`,
html:
`<div style="background:#f5f1e8;padding:24px;font-family:Georgia,serif;color:#201a10;">` +
`<div style="max-width:680px;margin:0 auto;background:#ffffff;border:1px solid #e1d3b2;border-radius:14px;overflow:hidden;">` +
`<div style="background:#111111;padding:20px 24px;border-bottom:3px solid #c8860a;">` +
`<div style="font-family:Arial,sans-serif;font-size:12px;letter-spacing:0.32em;text-transform:uppercase;color:#c8860a;">Verse by Verse with Nate</div>` +
`<h1 style="margin:10px 0 0;color:#f4ead5;font-size:28px;line-height:1.2;">New Contact Form Submission</h1>` +
`</div>` +
`<div style="padding:24px;">` +
`<p style="margin:0 0 18px;font-family:Arial,sans-serif;font-size:15px;line-height:1.6;color:#57452b;">A new message was sent from the website contact form. Reply directly to this email to respond to <strong>${escapeHtml(trimmedName)}</strong>.</p>` +
`<table role="presentation" cellpadding="0" cellspacing="0" style="width:100%;border-collapse:collapse;margin-bottom:20px;">` +
`<tr><td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;border-bottom:1px solid #efe4cc;">Type</td><td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;border-bottom:1px solid #efe4cc;">${escapeHtml(normalizedMessageType)}</td></tr>` +
`<tr><td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;border-bottom:1px solid #efe4cc;">Name</td><td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;border-bottom:1px solid #efe4cc;">${escapeHtml(trimmedName)}</td></tr>` +
`<tr><td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;border-bottom:1px solid #efe4cc;">Email</td><td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;border-bottom:1px solid #efe4cc;"><a href="mailto:${escapeHtml(trimmedEmail)}" style="color:#8f5f05;text-decoration:none;">${escapeHtml(trimmedEmail)}</a></td></tr>` +
`<tr><td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;">Submitted</td><td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;">${escapeHtml(submittedAt)}</td></tr>` +
`</table>` +
`<div style="background:#fbf7ef;border:1px solid #efe4cc;border-radius:12px;padding:18px 20px;">` +
`<div style="margin:0 0 10px;font-family:Arial,sans-serif;font-size:13px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:#8a6d35;">Message</div>` +
`<div style="font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;white-space:pre-wrap;">${escapeHtml(trimmedMessage)}</div>` +
`</div>` +
`</div>` +
`</div>` +
`</div>`,
}
}
export function buildAdminReplyTemplate({ recipientName, message }) {
const safeRecipientName = escapeHtml(recipientName || 'friend')
const safeMessage = escapeHtml(message).replace(/\n/g, '<br/>')
return `
<div style="margin:0;padding:0;background-color:#f5f1e8;font-family:Georgia,serif;color:#201a10;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="background-color:#f5f1e8;">
<tr>
<td align="center" style="padding:28px 16px;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="max-width:680px;background:#ffffff;border:1px solid #e1d3b2;border-radius:14px;overflow:hidden;">
<tr>
<td style="background:#111111;padding:20px 24px;border-bottom:3px solid #c8860a;">
<div style="font-family:Arial,sans-serif;font-size:12px;letter-spacing:0.32em;text-transform:uppercase;color:#c8860a;">Verse by Verse with Nate</div>
<h1 style="margin:10px 0 0;color:#f4ead5;font-size:26px;line-height:1.2;">A Personal Reply</h1>
</td>
</tr>
<tr>
<td style="padding:26px 24px 18px;">
<p style="margin:0 0 16px;font-family:Arial,sans-serif;font-size:16px;line-height:1.6;color:#201a10;">Hi ${safeRecipientName},</p>
<div style="margin:0 0 18px;font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;">${safeMessage}</div>
<p style="margin:0;font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;">Grace and peace,<br/>Verse by Verse with Nate</p>
</td>
</tr>
<tr>
<td style="background:#f7f2e5;border-top:1px solid #e8dcc1;padding:14px 24px;">
<p style="margin:0;font-family:Arial,sans-serif;font-size:12px;line-height:1.5;color:#735a2b;">From: hello@versebyversewithnate.us</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
`
}
export function buildQuestionAnsweredEmailTemplate({ firstName, question, answer, questionUrl }) {
const cfg = state.cachedSiteContent ?? {}
const subject = cfg.qaAnsweredEmailSubject?.trim() || 'Your question has been answered — Verse by Verse with Nate'
const bodyText = cfg.qaAnsweredEmailBody?.trim() || 'Nate has answered your question on Verse by Verse with Nate.'
const ctaLabel = cfg.qaAnsweredEmailCtaLabel?.trim() || 'Read Full Answer →'
const signoff = cfg.qaAnsweredEmailSignoff?.trim() || 'Grace and peace,\nNate'
const safeName = escapeHtml(firstName || 'friend')
const safeQuestion = escapeHtml(question)
const safeAnswer = escapeHtml(answer.length > 600 ? answer.slice(0, 597) + '…' : answer).replace(/\n/g, '<br/>')
const safeUrl = escapeHtml(questionUrl)
const safeBody = escapeHtml(bodyText)
const safeSignoff = escapeHtml(signoff).replace(/\n/g, '<br/>')
const text = `Hi ${firstName || 'friend'},\n\n${bodyText}\n\nYour question: ${question}\n\nAnswer: ${answer}\n\nRead it at: ${questionUrl}\n\n${signoff}`
const html = `
<div style="margin:0;padding:0;background-color:#f5f1e8;font-family:Georgia,serif;color:#201a10;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="background-color:#f5f1e8;">
<tr>
<td align="center" style="padding:28px 16px;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="max-width:680px;background:#ffffff;border:1px solid #e1d3b2;border-radius:14px;overflow:hidden;">
<tr>
<td style="background:#111111;padding:20px 24px;border-bottom:3px solid #c8860a;">
<div style="font-family:Arial,sans-serif;font-size:12px;letter-spacing:0.32em;text-transform:uppercase;color:#c8860a;">Verse by Verse with Nate</div>
<h1 style="margin:10px 0 0;color:#f4ead5;font-size:26px;line-height:1.2;">Your Question Was Answered</h1>
</td>
</tr>
<tr>
<td style="padding:26px 24px 18px;">
<p style="margin:0 0 16px;font-family:Arial,sans-serif;font-size:16px;line-height:1.6;color:#201a10;">Hi ${safeName},</p>
<p style="margin:0 0 16px;font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;">${safeBody}</p>
<div style="background:#f7f2e5;border-left:3px solid #c8860a;padding:14px 18px;margin:0 0 20px;border-radius:0 8px 8px 0;">
<p style="margin:0 0 6px;font-family:Arial,sans-serif;font-size:13px;letter-spacing:0.06em;text-transform:uppercase;color:#a07830;">Your question</p>
<p style="margin:0;font-family:Georgia,serif;font-size:15px;line-height:1.6;color:#201a10;font-style:italic;">${safeQuestion}</p>
</div>
<div style="margin:0 0 24px;">
<p style="margin:0 0 6px;font-family:Arial,sans-serif;font-size:13px;letter-spacing:0.06em;text-transform:uppercase;color:#a07830;">Answer</p>
<p style="margin:0;font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;">${safeAnswer}</p>
</div>
<a href="${safeUrl}" style="display:inline-block;background:#c8860a;color:#ffffff;font-family:Arial,sans-serif;font-size:14px;font-weight:bold;text-decoration:none;padding:12px 24px;border-radius:6px;">${escapeHtml(ctaLabel)}</a>
</td>
</tr>
<tr>
<td style="background:#f7f2e5;border-top:1px solid #e8dcc1;padding:14px 24px;">
<p style="margin:0 0 12px;font-family:Arial,sans-serif;font-size:13px;line-height:1.6;color:#201a10;">${safeSignoff}</p>
<p style="margin:0;font-family:Arial,sans-serif;font-size:12px;line-height:1.5;color:#735a2b;">You received this because you requested a notification when this question was answered.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
`
return { subject, text, html }
}
// ── Transactional email senders ────────────────────────────────────────────
export async function sendStudyWelcomeEmail(email, displayName) {
if (!process.env.RESEND_API_KEY) return
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const cfg = state.cachedSiteContent ?? {}
const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend'
const baseUrl = getCanonicalBaseUrl()
const accountUrl = buildAbsoluteUrl(baseUrl, '/study/account')
const subject = cfg.studyWelcomeEmailSubject?.trim() || 'Welcome to the Study Community'
const bodyText = cfg.studyWelcomeEmailBody?.trim() || 'Your student account is ready. Open your studies and continue learning, or manage your account details anytime.'
const ctaLabel = cfg.studyWelcomeEmailCtaLabel?.trim() || 'Open Studies'
const studiesUrl = buildAbsoluteUrl(baseUrl, cfg.studyWelcomeEmailCtaPath?.trim() || '/study')
const signoff = cfg.studyWelcomeEmailSignoff?.trim() || 'Grace and peace,\nVerse by Verse with Nate'
const bodyHtml = (
`<p style="margin:0 0 16px;">Welcome, <strong style="color:#f0ead8;">${escapeHtml(namePart)}</strong>.</p>` +
`<p style="margin:0 0 16px;">${escapeHtml(bodyText)}</p>`
)
const footerHtml = `<p style="margin:0;font-family:Georgia,serif;font-size:12px;font-weight:300;color:#7a7060;line-height:1.6;">${escapeHtml(signoff).replace(/\n/g, '<br/>')}</p>`
const { error } = await resend.emails.send({
from: process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM,
to: [email],
subject,
text: `Welcome, ${namePart}.\n\n${bodyText}\n\nOpen studies: ${studiesUrl}\nManage account: ${accountUrl}\n\n${signoff}`,
html: buildBrandedEmailHtml({
title: 'Welcome to the Study Community',
eyebrow: 'Study Account',
bodyHtml,
ctaLabel,
ctaUrl: studiesUrl,
footerHtml: footerHtml + `<p style="margin:14px 0 0;font-family:Georgia,serif;font-size:12px;font-weight:300;color:#7a7060;line-height:1.6;"><a href="${escapeHtml(accountUrl)}" target="_blank" style="color:#c9a84c;text-decoration:none;">Manage your account</a></p>`,
}),
})
if (error) console.error('[study-signup] welcome email send error:', error)
} catch (err) {
console.error('[study-signup] welcome email exception:', err)
}
}
export async function sendEmailOtp(email, code) {
if (!process.env.RESEND_API_KEY) return
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const cfg = state.cachedSiteContent ?? {}
const subject = cfg.twoFaOtpEmailSubject?.trim() || 'Your sign-in code — Verse by Verse with Nate'
const bodyText = cfg.twoFaOtpEmailBody?.trim() || 'Your two-factor sign-in code is below. Enter it to complete sign-in.'
const expiryText = cfg.twoFaOtpEmailExpiry?.trim() || 'This code expires in 10 minutes. If you did not request this, you can ignore this message.'
await resend.emails.send({
from: process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM,
to: [email],
subject,
text: `${bodyText}\n\n${code}\n\n${expiryText}\n\nVerse by Verse with Nate`,
html: buildBrandedEmailHtml({
title: 'Your Sign-In Code',
eyebrow: 'Account Security',
bodyHtml:
`<p style="margin:0 0 16px;">${escapeHtml(bodyText)}</p>` +
`<p style="margin:0 0 16px;font-size:2.5rem;font-weight:700;letter-spacing:0.35em;color:#c9a84c;font-family:monospace;">${code}</p>` +
`<p style="margin:0 0 16px;font-size:0.9rem;color:#7a7060;">${escapeHtml(expiryText)}</p>`,
footerHtml: `<p style="margin:0;font-family:Georgia,serif;font-size:12px;color:#7a7060;">Verse by Verse with Nate</p>`,
}),
})
} catch (err) {
console.error('[email-otp] send error:', err)
}
}
export async function sendStudyAccountDeletedEmail(email, displayName) {
if (!process.env.RESEND_API_KEY) return
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const cfg = state.cachedSiteContent ?? {}
const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend'
const baseUrl = getCanonicalBaseUrl()
const subject = cfg.studyDeletedEmailSubject?.trim() || 'Your study account was deleted'
const bodyText = cfg.studyDeletedEmailBody?.trim() || 'This confirms your study account and saved notes were deleted. If this was not you, please contact us immediately.'
const ctaLabel = cfg.studyDeletedEmailCtaLabel?.trim() || 'Create a New Account'
const signupUrl = buildAbsoluteUrl(baseUrl, cfg.studyDeletedEmailCtaPath?.trim() || '/study/signup')
const signoff = cfg.studyDeletedEmailSignoff?.trim() || 'Verse by Verse with Nate'
const bodyHtml = (
`<p style="margin:0 0 16px;">Hi <strong style="color:#f0ead8;">${escapeHtml(namePart)}</strong>,</p>` +
`<p style="margin:0 0 16px;">${escapeHtml(bodyText)}</p>`
)
const footerHtml = `<p style="margin:0;font-family:Georgia,serif;font-size:12px;font-weight:300;color:#7a7060;line-height:1.6;">${escapeHtml(signoff).replace(/\n/g, '<br/>')}</p>`
const { error } = await resend.emails.send({
from: process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM,
to: [email],
subject,
text: `Hi ${namePart},\n\n${bodyText}\n\nCreate a new account anytime: ${signupUrl}\n\n${signoff}`,
html: buildBrandedEmailHtml({
title: 'Study Account Deleted',
eyebrow: 'Account Update',
bodyHtml,
ctaLabel,
ctaUrl: signupUrl,
footerHtml,
}),
})
if (error) console.error('[study-account] delete email send error:', error)
} catch (err) {
console.error('[study-account] delete email exception:', err)
}
}
export async function sendStudyReminderEmail(email, displayName, studyTitle, sectionTitle, sectionReference, sectionUrl) {
if (!process.env.RESEND_API_KEY) return
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const cfg = state.cachedSiteContent ?? {}
const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend'
const subjectPrefix = cfg.studyReminderEmailSubjectPrefix?.trim() || 'New lesson available:'
const bodyText = cfg.studyReminderEmailBody?.trim() || 'A new lesson has been unlocked in your study track. Open it below to continue where you left off.'
const ctaLabel = cfg.studyReminderEmailCtaLabel?.trim() || 'Open the Lesson'
const signoff = cfg.studyReminderEmailSignoff?.trim() || 'Grace and peace,\nVerse by Verse with Nate'
const subject = process.env.RESEND_REMINDER_SUBJECT ?? `${subjectPrefix} ${sectionTitle}`
const bodyHtml = (
`<p style="margin:0 0 16px;">Hi <strong style="color:#f0ead8;">${escapeHtml(namePart)}</strong>,</p>` +
`<p style="margin:0 0 16px;">${escapeHtml(bodyText)}</p>` +
`<p style="margin:0 0 16px;"><strong style="color:#f0ead8;">${escapeHtml(sectionTitle)}</strong> (${escapeHtml(sectionReference)}) — ${escapeHtml(studyTitle)}</p>`
)
const footerHtml = `<p style="margin:0;font-family:Georgia,serif;font-size:12px;font-weight:300;color:#7a7060;line-height:1.6;">${escapeHtml(signoff).replace(/\n/g, '<br/>')}</p>`
const { error } = await resend.emails.send({
from: process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM,
to: [email],
subject,
text: `Hi ${namePart},\n\n${bodyText}\n\n${sectionTitle} (${sectionReference}) — ${studyTitle}\n\nOpen it here: ${sectionUrl}\n\n${signoff}`,
html: buildBrandedEmailHtml({
title: 'New Lesson Available',
eyebrow: 'Study Reminder',
bodyHtml,
ctaLabel,
ctaUrl: sectionUrl,
footerHtml,
}),
})
if (error) console.error('[study-reminder] send error:', error)
} catch (err) {
console.error('[study-reminder] send exception:', err)
}
}