Improve admin UX and autosave handling

This commit is contained in:
nmemmert
2026-06-01 12:56:26 -04:00
parent b9ed8761db
commit 1d536827e3
5 changed files with 1504 additions and 416 deletions
+629 -158
View File
@@ -610,6 +610,7 @@ const EMPTY_VISITOR_STATS = {
}
const MAX_CONTACT_SUBMISSIONS = 5000
const CONTACT_EMAIL_COOLDOWN_MS = Math.max(10 * 1000, Number(process.env.CONTACT_EMAIL_COOLDOWN_MS ?? 60 * 1000) || 60 * 1000)
const DOWNLOAD_TOKEN_TTL_MS = 10 * 60 * 1000
const titusDownloadTokens = new Map()
@@ -625,6 +626,8 @@ let visitorStats = { ...EMPTY_VISITOR_STATS }
let visitorStatsWritePromise = Promise.resolve()
let contactSubmissions = []
let contactSubmissionsWritePromise = Promise.resolve()
const contactSubmitCooldownByEmail = new Map()
const resendEmailSubmissionIndex = new Map()
let questions = []
let questionsWritePromise = Promise.resolve()
let studyUsers = []
@@ -780,9 +783,7 @@ function loadContactSubmissionsFromDisk() {
return readFile(CONTACT_SUBMISSIONS_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
contactSubmissions = Array.isArray(parsed?.submissions)
? parsed.submissions.slice(0, MAX_CONTACT_SUBMISSIONS)
: []
contactSubmissions = sanitizeLoadedContactSubmissions(parsed?.submissions)
})
.catch(() => {
contactSubmissions = []
@@ -841,8 +842,186 @@ function normalizeMessageType(value) {
return 'general'
}
function createEmailDeliveryState(status = 'pending') {
return {
status,
lastEventAt: null,
lastEventType: null,
resendEmailId: null,
error: null,
}
}
function normalizeEmailDeliveryState(value, fallbackStatus = 'pending') {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return createEmailDeliveryState(fallbackStatus)
}
return {
status: typeof value.status === 'string' && value.status.trim() ? value.status.trim().slice(0, 40) : fallbackStatus,
lastEventAt: typeof value.lastEventAt === 'string' ? value.lastEventAt : null,
lastEventType: typeof value.lastEventType === 'string' ? value.lastEventType.trim().slice(0, 120) : null,
resendEmailId: typeof value.resendEmailId === 'string' && value.resendEmailId.trim() ? value.resendEmailId.trim().slice(0, 200) : null,
error: typeof value.error === 'string' && value.error.trim() ? value.error.trim().slice(0, 600) : null,
}
}
function normalizeContactEmailStatus(value, subscribe) {
const base = value && typeof value === 'object' && !Array.isArray(value) ? value : {}
return {
welcome: normalizeEmailDeliveryState(base.welcome, subscribe === true ? 'pending' : 'not-requested'),
adminNotification: normalizeEmailDeliveryState(base.adminNotification, 'pending'),
adminReply: normalizeEmailDeliveryState(base.adminReply, 'idle'),
}
}
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
contactSubmissions = contactSubmissions.map(submission => {
if (submission.id !== submissionId) return submission
const next = normalizeContactEmailStatus(submission.emailStatus, submission.subscribe === true)
const current = normalizeEmailDeliveryState(next[stream], 'pending')
next[stream] = {
...current,
...patch,
lastEventAt: at,
}
updated = true
return {
...submission,
emailStatus: next,
}
})
if (updated) {
queueContactSubmissionsWrite()
}
}
function extractResendMessageId(result) {
if (!result || typeof result !== 'object') return ''
if (typeof result.id === 'string' && result.id.trim()) return result.id.trim()
if (result.data && typeof result.data === 'object' && typeof result.data.id === 'string' && result.data.id.trim()) {
return result.data.id.trim()
}
return ''
}
function registerResendMessageForSubmission(submissionId, stream, sendResult) {
const resendMessageId = extractResendMessageId(sendResult)
if (!resendMessageId || !submissionId || !stream) return
resendEmailSubmissionIndex.set(resendMessageId, { submissionId, stream })
upsertContactEmailStatus(submissionId, stream, {
resendEmailId: resendMessageId,
})
}
function noteContactEmailCooldown(emailAddress) {
const normalized = String(emailAddress || '').trim().toLowerCase()
if (!normalized) return { ok: true, retryAfterMs: 0 }
const now = Date.now()
const lastAt = contactSubmitCooldownByEmail.get(normalized)
if (typeof lastAt === 'number' && now - lastAt < CONTACT_EMAIL_COOLDOWN_MS) {
return { ok: false, retryAfterMs: CONTACT_EMAIL_COOLDOWN_MS - (now - lastAt) }
}
contactSubmitCooldownByEmail.set(normalized, now)
// Prevent unbounded growth while keeping this in-memory cache simple.
if (contactSubmitCooldownByEmail.size > 8000) {
const cutoff = now - CONTACT_EMAIL_COOLDOWN_MS * 3
for (const [email, timestamp] of contactSubmitCooldownByEmail.entries()) {
if (timestamp < cutoff) {
contactSubmitCooldownByEmail.delete(email)
}
}
}
return { ok: true, retryAfterMs: 0 }
}
function extractTagValue(tags, name) {
if (!Array.isArray(tags)) return ''
const target = String(name || '').trim().toLowerCase()
if (!target) return ''
for (const tag of tags) {
if (!tag || typeof tag !== 'object') continue
const key = typeof tag.name === 'string' ? tag.name.trim().toLowerCase() : ''
const value = typeof tag.value === 'string' ? tag.value.trim() : ''
if (key === target && value) return value
}
return ''
}
function mapResendEventToStatus(eventType) {
const normalized = String(eventType || '').trim().toLowerCase()
if (!normalized) return 'updated'
if (normalized.includes('delivered')) return 'delivered'
if (normalized.includes('delivery_delayed') || normalized.includes('delivery delayed')) return 'delayed'
if (normalized.includes('bounce')) return 'bounced'
if (normalized.includes('complain')) return 'complained'
if (normalized.includes('click')) return 'clicked'
if (normalized.includes('open')) return 'opened'
if (normalized.includes('send')) return 'sent'
return 'updated'
}
function getAddressDomain(addressValue) {
const raw = String(addressValue || '').trim()
if (!raw) return ''
const candidate = raw.includes('<') && raw.includes('>')
? raw.slice(raw.lastIndexOf('<') + 1, raw.lastIndexOf('>')).trim()
: raw
const at = candidate.lastIndexOf('@')
if (at <= 0 || at === candidate.length - 1) return ''
return candidate.slice(at + 1).toLowerCase()
}
function logResendEmailAlignmentWarnings() {
const warnings = []
const fromAddress = getResendFromAddress()
const replyToAddress = getResendReplyToAddress()
const fromDomain = getAddressDomain(fromAddress)
const replyDomain = getAddressDomain(replyToAddress)
const hasApiKey = Boolean(process.env.RESEND_API_KEY)
if (!hasApiKey) {
warnings.push('RESEND_API_KEY is missing. Contact and reply emails cannot send.')
}
if (!fromDomain) {
warnings.push('RESEND_FROM is missing or malformed. Use a verified domain sender identity.')
}
if (fromDomain.endsWith('resend.dev')) {
warnings.push('RESEND_FROM uses resend.dev. Move to your own verified domain for best deliverability.')
}
if (fromDomain && replyDomain && fromDomain !== replyDomain) {
warnings.push('RESEND_FROM and RESEND_REPLY_TO use different domains. This can weaken alignment.')
}
if (!process.env.RESEND_WEBHOOK_TOKEN) {
warnings.push('RESEND_WEBHOOK_TOKEN is not set. Delivery webhooks are not authenticated.')
}
if (hasApiKey) {
warnings.push('Verify SPF, DKIM, and DMARC for the sender domain to improve inbox placement.')
}
if (warnings.length > 0) {
console.warn('[email-health] Resend alignment checks:')
for (const warning of warnings) {
console.warn(`[email-health] - ${warning}`)
}
}
}
const USE_RESEND_AUTOMATION_WELCOME = process.env.RESEND_AUTOMATION_WELCOME === 'true'
const ADMIN_REPLY_FROM = 'Verse by Verse with Nate <hello@versebyversewithnate.us>'
const DEFAULT_RESEND_FROM = 'Verse by Verse with Nate <hello@versebyversewithnate.us>'
const DEFAULT_RESEND_TO = 'hello@versebyversewithnate.us'
const DEFAULT_RESEND_REPLY_TO = 'hello@versebyversewithnate.us'
const ADMIN_REPLY_FROM = DEFAULT_RESEND_FROM
function shouldSendWelcomeEmail({ subscribe }) {
return subscribe === true
@@ -884,7 +1063,201 @@ function buildAdminReplyTemplate({ recipientName, message }) {
`
}
function buildContactWelcomeEmailTemplate({
greetingName,
welcomeIntro,
welcomeCurrentSeries,
welcomeStartHereTitle,
welcomeStartHereSummary,
welcomeExpect1,
welcomeExpect2,
welcomeExpect3,
welcomeScripture,
welcomeScriptureRef,
welcomeSignoff,
welcomeHeading,
welcomeSpotifyUrl,
welcomeAppleUrl,
welcomeAmazonUrl,
welcomeWebsiteUrl,
welcomeEpisodeUrl,
welcomeImageUrl,
}) {
const welcomeSignoffHtml = escapeHtml(welcomeSignoff).replace(/\n/g, '<br/>')
return {
text:
`Welcome to Verse by Verse with Nate!\n\n` +
`${greetingName ? `Glad you're here, ${greetingName}.` : "Glad you're here."}\n\n` +
`${welcomeIntro}\n\n` +
`${welcomeCurrentSeries}\n\n` +
`Start here: ${welcomeEpisodeUrl}\n` +
`${welcomeStartHereTitle}\n` +
`${welcomeStartHereSummary}\n` +
`Spotify: ${welcomeSpotifyUrl}\n` +
`Apple Podcasts: ${welcomeAppleUrl}\n` +
`Amazon Music: ${welcomeAmazonUrl}\n` +
`Website: ${welcomeWebsiteUrl}\n\n` +
`What to expect:\n` +
`- ${welcomeExpect1}\n` +
`- ${welcomeExpect2}\n` +
`- ${welcomeExpect3}\n\n` +
`${welcomeScripture}\n${welcomeScriptureRef}\n\n` +
`${welcomeSignoff}`,
html:
`<div style="margin:0;padding:0;background-color:#0a0a08;font-family:Georgia,serif;">` +
`<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="background-color:#0a0a08;">` +
`<tr><td align="center" style="padding:40px 20px;">` +
`<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="max-width:580px;margin:0 auto;background-color:#0f0f0c;border:1px solid #2a2518;">` +
`<tr><td align="center" style="background-color:#0d0d0a;padding:36px 40px 28px;border-bottom:1px solid #2a2518;">` +
`<img src="${escapeHtml(welcomeImageUrl)}" alt="Verse by Verse with Nate" width="110" height="110" style="display:block;margin:0 auto 20px;border-radius:12px;border:2px solid #2a2518;" />` +
`<p style="margin:0 0 6px;font-family:Georgia,serif;font-size:22px;font-weight:600;color:#c9a84c;letter-spacing:0.04em;">Verse by Verse with Nate</p>` +
`<p style="margin:0;font-family:Georgia,serif;font-size:14px;font-weight:300;color:#7a7060;letter-spacing:0.08em;text-transform:uppercase;">Verse by verse. Nugget by nugget.</p>` +
`</td></tr>` +
`<tr><td style="padding:40px 40px 0;">` +
`<p style="margin:0 0 8px;font-family:Georgia,serif;font-size:13px;font-weight:400;color:#7a7060;letter-spacing:0.12em;text-transform:uppercase;">Welcome</p>` +
`<h1 style="margin:0 0 20px;font-family:Georgia,serif;font-size:30px;font-weight:600;color:#f0ead8;line-height:1.2;">${welcomeHeading}</h1>` +
`<div style="width:40px;height:2px;background-color:#c9a84c;margin-bottom:28px;"></div>` +
`</td></tr>` +
`<tr><td style="padding:0 40px 32px;">` +
`<p style="margin:0 0 18px;font-family:Georgia,serif;font-size:18px;font-weight:300;color:#c8c0ac;line-height:1.75;">${escapeHtml(welcomeIntro)}</p>` +
`<p style="margin:0 0 18px;font-family:Georgia,serif;font-size:18px;font-weight:300;color:#c8c0ac;line-height:1.75;">${escapeHtml(welcomeCurrentSeries)}</p>` +
`<p style="margin:0;font-family:Georgia,serif;font-size:18px;font-weight:300;color:#c8c0ac;line-height:1.75;">If you&rsquo;re just joining us, the best place to start is Episode 1. It sets the table for everything that follows.</p>` +
`</td></tr>` +
`<tr><td style="padding:0 40px;"><div style="height:1px;background-color:#2a2518;margin-bottom:32px;"></div></td></tr>` +
`<tr><td style="padding:0 40px 32px;">` +
`<p style="margin:0 0 6px;font-family:Georgia,serif;font-size:13px;font-weight:400;color:#7a7060;letter-spacing:0.12em;text-transform:uppercase;">Start here</p>` +
`<p style="margin:0 0 6px;font-family:Georgia,serif;font-size:22px;font-weight:600;color:#f0ead8;">${escapeHtml(welcomeStartHereTitle)}</p>` +
`<p style="margin:0 0 20px;font-family:Georgia,serif;font-size:16px;font-weight:300;color:#7a7060;line-height:1.6;">${escapeHtml(welcomeStartHereSummary)}</p>` +
`<table cellpadding="0" cellspacing="0" border="0" role="presentation"><tr>` +
`<td style="padding-right:12px;"><a href="${escapeHtml(welcomeSpotifyUrl)}" target="_blank" style="display:inline-block;padding:11px 22px;background-color:#c9a84c;color:#0d0d0a;font-family:Georgia,serif;font-size:14px;font-weight:600;letter-spacing:0.06em;text-decoration:none;border-radius:3px;">Listen on Spotify</a></td>` +
`<td><a href="${escapeHtml(welcomeAppleUrl)}" target="_blank" style="display:inline-block;padding:11px 22px;background-color:transparent;color:#c9a84c;font-family:Georgia,serif;font-size:14px;font-weight:600;letter-spacing:0.06em;text-decoration:none;border-radius:3px;border:1px solid #c9a84c;">Apple Podcasts</a></td>` +
`</tr></table>` +
`<p style="margin:18px 0 0;"><a href="${escapeHtml(welcomeEpisodeUrl)}" target="_blank" style="color:#c9a84c;text-decoration:underline;font-family:Georgia,serif;font-size:14px;">Open Start Here page</a></p>` +
`</td></tr>` +
`<tr><td style="padding:0 40px;"><div style="height:1px;background-color:#2a2518;margin-bottom:32px;"></div></td></tr>` +
`<tr><td style="padding:0 40px 32px;">` +
`<p style="margin:0 0 20px;font-family:Georgia,serif;font-size:13px;font-weight:400;color:#7a7060;letter-spacing:0.12em;text-transform:uppercase;">What to expect</p>` +
`<p style="margin:0 0 12px;font-family:Georgia,serif;font-size:17px;font-weight:400;color:#c8c0ac;line-height:1.65;">${escapeHtml(welcomeExpect1)}</p>` +
`<p style="margin:0 0 12px;font-family:Georgia,serif;font-size:17px;font-weight:400;color:#c8c0ac;line-height:1.65;">${escapeHtml(welcomeExpect2)}</p>` +
`<p style="margin:0;font-family:Georgia,serif;font-size:17px;font-weight:400;color:#c8c0ac;line-height:1.65;">${escapeHtml(welcomeExpect3)}</p>` +
`</td></tr>` +
`<tr><td style="padding:0 40px;"><div style="height:1px;background-color:#2a2518;margin-bottom:32px;"></div></td></tr>` +
`<tr><td style="padding:0 40px 40px;">` +
`<table cellpadding="0" cellspacing="0" border="0" role="presentation" style="width:100%;border-left:2px solid #c9a84c;"><tr><td style="padding:4px 0 4px 20px;">` +
`<p style="margin:0 0 8px;font-family:Georgia,serif;font-size:19px;font-style:italic;font-weight:400;color:#e0c070;line-height:1.6;">&ldquo;${escapeHtml(welcomeScripture)}&rdquo;</p>` +
`<p style="margin:0;font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;letter-spacing:0.06em;text-transform:uppercase;">${escapeHtml(welcomeScriptureRef)}</p>` +
`</td></tr></table>` +
`</td></tr>` +
`<tr><td style="background-color:#0a0a08;border-top:1px solid #2a2518;padding:28px 40px;text-align:center;">` +
`<p style="margin:0 0 14px;font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;letter-spacing:0.06em;">Find the podcast on</p>` +
`<table cellpadding="0" cellspacing="0" border="0" role="presentation" style="margin:0 auto 24px;"><tr>` +
`<td style="padding:0 10px;"><a href="${escapeHtml(welcomeSpotifyUrl)}" target="_blank" style="font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;text-decoration:none;letter-spacing:0.04em;">Spotify</a></td>` +
`<td style="color:#2a2518;font-size:12px;">&middot;</td>` +
`<td style="padding:0 10px;"><a href="${escapeHtml(welcomeAppleUrl)}" target="_blank" style="font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;text-decoration:none;letter-spacing:0.04em;">Apple Podcasts</a></td>` +
`<td style="color:#2a2518;font-size:12px;">&middot;</td>` +
`<td style="padding:0 10px;"><a href="${escapeHtml(welcomeAmazonUrl)}" target="_blank" style="font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;text-decoration:none;letter-spacing:0.04em;">Amazon Music</a></td>` +
`<td style="color:#2a2518;font-size:12px;">&middot;</td>` +
`<td style="padding:0 10px;"><a href="${escapeHtml(welcomeWebsiteUrl)}" target="_blank" style="font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;text-decoration:none;letter-spacing:0.04em;">Website</a></td>` +
`</tr></table>` +
`<p style="margin:0 0 6px;font-family:Georgia,serif;font-size:12px;font-weight:300;color:#4a4438;line-height:1.6;">You&rsquo;re receiving this because you subscribed to <strong style="color:#5a5035;font-weight:400;">Verse by Verse with Nate</strong>.</p>` +
`<p style="margin:10px 0 0;font-family:Georgia,serif;font-size:14px;font-weight:400;color:#c8c0ac;line-height:1.5;">${welcomeSignoffHtml}</p>` +
`</td></tr>` +
`</table>` +
`</td></tr></table>` +
`</div>`,
}
}
function buildContactAdminNotificationTemplate({
normalizedMessageType,
trimmedName,
trimmedEmail,
submittedAt,
trimmedMessage,
}) {
return {
subject: `Verse by Verse contact (${normalizedMessageType}): ${trimmedName}`,
text:
`New contact form submission\n\n` +
`Message Type: ${normalizedMessageType}\n` +
`Name: ${trimmedName}\n` +
`Email: ${trimmedEmail}\n` +
`Submitted: ${submittedAt}\n\n` +
`Message:\n${trimmedMessage}`,
html:
`<div style="background:#f5f1e8;padding:24px;font-family:Georgia,serif;color:#201a10;">` +
`<div style="max-width:680px;margin:0 auto;background:#ffffff;border:1px solid #e1d3b2;border-radius:14px;overflow:hidden;">` +
`<div style="background:#111111;padding:20px 24px;border-bottom:3px solid #c8860a;">` +
`<div style="font-family:Arial,sans-serif;font-size:12px;letter-spacing:0.32em;text-transform:uppercase;color:#c8860a;">Verse by Verse with Nate</div>` +
`<h1 style="margin:10px 0 0;color:#f4ead5;font-size:28px;line-height:1.2;">New Contact Form Submission</h1>` +
`</div>` +
`<div style="padding:24px;">` +
`<p style="margin:0 0 18px;font-family:Arial,sans-serif;font-size:15px;line-height:1.6;color:#57452b;">A new message was sent from the website contact form. Reply directly to this email to respond to <strong>${escapeHtml(trimmedName)}</strong>.</p>` +
`<table role="presentation" cellpadding="0" cellspacing="0" style="width:100%;border-collapse:collapse;margin-bottom:20px;">` +
`<tr>` +
`<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;border-bottom:1px solid #efe4cc;">Type</td>` +
`<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;border-bottom:1px solid #efe4cc;">${escapeHtml(normalizedMessageType)}</td>` +
`</tr>` +
`<tr>` +
`<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;border-bottom:1px solid #efe4cc;">Name</td>` +
`<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;border-bottom:1px solid #efe4cc;">${escapeHtml(trimmedName)}</td>` +
`</tr>` +
`<tr>` +
`<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;border-bottom:1px solid #efe4cc;">Email</td>` +
`<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;border-bottom:1px solid #efe4cc;"><a href="mailto:${escapeHtml(trimmedEmail)}" style="color:#8f5f05;text-decoration:none;">${escapeHtml(trimmedEmail)}</a></td>` +
`</tr>` +
`<tr>` +
`<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;">Submitted</td>` +
`<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;">${escapeHtml(submittedAt)}</td>` +
`</tr>` +
`</table>` +
`<div style="background:#fbf7ef;border:1px solid #efe4cc;border-radius:12px;padding:18px 20px;">` +
`<div style="margin:0 0 10px;font-family:Arial,sans-serif;font-size:13px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:#8a6d35;">Message</div>` +
`<div style="font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;white-space:pre-wrap;">${escapeHtml(trimmedMessage)}</div>` +
`</div>` +
`</div>` +
`</div>` +
`</div>`,
}
}
function getResendFromAddress() {
return process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM
}
function getResendReplyToAddress() {
return process.env.RESEND_REPLY_TO ?? DEFAULT_RESEND_REPLY_TO
}
function getResendInboxAddress() {
return process.env.RESEND_TO ?? DEFAULT_RESEND_TO
}
async function sendResendEmailWithRetry({ resend, payload, context, maxAttempts = 2 }) {
let lastError = null
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
const result = await resend.emails.send(payload)
if (!result?.error) return result
lastError = result.error
if (attempt < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, 250 * attempt))
}
} catch (err) {
lastError = err
if (attempt < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, 250 * attempt))
}
}
}
throw lastError ?? new Error(`[${context}] email send failed`)
}
function addContactSubmission({ name, email, message, messageType, subscribe }) {
const wantsWelcome = subscribe === true
const submission = {
id: randomUUID(),
submittedAt: new Date().toISOString(),
@@ -892,8 +1265,9 @@ function addContactSubmission({ name, email, message, messageType, subscribe })
email,
message,
messageType: normalizeMessageType(messageType),
subscribe: subscribe === true,
subscribe: wantsWelcome,
archived: false,
emailStatus: normalizeContactEmailStatus(null, wantsWelcome),
}
contactSubmissions.unshift(submission)
@@ -1369,6 +1743,7 @@ function sanitizeLoadedContactSubmissions(value) {
messageType: normalizeMessageType(entry.messageType),
subscribe: entry.subscribe === true,
archived: entry.archived === true,
emailStatus: normalizeContactEmailStatus(entry.emailStatus, entry.subscribe === true),
}))
}
@@ -3325,8 +3700,8 @@ app.patch('/api/admin-contact-submissions/:id', requireAdminAuth, (req, res) =>
app.get('/api/admin-reply-config', requireAdminAuth, (_req, res) => {
res.json({
fromEmail: 'hello@versebyversewithnate.us',
fromIdentity: ADMIN_REPLY_FROM,
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
@@ -3407,26 +3782,44 @@ app.post('/api/admin-contact-submissions/:id/reply', requireAdminAuth, async (re
const recipientName = splitName(submission.name).firstName || submission.name || 'friend'
const html = buildAdminReplyTemplate({ recipientName, message })
const text = `Hi ${recipientName},\n\n${message}\n\nGrace and peace,\nVerse by Verse with Nate\nhello@versebyversewithnate.us`
const replyToAddress = getResendReplyToAddress()
const fromAddress = getResendFromAddress()
const text = `Hi ${recipientName},\n\n${message}\n\nGrace and peace,\nVerse by Verse with Nate\n${replyToAddress}`
const resend = new Resend(process.env.RESEND_API_KEY)
const { error } = await resend.emails.send({
from: ADMIN_REPLY_FROM,
to: [submission.email],
subject,
replyTo: 'hello@versebyversewithnate.us',
text,
html,
const sendResult = await sendResendEmailWithRetry({
resend,
context: 'admin-contact-reply',
payload: {
from: fromAddress || ADMIN_REPLY_FROM,
to: [submission.email],
subject,
replyTo: replyToAddress,
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,
},
text,
html,
},
})
registerResendMessageForSubmission(submission.id, 'adminReply', sendResult)
upsertContactEmailStatus(submission.id, 'adminReply', {
status: 'sent',
lastEventType: 'email.sent',
error: null,
})
if (error) throw error
replyHistory.unshift({
id: randomUUID(),
submissionId: submission.id,
toEmail: submission.email,
toName: submission.name,
fromEmail: 'hello@versebyversewithnate.us',
fromEmail: replyToAddress,
subject,
preview: message.slice(0, 500),
sentAt: new Date().toISOString(),
@@ -3436,6 +3829,13 @@ app.post('/api/admin-contact-submissions/:id/reply', requireAdminAuth, async (re
res.json({ ok: true })
} 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.' })
}
@@ -3831,6 +4231,12 @@ app.post('/api/contact', contactRateLimit, async (req, res) => {
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',
@@ -3839,7 +4245,7 @@ app.post('/api/contact', contactRateLimit, async (req, res) => {
subscribe,
})
addContactSubmission({
const submission = addContactSubmission({
name: trimmedName,
email: trimmedEmail,
message: trimmedMessage,
@@ -3870,6 +4276,18 @@ app.post('/api/contact', contactRateLimit, async (req, res) => {
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)
@@ -3908,7 +4326,6 @@ app.post('/api/contact', contactRateLimit, async (req, res) => {
const welcomeScripture = emailConfig.welcomeEmailScripture?.trim() || 'For the grace of God has appeared, bringing salvation to all people.'
const welcomeScriptureRef = emailConfig.welcomeEmailScriptureRef?.trim() || 'Titus 2:11 - BSB'
const welcomeSignoff = emailConfig.welcomeEmailSignoff?.trim() || 'Grace and peace,\nNate'
const welcomeSignoffHtml = escapeHtml(welcomeSignoff).replace(/\n/g, '<br/>')
const welcomeSpotifyUrl = buildAbsoluteUrl(
welcomeBaseUrl,
process.env.RESEND_WELCOME_SPOTIFY_URL ?? emailConfig.welcomeEmailSpotifyUrl ?? '/spotify',
@@ -3934,156 +4351,209 @@ app.post('/api/contact', contactRateLimit, async (req, res) => {
process.env.RESEND_WELCOME_IMAGE_URL ?? emailConfig.welcomeEmailImageUrl ?? '/images/podcast-art.jpeg',
)
const { error: welcomeError } = await resend.emails.send({
from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate <hello@versebyversewithnate.us>',
to: [trimmedEmail],
subject: welcomeSubject,
text:
`Welcome to Verse by Verse with Nate!\n\n` +
`${greetingName ? `Glad you're here, ${greetingName}.` : "Glad you're here."}\n\n` +
`${welcomeIntro}\n\n` +
`${welcomeCurrentSeries}\n\n` +
`Start here: ${welcomeEpisodeUrl}\n` +
`${welcomeStartHereTitle}\n` +
`${welcomeStartHereSummary}\n` +
`Spotify: ${welcomeSpotifyUrl}\n` +
`Apple Podcasts: ${welcomeAppleUrl}\n` +
`Amazon Music: ${welcomeAmazonUrl}\n` +
`Website: ${welcomeWebsiteUrl}\n\n` +
`What to expect:\n` +
`- ${welcomeExpect1}\n` +
`- ${welcomeExpect2}\n` +
`- ${welcomeExpect3}\n\n` +
`${welcomeScripture}\n${welcomeScriptureRef}\n\n` +
`${welcomeSignoff}`,
html:
`<div style="margin:0;padding:0;background-color:#0a0a08;font-family:Georgia,serif;">` +
`<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="background-color:#0a0a08;">` +
`<tr><td align="center" style="padding:40px 20px;">` +
`<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="max-width:580px;margin:0 auto;background-color:#0f0f0c;border:1px solid #2a2518;">` +
`<tr><td align="center" style="background-color:#0d0d0a;padding:36px 40px 28px;border-bottom:1px solid #2a2518;">` +
`<img src="${escapeHtml(welcomeImageUrl)}" alt="Verse by Verse with Nate" width="110" height="110" style="display:block;margin:0 auto 20px;border-radius:12px;border:2px solid #2a2518;" />` +
`<p style="margin:0 0 6px;font-family:Georgia,serif;font-size:22px;font-weight:600;color:#c9a84c;letter-spacing:0.04em;">Verse by Verse with Nate</p>` +
`<p style="margin:0;font-family:Georgia,serif;font-size:14px;font-weight:300;color:#7a7060;letter-spacing:0.08em;text-transform:uppercase;">Verse by verse. Nugget by nugget.</p>` +
`</td></tr>` +
`<tr><td style="padding:40px 40px 0;">` +
`<p style="margin:0 0 8px;font-family:Georgia,serif;font-size:13px;font-weight:400;color:#7a7060;letter-spacing:0.12em;text-transform:uppercase;">Welcome</p>` +
`<h1 style="margin:0 0 20px;font-family:Georgia,serif;font-size:30px;font-weight:600;color:#f0ead8;line-height:1.2;">${welcomeHeading}</h1>` +
`<div style="width:40px;height:2px;background-color:#c9a84c;margin-bottom:28px;"></div>` +
`</td></tr>` +
`<tr><td style="padding:0 40px 32px;">` +
`<p style="margin:0 0 18px;font-family:Georgia,serif;font-size:18px;font-weight:300;color:#c8c0ac;line-height:1.75;">${escapeHtml(welcomeIntro)}</p>` +
`<p style="margin:0 0 18px;font-family:Georgia,serif;font-size:18px;font-weight:300;color:#c8c0ac;line-height:1.75;">${escapeHtml(welcomeCurrentSeries)}</p>` +
`<p style="margin:0;font-family:Georgia,serif;font-size:18px;font-weight:300;color:#c8c0ac;line-height:1.75;">If you&rsquo;re just joining us, the best place to start is Episode 1. It sets the table for everything that follows.</p>` +
`</td></tr>` +
`<tr><td style="padding:0 40px;"><div style="height:1px;background-color:#2a2518;margin-bottom:32px;"></div></td></tr>` +
`<tr><td style="padding:0 40px 32px;">` +
`<p style="margin:0 0 6px;font-family:Georgia,serif;font-size:13px;font-weight:400;color:#7a7060;letter-spacing:0.12em;text-transform:uppercase;">Start here</p>` +
`<p style="margin:0 0 6px;font-family:Georgia,serif;font-size:22px;font-weight:600;color:#f0ead8;">${escapeHtml(welcomeStartHereTitle)}</p>` +
`<p style="margin:0 0 20px;font-family:Georgia,serif;font-size:16px;font-weight:300;color:#7a7060;line-height:1.6;">${escapeHtml(welcomeStartHereSummary)}</p>` +
`<table cellpadding="0" cellspacing="0" border="0" role="presentation"><tr>` +
`<td style="padding-right:12px;"><a href="${escapeHtml(welcomeSpotifyUrl)}" target="_blank" style="display:inline-block;padding:11px 22px;background-color:#c9a84c;color:#0d0d0a;font-family:Georgia,serif;font-size:14px;font-weight:600;letter-spacing:0.06em;text-decoration:none;border-radius:3px;">Listen on Spotify</a></td>` +
`<td><a href="${escapeHtml(welcomeAppleUrl)}" target="_blank" style="display:inline-block;padding:11px 22px;background-color:transparent;color:#c9a84c;font-family:Georgia,serif;font-size:14px;font-weight:600;letter-spacing:0.06em;text-decoration:none;border-radius:3px;border:1px solid #c9a84c;">Apple Podcasts</a></td>` +
`</tr></table>` +
`<p style="margin:18px 0 0;"><a href="${escapeHtml(welcomeEpisodeUrl)}" target="_blank" style="color:#c9a84c;text-decoration:underline;font-family:Georgia,serif;font-size:14px;">Open Start Here page</a></p>` +
`</td></tr>` +
`<tr><td style="padding:0 40px;"><div style="height:1px;background-color:#2a2518;margin-bottom:32px;"></div></td></tr>` +
`<tr><td style="padding:0 40px 32px;">` +
`<p style="margin:0 0 20px;font-family:Georgia,serif;font-size:13px;font-weight:400;color:#7a7060;letter-spacing:0.12em;text-transform:uppercase;">What to expect</p>` +
`<p style="margin:0 0 12px;font-family:Georgia,serif;font-size:17px;font-weight:400;color:#c8c0ac;line-height:1.65;">${escapeHtml(welcomeExpect1)}</p>` +
`<p style="margin:0 0 12px;font-family:Georgia,serif;font-size:17px;font-weight:400;color:#c8c0ac;line-height:1.65;">${escapeHtml(welcomeExpect2)}</p>` +
`<p style="margin:0;font-family:Georgia,serif;font-size:17px;font-weight:400;color:#c8c0ac;line-height:1.65;">${escapeHtml(welcomeExpect3)}</p>` +
`</td></tr>` +
`<tr><td style="padding:0 40px;"><div style="height:1px;background-color:#2a2518;margin-bottom:32px;"></div></td></tr>` +
`<tr><td style="padding:0 40px 40px;">` +
`<table cellpadding="0" cellspacing="0" border="0" role="presentation" style="width:100%;border-left:2px solid #c9a84c;"><tr><td style="padding:4px 0 4px 20px;">` +
`<p style="margin:0 0 8px;font-family:Georgia,serif;font-size:19px;font-style:italic;font-weight:400;color:#e0c070;line-height:1.6;">&ldquo;${escapeHtml(welcomeScripture)}&rdquo;</p>` +
`<p style="margin:0;font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;letter-spacing:0.06em;text-transform:uppercase;">${escapeHtml(welcomeScriptureRef)}</p>` +
`</td></tr></table>` +
`</td></tr>` +
`<tr><td style="background-color:#0a0a08;border-top:1px solid #2a2518;padding:28px 40px;text-align:center;">` +
`<p style="margin:0 0 14px;font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;letter-spacing:0.06em;">Find the podcast on</p>` +
`<table cellpadding="0" cellspacing="0" border="0" role="presentation" style="margin:0 auto 24px;"><tr>` +
`<td style="padding:0 10px;"><a href="${escapeHtml(welcomeSpotifyUrl)}" target="_blank" style="font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;text-decoration:none;letter-spacing:0.04em;">Spotify</a></td>` +
`<td style="color:#2a2518;font-size:12px;">&middot;</td>` +
`<td style="padding:0 10px;"><a href="${escapeHtml(welcomeAppleUrl)}" target="_blank" style="font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;text-decoration:none;letter-spacing:0.04em;">Apple Podcasts</a></td>` +
`<td style="color:#2a2518;font-size:12px;">&middot;</td>` +
`<td style="padding:0 10px;"><a href="${escapeHtml(welcomeAmazonUrl)}" target="_blank" style="font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;text-decoration:none;letter-spacing:0.04em;">Amazon Music</a></td>` +
`<td style="color:#2a2518;font-size:12px;">&middot;</td>` +
`<td style="padding:0 10px;"><a href="${escapeHtml(welcomeWebsiteUrl)}" target="_blank" style="font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;text-decoration:none;letter-spacing:0.04em;">Website</a></td>` +
`</tr></table>` +
`<p style="margin:0 0 6px;font-family:Georgia,serif;font-size:12px;font-weight:300;color:#4a4438;line-height:1.6;">You&rsquo;re receiving this because you subscribed to <strong style="color:#5a5035;font-weight:400;">Verse by Verse with Nate</strong>.</p>` +
`<p style="margin:10px 0 0;font-family:Georgia,serif;font-size:14px;font-weight:400;color:#c8c0ac;line-height:1.5;">${welcomeSignoffHtml}</p>` +
`</td></tr>` +
`</table>` +
`</td></tr></table>` +
`</div>`,
const welcomeTemplate = buildContactWelcomeEmailTemplate({
greetingName,
welcomeIntro,
welcomeCurrentSeries,
welcomeStartHereTitle,
welcomeStartHereSummary,
welcomeExpect1,
welcomeExpect2,
welcomeExpect3,
welcomeScripture,
welcomeScriptureRef,
welcomeSignoff,
welcomeHeading,
welcomeSpotifyUrl,
welcomeAppleUrl,
welcomeAmazonUrl,
welcomeWebsiteUrl,
welcomeEpisodeUrl,
welcomeImageUrl,
})
if (welcomeError) throw welcomeError
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),
})
throw welcomeErr
}
} else if (shouldSendWelcome && USE_RESEND_AUTOMATION_WELCOME) {
upsertContactEmailStatus(submission.id, 'welcome', {
status: 'automation-enabled',
lastEventType: 'email.automation.enabled',
error: null,
})
}
if (shouldSendWelcome) {
res.json({ ok: true })
return
}
const { error } = await resend.emails.send({
from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate <hello@versebyversewithnate.us>',
to: [process.env.RESEND_TO ?? 'hello@versebyversewithnate.us'],
try {
const adminSendResult = await sendResendEmailWithRetry({
resend,
context: 'contact-admin-notification',
payload: {
from: fromAddress,
to: [adminInbox],
replyTo: trimmedEmail,
subject: `Verse by Verse contact form: ${trimmedName}`,
text:
`New contact form submission\n\n` +
`Message Type: ${normalizedMessageType}\n` +
`Name: ${trimmedName}\n` +
`Email: ${trimmedEmail}\n` +
`Submitted: ${submittedAt}\n\n` +
`Message:\n${trimmedMessage}`,
html:
`<div style="background:#f5f1e8;padding:24px;font-family:Georgia,serif;color:#201a10;">` +
`<div style="max-width:680px;margin:0 auto;background:#ffffff;border:1px solid #e1d3b2;border-radius:14px;overflow:hidden;">` +
`<div style="background:#111111;padding:20px 24px;border-bottom:3px solid #c8860a;">` +
`<div style="font-family:Arial,sans-serif;font-size:12px;letter-spacing:0.32em;text-transform:uppercase;color:#c8860a;">Verse by Verse with Nate</div>` +
`<h1 style="margin:10px 0 0;color:#f4ead5;font-size:28px;line-height:1.2;">New Contact Form Submission</h1>` +
`</div>` +
`<div style="padding:24px;">` +
`<p style="margin:0 0 18px;font-family:Arial,sans-serif;font-size:15px;line-height:1.6;color:#57452b;">A new message was sent from the website contact form. Reply directly to this email to respond to <strong>${escapeHtml(trimmedName)}</strong>.</p>` +
`<table role="presentation" cellpadding="0" cellspacing="0" style="width:100%;border-collapse:collapse;margin-bottom:20px;">` +
`<tr>` +
`<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;border-bottom:1px solid #efe4cc;">Type</td>` +
`<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;border-bottom:1px solid #efe4cc;">${escapeHtml(normalizedMessageType)}</td>` +
`</tr>` +
`<tr>` +
`<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;border-bottom:1px solid #efe4cc;">Name</td>` +
`<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;border-bottom:1px solid #efe4cc;">${escapeHtml(trimmedName)}</td>` +
`</tr>` +
`<tr>` +
`<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;border-bottom:1px solid #efe4cc;">Email</td>` +
`<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;border-bottom:1px solid #efe4cc;"><a href="mailto:${escapeHtml(trimmedEmail)}" style="color:#8f5f05;text-decoration:none;">${escapeHtml(trimmedEmail)}</a></td>` +
`</tr>` +
`<tr>` +
`<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;">Submitted</td>` +
`<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;">${escapeHtml(submittedAt)}</td>` +
`</tr>` +
`</table>` +
`<div style="background:#fbf7ef;border:1px solid #efe4cc;border-radius:12px;padding:18px 20px;">` +
`<div style="margin:0 0 10px;font-family:Arial,sans-serif;font-size:13px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:#8a6d35;">Message</div>` +
`<div style="font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;white-space:pre-wrap;">${escapeHtml(trimmedMessage)}</div>` +
`</div>` +
`</div>` +
`</div>` +
`</div>`,
})
if (error) throw error
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),
})
throw adminSendErr
}
res.json({ ok: true })
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 ? 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 = 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,
})
})
// Get all questions (for admin)
app.get('/api/admin-questions', requireAdminAuth, (_req, res) => {
res.json({ questions: draftQuestions ?? questions })
@@ -4576,6 +5046,7 @@ Promise.all([
}, 60 * 60 * 1000)
app.listen(PORT, () => {
logResendEmailAlignmentWarnings()
console.log(`Portfolio app listening on http://localhost:${PORT}`)
})
})