2732730f5e
Sends 7/14/30-day inactivity emails to study users who haven't logged in, with one-click HMAC-signed unsubscribe and automatic re-arm on next login. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
645 lines
41 KiB
JavaScript
645 lines
41 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’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">“${escapeHtml(welcomeScripture)}”</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">·</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">·</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">·</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’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">“Your word is a lamp to my feet and a light to my path.” — 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, signature }) {
|
|
const safeRecipientName = escapeHtml(recipientName || 'friend')
|
|
const safeMessage = escapeHtml(message).replace(/\n/g, '<br/>')
|
|
const sig = typeof signature === 'string' && signature.trim() ? signature.trim() : 'Grace and peace,\nVerse by Verse with Nate'
|
|
const safeSig = escapeHtml(sig).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;">${safeSig}</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)
|
|
}
|
|
}
|
|
|
|
const REENGAGEMENT_COPY = {
|
|
'7d': {
|
|
subject: 'Your study is waiting for you',
|
|
eyebrow: 'Come Back',
|
|
headline: 'Pick up where you left off',
|
|
body: "It's been a week — your progress is saved and your next lesson is ready whenever you are.",
|
|
cta: 'Continue Studying',
|
|
},
|
|
'14d': {
|
|
subject: "Don't lose your momentum",
|
|
eyebrow: 'Still With You',
|
|
headline: 'Your spot is still saved',
|
|
body: 'Two weeks have passed, but every note and completed lesson is still right there. A little each day adds up.',
|
|
cta: 'Return to Your Study',
|
|
},
|
|
'30d': {
|
|
subject: 'Your progress is still here',
|
|
eyebrow: 'We Saved Your Spot',
|
|
headline: "It's been a month — come back anytime",
|
|
body: 'Your study progress is still intact and waiting. There\'s no deadline — come back whenever you\'re ready.',
|
|
cta: 'Open Your Study',
|
|
},
|
|
}
|
|
|
|
export async function sendStudyReengagementEmail(email, displayName, studyTitle, studyUrl, tier, unsubUrl) {
|
|
if (!process.env.RESEND_API_KEY) return
|
|
const copy = REENGAGEMENT_COPY[tier]
|
|
if (!copy) return
|
|
try {
|
|
const resend = new Resend(process.env.RESEND_API_KEY)
|
|
const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend'
|
|
const subject = `${copy.subject}${studyTitle ? ` — ${studyTitle}` : ''}`
|
|
const bodyHtml = (
|
|
`<p style="margin:0 0 16px;">Hi <strong style="color:#f0ead8;">${escapeHtml(namePart)}</strong>,</p>` +
|
|
`<p style="margin:0 0 16px;">${escapeHtml(copy.body)}${studyTitle ? ` Your current study: <strong style="color:#f0ead8;">${escapeHtml(studyTitle)}</strong>.` : ''}</p>`
|
|
)
|
|
const unsubLine = unsubUrl ? `<p style="margin:16px 0 0;font-size:11px;color:#7a7060;">Not interested? <a href="${escapeHtml(unsubUrl)}" style="color:#7a7060;">Unsubscribe from these reminders.</a></p>` : ''
|
|
const footerHtml = `<p style="margin:0;font-family:Georgia,serif;font-size:12px;font-weight:300;color:#7a7060;line-height:1.6;">Grace and peace,<br/>Verse by Verse with Nate</p>${unsubLine}`
|
|
const { error } = await resend.emails.send({
|
|
from: process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM,
|
|
to: [email],
|
|
subject,
|
|
text: `Hi ${namePart},\n\n${copy.body}${studyTitle ? ` Your current study: ${studyTitle}.` : ''}\n\n${studyUrl}\n\nGrace and peace,\nVerse by Verse with Nate${unsubUrl ? `\n\nUnsubscribe: ${unsubUrl}` : ''}`,
|
|
html: buildBrandedEmailHtml({
|
|
title: copy.headline,
|
|
eyebrow: copy.eyebrow,
|
|
bodyHtml,
|
|
ctaLabel: copy.cta,
|
|
ctaUrl: studyUrl,
|
|
footerHtml,
|
|
}),
|
|
})
|
|
if (error) console.error('[study-reengagement] send error:', error)
|
|
} catch (err) {
|
|
console.error('[study-reengagement] send exception:', err)
|
|
}
|
|
}
|