feat(admin): consentement aux annonces et désabonnement sans connexion
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 5m58s
CI / Deploy production (on server) (push) Successful in 26s

Les comptes déjà créés ne sont pas inscrits. Le choix est facultatif à l’inscription et dans les paramètres. Un lien public demande confirmation avant d’arrêter les actualités, sans toucher aux messages de compte.
This commit is contained in:
Antigravity
2026-09-06 09:23:10 +00:00
parent 6fa12f6e13
commit 360e58e473
33 changed files with 1036 additions and 9 deletions

View File

@@ -0,0 +1,24 @@
export const MARKETING_CONSENT_VERSION = 'memento-announcements-2026-09-v1'
const COPY: Record<string, string> = {
fr: 'Jaccepte de recevoir, de temps en temps, des actualités et des offres de Memento. Ce nest pas obligatoire. Les messages indispensables (compte, facture, mot de passe) continueront.',
en: 'I agree to receive occasional Memento news and offers. This is optional. Essential messages (account, billing, password) will continue.',
de: 'Ich stimme zu, gelegentlich Neuigkeiten und Angebote von Memento zu erhalten. Das ist freiwillig. Unerlässliche Nachrichten (Konto, Rechnung, Passwort) gehen weiter.',
es: 'Acepto recibir de vez en cuando novedades y ofertas de Memento. No es obligatorio. Los mensajes imprescindibles (cuenta, factura, contraseña) seguirán llegando.',
it: 'Accetto di ricevere di tanto in tanto novità e offerte di Memento. Non è obbligatorio. I messaggi indispensabili (account, fattura, password) continueranno.',
pt: 'Aceito receber de vez em quando novidades e ofertas da Memento. Não é obrigatório. As mensagens indispensáveis (conta, fatura, palavra-passe) continuam.',
nl: 'Ik ga akkoord om af en toe nieuws en aanbiedingen van Memento te ontvangen. Dit is niet verplicht. Essentiële berichten (account, factuur, wachtwoord) blijven komen.',
pl: 'Wyrażam zgodę na okazjonalne wiadomości i oferty Memento. To nie jest obowiązkowe. Niezbędne wiadomości (konto, faktura, hasło) będą nadal wysyłane.',
ru: 'Я соглашаюсь иногда получать новости и предложения Memento. Это необязательно. Обязательные письма (аккаунт, счёт, пароль) продолжат приходить.',
zh: '我同意偶尔接收 Memento 的近况和优惠。这不是必须的。必要邮件(账户、账单、密码)仍会发送。',
ja: 'Memento の近況や特典をときどき受け取ることへ同意します。必須ではありません。必要な連絡(アカウント、請求、パスワード)は続きます。',
ko: 'Memento 소식과 혜택을 가끔 받는 데 동의합니다. 필수는 아닙니다. 꼭 필요한 메일(계정, 청구, 비밀번호)은 계속 옵니다.',
ar: 'أوافق على تلقّي أخبار وعروض ميمنتو من حين لآخر. هذا اختياري. الرسائل الضرورية (الحساب، الفاتورة، كلمة المرور) ستستمر.',
fa: 'می‌پذیرم گاهی اخبار و پیشنهادهای Memento را دریافت کنم. اجباری نیست. پیام‌های ضروری (حساب، صورتحساب، گذرواژه) ادامه دارند.',
hi: 'मैं कभी-कभी Memento की खबरें और प्रस्ताव पाने के लिए सहमत हूँ। यह ज़रूरी नहीं है। ज़रूरी पत्र (खाता, बिल, पासवर्ड) आते रहेंगे।',
}
export function marketingConsentText(language?: string | null): string {
const lang = (language || 'en').toLowerCase()
return COPY[lang] || COPY.en
}

View File

@@ -0,0 +1,3 @@
export function normalizeMarketingEmail(email: string): string {
return email.trim().toLowerCase()
}

View File

