Files
Siteforge/src/EmailPage.tsx
T
2026-07-28 11:00:40 -04:00

1000 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
// ── Constants ────────────────────────────────────────────────────────────────
const REPLY_FROM_OPTIONS = [
{ value: 'Verse by Verse with Nate <hello@versebyversewithnate.us>', label: 'hello@versebyversewithnate.us' },
{ value: 'Verse by Verse with Nate <nate@versebyversewithnate.us>', label: 'nate@versebyversewithnate.us' },
]
// ── Types ────────────────────────────────────────────────────────────────────
interface ContactSubmission {
id: string
submittedAt: string
name: string
email: string
message: string
messageType: 'question' | 'testimony' | 'topic' | 'general'
subscribe: boolean
archived?: boolean
source?: 'contact-form' | 'download' | 'inbound-email'
inboundTo?: string
htmlBody?: string | null
messageId?: string
}
interface ReplyDraft {
submissionId: string | null
recipientName: string
recipientEmail: string
subject: string
message: string
fromAddress: string
}
interface ReplyTemplate {
id: string
label: string
subject: string
message: string
}
interface ReplyHistoryItem {
id: string
submissionId: string
toEmail: string
toName: string
fromEmail: string
subject: string
preview: string
sentAt: string
}
interface ReplyConfig {
fromEmail: string
fromIdentity: string
resendApiConfigured: boolean
canSendReplies: boolean
note: string
}
interface EmailSettings {
signature: string
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function extractSubject(message: string): string {
const m = /^Subject:\s*(.+)/m.exec(message ?? '')
return m ? m[1].trim() : ''
}
function extractBodyPreview(message: string): string {
const afterSubject = message.replace(/^Subject:\s*.+\n+/m, '').trim()
return afterSubject.slice(0, 200)
}
function formatDate(value: string | null | undefined): string {
if (!value) return '—'
const d = new Date(value)
return Number.isNaN(d.getTime()) ? '—' : d.toLocaleString()
}
function formatShortDate(value: string | null | undefined): string {
if (!value) return '—'
const d = new Date(value)
if (Number.isNaN(d.getTime())) return '—'
const now = new Date()
const sameDay = d.toDateString() === now.toDateString()
if (sameDay) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
const sameYear = d.getFullYear() === now.getFullYear()
if (sameYear) return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
return d.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' })
}
function readIds(): Set<string> {
try { return new Set(JSON.parse(localStorage.getItem('em-read-ids') ?? '[]') as string[]) }
catch { return new Set() }
}
function persistReadId(id: string) {
try {
const next = [...readIds(), id].slice(-1000)
localStorage.setItem('em-read-ids', JSON.stringify(next))
} catch {}
}
// ── EmailClient ──────────────────────────────────────────────────────────────
function EmailClient({ onLogout }: { onLogout: () => void }) {
const [submissions, setSubmissions] = useState<ContactSubmission[]>([])
const [loadStatus, setLoadStatus] = useState<'loading' | 'ready' | 'error'>('loading')
const [mailbox, setMailbox] = useState<'inbox' | 'archived'>('inbox')
const [selectedId, setSelectedId] = useState<string | null>(null)
const [search, setSearch] = useState('')
const [localReadIds, setLocalReadIds] = useState<Set<string>>(readIds)
const [replyDraft, setReplyDraft] = useState<ReplyDraft | null>(null)
const [replySending, setReplySending] = useState(false)
const [replyMsg, setReplyMsg] = useState('')
const [templates, setTemplates] = useState<ReplyTemplate[]>([])
const [history, setHistory] = useState<ReplyHistoryItem[]>([])
const [config, setConfig] = useState<ReplyConfig | null>(null)
const [templateStatus, setTemplateStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
const [showTemplatesMgr, setShowTemplatesMgr] = useState(false)
const [showHistory, setShowHistory] = useState(false)
const [showSettings, setShowSettings] = useState(false)
const [emailSettings, setEmailSettings] = useState<EmailSettings>({ signature: 'Grace and peace,\nVerse by Verse with Nate' })
const [settingsSig, setSettingsSig] = useState('')
const [settingsSaving, setSettingsSaving] = useState(false)
const [settingsSaved, setSettingsSaved] = useState(false)
const [actionMsg, setActionMsg] = useState('')
const composeRef = useRef<HTMLTextAreaElement>(null)
const listRef = useRef<HTMLElement>(null)
// ── Load ──
const loadAll = useCallback(async () => {
try {
const [subRes, tplRes, histRes, cfgRes, settingsRes] = await Promise.all([
fetch('/api/admin-contact-submissions'),
fetch('/api/admin-contact-reply-templates'),
fetch('/api/admin-contact-reply-history'),
fetch('/api/admin-reply-config'),
fetch('/api/admin-email-settings'),
])
if (!subRes.ok) throw new Error('submissions failed')
const subData = await subRes.json() as { submissions?: ContactSubmission[] }
setSubmissions(subData.submissions ?? [])
setLoadStatus('ready')
if (tplRes.ok) {
const d = await tplRes.json() as { templates?: ReplyTemplate[] }
setTemplates(d.templates ?? [])
}
if (histRes.ok) {
const d = await histRes.json() as { items?: ReplyHistoryItem[] }
setHistory(d.items ?? [])
}
if (cfgRes.ok) setConfig(await cfgRes.json() as ReplyConfig)
if (settingsRes.ok) {
const s = await settingsRes.json() as EmailSettings
setEmailSettings(s)
}
} catch {
setLoadStatus('error')
}
}, [])
useEffect(() => { loadAll() }, [loadAll])
// 30s poll for new messages
useEffect(() => {
const id = 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 = data.submissions ?? []
setSubmissions(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 {}
}, 30_000)
return () => clearInterval(id)
}, [])
// ── Derived ──
const filtered = useMemo(() => {
const inMailbox = submissions.filter(s => mailbox === 'archived' ? s.archived === true : s.archived !== true)
const q = search.trim().toLowerCase()
if (!q) return inMailbox
return inMailbox.filter(s => {
const subj = extractSubject(s.message).toLowerCase()
return (
s.name.toLowerCase().includes(q) ||
s.email.toLowerCase().includes(q) ||
subj.includes(q) ||
s.message.toLowerCase().includes(q)
)
})
}, [submissions, mailbox, search])
const selected = filtered.find(s => s.id === selectedId) ?? null
const inboxCount = submissions.filter(s => s.archived !== true).length
const unreadCount = submissions.filter(s => s.archived !== true && !localReadIds.has(s.id)).length
// Auto-select first on mailbox switch or after actions
useEffect(() => {
if (filtered.length === 0) { setSelectedId(null); return }
if (!selectedId || !filtered.some(s => s.id === selectedId)) {
setSelectedId(filtered[0].id)
}
}, [filtered, selectedId])
// Mark as read when selected
useEffect(() => {
if (!selectedId) return
if (localReadIds.has(selectedId)) return
persistReadId(selectedId)
setLocalReadIds(prev => { const n = new Set(prev); n.add(selectedId); return n })
}, [selectedId, localReadIds])
// Auto-scroll selected into view in list
useEffect(() => {
if (!selectedId || !listRef.current) return
const el = listRef.current.querySelector(`[data-id="${selectedId}"]`) as HTMLElement | null
el?.scrollIntoView({ block: 'nearest' })
}, [selectedId])
// Focus compose textarea when reply opens
useEffect(() => {
if (replyDraft) setTimeout(() => composeRef.current?.focus(), 50)
}, [replyDraft])
// ── Keyboard nav ──
useEffect(() => {
function onKey(e: KeyboardEvent) {
const tag = (e.target as Element).tagName.toLowerCase()
if (tag === 'input' || tag === 'textarea' || tag === 'select') return
const idx = filtered.findIndex(s => s.id === selectedId)
if (e.key === 'ArrowDown' || e.key === 'j') {
e.preventDefault()
if (idx < filtered.length - 1) setSelectedId(filtered[idx + 1].id)
} else if (e.key === 'ArrowUp' || e.key === 'k') {
e.preventDefault()
if (idx > 0) setSelectedId(filtered[idx - 1].id)
} else if (e.key === 'r' && selected && !replyDraft) {
openReply(selected)
} else if (e.key === 'Escape' && replyDraft) {
setReplyDraft(null)
} else if (e.key === 'e' && selected) {
handleArchive(selected.id, !(selected.archived === true))
}
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [filtered, selectedId, selected, replyDraft])
// ── Actions ──
async function handleArchive(id: string, archive: boolean) {
const res = await fetch(`/api/admin-contact-submissions/${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ archived: archive }),
})
if (!res.ok) { flash('Failed to update.'); return }
setSubmissions(prev => prev.map(s => s.id === id ? { ...s, archived: archive } : s))
flash(archive ? 'Archived.' : 'Moved to inbox.')
}
async function handleDelete(id: string) {
if (!confirm('Delete this message permanently?')) return
const res = await fetch(`/api/admin-contact-submissions/${encodeURIComponent(id)}`, { method: 'DELETE' })
if (!res.ok) { flash('Failed to delete.'); return }
setSubmissions(prev => prev.filter(s => s.id !== id))
flash('Message deleted.')
}
function flash(msg: string) {
setActionMsg(msg)
setTimeout(() => setActionMsg(''), 3000)
}
function openReply(submission: ContactSubmission) {
const firstName = submission.name?.trim().split(/\s+/)[0] || 'there'
const subject = extractSubject(submission.message)
const inboundTo = submission.inboundTo ?? ''
const defaultFrom = inboundTo.includes('nate@') ? REPLY_FROM_OPTIONS[1].value : REPLY_FROM_OPTIONS[0].value
setReplyDraft({
submissionId: submission.id,
recipientName: submission.name,
recipientEmail: submission.email,
subject: subject ? `Re: ${subject}` : 'Re: Your message',
message: '',
fromAddress: defaultFrom,
})
setReplyMsg('')
flash(`Composing reply to ${firstName}`)
}
function openCompose() {
setSelectedId(null)
setReplyDraft({
submissionId: null,
recipientName: '',
recipientEmail: '',
subject: '',
message: '',
fromAddress: REPLY_FROM_OPTIONS[0].value,
})
setReplyMsg('')
}
function applyTemplate(templateId: string) {
if (!replyDraft) return
const tpl = templates.find(t => t.id === templateId)
if (!tpl) return
setReplyDraft({ ...replyDraft, message: tpl.message })
}
async function handleSend() {
if (!replyDraft) return
setReplySending(true)
setReplyMsg('')
try {
if (replyDraft.submissionId) {
// Reply to existing submission
const res = await fetch(`/api/admin-contact-submissions/${encodeURIComponent(replyDraft.submissionId)}/reply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
subject: replyDraft.subject,
message: replyDraft.message,
fromAddress: replyDraft.fromAddress,
}),
})
if (!res.ok) {
const d = await res.json().catch(() => ({})) as { message?: string }
throw new Error(d.message ?? 'Failed to send.')
}
} else {
// Compose new (no existing submission)
const res = await fetch('/api/admin-email/compose', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
to: replyDraft.recipientEmail,
toName: replyDraft.recipientName,
subject: replyDraft.subject,
message: replyDraft.message,
fromAddress: replyDraft.fromAddress,
}),
})
if (!res.ok) {
const d = await res.json().catch(() => ({})) as { message?: string }
throw new Error(d.message ?? 'Failed to send.')
}
}
const sentFrom = replyDraft.fromAddress.match(/<([^>]+)>/)?.[1] ?? replyDraft.fromAddress
setReplyMsg(`Sent to ${replyDraft.recipientEmail} from ${sentFrom}.`)
setReplyDraft(null)
// Refresh history
fetch('/api/admin-contact-reply-history')
.then(r => r.ok ? r.json() : null)
.then(d => { if (d) setHistory((d as { items?: ReplyHistoryItem[] }).items ?? []) })
.catch(() => {})
} catch (err) {
setReplyMsg(err instanceof Error ? err.message : 'Failed to send.')
} finally {
setReplySending(false)
}
}
async function handleSaveTemplates() {
setTemplateStatus('saving')
try {
const res = await fetch('/api/admin-contact-reply-templates', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ templates }),
})
if (!res.ok) throw new Error('Failed to save')
setTemplateStatus('saved')
} catch {
setTemplateStatus('error')
}
}
// ── Render ──
return (
<div className="em-app">
{/* Header */}
<header className="em-header">
<div className="em-header-left">
<span className="em-header-brand"> Email Center</span>
{unreadCount > 0 && <span className="em-badge em-badge--alert">{unreadCount} new</span>}
</div>
<div className="em-header-actions">
<button type="button" className="em-btn em-btn--primary" onClick={openCompose}>
Compose
</button>
<Link to="/contacts" className="em-btn em-btn--ghost">Contacts</Link>
<Link to="/calendar" className="em-btn em-btn--ghost">Calendar</Link>
<Link to="/admin" className="em-btn em-btn--ghost"> Admin</Link>
<button type="button" className="em-btn em-btn--ghost" onClick={onLogout}>Log Out</button>
</div>
</header>
{/* Body */}
<div className="em-body">
{/* Sidebar */}
<aside className="em-sidebar">
{/* Search */}
<div className="em-search-wrap">
<input
className="em-search"
type="search"
placeholder="Search…"
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
{/* Mailbox tabs */}
<div className="em-mailbox-tabs">
<button
type="button"
className={`em-mailbox-tab${mailbox === 'inbox' ? ' em-mailbox-tab--active' : ''}`}
onClick={() => { setMailbox('inbox'); setSearch('') }}
>
Inbox
{inboxCount > 0 && (
<span className={`em-tab-badge${unreadCount > 0 ? ' em-tab-badge--unread' : ''}`}>
{inboxCount}
</span>
)}
</button>
<button
type="button"
className={`em-mailbox-tab${mailbox === 'archived' ? ' em-mailbox-tab--active' : ''}`}
onClick={() => { setMailbox('archived'); setSearch('') }}
>
Archived
</button>
</div>
{/* List */}
<section className="em-list" ref={listRef} aria-label="Messages">
{loadStatus === 'loading' && <p className="em-list-empty">Loading</p>}
{loadStatus === 'error' && <p className="em-list-empty">Could not load messages.</p>}
{loadStatus === 'ready' && filtered.length === 0 && (
<p className="em-list-empty">{search ? 'No results.' : 'No messages here.'}</p>
)}
{filtered.map(item => {
const subject = item.source === 'inbound-email' ? extractSubject(item.message) : ''
const preview = item.source === 'inbound-email' ? extractBodyPreview(item.message) : item.message
const isUnread = !localReadIds.has(item.id)
return (
<button
key={item.id}
data-id={item.id}
type="button"
className={`em-list-item${selectedId === item.id ? ' em-list-item--active' : ''}${isUnread ? ' em-list-item--unread' : ''}`}
onClick={() => setSelectedId(item.id)}
>
{isUnread && <span className="em-unread-dot" aria-label="Unread" />}
<div className="em-list-item-head">
<strong className="em-list-item-name">{item.name}</strong>
<span className="em-list-item-date">{formatShortDate(item.submittedAt)}</span>
</div>
{subject && <div className="em-list-item-subject">{subject}</div>}
<div className="em-list-item-preview">{preview}</div>
{item.source === 'inbound-email' && (
<span className="em-source-badge em-source-badge--inbound">
{item.inboundTo?.includes('nate@') ? 'nate@' : 'hello@'}
</span>
)}
</button>
)
})}
</section>
</aside>
{/* Detail pane */}
<main className="em-detail">
{replyDraft && !replyDraft.submissionId ? (
/* ── Compose New ── */
<div className="em-compose-new">
<div className="em-detail-header">
<h2 className="em-detail-subject">New Message</h2>
</div>
<div className="em-compose-fields">
<div className="em-compose-row">
<label className="em-compose-label">To</label>
<input
className="em-compose-input"
type="email"
placeholder="recipient@example.com"
value={replyDraft.recipientEmail}
onChange={e => setReplyDraft({ ...replyDraft, recipientEmail: e.target.value })}
/>
</div>
<div className="em-compose-row">
<label className="em-compose-label">Name</label>
<input
className="em-compose-input"
type="text"
placeholder="Recipient name (optional)"
value={replyDraft.recipientName}
onChange={e => setReplyDraft({ ...replyDraft, recipientName: e.target.value })}
/>
</div>
<div className="em-compose-row">
<label className="em-compose-label">From</label>
<select
className="em-compose-select"
value={replyDraft.fromAddress}
onChange={e => setReplyDraft({ ...replyDraft, fromAddress: e.target.value })}
>
{REPLY_FROM_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
<div className="em-compose-row">
<label className="em-compose-label">Subject</label>
<input
className="em-compose-input"
type="text"
value={replyDraft.subject}
onChange={e => setReplyDraft({ ...replyDraft, subject: e.target.value })}
/>
</div>
{templates.length > 0 && (
<div className="em-compose-row">
<label className="em-compose-label">Template</label>
<select className="em-compose-select" defaultValue="" onChange={e => applyTemplate(e.target.value)}>
<option value="">Choose a template</option>
{templates.map(t => <option key={t.id} value={t.id}>{t.label}</option>)}
</select>
</div>
)}
<textarea
ref={composeRef}
className="em-compose-body"
rows={12}
placeholder="Type your message…"
value={replyDraft.message}
onChange={e => setReplyDraft({ ...replyDraft, message: e.target.value })}
/>
<div className="em-compose-signature">
<span className="em-compose-signature-text">{emailSettings.signature}</span>
<span className="em-compose-signature-note">auto-appended · <button type="button" className="em-sig-edit-link" onClick={() => { setSettingsSig(emailSettings.signature); setShowSettings(true) }}>edit signature</button></span>
</div>
<div className="em-compose-actions">
<button type="button" className="em-btn em-btn--primary" onClick={handleSend} disabled={replySending}>
{replySending ? 'Sending…' : 'Send'}
</button>
<button type="button" className="em-btn em-btn--ghost" onClick={() => setReplyDraft(null)}>Discard</button>
</div>
{replyMsg && <p className="em-status-msg">{replyMsg}</p>}
</div>
</div>
) : !selected ? (
/* ── Empty state ── */
<div className="em-empty-state">
<div className="em-empty-icon"></div>
<p>Select a message to read it.</p>
<p className="em-empty-hint"> or j / k to navigate · r to reply · e to archive</p>
</div>
) : (
/* ── Message detail ── */
<>
<div className="em-detail-header">
<div className="em-detail-meta-row">
<div className="em-detail-from">
<div className="em-detail-avatar">{(selected.name || '?')[0].toUpperCase()}</div>
<div>
<div className="em-detail-from-name">{selected.name}</div>
<div className="em-detail-from-email">{selected.email}</div>
</div>
</div>
<div className="em-detail-meta-right">
<div className="em-detail-date">{formatDate(selected.submittedAt)}</div>
{selected.source === 'inbound-email' && selected.inboundTo && (
<div className="em-detail-to">to: {selected.inboundTo}</div>
)}
</div>
</div>
<div className="em-detail-subject-row">
<h2 className="em-detail-subject">
{selected.source === 'inbound-email'
? extractSubject(selected.message) || selected.messageType
: selected.messageType}
</h2>
<div className="em-detail-badges">
{selected.source && (
<span className={`em-source-badge em-source-badge--${selected.source}`}>
{selected.source === 'inbound-email' ? 'email' : selected.source === 'contact-form' ? 'contact form' : selected.source}
</span>
)}
{selected.subscribe && <span className="em-source-badge em-source-badge--subscribed">subscribed</span>}
</div>
</div>
</div>
<div className="em-detail-body">
{selected.htmlBody ? (
<iframe
srcDoc={selected.htmlBody}
sandbox="allow-same-origin"
className="em-detail-iframe"
onLoad={e => {
const f = e.currentTarget
const h = f.contentDocument?.documentElement?.scrollHeight
if (h) f.style.height = `${h + 24}px`
}}
/>
) : (
<pre className="em-detail-plain">{
selected.source === 'inbound-email'
? selected.message.replace(/^Subject:\s*.+\n+/m, '').trim()
: selected.message
}</pre>
)}
</div>
<div className="em-detail-footer">
<div className="em-detail-actions">
<button type="button" className="em-btn em-btn--primary" onClick={() => openReply(selected)}>
Reply
</button>
<button
type="button"
className="em-btn em-btn--secondary"
onClick={() => handleArchive(selected.id, !(selected.archived === true))}
>
{selected.archived ? 'Move to Inbox' : 'Archive'}
</button>
<button type="button" className="em-btn em-btn--danger" onClick={() => handleDelete(selected.id)}>
Delete
</button>
</div>
{actionMsg && !replyDraft && <p className="em-status-msg">{actionMsg}</p>}
</div>
{/* Inline reply composer */}
{replyDraft && replyDraft.submissionId === selected.id && (
<div className="em-reply-composer">
<div className="em-compose-header">
<span className="em-compose-header-label">Reply to {replyDraft.recipientName}</span>
<button type="button" className="em-compose-close" onClick={() => setReplyDraft(null)} aria-label="Close">×</button>
</div>
<div className="em-compose-fields">
<div className="em-compose-row em-compose-row--inline">
<label className="em-compose-label">From</label>
<select
className="em-compose-select"
value={replyDraft.fromAddress}
onChange={e => setReplyDraft({ ...replyDraft, fromAddress: e.target.value })}
>
{REPLY_FROM_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
{templates.length > 0 && (
<>
<label className="em-compose-label">Template</label>
<select className="em-compose-select" defaultValue="" onChange={e => applyTemplate(e.target.value)}>
<option value="">Choose</option>
{templates.map(t => <option key={t.id} value={t.id}>{t.label}</option>)}
</select>
</>
)}
</div>
<div className="em-compose-row">
<label className="em-compose-label">Subject</label>
<input
className="em-compose-input"
type="text"
value={replyDraft.subject}
onChange={e => setReplyDraft({ ...replyDraft, subject: e.target.value })}
/>
</div>
<textarea
ref={composeRef}
className="em-compose-body"
rows={7}
placeholder="Type your reply…"
value={replyDraft.message}
onChange={e => setReplyDraft({ ...replyDraft, message: e.target.value })}
/>
<div className="em-compose-signature">
Grace and peace, · Verse by Verse with Nate
<span className="em-compose-signature-note">auto-appended</span>
</div>
<div className="em-compose-actions">
<button type="button" className="em-btn em-btn--primary" onClick={handleSend} disabled={replySending}>
{replySending ? 'Sending…' : 'Send Reply'}
</button>
<button type="button" className="em-btn em-btn--ghost" onClick={() => setReplyDraft(null)}>Cancel</button>
{replyMsg && <span className="em-status-msg em-status-msg--inline">{replyMsg}</span>}
</div>
</div>
</div>
)}
</>
)}
</main>
</div>
{/* Templates manager — slide-up drawer */}
{showTemplatesMgr && (
<div className="em-drawer-overlay" onClick={e => { if (e.target === e.currentTarget) setShowTemplatesMgr(false) }}>
<div className="em-drawer">
<div className="em-drawer-header">
<h3>Reply Templates</h3>
<button type="button" className="em-compose-close" onClick={() => setShowTemplatesMgr(false)}>×</button>
</div>
<div className="em-drawer-body">
{templates.length === 0 && <p className="em-list-empty">No templates yet.</p>}
{templates.map((tpl, i) => (
<div key={tpl.id} className="em-template-card">
<input
className="em-compose-input"
placeholder="Template label"
value={tpl.label}
onChange={e => setTemplates(prev => prev.map((t, j) => j === i ? { ...t, label: e.target.value } : t))}
/>
<input
className="em-compose-input"
placeholder="Subject (optional)"
value={tpl.subject}
onChange={e => setTemplates(prev => prev.map((t, j) => j === i ? { ...t, subject: e.target.value } : t))}
/>
<textarea
className="em-compose-body"
rows={4}
placeholder="Message body"
value={tpl.message}
onChange={e => setTemplates(prev => prev.map((t, j) => j === i ? { ...t, message: e.target.value } : t))}
/>
<button
type="button"
className="em-btn em-btn--danger em-btn--sm"
onClick={() => setTemplates(prev => prev.filter((_, j) => j !== i))}
>
Remove
</button>
</div>
))}
<div className="em-drawer-actions">
<button
type="button"
className="em-btn em-btn--ghost"
onClick={() => setTemplates(prev => [...prev, { id: Date.now().toString(36), label: '', subject: '', message: '' }])}
>
Add Template
</button>
<button type="button" className="em-btn em-btn--primary" onClick={handleSaveTemplates} disabled={templateStatus === 'saving'}>
{templateStatus === 'saving' ? 'Saving…' : templateStatus === 'saved' ? 'Saved ✓' : 'Save Templates'}
</button>
</div>
</div>
</div>
</div>
)}
{/* Sent history drawer */}
{showHistory && (
<div className="em-drawer-overlay" onClick={e => { if (e.target === e.currentTarget) setShowHistory(false) }}>
<div className="em-drawer em-drawer--wide">
<div className="em-drawer-header">
<h3>Reply History</h3>
<button type="button" className="em-compose-close" onClick={() => setShowHistory(false)}>×</button>
</div>
<div className="em-drawer-body">
{history.length === 0 ? (
<p className="em-list-empty">No replies sent yet.</p>
) : (
<div className="em-history-list">
{history.map(item => (
<div key={item.id} className="em-history-item">
<div className="em-history-meta">
<span className="em-history-date">{formatDate(item.sentAt)}</span>
<span className="em-history-flow">from {item.fromEmail} {item.toName} ({item.toEmail})</span>
</div>
<div className="em-history-subject">{item.subject}</div>
<div className="em-history-preview">{item.preview}</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
)}
{/* Settings drawer */}
{showSettings && (
<div className="em-drawer-overlay" onClick={e => { if (e.target === e.currentTarget) setShowSettings(false) }}>
<div className="em-drawer">
<div className="em-drawer-header">
<h3>Email Settings</h3>
<button type="button" className="em-compose-close" onClick={() => setShowSettings(false)}>×</button>
</div>
<div className="em-drawer-body">
<div className="em-settings-section">
<label className="em-settings-label">
Outgoing Signature
<p className="em-settings-note">Appended to every reply and compose email you send.</p>
<textarea
className="em-compose-body em-settings-sig-textarea"
rows={4}
value={settingsSig}
onChange={e => { setSettingsSig(e.target.value); setSettingsSaved(false) }}
placeholder="Grace and peace,&#10;Verse by Verse with Nate"
/>
</label>
</div>
{config && (
<div className="em-settings-section">
<p className="em-settings-label">
Email Configuration
<span className={`em-settings-status${config.canSendReplies ? ' em-settings-status--ok' : ' em-settings-status--warn'}`}>
{config.canSendReplies ? 'Configured' : 'Not configured'}
</span>
</p>
<p className="em-settings-note">{config.note}</p>
{config.fromEmail && <p className="em-settings-note">Reply-to: {config.fromEmail}</p>}
</div>
)}
</div>
<div className="em-drawer-actions">
<button
type="button"
className="em-btn em-btn--primary"
disabled={settingsSaving}
onClick={async () => {
setSettingsSaving(true)
try {
const res = await fetch('/api/admin-email-settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ signature: settingsSig }),
})
if (res.ok) {
const data = await res.json() as { settings?: EmailSettings }
if (data.settings) setEmailSettings(data.settings)
setSettingsSaved(true)
}
} catch { /* silent */ }
setSettingsSaving(false)
}}
>
{settingsSaving ? 'Saving…' : settingsSaved ? 'Saved ✓' : 'Save Settings'}
</button>
</div>
</div>
</div>
)}
{/* Footer toolbar */}
<div className="em-footer-toolbar">
{config && !config.canSendReplies && (
<span className="em-footer-warn"> RESEND_API_KEY not configured sending is disabled</span>
)}
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => setShowTemplatesMgr(true)}>
Templates
</button>
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => setShowHistory(true)}>
Sent History
</button>
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => { setSettingsSig(emailSettings.signature); setShowSettings(true) }}>
Settings
</button>
<span className="em-footer-hint"> navigate · r reply · e archive · Esc close</span>
</div>
</div>
)
}
// ── Auth shell ───────────────────────────────────────────────────────────────
export default function EmailShell() {
const [authStatus, setAuthStatus] = useState<'checking' | 'authenticated' | 'unauthenticated' | 'misconfigured'>('checking')
const [password, setPassword] = useState('')
const [errorMsg, setErrorMsg] = useState('')
const [submitting, setSubmitting] = useState(false)
const [totpRequired, setTotpRequired] = useState(false)
const [pendingToken, setPendingToken] = useState('')
const [totpCode, setTotpCode] = useState('')
useEffect(() => {
fetch('/api/admin-auth/status')
.then(r => (r.ok ? r.json() : Promise.reject()))
.then(data => {
const d = data as { authenticated?: boolean; configured?: boolean }
if (d.configured === false) { setAuthStatus('misconfigured'); return }
setAuthStatus(d.authenticated ? 'authenticated' : 'unauthenticated')
})
.catch(() => setAuthStatus('unauthenticated'))
}, [])
async function handleLogin(e: React.FormEvent) {
e.preventDefault()
setSubmitting(true)
setErrorMsg('')
try {
const res = await fetch('/api/admin-auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
})
const data = await res.json().catch(() => ({})) as { ok?: boolean; totpRequired?: boolean; pendingToken?: string; message?: string }
if (!res.ok) { setErrorMsg(data.message ?? 'Login failed.'); setSubmitting(false); return }
if (data.totpRequired && data.pendingToken) {
setPendingToken(data.pendingToken); setTotpRequired(true); setPassword(''); setSubmitting(false); return
}
setAuthStatus('authenticated'); setPassword('')
} catch {
setErrorMsg('Login failed.')
} finally {
setSubmitting(false)
}
}
async function handleTotpVerify(e: React.FormEvent) {
e.preventDefault()
setSubmitting(true)
setErrorMsg('')
try {
const res = await fetch('/api/admin-auth/totp-verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pendingToken, code: totpCode }),
})
if (!res.ok) {
const d = await res.json().catch(() => ({})) as { message?: string }
setErrorMsg(d.message ?? 'Invalid code.'); setSubmitting(false); return
}
setAuthStatus('authenticated'); setTotpCode('')
} catch {
setErrorMsg('Verification failed.')
} finally {
setSubmitting(false)
}
}
async function handleLogout() {
try { await fetch('/api/admin-auth/logout', { method: 'POST' }) } finally {
setAuthStatus('unauthenticated'); setTotpRequired(false); setPendingToken('')
}
}
if (authStatus === 'authenticated') return <EmailClient onLogout={handleLogout} />
return (
<main className="admin-auth-page" aria-label="Email sign in">
<div className="admin-auth-card">
<p className="eyebrow">Email Center</p>
<h1>{authStatus === 'misconfigured' ? 'Not Configured' : 'Sign In'}</h1>
{authStatus === 'checking' && <p className="admin-auth-note">Checking session</p>}
{authStatus === 'misconfigured' && (
<p className="admin-auth-note">Set the ADMIN_PASSWORD environment variable to enable access.</p>
)}
{authStatus === 'unauthenticated' && !totpRequired && (
<form className="admin-auth-form" onSubmit={handleLogin}>
<label>Password
<input type="password" value={password} onChange={e => setPassword(e.target.value)} autoComplete="current-password" required />
</label>
{errorMsg && <p className="admin-auth-error">{errorMsg}</p>}
<button type="submit" className="btn-primary" disabled={submitting}>{submitting ? 'Signing In…' : 'Sign In'}</button>
<Link to="/" className="btn-secondary">Back to Site</Link>
</form>
)}
{authStatus === 'unauthenticated' && totpRequired && (
<form className="admin-auth-form" onSubmit={handleTotpVerify}>
<p className="admin-auth-note">Enter the 6-digit code from your authenticator app.</p>
<label>Code
<input type="text" inputMode="numeric" value={totpCode} onChange={e => setTotpCode(e.target.value)} autoComplete="one-time-code" placeholder="000000" autoFocus required />
</label>
{errorMsg && <p className="admin-auth-error">{errorMsg}</p>}
<button type="submit" className="btn-primary" disabled={submitting}>{submitting ? 'Verifying…' : 'Verify'}</button>
<button type="button" className="btn-secondary" onClick={() => { setTotpRequired(false); setPendingToken(''); setTotpCode(''); setErrorMsg('') }}>Back</button>
</form>
)}
</div>
</main>
)
}