import { Resend } from 'resend' import { escapeHtml, buildAbsoluteUrl, splitName } from './helpers.js' import { DEFAULT_RESEND_FROM, DEFAULT_RESEND_TO, DEFAULT_RESEND_REPLY_TO, 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 ?? DEFAULT_RESEND_REPLY_TO } export function getResendInboxAddress() { return process.env.RESEND_TO ?? DEFAULT_RESEND_TO } 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`) } // Retry a Resend SDK call when the account's rate limit (429) is hit. The SDK returns { error } rather than throwing. export async function resendRetry(call, maxAttempts = 5) { let result for (let attempt = 0; attempt < maxAttempts; attempt += 1) { result = await call() const err = result?.error const limited = err && (err.statusCode === 429 || err.name === 'rate_limit_exceeded') if (!limited) return result await new Promise(resolve => setTimeout(resolve, 1000 * 2 ** attempt)) } return result } 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 { // Never touch `unsubscribed` for a contact that already exists — otherwise a form submission // would silently resubscribe someone who opted out in Resend. const { data: existing } = await resendRetry(() => contactResend.contacts.get({ email })) if (existing) { const { error: updateError } = await resendRetry(() => contactResend.contacts.update({ email, firstName, lastName })) if (updateError) console.error('[resend] contact sync error:', updateError) return } const { error: createError } = await resendRetry(() => contactResend.contacts.create({ email, firstName, lastName, unsubscribed: false, ...(process.env.RESEND_SEGMENT_ID ? { segments: [{ id: process.env.RESEND_SEGMENT_ID }] } : {}), })) if (createError) console.error('[resend] contact sync error:', createError) } 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 ? `

${escapeHtml(ctaLabel)}

` : '' return ( `
` + `` + `
` + `` + `` + `` + `` + `
` + `

${escapeHtml(eyebrow ?? 'Verse by Verse with Nate')}

` + `

${escapeHtml(title)}

` + `
` + `
${bodyHtml}
` + `${ctaBlock}` + `
${footerHtml ?? ''}
` + `
` + `
` ) } 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: `
Verse by Verse with Nate

Verse by Verse with Nate

Verse by verse. Nugget by nugget.

Welcome

${heading}

${escapeHtml(welcomeIntro)}

${escapeHtml(welcomeCurrentSeries)}

If you’re just joining us, the best place to start is Episode 1. It sets the table for everything that follows.

Start here

${escapeHtml(welcomeStartHereTitle)}

${escapeHtml(welcomeStartHereSummary)}

${escapeHtml(welcomeSpotifyBtnLabel)} ${escapeHtml(welcomeAppleBtnLabel)}

What to expect

${escapeHtml(welcomeExpect1)}

${escapeHtml(welcomeExpect2)}

${escapeHtml(welcomeExpect3)}

“${escapeHtml(welcomeScripture)}”

${escapeHtml(welcomeScriptureRef)}

Find the podcast on

Spotify · Apple Podcasts · Amazon Music · Website

You’re receiving this because you subscribed to Verse by Verse with Nate.

Unsubscribe

“Your word is a lamp to my feet and a light to my path.” — Psalm 119:105

`, } } 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: `
` + `
` + `
` + `
Verse by Verse with Nate
` + `

New Contact Form Submission

` + `
` + `
` + `

A new message was sent from the website contact form. Reply directly to this email to respond to ${escapeHtml(trimmedName)}.

` + `` + `` + `` + `` + `` + `
Type${escapeHtml(normalizedMessageType)}
Name${escapeHtml(trimmedName)}
Email${escapeHtml(trimmedEmail)}
Submitted${escapeHtml(submittedAt)}
` + `
` + `
Message
` + `
${escapeHtml(trimmedMessage)}
` + `
` + `
` + `
` + `
`, } } export function buildAdminReplyTemplate({ recipientName, message, signature }) { const safeRecipientName = escapeHtml(recipientName || 'friend') const safeMessage = escapeHtml(message).replace(/\n/g, '
') const sig = typeof signature === 'string' && signature.trim() ? signature.trim() : 'Grace and peace,\nVerse by Verse with Nate' const safeSig = escapeHtml(sig).replace(/\n/g, '
') return `
Verse by Verse with Nate

A Personal Reply

Hi ${safeRecipientName},

${safeMessage}

${safeSig}

From: hello@versebyversewithnate.us

` } 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, '
') const safeUrl = escapeHtml(questionUrl) const safeBody = escapeHtml(bodyText) const safeSignoff = escapeHtml(signoff).replace(/\n/g, '
') 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 = `
Verse by Verse with Nate

Your Question Was Answered

Hi ${safeName},

${safeBody}

Your question

${safeQuestion}

Answer

