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
+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 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('<!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>')
})
}