Add study user re-engagement reminder emails; v1.1.19

Sends 7/14/30-day inactivity emails to study users who haven't logged in,
with one-click HMAC-signed unsubscribe and automatic re-arm on next login.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmert
2026-07-28 15:56:09 -04:00
parent d0c949070f
commit 2732730f5e
6 changed files with 166 additions and 4 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "siteforge", "name": "siteforge",
"private": true, "private": true,
"version": "1.1.18", "version": "1.1.19",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+10 -1
View File
@@ -29,13 +29,14 @@ import {
refreshContentCaches, refreshContentCaches,
queueHitStatsWrite, queueHitStatsWrite,
} from './server/data.js' } from './server/data.js'
import { logResendEmailAlignmentWarnings, sendStudyReminderEmail } from './server/email.js' import { logResendEmailAlignmentWarnings, sendStudyReminderEmail, sendStudyReengagementEmail } from './server/email.js'
import { import {
detectBot, detectBot,
sanitizeUserAgent, sanitizeUserAgent,
shouldCountHit, shouldCountHit,
recordHit, recordHit,
scheduleStudyReminders, scheduleStudyReminders,
scheduleReengagementEmails,
} from './server/study-helpers.js' } from './server/study-helpers.js'
import { recordVisitor } from './server/routes/analytics.js' import { recordVisitor } from './server/routes/analytics.js'
@@ -179,6 +180,14 @@ Promise.all([
}, 60 * 60 * 1000) }, 60 * 60 * 1000)
void scheduleStudyReminders(sendStudyReminderEmail) void scheduleStudyReminders(sendStudyReminderEmail)
// Send re-engagement emails to inactive study users every hour
setInterval(() => {
scheduleReengagementEmails(sendStudyReengagementEmail).catch(err => {
console.error('[study-reengagement] failed to schedule emails:', err)
})
}, 60 * 60 * 1000)
void scheduleReengagementEmails(sendStudyReengagementEmail)
app.listen(PORT, () => { app.listen(PORT, () => {
logResendEmailAlignmentWarnings() logResendEmailAlignmentWarnings()
console.log(`Portfolio app listening on http://localhost:${PORT}`) console.log(`Portfolio app listening on http://localhost:${PORT}`)
+1
View File
@@ -1307,6 +1307,7 @@ export function sanitizeStudyUsers(value) {
createdAt: typeof item?.createdAt === 'string' ? item.createdAt : null, createdAt: typeof item?.createdAt === 'string' ? item.createdAt : null,
updatedAt: typeof item?.updatedAt === 'string' ? item.updatedAt : null, updatedAt: typeof item?.updatedAt === 'string' ? item.updatedAt : null,
lastLoginAt: typeof item?.lastLoginAt === 'string' ? item.lastLoginAt : null, lastLoginAt: typeof item?.lastLoginAt === 'string' ? item.lastLoginAt : null,
reengagementSentAt: typeof item?.reengagementSentAt === 'object' && item.reengagementSentAt !== null && !Array.isArray(item.reengagementSentAt) ? item.reengagementSentAt : {},
pendingEmailChange, pendingEmailChange,
twoFaMethod: item?.twoFaMethod === 'app' || item?.twoFaMethod === 'email' ? item.twoFaMethod : null, twoFaMethod: item?.twoFaMethod === 'app' || item?.twoFaMethod === 'email' ? item.twoFaMethod : null,
totpSecret: typeof item?.totpSecret === 'string' && item.totpSecret ? item.totpSecret : null, totpSecret: typeof item?.totpSecret === 'string' && item.totpSecret ? item.totpSecret : null,
+58
View File
@@ -584,3 +584,61 @@ export async function sendStudyReminderEmail(email, displayName, studyTitle, sec
console.error('[study-reminder] send exception:', err) console.error('[study-reminder] send exception:', err)
} }
} }
const REENGAGEMENT_COPY = {
'7d': {
subject: 'Your study is waiting for you',
eyebrow: 'Come Back',
headline: 'Pick up where you left off',
body: "It's been a week — your progress is saved and your next lesson is ready whenever you are.",
cta: 'Continue Studying',
},
'14d': {
subject: "Don't lose your momentum",
eyebrow: 'Still With You',
headline: 'Your spot is still saved',
body: 'Two weeks have passed, but every note and completed lesson is still right there. A little each day adds up.',
cta: 'Return to Your Study',
},
'30d': {
subject: 'Your progress is still here',
eyebrow: 'We Saved Your Spot',
headline: "It's been a month — come back anytime",
body: 'Your study progress is still intact and waiting. There\'s no deadline — come back whenever you\'re ready.',
cta: 'Open Your Study',
},
}
export async function sendStudyReengagementEmail(email, displayName, studyTitle, studyUrl, tier, unsubUrl) {
if (!process.env.RESEND_API_KEY) return
const copy = REENGAGEMENT_COPY[tier]
if (!copy) return
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend'
const subject = `${copy.subject}${studyTitle ? `${studyTitle}` : ''}`
const bodyHtml = (
`<p style="margin:0 0 16px;">Hi <strong style="color:#f0ead8;">${escapeHtml(namePart)}</strong>,</p>` +
`<p style="margin:0 0 16px;">${escapeHtml(copy.body)}${studyTitle ? ` Your current study: <strong style="color:#f0ead8;">${escapeHtml(studyTitle)}</strong>.` : ''}</p>`
)
const unsubLine = unsubUrl ? `<p style="margin:16px 0 0;font-size:11px;color:#7a7060;">Not interested? <a href="${escapeHtml(unsubUrl)}" style="color:#7a7060;">Unsubscribe from these reminders.</a></p>` : ''
const footerHtml = `<p style="margin:0;font-family:Georgia,serif;font-size:12px;font-weight:300;color:#7a7060;line-height:1.6;">Grace and peace,<br/>Verse by Verse with Nate</p>${unsubLine}`
const { error } = await resend.emails.send({
from: process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM,
to: [email],
subject,
text: `Hi ${namePart},\n\n${copy.body}${studyTitle ? ` Your current study: ${studyTitle}.` : ''}\n\n${studyUrl}\n\nGrace and peace,\nVerse by Verse with Nate${unsubUrl ? `\n\nUnsubscribe: ${unsubUrl}` : ''}`,
html: buildBrandedEmailHtml({
title: copy.headline,
eyebrow: copy.eyebrow,
bodyHtml,
ctaLabel: copy.cta,
ctaUrl: studyUrl,
footerHtml,
}),
})
if (error) console.error('[study-reengagement] send error:', error)
} catch (err) {
console.error('[study-reengagement] send exception:', err)
}
}
+31 -1
View File
@@ -1,4 +1,4 @@
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto' import { createHash, createHmac, randomUUID, timingSafeEqual } from 'node:crypto'
import rateLimit from 'express-rate-limit' import rateLimit from 'express-rate-limit'
import qrcode from 'qrcode' import qrcode from 'qrcode'
import { import {
@@ -174,6 +174,7 @@ export function register(app) {
user.lastLoginAt = new Date().toISOString() user.lastLoginAt = new Date().toISOString()
user.updatedAt = user.lastLoginAt user.updatedAt = user.lastLoginAt
user.reengagementSentAt = {}
queueStudyUsersWrite() queueStudyUsersWrite()
const sessionToken = createStudySession(user.id) const sessionToken = createStudySession(user.id)
@@ -209,6 +210,7 @@ export function register(app) {
function completeLogin(extra = {}) { function completeLogin(extra = {}) {
user.lastLoginAt = new Date().toISOString() user.lastLoginAt = new Date().toISOString()
user.updatedAt = user.lastLoginAt user.updatedAt = user.lastLoginAt
user.reengagementSentAt = {}
queueStudyUsersWrite() queueStudyUsersWrite()
const sessionToken = createStudySession(user.id) const sessionToken = createStudySession(user.id)
setStudySessionCookie(res, sessionToken) setStudySessionCookie(res, sessionToken)
@@ -359,4 +361,32 @@ export function register(app) {
clearStudySessionCookie(res) clearStudySessionCookie(res)
res.json({ ok: true }) res.json({ ok: true })
}) })
// One-click unsubscribe from re-engagement emails (no login required)
app.get('/api/study-auth/unsubscribe-reminders', (req, res) => {
const { uid, token } = req.query ?? {}
if (typeof uid !== 'string' || typeof token !== 'string') {
res.status(400).send('Invalid unsubscribe link.')
return
}
const secret = process.env.RESEND_WEBHOOK_TOKEN ?? 'siteforge'
const expected = createHmac('sha256', secret).update(uid).digest('hex')
const expectedBuf = Buffer.from(expected, 'hex')
const actualBuf = Buffer.from(token.length === expected.length ? token : '', 'hex')
let valid = false
try { valid = timingSafeEqual(expectedBuf, actualBuf) } catch { valid = false }
if (!valid) {
res.status(400).send('Invalid or expired unsubscribe link.')
return
}
const user = state.studyUsers.find(u => u.id === uid)
if (!user) {
res.status(404).send('Account not found.')
return
}
user.studyRemindersEnabled = false
user.updatedAt = new Date().toISOString()
queueStudyUsersWrite()
res.send('<!doctype html><html><head><meta charset="utf-8"><title>Unsubscribed</title><style>body{font-family:system-ui,sans-serif;max-width:480px;margin:80px auto;padding:24px;text-align:center;color:#333}h1{font-size:1.4rem;margin-bottom:12px}p{color:#666;line-height:1.6}</style></head><body><h1>You\'ve been unsubscribed</h1><p>You will no longer receive re-engagement reminder emails. You can re-enable them anytime from your account settings.</p></body></html>')
})
} }
+65 -1
View File
@@ -1,4 +1,4 @@
import { createHash, randomUUID } from 'node:crypto' import { createHash, createHmac, randomUUID } from 'node:crypto'
import path from 'node:path' import path from 'node:path'
import { parseCookies, cookieSecureFlag } from './helpers.js' import { parseCookies, cookieSecureFlag } from './helpers.js'
import { import {
@@ -666,3 +666,67 @@ export async function scheduleStudyReminders(sendStudyReminderEmail) {
state.studyReminders.updatedAt = new Date().toISOString() state.studyReminders.updatedAt = new Date().toISOString()
queueStudyRemindersWrite() queueStudyRemindersWrite()
} }
// ── Study re-engagement scheduler ─────────────────────────────────────────
const REENGAGEMENT_TIERS = [
{ key: '7d', days: 7 },
{ key: '14d', days: 14 },
{ key: '30d', days: 30 },
]
function makeUnsubUrl(userId) {
const secret = process.env.RESEND_WEBHOOK_TOKEN ?? 'siteforge'
const token = createHmac('sha256', secret).update(userId).digest('hex')
const canonical = state.cachedSiteContent?.seo?.canonicalUrl || 'https://versebyversewithnate.us/'
const base = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical
return `${base}/api/study-auth/unsubscribe-reminders?uid=${encodeURIComponent(userId)}&token=${token}`
}
export async function scheduleReengagementEmails(sendStudyReengagementEmail) {
if (!state.cachedSiteContent) return
const now = new Date()
const nowMs = now.getTime()
for (const user of state.studyUsers) {
if (user.studyRemindersEnabled !== true) continue
if (!user.lastLoginAt) continue
const lastLogin = new Date(user.lastLoginAt)
if (isNaN(lastLogin.getTime())) continue
const daysSinceLogin = (nowMs - lastLogin.getTime()) / (1000 * 60 * 60 * 24)
const sent = typeof user.reengagementSentAt === 'object' && user.reengagementSentAt !== null ? user.reengagementSentAt : {}
const enrolledStudySlugs = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []
const studies = Array.isArray(state.cachedSiteContent.studies) ? state.cachedSiteContent.studies : []
const enrolledStudies = enrolledStudySlugs
.map(slug => studies.find(s => normalizeStudySlug(s?.slug) === slug))
.filter(Boolean)
if (enrolledStudies.length === 0) continue
const firstStudy = enrolledStudies[0]
const studyTitle = enrolledStudies.length === 1 ? firstStudy.title : null
const canonical = state.cachedSiteContent?.seo?.canonicalUrl || 'https://versebyversewithnate.us/'
const base = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical
const studyUrl = enrolledStudies.length === 1
? `${base}/study/${firstStudy.slug}`
: `${base}/study`
for (const tier of REENGAGEMENT_TIERS) {
if (daysSinceLogin < tier.days) continue
if (sent[tier.key]) continue
const unsubUrl = makeUnsubUrl(user.id)
try {
await sendStudyReengagementEmail(user.username, user.displayName || user.username, studyTitle, studyUrl, tier.key, unsubUrl)
} catch (err) {
console.error(`[study-reengagement] failed to send ${tier.key} to ${user.username}:`, err)
continue
}
user.reengagementSentAt = { ...sent, [tier.key]: new Date().toISOString() }
queueStudyUsersWrite()
break // one tier per run so we don't spam if they've been gone >30 days
}
}
}