import { createHash, createHmac, randomUUID } from 'node:crypto' import path from 'node:path' import { parseCookies, cookieSecureFlag } from './helpers.js' import { STUDY_SESSION_COOKIE, STUDY_SESSION_TTL_MS, STUDY_TOTP_PENDING_TTL_MS, EMAIL_OTP_TTL_MS, EMAIL_OTP_MAX_ATTEMPTS, CONTACT_EMAIL_COOLDOWN_MS, DEFAULT_REDIRECT_RULES, } from './config.js' import { state } from './state.js' import { queueStudyRemindersWrite } from './data.js' // ── Username / slug normalizers ──────────────────────────────────────────── export function normalizeStudyUsername(value) { if (typeof value !== 'string') return '' return value.trim().toLowerCase() } export function normalizeStudySlug(value) { if (typeof value !== 'string') return '' const trimmed = value.trim().toLowerCase() return /^[a-z0-9-]{1,80}$/.test(trimmed) ? trimmed : '' } export function isValidStudyUsername(value) { return /^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(value) && value.length <= 254 } export function normalizeLessonSectionId(value) { if (typeof value !== 'string') return '' const trimmed = value.trim().toLowerCase() return /^[a-z0-9-]{1,80}$/.test(trimmed) ? trimmed : '' } export function getStudySlugFromNoteId(sectionId) { if (typeof sectionId !== 'string') return '' const separatorIndex = sectionId.indexOf('--') if (separatorIndex <= 0) return '' return normalizeStudySlug(sectionId.slice(0, separatorIndex)) } // ── Password / token hashing ─────────────────────────────────────────────── export function hashStudyPassword(password) { return createHash('sha256').update(`study-user:${String(password)}`).digest('hex') } export function hashEmailChangeToken(token) { return createHash('sha256').update(`study-email-change:${String(token)}`).digest('hex') } // ── Catalog / enrollment helpers ─────────────────────────────────────────── export function getStudyCatalog() { const fallback = [ { slug: 'colossians', title: 'Colossians: Rooted in Christ', status: 'active' }, ] const content = state.cachedSiteContent if (!content || typeof content !== 'object') return fallback if (Array.isArray(content.studies) && content.studies.length > 0) { const out = [] const seen = new Set() for (const study of content.studies) { const slug = normalizeStudySlug(study?.slug) if (!slug || seen.has(slug)) continue seen.add(slug) out.push({ slug, title: typeof study?.title === 'string' && study.title.trim() ? study.title.trim() : slug, status: study?.status === 'planned' ? 'planned' : 'active', }) } if (out.length > 0) return out } return fallback } export function isEnrollableStudySlug(studySlug) { const normalized = normalizeStudySlug(studySlug) if (!normalized) return false return getStudyCatalog().some(study => study.slug === normalized) } export function getStudyTitleBySlug(studySlug) { const normalized = normalizeStudySlug(studySlug) if (!normalized) return '' const study = getStudyCatalog().find(item => item.slug === normalized) return study?.title ?? '' } export function isStudyUserEnrolled(user, studySlug) { const normalized = normalizeStudySlug(studySlug) if (!normalized || !user) return false return Array.isArray(user.enrolledStudySlugs) && user.enrolledStudySlugs.includes(normalized) } export function findStudyUserById(userId) { if (typeof userId !== 'string' || !userId.trim()) return undefined return state.studyUsers.find(user => user.id === userId) } export function findStudyUserByUsername(username) { return state.studyUsers.find(user => user.username === normalizeStudyUsername(username)) } // ── Avatar ───────────────────────────────────────────────────────────────── export function getStudyAvatarUrl(subject) { let customAvatar = '' let username = '' if (subject && typeof subject === 'object') { customAvatar = typeof subject.avatarUrl === 'string' ? subject.avatarUrl.trim() : '' username = normalizeStudyUsername(subject.username) } else if (typeof subject === 'string') { username = normalizeStudyUsername(subject) } if (customAvatar) return customAvatar if (!username) return '' const hash = createHash('md5').update(username).digest('hex') return `https://www.gravatar.com/avatar/${hash}?d=identicon&s=96` } // ── Session management ───────────────────────────────────────────────────── export function cookieFlags() { return cookieSecureFlag() } export function createStudySession(userId) { const token = randomUUID() state.studySessions.set(token, { userId, expiresAt: Date.now() + STUDY_SESSION_TTL_MS }) return token } export function setStudySessionCookie(res, token) { res.append( 'Set-Cookie', `${STUDY_SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${Math.floor(STUDY_SESSION_TTL_MS / 1000)}; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`, ) } export function clearStudySessionCookie(res) { res.append( 'Set-Cookie', `${STUDY_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`, ) } export function getStudyUserFromRequest(req) { const cookies = parseCookies(req.headers.cookie) const token = cookies[STUDY_SESSION_COOKIE] if (!token) return null const session = state.studySessions.get(token) if (!session || session.expiresAt <= Date.now()) { state.studySessions.delete(token) return null } const user = state.studyUsers.find(item => item.id === session.userId) if (!user) { state.studySessions.delete(token) return null } session.expiresAt = Date.now() + STUDY_SESSION_TTL_MS state.studySessions.set(token, session) return user } export function requireStudyAuth(req, res, next) { const user = getStudyUserFromRequest(req) if (!user) { res.status(401).json({ message: 'Please sign in to save notes.' }) return } req.studyUser = user next() } // ── TOTP pending tokens ──────────────────────────────────────────────────── export function createStudyTotpPendingToken(userId) { const token = randomUUID() state.studyTotpPendingTokens.set(token, { userId, expiresAt: Date.now() + STUDY_TOTP_PENDING_TTL_MS }) return token } export function consumeStudyTotpPendingToken(token) { const entry = state.studyTotpPendingTokens.get(token) if (!entry) return null state.studyTotpPendingTokens.delete(token) if (Date.now() > entry.expiresAt) return null return entry.userId } // ── Email OTP ────────────────────────────────────────────────────────────── export function generateEmailOtp() { return String(Math.floor(100000 + Math.random() * 900000)) } function hashEmailOtp(code) { return createHash('sha256').update(String(code).trim()).digest('hex') } export function storeEmailOtp(userId, code) { state.emailOtpStore.set(userId, { codeHash: hashEmailOtp(code), expiresAt: Date.now() + EMAIL_OTP_TTL_MS, attempts: 0 }) } export function verifyEmailOtp(userId, code) { const entry = state.emailOtpStore.get(userId) if (!entry) return 'no-code' if (Date.now() > entry.expiresAt) { state.emailOtpStore.delete(userId); return 'expired' } entry.attempts += 1 if (entry.attempts > EMAIL_OTP_MAX_ATTEMPTS) { state.emailOtpStore.delete(userId); return 'too-many' } if (hashEmailOtp(String(code).trim()) !== entry.codeHash) return 'wrong' state.emailOtpStore.delete(userId) return 'ok' } // ── Titus download tokens ────────────────────────────────────────────────── export function createTitusDownloadToken(email) { const token = randomUUID() state.titusDownloadTokens.set(token, { email, expiresAt: Date.now() + (10 * 60 * 1000), }) return token } export function consumeTitusDownloadToken(token) { const entry = state.titusDownloadTokens.get(token) if (!entry) return false state.titusDownloadTokens.delete(token) if (entry.expiresAt <= Date.now()) return false return true } // ── Release date helpers ─────────────────────────────────────────────────── export function getSectionReleaseTime(section) { if (!section || typeof section !== 'object') return Number.NaN const candidateValues = [section.releasedAt, section.releaseDate, section.availableAt, section.publishAt] for (const candidate of candidateValues) { if (typeof candidate !== 'string' || !candidate.trim()) continue const releaseTime = Date.parse(candidate) if (Number.isFinite(releaseTime)) return releaseTime } return Number.NaN } export function getSectionReleaseDate(section) { const releaseTime = getSectionReleaseTime(section) if (!Number.isFinite(releaseTime)) return null return new Date(releaseTime) } export function isSectionReleased(section) { const releaseTime = getSectionReleaseTime(section) if (!Number.isFinite(releaseTime)) return false return releaseTime <= Date.now() } export function redactUnreleasedSection(section) { if (!section || typeof section !== 'object') return section if (isSectionReleased(section)) return section return { ...section, passageText: '', commentary: '', greekNotes: [], studyQuestions: [], audioEmbedUrl: '', } } export function filterSiteContentByReleaseDate(siteContent) { if (!siteContent || typeof siteContent !== 'object' || Array.isArray(siteContent)) return siteContent const filteredStudies = Array.isArray(siteContent.studies) ? siteContent.studies.map(study => { if (!study || typeof study !== 'object') return study const sections = Array.isArray(study.sections) ? study.sections.map(redactUnreleasedSection) : [] return { ...study, sections } }) : siteContent.studies const filteredLegacySections = Array.isArray(siteContent.colossiansStudySections) ? siteContent.colossiansStudySections.map(redactUnreleasedSection) : siteContent.colossiansStudySections return { ...siteContent, studies: filteredStudies, colossiansStudySections: filteredLegacySections, } } // ── Analytics helpers ────────────────────────────────────────────────────── export function normalizeHitPath(pathname) { if (!pathname || pathname === '') return '/' if (pathname.length > 1 && pathname.endsWith('/')) { return pathname.slice(0, -1) } return pathname } export function shouldCountHit(req) { if (req.method !== 'GET') return false if (req.path.startsWith('/api/')) return false if (req.path === '/admin' || req.path.startsWith('/admin/')) return false if (req.path === '/favicon.ico') return false const hasFileExt = path.extname(req.path) !== '' if (hasFileExt) return false const accept = req.get('accept') ?? '' return accept.includes('text/html') || accept === '*/*' || accept === '' } export function sanitizeUserAgent(userAgent) { if (!userAgent || typeof userAgent !== 'string') return 'unknown' return userAgent.trim().slice(0, 300) || 'unknown' } export function detectDevice(userAgent) { if (!userAgent || typeof userAgent !== 'string') return 'unknown' const ua = userAgent.toLowerCase() if (/tablet|ipad|playbook|silk|(android(?!.*mobile))/.test(ua)) return 'tablet' if (/mobile|iphone|ipod|android|blackberry|opera mini|opera mobi|iemobile|windows phone|palm|smartphone/.test(ua)) return 'mobile' return 'desktop' } export function sanitizeReferrer(referrer) { if (!referrer || typeof referrer !== 'string') return '' try { const parsed = new URL(referrer.trim()) return `${parsed.hostname}${parsed.pathname}`.slice(0, 200) } catch { return '' } } export function detectBot(userAgent) { if (!userAgent || typeof userAgent !== 'string') { return { isBot: true, reason: 'missing-user-agent' } } const ua = userAgent.toLowerCase() if (/googlebot|bingbot|yandexbot|baiduspider|slurp|duckduckbot|sluplicate|googlebot-mobile/.test(ua)) { return { isBot: true, reason: 'search-crawler' } } if (/facebookexternalhit|twitterbot|linkedinbot|pinterest|whatsapp|slack|discord|telegram|reddit|mastodon/.test(ua)) { return { isBot: true, reason: 'social-crawler' } } if (/headless|phantomjs|puppeteer|playwright|selenium|nightmarebot|watir|webdriver|wdio|nightmare/.test(ua)) { return { isBot: true, reason: 'headless-browser' } } if (/uptimerobot|pingdom|statuspage|pagerduty|sentry|datadog|grafana|prometheus|newrelic|appdynamics/.test(ua)) { return { isBot: true, reason: 'monitoring-tool' } } if (/nmap|nikto|masscan|metasploit|nessus|openvas|qualys|burpsuite|zap|acunetix|sqlmap/.test(ua)) { return { isBot: true, reason: 'security-scanner' } } if (/^(curl|wget|python|java|go|node|ruby|php|perl|lua|rust)[\/-]/.test(ua)) { return { isBot: true, reason: 'http-client' } } if (/bot|crawler|spider|scraper|indexer|reader|fetcher|loader|agent|spyware|tracking|monitor/.test(ua)) { if (!/chrome|firefox|safari|opera|edge|msie|trident|like gecko/.test(ua)) { return { isBot: true, reason: 'bot-keyword' } } } return { isBot: false, reason: null } } export function normalizeIp(rawIp) { if (!rawIp) return 'unknown' let ip = String(rawIp).trim() if (ip.includes(',')) ip = ip.split(',')[0].trim() if (ip.startsWith('::ffff:')) ip = ip.slice(7) if (ip === '::1') ip = '127.0.0.1' return ip || 'unknown' } export function isPrivateOrLocalIp(ip) { return ( ip === '127.0.0.1' || ip === 'localhost' || ip.startsWith('10.') || ip.startsWith('192.168.') || /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(ip) || ip.startsWith('fc') || ip.startsWith('fd') || ip.startsWith('fe80:') || ip === 'unknown' ) } export function buildTopLocations(list, key) { const counts = {} for (const row of list) { const val = row?.[key] || 'Unknown' counts[val] = (counts[val] ?? 0) + 1 } return Object.entries(counts) .sort((a, b) => b[1] - a[1]) .slice(0, 10) .map(([name, hits]) => ({ name, hits })) } export function buildLastNDaysStats(days) { const out = [] const today = new Date() for (let i = days - 1; i >= 0; i -= 1) { const d = new Date(today) d.setDate(today.getDate() - i) const dayKey = d.toISOString().slice(0, 10) out.push({ day: dayKey, hits: state.hitStats.byDay[dayKey] ?? 0 }) } return out } export function recordHit(pathname, isBot = false, botReason = null) { const nowIso = new Date().toISOString() const dayKey = nowIso.slice(0, 10) const safePath = normalizeHitPath(pathname) state.hitStats.totalHits += 1 state.hitStats.lastHitAt = nowIso state.hitStats.firstHitAt = state.hitStats.firstHitAt ?? nowIso if (isBot) { state.hitStats.botHits += 1 state.hitStats.byPathBot[safePath] = (state.hitStats.byPathBot[safePath] ?? 0) + 1 state.hitStats.byDayBot[dayKey] = (state.hitStats.byDayBot[dayKey] ?? 0) + 1 if (botReason) { state.hitStats.botReasons[botReason] = (state.hitStats.botReasons[botReason] ?? 0) + 1 } } else { state.hitStats.realHits += 1 state.hitStats.byPathReal[safePath] = (state.hitStats.byPathReal[safePath] ?? 0) + 1 state.hitStats.byDayReal[dayKey] = (state.hitStats.byDayReal[dayKey] ?? 0) + 1 } state.hitStats.byPath[safePath] = (state.hitStats.byPath[safePath] ?? 0) + 1 state.hitStats.byDay[dayKey] = (state.hitStats.byDay[dayKey] ?? 0) + 1 } export function pruneStatsByDays(daysRaw) { const { VISITOR_RETENTION_DAYS_DEFAULT, EMPTY_HIT_STATS: _unused } = { VISITOR_RETENTION_DAYS_DEFAULT: 180 } const days = Number(daysRaw) const retentionDays = Number.isFinite(days) && days > 0 ? Math.floor(days) : 180 const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000 const keepRecent = state.visitorStats.recentVisits.filter(v => { const ts = new Date(v.at).getTime() return Number.isFinite(ts) && ts >= cutoff }) const allowedVisitorIds = new Set(keepRecent.map(v => v.visitorId)) const nextVisitors = {} for (const [id, data] of Object.entries(state.visitorStats.visitors)) { const lastSeen = new Date(data.lastSeenAt ?? 0).getTime() if (allowedVisitorIds.has(id) || (Number.isFinite(lastSeen) && lastSeen >= cutoff)) { nextVisitors[id] = data } } const nextByDay = {} const nextByDayReal = {} const nextByDayBot = {} for (const [day, count] of Object.entries(state.hitStats.byDay)) { const ts = new Date(`${day}T00:00:00.000Z`).getTime() if (Number.isFinite(ts) && ts >= cutoff) { nextByDay[day] = count nextByDayReal[day] = state.hitStats.byDayReal?.[day] ?? 0 nextByDayBot[day] = state.hitStats.byDayBot?.[day] ?? 0 } } state.visitorStats.recentVisits = keepRecent state.visitorStats.visitors = nextVisitors state.visitorStats.uniqueVisitors = Object.keys(nextVisitors).length state.visitorStats.totalVisits = keepRecent.length state.visitorStats.returningVisits = keepRecent.filter(v => v.returningVisitor).length state.visitorStats.firstVisitAt = keepRecent.length > 0 ? keepRecent[keepRecent.length - 1].at : null state.visitorStats.lastVisitAt = keepRecent.length > 0 ? keepRecent[0].at : null state.hitStats.byDay = nextByDay state.hitStats.byDayReal = nextByDayReal state.hitStats.byDayBot = nextByDayBot return { retentionDays, remainingVisits: state.visitorStats.totalVisits, remainingVisitors: state.visitorStats.uniqueVisitors, } } // ── Redirect / URL helpers ───────────────────────────────────────────────── export function normalizeRedirectPath(value) { if (typeof value !== 'string') return '' const trimmed = value.trim() if (!trimmed) return '' const withSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}` const normalized = withSlash.replace(/\/+/g, '/') if (normalized === '/') return '' if (normalized.startsWith('/api/') || normalized.startsWith('/admin')) return '' return normalized } export function sanitizeUrl(value) { if (typeof value !== 'string') return '' const trimmed = value.trim() if (!trimmed) return '' if (trimmed.startsWith('/')) return trimmed if (/^https?:\/\//i.test(trimmed)) return trimmed return '' } export function sanitizeRedirectRules(value) { const source = Array.isArray(value) ? value : [] const seen = new Set() const out = [] for (const item of source) { const pathValue = normalizeRedirectPath(item?.path) const target = sanitizeUrl(item?.target) const statusCode = Number(item?.statusCode) === 302 ? 302 : 301 if (!pathValue || !target) continue if (seen.has(pathValue)) continue seen.add(pathValue) out.push({ id: typeof item?.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(), path: pathValue, target, statusCode, }) } return out.length > 0 ? out : DEFAULT_REDIRECT_RULES } // ── Contact / email tracking ─────────────────────────────────────────────── export function noteContactEmailCooldown(emailAddress) { const normalized = String(emailAddress || '').trim().toLowerCase() if (!normalized) return { ok: true, retryAfterMs: 0 } const now = Date.now() const lastAt = state.contactSubmitCooldownByEmail.get(normalized) if (typeof lastAt === 'number' && now - lastAt < CONTACT_EMAIL_COOLDOWN_MS) { return { ok: false, retryAfterMs: CONTACT_EMAIL_COOLDOWN_MS - (now - lastAt) } } state.contactSubmitCooldownByEmail.set(normalized, now) if (state.contactSubmitCooldownByEmail.size > 8000) { const cutoff = now - CONTACT_EMAIL_COOLDOWN_MS * 3 for (const [email, timestamp] of state.contactSubmitCooldownByEmail.entries()) { if (timestamp < cutoff) state.contactSubmitCooldownByEmail.delete(email) } } return { ok: true, retryAfterMs: 0 } } export function extractTagValue(tags, name) { if (!Array.isArray(tags)) return '' const target = String(name || '').trim().toLowerCase() if (!target) return '' for (const tag of tags) { if (!tag || typeof tag !== 'object') continue const key = typeof tag.name === 'string' ? tag.name.trim().toLowerCase() : '' const value = typeof tag.value === 'string' ? tag.value.trim() : '' if (key === target && value) return value } return '' } export function mapResendEventToStatus(eventType) { const normalized = String(eventType || '').trim().toLowerCase() if (!normalized) return 'updated' if (normalized.includes('delivered')) return 'delivered' if (normalized.includes('delivery_delayed') || normalized.includes('delivery delayed')) return 'delayed' if (normalized.includes('bounce')) return 'bounced' if (normalized.includes('complain')) return 'complained' if (normalized.includes('click')) return 'clicked' if (normalized.includes('open')) return 'opened' if (normalized.includes('send')) return 'sent' return 'updated' } export function extractResendMessageId(result) { if (!result || typeof result !== 'object') return '' if (typeof result.id === 'string' && result.id.trim()) return result.id.trim() if (result.data && typeof result.data === 'object' && typeof result.data.id === 'string' && result.data.id.trim()) { return result.data.id.trim() } return '' } export function normalizeMessageType(value) { if (value === 'question' || value === 'testimony' || value === 'topic') return value return 'general' } // ── Study reminder scheduler ─────────────────────────────────────────────── export async function scheduleStudyReminders(sendStudyReminderEmail) { if (!state.cachedSiteContent) return const now = new Date() for (const user of state.studyUsers) { if (user.studyRemindersEnabled !== true) continue const email = user.username const displayName = user.displayName || email const enrolledStudySlugs = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : [] if (enrolledStudySlugs.length === 0) continue const userSent = state.studyReminders.users[user.id] ?? {} for (const studySlug of enrolledStudySlugs) { const study = Array.isArray(state.cachedSiteContent.studies) ? state.cachedSiteContent.studies.find(item => normalizeStudySlug(item?.slug) === studySlug) : undefined if (!study) continue for (const section of study.sections ?? []) { const sectionId = section.id const releaseDate = getSectionReleaseDate(section) if (!releaseDate) continue // Lessons release at 1:00 AM; reminder emails go out 8 hours later, at 9:00 AM the same day. const sendDate = new Date(releaseDate.getTime() + 8 * 60 * 60 * 1000) if (sendDate > now) continue const sentForStudy = Array.isArray(userSent[studySlug]) ? userSent[studySlug] : [] if (sentForStudy.includes(sectionId)) continue const hoursSinceSend = (now.getTime() - sendDate.getTime()) / (1000 * 60 * 60) if (hoursSinceSend > 24) continue const canonical = state.cachedSiteContent?.seo?.canonicalUrl || 'https://versebyversewithnate.us/' const base = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical const sectionUrl = `${base}/study/${study.slug}/${section.id}` try { await sendStudyReminderEmail(email, displayName, study.title, section.title, section.reference, sectionUrl) } catch (err) { console.error(`[study-reminders] failed to send reminder to ${email} for ${studySlug}/${sectionId}:`, err) continue } userSent[studySlug] = [...sentForStudy, sectionId] state.studyReminders.users[user.id] = userSent // Persist after each successful send so a later crash doesn't re-send queueStudyRemindersWrite() } } } 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 } } }