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
+63
View File
@@ -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