import { randomUUID } 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': ``, '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() if (!providedToken || providedToken !== expectedToken) { 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 = '

Unsubscribe

' // 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}`) : `${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'}` }) } }) }