From 7670b44d2763513020232361825a8bb6fd420051 Mon Sep 17 00:00:00 2001 From: nmemmert Date: Tue, 28 Jul 2026 09:34:39 -0400 Subject: [PATCH] Add /email route as standalone web email client; v1.1.11 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts the email inbox from /admin and builds it as a full-viewport email client at /email with the same admin auth. Key improvements over the old embedded panel: - Full-height split-pane layout (sidebar list + detail pane) - Subject line extracted and shown separately for inbound emails - Inline reply composer inside the detail pane (no more page-jump) - Compose-new button for outbound messages to arbitrary addresses - Search/filter across name, email, subject, and body - Unread dot indicators with localStorage tracking (marks read on open) - Keyboard navigation: ↑/↓ or j/k to move, r to reply, e to archive, Esc to close - Source badges (contact-form / inbound-email) on list items - Sent history and reply templates accessible via footer drawers - 30-second polling for new messages - New POST /api/admin-email/compose server endpoint for outbound sends - Dashboard card "Open Inbox" now links to /email Co-Authored-By: Claude Sonnet 4.6 --- package.json | 2 +- server/routes/contact.js | 63 +++ src/AdminPage.tsx | 512 +--------------------- src/App.css | 809 ++++++++++++++++++++++++++++++++++ src/App.tsx | 2 + src/EmailPage.tsx | 916 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 1794 insertions(+), 510 deletions(-) create mode 100644 src/EmailPage.tsx diff --git a/package.json b/package.json index b23954d..5fe0743 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "1.1.10", + "version": "1.1.11", "type": "module", "scripts": { "dev": "vite", diff --git a/server/routes/contact.js b/server/routes/contact.js index 2ac8808..abc98c2 100644 --- a/server/routes/contact.js +++ b/server/routes/contact.js @@ -554,6 +554,69 @@ export function register(app) { } }) + app.post('/api/admin-email/compose', requireAdminAuth, async (req, res) => { + try { + if (!process.env.RESEND_API_KEY) { + res.status(503).json({ message: 'RESEND_API_KEY is not configured on the server.' }); return + } + const to = typeof req.body?.to === 'string' ? req.body.to.trim() : '' + const toName = typeof req.body?.toName === 'string' ? req.body.toName.trim() : '' + const subject = typeof req.body?.subject === 'string' ? req.body.subject.trim() : '' + const message = typeof req.body?.message === 'string' ? req.body.message.trim() : '' + const requestedFrom = typeof req.body?.fromAddress === 'string' ? req.body.fromAddress.trim() : '' + + if (!to || !/^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(to)) { + res.status(400).json({ message: 'A valid recipient email address is required.' }); return + } + if (!subject || subject.length > 180) { + res.status(400).json({ message: 'Subject is required and must be 180 characters or fewer.' }); return + } + if (!message || message.length > 6000) { + res.status(400).json({ message: 'Message is required and must be 6000 characters or fewer.' }); return + } + + const recipientName = toName ? splitName(toName).firstName || toName : 'friend' + const html = buildAdminReplyTemplate({ recipientName, message }) + const defaultFrom = getResendFromAddress() || ADMIN_REPLY_FROM + const fromAddress = ADMIN_REPLY_FROM_OPTIONS.includes(requestedFrom) ? requestedFrom : defaultFrom + const replyToAddress = getResendReplyToAddress() + const text = `Hi ${recipientName},\n\n${message}\n\nGrace and peace,\nVerse by Verse with Nate` + const resend = new Resend(process.env.RESEND_API_KEY) + + await sendResendEmailWithRetry({ + resend, + context: 'admin-compose', + payload: { + from: fromAddress, + to: [to], + subject, + replyTo: replyToAddress, + tags: [{ name: 'flow', value: 'admin-reply' }], + text, + html, + }, + }) + + state.replyHistory.unshift({ + id: randomUUID(), + submissionId: '', + toEmail: to, + toName: toName || to, + fromEmail: replyToAddress, + subject, + preview: message.slice(0, 500), + sentAt: new Date().toISOString(), + }) + state.replyHistory = state.replyHistory.slice(0, 500) + queueReplyHistoryWrite() + + res.json({ ok: true }) + } catch (err) { + console.error('[admin-compose] send error:', err) + res.status(500).json({ message: 'Failed to send email.' }) + } + }) + app.get('/api/admin-subscribers', requireAdminAuth, (_req, res) => { const seen = new Set() const subscribers = state.contactSubmissions diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index ff879f4..f36882d 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -11,12 +11,6 @@ import { AdminCollapsibleCard } from './components/AdminCollapsibleCard' import { useAutosave } from './hooks/useAutosave' import QRCode from 'qrcode' -// Addresses an admin may send a reply from — must stay in sync with ADMIN_REPLY_FROM_OPTIONS in server/config.js. -const REPLY_FROM_OPTIONS = [ - { value: 'Verse by Verse with Nate ', label: 'hello@versebyversewithnate.us' }, - { value: 'Verse by Verse with Nate ', label: 'nate@versebyversewithnate.us' }, -] - interface SortableLessonSectionProps { section: ColossiansStudySection study: StudyProgram @@ -657,33 +651,6 @@ interface ContactSubmission { htmlBody?: string | null } -interface ContactReplyDraft { - submissionId: string - recipientName: string - recipientEmail: string - subject: string - message: string - fromAddress: string -} - -interface ContactReplyTemplate { - id: string - label: string - subject: string - message: string -} - -interface ContactReplyHistoryItem { - id: string - submissionId: string - toEmail: string - toName: string - fromEmail: string - subject: string - preview: string - sentAt: string -} - interface Subscriber { name: string email: string @@ -691,14 +658,6 @@ interface Subscriber { source: 'contact-form' | 'download' } -interface ContactReplyConfig { - fromEmail: string - fromIdentity: string - resendApiConfigured: boolean - canSendReplies: boolean - note: string -} - type ChecklistPhase = 'pre' | 'post' interface PodcastChecklistTask { @@ -729,7 +688,7 @@ type AdminView = | 'podcast' | 'current-series' | 'episode-highlights' | 'podcast-checklist' | 'downloads' | 'custom-links' | 'content-blocks' | 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes' - | 'emails' | 'subscribers' | 'contacts' | 'study-users' | 'email-templates' + | 'subscribers' | 'contacts' | 'study-users' | 'email-templates' | 'seo' | 'legal' | 'security' | 'brand' | 'global' interface AdminSectionLink { @@ -766,7 +725,6 @@ const ADMIN_VIEW_OPTIONS: Array<{ group: string; options: Array<{ value: AdminVi options: [ { value: 'questions', label: 'Questions' }, { value: 'study-comments', label: 'Study Comments' }, - { value: 'emails', label: 'Emails' }, { value: 'contacts', label: 'Contacts' }, { value: 'subscribers', label: 'Subscribers' }, { value: 'study-users', label: 'Study Users' }, @@ -1155,16 +1113,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { const [questions, setQuestions] = useState([]) const [contactSubmissions, setContactSubmissions] = useState([]) - const [contactStatus, setContactStatus] = useState<'loading' | 'ready' | 'error'>('loading') - const [contactReplyDraft, setContactReplyDraft] = useState(null) - const [contactReplyStatus, setContactReplyStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle') - const [contactReplyMsg, setContactReplyMsg] = useState('') - const [contactReplyTemplates, setContactReplyTemplates] = useState([]) - const [contactReplyHistory, setContactReplyHistory] = useState([]) - const [contactReplyConfig, setContactReplyConfig] = useState(null) - const [contactTemplateStatus, setContactTemplateStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle') - const [emailMailboxView, setEmailMailboxView] = useState<'inbox' | 'archived'>('inbox') - const [selectedEmailId, setSelectedEmailId] = useState(null) const [answeredQuestions, setAnsweredQuestions] = useState<{ [key: string]: string }>({}) const [editingQuestionId, setEditingQuestionId] = useState(null) const [questionSearch, setQuestionSearch] = useState('') @@ -1386,30 +1334,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { .then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load contact submissions')))) .then(data => { setContactSubmissions((data as { submissions: ContactSubmission[] }).submissions ?? []) - setContactStatus('ready') - }) - .catch(() => { - setContactStatus('error') - }) - - fetch('/api/admin-contact-reply-templates') - .then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load reply templates')))) - .then(data => { - setContactReplyTemplates((data as { templates: ContactReplyTemplate[] }).templates ?? []) - }) - .catch(() => {}) - - fetch('/api/admin-contact-reply-history') - .then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load reply history')))) - .then(data => { - setContactReplyHistory((data as { items: ContactReplyHistoryItem[] }).items ?? []) - }) - .catch(() => {}) - - fetch('/api/admin-reply-config') - .then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load reply config')))) - .then(data => { - setContactReplyConfig(data as ContactReplyConfig) }) .catch(() => {}) @@ -1501,58 +1425,12 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { if (!r.ok) throw new Error('Could not refresh contact submissions') const data = await r.json() as { submissions?: ContactSubmission[] } setContactSubmissions(Array.isArray(data.submissions) ? data.submissions : []) - setContactStatus('ready') } - // Poll for new messages every 30 seconds — prepend new ones and refresh fields (e.g. archived) on existing ones - useEffect(() => { - const interval = setInterval(async () => { - try { - const r = await fetch('/api/admin-contact-submissions') - if (!r.ok) return - const data = await r.json() as { submissions?: ContactSubmission[] } - const fresh = Array.isArray(data.submissions) ? data.submissions : [] - setContactSubmissions(prev => { - const freshById = new Map(fresh.map(s => [s.id, s])) - const existingIds = new Set(prev.map(s => s.id)) - const merged = prev.map(s => freshById.get(s.id) ?? s) - const newOnes = fresh.filter(s => !existingIds.has(s.id)) - return newOnes.length > 0 ? [...newOnes, ...merged] : merged - }) - } catch { /* silent — don't disrupt the UI */ } - }, 30_000) - return () => clearInterval(interval) - }, []) - - useEffect(() => { - const visible = contactSubmissions.filter(item => (emailMailboxView === 'archived' ? item.archived === true : item.archived !== true)) - if (visible.length === 0) { - setSelectedEmailId(null) - return - } - if (!selectedEmailId || !visible.some(item => item.id === selectedEmailId)) { - setSelectedEmailId(visible[0].id) - } - }, [contactSubmissions, emailMailboxView, selectedEmailId]) - useEffect(() => { setQuestionPage(0) }, [questionSearch, questionFilter]) - async function reloadContactReplyHistory() { - const r = await fetch('/api/admin-contact-reply-history') - if (!r.ok) throw new Error('Could not refresh reply history') - const data = await r.json() as { items?: ContactReplyHistoryItem[] } - setContactReplyHistory(Array.isArray(data.items) ? data.items : []) - } - - async function reloadContactReplyTemplates() { - const r = await fetch('/api/admin-contact-reply-templates') - if (!r.ok) throw new Error('Could not refresh reply templates') - const data = await r.json() as { templates?: ContactReplyTemplate[] } - setContactReplyTemplates(Array.isArray(data.templates) ? data.templates : []) - } - async function reloadBackups() { const r = await fetch('/api/admin-stats/backups') if (!r.ok) throw new Error('Could not refresh backups') @@ -2331,9 +2209,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { if (view === 'questions' && unansweredCount > 0) { return { count: unansweredCount, neutral: false } } - if (view === 'emails' && unreadEmailCount > 0) { - return { count: unreadEmailCount, neutral: false } - } if (view === 'contacts' && contactSubmissions.length > 0) { return { count: contactSubmissions.length, neutral: true } } @@ -2837,147 +2712,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { } } - async function handleDeleteContactSubmission(submissionId: string) { - if (!confirm('Delete this contact submission permanently?')) return - try { - const res = await fetch(`/api/admin-contact-submissions/${encodeURIComponent(submissionId)}`, { method: 'DELETE' }) - if (!res.ok) throw new Error('Failed to delete submission') - await reloadContactSubmissions() - await reloadStats() - setMaintenanceMsg('Contact submission deleted.') - } catch { - setMaintenanceMsg('Failed to delete contact submission.') - } - } - - async function handleArchiveContactSubmission(submissionId: string, archived: boolean) { - try { - const res = await fetch(`/api/admin-contact-submissions/${encodeURIComponent(submissionId)}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ archived }), - }) - if (!res.ok) throw new Error('Failed to update submission') - await reloadContactSubmissions() - await reloadStats() - setContactReplyMsg(archived ? 'Message archived.' : 'Message moved back to inbox.') - } catch { - setContactReplyMsg('Failed to update archive status.') - } - } - - 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' - // Default reply-from to whichever address the email was sent to - const inboundTo = submission.inboundTo ?? '' - const defaultFrom = inboundTo.includes('nate@') - ? REPLY_FROM_OPTIONS[1].value - : REPLY_FROM_OPTIONS[0].value - setContactReplyDraft({ - submissionId: submission.id, - recipientName: submission.name, - recipientEmail: submission.email, - subject: subjectLine, - message: '', - fromAddress: defaultFrom, - }) - setContactReplyStatus('idle') - setContactReplyMsg(`Composing a reply to ${firstName}.`) - } - - function applyContactReplyTemplate(templateId: string) { - if (!contactReplyDraft) return - const template = contactReplyTemplates.find(item => item.id === templateId) - if (!template) return - - setContactReplyDraft({ - ...contactReplyDraft, - message: template.message, - }) - setContactReplyMsg(`Applied template: ${template.label}.`) - } - - function addContactReplyTemplate() { - setContactReplyTemplates(items => ([ - ...items, - { - id: Date.now().toString(36), - label: '', - subject: '', - message: '', - }, - ])) - setContactTemplateStatus('idle') - } - - function updateContactReplyTemplate(id: string, field: keyof ContactReplyTemplate, value: string) { - setContactReplyTemplates(items => items.map(item => item.id === id ? { ...item, [field]: value } : item)) - setContactTemplateStatus('idle') - } - - function removeContactReplyTemplate(id: string) { - setContactReplyTemplates(items => items.filter(item => item.id !== id)) - setContactTemplateStatus('idle') - } - - async function handleSaveContactReplyTemplates() { - setContactTemplateStatus('saving') - try { - const res = await fetch('/api/admin-contact-reply-templates', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ templates: contactReplyTemplates }), - }) - if (!res.ok) throw new Error('Failed to save templates') - await reloadContactReplyTemplates() - setContactTemplateStatus('saved') - setContactReplyMsg('Reply templates saved.') - } catch { - setContactTemplateStatus('error') - setContactReplyMsg('Failed to save reply templates.') - } - } - - async function handleSendContactReply() { - if (!contactReplyDraft) return - setContactReplyStatus('sending') - setContactReplyMsg('') - - try { - const res = await fetch(`/api/admin-contact-submissions/${encodeURIComponent(contactReplyDraft.submissionId)}/reply`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - subject: contactReplyDraft.subject, - message: contactReplyDraft.message, - fromAddress: contactReplyDraft.fromAddress, - }), - }) - - if (!res.ok) { - const data = await res.json().catch(() => ({})) - throw new Error((data as { message?: string }).message ?? 'Failed to send email.') - } - - setContactReplyStatus('sent') - const sentFrom = contactReplyDraft.fromAddress.match(/<([^>]+)>/)?.[1] ?? contactReplyDraft.fromAddress - setContactReplyMsg(`Reply sent to ${contactReplyDraft.recipientEmail} from ${sentFrom}.`) - await reloadContactReplyHistory() - setContactReplyDraft(null) - } catch (err) { - setContactReplyStatus('error') - setContactReplyMsg(err instanceof Error ? err.message : 'Failed to send email.') - } - } - const resourceLinks = (form.customLinks ?? []).filter(link => link.placement === 'resources') const filteredAdminQuestions = questions.filter(question => { @@ -3259,9 +2993,9 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
0 ? ' admin-dashboard-card--alert' : ''}`}>
{unreadEmailCount}
-
Unread Emails
+
Inbox Messages
{contactSubmissions.filter(s => s.archived).length} archived · {contactSubmissions.length} total
- {unreadEmailCount > 0 && } + Open Inbox →
{thisWeekReal.toLocaleString()}
@@ -5241,246 +4975,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { /> )} - {/* EMAILS */} - {adminView === 'emails' && ( -
-
-

Email Center

-

Manage inbound contact emails, archive threads, reply with templates, and review sent history.

-
- - {contactReplyConfig && ( -
-

Sender Status

-

From: {contactReplyConfig.fromIdentity}

-

Resend Configured: {contactReplyConfig.resendApiConfigured ? 'Yes' : 'No'}

-

Can Send Replies: {contactReplyConfig.canSendReplies ? 'Yes' : 'No'}

-

{contactReplyConfig.note}

-
- )} - -
- - -
- - {contactStatus === 'loading' &&

Loading emails…

} - {contactStatus === 'error' &&

Could not load contact submissions.

} - - {contactStatus === 'ready' && ( -
- - -
- {(() => { - const visible = contactSubmissions.filter(item => (emailMailboxView === 'archived' ? item.archived === true : item.archived !== true)) - const selected = visible.find(item => item.id === selectedEmailId) ?? null - if (!selected) return

Select an email to view details.

- - return ( - <> -
-

From: {selected.name} <{selected.email}>

-

Type: {selected.messageType}

-

Subscribed: {selected.subscribe ? 'Yes' : 'No'}

-

Received: {formatDate(selected.submittedAt)}

-
- -
- {selected.htmlBody ? ( -