@@ -0,0 +1,205 @@
import prisma from '@/lib/prisma'
import { MARKETING_CONSENT_VERSION, marketingConsentText } from '@/lib/marketing/consent-copy'
import { normalizeMarketingEmail } from '@/lib/marketing/normalize-email'
export const MARKETING_BLOCKING_REASONS = ['COMPLAINT', 'HARD_BOUNCE', 'INVALID'] as const
export type MarketingSource =
| 'settings'
| 'register'
| 'unsubscribe'
| 'unsubscribe-one-click'
| 'complaint'
| 'bounce'
export type MarketingPreferenceView = {
optedIn: boolean
emailNormalized: string
consentVersion: string | null
updatedAt: Date | null
blocked: boolean
blockReason: string | null
}
type RequestMeta = {
ip?: string | null
userAgent?: string | null
}
export async function getMarketingPreferenceView(userId: string): Promise<MarketingPreferenceView> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
email: true,
marketingPreference: true,
},
})
const emailNormalized = normalizeMarketingEmail(user?.email || '')
const suppression = emailNormalized
? await prisma.emailSuppression.findUnique({ where: { emailNormalized } })
: null
const blocked = Boolean(
suppression && MARKETING_BLOCKING_REASONS.includes(suppression.reason as (typeof MARKETING_BLOCKING_REASONS)[number]),
)
return {
optedIn: user?.marketingPreference?.optedIn === true && !suppression,
emailNormalized,
consentVersion: user?.marketingPreference?.consentVersion ?? null,
updatedAt: user?.marketingPreference?.updatedAt ?? null,
blocked,
blockReason: blocked ? suppression?.reason ?? null : null,
}
}
export async function canReceiveMarketing(opts: {
userId?: string | null
email: string
requireVerified?: boolean
}): Promise<{ allowed: boolean; reason: string }> {
const emailNormalized = normalizeMarketingEmail(opts.email)
if (!emailNormalized) return { allowed: false, reason: 'invalid_email' }
const suppression = await prisma.emailSuppression.findUnique({ where: { emailNormalized } })
if (suppression) return { allowed: false, reason: `suppressed:${suppression.reason}` }
const user = opts.userId
? await prisma.user.findUnique({
where: { id: opts.userId },
select: {
emailVerified: true,
marketingPreference: { select: { optedIn: true, emailNormalized: true } },
},
})
: await prisma.user.findUnique({
where: { email: emailNormalized },
select: {
emailVerified: true,
marketingPreference: { select: { optedIn: true, emailNormalized: true } },
},
})
if (!user) return { allowed: false, reason: 'no_account' }
if (opts.requireVerified !== false && !user.emailVerified) {
return { allowed: false, reason: 'unverified' }
}
if (!user.marketingPreference?.optedIn) return { allowed: false, reason: 'not_opted_in' }
return { allowed: true, reason: 'ok' }
}
export async function setMarketingOptIn(opts: {
userId: string
optedIn: boolean
source: MarketingSource
language?: string | null
meta?: RequestMeta
}): Promise<{ ok: true } | { ok: false; error: 'blocked' | 'missing_user' }> {
const user = await prisma.user.findUnique({
where: { id: opts.userId },
select: { id: true, email: true },
})
if (!user) return { ok: false, error: 'missing_user' }
const emailNormalized = normalizeMarketingEmail(user.email)
const existingSuppression = await prisma.emailSuppression.findUnique({
where: { emailNormalized },
})
if (
opts.optedIn &&
existingSuppression &&
MARKETING_BLOCKING_REASONS.includes(existingSuppression.reason as (typeof MARKETING_BLOCKING_REASONS)[number])
) {
return { ok: false, error: 'blocked' }
}
const consentText = opts.optedIn ? marketingConsentText(opts.language) : null
const consentVersion = opts.optedIn ? MARKETING_CONSENT_VERSION : null
const preference = await prisma.marketingPreference.upsert({
where: { userId: user.id },
create: {
userId: user.id,
emailNormalized,
optedIn: opts.optedIn,
preferredLanguage: opts.language || null,
consentVersion,
consentText,
source: opts.source,
},
update: {
emailNormalized,
optedIn: opts.optedIn,
preferredLanguage: opts.language || undefined,
consentVersion: opts.optedIn ? consentVersion : undefined,
consentText: opts.optedIn ? consentText : undefined,
source: opts.source,
},
})
await prisma.marketingPreferenceEvent.create({
data: {
preferenceId: preference.id,
userId: user.id,
emailNormalized,
optedIn: opts.optedIn,
source: opts.source,
consentVersion,
consentText,
ip: opts.meta?.ip || null,
userAgent: opts.meta?.userAgent || null,
},
})
if (opts.optedIn) {
if (existingSuppression?.reason === 'UNSUBSCRIBE') {
await prisma.emailSuppression.delete({ where: { emailNormalized } })
}
} else {
await prisma.emailSuppression.upsert({
where: { emailNormalized },
create: {
emailNormalized,
reason: 'UNSUBSCRIBE',
source: opts.source,
userId: user.id,
},
update: {
reason: existingSuppression && MARKETING_BLOCKING_REASONS.includes(existingSuppression.reason as (typeof MARKETING_BLOCKING_REASONS)[number])
? existingSuppression.reason
: 'UNSUBSCRIBE',
source: opts.source,
userId: user.id,
},
})
}
await prisma.auditLog.create({
data: {
userId: user.id,
action: opts.optedIn ? 'marketing.opt_in' : 'marketing.opt_out',
resource: 'MarketingPreference',
metadata: { source: opts.source, consentVersion },
ip: opts.meta?.ip || null,
userAgent: opts.meta?.userAgent || null,
},
}).catch(() => {})
return { ok: true }
}
export async function unsubscribeByUserId(opts: {
userId: string
source: MarketingSource
meta?: RequestMeta
}): Promise<{ ok: true; already: boolean } | { ok: false }> {
const view = await getMarketingPreferenceView(opts.userId)
if (!view.emailNormalized) return { ok: false }
const already = !view.optedIn
const result = await setMarketingOptIn({
userId: opts.userId,
optedIn: false,
source: opts.source,
meta: opts.meta,
})
if (!result.ok) return { ok: false }
return { ok: true, already }
}

