refractor server.js
This commit is contained in:
+458
@@ -0,0 +1,458 @@
|
||||
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,
|
||||
welcomeHeading,
|
||||
welcomeSpotifyUrl,
|
||||
welcomeAppleUrl,
|
||||
welcomeAmazonUrl,
|
||||
welcomeWebsiteUrl,
|
||||
welcomeEpisodeUrl,
|
||||
welcomeImageUrl,
|
||||
welcomeSpotifyBtnLabel = 'Listen on Spotify',
|
||||
welcomeAppleBtnLabel = 'Apple Podcasts',
|
||||
welcomeStartHereLinkLabel = 'Open Start Here page',
|
||||
}) {
|
||||
const welcomeSignoffHtml = escapeHtml(welcomeSignoff).replace(/\n/g, '<br/>')
|
||||
|
||||
return {
|
||||
text:
|
||||
`Welcome to Verse by Verse with Nate!\n\n` +
|
||||
`${greetingName ? `Glad you're here, ${greetingName}.` : "Glad you're here."}\n\n` +
|
||||
`${welcomeIntro}\n\n` +
|
||||
`${welcomeCurrentSeries}\n\n` +
|
||||
`Start here: ${welcomeEpisodeUrl}\n` +
|
||||
`${welcomeStartHereTitle}\n` +
|
||||
`${welcomeStartHereSummary}\n` +
|
||||
`Spotify: ${welcomeSpotifyUrl}\n` +
|
||||
`Apple Podcasts: ${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:
|
||||
`<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;">` +
|
||||
`<img src="${escapeHtml(welcomeImageUrl)}" alt="Verse by Verse with Nate" width="110" height="110" style="display:block;margin:0 auto 20px;border-radius:12px;border:2px solid #2a2518;" />` +
|
||||
`<p style="margin:0 0 6px;font-family:Georgia,serif;font-size:22px;font-weight:600;color:#c9a84c;letter-spacing:0.04em;">Verse by Verse with Nate</p>` +
|
||||
`<p style="margin:0;font-family: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;font-family: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;font-family:Georgia,serif;font-size:30px;font-weight:600;color:#f0ead8;line-height:1.2;">${welcomeHeading}</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;font-family:Georgia,serif;font-size:18px;font-weight:300;color:#c8c0ac;line-height:1.75;">${escapeHtml(welcomeIntro)}</p>` +
|
||||
`<p style="margin:0 0 18px;font-family:Georgia,serif;font-size:18px;font-weight:300;color:#c8c0ac;line-height:1.75;">${escapeHtml(welcomeCurrentSeries)}</p>` +
|
||||
`<p style="margin:0;font-family: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;font-family: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;font-family:Georgia,serif;font-size:22px;font-weight:600;color:#f0ead8;">${escapeHtml(welcomeStartHereTitle)}</p>` +
|
||||
`<p style="margin:0 0 20px;font-family:Georgia,serif;font-size:16px;font-weight:300;color:#7a7060;line-height:1.6;">${escapeHtml(welcomeStartHereSummary)}</p>` +
|
||||
`<table cellpadding="0" cellspacing="0" border="0" role="presentation"><tr>` +
|
||||
`<td style="padding-right:12px;"><a href="${escapeHtml(welcomeSpotifyUrl)}" target="_blank" style="display:inline-block;padding:11px 22px;background-color:#c9a84c;color:#0d0d0a;font-family:Georgia,serif;font-size:14px;font-weight:600;letter-spacing:0.06em;text-decoration:none;border-radius:3px;">${escapeHtml(welcomeSpotifyBtnLabel)}</a></td>` +
|
||||
`<td><a href="${escapeHtml(welcomeAppleUrl)}" target="_blank" style="display:inline-block;padding:11px 22px;background-color:transparent;color:#c9a84c;font-family:Georgia,serif;font-size:14px;font-weight:600;letter-spacing:0.06em;text-decoration:none;border-radius:3px;border:1px solid #c9a84c;">${escapeHtml(welcomeAppleBtnLabel)}</a></td>` +
|
||||
`</tr></table>` +
|
||||
`<p style="margin:18px 0 0;"><a href="${escapeHtml(welcomeEpisodeUrl)}" target="_blank" style="color:#c9a84c;text-decoration:underline;font-family:Georgia,serif;font-size:14px;">${escapeHtml(welcomeStartHereLinkLabel)}</a></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 20px;font-family:Georgia,serif;font-size:13px;font-weight:400;color:#7a7060;letter-spacing:0.12em;text-transform:uppercase;">What to expect</p>` +
|
||||
`<p style="margin:0 0 12px;font-family:Georgia,serif;font-size:17px;font-weight:400;color:#c8c0ac;line-height:1.65;">${escapeHtml(welcomeExpect1)}</p>` +
|
||||
`<p style="margin:0 0 12px;font-family:Georgia,serif;font-size:17px;font-weight:400;color:#c8c0ac;line-height:1.65;">${escapeHtml(welcomeExpect2)}</p>` +
|
||||
`<p style="margin:0;font-family:Georgia,serif;font-size:17px;font-weight:400;color:#c8c0ac;line-height:1.65;">${escapeHtml(welcomeExpect3)}</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 40px;">` +
|
||||
`<table cellpadding="0" cellspacing="0" border="0" role="presentation" style="width:100%;border-left:2px solid #c9a84c;"><tr><td style="padding:4px 0 4px 20px;">` +
|
||||
`<p style="margin:0 0 8px;font-family:Georgia,serif;font-size:19px;font-style:italic;font-weight:400;color:#e0c070;line-height:1.6;">“${escapeHtml(welcomeScripture)}”</p>` +
|
||||
`<p style="margin:0;font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;letter-spacing:0.06em;text-transform:uppercase;">${escapeHtml(welcomeScriptureRef)}</p>` +
|
||||
`</td></tr></table>` +
|
||||
`</td></tr>` +
|
||||
`<tr><td style="background-color:#0a0a08;border-top:1px solid #2a2518;padding:28px 40px;text-align:center;">` +
|
||||
`<p style="margin:0 0 14px;font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;letter-spacing:0.06em;">Find the podcast on</p>` +
|
||||
`<table cellpadding="0" cellspacing="0" border="0" role="presentation" style="margin:0 auto 24px;"><tr>` +
|
||||
`<td style="padding:0 10px;"><a href="${escapeHtml(welcomeSpotifyUrl)}" target="_blank" style="font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;text-decoration:none;letter-spacing:0.04em;">Spotify</a></td>` +
|
||||
`<td style="color:#2a2518;font-size:12px;">·</td>` +
|
||||
`<td style="padding:0 10px;"><a href="${escapeHtml(welcomeAppleUrl)}" target="_blank" style="font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;text-decoration:none;letter-spacing:0.04em;">Apple Podcasts</a></td>` +
|
||||
`<td style="color:#2a2518;font-size:12px;">·</td>` +
|
||||
`<td style="padding:0 10px;"><a href="${escapeHtml(welcomeAmazonUrl)}" target="_blank" style="font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;text-decoration:none;letter-spacing:0.04em;">Amazon Music</a></td>` +
|
||||
`<td style="color:#2a2518;font-size:12px;">·</td>` +
|
||||
`<td style="padding:0 10px;"><a href="${escapeHtml(welcomeWebsiteUrl)}" target="_blank" style="font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;text-decoration:none;letter-spacing:0.04em;">Website</a></td>` +
|
||||
`</tr></table>` +
|
||||
`<p style="margin:0 0 6px;font-family:Georgia,serif;font-size:12px;font-weight:300;color:#4a4438;line-height:1.6;">You’re receiving this because you subscribed to <strong style="color:#5a5035;font-weight:400;">Verse by Verse with Nate</strong>.</p>` +
|
||||
`<p style="margin:10px 0 0;font-family:Georgia,serif;font-size:14px;font-weight:400;color:#c8c0ac;line-height:1.5;">${welcomeSignoffHtml}</p>` +
|
||||
`</td></tr>` +
|
||||
`</table>` +
|
||||
`</td></tr></table>` +
|
||||
`</div>`,
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
`
|
||||
}
|
||||
|
||||
// ── 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user