Add /email route as standalone web email client; v1.1.11

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 <noreply@anthropic.com>
This commit is contained in:
nmemmert
2026-07-28 09:34:39 -04:00
parent e75a6d0e15
commit 7670b44d27
6 changed files with 1794 additions and 510 deletions
+916
View File
@@ -0,0 +1,916 @@
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
}
// ── 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 [actionMsg, setActionMsg] = useState('')
const composeRef = useRef<HTMLTextAreaElement>(null)
const listRef = useRef<HTMLElement>(null)
// ── Load ──
const loadAll = useCallback(async () => {
try {
const [subRes, tplRes, histRes, cfgRes] = 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'),
])
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)
} 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="/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">
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'}
</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>
)}
{/* 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>
<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>
)
}