Publish Container / build (push) Successful in 16m4s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
308 lines
17 KiB
JavaScript
308 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;">⚖</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;">“There is joy in the presence of the angels of God over one sinner who repents.” <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;">✓</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;">“Therefore, there is now no condemnation for those who are in Christ Jesus.” <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 URLs set in Admin → Gospel Page.
|
|
app.get('/api/salvation-audio', (_req, res) => {
|
|
const c = state.cachedSiteContent ?? {}
|
|
res.set('Cache-Control', 'public, max-age=300')
|
|
res.json({
|
|
url: sanitizeUrl(c.salvationAudioUrl) || '',
|
|
guiltyUrl: sanitizeUrl(c.salvationAudioGuiltyUrl) || '',
|
|
notGuiltyUrl: sanitizeUrl(c.salvationAudioNotGuiltyUrl) || '',
|
|
})
|
|
})
|
|
|
|
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.' })
|
|
}
|
|
})
|
|
}
|