diff --git a/package-lock.json b/package-lock.json
index 39defd4..41a847b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "siteforge",
- "version": "1.1.2",
+ "version": "1.1.44",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "siteforge",
- "version": "1.1.2",
+ "version": "1.1.44",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
diff --git a/package.json b/package.json
index fb416f7..1c3e768 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "siteforge",
"private": true,
- "version": "1.1.44",
+ "version": "1.1.45",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/server/routes/contact.js b/server/routes/contact.js
index 49d893b..436c8a9 100644
--- a/server/routes/contact.js
+++ b/server/routes/contact.js
@@ -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, '
')).map(p => `
${p}
`).join('') - const html = `${paragraphs}` - - 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.') }) - } - }) } diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index cdf2fc5..4621d18 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -698,7 +698,7 @@ type StringField = Exclude