View File

@@ -0,0 +1,11 @@
import { headers } from 'next/headers'
export async function marketingRequestMeta(): Promise<{ ip: string | null; userAgent: string | null }> {
const h = await headers()
const forwarded = h.get('x-forwarded-for')
const ip = forwarded?.split(',')[0]?.trim() || h.get('x-real-ip') || null
return {
ip,
userAgent: h.get('user-agent'),
}
}

View File

@@ -0,0 +1,71 @@
import { createHmac, timingSafeEqual } from 'crypto'
const TOKEN_TTL_MS = 10 * 365 * 24 * 60 * 60 * 1000
function getSecret(): string {
const secret = process.env.NEXTAUTH_SECRET
if (!secret) {
throw new Error('NEXTAUTH_SECRET is required for unsubscribe tokens')
}
return secret
}
export type UnsubscribeTokenPayload = {
userId: string
issuedAt: number
}
export function createUnsubscribeToken(userId: string): string {
const issuedAt = Date.now()
const inner = Buffer.from(`${userId}.${issuedAt}`, 'utf8').toString('base64url')
const sig = createHmac('sha256', getSecret()).update(inner).digest('base64url')
return `${inner}.${sig}`
}
export function verifyUnsubscribeToken(token: string): UnsubscribeTokenPayload | null {
try {
const trimmed = token.trim()
const lastDot = trimmed.lastIndexOf('.')
if (lastDot <= 0) return null
const sig = trimmed.slice(lastDot + 1)
const inner = trimmed.slice(0, lastDot)
const expected = createHmac('sha256', getSecret()).update(inner).digest('base64url')
const sigBuf = Buffer.from(sig)
const expectedBuf = Buffer.from(expected)
if (sigBuf.length !== expectedBuf.length || !timingSafeEqual(sigBuf, expectedBuf)) {
return null
}
const decoded = Buffer.from(inner, 'base64url').toString('utf8')
const firstDot = decoded.indexOf('.')
if (firstDot <= 0) return null
const userId = decoded.slice(0, firstDot)
const issuedAt = Number(decoded.slice(firstDot + 1))
if (!userId || !Number.isFinite(issuedAt)) return null
if (Date.now() - issuedAt > TOKEN_TTL_MS) return null
return { userId, issuedAt }
} catch {
return null
}
}
export function publicAppOrigin(): string {
return (process.env.NEXTAUTH_URL || '').replace(/\/$/, '')
}
export function unsubscribePageUrl(token: string): string {
return `${publicAppOrigin()}/unsubscribe?token=${encodeURIComponent(token)}`
}
export function unsubscribeOneClickUrl(token: string): string {
return `${publicAppOrigin()}/api/marketing/unsubscribe?token=${encodeURIComponent(token)}`
}
/** Headers for future campaign messages (RFC 8058). */
export function listUnsubscribeHeaders(token: string): Record<string, string> {
const page = unsubscribePageUrl(token)
const oneClick = unsubscribeOneClickUrl(token)
return {
'List-Unsubscribe': `<${oneClick}>, <${page}>`,
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
}
}