diff --git a/package.json b/package.json
index fd387df..2b97a02 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "siteforge",
"private": true,
- "version": "2.2.4",
+ "version": "2.3.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/server.js b/server.js
index a282747..071a458 100644
--- a/server.js
+++ b/server.js
@@ -29,6 +29,7 @@ import {
loadWebhooksFromDisk,
loadStudySessionsFromDisk,
queueStudySessionsWrite,
+ loadNewsletterIssuesFromDisk,
createBackupSnapshot,
refreshContentCaches,
queueHitStatsWrite,
@@ -70,6 +71,7 @@ import { startNuggetsSeafileWatcher } from './server/nuggets-seafile-watcher.js'
import { register as registerFeed } from './server/routes/feed.js'
import { register as registerQrCodes } from './server/routes/qr-codes.js'
import { register as registerBiblePassage } from './server/routes/bible-passage.js'
+import { register as registerNewsletter } from './server/routes/newsletter.js'
import { register as registerPublic } from './server/routes/public.js'
const app = express()
@@ -120,6 +122,7 @@ registerNuggetsHostUpload(app)
registerFeed(app)
registerQrCodes(app)
registerBiblePassage(app)
+registerNewsletter(app)
// Hit-counting middleware (must come before public routes)
app.use((req, res, next) => {
@@ -168,6 +171,7 @@ Promise.all([
loadAuditLogFromDisk(),
loadWebhooksFromDisk(),
loadStudySessionsFromDisk(),
+ loadNewsletterIssuesFromDisk(),
refreshContentCaches(),
])
.catch(err => {
diff --git a/server/config.js b/server/config.js
index a24c135..6a06c70 100644
--- a/server/config.js
+++ b/server/config.js
@@ -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
diff --git a/server/data.js b/server/data.js
index 8184460..1781162 100644
--- a/server/data.js
+++ b/server/data.js
@@ -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 = []
+ })
+}
diff --git a/server/routes/newsletter.js b/server/routes/newsletter.js
new file mode 100644
index 0000000..780b065
--- /dev/null
+++ b/server/routes/newsletter.js
@@ -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(
+ /
]*>\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 ctaBlock = `
+
+