diff --git a/package.json b/package.json index 610c51c..1001c0c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "1.1.18", + "version": "1.1.19", "type": "module", "scripts": { "dev": "vite", diff --git a/server.js b/server.js index 4f33161..251b00b 100644 --- a/server.js +++ b/server.js @@ -29,13 +29,14 @@ import { refreshContentCaches, queueHitStatsWrite, } from './server/data.js' -import { logResendEmailAlignmentWarnings, sendStudyReminderEmail } from './server/email.js' +import { logResendEmailAlignmentWarnings, sendStudyReminderEmail, sendStudyReengagementEmail } from './server/email.js' import { detectBot, sanitizeUserAgent, shouldCountHit, recordHit, scheduleStudyReminders, + scheduleReengagementEmails, } from './server/study-helpers.js' import { recordVisitor } from './server/routes/analytics.js' @@ -179,6 +180,14 @@ Promise.all([ }, 60 * 60 * 1000) 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, () => { logResendEmailAlignmentWarnings() console.log(`Portfolio app listening on http://localhost:${PORT}`) diff --git a/server/data.js b/server/data.js index dd17b49..ec4ade7 100644 --- a/server/data.js +++ b/server/data.js @@ -1307,6 +1307,7 @@ export function sanitizeStudyUsers(value) { createdAt: typeof item?.createdAt === 'string' ? item.createdAt : null, updatedAt: typeof item?.updatedAt === 'string' ? item.updatedAt : null, lastLoginAt: typeof item?.lastLoginAt === 'string' ? item.lastLoginAt : null, + reengagementSentAt: typeof item?.reengagementSentAt === 'object' && item.reengagementSentAt !== null && !Array.isArray(item.reengagementSentAt) ? item.reengagementSentAt : {}, pendingEmailChange, twoFaMethod: item?.twoFaMethod === 'app' || item?.twoFaMethod === 'email' ? item.twoFaMethod : null, totpSecret: typeof item?.totpSecret === 'string' && item.totpSecret ? item.totpSecret : null, diff --git a/server/email.js b/server/email.js index 3e8c9cb..4834747 100644 --- a/server/email.js +++ b/server/email.js @@ -584,3 +584,61 @@ export async function sendStudyReminderEmail(email, displayName, studyTitle, sec 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 = ( + `

Hi ${escapeHtml(namePart)},

` + + `

${escapeHtml(copy.body)}${studyTitle ? ` Your current study: ${escapeHtml(studyTitle)}.` : ''}

` + ) + const unsubLine = unsubUrl ? `

Not interested? Unsubscribe from these reminders.

` : '' + const footerHtml = `

Grace and peace,
Verse by Verse with Nate

${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) + } +} diff --git a/server/routes/study-auth.js b/server/routes/study-auth.js index 5032e97..2c2a12b 100644 --- a/server/routes/study-auth.js +++ b/server/routes/study-auth.js @@ -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 qrcode from 'qrcode' import { @@ -174,6 +174,7 @@ export function register(app) { user.lastLoginAt = new Date().toISOString() user.updatedAt = user.lastLoginAt + user.reengagementSentAt = {} queueStudyUsersWrite() const sessionToken = createStudySession(user.id) @@ -209,6 +210,7 @@ export function register(app) { function completeLogin(extra = {}) { user.lastLoginAt = new Date().toISOString() user.updatedAt = user.lastLoginAt + user.reengagementSentAt = {} queueStudyUsersWrite() const sessionToken = createStudySession(user.id) setStudySessionCookie(res, sessionToken) @@ -359,4 +361,32 @@ export function register(app) { clearStudySessionCookie(res) 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('Unsubscribed

You\'ve been unsubscribed

You will no longer receive re-engagement reminder emails. You can re-enable them anytime from your account settings.

') + }) } diff --git a/server/study-helpers.js b/server/study-helpers.js index 827fabb..ede0278 100644 --- a/server/study-helpers.js +++ b/server/study-helpers.js @@ -1,4 +1,4 @@ -import { createHash, randomUUID } from 'node:crypto' +import { createHash, createHmac, randomUUID } from 'node:crypto' import path from 'node:path' import { parseCookies, cookieSecureFlag } from './helpers.js' import { @@ -666,3 +666,67 @@ export async function scheduleStudyReminders(sendStudyReminderEmail) { state.studyReminders.updatedAt = new Date().toISOString() 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 + } + } +}