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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user