Add inbound email capture via Cloudflare Email Worker
- Cloudflare Email Worker forwards hello@ and nate@ to Gmail and POSTs parsed MIME email to /api/inbound-email as admin inbox entries - New server route authenticates via shared secret and deduplicates by Message-ID before storing inbound emails as contact submissions - Admin inbox shows "email" badge for inbound messages; reply composer opens blank with auto-subject "Re: [original]" and signature preview - Documents setup steps in cloudflare/email-worker.js and .env.example Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -21,3 +21,8 @@ RESEND_SEGMENT_ID=
|
|||||||
# During testing you can leave this as-is (uses Resend's shared domain).
|
# 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.
|
# For production: verify your own domain at resend.com/domains and change this.
|
||||||
RESEND_FROM=Verse by Verse with Nate <hello@versebyversewithnate.us>
|
RESEND_FROM=Verse by Verse with Nate <hello@versebyversewithnate.us>
|
||||||
|
|
||||||
|
# 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=
|
||||||
|
|||||||
@@ -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 = <a long random string — must match INBOUND_EMAIL_SECRET on the server>
|
||||||
|
*
|
||||||
|
* SERVER ENVIRONMENT VARIABLE (set in your server host / .env):
|
||||||
|
* INBOUND_EMAIL_SECRET = <same long random string as WEBHOOK_SECRET above>
|
||||||
|
*/
|
||||||
|
|
||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 = (/<itunes: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 `<NavLink to="/episodes">` 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
|
||||||
|
<Route path="/finished" element={<FinishedBooksPage content={content} />} />
|
||||||
|
<Route path="/finished/:id" element={<FinishedSeriesPage content={content} />} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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 `<itunes:season>` tags and what number they use:
|
||||||
|
```
|
||||||
|
curl -s https://anchor.fm/s/11068d290/podcast/rss | grep -o '<itunes:season>[^<]*</itunes:season>' | 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)
|
||||||
@@ -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 registerStudyCertificate } from './server/routes/study-certificate.js'
|
||||||
import { register as registerEpisodeScripts } from './server/routes/episode-scripts.js'
|
import { register as registerEpisodeScripts } from './server/routes/episode-scripts.js'
|
||||||
import { register as registerContact } from './server/routes/contact.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 registerQuestions } from './server/routes/questions.js'
|
||||||
import { register as registerAnalytics } from './server/routes/analytics.js'
|
import { register as registerAnalytics } from './server/routes/analytics.js'
|
||||||
import { register as registerDownloads } from './server/routes/downloads.js'
|
import { register as registerDownloads } from './server/routes/downloads.js'
|
||||||
@@ -91,6 +92,7 @@ registerStudyComments(app)
|
|||||||
registerStudyCertificate(app)
|
registerStudyCertificate(app)
|
||||||
registerEpisodeScripts(app)
|
registerEpisodeScripts(app)
|
||||||
registerContact(app)
|
registerContact(app)
|
||||||
|
registerInboundEmail(app)
|
||||||
registerQuestions(app)
|
registerQuestions(app)
|
||||||
registerAnalytics(app)
|
registerAnalytics(app)
|
||||||
registerDownloads(app)
|
registerDownloads(app)
|
||||||
|
|||||||
@@ -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 <email>" 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 })
|
||||||
|
})
|
||||||
|
}
|
||||||
+22
-5
@@ -626,6 +626,8 @@ interface ContactSubmission {
|
|||||||
messageType: 'question' | 'testimony' | 'topic' | 'general'
|
messageType: 'question' | 'testimony' | 'topic' | 'general'
|
||||||
subscribe: boolean
|
subscribe: boolean
|
||||||
archived?: boolean
|
archived?: boolean
|
||||||
|
source?: 'contact-form' | 'download' | 'inbound-email'
|
||||||
|
inboundTo?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ContactReplyDraft {
|
interface ContactReplyDraft {
|
||||||
@@ -2722,15 +2724,23 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
|
|
||||||
function openContactReplyComposer(submission: ContactSubmission) {
|
function openContactReplyComposer(submission: ContactSubmission) {
|
||||||
const firstName = submission.name?.trim().split(/\s+/)[0] || 'there'
|
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({
|
setContactReplyDraft({
|
||||||
submissionId: submission.id,
|
submissionId: submission.id,
|
||||||
recipientName: submission.name,
|
recipientName: submission.name,
|
||||||
recipientEmail: submission.email,
|
recipientEmail: submission.email,
|
||||||
subject: 'Thanks for reaching out to Verse by Verse with Nate',
|
subject: subjectLine,
|
||||||
message: `Thank you for reaching out.\n\nI appreciate your message and wanted to follow up personally.`,
|
message: '',
|
||||||
})
|
})
|
||||||
setContactReplyStatus('idle')
|
setContactReplyStatus('idle')
|
||||||
setContactReplyMsg(`Composing a reply to ${firstName}.`)
|
setContactReplyMsg(`Composing a reply to ${firstName}.`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyContactReplyTemplate(templateId: string) {
|
function applyContactReplyTemplate(templateId: string) {
|
||||||
@@ -3282,7 +3292,10 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
{c.submissionCount > 1 && <div className="admin-stats-note" style={{ margin: '0.2rem 0 0' }}>{c.submissionCount} submissions</div>}
|
{c.submissionCount > 1 && <div className="admin-stats-note" style={{ margin: '0.2rem 0 0' }}>{c.submissionCount} submissions</div>}
|
||||||
</td>
|
</td>
|
||||||
<td><a href={`mailto:${c.email}`}>{c.email}</a></td>
|
<td><a href={`mailto:${c.email}`}>{c.email}</a></td>
|
||||||
<td><span className="admin-badge admin-badge--pending" style={{ fontSize: '0.7rem' }}>{c.messageType ?? 'contact'}</span></td>
|
<td>
|
||||||
|
<span className="admin-badge admin-badge--pending" style={{ fontSize: '0.7rem' }}>{c.messageType ?? 'contact'}</span>
|
||||||
|
{c.source === 'inbound-email' && <span className="admin-badge admin-badge--info" style={{ fontSize: '0.7rem', marginLeft: '0.3rem' }} title={c.inboundTo ? `To: ${c.inboundTo}` : 'Direct email'}>email</span>}
|
||||||
|
</td>
|
||||||
<td style={{ textAlign: 'center' }}>{c.subscribe ? '✓' : ''}</td>
|
<td style={{ textAlign: 'center' }}>{c.subscribe ? '✓' : ''}</td>
|
||||||
<td style={{ whiteSpace: 'nowrap' }}>{formatDate(c.submittedAt)}</td>
|
<td style={{ whiteSpace: 'nowrap' }}>{formatDate(c.submittedAt)}</td>
|
||||||
<td style={{ maxWidth: '280px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={c.message}>{c.message ?? '—'}</td>
|
<td style={{ maxWidth: '280px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={c.message}>{c.message ?? '—'}</td>
|
||||||
@@ -5095,10 +5108,14 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
<textarea
|
<textarea
|
||||||
id="reply-message"
|
id="reply-message"
|
||||||
rows={8}
|
rows={8}
|
||||||
|
placeholder="Type your reply here…"
|
||||||
value={contactReplyDraft.message}
|
value={contactReplyDraft.message}
|
||||||
onChange={e => setContactReplyDraft(draft => draft ? { ...draft, message: e.target.value } : draft)}
|
onChange={e => setContactReplyDraft(draft => draft ? { ...draft, message: e.target.value } : draft)}
|
||||||
/>
|
/>
|
||||||
<p className="admin-stats-note">This will be wrapped in a professional HTML email template automatically.</p>
|
<div className="admin-stats-note" style={{ borderLeft: '3px solid #ccc', paddingLeft: '0.6rem', marginTop: '0.4rem', color: '#666', fontStyle: 'italic', whiteSpace: 'pre-line' }}>
|
||||||
|
{'Grace and peace,\nVerse by Verse with Nate'}
|
||||||
|
<span style={{ display: 'block', marginTop: '0.25rem', fontSize: '0.75em', fontStyle: 'normal', color: '#999' }}>Your signature — appended automatically</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user