feat(admin): consentement aux annonces et désabonnement sans connexion
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:
24
memento-note/lib/marketing/consent-copy.ts
Normal file
24
memento-note/lib/marketing/consent-copy.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export const MARKETING_CONSENT_VERSION = 'memento-announcements-2026-09-v1'
|
||||
|
||||
const COPY: Record<string, string> = {
|
||||
fr: 'J’accepte de recevoir, de temps en temps, des actualités et des offres de Memento. Ce n’est 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
|
||||
}
|
||||
3
memento-note/lib/marketing/normalize-email.ts
Normal file
3
memento-note/lib/marketing/normalize-email.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function normalizeMarketingEmail(email: string): string {
|
||||
return email.trim().toLowerCase()
|
||||
}
|
||||
205
memento-note/lib/marketing/preference.ts
Normal file
205
memento-note/lib/marketing/preference.ts
Normal 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 }
|
||||
}
|
||||
11
memento-note/lib/marketing/request-meta.ts
Normal file
11
memento-note/lib/marketing/request-meta.ts
Normal 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'),
|
||||
}
|
||||
}
|
||||
71
memento-note/lib/marketing/unsubscribe-token.ts
Normal file
71
memento-note/lib/marketing/unsubscribe-token.ts
Normal 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',
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user