Add a newsletter archive so past issues can be shared and found; v2.3.0
Publish Container / build (push) Successful in 20m51s
Publish Container / build (push) Successful in 20m51s
Web-hosted copies of episode newsletters, published from Resend broadcasts tagged with the "New Episode" topic. Gives Facebook (and anyone else) a proper link to share instead of raw HTML email, and makes issues findable via site search and Google. - Admin "Newsletter Archive" tab lists sent Resend broadcasts, flags ones tagged "New Episode", and publishes them with one click (pulls the HTML straight from Resend's API) - Public /newsletter archive index and /newsletter/:slug issue pages, server-rendered with per-issue Open Graph tags for social previews - Unsubscribe merge-tag stripped from the web copy; archive + subscribe links appended - Issues added to sitemap.xml and the on-site search index Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
60b23b0fba
commit
fe4c09f8a3
11 files changed
+516
-4
No files matched your search
@@ -74,6 +74,7 @@ export const CALENDAR_EVENTS_FILE = path.join(DATA_DIR, 'calendar-events.json')
|
||||
export const AUDIT_LOG_FILE = path.join(DATA_DIR, 'audit-log.json')
|
||||
export const WEBHOOKS_FILE = path.join(DATA_DIR, 'webhooks.json')
|
||||
export const STUDY_SESSIONS_FILE = path.join(DATA_DIR, 'study-sessions.json')
|
||||
export const NEWSLETTER_ISSUES_FILE = path.join(DATA_DIR, 'newsletter-issues.json')
|
||||
export const MAX_EPISODE_SCRIPT_LENGTH = 200_000 // ~150k words, well beyond any sermon
|
||||
|
||||
// Seafile integration for Nuggets episode uploads
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
AUDIT_LOG_FILE,
|
||||
WEBHOOKS_FILE,
|
||||
STUDY_SESSIONS_FILE,
|
||||
NEWSLETTER_ISSUES_FILE,
|
||||
EMPTY_HIT_STATS,
|
||||
EMPTY_VISITOR_STATS,
|
||||
DEFAULT_REPLY_TEMPLATES,
|
||||
@@ -1673,3 +1674,27 @@ export function loadQrCodesFromDisk() {
|
||||
state.qrScans = []
|
||||
})
|
||||
}
|
||||
|
||||
// ── Newsletter Issues ────────────────────────────────────────────────────────
|
||||
|
||||
export function queueNewsletterIssuesWrite() {
|
||||
state.newsletterIssuesWritePromise = state.newsletterIssuesWritePromise
|
||||
.then(async () => {
|
||||
await mkdir(DATA_DIR, { recursive: true })
|
||||
await writeFile(NEWSLETTER_ISSUES_FILE, JSON.stringify({ issues: state.newsletterIssues }, null, 2), 'utf8')
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('[newsletter-issues] failed to write:', err)
|
||||
})
|
||||
}
|
||||
|
||||
export function loadNewsletterIssuesFromDisk() {
|
||||
return readFile(NEWSLETTER_ISSUES_FILE, 'utf8')
|
||||
.then(raw => {
|
||||
const parsed = JSON.parse(raw)
|
||||
state.newsletterIssues = Array.isArray(parsed?.issues) ? parsed.issues : []
|
||||
})
|
||||
.catch(() => {
|
||||
state.newsletterIssues = []
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
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'
|
||||
|
||||
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(
|
||||
/<p[^>]*>\s*<a[^>]*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>${title} — Verse by Verse with Nate</title>
|
||||
<meta name="description" content="${description}"/>
|
||||
<meta property="og:type" content="article"/>
|
||||
<meta property="og:site_name" content="Verse by Verse with Nate"/>
|
||||
<meta property="og:url" content="${escapeHtml(canonical)}"/>
|
||||
<meta property="og:title" content="${title}"/>
|
||||
<meta property="og:description" content="${description}"/>
|
||||
<meta property="og:image" content="${escapeHtml(ogImage)}"/>
|
||||
<meta property="og:image:alt" content="${title}"/>
|
||||
<meta name="twitter:card" content="summary_large_image"/>
|
||||
<meta name="twitter:title" content="${title}"/>
|
||||
<meta name="twitter:description" content="${description}"/>
|
||||
<meta name="twitter:image" content="${escapeHtml(ogImage)}"/>
|
||||
<link rel="canonical" href="${escapeHtml(canonical)}"/>
|
||||
</head>`
|
||||
|
||||
const ctaBlock = `
|
||||
<table border="0" width="100%" cellpadding="0" cellspacing="0" role="presentation" align="center">
|
||||
<tbody><tr><td align="center" style="padding:32px 20px 48px;background-color:#080807">
|
||||
<table width="580" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width:580px;width:100%">
|
||||
<tbody><tr><td align="center" style="padding:0">
|
||||
<p style="margin:0 0 16px;font-family:Georgia,serif;font-size:13px;color:#6b6044">
|
||||
<a href="${BASE}/newsletter" style="color:#6b6044;text-decoration:none">← Newsletter Archive</a>
|
||||
</p>
|
||||
<a href="${BASE}/subscribe" style="display:inline-block;padding:12px 32px;font-family:Georgia,serif;font-size:12px;letter-spacing:0.16em;text-transform:uppercase;color:#B8972A;border:1px solid #B8972A;text-decoration:none">Subscribe by Email</a>
|
||||
</td></tr></tbody></table>
|
||||
</td></tr></tbody></table>
|
||||
</body>`
|
||||
|
||||
let html = stripUnsubscribeLink(issue.html)
|
||||
html = html.replace(/<title>[\s\S]*?<\/title>/i, '')
|
||||
html = /<\/head>/i.test(html) ? html.replace(/<\/head>/i, metaBlock) : html.replace(/<head[^>]*>/i, m => `${m}${metaBlock.replace('</head>', '')}`)
|
||||
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([
|
||||
resend.topics.list(),
|
||||
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')
|
||||
|
||||
const details = await Promise.all(sentBroadcasts.map(b => resend.broadcasts.get(b.id)))
|
||||
|
||||
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))
|
||||
})
|
||||
}
|
||||
@@ -106,6 +106,11 @@ export function register(app) {
|
||||
for (const ep of highlights) {
|
||||
if (ep.id) urls.push(`${root}/episodes/${encodeURIComponent(ep.id)}`)
|
||||
}
|
||||
// Newsletter archive + published issues
|
||||
urls.push(`${root}/newsletter`)
|
||||
for (const issue of state.newsletterIssues) {
|
||||
if (issue.slug && issue.public !== false) urls.push(`${root}/newsletter/${encodeURIComponent(issue.slug)}`)
|
||||
}
|
||||
|
||||
const xml = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
|
||||
@@ -96,6 +96,10 @@ export const state = {
|
||||
emailSettings: { signature: 'Grace and peace,\nVerse by Verse with Nate' },
|
||||
emailSettingsWritePromise: Promise.resolve(),
|
||||
|
||||
// newsletterIssues: Array<{ id, resendBroadcastId, slug, title, subject, description, html, sentAt, publishedAt, public }>
|
||||
newsletterIssues: [],
|
||||
newsletterIssuesWritePromise: Promise.resolve(),
|
||||
|
||||
calendarEvents: [],
|
||||
calendarEventsWritePromise: Promise.resolve(),
|
||||
|
||||
|
||||
Reference in new issue
Block a user