Add notify-on-answer email and save-as-image for Q&A; v1.0.10

- Contact form: notifyOnAnswer checkbox (shown for Bible Questions only)
- Contact route: stores notifyOnAnswer flag on question record
- Email: buildQuestionAnsweredEmailTemplate for branded notification
- Questions route: sends notification email on approve when notifyOnAnswer + email + answer are set
- Q&A section: Canvas API "Save as image" button generates 1080x1080 PNG quote card

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmert
2026-07-22 12:51:38 -04:00
parent 43799b8c6d
commit d74b7523d3
6 changed files with 202 additions and 3 deletions
+47
View File
@@ -385,6 +385,53 @@ export function buildAdminReplyTemplate({ recipientName, message }) {
`
}
export function buildQuestionAnsweredEmailTemplate({ firstName, question, answer, questionUrl }) {
const safeName = escapeHtml(firstName || 'friend')
const safeQuestion = escapeHtml(question)
const safeAnswer = escapeHtml(answer.length > 600 ? answer.slice(0, 597) + '…' : answer).replace(/\n/g, '<br/>')
const safeUrl = escapeHtml(questionUrl)
const text = `Hi ${firstName || 'friend'},\n\nYour question has been answered on Verse by Verse with Nate.\n\nYour question: ${question}\n\nAnswer: ${answer}\n\nRead it at: ${questionUrl}\n\nGrace and peace,\nNate`
const html = `
<div style="margin:0;padding:0;background-color:#f5f1e8;font-family:Georgia,serif;color:#201a10;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="background-color:#f5f1e8;">
<tr>
<td align="center" style="padding:28px 16px;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="max-width:680px;background:#ffffff;border:1px solid #e1d3b2;border-radius:14px;overflow:hidden;">
<tr>
<td 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:26px;line-height:1.2;">Your Question Was Answered</h1>
</td>
</tr>
<tr>
<td style="padding:26px 24px 18px;">
<p style="margin:0 0 16px;font-family:Arial,sans-serif;font-size:16px;line-height:1.6;color:#201a10;">Hi ${safeName},</p>
<p style="margin:0 0 16px;font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;">Nate has answered your question on Verse by Verse with Nate.</p>
<div style="background:#f7f2e5;border-left:3px solid #c8860a;padding:14px 18px;margin:0 0 20px;border-radius:0 8px 8px 0;">
<p style="margin:0 0 6px;font-family:Arial,sans-serif;font-size:13px;letter-spacing:0.06em;text-transform:uppercase;color:#a07830;">Your question</p>
<p style="margin:0;font-family:Georgia,serif;font-size:15px;line-height:1.6;color:#201a10;font-style:italic;">${safeQuestion}</p>
</div>
<div style="margin:0 0 24px;">
<p style="margin:0 0 6px;font-family:Arial,sans-serif;font-size:13px;letter-spacing:0.06em;text-transform:uppercase;color:#a07830;">Answer</p>
<p style="margin:0;font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;">${safeAnswer}</p>
</div>
<a href="${safeUrl}" style="display:inline-block;background:#c8860a;color:#ffffff;font-family:Arial,sans-serif;font-size:14px;font-weight:bold;text-decoration:none;padding:12px 24px;border-radius:6px;">Read Full Answer →</a>
</td>
</tr>
<tr>
<td style="background:#f7f2e5;border-top:1px solid #e8dcc1;padding:14px 24px;">
<p style="margin:0;font-family:Arial,sans-serif;font-size:12px;line-height:1.5;color:#735a2b;">You received this because you requested a notification when this question was answered.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
`
return { subject: 'Your question has been answered — Verse by Verse with Nate', text, html }
}
// ── Transactional email senders ────────────────────────────────────────────
export async function sendStudyWelcomeEmail(email, displayName) {
+2 -1
View File
@@ -102,7 +102,7 @@ function contactRateLimit(req, res, next) {
export function register(app) {
app.post('/api/contact', contactRateLimit, async (req, res) => {
try {
const { firstName, lastName, email, message, messageType, subscribe, _honey } = req.body ?? {}
const { firstName, lastName, email, message, messageType, subscribe, notifyOnAnswer, _honey } = req.body ?? {}
if (_honey) { res.json({ ok: true }); return }
@@ -164,6 +164,7 @@ export function register(app) {
answeredAt: null,
isApproved: false,
approvedAt: null,
notifyOnAnswer: notifyOnAnswer === true,
}
state.questions.unshift(question)
state.questions = state.questions.slice(0, MAX_QUESTIONS)
+26
View File
@@ -3,6 +3,9 @@ import { requireAdminAuth } from '../auth.js'
import { MAX_QUESTIONS } from '../config.js'
import { state } from '../state.js'
import { queueQuestionsWrite, queueDraftQuestionsWrite } from '../data.js'
import { buildQuestionAnsweredEmailTemplate, getResendFromAddress, getResendReplyToAddress, sendResendEmailWithRetry, getCanonicalBaseUrl } from '../email.js'
import { buildAbsoluteUrl } from '../helpers.js'
import { Resend } from 'resend'
function ensureDraftQuestions() {
if (state.draftQuestions !== null) return
@@ -103,6 +106,29 @@ export function register(app) {
question.approvedAt = approved === true ? new Date().toISOString() : null
queueDraftQuestionsWrite()
if (approved === true && question.notifyOnAnswer && question.email && question.answer) {
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const from = getResendFromAddress()
const replyTo = getResendReplyToAddress()
const baseUrl = getCanonicalBaseUrl()
const questionUrl = buildAbsoluteUrl(baseUrl, `/questions#qa-${question.id}`)
const template = buildQuestionAnsweredEmailTemplate({
firstName: question.firstName,
question: question.question,
answer: question.answer,
questionUrl,
})
await sendResendEmailWithRetry({
resend,
payload: { from, replyTo, to: question.email, subject: template.subject, text: template.text, html: template.html },
context: `notify-on-answer:${question.id}`,
})
} catch (err) {
console.error('[notify-on-answer] Failed to send notification email:', err?.message ?? err)
}
}
res.json({ ok: true, question })
})