Files
Siteforge/server/routes/salvation.js
T
2026-10-14 08:06:32 -04:00

304 lines
17 KiB
JavaScript

import { Resend } from 'resend'
import { escapeHtml, sanitizeUrl } from '../helpers.js'
import { state } from '../state.js'
import { getResendFromAddress, getResendInboxAddress, getResendReplyToAddress, getCanonicalBaseUrl, sendResendEmailWithRetry } from '../email.js'
// A visitor who finished the /salvation page can tell us they prayed to receive Christ.
// Nothing is stored on the server: the response is emailed once to the site inbox and that is all.
const hits = new Map()
// One confirmation per address per hour, so the form can't be used to mail someone repeatedly.
const confirmedAt = new Map()
const CONFIRM_COOLDOWN_MS = 60 * 60 * 1000
function mayConfirm(address) {
const key = address.toLowerCase()
const now = Date.now()
const last = confirmedAt.get(key)
if (typeof last === 'number' && now - last < CONFIRM_COOLDOWN_MS) return false
confirmedAt.set(key, now)
if (confirmedAt.size > 2000) {
for (const [k, v] of confirmedAt) { if (now - v > CONFIRM_COOLDOWN_MS) confirmedAt.delete(k) }
}
return true
}
function rateLimit(req, res, next) {
const ip = req.ip ?? 'unknown'
const now = Date.now()
const windowMs = 10 * 60 * 1000
const entry = hits.get(ip) ?? { count: 0, start: now }
if (now - entry.start > windowMs) { entry.count = 0; entry.start = now }
entry.count += 1
hits.set(ip, entry)
if (hits.size > 2000) {
for (const [k, v] of hits) { if (now - v.start > windowMs) hits.delete(k) }
}
if (entry.count > 3) {
res.status(429).json({ message: 'Please wait a few minutes before trying again.' })
return
}
next()
}
function clean(value, max) {
return typeof value === 'string' ? value.trim().replace(/\s+/g, ' ').slice(0, max) : ''
}
// The notification the site owner receives. Styled to match the other admin emails
// (dark header, gold accent, cream cards) and written to be acted on, not just read.
function buildSalvationEmail({ name, email, message, when }) {
const who = name || 'Someone'
const subject = name ? `A new decision for Christ: ${name}` : 'A new decision for Christ on your salvation page'
const nameLine = name || 'Name not given'
const safeName = escapeHtml(nameLine)
const safeEmail = escapeHtml(email)
const safeWhen = escapeHtml(when)
const followUp = email
? [
'Reply to this email within a day or two. A short, warm note is plenty.',
'Congratulate them, and ask them to tell you their story.',
'Point them to the Gospel of John, a Bible-teaching church, and baptism.',
'Invite them to Start Here at versebyversewithnate.us/start-here.',
'Pray for them by name.',
]
: ['They did not leave an email address, so there is no way to reply.', 'Pray for them. God knows exactly who they are.']
const text = [
`A VERDICT HAS COME IN`,
``,
`${who} pleaded guilty and accepted Christ's pardon on the "The Gospel, in a Courtroom" page and says they prayed to receive Jesus as their Savior.`,
``,
`"There is joy in the presence of the angels of God over one sinner who repents." (Luke 15:10)`,
``,
`DETAILS`,
`Name: ${nameLine}`,
`Email: ${email || 'Not given'}`,
`When: ${when}`,
``,
`THEIR MESSAGE`,
message || '(They did not leave a message.)',
``,
`SUGGESTED FOLLOW-UP`,
...followUp.map((t, i) => `${i + 1}. ${t}`),
``,
`This email is the only record of this response. Nothing is stored on the website, so keep it somewhere safe.`,
email ? `Reply directly to this email to reach ${who}.` : '',
].filter(l => l !== '').join('\n')
const row = (label, value, last = false) =>
`<tr><td style="width:96px;padding:11px 0;font-family:Arial,sans-serif;font-size:12px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:#8a6d35;${last ? '' : 'border-bottom:1px solid #efe4cc;'}">${label}</td>` +
`<td style="padding:11px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;${last ? '' : 'border-bottom:1px solid #efe4cc;'}">${value}</td></tr>`
const steps = followUp
.map(t => `<li style="margin:0 0 8px;font-family:Arial,sans-serif;font-size:15px;line-height:1.55;color:#201a10;">${escapeHtml(t)}</li>`)
.join('')
const messageBlock = message
? `<tr><td style="padding:0 28px 22px;">
<div style="font-family:Arial,sans-serif;font-size:12px;font-weight:700;letter-spacing:0.14em;text-transform:uppercase;color:#8a6d35;margin:0 0 8px;">Their message</div>
<div style="background:#fbf7ef;border-left:4px solid #c8860a;border-radius:4px;padding:16px 18px;font-family:Georgia,serif;font-size:16px;line-height:1.7;color:#201a10;white-space:pre-wrap;">${escapeHtml(message)}</div>
</td></tr>`
: `<tr><td style="padding:0 28px 22px;font-family:Arial,sans-serif;font-size:14px;color:#735a2b;font-style:italic;">They did not leave a message.</td></tr>`
const replyButton = email
? `<tr><td align="center" style="padding:0 28px 26px;">
<a href="mailto:${safeEmail}?subject=${encodeURIComponent('Welcome to the family of God')}" style="display:inline-block;background:#c8860a;color:#111111;font-family:Arial,sans-serif;font-size:14px;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;text-decoration:none;padding:14px 30px;border-radius:4px;">Reply to ${escapeHtml(who)}</a>
</td></tr>`
: ''
const html = `<!doctype html><html><body style="margin:0;padding:0;background:#f5f1e8;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="background:#f5f1e8;"><tr><td align="center" style="padding:28px 14px;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="max-width:640px;background:#ffffff;border:1px solid #e1d3b2;border-radius:14px;overflow:hidden;">
<tr><td style="background:#111111;padding:30px 28px 26px;text-align:center;border-bottom:4px solid #c8860a;">
<div style="font-family:Arial,sans-serif;font-size:12px;letter-spacing:0.34em;text-transform:uppercase;color:#c8860a;">The Court of Heaven</div>
<div style="font-size:40px;line-height:1;margin:14px 0 6px;color:#c8860a;">&#9878;</div>
<h1 style="margin:0;font-family:Georgia,serif;font-size:30px;line-height:1.2;color:#f4ead5;">A Verdict Has Come In</h1>
<p style="margin:10px 0 0;font-family:Georgia,serif;font-style:italic;font-size:17px;color:#d9c9a3;">${escapeHtml(who)} pleaded guilty and accepted the pardon.</p>
</td></tr>
<tr><td style="padding:28px 28px 8px;">
<p style="margin:0 0 14px;font-family:Georgia,serif;font-size:17px;line-height:1.7;color:#201a10;">Someone just worked through <em>The Gospel, in a Courtroom</em> on your website, pleaded guilty, and says they prayed to receive Jesus as their Savior.</p>
<p style="margin:0 0 20px;font-family:Georgia,serif;font-style:italic;font-size:16px;line-height:1.6;color:#57452b;border-left:3px solid #c8860a;padding-left:14px;">&ldquo;There is joy in the presence of the angels of God over one sinner who repents.&rdquo; <span style="font-style:normal;font-size:13px;color:#8a6d35;">Luke 15:10</span></p>
</td></tr>
<tr><td style="padding:0 28px 22px;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="border-top:2px solid #c8860a;">
${row('Name', safeName)}
${row('Email', email ? `<a href="mailto:${safeEmail}" style="color:#8f5f05;text-decoration:none;">${safeEmail}</a>` : '<span style="color:#735a2b;font-style:italic;">Not given</span>')}
${row('When', safeWhen, true)}
</table>
</td></tr>
${messageBlock}
${replyButton}
<tr><td style="padding:0 28px 26px;">
<div style="background:#f7f2e5;border:1px solid #e8dcc1;border-radius:10px;padding:18px 22px;">
<div style="font-family:Arial,sans-serif;font-size:12px;font-weight:700;letter-spacing:0.14em;text-transform:uppercase;color:#8a6d35;margin:0 0 12px;">Suggested follow-up</div>
<ol style="margin:0;padding-left:20px;">${steps}</ol>
</div>
</td></tr>
<tr><td style="background:#f7f2e5;border-top:1px solid #e8dcc1;padding:16px 28px;">
<p style="margin:0;font-family:Arial,sans-serif;font-size:12px;line-height:1.6;color:#735a2b;"><strong>Keep this email.</strong> It is the only record of this response. Nothing is stored on the website. ${email ? 'Replying to this message goes straight to them.' : ''}</p>
</td></tr>
</table>
</td></tr></table></body></html>`
return { subject, text, html }
}
// The warm, one-time confirmation the visitor receives at the address they gave.
function buildSalvationConfirmationEmail({ name }) {
const base = getCanonicalBaseUrl().replace(/\/$/, '')
const greeting = name ? `Hi ${name},` : 'Hi friend,'
const subject = 'We received your message. Welcome to the family of God'
const steps = [
['Read the Gospel of John.', 'Start there, then keep going through the New Testament.'],
['Talk to God every day.', 'Prayer is simply talking with him, in your own words.'],
['Tell someone.', 'Share your decision with a Christian friend or family member.'],
['Find a church.', 'Join a Bible-teaching church, and be baptized as a public sign of your faith (Acts 2:38).'],
]
const text = [
greeting,
'',
'Thank you for letting us know that you prayed to receive Jesus. We are rejoicing with you, and your message has been received.',
'Someone from our team will reach out to you personally soon.',
'',
'"Therefore, there is now no condemnation for those who are in Christ Jesus." (Romans 8:1)',
'You are no longer a defendant. You are a child of the Judge.',
'',
'WHAT TO DO NEXT',
...steps.map(([h, b], i) => `${i + 1}. ${h} ${b}`),
'',
`A good path through the podcast and studies: ${base}/start-here`,
`Questions? Ask here: ${base}/contact?source=salvation`,
'',
'Grace and peace,',
'Verse by Verse with Nate',
'',
`You are receiving this one-time email because this address was entered on ${base}/salvation. If that was not you, you can safely ignore it.`,
].join('\n')
const stepRows = steps
.map(([h, b], i) =>
`<tr><td valign="top" style="width:34px;padding:0 0 14px;"><div style="width:26px;height:26px;border-radius:13px;background:#c8860a;color:#111;font-family:Arial,sans-serif;font-size:13px;font-weight:700;line-height:26px;text-align:center;">${i + 1}</div></td>` +
`<td style="padding:2px 0 14px;font-family:Arial,sans-serif;font-size:15px;line-height:1.55;color:#201a10;"><strong>${escapeHtml(h)}</strong> ${escapeHtml(b)}</td></tr>`)
.join('')
const html = `<!doctype html><html><body style="margin:0;padding:0;background:#f5f1e8;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="background:#f5f1e8;"><tr><td align="center" style="padding:28px 14px;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="max-width:600px;background:#ffffff;border:1px solid #e1d3b2;border-radius:14px;overflow:hidden;">
<tr><td style="background:#111111;padding:30px 28px 26px;text-align:center;border-bottom:4px solid #c8860a;">
<div style="font-family:Arial,sans-serif;font-size:12px;letter-spacing:0.34em;text-transform:uppercase;color:#c8860a;">Verse by Verse with Nate</div>
<div style="width:54px;height:54px;border-radius:27px;background:#c8860a;color:#111111;font-family:Arial,sans-serif;font-size:30px;font-weight:700;line-height:54px;margin:16px auto 10px;">&#10003;</div>
<h1 style="margin:0;font-family:Georgia,serif;font-size:28px;line-height:1.25;color:#f4ead5;">Your message was received</h1>
<p style="margin:10px 0 0;font-family:Georgia,serif;font-style:italic;font-size:17px;color:#d9c9a3;">Welcome to the family of God.</p>
</td></tr>
<tr><td style="padding:30px 28px 6px;">
<p style="margin:0 0 16px;font-family:Georgia,serif;font-size:18px;line-height:1.6;color:#201a10;">${escapeHtml(greeting)}</p>
<p style="margin:0 0 16px;font-family:Georgia,serif;font-size:17px;line-height:1.7;color:#201a10;">Thank you for letting us know that you prayed to receive Jesus. We are rejoicing with you, and your message has been received.</p>
<p style="margin:0 0 22px;font-family:Georgia,serif;font-size:17px;line-height:1.7;color:#201a10;"><strong>Someone from our team will reach out to you personally soon.</strong></p>
</td></tr>
<tr><td style="padding:0 28px 26px;">
<div style="background:#fbf7ef;border-left:4px solid #c8860a;border-radius:4px;padding:18px 20px;">
<p style="margin:0 0 8px;font-family:Georgia,serif;font-style:italic;font-size:17px;line-height:1.6;color:#201a10;">&ldquo;Therefore, there is now no condemnation for those who are in Christ Jesus.&rdquo; <span style="font-style:normal;font-size:13px;color:#8a6d35;">Romans 8:1</span></p>
<p style="margin:0;font-family:Georgia,serif;font-size:15px;line-height:1.6;color:#57452b;">You are no longer a defendant. You are a child of the Judge.</p>
</div>
</td></tr>
<tr><td style="padding:0 28px 8px;">
<div style="font-family:Arial,sans-serif;font-size:12px;font-weight:700;letter-spacing:0.14em;text-transform:uppercase;color:#8a6d35;margin:0 0 14px;">What to do next</div>
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation">${stepRows}</table>
</td></tr>
<tr><td align="center" style="padding:6px 28px 8px;">
<a href="${escapeHtml(base)}/start-here" style="display:inline-block;background:#c8860a;color:#111111;font-family:Arial,sans-serif;font-size:14px;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;text-decoration:none;padding:14px 28px;border-radius:4px;">Start Here</a>
</td></tr>
<tr><td align="center" style="padding:6px 28px 26px;font-family:Arial,sans-serif;font-size:14px;color:#57452b;">
Have a question? <a href="${escapeHtml(base)}/contact?source=salvation" style="color:#8f5f05;">Ask us here</a>, or just reply to this email.
</td></tr>
<tr><td style="padding:0 28px 28px;">
<p style="margin:0;font-family:Georgia,serif;font-size:16px;line-height:1.6;color:#201a10;">Grace and peace,<br><strong>Verse by Verse with Nate</strong></p>
</td></tr>
<tr><td style="background:#f7f2e5;border-top:1px solid #e8dcc1;padding:16px 28px;">
<p style="margin:0;font-family:Arial,sans-serif;font-size:12px;line-height:1.6;color:#735a2b;">You are receiving this one-time email because this address was entered at ${escapeHtml(base)}/salvation. If that was not you, you can safely ignore it.</p>
</td></tr>
</table>
</td></tr></table></body></html>`
return { subject, text, html }
}
export function register(app) {
// The /salvation page is static HTML, so it asks the server for the narration URL set in Admin → Gospel Page.
app.get('/api/salvation-audio', (_req, res) => {
const url = sanitizeUrl(state.cachedSiteContent?.salvationAudioUrl) || ''
res.set('Cache-Control', 'public, max-age=300')
res.json({ url })
})
app.post('/api/salvation-response', rateLimit, async (req, res) => {
const { name, email, message, _honey } = req.body ?? {}
if (_honey) { res.json({ ok: true }); return }
const safeName = clean(name, 100)
const safeEmail = clean(email, 200)
const safeMessage = typeof message === 'string' ? message.trim().slice(0, 2000) : ''
if (safeEmail && !/^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(safeEmail)) {
res.status(400).json({ message: 'That email address does not look right. You can also leave it blank.' })
return
}
if (!process.env.RESEND_API_KEY) {
console.error('[salvation] RESEND_API_KEY env var not set')
res.status(503).json({ message: 'We could not send this right now.' })
return
}
const when = new Date().toLocaleString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZone: 'America/New_York', timeZoneName: 'short' })
const { subject, text, html } = buildSalvationEmail({ name: safeName, email: safeEmail, message: safeMessage, when })
try {
const resend = new Resend(process.env.RESEND_API_KEY)
await sendResendEmailWithRetry({
resend,
context: 'salvation-response',
payload: {
from: getResendFromAddress(),
to: [getResendInboxAddress()],
...(safeEmail ? { replyTo: safeEmail } : {}),
subject,
tags: [{ name: 'flow', value: 'salvation-response' }],
text,
html,
},
})
// Best effort: a failed or skipped confirmation must never turn a delivered decision into an error.
let confirmationSent = false
if (safeEmail && mayConfirm(safeEmail)) {
try {
const confirmation = buildSalvationConfirmationEmail({ name: safeName })
await sendResendEmailWithRetry({
resend,
context: 'salvation-confirmation',
payload: {
from: getResendFromAddress(),
to: [safeEmail],
replyTo: getResendReplyToAddress(),
subject: confirmation.subject,
tags: [{ name: 'flow', value: 'salvation-confirmation' }],
text: confirmation.text,
html: confirmation.html,
},
})
confirmationSent = true
} catch (confirmErr) {
console.error('[salvation] confirmation email failed:', confirmErr?.message ?? confirmErr)
}
}
res.json({ ok: true, confirmationSent })
} catch (err) {
console.error('[salvation] email failed:', err?.message ?? err)
res.status(502).json({ message: 'We could not send this right now.' })
}
})
}