${safeAnswer}

${escapeHtml(ctaLabel)}

${safeSignoff}

You received this because you requested a notification when this question was answered.

` 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 = ( `

Welcome, ${escapeHtml(namePart)}.

` + `

${escapeHtml(bodyText)}

` ) const footerHtml = `

${escapeHtml(signoff).replace(/\n/g, '
')}

` 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 + `

Manage your account

`, }), }) if (error) console.error('[study-signup] welcome email send error:', error) } catch (err) { console.error('[study-signup] welcome email exception:', err) } } export async function sendStudyVerifyEmail(email, displayName, verifyUrl) { 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 subject = cfg.emailVerifySubject?.trim() || 'Verify your email address' const bodyText = cfg.emailVerifyBody?.trim() || 'Please confirm this is your email address so we can keep your study account secure and deliver reminders you opt into.' const ctaLabel = cfg.emailVerifyCtaLabel?.trim() || 'Verify Email Address' const bodyHtml = ( `

Hi ${escapeHtml(namePart)},

` + `

${escapeHtml(bodyText)}

` ) 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${verifyUrl}\n\nVerse by Verse with Nate`, html: buildBrandedEmailHtml({ title: 'Verify Your Email', eyebrow: 'Study Account', bodyHtml, ctaLabel, ctaUrl: verifyUrl, footerHtml: `

If you didn't create this account, you can ignore this message.

`, }), }) if (error) console.error('[study-verify-email] send error:', error) } catch (err) { console.error('[study-verify-email] send exception:', err) } } export async function sendStudyPasswordResetEmail(email, displayName, resetUrl) { 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 subject = cfg.passwordResetSubject?.trim() || 'Reset your password' const bodyText = cfg.passwordResetBody?.trim() || 'We received a request to reset the password on your study account. Click below to choose a new password. This link expires in 1 hour.' const ctaLabel = cfg.passwordResetCtaLabel?.trim() || 'Reset Password' const bodyHtml = ( `

Hi ${escapeHtml(namePart)},

` + `

${escapeHtml(bodyText)}

` ) 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${resetUrl}\n\nIf you didn't request this, you can safely ignore this email — your password won't change.\n\nVerse by Verse with Nate`, html: buildBrandedEmailHtml({ title: 'Reset Your Password', eyebrow: 'Account Security', bodyHtml, ctaLabel, ctaUrl: resetUrl, footerHtml: `

If you didn't request this, you can safely ignore this email — your password won't change.

`, }), }) if (error) console.error('[study-password-reset] send error:', error) } catch (err) { console.error('[study-password-reset] send exception:', err) } } export async function sendStudyPasswordChangedEmail(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.passwordChangedSubject?.trim() || 'Your password was changed' const bodyText = cfg.passwordChangedBody?.trim() || 'This confirms the password on your study account was just changed. All other active sessions have been signed out. If this wasn’t you, please contact us right away.' const bodyHtml = ( `

Hi ${escapeHtml(namePart)},

` + `

${escapeHtml(bodyText)}

` ) const { error } = await resend.emails.send({ from: process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM, to: [email], subject, text: `Hi ${namePart},\n\n${bodyText}\n\nManage your account: ${accountUrl}\n\nVerse by Verse with Nate`, html: buildBrandedEmailHtml({ title: 'Password Changed', eyebrow: 'Account Security', bodyHtml, ctaLabel: 'Manage Account', ctaUrl: accountUrl, footerHtml: `

Verse by Verse with Nate

`, }), }) if (error) console.error('[study-password-changed] send error:', error) } catch (err) { console.error('[study-password-changed] send 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: `

${escapeHtml(bodyText)}

` + `

${code}

` + `

${escapeHtml(expiryText)}

`, footerHtml: `

Verse by Verse with Nate

`, }), }) } 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 = ( `

Hi ${escapeHtml(namePart)},

` + `

${escapeHtml(bodyText)}

` ) const footerHtml = `

${escapeHtml(signoff).replace(/\n/g, '
')}

` 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 = ( `

Hi ${escapeHtml(namePart)},

` + `

${escapeHtml(bodyText)}

` + `

${escapeHtml(sectionTitle)} (${escapeHtml(sectionReference)}) — ${escapeHtml(studyTitle)}

` ) const footerHtml = `

${escapeHtml(signoff).replace(/\n/g, '
')}

` 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_DEFAULTS = { t1: { subject: 'Your study is waiting for you', body: "It's been a while — your progress is saved and your next lesson is ready whenever you are.", cta: 'Continue Studying' }, t2: { subject: "Don't lose your momentum", body: 'Every note and completed lesson is still right there. A little each day adds up — come back and continue.', cta: 'Return to Your Study' }, t3: { subject: 'Your progress is still here', body: "Your study progress is still intact and waiting. There's no deadline — come back whenever you're ready.", cta: 'Open Your Study' }, } function getReengagementCopy(tier) { const cfg = state.cachedSiteContent ?? {} const t = tier === 't1' ? '1' : tier === 't2' ? '2' : tier === 't3' ? '3' : null const def = REENGAGEMENT_DEFAULTS[tier] ?? REENGAGEMENT_DEFAULTS.t1 if (!t) return def return { subject: cfg[`studyReengagementT${t}Subject`]?.trim() || def.subject, body: cfg[`studyReengagementT${t}Body`]?.trim() || def.body, cta: cfg[`studyReengagementT${t}Cta`]?.trim() || def.cta, } } export async function sendStudyReengagementEmail(email, displayName, studyTitle, studyUrl, tier, unsubUrl) { if (!process.env.RESEND_API_KEY) return try { const resend = new Resend(process.env.RESEND_API_KEY) const copy = getReengagementCopy(tier) const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend' const subject = `${copy.subject}${studyTitle ? ` — ${studyTitle}` : ''}` const bodyHtml = ( `

Hi ${escapeHtml(namePart)},

` + `

${escapeHtml(copy.body)}${studyTitle ? ` Your current study: ${escapeHtml(studyTitle)}.` : ''}

` ) const unsubLine = unsubUrl ? `

Not interested? Unsubscribe from these reminders.

` : '' const footerHtml = `

Grace and peace,
Verse by Verse with Nate

${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.subject, eyebrow: 'Study Reminder', 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) } } export async function sendStudyCertificateEmail(email, displayName, studyTitle, certUrl) { 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 subject = cfg.studyCertificateEmailSubject?.trim() || 'Your certificate is ready' const bodyText = cfg.studyCertificateEmailBody?.trim() || 'Congratulations — you have completed every lesson in this study. Your certificate is ready to view and share.' const ctaLabel = cfg.studyCertificateEmailCtaLabel?.trim() || 'View Certificate' const signoff = cfg.studyCertificateEmailSignoff?.trim() || 'Grace and peace,\nVerse by Verse with Nate' const bodyHtml = ( `

Congratulations, ${escapeHtml(namePart)}!

` + `

${escapeHtml(bodyText)}

` + `

${escapeHtml(studyTitle)}

` ) const footerHtml = `

${escapeHtml(signoff).replace(/\n/g, '
')}

` const { error } = await resend.emails.send({ from: process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM, to: [email], subject, text: `Congratulations, ${namePart}!\n\n${bodyText}\n\n${studyTitle}\n\nView your certificate: ${certUrl}\n\n${signoff}`, html: buildBrandedEmailHtml({ title: 'Study Complete', eyebrow: 'Certificate of Completion', bodyHtml, ctaLabel, ctaUrl: certUrl, footerHtml, }), }) if (error) console.error('[study-certificate] email send error:', error) } catch (err) { console.error('[study-certificate] email exception:', err) } } export async function sendStudyWeeklyDigestEmail(email, displayName, lessons, unsubUrl) { 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 subject = cfg.studyWeeklyDigestEmailSubject?.trim() || 'Your weekly study digest' const introText = cfg.studyWeeklyDigestEmailBody?.trim() || 'Here are the lessons released this week in your study track. Open any to continue where you left off.' const signoff = cfg.studyWeeklyDigestEmailSignoff?.trim() || 'Grace and peace,\nVerse by Verse with Nate' const lessonListHtml = lessons.map(l => `

${escapeHtml(l.sectionTitle)}` + (l.sectionReference ? ` (${escapeHtml(l.sectionReference)})` : '') + (l.studyTitle ? ` — ${escapeHtml(l.studyTitle)}` : '') + `

` ).join('') const lessonListText = lessons.map(l => `• ${l.sectionTitle}${l.sectionReference ? ` (${l.sectionReference})` : ''}: ${l.sectionUrl}`).join('\n') const bodyHtml = ( `

Hi ${escapeHtml(namePart)},

` + `

${escapeHtml(introText)}

` + `
${lessonListHtml}
` ) const unsubLine = unsubUrl ? `

Not interested? Unsubscribe from these reminders.

` : '' const footerHtml = `

${escapeHtml(signoff).replace(/\n/g, '
')}

${unsubLine}` const { error } = await resend.emails.send({ from: process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM, to: [email], subject, text: `Hi ${namePart},\n\n${introText}\n\n${lessonListText}\n\n${signoff}${unsubUrl ? `\n\nUnsubscribe: ${unsubUrl}` : ''}`, html: buildBrandedEmailHtml({ title: 'Weekly Study Digest', eyebrow: 'Study Reminder', bodyHtml, footerHtml, }), }) if (error) console.error('[study-digest] send error:', error) } catch (err) { console.error('[study-digest] send exception:', err) } } // ── Nuggets upload notifications ─────────────────────────────────────────── export async function sendNuggetsUploadNotification({ to, source, hostName, episodeName, passage, description, filename, infoFile }) { if (!process.env.RESEND_API_KEY) return const resend = new Resend(process.env.RESEND_API_KEY) const from = process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM const sourceLabel = source === 'portal' ? 'Upload Portal' : 'Direct to Seafile' const subject = `New Nuggets Episode Upload: ${episodeName || filename}` const bodyHtml = `

A new episode file has arrived in Seafile.

${hostName ? `` : ''} ${episodeName ? `` : ''} ${passage ? `` : ''} ${infoFile ? `` : ''}
Source${escapeHtml(sourceLabel)}
Host${escapeHtml(hostName)}
Episode${escapeHtml(episodeName)}
Passage${escapeHtml(passage)}
Audio File${escapeHtml(filename)}
Info File${escapeHtml(infoFile)}
${description ? `

Description:
${escapeHtml(description).replace(/\n/g, '
')}

` : ''} ` const textBody = [ `New Nuggets Episode Upload (${sourceLabel})`, '', hostName ? `Host: ${hostName}` : '', episodeName ? `Episode: ${episodeName}` : '', passage ? `Passage: ${passage}` : '', `Audio File: ${filename}`, infoFile ? `Info File: ${infoFile}` : '', description ? `\nDescription:\n${description}` : '', ].filter(Boolean).join('\n') try { const { error } = await resend.emails.send({ from, to: [to], subject, text: textBody, html: buildBrandedEmailHtml({ title: 'New Episode Upload', eyebrow: 'Nuggets', bodyHtml, footerHtml: '

This notification was sent because a file was uploaded to the Nuggets Seafile library.

', }), }) if (error) console.error('[nuggets-notify] send error:', error) } catch (err) { console.error('[nuggets-notify] send exception:', err) } } export async function sendNuggetsHostWelcomeEmail({ to, name, username, password, portalUrl }) { if (!process.env.RESEND_API_KEY || !to) return const resend = new Resend(process.env.RESEND_API_KEY) const from = process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM const bodyHtml = `

Hi ${escapeHtml(name)},

You've been set up as a host on the Nuggets episode upload portal. Use the details below to sign in and submit your episodes.

Portal URL${escapeHtml(portalUrl)}
Email${escapeHtml(username)}
Password${escapeHtml(password)}

When you're ready to submit an episode, fill in the episode name, the Bible passage, a short description, and attach your audio file. Everything uploads in one step.

If you have any trouble signing in, reply to this email and we'll get you sorted.

` try { const { error } = await resend.emails.send({ from, to: [to], subject: 'Your Nuggets Host Upload Portal Access', text: `Hi ${name},\n\nYou've been set up as a host on the Nuggets episode upload portal.\n\nPortal URL: ${portalUrl}\nUsername: ${username}\nPassword: ${password}\n\nWhen submitting an episode, fill in the episode name, Bible passage, a short description, and attach your audio file.\n\nIf you have any trouble, reply to this email.`, html: buildBrandedEmailHtml({ title: 'Upload Portal Access', eyebrow: 'Nuggets', bodyHtml, ctaLabel: 'Open Upload Portal', ctaUrl: portalUrl, footerHtml: '

You received this because an admin created an upload account for you on the Nuggets podcast portal.

', }), }) if (error) console.error('[nuggets-welcome] send error:', error) } catch (err) { console.error('[nuggets-welcome] send exception:', err) } } export async function sendNuggetsHostOtpEmail({ to, name, code }) { if (!process.env.RESEND_API_KEY || !to) return const resend = new Resend(process.env.RESEND_API_KEY) const from = process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM const bodyHtml = `

Hi ${escapeHtml(name)},

Your one-time sign-in code for the Nuggets host portal is below. It expires in 10 minutes.

${escapeHtml(code)}

If you did not request this code, you can safely ignore this email.

` try { const { error } = await resend.emails.send({ from, to: [to], subject: 'Your Nuggets Portal Sign-In Code', text: `Hi ${name},\n\nYour one-time sign-in code is: ${code}\n\nIt expires in 10 minutes. If you did not request this, ignore this email.`, html: buildBrandedEmailHtml({ title: 'Sign-In Code', eyebrow: 'Nuggets', bodyHtml, footerHtml: '

You received this because a sign-in was attempted on the Nuggets podcast host portal.

', }), }) if (error) console.error('[nuggets-otp] send error:', error) } catch (err) { console.error('[nuggets-otp] send exception:', err) } }