diff --git a/.env.example b/.env.example index 007f635..05cdf87 100644 --- a/.env.example +++ b/.env.example @@ -21,3 +21,8 @@ RESEND_SEGMENT_ID= # During testing you can leave this as-is (uses Resend's shared domain). # For production: verify your own domain at resend.com/domains and change this. RESEND_FROM=Verse by Verse with Nate + +# Shared secret for the Cloudflare Email Worker webhook. +# Must match the WEBHOOK_SECRET variable set in the Cloudflare Worker settings. +# Generate with: openssl rand -hex 32 +INBOUND_EMAIL_SECRET= diff --git a/cloudflare/email-worker.js b/cloudflare/email-worker.js new file mode 100644 index 0000000..24e96e8 --- /dev/null +++ b/cloudflare/email-worker.js @@ -0,0 +1,141 @@ +/** + * Cloudflare Email Worker — versebyversewithnate.us + * + * Receives inbound emails to hello@ and nate@, forwards to Gmail, + * and POSTs a parsed copy to the site admin inbox. + * + * CLOUDFLARE SETUP: + * 1. Go to Email > Email Routing > Routes in your Cloudflare dashboard + * 2. Add two "Custom address" rules: + * hello@versebyversewithnate.us → Send to Worker → this worker + * nate@versebyversewithnate.us → Send to Worker → this worker + * 3. Make sure nmemmert@gmail.com is listed as a verified destination address + * (Email Routing > Destination addresses) + * + * WORKER ENVIRONMENT VARIABLES (Workers & Pages > this worker > Settings > Variables): + * GMAIL_FORWARD_ADDRESS = nmemmert@gmail.com + * WEBHOOK_URL = https://versebyversewithnate.us/api/inbound-email + * WEBHOOK_SECRET = + * + * SERVER ENVIRONMENT VARIABLE (set in your server host / .env): + * INBOUND_EMAIL_SECRET = + */ + +export default { + async email(message, env, ctx) { + const forwardPromise = message.forward(env.GMAIL_FORWARD_ADDRESS) + + const rawEmail = await streamToText(message.raw) + + const parsed = parseEmail(rawEmail, message) + + const webhookPromise = fetch(env.WEBHOOK_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Webhook-Secret': env.WEBHOOK_SECRET, + }, + body: JSON.stringify(parsed), + }).catch(err => console.error('[email-worker] webhook failed:', err.message)) + + await Promise.all([forwardPromise, webhookPromise]) + }, +} + +async function streamToText(stream) { + const reader = stream.getReader() + const chunks = [] + while (true) { + const { done, value } = await reader.read() + if (done) break + chunks.push(value) + } + const bytes = new Uint8Array(chunks.reduce((acc, c) => acc + c.length, 0)) + let offset = 0 + for (const chunk of chunks) { + bytes.set(chunk, offset) + offset += chunk.length + } + return new TextDecoder().decode(bytes) +} + +function parseEmail(raw, message) { + const lines = raw.split(/\r?\n/) + + // Parse headers (everything before the first blank line) + const headers = {} + let bodyStart = 0 + for (let i = 0; i < lines.length; i++) { + if (lines[i].trim() === '') { + bodyStart = i + 1 + break + } + const colon = lines[i].indexOf(':') + if (colon > 0) { + const key = lines[i].slice(0, colon).trim().toLowerCase() + const val = lines[i].slice(colon + 1).trim() + if (!headers[key]) headers[key] = val + } + } + + const subject = decodeHeaderValue(headers['subject'] ?? '(no subject)') + const from = message.from ?? headers['from'] ?? '' + const to = message.to ?? headers['to'] ?? '' + const date = headers['date'] ?? new Date().toISOString() + const messageId = headers['message-id'] ?? '' + + // Extract plain text body — skip MIME boundaries and HTML parts + const bodyLines = lines.slice(bodyStart) + const plainText = extractPlainText(raw, bodyLines) + + return { + from, + to, + subject, + body: plainText, + date, + messageId, + source: 'inbound-email', + } +} + +function extractPlainText(raw, bodyLines) { + // Look for Content-Type: text/plain section in multipart emails + const textPlainMatch = /Content-Type: text\/plain[^\r\n]*\r?\n(?:[^\r\n]+\r?\n)*\r?\n([\s\S]*?)(?=--|\z)/i.exec(raw) + if (textPlainMatch) { + return decodeEmailBody(textPlainMatch[1]).trim() + } + + // Fallback: join body lines, strip HTML tags if present + const joined = bodyLines.join('\n') + if (/<[a-z][\s\S]*>/i.test(joined)) { + return joined.replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/&/g, '&').replace(/\s{3,}/g, '\n\n').trim() + } + + return joined.trim() +} + +function decodeEmailBody(text) { + // Handle quoted-printable encoding (=XX hex sequences and soft line breaks) + return text + .replace(/=\r?\n/g, '') + .replace(/=([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16))) +} + +function decodeHeaderValue(value) { + // Handle encoded words: =?UTF-8?B?...?= or =?UTF-8?Q?...?= + return value.replace(/=\?([^?]+)\?([BQ])\?([^?]*)\?=/gi, (_, charset, encoding, encoded) => { + try { + if (encoding.toUpperCase() === 'B') { + const binary = atob(encoded) + return new TextDecoder(charset).decode(Uint8Array.from(binary, c => c.charCodeAt(0))) + } + if (encoding.toUpperCase() === 'Q') { + return encoded.replace(/_/g, ' ').replace(/=([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16))) + } + } catch { + return encoded + } + return encoded + }) +} diff --git a/plans/finished-books-playlist.md b/plans/finished-books-playlist.md new file mode 100644 index 0000000..c1eee36 --- /dev/null +++ b/plans/finished-books-playlist.md @@ -0,0 +1,156 @@ +# Finished Books — Episode Playlist Feature + +## Goal +Add a proper **Finished Books** section where completed book studies live with a full ordered episode playlist. The Downloads page gets a minor label/copy update (Option A). A single "End Current Series" button in admin handles the full book transition in one step. + +--- + +## User Flow (Public Site) + +1. User clicks **Finished Books** in the nav +2. Lands on `/finished` — grid of completed book cards (image, title, description) +3. Clicks a book → `/finished/:id` — sees all episodes in numbered order, each with a player +4. Breadcrumb: `Finished Books → Titus` + +**Episode Highlights** on the Episodes page only ever shows picks from the current book. When a book ends, highlights are cleared and replaced with picks from the new series automatically (see transition workflow below). + +--- + +## Book Transition Workflow — "End Current Series" Button + +A single button in admin (lives on the Current Series tab) that does all three transition steps at once: + +### What it does +1. **Creates a Finished Book entry** from the current series data (title, image, description, season number) — immediately appears on `/finished` +2. **Clears Episode Highlights** — wipes `podcastFeaturedLinks` so the old season's picks are gone +3. **Opens a guided form** for the new series — prompts for new series title, image URL, and season number before saving + +### The form flow +- User clicks **"End Current Series & Start New Book"** +- A confirmation modal appears with a summary: "This will archive [Titus] to Finished Books and clear Episode Highlights." +- Below the confirmation, a small form: New Series Title, New Series Image, New Season Number +- User fills it in and hits **Confirm** +- All three changes save together as one content update + +### What the user still does manually after +- Go to Episode Highlights and add 3–5 hand-picked episodes from the new series (the section is now empty and ready) + +--- + +## Changes Required + +### 1. `server/routes/episodes.js` — Parse `itunes:season` from RSS +One line added to `parseRssItems` alongside the existing `itunes:episode` extraction: +```js +const season = (/([\s\S]*?)<\/itunes:season>/.exec(block)?.[1] ?? '').trim() +``` +Include `season` in the returned episode object. + +### 2. `src/content.ts` — New `FinishedBook` type + field on `SiteContent` +Clean, purpose-built interface: +```ts +export interface FinishedBook { + id: string + title: string + description: string + imageUrl: string + season: number +} +``` +Add `finishedBooks: FinishedBook[]` to `SiteContent` with default `[]`. + +### 3. `src/App.tsx` — New `useEpisodesForBook` hook +Fetches `/api/episodes/all` and filters by season number, sorted ascending: +```js +const filtered = (data.episodes ?? []) + .filter(ep => ep.season === String(book.season)) + .sort((a, b) => parseInt(a.episode) - parseInt(b.episode)) +``` + +### 4. `src/App.tsx` — Nav: "Episodes" dropdown replacing flat link + +"Episodes" becomes a dropdown parent with two children: **Current Series** (`/episodes`) and **Finished Books** (`/finished`). The flat `` is replaced with a dropdown component. Everything else in the nav stays flat and unchanged. + +**Desktop behavior:** +- "Episodes ▾" shows on hover (or focus), revealing a small popover menu below with the two links +- Parent label stays gold/active when either child route is active +- Implemented with CSS `:hover` + `:focus-within` on a wrapper — no JS state needed for desktop + +**Mobile behavior (under ~540px):** +- The mobile nav already collapses to a full-width vertical list with `max-height` animation +- "Episodes" becomes a top-level row with an expand arrow (chevron) +- Tapping it toggles a sub-list showing "Current Series" and "Finished Books" indented beneath it +- Implemented with a small React `useState` toggle on the dropdown item — consistent with how `menuOpen` already works +- Sub-items get slightly smaller text and left padding to show hierarchy +- The `max-height: 500px` on `.header-nav--open` will need to increase to `600px` to accommodate the expanded sub-items + +**New elements needed:** +- `NavDropdown` component (or inline JSX) wrapping the two episode links +- CSS classes: `.nav-dropdown`, `.nav-dropdown-menu`, `.nav-dropdown-item`, `.nav-dropdown--open` (mobile only) +- Mobile sub-item styles: indented, smaller text, no bottom border on last child before parent closes + +**Footer:** Add a flat "Finished Books" link alongside the existing footer nav links — no dropdown needed there. + +### 5. `src/App.tsx` — Two new routes +```tsx +} /> +} /> +``` + +### 6. `src/App.tsx` — Two new page components + +**`FinishedBooksPage`** — grid of `finishedBooks` cards (image, title, description), each linking to `/finished/:id`. Friendly empty state if no books yet. + +**`FinishedSeriesPage`** — uses `useEpisodesForBook`, renders episodes sorted ascending by episode number as a numbered playlist. Each row: episode number, title, duration, `EpisodeAudioPlayer`. + +### 7. `src/AdminPage.tsx` — "End Current Series" button on Current Series tab +- Button labeled **"End Current Series & Start New Book"** +- Opens an inline confirmation + new series form (not a browser `confirm()` — a proper UI panel) +- On confirm: creates `FinishedBook` from current series data, clears `podcastFeaturedLinks`, updates series fields with new book info +- All saved as one atomic content update + +### 8. `src/AdminPage.tsx` — Finished Books management section +Separate tab for viewing and managing the finished books list (in case you need to edit or remove one): +- Shows each finished book as a collapsible card +- Edit title, description, image, season number +- Remove button +- "Add Finished Book" button for manually adding one without going through the transition wizard + +### 9. CSS — Playlist and finished books grid styles +Reuse existing episode-list and episode-card patterns where possible. + +### 10. `src/App.tsx` — Downloads page copy tweak (Option A, low priority) +Minor label changes only — no structural change: +- Eyebrow becomes "Current Study" +- Heading becomes `{content.seriesTitle} — Study Guide` +- Everything else (download form, Amazon button, library) stays identical + +--- + +## Prerequisite — Verify RSS Season Tags +Before starting, confirm Titus episodes have `` tags and what number they use: +``` +curl -s https://anchor.fm/s/11068d290/podcast/rss | grep -o '[^<]*' | head -5 +``` +If seasons are missing from the RSS, the fallback is an `episodeRange: { from, to }` field on `FinishedBook` instead of `season`. + +--- + +## Files Touched Summary +| File | What changes | +|---|---| +| `server/routes/episodes.js` | Parse `itunes:season` into episode objects | +| `src/content.ts` | New `FinishedBook` interface + `finishedBooks` field on `SiteContent` | +| `src/App.tsx` | New hook, nav links, 2 routes, 2 page components, Downloads copy tweak | +| `src/AdminPage.tsx` | "End Current Series" transition button + Finished Books management tab | +| CSS | Playlist page + finished books grid styles | + +--- + +## Order of Work +1. Run the RSS curl check to confirm season tags (5 min) +2. `content.ts` — add `FinishedBook` type and `finishedBooks` field +3. `server/routes/episodes.js` — parse season from RSS +4. Admin — "End Current Series" transition button + Finished Books management tab +5. Public pages — nav link, routes, `FinishedBooksPage`, `FinishedSeriesPage` +6. Downloads copy tweak (last, lowest priority) diff --git a/server.js b/server.js index 50100eb..97851fe 100644 --- a/server.js +++ b/server.js @@ -48,6 +48,7 @@ import { register as registerStudyComments } from './server/routes/study-comment import { register as registerStudyCertificate } from './server/routes/study-certificate.js' import { register as registerEpisodeScripts } from './server/routes/episode-scripts.js' import { register as registerContact } from './server/routes/contact.js' +import { register as registerInboundEmail } from './server/routes/inbound-email.js' import { register as registerQuestions } from './server/routes/questions.js' import { register as registerAnalytics } from './server/routes/analytics.js' import { register as registerDownloads } from './server/routes/downloads.js' @@ -91,6 +92,7 @@ registerStudyComments(app) registerStudyCertificate(app) registerEpisodeScripts(app) registerContact(app) +registerInboundEmail(app) registerQuestions(app) registerAnalytics(app) registerDownloads(app) diff --git a/server/routes/inbound-email.js b/server/routes/inbound-email.js new file mode 100644 index 0000000..ca06a1d --- /dev/null +++ b/server/routes/inbound-email.js @@ -0,0 +1,63 @@ +import { randomUUID } from 'node:crypto' +import { state } from '../state.js' +import { queueContactSubmissionsWrite } from '../data.js' +import { MAX_CONTACT_SUBMISSIONS } from '../config.js' + +export function register(app) { + app.post('/api/inbound-email', (req, res) => { + const secret = process.env.INBOUND_EMAIL_SECRET + if (!secret) { + res.status(503).json({ message: 'Inbound email not configured.' }); return + } + + const provided = req.get('x-webhook-secret') ?? '' + if (!provided || provided !== secret) { + res.status(401).json({ message: 'Unauthorized.' }); return + } + + const { from, to, subject, body, date, messageId, source } = req.body ?? {} + + if (!from || typeof from !== 'string') { + res.status(400).json({ message: 'Missing from address.' }); return + } + + // Extract display name and email address from "Name " format + const fromMatch = /^(.*?)\s*<([^>]+)>$/.exec(from.trim()) + const fromEmail = fromMatch ? fromMatch[2].trim() : from.trim() + const fromName = fromMatch ? fromMatch[1].trim() : from.trim() + + // Deduplicate by messageId if provided + if (messageId && typeof messageId === 'string' && messageId.trim()) { + const exists = state.contactSubmissions.some(s => s.messageId === messageId.trim()) + if (exists) { + res.json({ ok: true, duplicate: true }); return + } + } + + const submission = { + id: randomUUID(), + submittedAt: date ? new Date(date).toISOString() : new Date().toISOString(), + name: fromName || fromEmail, + email: fromEmail, + message: [subject ? `Subject: ${subject}` : '', body ?? ''].filter(Boolean).join('\n\n'), + messageType: 'general', + subscribe: false, + archived: false, + source: 'inbound-email', + inboundTo: typeof to === 'string' ? to : '', + messageId: typeof messageId === 'string' ? messageId.trim() : '', + emailStatus: { + welcome: { status: 'not-applicable', lastEventAt: null, lastEventType: null, resendEmailId: null, error: null }, + adminNotification: { status: 'not-applicable', lastEventAt: null, lastEventType: null, resendEmailId: null, error: null }, + adminReply: { status: 'pending', lastEventAt: null, lastEventType: null, resendEmailId: null, error: null }, + }, + } + + state.contactSubmissions.unshift(submission) + state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS) + queueContactSubmissionsWrite() + + console.log(`[inbound-email] received from ${fromEmail} — subject: ${subject ?? '(none)'}`) + res.json({ ok: true }) + }) +} diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index ab1b0b1..d800235 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -626,6 +626,8 @@ interface ContactSubmission { messageType: 'question' | 'testimony' | 'topic' | 'general' subscribe: boolean archived?: boolean + source?: 'contact-form' | 'download' | 'inbound-email' + inboundTo?: string } interface ContactReplyDraft { @@ -2722,15 +2724,23 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { function openContactReplyComposer(submission: ContactSubmission) { const firstName = submission.name?.trim().split(/\s+/)[0] || 'there' + const isInbound = submission.source === 'inbound-email' + // For inbound emails, extract the original subject from the stored message + const subjectLine = isInbound + ? (() => { + const match = /^Subject: (.+)/m.exec(submission.message ?? '') + return match ? `Re: ${match[1].trim()}` : 'Re: Your message' + })() + : 'Thanks for reaching out to Verse by Verse with Nate' setContactReplyDraft({ submissionId: submission.id, recipientName: submission.name, recipientEmail: submission.email, - subject: 'Thanks for reaching out to Verse by Verse with Nate', - message: `Thank you for reaching out.\n\nI appreciate your message and wanted to follow up personally.`, + subject: subjectLine, + message: '', }) setContactReplyStatus('idle') - setContactReplyMsg(`Composing a reply to ${firstName}.`) + setContactReplyMsg(`Composing a reply to ${firstName}.`) } function applyContactReplyTemplate(templateId: string) { @@ -3282,7 +3292,10 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { {c.submissionCount > 1 &&
{c.submissionCount} submissions
}
{c.email} - {c.messageType ?? 'contact'} + + {c.messageType ?? 'contact'} + {c.source === 'inbound-email' && email} + {c.subscribe ? '✓' : ''} {formatDate(c.submittedAt)} {c.message ?? '—'} @@ -5095,10 +5108,14 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {