e2c560a1ab
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
816 lines
37 KiB
JavaScript
816 lines
37 KiB
JavaScript
import { randomUUID } from 'node:crypto'
|
|
import { Resend } from 'resend'
|
|
import { requireAdminAuth } from '../auth.js'
|
|
import { escapeHtml, splitName } from '../helpers.js'
|
|
import {
|
|
MAX_CONTACT_SUBMISSIONS,
|
|
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,
|
|
} from '../data.js'
|
|
import {
|
|
noteContactEmailCooldown,
|
|
extractTagValue,
|
|
mapResendEventToStatus,
|
|
extractResendMessageId,
|
|
} from '../study-helpers.js'
|
|
import {
|
|
getResendFromAddress,
|
|
getResendReplyToAddress,
|
|
getResendInboxAddress,
|
|
getAddressDomain,
|
|
buildContactWelcomeEmailTemplate,
|
|
buildContactAdminNotificationTemplate,
|
|
buildAdminReplyTemplate,
|
|
sendResendEmailWithRetry,
|
|
syncContactToResend,
|
|
} from '../email.js'
|
|
|
|
// RFC 5322 msg-id: "<" printable-ASCII-no-whitespace ">"
|
|
const MESSAGE_ID_RE = /^<[\x21-\x7E]+>$/
|
|
|
|
function upsertContactEmailStatus(submissionId, stream, patch) {
|
|
if (!submissionId || typeof submissionId !== 'string') return
|
|
if (!stream || typeof stream !== 'string') return
|
|
const at = typeof patch?.lastEventAt === 'string' ? patch.lastEventAt : new Date().toISOString()
|
|
let updated = false
|
|
|
|
state.contactSubmissions = state.contactSubmissions.map(submission => {
|
|
if (submission.id !== submissionId) return submission
|
|
const next = normalizeContactEmailStatus(submission.emailStatus, submission.subscribe === true)
|
|
const current = next[stream] ?? { status: 'pending', lastEventAt: null, lastEventType: null, resendEmailId: null, error: null }
|
|
next[stream] = { ...current, ...patch, lastEventAt: at }
|
|
updated = true
|
|
return { ...submission, emailStatus: next }
|
|
})
|
|
|
|
if (updated) queueContactSubmissionsWrite()
|
|
}
|
|
|
|
function registerResendMessageForSubmission(submissionId, stream, sendResult) {
|
|
const resendMessageId = extractResendMessageId(sendResult)
|
|
if (!resendMessageId || !submissionId || !stream) return
|
|
state.resendEmailSubmissionIndex.set(resendMessageId, { submissionId, stream })
|
|
// Trim the index when it grows large; oldest entries are least likely to receive webhooks
|
|
if (state.resendEmailSubmissionIndex.size > 2000) {
|
|
const firstKey = state.resendEmailSubmissionIndex.keys().next().value
|
|
state.resendEmailSubmissionIndex.delete(firstKey)
|
|
}
|
|
upsertContactEmailStatus(submissionId, stream, { resendEmailId: resendMessageId })
|
|
}
|
|
|
|
function shouldSendWelcomeEmail({ subscribe }) {
|
|
return subscribe === true
|
|
}
|
|
|
|
const contactHits = new Map()
|
|
|
|
function contactRateLimit(req, res, next) {
|
|
const ip = req.ip ?? 'unknown'
|
|
const now = Date.now()
|
|
const windowMs = 10 * 60 * 1000
|
|
const entry = contactHits.get(ip) ?? { count: 0, start: now }
|
|
if (now - entry.start > windowMs) { entry.count = 0; entry.start = now }
|
|
entry.count += 1
|
|
contactHits.set(ip, entry)
|
|
// Prune stale entries to prevent unbounded growth
|
|
if (contactHits.size > 5000) {
|
|
const cutoff = now - windowMs
|
|
for (const [k, v] of contactHits) { if (v.start < cutoff) contactHits.delete(k) }
|
|
}
|
|
if (entry.count > 5) {
|
|
res.status(429).json({ message: 'Too many messages. Please wait a few minutes.' })
|
|
return
|
|
}
|
|
next()
|
|
}
|
|
|
|
export function register(app) {
|
|
app.post('/api/contact', contactRateLimit, async (req, res) => {
|
|
try {
|
|
const { firstName, lastName, email, message, messageType, subscribe, notifyOnAnswer, _honey } = req.body ?? {}
|
|
|
|
if (_honey) { res.json({ ok: true }); return }
|
|
|
|
if (!firstName || typeof firstName !== 'string' || firstName.trim().length < 1 || firstName.trim().length > 100) {
|
|
res.status(400).json({ message: 'First name is required.' }); return
|
|
}
|
|
if (lastName !== undefined && lastName !== null && (typeof lastName !== 'string' || lastName.trim().length > 100)) {
|
|
res.status(400).json({ message: 'Last name is too long.' }); return
|
|
}
|
|
if (!email || typeof email !== 'string' || !/^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(email.trim())) {
|
|
res.status(400).json({ message: 'A valid email address is required.' }); return
|
|
}
|
|
if (!message || typeof message !== 'string' || message.trim().length < 5 || message.trim().length > 3000) {
|
|
res.status(400).json({ message: 'Message must be between 5 and 3000 characters.' }); return
|
|
}
|
|
|
|
if (!process.env.RESEND_API_KEY) {
|
|
console.error('[contact] RESEND_API_KEY env var not set')
|
|
res.status(503).json({ message: 'The contact form is not yet configured on the server.' }); return
|
|
}
|
|
|
|
const trimmedName = [firstName.trim(), typeof lastName === 'string' ? lastName.trim() : ''].filter(Boolean).join(' ')
|
|
const trimmedEmail = email.trim()
|
|
const trimmedMessage = message.trim()
|
|
const normalizedMessageType = normalizeMessageType(messageType)
|
|
const cooldown = noteContactEmailCooldown(trimmedEmail)
|
|
if (!cooldown.ok) {
|
|
const retryAfterSeconds = Math.max(1, Math.ceil(cooldown.retryAfterMs / 1000))
|
|
res.status(429).json({ message: `Please wait ${retryAfterSeconds}s before sending another message from this email.` }); return
|
|
}
|
|
|
|
const submittedAt = new Date().toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' })
|
|
const shouldSendWelcome = shouldSendWelcomeEmail({ subscribe })
|
|
|
|
const wantsWelcome = subscribe === true
|
|
const submission = {
|
|
id: randomUUID(),
|
|
submittedAt: new Date().toISOString(),
|
|
name: trimmedName,
|
|
email: trimmedEmail,
|
|
message: trimmedMessage,
|
|
messageType: normalizedMessageType,
|
|
subscribe: wantsWelcome,
|
|
archived: false,
|
|
emailStatus: normalizeContactEmailStatus(null, wantsWelcome),
|
|
}
|
|
state.contactSubmissions.unshift(submission)
|
|
state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS)
|
|
queueContactSubmissionsWrite()
|
|
|
|
if (normalizedMessageType === 'question') {
|
|
const question = {
|
|
id: randomUUID(),
|
|
submittedAt: new Date().toISOString(),
|
|
firstName: splitName(trimmedName).firstName,
|
|
email: trimmedEmail,
|
|
question: trimmedMessage,
|
|
answer: '',
|
|
answeredAt: null,
|
|
isApproved: false,
|
|
approvedAt: null,
|
|
notifyOnAnswer: notifyOnAnswer === true,
|
|
}
|
|
state.questions.unshift(question)
|
|
state.questions = state.questions.slice(0, MAX_QUESTIONS)
|
|
if (state.draftQuestions !== null) {
|
|
state.draftQuestions.unshift(question)
|
|
state.draftQuestions = state.draftQuestions.slice(0, MAX_QUESTIONS)
|
|
queueDraftQuestionsWrite()
|
|
}
|
|
queueQuestionsWrite()
|
|
}
|
|
|
|
const resend = new Resend(process.env.RESEND_API_KEY)
|
|
const adminInbox = getResendInboxAddress()
|
|
const replyToAddress = getResendReplyToAddress()
|
|
const fromAddress = getResendFromAddress()
|
|
const safeMessageTypeTag = normalizedMessageType.replace(/[^a-z0-9_-]/gi, '-').toLowerCase()
|
|
const adminTemplate = buildContactAdminNotificationTemplate({ normalizedMessageType, trimmedName, trimmedEmail, submittedAt, trimmedMessage })
|
|
let welcomeSent = false
|
|
|
|
if (subscribe === true) {
|
|
await syncContactToResend(trimmedName, trimmedEmail)
|
|
}
|
|
|
|
if (shouldSendWelcome && !USE_RESEND_AUTOMATION_WELCOME) {
|
|
const greetingName = splitName(trimmedName).firstName?.trim() ?? ''
|
|
let publishedSiteContent = state.cachedSiteContent
|
|
if (!publishedSiteContent) {
|
|
try {
|
|
const { loadSiteContentFile } = await import('../data.js')
|
|
const { DATA_FILE } = await import('../config.js')
|
|
const published = await loadSiteContentFile(DATA_FILE)
|
|
publishedSiteContent = published?.siteContent ?? null
|
|
} catch { publishedSiteContent = null }
|
|
}
|
|
|
|
const emailConfig = publishedSiteContent ?? {}
|
|
const welcomeBaseUrl = typeof emailConfig?.seo?.canonicalUrl === 'string' && emailConfig.seo.canonicalUrl.trim()
|
|
? emailConfig.seo.canonicalUrl.trim()
|
|
: DEFAULT_SEO.canonicalUrl
|
|
const welcomeSubject = process.env.RESEND_WELCOME_SUBJECT ?? emailConfig.welcomeEmailSubject ?? 'Welcome to Verse by Verse with Nate'
|
|
const welcomeGreetingPrefix = emailConfig.welcomeEmailGreetingPrefix?.trim() || "Glad you're here"
|
|
const { buildAbsoluteUrl } = await import('../helpers.js')
|
|
const welcomeTemplate = buildContactWelcomeEmailTemplate({
|
|
greetingName,
|
|
welcomeGreetingPrefix,
|
|
welcomeIntro: emailConfig.welcomeEmailIntro?.trim() || 'Thanks for subscribing to Verse by Verse with Nate - a Bible teaching podcast where we slow down, dig into the text, and pull out the nuggets God has for us word by word.',
|
|
welcomeCurrentSeries: emailConfig.welcomeEmailCurrentSeries?.trim() || "Right now we're working through the book of Titus - a short letter packed with practical wisdom about grace, godliness, and what the Christian life looks like when it's rooted in sound doctrine.",
|
|
welcomeStartHereTitle: emailConfig.welcomeEmailStartHereTitle?.trim() || 'Episode 1 - Introduction to Titus',
|
|
welcomeStartHereSummary: emailConfig.welcomeEmailStartHereSummary?.trim() || 'Who wrote it, who received it, and why it still matters.',
|
|
welcomeExpect1: emailConfig.welcomeEmailWhatToExpect1?.trim() || 'Verse-by-verse teaching - we go slow and let the text speak for itself.',
|
|
welcomeExpect2: emailConfig.welcomeEmailWhatToExpect2?.trim() || 'Greek word studies - the kind that open up meaning without being a lecture.',
|
|
welcomeExpect3: emailConfig.welcomeEmailWhatToExpect3?.trim() || 'New episodes + study notes delivered right to your inbox.',
|
|
welcomeScripture: emailConfig.welcomeEmailScripture?.trim() || 'For the grace of God has appeared, bringing salvation to all people.',
|
|
welcomeScriptureRef: emailConfig.welcomeEmailScriptureRef?.trim() || 'Titus 2:11 - BSB',
|
|
welcomeSignoff: emailConfig.welcomeEmailSignoff?.trim() || 'Grace and peace,\nNate',
|
|
welcomeSpotifyUrl: buildAbsoluteUrl(welcomeBaseUrl, process.env.RESEND_WELCOME_SPOTIFY_URL ?? emailConfig.welcomeEmailSpotifyUrl ?? '/spotify'),
|
|
welcomeAppleUrl: buildAbsoluteUrl(welcomeBaseUrl, process.env.RESEND_WELCOME_APPLE_URL ?? emailConfig.welcomeEmailAppleUrl ?? '/apple'),
|
|
welcomeAmazonUrl: buildAbsoluteUrl(welcomeBaseUrl, process.env.RESEND_WELCOME_AMAZON_URL ?? emailConfig.welcomeEmailAmazonUrl ?? '/amazon'),
|
|
welcomeWebsiteUrl: buildAbsoluteUrl(welcomeBaseUrl, process.env.RESEND_WELCOME_WEBSITE_URL ?? emailConfig.welcomeEmailWebsiteUrl ?? '/'),
|
|
welcomeEpisodeUrl: buildAbsoluteUrl(welcomeBaseUrl, process.env.RESEND_WELCOME_EPISODE_URL ?? emailConfig.welcomeEmailStartHereUrl ?? '/start-here'),
|
|
welcomeImageUrl: buildAbsoluteUrl(welcomeBaseUrl, process.env.RESEND_WELCOME_IMAGE_URL ?? emailConfig.welcomeEmailImageUrl ?? '/images/podcast-art.jpeg'),
|
|
welcomeSpotifyBtnLabel: emailConfig.welcomeEmailSpotifyBtnLabel?.trim() || 'Listen on Spotify',
|
|
welcomeAppleBtnLabel: emailConfig.welcomeEmailAppleBtnLabel?.trim() || 'Apple Podcasts',
|
|
welcomeStartHereLinkLabel: emailConfig.welcomeEmailStartHereLinkLabel?.trim() || 'Open Start Here page',
|
|
})
|
|
|
|
try {
|
|
const welcomeSendResult = await sendResendEmailWithRetry({
|
|
resend,
|
|
context: 'contact-welcome',
|
|
payload: {
|
|
from: fromAddress,
|
|
to: [trimmedEmail],
|
|
replyTo: replyToAddress,
|
|
subject: welcomeSubject,
|
|
tags: [
|
|
{ name: 'flow', value: 'contact-welcome' },
|
|
{ name: 'message_type', value: safeMessageTypeTag },
|
|
{ name: 'submission_id', value: submission.id },
|
|
],
|
|
headers: {
|
|
'List-Unsubscribe': `<mailto:${replyToAddress}?subject=Unsubscribe>`,
|
|
'X-Contact-Submission-Id': submission.id,
|
|
},
|
|
text: welcomeTemplate.text,
|
|
html: welcomeTemplate.html,
|
|
},
|
|
})
|
|
registerResendMessageForSubmission(submission.id, 'welcome', welcomeSendResult)
|
|
upsertContactEmailStatus(submission.id, 'welcome', { status: 'sent', lastEventType: 'email.sent', error: null })
|
|
welcomeSent = true
|
|
} catch (welcomeErr) {
|
|
upsertContactEmailStatus(submission.id, 'welcome', {
|
|
status: 'failed',
|
|
lastEventType: 'email.failed',
|
|
error: String(welcomeErr?.message ?? welcomeErr ?? 'unknown error').slice(0, 600),
|
|
})
|
|
console.error('[contact] welcome email failed:', welcomeErr)
|
|
// Submission is already saved — don't 500 the user; fall through to admin notification.
|
|
}
|
|
} else if (shouldSendWelcome && USE_RESEND_AUTOMATION_WELCOME) {
|
|
upsertContactEmailStatus(submission.id, 'welcome', { status: 'automation-enabled', lastEventType: 'email.automation.enabled', error: null })
|
|
}
|
|
|
|
try {
|
|
const adminSendResult = await sendResendEmailWithRetry({
|
|
resend,
|
|
context: 'contact-admin-notification',
|
|
payload: {
|
|
from: fromAddress,
|
|
to: [adminInbox],
|
|
replyTo: trimmedEmail,
|
|
subject: adminTemplate.subject,
|
|
tags: [
|
|
{ name: 'flow', value: 'contact-admin' },
|
|
{ name: 'message_type', value: safeMessageTypeTag },
|
|
{ name: 'submission_id', value: submission.id },
|
|
],
|
|
headers: { 'X-Contact-Submission-Id': submission.id },
|
|
text: adminTemplate.text,
|
|
html: adminTemplate.html,
|
|
},
|
|
})
|
|
registerResendMessageForSubmission(submission.id, 'adminNotification', adminSendResult)
|
|
upsertContactEmailStatus(submission.id, 'adminNotification', { status: 'sent', lastEventType: 'email.sent', error: null })
|
|
} catch (adminSendErr) {
|
|
upsertContactEmailStatus(submission.id, 'adminNotification', {
|
|
status: 'failed',
|
|
lastEventType: 'email.failed',
|
|
error: String(adminSendErr?.message ?? adminSendErr ?? 'unknown error').slice(0, 600),
|
|
})
|
|
console.error('[contact] admin notification email failed:', adminSendErr)
|
|
// Submission is already saved — don't 500 the user.
|
|
}
|
|
|
|
res.json({ ok: true, welcomeSent, welcomeHandledByAutomation: shouldSendWelcome && USE_RESEND_AUTOMATION_WELCOME })
|
|
} catch (err) {
|
|
console.error('[contact] send error:', err)
|
|
res.status(500).json({ message: 'Failed to send your message. Please try again or email us directly.' })
|
|
}
|
|
})
|
|
|
|
app.post('/api/resend/webhook', (req, res) => {
|
|
const expectedToken = typeof process.env.RESEND_WEBHOOK_TOKEN === 'string' ? process.env.RESEND_WEBHOOK_TOKEN.trim() : ''
|
|
if (!expectedToken) {
|
|
res.status(503).json({ message: 'Webhook token is not configured.' }); return
|
|
}
|
|
|
|
const providedToken = (req.get('x-webhook-token') || '').trim()
|
|
|| (req.get('x-resend-webhook-token') || '').trim()
|
|
|| String(req.query?.token || '').trim()
|
|
|| (req.get('authorization') || '').replace(/^Bearer\s+/i, '').trim()
|
|
|
|
if (!providedToken || providedToken !== expectedToken) {
|
|
res.status(401).json({ message: 'Unauthorized webhook.' }); return
|
|
}
|
|
|
|
const body = req.body && typeof req.body === 'object' ? req.body : {}
|
|
const eventType = typeof body.type === 'string' ? body.type.trim() : ''
|
|
const data = body.data && typeof body.data === 'object' ? body.data : {}
|
|
const tags = Array.isArray(data.tags) ? data.tags : []
|
|
|
|
const resendMessageId = (
|
|
typeof data.email_id === 'string' && data.email_id.trim()
|
|
? data.email_id.trim()
|
|
: (typeof data.emailId === 'string' && data.emailId.trim()
|
|
? data.emailId.trim()
|
|
: (typeof data.id === 'string' && data.id.trim() ? data.id.trim() : ''))
|
|
)
|
|
|
|
const indexed = resendMessageId ? state.resendEmailSubmissionIndex.get(resendMessageId) : null
|
|
const taggedSubmissionId = extractTagValue(tags, 'submission_id')
|
|
const submissionId = indexed?.submissionId || taggedSubmissionId
|
|
|
|
const flow = extractTagValue(tags, 'flow')
|
|
const stream = indexed?.stream
|
|
|| (flow === 'contact-welcome' ? 'welcome' : '')
|
|
|| (flow === 'contact-admin' ? 'adminNotification' : '')
|
|
|| (flow === 'admin-reply' ? 'adminReply' : '')
|
|
|
|
if (!submissionId || !stream) {
|
|
res.json({ ok: true, ignored: true }); return
|
|
}
|
|
|
|
upsertContactEmailStatus(submissionId, stream, {
|
|
status: mapResendEventToStatus(eventType),
|
|
lastEventType: eventType || 'webhook.event',
|
|
resendEmailId: resendMessageId || null,
|
|
error: typeof data?.message === 'string' ? data.message.slice(0, 600) : null,
|
|
})
|
|
|
|
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()
|
|
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()
|
|
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()
|
|
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()
|
|
|
|
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()
|
|
|
|
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
|
|
.filter(entry => entry.subscribe === true && entry.email && !seen.has(entry.email) && seen.add(entry.email))
|
|
.map(entry => ({
|
|
name: entry.name,
|
|
email: entry.email,
|
|
subscribedAt: entry.submittedAt,
|
|
source: entry.message?.startsWith('Requested') ? 'download' : 'contact-form',
|
|
}))
|
|
.sort((a, b) => new Date(b.subscribedAt).getTime() - new Date(a.subscribedAt).getTime())
|
|
res.json({ subscribers, total: subscribers.length })
|
|
})
|
|
|
|
app.post('/api/admin-subscribers/export', requireAdminAuth, (_req, res) => {
|
|
const seen = new Set()
|
|
const rows = [['Name', 'Email', 'Subscribed At', 'Source']]
|
|
state.contactSubmissions
|
|
.filter(entry => entry.subscribe === true && entry.email && !seen.has(entry.email) && seen.add(entry.email))
|
|
.sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime())
|
|
.forEach(entry => {
|
|
const source = entry.message?.startsWith('Requested') ? 'download' : 'contact-form'
|
|
rows.push([entry.name, entry.email, entry.submittedAt, source])
|
|
})
|
|
const csv = rows.map(row => row.map(cell => `"${String(cell ?? '').replace(/"/g, '""')}"`).join(',')).join('\n')
|
|
res.setHeader('Content-Type', 'text/csv')
|
|
res.setHeader('Content-Disposition', `attachment; filename="subscribers-${new Date().toISOString().slice(0, 10)}.csv"`)
|
|
res.send(csv)
|
|
})
|
|
}
|