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 ? `
` : '' return ( `` +
`
|
|
Welcome, ${escapeHtml(namePart)}.
` + `${escapeHtml(bodyText)}
` ) const footerHtml = `${escapeHtml(signoff).replace(/\n/g, '
')}
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, '
')}
Hi ${escapeHtml(namePart)},
` + `${escapeHtml(bodyText)}
` + `${escapeHtml(sectionTitle)} (${escapeHtml(sectionReference)}) — ${escapeHtml(studyTitle)}
` ) const footerHtml = `${escapeHtml(signoff).replace(/\n/g, '
')}
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
Congratulations, ${escapeHtml(namePart)}!
` + `${escapeHtml(bodyText)}
` + `${escapeHtml(studyTitle)}
` ) const footerHtml = `${escapeHtml(signoff).replace(/\n/g, '
')}
${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)}
` + `Not interested? Unsubscribe from these reminders.
` : '' const footerHtml = `${escapeHtml(signoff).replace(/\n/g, '
')}
A new episode file has arrived in Seafile.
| Source | ${escapeHtml(sourceLabel)} |
| Host | ${escapeHtml(hostName)} |
| Episode | ${escapeHtml(episodeName)} |
| Passage | ${escapeHtml(passage)} |
| Audio File | ${escapeHtml(filename)} |
| Info File | ${escapeHtml(infoFile)} |
Description:
${escapeHtml(description).replace(/\n/g, '
')}
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)} |
| ${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.
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) } }