Files
Siteforge/server/routes/contact.js
T
nmemmertandClaude Sonnet 5.5 2a64f29a00
Publish Container / build (push) Successful in 15m46s
Second hardening pass: atomic data writes, bible-passage limits, webhook SSRF guard, note sanitizing, CI gates; v2.10.1
- Atomic temp-file + rename for all JSON data writes (no truncated files on crash)
- /api/bible-passage: rate limit, range caps (60 verses), bounded cache
- Admin webhooks: https-only, block private/internal/Tailscale addresses (checked at registration and delivery), no redirects
- Resend webhook token compare is timing-safe
- DOMPurify on rendered study notes and broadcast preview
- CI runs lint + tests before build
- Permissions-Policy and HSTS (production) headers; JSON error handler for /api
- Tests for URL safety and atomic writes

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 09:00:28 -04:00

797 lines
36 KiB
JavaScript

import { randomUUID, timingSafeEqual } from 'node:crypto'
import { Resend } from 'resend'
import { requireAdminAuth } from '../auth.js'
import { escapeHtml, splitName, buildAbsoluteUrl } from '../helpers.js'
import {
MAX_CONTACT_SUBMISSIONS,
MAX_QUESTIONS,
USE_RESEND_AUTOMATION_WELCOME,
DEFAULT_SEO,
DATA_FILE,
} from '../config.js'
import { state } from '../state.js'
import {
queueContactSubmissionsWrite,
queueQuestionsWrite,
queueDraftQuestionsWrite,
normalizeContactEmailStatus,
normalizeMessageType,
fireWebhooks,
loadSiteContentFile,
} from '../data.js'
import {
noteContactEmailCooldown,
extractTagValue,
mapResendEventToStatus,
extractResendMessageId,
} from '../study-helpers.js'
import {
getResendFromAddress,
getResendReplyToAddress,
getResendInboxAddress,
buildContactWelcomeEmailTemplate,
buildContactAdminNotificationTemplate,
sendResendEmailWithRetry,
syncContactToResend,
resendRetry,
} from '../email.js'
// RFC 5322 msg-id: "<" printable-ASCII-no-whitespace ">"
const MESSAGE_ID_RE = /^<[\x21-\x7E]+>$/
function upsertContactEmailStatus(submissionId, stream, patch) {
if (!submissionId || typeof submissionId !== 'string') return
if (!stream || typeof stream !== 'string') return
const at = typeof patch?.lastEventAt === 'string' ? patch.lastEventAt : new Date().toISOString()
let updated = false
state.contactSubmissions = state.contactSubmissions.map(submission => {
if (submission.id !== submissionId) return submission
const next = normalizeContactEmailStatus(submission.emailStatus, submission.subscribe === true)
const current = next[stream] ?? { status: 'pending', lastEventAt: null, lastEventType: null, resendEmailId: null, error: null }
next[stream] = { ...current, ...patch, lastEventAt: at }
updated = true
return { ...submission, emailStatus: next }
})
if (updated) queueContactSubmissionsWrite()
}
function registerResendMessageForSubmission(submissionId, stream, sendResult) {
const resendMessageId = extractResendMessageId(sendResult)
if (!resendMessageId || !submissionId || !stream) return
state.resendEmailSubmissionIndex.set(resendMessageId, { submissionId, stream })
// Trim the index when it grows large; oldest entries are least likely to receive webhooks
if (state.resendEmailSubmissionIndex.size > 2000) {
const firstKey = state.resendEmailSubmissionIndex.keys().next().value
state.resendEmailSubmissionIndex.delete(firstKey)
}
upsertContactEmailStatus(submissionId, stream, { resendEmailId: resendMessageId })
}
function shouldSendWelcomeEmail({ subscribe }) {
return subscribe === true
}
const contactHits = new Map()
function contactRateLimit(req, res, next) {
const ip = req.ip ?? 'unknown'
const now = Date.now()
const windowMs = 10 * 60 * 1000
const entry = contactHits.get(ip) ?? { count: 0, start: now }
if (now - entry.start > windowMs) { entry.count = 0; entry.start = now }
entry.count += 1
contactHits.set(ip, entry)
// Prune stale entries to prevent unbounded growth
if (contactHits.size > 5000) {
const cutoff = now - windowMs
for (const [k, v] of contactHits) { if (v.start < cutoff) contactHits.delete(k) }
}
if (entry.count > 5) {
res.status(429).json({ message: 'Too many messages. Please wait a few minutes.' })
return
}
next()
}
export function register(app) {
app.post('/api/contact', contactRateLimit, async (req, res) => {
try {
const { firstName, lastName, email, message, messageType, subscribe, notifyOnAnswer, _honey } = req.body ?? {}
if (_honey) { res.json({ ok: true }); return }
if (!firstName || typeof firstName !== 'string' || firstName.trim().length < 1 || firstName.trim().length > 100) {
res.status(400).json({ message: 'First name is required.' }); return
}
if (lastName !== undefined && lastName !== null && (typeof lastName !== 'string' || lastName.trim().length > 100)) {
res.status(400).json({ message: 'Last name is too long.' }); return
}
if (!email || typeof email !== 'string' || !/^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(email.trim())) {
res.status(400).json({ message: 'A valid email address is required.' }); return
}
if (!message || typeof message !== 'string' || message.trim().length < 5 || message.trim().length > 3000) {
res.status(400).json({ message: 'Message must be between 5 and 3000 characters.' }); return
}
if (!process.env.RESEND_API_KEY) {
console.error('[contact] RESEND_API_KEY env var not set')
res.status(503).json({ message: 'The contact form is not yet configured on the server.' }); return
}
const trimmedName = [firstName.trim(), typeof lastName === 'string' ? lastName.trim() : ''].filter(Boolean).join(' ')
const trimmedEmail = email.trim()
const trimmedMessage = message.trim()
const normalizedMessageType = normalizeMessageType(messageType)
const cooldown = noteContactEmailCooldown(trimmedEmail)
if (!cooldown.ok) {
const retryAfterSeconds = Math.max(1, Math.ceil(cooldown.retryAfterMs / 1000))
res.status(429).json({ message: `Please wait ${retryAfterSeconds}s before sending another message from this email.` }); return
}
const submittedAt = new Date().toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' })
const shouldSendWelcome = shouldSendWelcomeEmail({ subscribe })
const submission = {
id: randomUUID(),
submittedAt: new Date().toISOString(),
name: trimmedName,
email: trimmedEmail,
message: trimmedMessage,
messageType: normalizedMessageType,
subscribe: shouldSendWelcome,
archived: false,
emailStatus: normalizeContactEmailStatus(null, shouldSendWelcome),
}
state.contactSubmissions.unshift(submission)
state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS)
queueContactSubmissionsWrite()
fireWebhooks('contact.new', { name: trimmedName, email: trimmedEmail, message: trimmedMessage, messageType: normalizedMessageType, submittedAt: submission.submittedAt })
if (normalizedMessageType === 'question') {
const question = {
id: randomUUID(),
submittedAt: new Date().toISOString(),
firstName: splitName(trimmedName).firstName,
email: trimmedEmail,
question: trimmedMessage,
answer: '',
answeredAt: null,
isApproved: false,
approvedAt: null,
notifyOnAnswer: notifyOnAnswer === true,
}
state.questions.unshift(question)
state.questions = state.questions.slice(0, MAX_QUESTIONS)
if (state.draftQuestions !== null) {
state.draftQuestions.unshift(question)
state.draftQuestions = state.draftQuestions.slice(0, MAX_QUESTIONS)
queueDraftQuestionsWrite()
}
queueQuestionsWrite()
}
const resend = new Resend(process.env.RESEND_API_KEY)
const adminInbox = getResendInboxAddress()
const replyToAddress = getResendReplyToAddress()
const fromAddress = getResendFromAddress()
const safeMessageTypeTag = normalizedMessageType.replace(/[^a-z0-9_-]/gi, '-').toLowerCase()
const adminTemplate = buildContactAdminNotificationTemplate({ normalizedMessageType, trimmedName, trimmedEmail, submittedAt, trimmedMessage })
let welcomeSent = false
if (subscribe === true) {
await syncContactToResend(trimmedName, trimmedEmail)
}
if (shouldSendWelcome && !USE_RESEND_AUTOMATION_WELCOME) {
const greetingName = splitName(trimmedName).firstName?.trim() ?? ''
let publishedSiteContent = state.cachedSiteContent
if (!publishedSiteContent) {
try {
const published = await loadSiteContentFile(DATA_FILE)
publishedSiteContent = published?.siteContent ?? null
} catch { publishedSiteContent = null }
}
const emailConfig = publishedSiteContent ?? {}
const welcomeBaseUrl = typeof emailConfig?.seo?.canonicalUrl === 'string' && emailConfig.seo.canonicalUrl.trim()
? emailConfig.seo.canonicalUrl.trim()
: DEFAULT_SEO.canonicalUrl
const welcomeSubject = process.env.RESEND_WELCOME_SUBJECT ?? emailConfig.welcomeEmailSubject ?? 'Welcome to Verse by Verse with Nate'
const welcomeGreetingPrefix = emailConfig.welcomeEmailGreetingPrefix?.trim() || "Glad you're here"
const welcomeTemplate = buildContactWelcomeEmailTemplate({
greetingName,
welcomeGreetingPrefix,
welcomeIntro: emailConfig.welcomeEmailIntro?.trim() || 'Thanks for subscribing to Verse by Verse with Nate - a Bible teaching podcast where we slow down, dig into the text, and pull out the nuggets God has for us word by word.',
welcomeCurrentSeries: emailConfig.welcomeEmailCurrentSeries?.trim() || "Right now we're working through the book of Titus - a short letter packed with practical wisdom about grace, godliness, and what the Christian life looks like when it's rooted in sound doctrine.",
welcomeStartHereTitle: emailConfig.welcomeEmailStartHereTitle?.trim() || 'Episode 1 - Introduction to Titus',
welcomeStartHereSummary: emailConfig.welcomeEmailStartHereSummary?.trim() || 'Who wrote it, who received it, and why it still matters.',
welcomeExpect1: emailConfig.welcomeEmailWhatToExpect1?.trim() || 'Verse-by-verse teaching - we go slow and let the text speak for itself.',
welcomeExpect2: emailConfig.welcomeEmailWhatToExpect2?.trim() || 'Greek word studies - the kind that open up meaning without being a lecture.',
welcomeExpect3: emailConfig.welcomeEmailWhatToExpect3?.trim() || 'New episodes + study notes delivered right to your inbox.',
welcomeScripture: emailConfig.welcomeEmailScripture?.trim() || 'For the grace of God has appeared, bringing salvation to all people.',
welcomeScriptureRef: emailConfig.welcomeEmailScriptureRef?.trim() || 'Titus 2:11 - BSB',
welcomeSignoff: emailConfig.welcomeEmailSignoff?.trim() || 'Grace and peace,\nNate',
welcomeSpotifyUrl: buildAbsoluteUrl(welcomeBaseUrl, process.env.RESEND_WELCOME_SPOTIFY_URL ?? emailConfig.welcomeEmailSpotifyUrl ?? '/spotify'),
welcomeAppleUrl: buildAbsoluteUrl(welcomeBaseUrl, process.env.RESEND_WELCOME_APPLE_URL ?? emailConfig.welcomeEmailAppleUrl ?? '/apple'),
welcomeAmazonUrl: buildAbsoluteUrl(welcomeBaseUrl, process.env.RESEND_WELCOME_AMAZON_URL ?? emailConfig.welcomeEmailAmazonUrl ?? '/amazon'),
welcomeWebsiteUrl: buildAbsoluteUrl(welcomeBaseUrl, process.env.RESEND_WELCOME_WEBSITE_URL ?? emailConfig.welcomeEmailWebsiteUrl ?? '/'),
welcomeEpisodeUrl: buildAbsoluteUrl(welcomeBaseUrl, process.env.RESEND_WELCOME_EPISODE_URL ?? emailConfig.welcomeEmailStartHereUrl ?? '/start-here'),
welcomeImageUrl: buildAbsoluteUrl(welcomeBaseUrl, process.env.RESEND_WELCOME_IMAGE_URL ?? emailConfig.welcomeEmailImageUrl ?? '/images/podcast-art.jpeg'),
welcomeSpotifyBtnLabel: emailConfig.welcomeEmailSpotifyBtnLabel?.trim() || 'Listen on Spotify',
welcomeAppleBtnLabel: emailConfig.welcomeEmailAppleBtnLabel?.trim() || 'Apple Podcasts',
welcomeStartHereLinkLabel: emailConfig.welcomeEmailStartHereLinkLabel?.trim() || 'Open Start Here page',
})
try {
const welcomeSendResult = await sendResendEmailWithRetry({
resend,
context: 'contact-welcome',
payload: {
from: fromAddress,
to: [trimmedEmail],
replyTo: replyToAddress,
subject: welcomeSubject,
tags: [
{ name: 'flow', value: 'contact-welcome' },
{ name: 'message_type', value: safeMessageTypeTag },
{ name: 'submission_id', value: submission.id },
],
headers: {
'List-Unsubscribe': `<mailto:${replyToAddress}?subject=Unsubscribe>`,
'X-Contact-Submission-Id': submission.id,
},
text: welcomeTemplate.text,
html: welcomeTemplate.html,
},
})
registerResendMessageForSubmission(submission.id, 'welcome', welcomeSendResult)
upsertContactEmailStatus(submission.id, 'welcome', { status: 'sent', lastEventType: 'email.sent', error: null })
welcomeSent = true
} catch (welcomeErr) {
upsertContactEmailStatus(submission.id, 'welcome', {
status: 'failed',
lastEventType: 'email.failed',
error: String(welcomeErr?.message ?? welcomeErr ?? 'unknown error').slice(0, 600),
})
console.error('[contact] welcome email failed:', welcomeErr)
// Submission is already saved — don't 500 the user; fall through to admin notification.
}
} else if (shouldSendWelcome && USE_RESEND_AUTOMATION_WELCOME) {
upsertContactEmailStatus(submission.id, 'welcome', { status: 'automation-enabled', lastEventType: 'email.automation.enabled', error: null })
}
try {
const adminSendResult = await sendResendEmailWithRetry({
resend,
context: 'contact-admin-notification',
payload: {
from: fromAddress,
to: [adminInbox],
replyTo: trimmedEmail,
subject: adminTemplate.subject,
tags: [
{ name: 'flow', value: 'contact-admin' },
{ name: 'message_type', value: safeMessageTypeTag },
{ name: 'submission_id', value: submission.id },
],
headers: { 'X-Contact-Submission-Id': submission.id },
text: adminTemplate.text,
html: adminTemplate.html,
},
})
registerResendMessageForSubmission(submission.id, 'adminNotification', adminSendResult)
upsertContactEmailStatus(submission.id, 'adminNotification', { status: 'sent', lastEventType: 'email.sent', error: null })
} catch (adminSendErr) {
upsertContactEmailStatus(submission.id, 'adminNotification', {
status: 'failed',
lastEventType: 'email.failed',
error: String(adminSendErr?.message ?? adminSendErr ?? 'unknown error').slice(0, 600),
})
console.error('[contact] admin notification email failed:', adminSendErr)
// Submission is already saved — don't 500 the user.
}
res.json({ ok: true, welcomeSent, welcomeHandledByAutomation: shouldSendWelcome && USE_RESEND_AUTOMATION_WELCOME })
} catch (err) {
console.error('[contact] send error:', err)
res.status(500).json({ message: 'Failed to send your message. Please try again or email us directly.' })
}
})
app.post('/api/resend/webhook', (req, res) => {
const expectedToken = typeof process.env.RESEND_WEBHOOK_TOKEN === 'string' ? process.env.RESEND_WEBHOOK_TOKEN.trim() : ''
if (!expectedToken) {
res.status(503).json({ message: 'Webhook token is not configured.' }); return
}
const providedToken = (req.get('x-webhook-token') || '').trim()
|| (req.get('x-resend-webhook-token') || '').trim()
|| String(req.query?.token || '').trim()
|| (req.get('authorization') || '').replace(/^Bearer\s+/i, '').trim()
const tokenA = Buffer.from(providedToken, 'utf8')
const tokenB = Buffer.from(expectedToken, 'utf8')
if (!providedToken || tokenA.length !== tokenB.length || !timingSafeEqual(tokenA, tokenB)) {
res.status(401).json({ message: 'Unauthorized webhook.' }); return
}
const body = req.body && typeof req.body === 'object' ? req.body : {}
const eventType = typeof body.type === 'string' ? body.type.trim() : ''
const data = body.data && typeof body.data === 'object' ? body.data : {}
const tags = Array.isArray(data.tags) ? data.tags : []
const resendMessageId = (
typeof data.email_id === 'string' && data.email_id.trim()
? data.email_id.trim()
: (typeof data.emailId === 'string' && data.emailId.trim()
? data.emailId.trim()
: (typeof data.id === 'string' && data.id.trim() ? data.id.trim() : ''))
)
const indexed = resendMessageId ? state.resendEmailSubmissionIndex.get(resendMessageId) : null
const taggedSubmissionId = extractTagValue(tags, 'submission_id')
const submissionId = indexed?.submissionId || taggedSubmissionId
const flow = extractTagValue(tags, 'flow')
const stream = indexed?.stream
|| (flow === 'contact-welcome' ? 'welcome' : '')
|| (flow === 'contact-admin' ? 'adminNotification' : '')
|| (flow === 'admin-reply' ? 'adminReply' : '')
if (!submissionId || !stream) {
res.json({ ok: true, ignored: true }); return
}
upsertContactEmailStatus(submissionId, stream, {
status: mapResendEventToStatus(eventType),
lastEventType: eventType || 'webhook.event',
resendEmailId: resendMessageId || null,
error: typeof data?.message === 'string' ? data.message.slice(0, 600) : null,
})
res.json({ ok: true })
})
app.get('/api/admin-contact-submissions', requireAdminAuth, (_req, res) => {
res.json({ submissions: state.contactSubmissions })
})
app.patch('/api/admin-contact-submissions/:id', requireAdminAuth, (req, res) => {
const { id } = req.params
const { archived, read } = req.body ?? {}
if (typeof archived !== 'boolean' && typeof read !== 'boolean') {
res.status(400).json({ message: 'archived or read must be a boolean.' }); return
}
const idx = state.contactSubmissions.findIndex(s => s.id === id)
if (idx === -1) {
res.status(404).json({ message: 'Submission not found.' }); return
}
const patch = {}
if (typeof archived === 'boolean') patch.archived = archived
if (typeof read === 'boolean') patch.read = read
state.contactSubmissions[idx] = { ...state.contactSubmissions[idx], ...patch }
queueContactSubmissionsWrite()
res.json({ ok: true, submission: state.contactSubmissions[idx] })
})
app.post('/api/admin-contact-submissions/bulk', requireAdminAuth, (req, res) => {
const { ids, action } = req.body ?? {}
const actions = ['archive', 'unarchive', 'read', 'unread', 'delete']
if (!Array.isArray(ids) || ids.length === 0 || ids.length > 500 || !ids.every(id => typeof id === 'string') || !actions.includes(action)) {
res.status(400).json({ message: 'Provide ids (1-500) and a valid action.' }); return
}
const idSet = new Set(ids)
let count = 0
if (action === 'delete') {
const before = state.contactSubmissions.length
state.contactSubmissions = state.contactSubmissions.filter(s => !idSet.has(s.id))
count = before - state.contactSubmissions.length
} else {
state.contactSubmissions = state.contactSubmissions.map(s => {
if (!idSet.has(s.id)) return s
count += 1
if (action === 'archive') return { ...s, archived: true }
if (action === 'unarchive') return { ...s, archived: false }
return { ...s, read: action === 'read' }
})
}
queueContactSubmissionsWrite()
res.json({ ok: true, count })
})
app.get('/api/admin-subscribers', requireAdminAuth, (_req, res) => {
const seen = new Set()
const subscribers = state.contactSubmissions
.filter(entry => entry.subscribe === true && entry.email && !seen.has(entry.email) && seen.add(entry.email))
.map(entry => ({
name: entry.name,
email: entry.email,
subscribedAt: entry.submittedAt,
source: entry.source === 'resend-import' ? 'resend-import' : entry.message?.startsWith('Requested') ? 'download' : 'contact-form',
}))
.sort((a, b) => new Date(b.subscribedAt).getTime() - new Date(a.subscribedAt).getTime())
res.json({ subscribers, total: subscribers.length })
})
app.post('/api/admin-subscribers/export', requireAdminAuth, (_req, res) => {
const seen = new Set()
const rows = [['Name', 'Email', 'Subscribed At', 'Source']]
state.contactSubmissions
.filter(entry => entry.subscribe === true && entry.email && !seen.has(entry.email) && seen.add(entry.email))
.sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime())
.forEach(entry => {
const source = entry.message?.startsWith('Requested') ? 'download' : 'contact-form'
rows.push([entry.name, entry.email, entry.submittedAt, source])
})
const csv = rows.map(row => row.map(cell => `"${String(cell ?? '').replace(/"/g, '""')}"`).join(',')).join('\n')
res.setHeader('Content-Type', 'text/csv')
res.setHeader('Content-Disposition', `attachment; filename="subscribers-${new Date().toISOString().slice(0, 10)}.csv"`)
res.send(csv)
})
// ── Subscribers ⇄ Resend comparison ─────────────────────────────────────────
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
function getAppSubscribers() {
const byEmail = new Map()
for (const entry of state.contactSubmissions) {
if (entry.subscribe !== true || !entry.email) continue
const email = String(entry.email).trim().toLowerCase()
if (!byEmail.has(email)) byEmail.set(email, { name: entry.name ?? '', email })
}
return byEmail
}
async function listAllResendContacts(resend, segmentId) {
const out = []
let after
for (let page = 0; page < 50; page += 1) {
const { data, error } = await resendRetry(() => resend.contacts.list({ limit: 100, ...(segmentId ? { segmentId } : {}), ...(after ? { after } : {}) }))
if (error) throw new Error(error.message || 'Resend contacts.list failed')
const rows = data?.data ?? []
out.push(...rows)
if (!data?.has_more || rows.length === 0) break
after = rows[rows.length - 1].id
await sleep(550) // Resend allows ~2 requests/second
}
return out
}
async function buildResendComparison() {
const resend = new Resend(process.env.RESEND_CONTACTS_API_KEY ?? process.env.RESEND_API_KEY)
const segmentId = process.env.RESEND_SEGMENT_ID || null
const appSubs = getAppSubscribers()
const resendContacts = await listAllResendContacts(resend)
let segmentEmails = null
if (segmentId) {
await sleep(550)
segmentEmails = new Set((await listAllResendContacts(resend, segmentId)).map(c => String(c.email).toLowerCase()))
}
const resendByEmail = new Map(resendContacts.map(c => [String(c.email).toLowerCase(), c]))
const nameOf = c => [c.first_name, c.last_name].filter(Boolean).join(' ')
const onlyInApp = []
const unsubscribedInResend = []
const notInSegment = []
let matched = 0
for (const sub of appSubs.values()) {
const contact = resendByEmail.get(sub.email)
if (!contact) { onlyInApp.push(sub); continue }
if (contact.unsubscribed) { unsubscribedInResend.push(sub); continue }
if (segmentEmails && !segmentEmails.has(sub.email)) { notInSegment.push(sub); continue }
matched += 1
}
const onlyInResend = resendContacts
.filter(c => !appSubs.has(String(c.email).toLowerCase()) && !c.unsubscribed)
.map(c => ({ name: nameOf(c), email: String(c.email).toLowerCase() }))
return {
appCount: appSubs.size,
resendCount: resendContacts.length,
resendUnsubscribedCount: resendContacts.filter(c => c.unsubscribed).length,
segmentConfigured: Boolean(segmentId),
segmentCount: segmentEmails ? segmentEmails.size : null,
matched,
onlyInApp,
onlyInResend,
unsubscribedInResend,
notInSegment,
}
}
app.get('/api/admin-subscribers/resend-compare', requireAdminAuth, async (_req, res) => {
if (!process.env.RESEND_API_KEY) {
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
}
try {
res.json(await buildResendComparison())
} catch (err) {
console.error('[subscribers] resend compare failed:', err)
res.status(502).json({ message: 'Could not read contacts from Resend. Try again in a moment.' })
}
})
app.post('/api/admin-subscribers/resend-sync', requireAdminAuth, async (req, res) => {
const { action } = req.body ?? {}
if (!['add-to-resend', 'add-to-segment', 'apply-unsubscribes'].includes(action)) {
res.status(400).json({ message: 'Unknown action.' }); return
}
if (!process.env.RESEND_API_KEY) {
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
}
try {
const diff = await buildResendComparison()
if (action === 'apply-unsubscribes') {
const emails = new Set(diff.unsubscribedInResend.map(s => s.email))
let changed = 0
state.contactSubmissions = state.contactSubmissions.map(entry => {
if (entry.subscribe === true && emails.has(String(entry.email ?? '').trim().toLowerCase())) {
changed += 1
return { ...entry, subscribe: false }
}
return entry
})
if (changed > 0) queueContactSubmissionsWrite()
res.json({ ok: true, done: emails.size, failed: 0 }); return
}
const resend = new Resend(process.env.RESEND_CONTACTS_API_KEY ?? process.env.RESEND_API_KEY)
const segmentId = process.env.RESEND_SEGMENT_ID || null
const targets = (action === 'add-to-resend' ? diff.onlyInApp : diff.notInSegment).slice(0, 100)
if (action === 'add-to-segment' && !segmentId) {
res.status(400).json({ message: 'RESEND_SEGMENT_ID is not configured.' }); return
}
let done = 0
let failed = 0
for (const target of targets) {
try {
if (action === 'add-to-resend') {
const { firstName, lastName } = splitName(target.name)
const { error } = await resendRetry(() => resend.contacts.create({
email: target.email, firstName, lastName, unsubscribed: false,
...(segmentId ? { segments: [{ id: segmentId }] } : {}),
}))
if (error) throw new Error(error.message)
} else {
const { error } = await resendRetry(() => resend.contacts.segments.add({ email: target.email, segmentId }))
if (error) throw new Error(error.message)
}
done += 1
} catch (err) {
failed += 1
console.error(`[subscribers] resend sync failed for ${target.email}:`, err?.message ?? err)
}
await sleep(550)
}
const total = action === 'add-to-resend' ? diff.onlyInApp.length : diff.notInSegment.length
res.json({ ok: true, done, failed, remaining: Math.max(total - targets.length, 0) })
} catch (err) {
console.error('[subscribers] resend sync failed:', err)
res.status(502).json({ message: 'Resend sync failed. Try again in a moment.' })
}
})
app.get('/api/admin-broadcast/templates', requireAdminAuth, async (_req, res) => {
if (!process.env.RESEND_API_KEY) {
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
}
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const templates = []
let after
for (let page = 0; page < 5; page += 1) {
const { data, error } = await resendRetry(() => resend.templates.list({ limit: 100, ...(after ? { after } : {}) }))
if (error) throw new Error(error.message)
templates.push(...(data?.data ?? []))
if (!data?.has_more || !data.data?.length) break
after = data.data[data.data.length - 1].id
}
res.json({
templates: templates
.map(t => ({ id: t.id, name: t.name, status: t.status, updatedAt: t.updated_at, publishedAt: t.published_at }))
.sort((a, b) => String(b.updatedAt ?? '').localeCompare(String(a.updatedAt ?? ''))),
})
} catch (err) {
console.error('[admin-broadcast] template list failed:', err)
res.status(502).json({ message: 'Could not load templates from Resend.' })
}
})
app.get('/api/admin-broadcast/templates/:id', requireAdminAuth, async (req, res) => {
if (!process.env.RESEND_API_KEY) {
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
}
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const { data, error } = await resendRetry(() => resend.templates.get(req.params.id))
if (error || !data) {
res.status(404).json({ message: error?.message || 'Template not found.' }); return
}
// Resend variables look like {{{KEY}}}; fill in their saved fallback values, but keep name for per-subscriber personalization.
let html = data.html ?? ''
for (const v of data.variables ?? []) {
if (/^(first_?name|name)$/i.test(v.key)) continue
if (v.fallback_value === null || v.fallback_value === undefined) continue
html = html.replace(new RegExp(`\\{\\{\\{\\s*${v.key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\}\\}\\}`, 'g'), escapeHtml(String(v.fallback_value)))
}
res.json({
id: data.id,
name: data.name,
subject: data.subject ?? '',
html,
hasUnsubscribeLink: /\{\{\{\s*RESEND_UNSUBSCRIBE_URL\s*\}\}\}/i.test(html),
unresolved: [...new Set([...html.matchAll(/\{\{\{\s*([A-Za-z0-9_]+)\s*\}\}\}/g)].map(m => m[1]))]
.filter(k => !/^(RESEND_UNSUBSCRIBE_URL|first_?name|name)$/i.test(k)),
})
} catch (err) {
console.error('[admin-broadcast] template get failed:', err)
res.status(502).json({ message: 'Could not load that template from Resend.' })
}
})
// Import chosen Resend-only contacts into the app's subscriber list (explicit, per-person opt-in by the admin).
app.post('/api/admin-subscribers/import-from-resend', requireAdminAuth, async (req, res) => {
const { emails } = req.body ?? {}
if (!Array.isArray(emails) || emails.length === 0 || emails.length > 200 || !emails.every(e => typeof e === 'string')) {
res.status(400).json({ message: 'Choose between 1 and 200 contacts to import.' }); return
}
if (!process.env.RESEND_API_KEY) {
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
}
try {
const diff = await buildResendComparison()
const allowed = new Map(diff.onlyInResend.map(c => [c.email, c]))
const now = new Date().toISOString()
const created = []
for (const raw of emails) {
const email = raw.trim().toLowerCase()
const contact = allowed.get(email)
if (!contact) continue // not active in Resend, or already a subscriber here
created.push({
id: randomUUID(),
submittedAt: now,
name: contact.name || email,
email,
message: 'Imported from Resend audience',
messageType: 'general',
subscribe: true,
archived: true,
read: true,
source: 'resend-import',
})
}
if (created.length > 0) {
state.contactSubmissions = [...created, ...state.contactSubmissions]
queueContactSubmissionsWrite()
}
res.json({ ok: true, imported: created.length, skipped: emails.length - created.length })
} catch (err) {
console.error('[subscribers] import from resend failed:', err)
res.status(502).json({ message: 'Import failed. Try again in a moment.' })
}
})
// ── Broadcast Email: sent as a real Resend Broadcast to the Resend segment ──────
const UNSUB_FOOTER = '<p style="margin:32px 0 0;font-size:12px;color:#888;text-align:center;"><a href="{{{RESEND_UNSUBSCRIBE_URL}}}" style="color:#888;">Unsubscribe</a></p>'
// App-side {{name}} becomes Resend's per-contact variable; guarantee an unsubscribe link exists.
function toResendBroadcastHtml(html) {
let out = html.replace(/\{\{\{?\s*(?:first_?name|name)\s*\}?\}\}/gi, '{{{FIRST_NAME|Friend}}}')
if (!/RESEND_UNSUBSCRIBE_URL/i.test(out)) {
out = /<\/body>/i.test(out) ? out.replace(/<\/body>/i, `${UNSUB_FOOTER}</body>`) : `${out}\n${UNSUB_FOOTER}`
}
return out
}
app.get('/api/admin-broadcast/audience', requireAdminAuth, async (_req, res) => {
const segmentId = process.env.RESEND_SEGMENT_ID
if (!process.env.RESEND_API_KEY || !segmentId) {
res.json({ segmentConfigured: false, count: null }); return
}
try {
const resend = new Resend(process.env.RESEND_CONTACTS_API_KEY ?? process.env.RESEND_API_KEY)
const contacts = await listAllResendContacts(resend, segmentId)
res.json({ segmentConfigured: true, count: contacts.filter(c => !c.unsubscribed).length })
} catch (err) {
console.error('[admin-broadcast] audience count failed:', err)
res.status(502).json({ message: 'Could not read the Resend segment.' })
}
})
app.post('/api/admin-broadcast/test', requireAdminAuth, async (req, res) => {
const { subject, html, to } = req.body ?? {}
if (typeof subject !== 'string' || !subject.trim() || typeof html !== 'string' || !html.trim()) {
res.status(400).json({ message: 'Subject and body are required.' }); return
}
if (typeof to !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(to.trim())) {
res.status(400).json({ message: 'Enter a valid test email address.' }); return
}
if (!process.env.RESEND_API_KEY) {
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
}
try {
const preview = toResendBroadcastHtml(html)
.replace(/\{\{\{\s*FIRST_NAME\s*\|\s*([^}]*?)\s*\}\}\}/gi, (_m, fallback) => escapeHtml(fallback || 'Friend'))
.replace(/\{\{\{\s*RESEND_UNSUBSCRIBE_URL\s*\}\}\}/gi, '#')
const resend = new Resend(process.env.RESEND_API_KEY)
const { error } = await resend.emails.send({
from: getResendFromAddress(),
to: to.trim(),
replyTo: getResendReplyToAddress(),
subject: `[TEST] ${subject.trim()}`,
html: preview,
})
if (error) throw new Error(error.message)
res.json({ ok: true })
} catch (err) {
console.error('[admin-broadcast] test send failed:', err)
res.status(502).json({ message: 'Test email failed to send.' })
}
})
app.post('/api/admin-broadcast', requireAdminAuth, async (req, res) => {
const { subject, html, previewText, tagNewEpisode, scheduledAt } = req.body ?? {}
if (!subject || typeof subject !== 'string' || !subject.trim()) {
res.status(400).json({ message: 'Subject is required.' }); return
}
if (!html || typeof html !== 'string' || !html.trim()) {
res.status(400).json({ message: 'Email body is required.' }); return
}
if (!process.env.RESEND_API_KEY) {
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
}
const segmentId = process.env.RESEND_SEGMENT_ID
if (!segmentId) {
res.status(500).json({ message: 'RESEND_SEGMENT_ID is not configured, so there is no segment to send to.' }); return
}
let scheduled
if (typeof scheduledAt === 'string' && scheduledAt.trim()) {
const when = Date.parse(scheduledAt)
if (!Number.isFinite(when) || when < Date.now() + 60 * 1000) {
res.status(400).json({ message: 'Schedule time must be at least a minute in the future.' }); return
}
scheduled = new Date(when).toISOString()
}
try {
const resend = new Resend(process.env.RESEND_API_KEY)
let topicId
if (tagNewEpisode === true) {
const { data: topics, error: topicsError } = await resendRetry(() => resend.topics.list())
if (topicsError) throw new Error(topicsError.message)
topicId = (topics?.data ?? []).find(t => String(t.name || '').trim().toLowerCase() === 'new episode')?.id
if (!topicId) {
res.status(400).json({ message: 'No "New Episode" topic exists in Resend.' }); return
}
}
const { data, error } = await resendRetry(() => resend.broadcasts.create({
name: subject.trim().slice(0, 120),
segmentId,
from: getResendFromAddress(),
replyTo: getResendReplyToAddress(),
subject: subject.trim(),
html: toResendBroadcastHtml(html),
...(typeof previewText === 'string' && previewText.trim() ? { previewText: previewText.trim().slice(0, 200) } : {}),
...(topicId ? { topicId } : {}),
send: true,
...(scheduled ? { scheduledAt: scheduled } : {}),
}))
if (error || !data?.id) throw new Error(error?.message || 'Resend did not return a broadcast id.')
console.log(`[admin-broadcast] ${scheduled ? 'scheduled' : 'sent'} Resend broadcast ${data.id}: ${subject.trim()}`)
res.json({ ok: true, broadcastId: data.id, scheduledAt: scheduled ?? null })
} catch (err) {
console.error('[admin-broadcast] send failed:', err)
res.status(502).json({ message: `Resend rejected the broadcast: ${err instanceof Error ? err.message : 'unknown error'}` })
}
})
}