Remove dead admin routes and broken UI elements; v1.1.45
Publish Container / docker (push) Successful in 14m54s
Publish Container / docker (push) Successful in 14m54s
- Deleted ~24 unused API routes from contact.js (email inbox/CRM feature never wired to frontend): reply, compose, draft, broadcast, webhooks, audit-log, contact CRUD, merge, import, and health endpoints - Removed dead imports from contact.js (config, data, email modules) - Removed broken /email Link from admin dashboard Inbox card - Cleaned up AdminView type: removed unreachable 'current-series' and 'episode-highlights' top-level values (only valid as podcast sub-tabs) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
fc63a34ef2
commit
0d830d6e79
4 files changed
+6
-601
No files matched your search
@@ -7,24 +7,14 @@ import {
|
||||
MAX_QUESTIONS,
|
||||
USE_RESEND_AUTOMATION_WELCOME,
|
||||
DEFAULT_SEO,
|
||||
ADMIN_REPLY_FROM,
|
||||
ADMIN_REPLY_FROM_OPTIONS,
|
||||
} from '../config.js'
|
||||
import { state } from '../state.js'
|
||||
import {
|
||||
queueContactSubmissionsWrite,
|
||||
queueQuestionsWrite,
|
||||
queueDraftQuestionsWrite,
|
||||
queueReplyTemplatesWrite,
|
||||
queueReplyHistoryWrite,
|
||||
queueEmailSettingsWrite,
|
||||
normalizeContactEmailStatus,
|
||||
normalizeMessageType,
|
||||
sanitizeReplyTemplates,
|
||||
sanitizeReplyHistory,
|
||||
appendAuditEntry,
|
||||
queueWebhooksWrite,
|
||||
fireWebhooks,
|
||||
} from '../data.js'
|
||||
import {
|
||||
noteContactEmailCooldown,
|
||||
@@ -36,10 +26,8 @@ import {
|
||||
getResendFromAddress,
|
||||
getResendReplyToAddress,
|
||||
getResendInboxAddress,
|
||||
getAddressDomain,
|
||||
buildContactWelcomeEmailTemplate,
|
||||
buildContactAdminNotificationTemplate,
|
||||
buildAdminReplyTemplate,
|
||||
sendResendEmailWithRetry,
|
||||
syncContactToResend,
|
||||
} from '../email.js'
|
||||
@@ -364,435 +352,11 @@ export function register(app) {
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.get('/api/admin-contact-email-health', requireAdminAuth, (_req, res) => {
|
||||
const fromAddress = getResendFromAddress()
|
||||
const replyToAddress = getResendReplyToAddress()
|
||||
const fromDomain = getAddressDomain(fromAddress)
|
||||
const replyDomain = getAddressDomain(replyToAddress)
|
||||
const warnings = []
|
||||
|
||||
if (!process.env.RESEND_API_KEY) warnings.push('RESEND_API_KEY is missing.')
|
||||
if (!fromDomain) warnings.push('RESEND_FROM is missing or invalid.')
|
||||
if (fromDomain.endsWith('resend.dev')) warnings.push('RESEND_FROM uses resend.dev. Prefer a verified custom domain.')
|
||||
if (fromDomain && replyDomain && fromDomain !== replyDomain) warnings.push('Sender and reply-to domains are different.')
|
||||
if (!process.env.RESEND_WEBHOOK_TOKEN) warnings.push('RESEND_WEBHOOK_TOKEN is not configured.')
|
||||
warnings.push('Verify SPF, DKIM, and DMARC for the sender domain.')
|
||||
|
||||
const recent = state.contactSubmissions.slice(0, 300)
|
||||
const failed = recent.filter(item => {
|
||||
const status = normalizeContactEmailStatus(item.emailStatus, item.subscribe === true)
|
||||
return ['failed', 'bounced', 'complained'].includes(status.welcome.status)
|
||||
|| ['failed', 'bounced', 'complained'].includes(status.adminNotification.status)
|
||||
|| ['failed', 'bounced', 'complained'].includes(status.adminReply.status)
|
||||
}).length
|
||||
|
||||
res.json({
|
||||
resendApiConfigured: Boolean(process.env.RESEND_API_KEY),
|
||||
webhookConfigured: Boolean(process.env.RESEND_WEBHOOK_TOKEN),
|
||||
fromAddress,
|
||||
replyToAddress,
|
||||
fromDomain,
|
||||
replyDomain,
|
||||
warnings,
|
||||
recentSubmissionFailures: failed,
|
||||
trackedSubmissions: recent.length,
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/api/admin-contact-submissions', requireAdminAuth, (_req, res) => {
|
||||
res.json({ submissions: state.contactSubmissions.slice(0, 300) })
|
||||
})
|
||||
|
||||
app.post('/api/admin-contact-submissions/add', requireAdminAuth, (req, res) => {
|
||||
const name = typeof req.body?.name === 'string' ? req.body.name.trim() : ''
|
||||
const email = typeof req.body?.email === 'string' ? req.body.email.trim() : ''
|
||||
const notes = typeof req.body?.notes === 'string' ? req.body.notes.trim().slice(0, 2000) : ''
|
||||
|
||||
if (!name && !email) {
|
||||
res.status(400).json({ message: 'Name or email is required.' }); return
|
||||
}
|
||||
if (email && !/^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(email)) {
|
||||
res.status(400).json({ message: 'Invalid email address.' }); return
|
||||
}
|
||||
|
||||
const submission = {
|
||||
id: randomUUID(),
|
||||
submittedAt: new Date().toISOString(),
|
||||
name: name.slice(0, 200),
|
||||
email,
|
||||
message: '',
|
||||
messageType: 'general',
|
||||
subscribe: false,
|
||||
archived: false,
|
||||
source: 'manual',
|
||||
notes,
|
||||
emailStatus: normalizeContactEmailStatus(null, false),
|
||||
}
|
||||
|
||||
state.contactSubmissions.unshift(submission)
|
||||
state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS)
|
||||
queueContactSubmissionsWrite()
|
||||
res.json({ ok: true, submission })
|
||||
})
|
||||
|
||||
app.patch('/api/admin-contact-submissions/:id', requireAdminAuth, (req, res) => {
|
||||
const { id } = req.params
|
||||
if (typeof id !== 'string' || !id.trim()) {
|
||||
res.status(400).json({ message: 'Invalid submission id.' }); return
|
||||
}
|
||||
|
||||
const patch = {}
|
||||
if (typeof req.body?.archived === 'boolean') patch.archived = req.body.archived
|
||||
if (typeof req.body?.starred === 'boolean') patch.starred = req.body.starred
|
||||
if (typeof req.body?.name === 'string') patch.name = req.body.name.trim().slice(0, 200)
|
||||
if (typeof req.body?.notes === 'string') patch.notes = req.body.notes.trim().slice(0, 2000)
|
||||
if (Array.isArray(req.body?.tags)) {
|
||||
patch.tags = [...new Set(req.body.tags.filter(t => typeof t === 'string' && t.trim()).map(t => t.trim().slice(0, 50)))].slice(0, 20)
|
||||
}
|
||||
if ('snoozedUntil' in (req.body ?? {})) {
|
||||
const v = req.body.snoozedUntil
|
||||
patch.snoozedUntil = v === null ? null : (typeof v === 'string' && !isNaN(Date.parse(v)) ? v : undefined)
|
||||
if (patch.snoozedUntil === undefined) delete patch.snoozedUntil
|
||||
}
|
||||
|
||||
let found = false
|
||||
state.contactSubmissions = state.contactSubmissions.map(item => {
|
||||
if (item.id !== id) return item
|
||||
found = true
|
||||
return { ...item, ...patch }
|
||||
})
|
||||
|
||||
if (!found) {
|
||||
res.status(404).json({ message: 'Submission not found.' }); return
|
||||
}
|
||||
|
||||
queueContactSubmissionsWrite()
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.delete('/api/admin-contact-submissions/:id', requireAdminAuth, (req, res) => {
|
||||
const { id } = req.params
|
||||
if (typeof id !== 'string' || !id.trim()) {
|
||||
res.status(400).json({ message: 'Invalid submission id.' }); return
|
||||
}
|
||||
|
||||
const startLength = state.contactSubmissions.length
|
||||
state.contactSubmissions = state.contactSubmissions.filter(item => item.id !== id)
|
||||
if (state.contactSubmissions.length === startLength) {
|
||||
res.status(404).json({ message: 'Submission not found.' }); return
|
||||
}
|
||||
|
||||
queueContactSubmissionsWrite()
|
||||
appendAuditEntry('submission-deleted', `id: ${id}`)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.post('/api/admin-contact-submissions/bulk', requireAdminAuth, (req, res) => {
|
||||
const { ids, action } = req.body ?? {}
|
||||
if (!Array.isArray(ids) || !['archive', 'unarchive', 'delete', 'star', 'unstar'].includes(action)) {
|
||||
res.status(400).json({ message: 'Invalid bulk action.' }); return
|
||||
}
|
||||
const idSet = new Set(ids.filter(id => typeof id === 'string'))
|
||||
if (idSet.size === 0) { res.json({ ok: true, affected: 0 }); return }
|
||||
|
||||
let affected = 0
|
||||
if (action === 'delete') {
|
||||
const before = state.contactSubmissions.length
|
||||
state.contactSubmissions = state.contactSubmissions.filter(s => !idSet.has(s.id))
|
||||
affected = before - state.contactSubmissions.length
|
||||
} else {
|
||||
const patch = action === 'archive' ? { archived: true }
|
||||
: action === 'unarchive' ? { archived: false }
|
||||
: action === 'star' ? { starred: true }
|
||||
: { starred: false }
|
||||
state.contactSubmissions = state.contactSubmissions.map(s => {
|
||||
if (!idSet.has(s.id)) return s
|
||||
affected++
|
||||
return { ...s, ...patch }
|
||||
})
|
||||
}
|
||||
queueContactSubmissionsWrite()
|
||||
res.json({ ok: true, affected })
|
||||
})
|
||||
|
||||
app.post('/api/admin-contacts/merge', requireAdminAuth, (req, res) => {
|
||||
const keepEmail = typeof req.body?.keepEmail === 'string' ? req.body.keepEmail.trim().toLowerCase() : ''
|
||||
const mergeEmail = typeof req.body?.mergeEmail === 'string' ? req.body.mergeEmail.trim().toLowerCase() : ''
|
||||
if (!keepEmail || !mergeEmail || keepEmail === mergeEmail) {
|
||||
res.status(400).json({ message: 'keepEmail and mergeEmail must be different non-empty addresses.' }); return
|
||||
}
|
||||
let affected = 0
|
||||
state.contactSubmissions = state.contactSubmissions.map(s => {
|
||||
if ((s.email ?? '').trim().toLowerCase() !== mergeEmail) return s
|
||||
affected++
|
||||
return { ...s, email: keepEmail }
|
||||
})
|
||||
queueContactSubmissionsWrite()
|
||||
appendAuditEntry('contacts-merged', `${mergeEmail} → ${keepEmail} (${affected} submission${affected !== 1 ? 's' : ''})`)
|
||||
res.json({ ok: true, affected })
|
||||
})
|
||||
|
||||
app.post('/api/admin-contacts/import', requireAdminAuth, (req, res) => {
|
||||
const rows = req.body?.rows
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
res.status(400).json({ message: 'rows must be a non-empty array.' }); return
|
||||
}
|
||||
const EMAIL_RE = /^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/
|
||||
let created = 0
|
||||
let skipped = 0
|
||||
const toAdd = []
|
||||
for (const row of rows.slice(0, 1000)) {
|
||||
const name = typeof row.name === 'string' ? row.name.trim().slice(0, 200) : ''
|
||||
const email = typeof row.email === 'string' ? row.email.trim().toLowerCase().slice(0, 320) : ''
|
||||
const notes = typeof row.notes === 'string' ? row.notes.trim().slice(0, 2000) : ''
|
||||
const tags = Array.isArray(row.tags)
|
||||
? row.tags.filter(t => typeof t === 'string' && t.trim()).map(t => t.trim().slice(0, 50)).slice(0, 20)
|
||||
: (typeof row.tags === 'string' ? row.tags.split(';').map(t => t.trim()).filter(Boolean).slice(0, 20) : [])
|
||||
if (!name && !email) { skipped++; continue }
|
||||
if (email && !EMAIL_RE.test(email)) { skipped++; continue }
|
||||
toAdd.push({ name, email, notes, tags })
|
||||
}
|
||||
for (const row of toAdd) {
|
||||
const submission = {
|
||||
id: randomUUID(),
|
||||
submittedAt: new Date().toISOString(),
|
||||
name: row.name,
|
||||
email: row.email,
|
||||
message: '',
|
||||
messageType: 'general',
|
||||
subscribe: false,
|
||||
archived: false,
|
||||
source: 'manual',
|
||||
notes: row.notes,
|
||||
tags: row.tags,
|
||||
emailStatus: normalizeContactEmailStatus(null, false),
|
||||
}
|
||||
state.contactSubmissions.unshift(submission)
|
||||
created++
|
||||
}
|
||||
state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS)
|
||||
if (created > 0) queueContactSubmissionsWrite()
|
||||
if (created > 0) appendAuditEntry('contacts-imported', `${created} contacts imported, ${skipped} skipped`)
|
||||
res.json({ ok: true, created, skipped })
|
||||
})
|
||||
|
||||
app.get('/api/admin-contact-submissions/:id/attachments/:attachmentId', requireAdminAuth, (req, res) => {
|
||||
const { id, attachmentId } = req.params
|
||||
const submission = state.contactSubmissions.find(s => s.id === id)
|
||||
if (!submission) { res.status(404).send('Not found.'); return }
|
||||
const attachment = (submission.attachments ?? []).find(a => a.id === attachmentId)
|
||||
if (!attachment) { res.status(404).send('Attachment not found.'); return }
|
||||
const safe = attachment.filename.replace(/[^\w.\-]/g, '_')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${safe}"`)
|
||||
res.setHeader('Content-Type', attachment.contentType || 'application/octet-stream')
|
||||
res.send(Buffer.from(attachment.data, 'base64'))
|
||||
})
|
||||
|
||||
app.get('/api/admin-reply-config', requireAdminAuth, (_req, res) => {
|
||||
res.json({
|
||||
fromEmail: getResendReplyToAddress(),
|
||||
fromIdentity: getResendFromAddress() || ADMIN_REPLY_FROM,
|
||||
resendApiConfigured: Boolean(process.env.RESEND_API_KEY),
|
||||
canSendReplies: Boolean(process.env.RESEND_API_KEY),
|
||||
note: process.env.RESEND_API_KEY
|
||||
? 'App is configured to attempt sends through Resend. Delivery still depends on Resend sender/domain verification.'
|
||||
: 'RESEND_API_KEY is missing, so admin replies cannot be sent yet.',
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/api/admin-contact-reply-templates', requireAdminAuth, (_req, res) => {
|
||||
res.json({ templates: state.replyTemplates })
|
||||
})
|
||||
|
||||
app.put('/api/admin-contact-reply-templates', requireAdminAuth, (req, res) => {
|
||||
const nextTemplates = sanitizeReplyTemplates(req.body?.templates)
|
||||
state.replyTemplates = nextTemplates
|
||||
queueReplyTemplatesWrite()
|
||||
res.json({ ok: true, templates: state.replyTemplates })
|
||||
})
|
||||
|
||||
app.get('/api/admin-contact-reply-history', requireAdminAuth, (_req, res) => {
|
||||
res.json({ items: state.replyHistory.slice(0, 100) })
|
||||
})
|
||||
|
||||
app.post('/api/admin-contact-submissions/:id/reply', 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 { id } = req.params
|
||||
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() : ''
|
||||
const scheduledAt = typeof req.body?.scheduledAt === 'string' && !isNaN(Date.parse(req.body.scheduledAt)) && new Date(req.body.scheduledAt) > new Date() ? req.body.scheduledAt : null
|
||||
|
||||
if (!id || typeof id !== 'string') {
|
||||
res.status(400).json({ message: 'Invalid submission id.' }); 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 submission = state.contactSubmissions.find(entry => entry.id === id)
|
||||
if (!submission) {
|
||||
res.status(404).json({ message: 'Submission not found.' }); return
|
||||
}
|
||||
if (!submission.email || !/^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(submission.email)) {
|
||||
res.status(400).json({ message: 'Submission does not have a valid email address.' }); return
|
||||
}
|
||||
|
||||
const recipientName = splitName(submission.name).firstName || submission.name || 'friend'
|
||||
const signature = state.emailSettings?.signature ?? 'Grace and peace,\nVerse by Verse with Nate'
|
||||
const html = buildAdminReplyTemplate({ recipientName, message, signature })
|
||||
const replyToAddress = getResendReplyToAddress()
|
||||
const defaultFrom = getResendFromAddress() || ADMIN_REPLY_FROM
|
||||
const fromAddress = ADMIN_REPLY_FROM_OPTIONS.includes(requestedFrom) ? requestedFrom : defaultFrom
|
||||
const text = `Hi ${recipientName},\n\n${message}\n\n${signature}\n${replyToAddress}`
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
|
||||
const sendResult = await sendResendEmailWithRetry({
|
||||
resend,
|
||||
context: 'admin-contact-reply',
|
||||
payload: {
|
||||
from: fromAddress,
|
||||
to: [submission.email],
|
||||
subject,
|
||||
replyTo: replyToAddress,
|
||||
...(scheduledAt ? { scheduledAt } : {}),
|
||||
tags: [
|
||||
{ name: 'flow', value: 'admin-reply' },
|
||||
{ name: 'message_type', value: submission.messageType ?? 'general' },
|
||||
{ name: 'submission_id', value: submission.id },
|
||||
],
|
||||
headers: {
|
||||
'X-Contact-Submission-Id': submission.id,
|
||||
...(typeof submission.messageId === 'string' && MESSAGE_ID_RE.test(submission.messageId) ? {
|
||||
'In-Reply-To': submission.messageId,
|
||||
'References': submission.messageId,
|
||||
} : {}),
|
||||
},
|
||||
text,
|
||||
html,
|
||||
},
|
||||
})
|
||||
registerResendMessageForSubmission(submission.id, 'adminReply', sendResult)
|
||||
upsertContactEmailStatus(submission.id, 'adminReply', { status: 'sent', lastEventType: 'email.sent', error: null })
|
||||
|
||||
state.replyHistory.unshift({
|
||||
id: randomUUID(),
|
||||
submissionId: submission.id,
|
||||
toEmail: submission.email,
|
||||
toName: submission.name,
|
||||
fromEmail: replyToAddress,
|
||||
subject,
|
||||
preview: message.slice(0, 500),
|
||||
sentAt: new Date().toISOString(),
|
||||
scheduledAt: scheduledAt ?? null,
|
||||
})
|
||||
state.replyHistory = state.replyHistory.slice(0, 500)
|
||||
queueReplyHistoryWrite()
|
||||
appendAuditEntry('reply-sent', `To: ${submission.email} | Subject: ${subject}${scheduledAt ? ` | Scheduled: ${scheduledAt}` : ''}`)
|
||||
fireWebhooks('reply.sent', { toEmail: submission.email, toName: submission.name, subject, sentAt: new Date().toISOString() })
|
||||
|
||||
res.json({ ok: true, scheduled: Boolean(scheduledAt), scheduledAt: scheduledAt ?? null })
|
||||
} catch (err) {
|
||||
if (typeof req.params?.id === 'string' && req.params.id.trim()) {
|
||||
upsertContactEmailStatus(req.params.id.trim(), 'adminReply', {
|
||||
status: 'failed',
|
||||
lastEventType: 'email.failed',
|
||||
error: String(err?.message ?? err ?? 'unknown error').slice(0, 600),
|
||||
})
|
||||
}
|
||||
console.error('[admin-reply] send error:', err)
|
||||
res.status(500).json({ message: 'Failed to send reply email.' })
|
||||
}
|
||||
})
|
||||
|
||||
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() : ''
|
||||
const scheduledAt = typeof req.body?.scheduledAt === 'string' && !isNaN(Date.parse(req.body.scheduledAt)) && new Date(req.body.scheduledAt) > new Date() ? req.body.scheduledAt : null
|
||||
|
||||
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 signature = state.emailSettings?.signature ?? 'Grace and peace,\nVerse by Verse with Nate'
|
||||
const html = buildAdminReplyTemplate({ recipientName, message, signature })
|
||||
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\n${signature}`
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
|
||||
await sendResendEmailWithRetry({
|
||||
resend,
|
||||
context: 'admin-compose',
|
||||
payload: {
|
||||
from: fromAddress,
|
||||
to: [to],
|
||||
subject,
|
||||
replyTo: replyToAddress,
|
||||
...(scheduledAt ? { scheduledAt } : {}),
|
||||
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(),
|
||||
scheduledAt: scheduledAt ?? null,
|
||||
})
|
||||
state.replyHistory = state.replyHistory.slice(0, 500)
|
||||
queueReplyHistoryWrite()
|
||||
appendAuditEntry('email-sent', `To: ${to} | Subject: ${subject}${scheduledAt ? ` | Scheduled: ${scheduledAt}` : ''}`)
|
||||
|
||||
res.json({ ok: true, scheduled: Boolean(scheduledAt), scheduledAt: scheduledAt ?? null })
|
||||
} catch (err) {
|
||||
console.error('[admin-compose] send error:', err)
|
||||
res.status(500).json({ message: 'Failed to send email.' })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/admin-email-settings', requireAdminAuth, (_req, res) => {
|
||||
res.json(state.emailSettings ?? { signature: 'Grace and peace,\nVerse by Verse with Nate' })
|
||||
})
|
||||
|
||||
app.put('/api/admin-email-settings', requireAdminAuth, (req, res) => {
|
||||
const signature = typeof req.body?.signature === 'string'
|
||||
? req.body.signature.slice(0, 1000)
|
||||
: (state.emailSettings?.signature ?? 'Grace and peace,\nVerse by Verse with Nate')
|
||||
state.emailSettings = { ...state.emailSettings, signature }
|
||||
queueEmailSettingsWrite()
|
||||
res.json({ ok: true, settings: state.emailSettings })
|
||||
})
|
||||
|
||||
app.get('/api/admin-subscribers', requireAdminAuth, (_req, res) => {
|
||||
const seen = new Set()
|
||||
const subscribers = state.contactSubmissions
|
||||
@@ -822,162 +386,4 @@ export function register(app) {
|
||||
res.setHeader('Content-Disposition', `attachment; filename="subscribers-${new Date().toISOString().slice(0, 10)}.csv"`)
|
||||
res.send(csv)
|
||||
})
|
||||
|
||||
app.get('/api/admin-audit-log', requireAdminAuth, (_req, res) => {
|
||||
res.json({ entries: state.auditLog.slice(0, 200) })
|
||||
})
|
||||
|
||||
app.post('/api/admin-broadcasts/send', requireAdminAuth, async (req, res) => {
|
||||
if (!process.env.RESEND_API_KEY) { res.status(503).json({ message: 'RESEND_API_KEY is not configured.' }); return }
|
||||
const audienceId = process.env.RESEND_AUDIENCE_ID
|
||||
if (!audienceId) { res.status(503).json({ message: 'RESEND_AUDIENCE_ID is not configured.' }); return }
|
||||
|
||||
const subject = typeof req.body?.subject === 'string' ? req.body.subject.trim() : ''
|
||||
const text = typeof req.body?.text === 'string' ? req.body.text.trim() : ''
|
||||
const fromLabel = typeof req.body?.from === 'string' ? req.body.from.trim() : getResendFromAddress()
|
||||
const previewText = typeof req.body?.previewText === 'string' ? req.body.previewText.trim() : ''
|
||||
|
||||
if (!subject) { res.status(400).json({ message: 'Subject is required.' }); return }
|
||||
if (!text) { res.status(400).json({ message: 'Message body is required.' }); return }
|
||||
|
||||
const paragraphs = text.split(/\n{2,}/).map(p => p.replace(/\n/g, '<br>')).map(p => `<p>${p}</p>`).join('')
|
||||
const html = `<!DOCTYPE html><html><body style="font-family:Georgia,serif;max-width:600px;margin:0 auto;padding:24px;color:#1a1a1a;">${paragraphs}</body></html>`
|
||||
|
||||
try {
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
const { data: created, error: createErr } = await resend.broadcasts.create({
|
||||
audienceId,
|
||||
from: fromLabel,
|
||||
subject,
|
||||
...(previewText ? { previewText } : {}),
|
||||
html,
|
||||
text,
|
||||
})
|
||||
if (createErr || !created?.id) {
|
||||
res.status(502).json({ message: createErr?.message || 'Broadcast creation failed.' }); return
|
||||
}
|
||||
const { error: sendErr } = await resend.broadcasts.send(created.id)
|
||||
if (sendErr) { res.status(502).json({ message: sendErr.message || 'Broadcast send failed.' }); return }
|
||||
|
||||
appendAuditEntry('broadcast-sent', `Subject: ${subject} | AudienceId: ${audienceId}`)
|
||||
res.json({ ok: true, broadcastId: created.id })
|
||||
} catch (err) {
|
||||
res.status(502).json({ message: String(err?.message ?? 'Broadcast failed.') })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/admin-contacts/trigger-drip', requireAdminAuth, async (req, res) => {
|
||||
if (!process.env.RESEND_API_KEY) { res.status(503).json({ message: 'RESEND_API_KEY is not configured.' }); return }
|
||||
const automationId = process.env.RESEND_AUTOMATION_WELCOME || ''
|
||||
if (!automationId) { res.status(503).json({ message: 'No RESEND_AUTOMATION_* configured.' }); return }
|
||||
|
||||
const email = typeof req.body?.email === 'string' ? req.body.email.trim().toLowerCase() : ''
|
||||
const tag = typeof req.body?.tag === 'string' ? req.body.tag.trim().slice(0, 50) : ''
|
||||
if (!email) { res.status(400).json({ message: 'email is required.' }); return }
|
||||
|
||||
try {
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
// Add or update the contact in the Resend audience so the automation can fire
|
||||
const audienceId = process.env.RESEND_AUDIENCE_ID || ''
|
||||
if (audienceId) {
|
||||
await resend.contacts.create({ audienceId, email, unsubscribed: false }).catch(() => {})
|
||||
}
|
||||
appendAuditEntry('drip-triggered', `email: ${email}${tag ? ` | tag: ${tag}` : ''}`)
|
||||
res.json({ ok: true, note: 'Contact synced to Resend audience; automation will fire based on your Resend settings.' })
|
||||
} catch (err) {
|
||||
res.status(502).json({ message: String(err?.message ?? 'Drip trigger failed.') })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/admin-contact-submissions/:id/draft-reply', requireAdminAuth, async (req, res) => {
|
||||
const apiKey = process.env.ANTHROPIC_API_KEY
|
||||
if (!apiKey) { res.status(503).json({ message: 'ANTHROPIC_API_KEY is not configured on the server.' }); return }
|
||||
|
||||
const { id } = req.params
|
||||
if (!id || typeof id !== 'string') { res.status(400).json({ message: 'Invalid submission id.' }); return }
|
||||
|
||||
const submission = state.contactSubmissions.find(s => s.id === id.trim())
|
||||
if (!submission) { res.status(404).json({ message: 'Submission not found.' }); return }
|
||||
|
||||
const senderName = submission.name?.trim() || 'this listener'
|
||||
const messageText = submission.message?.trim() || '(no message body)'
|
||||
|
||||
try {
|
||||
const apiRes = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
max_tokens: 400,
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: `Draft a warm, personal reply to this message from a podcast listener. Write only the reply body — no greeting line (handled separately), no sign-off (handled by a signature). Keep it under 150 words. Be genuine and specific to their message.\n\nFrom: ${senderName}\nMessage:\n${messageText.slice(0, 1500)}`,
|
||||
}],
|
||||
}),
|
||||
})
|
||||
|
||||
if (!apiRes.ok) {
|
||||
const err = await apiRes.json().catch(() => ({}))
|
||||
res.status(502).json({ message: err?.error?.message || 'AI draft request failed.' }); return
|
||||
}
|
||||
|
||||
const data = await apiRes.json()
|
||||
const draft = String(data?.content?.[0]?.text ?? '').trim()
|
||||
res.json({ ok: true, draft })
|
||||
} catch {
|
||||
res.status(502).json({ message: 'AI draft request failed.' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Webhook CRUD ───────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/admin-webhooks', requireAdminAuth, (_req, res) => {
|
||||
res.json({ webhooks: state.webhooks })
|
||||
})
|
||||
|
||||
app.post('/api/admin-webhooks', requireAdminAuth, (req, res) => {
|
||||
const url = typeof req.body?.url === 'string' ? req.body.url.trim() : ''
|
||||
const events = Array.isArray(req.body?.events) ? req.body.events.filter(e => typeof e === 'string' && e.trim()) : ['*']
|
||||
const label = typeof req.body?.label === 'string' ? req.body.label.trim().slice(0, 100) : ''
|
||||
if (!url || !/^https?:\/\/./.test(url)) {
|
||||
res.status(400).json({ message: 'A valid http/https URL is required.' }); return
|
||||
}
|
||||
const webhook = { id: randomUUID(), url: url.slice(0, 500), label, events, createdAt: new Date().toISOString() }
|
||||
state.webhooks.push(webhook)
|
||||
state.webhooks = state.webhooks.slice(0, 50)
|
||||
queueWebhooksWrite()
|
||||
appendAuditEntry('webhook-added', url)
|
||||
res.json({ ok: true, webhook })
|
||||
})
|
||||
|
||||
app.delete('/api/admin-webhooks/:id', requireAdminAuth, (req, res) => {
|
||||
const { id } = req.params
|
||||
const before = state.webhooks.length
|
||||
state.webhooks = state.webhooks.filter(w => w.id !== id)
|
||||
if (state.webhooks.length === before) { res.status(404).json({ message: 'Webhook not found.' }); return }
|
||||
queueWebhooksWrite()
|
||||
appendAuditEntry('webhook-removed', `id: ${id}`)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.post('/api/admin-webhooks/:id/test', requireAdminAuth, async (req, res) => {
|
||||
const wh = state.webhooks.find(w => w.id === req.params.id)
|
||||
if (!wh) { res.status(404).json({ message: 'Webhook not found.' }); return }
|
||||
try {
|
||||
const r = await fetch(wh.url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'User-Agent': 'Siteforge-Webhook/1.0' },
|
||||
body: JSON.stringify({ event: 'test', data: { message: 'Test event from Siteforge.' }, firedAt: new Date().toISOString() }),
|
||||
signal: AbortSignal.timeout(8000),
|
||||
})
|
||||
appendAuditEntry('webhook-test', `${wh.url} → ${r.status}`)
|
||||
res.json({ ok: true, status: r.status })
|
||||
} catch (err) {
|
||||
res.status(502).json({ message: String(err?.message ?? 'Test failed.') })
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user