Salvation page: redesigned notification email, visitor confirmation email, clear on-screen confirmation and email notice, phone fixes; v2.39.1
Publish Container / build (push) Successful in 15m35s
Publish Container / build (push) Successful in 15m35s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
27656b84f2
commit
b3e887aef2
3 files changed
+271
-28
No files matched your search
+226
-23
@@ -1,10 +1,25 @@
|
||||
import { Resend } from 'resend'
|
||||
import { escapeHtml } from '../helpers.js'
|
||||
import { getResendFromAddress, getResendInboxAddress, sendResendEmailWithRetry } from '../email.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'
|
||||
@@ -28,6 +43,188 @@ 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) {
|
||||
app.post('/api/salvation-response', rateLimit, async (req, res) => {
|
||||
const { name, email, message, _honey } = req.body ?? {}
|
||||
@@ -47,26 +244,8 @@ export function register(app) {
|
||||
return
|
||||
}
|
||||
|
||||
const when = new Date().toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' })
|
||||
const nameLine = safeName || 'Name not given'
|
||||
const emailLine = safeEmail || 'Email not given'
|
||||
const text = [
|
||||
'Someone responded to the gospel on the /salvation page (the courtroom presentation) and says they pleaded guilty, accepted the pardon, and prayed to receive Jesus as Savior.',
|
||||
'',
|
||||
`Name: ${nameLine}`,
|
||||
`Email: ${emailLine}`,
|
||||
`When: ${when}`,
|
||||
'',
|
||||
safeMessage ? `Their message:\n${safeMessage}` : 'They did not leave a message.',
|
||||
'',
|
||||
safeEmail ? 'You can reply directly to this email to reach them.' : 'They did not leave an email address, so there is no way to reply.',
|
||||
].join('\n')
|
||||
const html = `<div style="font-family:Georgia,serif;max-width:560px;line-height:1.6;color:#1a1208">
|
||||
<p><strong>Someone responded to the gospel</strong> on the /salvation page and says they prayed to receive Jesus as Savior.</p>
|
||||
<p><strong>Name:</strong> ${escapeHtml(nameLine)}<br><strong>Email:</strong> ${escapeHtml(emailLine)}<br><strong>When:</strong> ${escapeHtml(when)}</p>
|
||||
${safeMessage ? `<p><strong>Their message:</strong></p><blockquote style="margin:0;padding-left:12px;border-left:3px solid #b8860b">${escapeHtml(safeMessage).replace(/\n/g, '<br>')}</blockquote>` : '<p><em>They did not leave a message.</em></p>'}
|
||||
<p style="color:#6b5a30">${safeEmail ? 'You can reply directly to this email to reach them.' : 'They did not leave an email address, so there is no way to reply.'}</p>
|
||||
</div>`
|
||||
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)
|
||||
@@ -77,13 +256,37 @@ ${safeMessage ? `<p><strong>Their message:</strong></p><blockquote style="margin
|
||||
from: getResendFromAddress(),
|
||||
to: [getResendInboxAddress()],
|
||||
...(safeEmail ? { replyTo: safeEmail } : {}),
|
||||
subject: 'Someone prayed to receive Christ on your salvation page',
|
||||
subject,
|
||||
tags: [{ name: 'flow', value: 'salvation-response' }],
|
||||
text,
|
||||
html,
|
||||
},
|
||||
})
|
||||
res.json({ ok: true })
|
||||
|
||||
// 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.' })
|
||||
|
||||
Reference in new issue
Block a user