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
+3 -509
View File
@@ -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 <hello@versebyversewithnate.us>', label: 'hello@versebyversewithnate.us' },
{ value: 'Verse by Verse with Nate <nate@versebyversewithnate.us>', 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<Question[]>([])
const [contactSubmissions, setContactSubmissions] = useState<ContactSubmission[]>([])
const [contactStatus, setContactStatus] = useState<'loading' | 'ready' | 'error'>('loading')
const [contactReplyDraft, setContactReplyDraft] = useState<ContactReplyDraft | null>(null)
const [contactReplyStatus, setContactReplyStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle')
const [contactReplyMsg, setContactReplyMsg] = useState('')
const [contactReplyTemplates, setContactReplyTemplates] = useState<ContactReplyTemplate[]>([])
const [contactReplyHistory, setContactReplyHistory] = useState<ContactReplyHistoryItem[]>([])
const [contactReplyConfig, setContactReplyConfig] = useState<ContactReplyConfig | null>(null)
const [contactTemplateStatus, setContactTemplateStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
const [emailMailboxView, setEmailMailboxView] = useState<'inbox' | 'archived'>('inbox')
const [selectedEmailId, setSelectedEmailId] = useState<string | null>(null)
const [answeredQuestions, setAnsweredQuestions] = useState<{ [key: string]: string }>({})
const [editingQuestionId, setEditingQuestionId] = useState<string | null>(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) {
</div>
<div className={`admin-dashboard-card${unreadEmailCount > 0 ? ' admin-dashboard-card--alert' : ''}`}>
<div className="admin-dashboard-card-value">{unreadEmailCount}</div>
<div className="admin-dashboard-card-label">Unread Emails</div>
<div className="admin-dashboard-card-label">Inbox Messages</div>
<div className="admin-dashboard-card-sub">{contactSubmissions.filter(s => s.archived).length} archived · {contactSubmissions.length} total</div>
{unreadEmailCount > 0 && <button type="button" className="admin-dashboard-card-action" onClick={() => navigateTo('emails')}>Open Inbox </button>}
<Link to="/email" className="admin-dashboard-card-action" style={{ textDecoration: 'none' }}>Open Inbox </Link>
</div>
<div className="admin-dashboard-card">
<div className="admin-dashboard-card-value">{thisWeekReal.toLocaleString()}</div>
@@ -5241,246 +4975,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
/>
)}
{/* EMAILS */}
{adminView === 'emails' && (
<section className="admin-panel-section" aria-label="Email center">
<div className="admin-panel-head">
<h2>Email Center</h2>
<p>Manage inbound contact emails, archive threads, reply with templates, and review sent history.</p>
</div>
{contactReplyConfig && (
<div className="admin-restore-preview" style={{ marginBottom: '0.9rem' }}>
<h3>Sender Status</h3>
<p><strong>From:</strong> {contactReplyConfig.fromIdentity}</p>
<p><strong>Resend Configured:</strong> {contactReplyConfig.resendApiConfigured ? 'Yes' : 'No'}</p>
<p><strong>Can Send Replies:</strong> {contactReplyConfig.canSendReplies ? 'Yes' : 'No'}</p>
<p>{contactReplyConfig.note}</p>
</div>
)}
<div className="admin-actions admin-actions--maintenance" style={{ marginBottom: '0.75rem' }}>
<button type="button" className={`btn-admin-reset${emailMailboxView === 'inbox' ? ' btn-admin-reset--active' : ''}`} onClick={() => setEmailMailboxView('inbox')}>Inbox</button>
<button type="button" className={`btn-admin-reset${emailMailboxView === 'archived' ? ' btn-admin-reset--active' : ''}`} onClick={() => setEmailMailboxView('archived')}>Archived</button>
</div>
{contactStatus === 'loading' && <p className="admin-stats-note">Loading emails</p>}
{contactStatus === 'error' && <p className="admin-stats-note">Could not load contact submissions.</p>}
{contactStatus === 'ready' && (
<div className="admin-email-layout">
<aside className="admin-email-list">
{contactSubmissions.filter(item => (emailMailboxView === 'archived' ? item.archived === true : item.archived !== true)).length === 0 && (
<p className="admin-stats-note">No messages in this mailbox.</p>
)}
{contactSubmissions
.filter(item => (emailMailboxView === 'archived' ? item.archived === true : item.archived !== true))
.map(item => (
<button
key={item.id}
type="button"
className={`admin-email-list-item${selectedEmailId === item.id ? ' admin-email-list-item--active' : ''}`}
onClick={() => setSelectedEmailId(item.id)}
>
<div className="admin-email-list-head">
<strong>{item.name}</strong>
<span>{formatDate(item.submittedAt)}</span>
</div>
<p>{item.message}</p>
</button>
))}
</aside>
<div className="admin-email-detail">
{(() => {
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 <p className="admin-stats-note">Select an email to view details.</p>
return (
<>
<div className="admin-email-meta">
<p><strong>From:</strong> {selected.name} &lt;{selected.email}&gt;</p>
<p><strong>Type:</strong> {selected.messageType}</p>
<p><strong>Subscribed:</strong> {selected.subscribe ? 'Yes' : 'No'}</p>
<p><strong>Received:</strong> {formatDate(selected.submittedAt)}</p>
</div>
<div className="admin-email-body">
{selected.htmlBody ? (
<iframe
srcDoc={selected.htmlBody}
sandbox="allow-same-origin"
style={{ width: '100%', minHeight: '320px', border: 'none', background: '#fff', borderRadius: '4px' }}
onLoad={e => {
const iframe = e.currentTarget
const h = iframe.contentDocument?.documentElement?.scrollHeight
if (h) iframe.style.height = `${h}px`
}}
/>
) : (
<p style={{ whiteSpace: 'pre-wrap' }}>{selected.message}</p>
)}
</div>
<div className="admin-actions admin-actions--maintenance">
<button type="button" className="btn-admin-save admin-email-action-btn" onClick={() => openContactReplyComposer(selected)}>Reply</button>
<button
type="button"
className="btn-admin-reset admin-email-action-btn"
onClick={() => handleArchiveContactSubmission(selected.id, !(selected.archived === true))}
>
{selected.archived === true ? 'Move to Inbox' : 'Archive'}
</button>
<button type="button" className="btn-admin-remove admin-email-action-btn" onClick={() => handleDeleteContactSubmission(selected.id)}>Delete</button>
</div>
</>
)
})()}
</div>
</div>
)}
{contactReplyDraft && (
<div className="admin-array-row" style={{ marginTop: '1rem' }}>
<div className="admin-array-fields">
{contactReplyTemplates.length > 0 && (
<div className="admin-field">
<label htmlFor="reply-template">Saved Template</label>
<select id="reply-template" defaultValue="" onChange={e => applyContactReplyTemplate(e.target.value)}>
<option value="">Choose a template</option>
{contactReplyTemplates.map(template => (
<option key={template.id} value={template.id}>{template.label}</option>
))}
</select>
</div>
)}
<div className="admin-field">
<label htmlFor="reply-to">To</label>
<input id="reply-to" type="text" value={`${contactReplyDraft.recipientName} <${contactReplyDraft.recipientEmail}>`} readOnly />
</div>
<div className="admin-field">
<label htmlFor="reply-from">From</label>
<select
id="reply-from"
value={contactReplyDraft.fromAddress}
onChange={e => setContactReplyDraft(draft => draft ? { ...draft, fromAddress: e.target.value } : draft)}
>
{REPLY_FROM_OPTIONS.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
<div className="admin-field">
<label htmlFor="reply-subject">Subject</label>
<input
id="reply-subject"
type="text"
value={contactReplyDraft.subject}
onChange={e => setContactReplyDraft(draft => draft ? { ...draft, subject: e.target.value } : draft)}
/>
</div>
<div className="admin-field">
<label htmlFor="reply-message">Message</label>
<textarea
id="reply-message"
rows={8}
placeholder="Type your reply here…"
value={contactReplyDraft.message}
onChange={e => setContactReplyDraft(draft => draft ? { ...draft, message: e.target.value } : draft)}
/>
<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 style={{ display: 'flex', gap: '0.5rem' }}>
<button type="button" className="btn-admin-save" onClick={handleSendContactReply} disabled={contactReplyStatus === 'sending'}>
{contactReplyStatus === 'sending' ? 'Sending…' : 'Send Reply'}
</button>
<button
type="button"
className="btn-admin-reset"
onClick={() => {
setContactReplyDraft(null)
setContactReplyStatus('idle')
setContactReplyMsg('')
}}
>
Cancel
</button>
</div>
</div>
)}
{contactReplyMsg && <p className="admin-stats-note">{contactReplyMsg}</p>}
<AdminCollapsibleCard
title="Saved Reply Templates"
subtitle={`${contactReplyTemplates.length} template${contactReplyTemplates.length === 1 ? '' : 's'} configured`}
>
{contactReplyTemplates.length === 0 && <p className="admin-stats-note">No saved templates yet.</p>}
{contactReplyTemplates.map(template => (
<AdminCollapsibleCard
key={template.id}
title={template.label || 'Untitled template'}
subtitle={template.subject || 'No subject set'}
className="admin-collapsible-card--nested"
>
<div className="admin-array-row admin-array-row--nested">
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor={`reply-template-label-${template.id}`}>Label</label>
<input id={`reply-template-label-${template.id}`} type="text" value={template.label} onChange={e => updateContactReplyTemplate(template.id, 'label', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`reply-template-subject-${template.id}`}>Subject</label>
<input id={`reply-template-subject-${template.id}`} type="text" value={template.subject} onChange={e => updateContactReplyTemplate(template.id, 'subject', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`reply-template-message-${template.id}`}>Message</label>
<textarea id={`reply-template-message-${template.id}`} rows={5} value={template.message} onChange={e => updateContactReplyTemplate(template.id, 'message', e.target.value)} />
</div>
</div>
<button type="button" className="btn-admin-remove" onClick={() => removeContactReplyTemplate(template.id)}>Remove</button>
</div>
</AdminCollapsibleCard>
))}
<div className="admin-actions admin-actions--maintenance">
<button type="button" className="btn-admin-reset" onClick={addContactReplyTemplate}>Add Template</button>
<button type="button" className="btn-admin-save" onClick={handleSaveContactReplyTemplates} disabled={contactTemplateStatus === 'saving'}>
{contactTemplateStatus === 'saving' ? 'Saving…' : 'Save Templates'}
</button>
</div>
</AdminCollapsibleCard>
<div className="admin-visits-table-wrap">
<h3>Reply History</h3>
{contactReplyHistory.length === 0 ? <p className="admin-stats-note">No admin replies have been sent yet.</p> : (
<div className="admin-visits-table-scroll">
<table className="admin-visits-table">
<thead>
<tr><th>Sent</th><th>To</th><th>From</th><th>Subject</th><th>Preview</th></tr>
</thead>
<tbody>
{contactReplyHistory.map(item => (
<tr key={item.id}>
<td>{formatDate(item.sentAt)}</td>
<td>{item.toName} ({item.toEmail})</td>
<td>{item.fromEmail}</td>
<td>{item.subject}</td>
<td>{item.preview}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</section>
)}
{/* ASSETS */}
{adminView === 'assets' && (
<section className="admin-panel-section" aria-label="Asset manager">