import { randomUUID } from 'node:crypto'
import { Resend } from 'resend'
import { requireAdminAuth } from '../auth.js'
import { state } from '../state.js'
import { queueNewsletterIssuesWrite } from '../data.js'
import { escapeHtml } from '../helpers.js'
import { resendRetry } from '../email.js'
const BASE = 'https://versebyversewithnate.us'
const NEW_EPISODE_TOPIC_NAME = 'new episode'
function truncate(str, max) {
const clean = String(str || '').trim()
if (clean.length <= max) return clean
return `${clean.slice(0, max - 1).trimEnd()}…`
}
function stripSubjectPrefix(subject) {
return String(subject || '').replace(/^New Episode:?\s*-?\s*/i, '').trim()
}
function slugify(str) {
return String(str || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80)
}
function uniqueSlug(base) {
const root = slugify(base) || 'issue'
let candidate = root
let n = 2
while (state.newsletterIssues.some(i => i.slug === candidate)) {
candidate = `${root}-${n}`
n += 1
}
return candidate
}
function extractDescription(text, previewText) {
if (previewText && previewText.trim()) return truncate(previewText, 200)
const paragraphs = String(text || '')
.split(/\n{2,}/)
.map(p => p.replace(/\s+/g, ' ').trim())
.filter(Boolean)
const candidate = paragraphs.find(p => p.length > 60 && !/^[◆✦✍✎▶]/.test(p) && p !== p.toUpperCase())
return truncate(candidate || paragraphs[paragraphs.length - 1] || '', 200)
}
// Strip the raw {{{RESEND_UNSUBSCRIBE_URL}}} placeholder link — it only resolves inside an actual sent email.
function stripUnsubscribeLink(html) {
return String(html || '').replace(
/
]*>\s*]*href="\{\{\{RESEND_UNSUBSCRIBE_URL\}\}\}"[\s\S]*?<\/a>\s*<\/p>/i,
'',
)
}
function buildIssuePageHtml(issue) {
const canonical = `${BASE}/newsletter/${encodeURIComponent(issue.slug)}`
const ogImage = `${BASE}/images/banner.png`
const title = escapeHtml(issue.title)
const description = escapeHtml(issue.description)
const metaBlock = `
${title} — Verse by Verse with Nate
`
const topBar = `
`
const ctaBlock = `
]*>/i, m => `${m}${topBar}`)
html = /<\/body>/i.test(html) ? html.replace(/<\/body>/i, ctaBlock) : `${html}${ctaBlock}`
return html
}
export function register(app) {
// ── Admin: browse Resend broadcasts, flagged by New Episode topic ────────
app.get('/api/admin/newsletter/resend-broadcasts', 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: topicsData, error: topicsError }, { data: listData, error: listError }] = await Promise.all([
resendRetry(() => resend.topics.list()),
resendRetry(() => resend.broadcasts.list()),
])
if (topicsError) throw new Error(topicsError.message)
if (listError) throw new Error(listError.message)
const newEpisodeTopic = (topicsData?.data ?? []).find(t => String(t.name || '').trim().toLowerCase() === NEW_EPISODE_TOPIC_NAME)
const sentBroadcasts = (listData?.data ?? []).filter(b => b.status === 'sent')
// Fetch a few at a time so a long broadcast history can't exceed Resend's requests-per-second limit.
const details = []
for (let i = 0; i < sentBroadcasts.length; i += 4) {
const chunk = await Promise.all(sentBroadcasts.slice(i, i + 4).map(b => resendRetry(() => resend.broadcasts.get(b.id))))
details.push(...chunk)
}
const broadcasts = details.map(({ data }, i) => {
const summary = sentBroadcasts[i]
if (!data) return null
return {
id: data.id,
name: data.name,
subject: data.subject,
sentAt: data.sent_at ?? summary.sent_at,
topicId: data.topic_id ?? null,
matchesNewEpisodeTopic: !!newEpisodeTopic && data.topic_id === newEpisodeTopic.id,
alreadyPublished: state.newsletterIssues.some(issue => issue.resendBroadcastId === data.id),
}
}).filter(Boolean).sort((a, b) => new Date(b.sentAt ?? 0).getTime() - new Date(a.sentAt ?? 0).getTime())
res.json({ broadcasts, newEpisodeTopicFound: !!newEpisodeTopic })
} catch (err) {
console.error('[newsletter] failed to list Resend broadcasts:', err)
res.status(502).json({ message: 'Could not reach Resend.' })
}
})
// ── Admin: manage published issues ────────────────────────────────────────
app.get('/api/admin/newsletter/issues', requireAdminAuth, (_req, res) => {
const issues = [...state.newsletterIssues]
.sort((a, b) => new Date(b.sentAt ?? b.publishedAt).getTime() - new Date(a.sentAt ?? a.publishedAt).getTime())
.map(({ html, ...rest }) => rest)
res.json({ issues })
})
app.post('/api/admin/newsletter/issues', requireAdminAuth, async (req, res) => {
const { broadcastId, title: titleOverride, slug: slugOverride, public: isPublic } = req.body ?? {}
if (!broadcastId || typeof broadcastId !== 'string') {
res.status(400).json({ message: 'broadcastId is required.' }); return
}
if (state.newsletterIssues.some(i => i.resendBroadcastId === broadcastId)) {
res.status(409).json({ message: 'This broadcast has already been published.' }); return
}
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 resend.broadcasts.get(broadcastId)
if (error || !data) throw new Error(error?.message ?? 'Broadcast not found.')
if (!data.html) { res.status(400).json({ message: 'This broadcast has no HTML content.' }); return }
const title = typeof titleOverride === 'string' && titleOverride.trim()
? titleOverride.trim().slice(0, 200)
: stripSubjectPrefix(data.subject) || data.name || 'Newsletter Issue'
const slug = typeof slugOverride === 'string' && slugOverride.trim()
? uniqueSlug(slugOverride)
: uniqueSlug(title)
const issue = {
id: randomUUID(),
resendBroadcastId: data.id,
slug,
title,
subject: data.subject ?? '',
description: extractDescription(data.text, data.preview_text),
html: data.html,
sentAt: data.sent_at ?? null,
publishedAt: new Date().toISOString(),
public: isPublic !== false,
}
state.newsletterIssues.push(issue)
queueNewsletterIssuesWrite()
const { html, ...rest } = issue
res.json({ ok: true, issue: rest })
} catch (err) {
console.error('[newsletter] failed to publish issue:', err)
res.status(502).json({ message: 'Could not fetch broadcast content from Resend.' })
}
})
app.patch('/api/admin/newsletter/issues/:id', requireAdminAuth, (req, res) => {
const issue = state.newsletterIssues.find(i => i.id === req.params.id)
if (!issue) { res.status(404).json({ message: 'Issue not found.' }); return }
if (typeof req.body?.title === 'string' && req.body.title.trim()) issue.title = req.body.title.trim().slice(0, 200)
if (typeof req.body?.slug === 'string' && req.body.slug.trim() && req.body.slug.trim() !== issue.slug) {
const candidate = slugify(req.body.slug)
if (!candidate) { res.status(400).json({ message: 'Invalid slug.' }); return }
if (state.newsletterIssues.some(i => i.id !== issue.id && i.slug === candidate)) {
res.status(409).json({ message: 'That slug is already in use.' }); return
}
issue.slug = candidate
}
if (typeof req.body?.public === 'boolean') issue.public = req.body.public
queueNewsletterIssuesWrite()
const { html, ...rest } = issue
res.json({ ok: true, issue: rest })
})
app.delete('/api/admin/newsletter/issues/:id', requireAdminAuth, (req, res) => {
const idx = state.newsletterIssues.findIndex(i => i.id === req.params.id)
if (idx === -1) { res.status(404).json({ message: 'Issue not found.' }); return }
state.newsletterIssues.splice(idx, 1)
queueNewsletterIssuesWrite()
res.json({ ok: true })
})
// ── Public ─────────────────────────────────────────────────────────────────
app.get('/api/newsletter/issues', (_req, res) => {
const issues = state.newsletterIssues
.filter(i => i.public !== false)
.sort((a, b) => new Date(b.sentAt ?? b.publishedAt).getTime() - new Date(a.sentAt ?? a.publishedAt).getTime())
.map(({ html, ...rest }) => rest)
res.json({ issues })
})
app.get('/newsletter/:slug', (req, res, next) => {
const issue = state.newsletterIssues.find(i => i.slug === req.params.slug && i.public !== false)
if (!issue) { next(); return }
res.type('html').send(buildIssuePageHtml(issue))
})
}