diff --git a/docs/plan-admin-marketing-statistiques-coupons.md b/docs/plan-admin-marketing-statistiques-coupons.md
index 4cb0fb71..e14f37dd 100644
--- a/docs/plan-admin-marketing-statistiques-coupons.md
+++ b/docs/plan-admin-marketing-statistiques-coupons.md
@@ -1,7 +1,7 @@
# Memento — Plan administration : marketing, statistiques et offres Stripe
Date : 6 septembre 2026.
-Statut : plan en cours. M1 livré le 6 septembre 2026 (navigation et pages de travail). Aucun envoi, aucun coupon, aucun chiffre inventé.
+Statut : plan en cours. M1 livré (pages de travail). M2 livré le 6 septembre 2026 (consentement explicite, désabonnement sans connexion, comptes existants non inscrits). Aucun envoi, aucun coupon.
Demande : examiner l'administration et préparer un plan détaillé avant toute implémentation.
Périmètre de cette session : lectures du code, consultation de documentation officielle, observation locale de `/admin` et `/admin/billing`, rédaction de ce document. Aucun courriel envoyé, aucun coupon créé, aucune donnée modifiée.
@@ -228,7 +228,7 @@ Une fonctionnalité à la fois, vérification et validation utilisateur avant la
| Ordre | Livraison | Acceptation principale |
| --- | --- | --- |
| M1 | Navigation et pages de travail | **Livré** — `/admin/marketing`, `/admin/stats`, menu admin. Tableau campagnes vide honnête ; statistiques « pas encore disponible » (pas de zéro). Fichiers : `admin-sidebar.tsx`, `app/(admin)/admin/marketing/`, `app/(admin)/admin/stats/`, clés i18n `admin.marketing` / `admin.stats`. |
-| M2 | Préférences et exclusions | Consentement explicite, retrait sans connexion, anciennes inscriptions non admises automatiquement |
+| M2 | Préférences et exclusions | **Livré** — consentement facultatif (réglages + inscription, case non cochée), journal, exclusion d’adresse, page `/unsubscribe` (GET confirme, POST agit), POST public un clic. Comptes existants : pas d’inscription automatique. |
| M3 | Audiences | Nombre cohérent, exclusions expliquées, segmentation réelle, pas d'utilisation de notes privées |
| M4 | Brouillons et éditeur | Sauvegarde, version texte/HTML, aperçu sécurisé, personnalisation contrôlée |
| M5 | Génération IA | Modèle serveur, consignes produit validées, langue et réservation de quota, aucun envoi automatique |
diff --git a/docs/user-stories.md b/docs/user-stories.md
index 3a1f55f3..ee1c89a3 100644
--- a/docs/user-stories.md
+++ b/docs/user-stories.md
@@ -1,7 +1,7 @@
# User Stories — Memento Next Phase
> Basé sur l'analyse du prototype `architectural-grid/` et du code production `memento-note/`.
-> Dernière mise à jour : 2026-09-06 (M1 admin marketing/statistiques — pages de travail, sans envoi)
+> Dernière mise à jour : 2026-09-06 (M2 consentement annonces + désabonnement)
---
@@ -31,6 +31,7 @@
| **US-PPTX-EXPORT** | Export PPTX + Watermark | ✅ **LIVRÉ** | `lib/brainstorm/export-pptx.ts`, `lib/ai/tools/pptx.tool.ts` |
| **US-PUBLISH-IA** | Publication IA (templates magazine/brief/essay + rewrite) | ✅ **LIVRÉ** | `lib/publish/`, `publish-enhance.service.ts`, 4 templates CSS, quota `publish_enhance` |
| **M1-ADMIN-MARKETING** | Admin Marketing + Statistiques : pages de travail, menu, aucun envoi ni chiffre fictif | ✅ **LIVRÉ** | `admin-sidebar.tsx`, `app/(admin)/admin/marketing/`, `app/(admin)/admin/stats/`, i18n 15 langues. Plan : `docs/plan-admin-marketing-statistiques-coupons.md` |
+| **M2-MARKETING-CONSENT** | Consentement explicite aux annonces + désabonnement sans connexion | ✅ **LIVRÉ** | `MarketingPreference`, `EmailSuppression`, réglages généraux, inscription (case non cochée), `/unsubscribe`, `/api/marketing/unsubscribe` |
---
diff --git a/memento-note/app/(auth)/unsubscribe/page.tsx b/memento-note/app/(auth)/unsubscribe/page.tsx
new file mode 100644
index 00000000..c973650b
--- /dev/null
+++ b/memento-note/app/(auth)/unsubscribe/page.tsx
@@ -0,0 +1,121 @@
+'use client'
+
+import { Suspense, useEffect, useState } from 'react'
+import Link from 'next/link'
+import { useSearchParams } from 'next/navigation'
+import { CheckCircle2, AlertCircle, Mail } from 'lucide-react'
+import { useLanguage } from '@/lib/i18n'
+import { confirmMarketingUnsubscribe, previewUnsubscribeToken } from '@/app/actions/marketing-unsubscribe'
+
+function UnsubscribeContent() {
+ const { t } = useLanguage()
+ const searchParams = useSearchParams()
+ const token = searchParams.get('token') || ''
+ const [status, setStatus] = useState<'idle' | 'saving' | 'ok' | 'already' | 'invalid'>(
+ token ? 'idle' : 'invalid',
+ )
+
+ useEffect(() => {
+ if (!token) return
+ let cancelled = false
+ void previewUnsubscribeToken(token).then((result) => {
+ if (!cancelled && !result.valid) setStatus('invalid')
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [token])
+
+ const confirm = async () => {
+ if (!token) {
+ setStatus('invalid')
+ return
+ }
+ setStatus('saving')
+ const result = await confirmMarketingUnsubscribe(token)
+ if (result.invalid || !result.ok) setStatus('invalid')
+ else if (result.already) setStatus('already')
+ else setStatus('ok')
+ }
+
+ if (status === 'ok' || status === 'already') {
+ return (
+
+
+
+
+
+
+
+
+
{t('settings.marketingTitle')}
+
+ {t('settings.marketingDescription')}
+
+
+
+
+
+
+ {t('settings.marketingLegal')}
+
+ {marketingBlocked && (
+
{t('settings.marketingBlocked')}
+ )}
+
+
)
}
diff --git a/memento-note/app/actions/marketing-preference.ts b/memento-note/app/actions/marketing-preference.ts
new file mode 100644
index 00000000..7076a175
--- /dev/null
+++ b/memento-note/app/actions/marketing-preference.ts
@@ -0,0 +1,29 @@
+'use server'
+
+import { auth } from '@/auth'
+import { marketingConsentText } from '@/lib/marketing/consent-copy'
+import { getMarketingPreferenceView, setMarketingOptIn } from '@/lib/marketing/preference'
+import { marketingRequestMeta } from '@/lib/marketing/request-meta'
+
+export async function getMyMarketingPreference() {
+ const session = await auth()
+ const userId = (session?.user as { id?: string } | undefined)?.id
+ if (!userId) return null
+ return getMarketingPreferenceView(userId)
+}
+
+export async function updateMyMarketingPreference(optedIn: boolean, language?: string) {
+ const session = await auth()
+ const userId = (session?.user as { id?: string } | undefined)?.id
+ if (!userId) return { ok: false as const, error: 'auth' as const }
+ const meta = await marketingRequestMeta()
+ const result = await setMarketingOptIn({
+ userId,
+ optedIn,
+ source: 'settings',
+ language: language || null,
+ meta,
+ })
+ if (!result.ok) return { ok: false as const, error: result.error }
+ return { ok: true as const, legalText: optedIn ? marketingConsentText(language) : null }
+}
diff --git a/memento-note/app/actions/marketing-unsubscribe.ts b/memento-note/app/actions/marketing-unsubscribe.ts
new file mode 100644
index 00000000..9a3215c0
--- /dev/null
+++ b/memento-note/app/actions/marketing-unsubscribe.ts
@@ -0,0 +1,30 @@
+'use server'
+
+import { unsubscribeByUserId } from '@/lib/marketing/preference'
+import { marketingRequestMeta } from '@/lib/marketing/request-meta'
+import { verifyUnsubscribeToken } from '@/lib/marketing/unsubscribe-token'
+
+export async function previewUnsubscribeToken(token: string | null): Promise<{
+ valid: boolean
+}> {
+ if (!token) return { valid: false }
+ const payload = verifyUnsubscribeToken(token)
+ return { valid: Boolean(payload) }
+}
+
+export async function confirmMarketingUnsubscribe(token: string): Promise<{
+ ok: boolean
+ already?: boolean
+ invalid?: boolean
+}> {
+ const payload = verifyUnsubscribeToken(token)
+ if (!payload) return { ok: false, invalid: true }
+ const meta = await marketingRequestMeta()
+ const result = await unsubscribeByUserId({
+ userId: payload.userId,
+ source: 'unsubscribe',
+ meta,
+ })
+ if (!result.ok) return { ok: false, invalid: true }
+ return { ok: true, already: result.already }
+}
diff --git a/memento-note/app/actions/register.ts b/memento-note/app/actions/register.ts
index 5a186bc7..cf9a7657 100644
--- a/memento-note/app/actions/register.ts
+++ b/memento-note/app/actions/register.ts
@@ -50,7 +50,7 @@ export async function register(prevState: string | undefined, formData: FormData
const hashedPassword = await bcrypt.hash(password, 10);
const role = isAdmin ? 'ADMIN' : 'USER';
- await prisma.user.create({
+ const created = await prisma.user.create({
data: {
email: normalizedEmail,
password: hashedPassword,
@@ -61,6 +61,25 @@ export async function register(prevState: string | undefined, formData: FormData
},
});
+ if (formData.get('marketingConsent') === '1') {
+ try {
+ const { setMarketingOptIn } = await import('@/lib/marketing/preference')
+ const { marketingRequestMeta } = await import('@/lib/marketing/request-meta')
+ const meta = await marketingRequestMeta()
+ await setMarketingOptIn({
+ userId: created.id,
+ optedIn: true,
+ source: 'register',
+ language: typeof formData.get('marketingLanguage') === 'string'
+ ? String(formData.get('marketingLanguage'))
+ : null,
+ meta,
+ })
+ } catch (err) {
+ console.error('[register] marketing consent save failed:', err)
+ }
+ }
+
if (!isAdmin) {
const mailResult = await sendVerificationEmail({
email: normalizedEmail,
diff --git a/memento-note/app/api/marketing/unsubscribe/route.ts b/memento-note/app/api/marketing/unsubscribe/route.ts
new file mode 100644
index 00000000..459e6630
--- /dev/null
+++ b/memento-note/app/api/marketing/unsubscribe/route.ts
@@ -0,0 +1,53 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { unsubscribeByUserId } from '@/lib/marketing/preference'
+import { publicAppOrigin, verifyUnsubscribeToken } from '@/lib/marketing/unsubscribe-token'
+
+export const dynamic = 'force-dynamic'
+
+function tokenFromRequest(request: NextRequest, bodyToken?: string | null): string | null {
+ return (
+ request.nextUrl.searchParams.get('token') ||
+ bodyToken ||
+ null
+ )
+}
+
+async function applyUnsubscribe(request: NextRequest, token: string | null) {
+ const payload = token ? verifyUnsubscribeToken(token) : null
+ if (!payload) {
+ return NextResponse.json({ ok: false }, { status: 400 })
+ }
+ const forwarded = request.headers.get('x-forwarded-for')
+ const ip = forwarded?.split(',')[0]?.trim() || request.headers.get('x-real-ip')
+ await unsubscribeByUserId({
+ userId: payload.userId,
+ source: 'unsubscribe-one-click',
+ meta: { ip, userAgent: request.headers.get('user-agent') },
+ })
+ return new NextResponse('OK', { status: 200 })
+}
+
+export async function POST(request: NextRequest) {
+ let bodyToken: string | null = null
+ const contentType = request.headers.get('content-type') || ''
+ try {
+ if (contentType.includes('application/x-www-form-urlencoded')) {
+ const form = await request.formData()
+ bodyToken = String(form.get('token') || '')
+ } else if (contentType.includes('application/json')) {
+ const json = await request.json().catch(() => null)
+ bodyToken = json?.token ? String(json.token) : null
+ }
+ } catch {
+ bodyToken = null
+ }
+ return applyUnsubscribe(request, tokenFromRequest(request, bodyToken))
+}
+
+export async function GET(request: NextRequest) {
+ const token = request.nextUrl.searchParams.get('token') || ''
+ const origin = publicAppOrigin() || request.nextUrl.origin
+ const url = new URL('/unsubscribe', origin)
+ if (token) url.searchParams.set('token', token)
+ return NextResponse.redirect(url, 303)
+}
diff --git a/memento-note/auth.config.ts b/memento-note/auth.config.ts
index 20cf0f14..300594dc 100644
--- a/memento-note/auth.config.ts
+++ b/memento-note/auth.config.ts
@@ -36,7 +36,9 @@ export const authConfig = {
nextUrl.pathname === '/forgot-password' ||
nextUrl.pathname === '/check-email' ||
nextUrl.pathname === '/verify-email' ||
- nextUrl.pathname.startsWith('/reset-password');
+ nextUrl.pathname.startsWith('/reset-password') ||
+ nextUrl.pathname === '/unsubscribe' ||
+ nextUrl.pathname.startsWith('/unsubscribe/');
if (isAdminPage) {
return isLoggedIn && isAdmin;
diff --git a/memento-note/components/register-form.tsx b/memento-note/components/register-form.tsx
index e1853c60..d8e76f58 100644
--- a/memento-note/components/register-form.tsx
+++ b/memento-note/components/register-form.tsx
@@ -43,7 +43,7 @@ function AuthDivider({ label }: { label: string }) {
export function RegisterForm({ googleAuthEnabled = false }: { googleAuthEnabled?: boolean }) {
const [errorMessage, dispatch] = useActionState(register, undefined);
- const { t } = useLanguage();
+ const { t, language } = useLanguage();
return (
@@ -147,6 +147,19 @@ export function RegisterForm({ googleAuthEnabled = false }: { googleAuthEnabled?
+
+
+
= {
+ 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
+}
diff --git a/memento-note/lib/marketing/normalize-email.ts b/memento-note/lib/marketing/normalize-email.ts
new file mode 100644
index 00000000..6028bd25
--- /dev/null
+++ b/memento-note/lib/marketing/normalize-email.ts
@@ -0,0 +1,3 @@
+export function normalizeMarketingEmail(email: string): string {
+ return email.trim().toLowerCase()
+}
diff --git a/memento-note/lib/marketing/preference.ts b/memento-note/lib/marketing/preference.ts
new file mode 100644
index 00000000..5937d9fe
--- /dev/null
+++ b/memento-note/lib/marketing/preference.ts
@@ -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 {
+ 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 }
+}
diff --git a/memento-note/lib/marketing/request-meta.ts b/memento-note/lib/marketing/request-meta.ts
new file mode 100644
index 00000000..31560218
--- /dev/null
+++ b/memento-note/lib/marketing/request-meta.ts
@@ -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'),
+ }
+}
diff --git a/memento-note/lib/marketing/unsubscribe-token.ts b/memento-note/lib/marketing/unsubscribe-token.ts
new file mode 100644
index 00000000..2ab799ef
--- /dev/null
+++ b/memento-note/lib/marketing/unsubscribe-token.ts
@@ -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 {
+ const page = unsubscribePageUrl(token)
+ const oneClick = unsubscribeOneClickUrl(token)
+ return {
+ 'List-Unsubscribe': `<${oneClick}>, <${page}>`,
+ 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
+ }
+}
diff --git a/memento-note/locales/ar.json b/memento-note/locales/ar.json
index 44e05172..9a7c7d53 100644
--- a/memento-note/locales/ar.json
+++ b/memento-note/locales/ar.json
@@ -1,4 +1,16 @@
{
+ "unsubscribe": {
+ "title": "إيقاف الأخبار والعروض؟",
+ "body": "هذا يوقف الإعلانات والعروض فقط. الحساب والفاتورة وكلمة المرور بلا تغيير. أكّد بالزر — فتح هذه الصفحة لا يكفي.",
+ "confirm": "إيقاف الإعلانات",
+ "keep": "متابعة التلقّي",
+ "successTitle": "تم إلغاء الاشتراك",
+ "successBody": "لن تصلك أخبار وعروض ميمنتو على هذا العنوان. رسائل الخدمة الضرورية قد تستمر.",
+ "invalidTitle": "هذا الرابط غير صالح",
+ "invalidBody": "الرابط ناقص أو لم يعد صالحًا. يمكنك أيضًا إيقاف الإعلانات من الإعدادات.",
+ "openSettings": "فتح الإعدادات",
+ "backToSignIn": "تسجيل الدخول"
+ },
"auth": {
"signIn": "تسجيل الدخول",
"signUp": "إنشاء حساب",
@@ -14,6 +26,7 @@
"forgotPassword": "هل نسيت كلمة المرور؟",
"noAccount": "ليس لديك حساب؟",
"hasAccount": "لديك حساب بالفعل؟",
+ "marketingConsent": "أوافق على تلقّي أخبار وعروض ميمنتو من حين لآخر (اختياري). الرسائل الضرورية عن حسابي ستستمر.",
"signInToAccount": "سجل الدخول إلى حسابك",
"createAccount": "أنشئ حسابك",
"rememberMe": "تذكرني",
@@ -1031,6 +1044,11 @@
"about": "حول",
"version": "الإصدار",
"settingsSaved": "تم حفظ الإعدادات",
+ "marketingTitle": "الأخبار والعروض",
+ "marketingDescription": "تلقّي أخبار وعروض ميمنتو من حين لآخر. هذا اختياري ومنفصل عن رسائل الحساب والفاتورة وكلمة المرور.",
+ "marketingLegal": "يمكنك الإيقاف في أي وقت. حملة سُلِّمت بالفعل إلى خدمة الإرسال لا يمكن استرجاعها.",
+ "marketingBlocked": "لا يمكن لهذا العنوان تلقّي الإعلانات (شكوى أو فشل تسليم نهائي).",
+ "settingsSaveFailed": "تعذّر حفظ هذا الاختيار.",
"cardSizeMode": "حجم الملاحظة",
"cardSizeModeDescription": "اختر بين أحجام متغيرة أو حجم موحد",
"selectCardSizeMode": "اختر وضع العرض",
diff --git a/memento-note/locales/de.json b/memento-note/locales/de.json
index 161a02c5..2db7d7d1 100644
--- a/memento-note/locales/de.json
+++ b/memento-note/locales/de.json
@@ -1,4 +1,16 @@
{
+ "unsubscribe": {
+ "title": "Neuigkeiten und Angebote beenden?",
+ "body": "Das beendet nur Ankündigungen und Angebote. Konto, Rechnung und Passwort bleiben. Bestätigen Sie mit der Schaltfläche — die Seite zu öffnen reicht nicht.",
+ "confirm": "Ankündigungen beenden",
+ "keep": "Weiterhin erhalten",
+ "successTitle": "Abgemeldet",
+ "successBody": "Sie erhalten an dieser Adresse keine Memento-Neuigkeiten und Angebote mehr. Unerlässliche Dienstnachrichten können weiterkommen.",
+ "invalidTitle": "Dieser Link gilt nicht",
+ "invalidBody": "Der Link fehlt oder ist veraltet. Sie können Ankündigungen auch in den Einstellungen beenden.",
+ "openSettings": "Einstellungen öffnen",
+ "backToSignIn": "Anmelden"
+ },
"auth": {
"signIn": "Anmelden",
"signUp": "Registrieren",
@@ -14,6 +26,7 @@
"forgotPassword": "Passwort vergessen?",
"noAccount": "Haben Sie kein Konto?",
"hasAccount": "Haben Sie bereits ein Konto?",
+ "marketingConsent": "Ich stimme zu, gelegentlich Neuigkeiten und Angebote von Memento zu erhalten (optional). Unerlässliche Kontonachrichten gehen weiter.",
"signInToAccount": "Melden Sie sich in Ihrem Konto an",
"createAccount": "Erstellen Sie Ihr Konto",
"rememberMe": "Angemeldet bleiben",
@@ -1031,6 +1044,11 @@
"about": "Über",
"version": "Version",
"settingsSaved": "Einstellungen gespeichert",
+ "marketingTitle": "Neuigkeiten und Angebote",
+ "marketingDescription": "Gelegentlich Neuigkeiten und Angebote von Memento. Freiwillig und getrennt von Konto-, Rechnungs- und Passwortnachrichten.",
+ "marketingLegal": "Sie können jederzeit aufhören. Eine bereits übergebene Kampagne lässt sich nicht zurückholen.",
+ "marketingBlocked": "Diese Adresse kann keine Ankündigungen empfangen (Beschwerde oder dauerhafter Zustellfehler).",
+ "settingsSaveFailed": "Diese Wahl konnte nicht gespeichert werden.",
"cardSizeMode": "Notizgröße",
"cardSizeModeDescription": "Wählen Sie zwischen variablen oder einheitlichen Größen",
"selectCardSizeMode": "Anzeigemodus auswählen",
diff --git a/memento-note/locales/en.json b/memento-note/locales/en.json
index 71d52149..c65634c3 100644
--- a/memento-note/locales/en.json
+++ b/memento-note/locales/en.json
@@ -1,4 +1,16 @@
{
+ "unsubscribe": {
+ "title": "Stop news and offers?",
+ "body": "This only stops announcements and offers. Account, billing, and password messages are unchanged. Confirm with the button — opening this page is not enough.",
+ "confirm": "Stop announcements",
+ "keep": "Keep receiving them",
+ "successTitle": "You are unsubscribed",
+ "successBody": "You will no longer receive Memento news and offers at this address. Essential service messages may still arrive.",
+ "invalidTitle": "This link is not valid",
+ "invalidBody": "The link is missing or outdated. You can also stop announcements in Settings.",
+ "openSettings": "Open settings",
+ "backToSignIn": "Sign in"
+ },
"auth": {
"signIn": "Sign In",
"signUp": "Sign Up",
@@ -14,6 +26,7 @@
"forgotPassword": "Forgot password?",
"noAccount": "Don't have an account?",
"hasAccount": "Already have an account?",
+ "marketingConsent": "I agree to receive occasional Memento news and offers (optional). Essential messages about my account will continue.",
"signInToAccount": "Sign in to your account",
"createAccount": "Create your account",
"rememberMe": "Remember me",
@@ -1092,6 +1105,11 @@
"about": "About",
"version": "Version",
"settingsSaved": "Settings saved",
+ "marketingTitle": "News and offers",
+ "marketingDescription": "Receive occasional Memento news and offers. This is optional and separate from account, billing, and password messages.",
+ "marketingLegal": "You can stop at any time. A campaign already handed to the mail service cannot be pulled back.",
+ "marketingBlocked": "This address cannot receive announcements (complaint or permanent delivery failure).",
+ "settingsSaveFailed": "Could not save this choice.",
"cardSizeMode": "Note Size",
"cardSizeModeDescription": "Choose between variable sizes or uniform size",
"selectCardSizeMode": "Select display mode",
diff --git a/memento-note/locales/es.json b/memento-note/locales/es.json
index aba9786f..9b4166e0 100644
--- a/memento-note/locales/es.json
+++ b/memento-note/locales/es.json
@@ -1,4 +1,16 @@
{
+ "unsubscribe": {
+ "title": "¿Dejar de recibir novedades y ofertas?",
+ "body": "Solo detiene anuncios y ofertas. Cuenta, factura y contraseña no cambian. Confirme con el botón: abrir esta página no basta.",
+ "confirm": "Dejar de recibir anuncios",
+ "keep": "Seguir recibiéndolos",
+ "successTitle": "Baja registrada",
+ "successBody": "Ya no recibirá novedades ni ofertas de Memento en esta dirección. Los mensajes imprescindibles del servicio pueden seguir llegando.",
+ "invalidTitle": "Este enlace no es válido",
+ "invalidBody": "Falta el enlace o ya no sirve. También puede dejar los anuncios en Ajustes.",
+ "openSettings": "Abrir ajustes",
+ "backToSignIn": "Iniciar sesión"
+ },
"auth": {
"signIn": "Iniciar sesión",
"signUp": "Registrarse",
@@ -14,6 +26,7 @@
"forgotPassword": "¿Olvidaste tu contraseña?",
"noAccount": "¿No tienes una cuenta?",
"hasAccount": "¿Ya tienes una cuenta?",
+ "marketingConsent": "Acepto recibir de vez en cuando novedades y ofertas de Memento (opcional). Los mensajes imprescindibles de la cuenta seguirán llegando.",
"signInToAccount": "Inicia sesión en tu cuenta",
"createAccount": "Crea tu cuenta",
"rememberMe": "Recordarme",
@@ -1031,6 +1044,11 @@
"about": "Acerca de",
"version": "Versión",
"settingsSaved": "Ajustes guardados",
+ "marketingTitle": "Novedades y ofertas",
+ "marketingDescription": "Recibir de vez en cuando novedades y ofertas de Memento. Es opcional y distinto de los mensajes de cuenta, factura y contraseña.",
+ "marketingLegal": "Puede dejarlo cuando quiera. Una campaña ya entregada al servicio de envío no se puede retirar.",
+ "marketingBlocked": "Esta dirección no puede recibir anuncios (queja o fallo definitivo de entrega).",
+ "settingsSaveFailed": "No se ha podido guardar esta elección.",
"cardSizeMode": "Tamaño de nota",
"cardSizeModeDescription": "Elige entre tamaños variables o tamaño uniforme",
"selectCardSizeMode": "Selecciona el modo de visualización",
diff --git a/memento-note/locales/fa.json b/memento-note/locales/fa.json
index 745bbeec..13fc7f66 100644
--- a/memento-note/locales/fa.json
+++ b/memento-note/locales/fa.json
@@ -1,4 +1,16 @@
{
+ "unsubscribe": {
+ "title": "اخبار و پیشنهادها متوقف شود؟",
+ "body": "فقط اعلامیهها و پیشنهادها قطع میشود. حساب، صورتحساب و گذرواژه عوض نمیشود. با دکمه تأیید کنید — باز کردن این صفحه کافی نیست.",
+ "confirm": "قطع اعلامیهها",
+ "keep": "دریافت ادامه یابد",
+ "successTitle": "لغو ثبت شد",
+ "successBody": "دیگر اخبار و پیشنهادهای Memento به این نشانی نمیرسد. پیامهای ضروری خدمت ممکن است همچنان برسند.",
+ "invalidTitle": "این پیوند معتبر نیست",
+ "invalidBody": "پیوند نیست یا کهنه است. از تنظیمات هم میتوانید اعلامیهها را قطع کنید.",
+ "openSettings": "باز کردن تنظیمات",
+ "backToSignIn": "ورود"
+ },
"auth": {
"signIn": "ورود",
"signUp": "ثبتنام",
@@ -14,6 +26,7 @@
"forgotPassword": "رمز عبور را فراموش کردهاید؟",
"noAccount": "حساب کاربری ندارید؟",
"hasAccount": "قبلاً ثبتنام کردهاید؟",
+ "marketingConsent": "میپذیرم گاهی اخبار و پیشنهادهای Memento را دریافت کنم (اختیاری). پیامهای ضروری حساب ادامه دارند.",
"signInToAccount": "به حساب کاربری خود وارد شوید",
"createAccount": "ایجاد حساب کاربری",
"rememberMe": "مرا به خاطر بسپار",
@@ -1031,6 +1044,11 @@
"about": "درباره",
"version": "نسخه",
"settingsSaved": "تنظیمات ذخیره شد",
+ "marketingTitle": "اخبار و پیشنهادها",
+ "marketingDescription": "گاهی اخبار و پیشنهادهای Memento را دریافت کنید. اجباری نیست و جدا از پیامهای حساب، صورتحساب و گذرواژه است.",
+ "marketingLegal": "هر زمان میتوانید متوقف کنید. کارزاری که به سرویس ارسال سپرده شده قابل بازگرداندن نیست.",
+ "marketingBlocked": "این نشانی نمیتواند اعلامیه دریافت کند (شکایت یا شکست قطعی تحویل).",
+ "settingsSaveFailed": "این انتخاب ذخیره نشد.",
"cardSizeMode": "اندازه یادداشت",
"cardSizeModeDescription": "انتخاب بین اندازه متغیر یا یکنواخت",
"selectCardSizeMode": "انتخاب حالت نمایش",
diff --git a/memento-note/locales/fr.json b/memento-note/locales/fr.json
index b66314b9..376b91f2 100644
--- a/memento-note/locales/fr.json
+++ b/memento-note/locales/fr.json
@@ -1,4 +1,16 @@
{
+ "unsubscribe": {
+ "title": "Arrêter les actualités et les offres ?",
+ "body": "Cela n’arrête que les annonces et les offres. Les messages de compte, de facture et de mot de passe restent. Confirmez avec le bouton — ouvrir cette page ne suffit pas.",
+ "confirm": "Arrêter les annonces",
+ "keep": "Continuer à les recevoir",
+ "successTitle": "C’est enregistré",
+ "successBody": "Vous ne recevrez plus les actualités et offres Memento à cette adresse. Les messages indispensables au service peuvent encore arriver.",
+ "invalidTitle": "Ce lien n’est pas valable",
+ "invalidBody": "Le lien manque ou n’est plus bon. Vous pouvez aussi arrêter les annonces dans les paramètres.",
+ "openSettings": "Ouvrir les paramètres",
+ "backToSignIn": "Se connecter"
+ },
"auth": {
"signIn": "Connexion",
"signUp": "S'inscrire",
@@ -14,6 +26,7 @@
"forgotPassword": "Mot de passe oublié ?",
"noAccount": "Pas de compte ?",
"hasAccount": "Déjà un compte ?",
+ "marketingConsent": "J’accepte de recevoir, de temps en temps, des actualités et des offres de Memento (facultatif). Les messages indispensables sur mon compte continueront.",
"signInToAccount": "Connectez-vous à votre compte",
"createAccount": "Créez votre compte",
"rememberMe": "Se souvenir de moi",
@@ -1098,6 +1111,11 @@
"about": "À propos",
"version": "Version",
"settingsSaved": "Paramètres enregistrés avec succès",
+ "marketingTitle": "Actualités et offres",
+ "marketingDescription": "Recevoir de temps en temps des nouvelles de Memento et des offres. Ce n’est pas obligatoire, et c’est distinct des messages de compte, de facture et de mot de passe.",
+ "marketingLegal": "Vous pouvez arrêter à tout moment. Une campagne déjà confiée au service d’envoi ne peut pas être rappelée.",
+ "marketingBlocked": "Cette adresse ne peut plus recevoir d’annonces (plainte ou échec définitif de livraison).",
+ "settingsSaveFailed": "Ce choix n’a pas pu être enregistré.",
"cardSizeMode": "Taille des notes",
"cardSizeModeDescription": "Choisir entre des notes de tailles différentes ou uniformes",
"selectCardSizeMode": "Sélectionner le mode d'affichage",
diff --git a/memento-note/locales/hi.json b/memento-note/locales/hi.json
index cdb7dc08..14934ffe 100644
--- a/memento-note/locales/hi.json
+++ b/memento-note/locales/hi.json
@@ -1,4 +1,16 @@
{
+ "unsubscribe": {
+ "title": "खबरें और प्रस्ताव रोकें?",
+ "body": "केवल घोषणाएँ और प्रस्ताव रुकते हैं। खाता, बिल, पासवर्ड नहीं बदलते। बटन से पुष्टि करें — पेज खोलना काफी नहीं।",
+ "confirm": "घोषणाएँ रोकें",
+ "keep": "इन्हें पाते रहें",
+ "successTitle": "सदस्यता रद्द",
+ "successBody": "इस पते पर Memento की खबरें और प्रस्ताव नहीं आएँगे। ज़रूरी सेवा पत्र आ सकते हैं।",
+ "invalidTitle": "यह लिंक मान्य नहीं",
+ "invalidBody": "लिंक नहीं है या पुराना है। सेटिंग में भी घोषणाएँ रोकी जा सकती हैं।",
+ "openSettings": "सेटिंग खोलें",
+ "backToSignIn": "साइन इन"
+ },
"auth": {
"signIn": "साइन इन करें",
"signUp": "साइन अप करें",
@@ -14,6 +26,7 @@
"forgotPassword": "पासवर्ड भूल गए?",
"noAccount": "खाता नहीं है?",
"hasAccount": "पहले से खाता है?",
+ "marketingConsent": "मैं कभी-कभी Memento की खबरें और प्रस्ताव पाने के लिए सहमत हूँ (वैकल्पिक)। खाते के ज़रूरी पत्र आते रहेंगे।",
"signInToAccount": "अपने खाते में साइन इन करें",
"createAccount": "अपना खाता बनाएं",
"rememberMe": "मुझे याद रखें",
@@ -1031,6 +1044,11 @@
"about": "के बारे में",
"version": "संस्करण",
"settingsSaved": "सेटिंग्स सहेजी गईं",
+ "marketingTitle": "खबरें और प्रस्ताव",
+ "marketingDescription": "कभी-कभी Memento की खबरें और प्रस्ताव पाएँ। यह ज़रूरी नहीं है और खाता, बिल, पासवर्ड के पत्रों से अलग है।",
+ "marketingLegal": "आप कभी भी रोक सकते हैं। भेजने वाली सेवा को सौंपी गई अभियान वापस नहीं ली जा सकती।",
+ "marketingBlocked": "यह पता घोषणाएँ नहीं पा सकता (शिकायत या स्थायी डिलीवरी विफलता)।",
+ "settingsSaveFailed": "यह चयन सहेजा नहीं जा सका।",
"cardSizeMode": "नोट आकार",
"cardSizeModeDescription": "परिवर्तनीय या समान आकार चुनें",
"selectCardSizeMode": "प्रदर्शन मोड चुनें",
diff --git a/memento-note/locales/it.json b/memento-note/locales/it.json
index 05d2aa28..f987f831 100644
--- a/memento-note/locales/it.json
+++ b/memento-note/locales/it.json
@@ -1,4 +1,16 @@
{
+ "unsubscribe": {
+ "title": "Interrompere novità e offerte?",
+ "body": "Si interrompono solo annunci e offerte. Account, fattura e password restano. Confermi con il pulsante: aprire questa pagina non basta.",
+ "confirm": "Interrompi gli annunci",
+ "keep": "Continua a riceverli",
+ "successTitle": "Iscrizione annullata",
+ "successBody": "Non riceverà più novità e offerte Memento a questo indirizzo. I messaggi indispensabili del servizio possono arrivare ancora.",
+ "invalidTitle": "Questo collegamento non è valido",
+ "invalidBody": "Manca il collegamento oppure non è più valido. Può interrompere gli annunci anche nelle impostazioni.",
+ "openSettings": "Apri le impostazioni",
+ "backToSignIn": "Accedi"
+ },
"auth": {
"signIn": "Accedi",
"signUp": "Registrati",
@@ -14,6 +26,7 @@
"forgotPassword": "Password dimenticata?",
"noAccount": "Non hai un account?",
"hasAccount": "Hai già un account?",
+ "marketingConsent": "Accetto di ricevere di tanto in tanto novità e offerte di Memento (facoltativo). I messaggi indispensabili sull’account continueranno.",
"signInToAccount": "Accedi al tuo account",
"createAccount": "Crea il tuo account",
"rememberMe": "Ricordami",
@@ -1031,6 +1044,11 @@
"about": "Informazioni",
"version": "Versione",
"settingsSaved": "Impostazioni salvate",
+ "marketingTitle": "Novità e offerte",
+ "marketingDescription": "Ricevere di tanto in tanto novità e offerte di Memento. Facoltativo e distinto dai messaggi di account, fattura e password.",
+ "marketingLegal": "Può interrompere in qualsiasi momento. Una campagna già affidata al servizio di invio non può essere richiamata.",
+ "marketingBlocked": "Questo indirizzo non può ricevere annunci (reclamo o errore definitivo di consegna).",
+ "settingsSaveFailed": "Non è stato possibile salvare questa scelta.",
"cardSizeMode": "Dimensione nota",
"cardSizeModeDescription": "Scegli tra dimensioni variabili o uniformi",
"selectCardSizeMode": "Seleziona modalità di visualizzazione",
diff --git a/memento-note/locales/ja.json b/memento-note/locales/ja.json
index a71b5a93..76896f91 100644
--- a/memento-note/locales/ja.json
+++ b/memento-note/locales/ja.json
@@ -1,4 +1,16 @@
{
+ "unsubscribe": {
+ "title": "近況と特典を止めますか?",
+ "body": "止まるのは案内と特典だけです。アカウント、請求、パスワードは変わりません。ボタンで確定してください。このページを開いただけでは止まりません。",
+ "confirm": "案内を止める",
+ "keep": "受け取りを続ける",
+ "successTitle": "配信を止めました",
+ "successBody": "この宛先に Memento の近況や特典は届かなくなります。必要なサービス連絡は続くことがあります。",
+ "invalidTitle": "このリンクは使えません",
+ "invalidBody": "リンクがないか、古くなっています。設定からも案内を止められます。",
+ "openSettings": "設定を開く",
+ "backToSignIn": "ログイン"
+ },
"auth": {
"signIn": "ログイン",
"signUp": "新規登録",
@@ -14,6 +26,7 @@
"forgotPassword": "パスワードをお忘れですか?",
"noAccount": "アカウントをお持ちでない方",
"hasAccount": "すでにアカウントをお持ちの方",
+ "marketingConsent": "Memento の近況や特典をときどき受け取ることに同意します(任意)。アカウントに必要な連絡は続きます。",
"signInToAccount": "アカウントにログイン",
"createAccount": "アカウントを作成",
"rememberMe": "ログイン状態を保持",
@@ -1031,6 +1044,11 @@
"about": "について",
"version": "バージョン",
"settingsSaved": "設定を保存しました",
+ "marketingTitle": "近況と特典",
+ "marketingDescription": "Memento の近況や特典をときどき受け取ります。任意で、アカウント・請求・パスワードの連絡とは別です。",
+ "marketingLegal": "いつでも止められます。すでに送信サービスへ渡した案内は取り下げできません。",
+ "marketingBlocked": "この宛先は案内を受け取れません(苦情または配達の確定失敗)。",
+ "settingsSaveFailed": "この選択を保存できませんでした。",
"cardSizeMode": "ノートサイズ",
"cardSizeModeDescription": "可変サイズまたは均一サイズを選択",
"selectCardSizeMode": "表示モードを選択",
diff --git a/memento-note/locales/ko.json b/memento-note/locales/ko.json
index c5d3f109..7bcb7916 100644
--- a/memento-note/locales/ko.json
+++ b/memento-note/locales/ko.json
@@ -1,4 +1,16 @@
{
+ "unsubscribe": {
+ "title": "소식과 혜택을 멈출까요?",
+ "body": "안내와 혜택만 멈춥니다. 계정, 청구, 비밀번호는 그대로입니다. 버튼으로 확인하세요. 이 페이지만 열어서는 멈추지 않습니다.",
+ "confirm": "안내 멈추기",
+ "keep": "계속 받기",
+ "successTitle": "수신이 중단되었습니다",
+ "successBody": "이 주소로는 더 이상 Memento 소식과 혜택이 오지 않습니다. 꼭 필요한 서비스 메일은 올 수 있습니다.",
+ "invalidTitle": "이 링크는 유효하지 않습니다",
+ "invalidBody": "링크가 없거나 오래되었습니다. 설정에서도 안내를 멈출 수 있습니다.",
+ "openSettings": "설정 열기",
+ "backToSignIn": "로그인"
+ },
"auth": {
"signIn": "로그인",
"signUp": "회원가입",
@@ -14,6 +26,7 @@
"forgotPassword": "비밀번호를 잊으셨나요?",
"noAccount": "계정이 없으신가요?",
"hasAccount": "이미 계정이 있으신가요?",
+ "marketingConsent": "Memento 소식과 혜택을 가끔 받는 데 동의합니다(선택). 계정에 꼭 필요한 메일은 계속 옵니다.",
"signInToAccount": "계정에 로그인하세요",
"createAccount": "계정 만들기",
"rememberMe": "로그인 상태 유지",
@@ -1031,6 +1044,11 @@
"about": "정보",
"version": "버전",
"settingsSaved": "설정이 저장되었습니다",
+ "marketingTitle": "소식과 혜택",
+ "marketingDescription": "Memento 소식과 혜택을 가끔 받습니다. 필수는 아니며 계정, 청구, 비밀번호 메일과는 다릅니다.",
+ "marketingLegal": "언제든 멈출 수 있습니다. 이미 발송 서비스에 넘긴 안내는 거둘 수 없습니다.",
+ "marketingBlocked": "이 주소는 안내를 받을 수 없습니다(신고 또는 영구 배달 실패).",
+ "settingsSaveFailed": "이 선택을 저장하지 못했습니다.",
"cardSizeMode": "노트 크기",
"cardSizeModeDescription": "가변 크기 또는 균일한 크기中选择",
"selectCardSizeMode": "표시 모드 선택",
diff --git a/memento-note/locales/nl.json b/memento-note/locales/nl.json
index 50bfc11d..443e0f87 100644
--- a/memento-note/locales/nl.json
+++ b/memento-note/locales/nl.json
@@ -1,4 +1,16 @@
{
+ "unsubscribe": {
+ "title": "Nieuws en aanbiedingen stoppen?",
+ "body": "Dit stopt alleen aankondigingen en aanbiedingen. Account, factuur en wachtwoord blijven. Bevestig met de knop — deze pagina openen volstaat niet.",
+ "confirm": "Aankondigingen stoppen",
+ "keep": "Blijven ontvangen",
+ "successTitle": "Uitgeschreven",
+ "successBody": "U ontvangt op dit adres geen Memento-nieuws en aanbiedingen meer. Essentiële dienstberichten kunnen nog komen.",
+ "invalidTitle": "Deze koppeling is ongeldig",
+ "invalidBody": "De koppeling ontbreekt of is verouderd. U kunt aankondigingen ook in Instellingen stoppen.",
+ "openSettings": "Instellingen openen",
+ "backToSignIn": "Aanmelden"
+ },
"auth": {
"signIn": "Inloggen",
"signUp": "Registreren",
@@ -14,6 +26,7 @@
"forgotPassword": "Wachtwoord vergeten?",
"noAccount": "Heeft u geen account?",
"hasAccount": "Heeft u al een account?",
+ "marketingConsent": "Ik ga akkoord om af en toe nieuws en aanbiedingen van Memento te ontvangen (optioneel). Essentiële berichten over mijn account blijven komen.",
"signInToAccount": "Log in op uw account",
"createAccount": "Maak uw account",
"rememberMe": "Onthoud mij",
@@ -1031,6 +1044,11 @@
"about": "Over",
"version": "Versie",
"settingsSaved": "Instellingen opgeslagen",
+ "marketingTitle": "Nieuws en aanbiedingen",
+ "marketingDescription": "Af en toe nieuws en aanbiedingen van Memento. Optioneel en gescheiden van account-, factuur- en wachtwoordberichten.",
+ "marketingLegal": "U kunt dit altijd stopzetten. Een campagne die al bij de verzenddienst ligt, kan niet worden teruggehaald.",
+ "marketingBlocked": "Dit adres kan geen aankondigingen ontvangen (klacht of blijvende bezorgfout).",
+ "settingsSaveFailed": "Deze keuze kon niet worden opgeslagen.",
"cardSizeMode": "Notitiegrootte",
"cardSizeModeDescription": "Kies tussen variabele of uniforme groottes",
"selectCardSizeMode": "Weergavemodus selecteren",
diff --git a/memento-note/locales/pl.json b/memento-note/locales/pl.json
index 6204ed99..a5397779 100644
--- a/memento-note/locales/pl.json
+++ b/memento-note/locales/pl.json
@@ -1,4 +1,16 @@
{
+ "unsubscribe": {
+ "title": "Zatrzymać aktualności i oferty?",
+ "body": "To wstrzymuje tylko ogłoszenia i oferty. Konto, rachunek i hasło zostają. Potwierdź przyciskiem — samo otwarcie strony nie wystarczy.",
+ "confirm": "Zatrzymaj ogłoszenia",
+ "keep": "Nadal je otrzymuj",
+ "successTitle": "Wypisano",
+ "successBody": "Nie będziesz już otrzymywać aktualności i ofert Memento na ten adres. Niezbędne wiadomości usługi mogą nadal przychodzić.",
+ "invalidTitle": "Ten odnośnik jest nieważny",
+ "invalidBody": "Brakuje odnośnika albo jest nieaktualny. Ogłoszenia możesz też zatrzymać w ustawieniach.",
+ "openSettings": "Otwórz ustawienia",
+ "backToSignIn": "Zaloguj się"
+ },
"auth": {
"signIn": "Zaloguj się",
"signUp": "Zarejestruj się",
@@ -14,6 +26,7 @@
"forgotPassword": "Zapomniałeś hasła?",
"noAccount": "Nie masz konta?",
"hasAccount": "Masz już konto?",
+ "marketingConsent": "Wyrażam zgodę na okazjonalne wiadomości i oferty Memento (opcjonalnie). Niezbędne wiadomości o koncie będą nadal wysyłane.",
"signInToAccount": "Zaloguj się na swoje konto",
"createAccount": "Utwórz swoje konto",
"rememberMe": "Zapamiętaj mnie",
@@ -1031,6 +1044,11 @@
"about": "O aplikacji",
"version": "Wersja",
"settingsSaved": "Ustawienia zapisane",
+ "marketingTitle": "Aktualności i oferty",
+ "marketingDescription": "Okazjonalne aktualności i oferty Memento. To nieobowiązkowe i osobne od wiadomości o koncie, rachunku i haśle.",
+ "marketingLegal": "Możesz zrezygnować w każdej chwili. Kampanii już przekazanej usłudze wysyłki nie da się wycofać.",
+ "marketingBlocked": "Ten adres nie może otrzymywać ogłoszeń (skarga lub trwały błąd doręczenia).",
+ "settingsSaveFailed": "Nie udało się zapisać tego wyboru.",
"cardSizeMode": "Rozmiar notatki",
"cardSizeModeDescription": "Wybierz zmienne lub jednolite rozmiary",
"selectCardSizeMode": "Wybierz tryb wyświetlania",
diff --git a/memento-note/locales/pt.json b/memento-note/locales/pt.json
index 9defcbe4..a2deeadd 100644
--- a/memento-note/locales/pt.json
+++ b/memento-note/locales/pt.json
@@ -1,4 +1,16 @@
{
+ "unsubscribe": {
+ "title": "Parar novidades e ofertas?",
+ "body": "Isto só pára anúncios e ofertas. Conta, fatura e palavra-passe ficam. Confirme com o botão — abrir esta página não chega.",
+ "confirm": "Parar os anúncios",
+ "keep": "Continuar a recebê-los",
+ "successTitle": "Anulação registada",
+ "successBody": "Deixará de receber novidades e ofertas da Memento neste endereço. As mensagens indispensáveis do serviço podem continuar a chegar.",
+ "invalidTitle": "Esta ligação não é válida",
+ "invalidBody": "Falta a ligação ou já não serve. Também pode parar os anúncios nas definições.",
+ "openSettings": "Abrir definições",
+ "backToSignIn": "Iniciar sessão"
+ },
"auth": {
"signIn": "Entrar",
"signUp": "Cadastrar-se",
@@ -14,6 +26,7 @@
"forgotPassword": "Esqueceu sua senha?",
"noAccount": "Não tem uma conta?",
"hasAccount": "Já tem uma conta?",
+ "marketingConsent": "Aceito receber de vez em quando novidades e ofertas da Memento (opcional). As mensagens indispensáveis da conta continuam.",
"signInToAccount": "Entre na sua conta",
"createAccount": "Crie sua conta",
"rememberMe": "Lembrar-me",
@@ -1031,6 +1044,11 @@
"about": "Sobre",
"version": "Versão",
"settingsSaved": "Definições guardadas",
+ "marketingTitle": "Novidades e ofertas",
+ "marketingDescription": "Receber de vez em quando novidades e ofertas da Memento. É opcional e distinto das mensagens de conta, fatura e palavra-passe.",
+ "marketingLegal": "Pode parar a qualquer momento. Uma campanha já entregue ao serviço de envio não pode ser retirada.",
+ "marketingBlocked": "Este endereço não pode receber anúncios (queixa ou falha definitiva de entrega).",
+ "settingsSaveFailed": "Não foi possível guardar esta escolha.",
"cardSizeMode": "Tamanho da nota",
"cardSizeModeDescription": "Escolha entre tamanhos variáveis ou uniformes",
"selectCardSizeMode": "Selecione o modo de exibição",
diff --git a/memento-note/locales/ru.json b/memento-note/locales/ru.json
index 86ab21b8..eb0ba5cd 100644
--- a/memento-note/locales/ru.json
+++ b/memento-note/locales/ru.json
@@ -1,4 +1,16 @@
{
+ "unsubscribe": {
+ "title": "Отключить новости и предложения?",
+ "body": "Это останавливает только объявления и предложения. Аккаунт, счёт и пароль не меняются. Подтвердите кнопкой — открыть страницу недостаточно.",
+ "confirm": "Отключить объявления",
+ "keep": "Продолжить получать",
+ "successTitle": "Отписка оформлена",
+ "successBody": "На этот адрес больше не будут приходить новости и предложения Memento. Обязательные служебные письма могут продолжить приходить.",
+ "invalidTitle": "Эта ссылка недействительна",
+ "invalidBody": "Ссылки нет или она устарела. Объявления можно отключить и в настройках.",
+ "openSettings": "Открыть настройки",
+ "backToSignIn": "Войти"
+ },
"auth": {
"signIn": "Войти",
"signUp": "Зарегистрироваться",
@@ -14,6 +26,7 @@
"forgotPassword": "Забыли пароль?",
"noAccount": "Нет аккаунта?",
"hasAccount": "Уже есть аккаунт?",
+ "marketingConsent": "Я соглашаюсь иногда получать новости и предложения Memento (по желанию). Обязательные письма об аккаунте продолжат приходить.",
"signInToAccount": "Войдите в свой аккаунт",
"createAccount": "Создайте свой аккаунт",
"rememberMe": "Запомнить меня",
@@ -1031,6 +1044,11 @@
"about": "О программе",
"version": "Версия",
"settingsSaved": "Настройки сохранены",
+ "marketingTitle": "Новости и предложения",
+ "marketingDescription": "Иногда получать новости и предложения Memento. Это необязательно и отдельно от писем об аккаунте, счёте и пароле.",
+ "marketingLegal": "Можно отказаться в любой момент. Кампанию, уже переданную службе отправки, отозвать нельзя.",
+ "marketingBlocked": "Этот адрес не может получать объявления (жалоба или окончательная ошибка доставки).",
+ "settingsSaveFailed": "Не удалось сохранить этот выбор.",
"cardSizeMode": "Размер заметки",
"cardSizeModeDescription": "Выберите переменные или одинаковые размеры",
"selectCardSizeMode": "Выберите режим отображения",
diff --git a/memento-note/locales/zh.json b/memento-note/locales/zh.json
index 12637e0a..5cc81c30 100644
--- a/memento-note/locales/zh.json
+++ b/memento-note/locales/zh.json
@@ -1,4 +1,16 @@
{
+ "unsubscribe": {
+ "title": "停止近况和优惠?",
+ "body": "这只停止公告和优惠。账户、账单、密码邮件不变。请用按钮确认——打开本页不够。",
+ "confirm": "停止公告",
+ "keep": "继续接收",
+ "successTitle": "已取消订阅",
+ "successBody": "此地址将不再收到 Memento 近况和优惠。必要的服务邮件仍可能到达。",
+ "invalidTitle": "此链接无效",
+ "invalidBody": "链接缺失或已过期。也可以在设置中停止公告。",
+ "openSettings": "打开设置",
+ "backToSignIn": "登录"
+ },
"auth": {
"signIn": "登录",
"signUp": "注册",
@@ -14,6 +26,7 @@
"forgotPassword": "忘记密码?",
"noAccount": "没有账户?",
"hasAccount": "已有账户?",
+ "marketingConsent": "我同意偶尔接收 Memento 的近况和优惠(可选)。账户必要邮件仍会发送。",
"signInToAccount": "登录您的账户",
"createAccount": "创建您的账户",
"rememberMe": "记住我",
@@ -1031,6 +1044,11 @@
"about": "关于",
"version": "版本",
"settingsSaved": "设置已保存",
+ "marketingTitle": "近况与优惠",
+ "marketingDescription": "偶尔接收 Memento 的近况和优惠。可选,并与账户、账单、密码邮件分开。",
+ "marketingLegal": "您可以随时停止。已经交给发送服务的活动无法撤回。",
+ "marketingBlocked": "此地址无法接收公告(投诉或永久投递失败)。",
+ "settingsSaveFailed": "无法保存此选择。",
"cardSizeMode": "笔记大小",
"cardSizeModeDescription": "选择可变大小或统一大小",
"selectCardSizeMode": "选择显示模式",
diff --git a/memento-note/prisma/migrations/20260906120000_marketing_consent_exclusions/migration.sql b/memento-note/prisma/migrations/20260906120000_marketing_consent_exclusions/migration.sql
new file mode 100644
index 00000000..22d7b34a
--- /dev/null
+++ b/memento-note/prisma/migrations/20260906120000_marketing_consent_exclusions/migration.sql
@@ -0,0 +1,61 @@
+-- Additive marketing consent and suppression. No existing user is opted in.
+
+CREATE TABLE "MarketingPreference" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "emailNormalized" TEXT NOT NULL,
+ "optedIn" BOOLEAN NOT NULL DEFAULT false,
+ "preferredLanguage" TEXT,
+ "consentVersion" TEXT,
+ "consentText" TEXT,
+ "source" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "MarketingPreference_pkey" PRIMARY KEY ("id")
+);
+
+CREATE UNIQUE INDEX "MarketingPreference_userId_key" ON "MarketingPreference"("userId");
+CREATE INDEX "MarketingPreference_emailNormalized_idx" ON "MarketingPreference"("emailNormalized");
+CREATE INDEX "MarketingPreference_optedIn_idx" ON "MarketingPreference"("optedIn");
+
+ALTER TABLE "MarketingPreference" ADD CONSTRAINT "MarketingPreference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+CREATE TABLE "MarketingPreferenceEvent" (
+ "id" TEXT NOT NULL,
+ "preferenceId" TEXT NOT NULL,
+ "userId" TEXT,
+ "emailNormalized" TEXT NOT NULL,
+ "optedIn" BOOLEAN NOT NULL,
+ "source" TEXT NOT NULL,
+ "consentVersion" TEXT,
+ "consentText" TEXT,
+ "ip" TEXT,
+ "userAgent" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "MarketingPreferenceEvent_pkey" PRIMARY KEY ("id")
+);
+
+CREATE INDEX "MarketingPreferenceEvent_preferenceId_idx" ON "MarketingPreferenceEvent"("preferenceId");
+CREATE INDEX "MarketingPreferenceEvent_userId_createdAt_idx" ON "MarketingPreferenceEvent"("userId", "createdAt");
+CREATE INDEX "MarketingPreferenceEvent_emailNormalized_createdAt_idx" ON "MarketingPreferenceEvent"("emailNormalized", "createdAt");
+
+ALTER TABLE "MarketingPreferenceEvent" ADD CONSTRAINT "MarketingPreferenceEvent_preferenceId_fkey" FOREIGN KEY ("preferenceId") REFERENCES "MarketingPreference"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+CREATE TABLE "EmailSuppression" (
+ "id" TEXT NOT NULL,
+ "emailNormalized" TEXT NOT NULL,
+ "reason" TEXT NOT NULL,
+ "source" TEXT NOT NULL,
+ "userId" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "EmailSuppression_pkey" PRIMARY KEY ("id")
+);
+
+CREATE UNIQUE INDEX "EmailSuppression_emailNormalized_key" ON "EmailSuppression"("emailNormalized");
+CREATE INDEX "EmailSuppression_reason_idx" ON "EmailSuppression"("reason");
+CREATE INDEX "EmailSuppression_userId_idx" ON "EmailSuppression"("userId");
+
+ALTER TABLE "EmailSuppression" ADD CONSTRAINT "EmailSuppression_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/memento-note/prisma/schema.prisma b/memento-note/prisma/schema.prisma
index fa544b3f..9af9f609 100644
--- a/memento-note/prisma/schema.prisma
+++ b/memento-note/prisma/schema.prisma
@@ -54,6 +54,8 @@ model User {
usageLogs UsageLog[]
apiKeys UserAPIKey[]
aiConsentLogs AiConsentLog[]
+ marketingPreference MarketingPreference?
+ emailSuppressions EmailSuppression[]
noteClusters NoteCluster[]
bridgeNotes BridgeNote[]
bridgeSuggestions BridgeSuggestion[]
@@ -1124,3 +1126,55 @@ model ErrorLog {
@@index([createdAt])
@@index([resolved])
}
+
+/// Explicit marketing announcements opt-in. Missing row = not opted in.
+model MarketingPreference {
+ id String @id @default(cuid())
+ userId String @unique
+ emailNormalized String
+ optedIn Boolean @default(false)
+ preferredLanguage String?
+ consentVersion String?
+ consentText String? @db.Text
+ source String
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ events MarketingPreferenceEvent[]
+
+ @@index([emailNormalized])
+ @@index([optedIn])
+}
+
+model MarketingPreferenceEvent {
+ id String @id @default(cuid())
+ preferenceId String
+ userId String?
+ emailNormalized String
+ optedIn Boolean
+ source String
+ consentVersion String?
+ consentText String? @db.Text
+ ip String?
+ userAgent String?
+ createdAt DateTime @default(now())
+ preference MarketingPreference @relation(fields: [preferenceId], references: [id], onDelete: Cascade)
+
+ @@index([preferenceId])
+ @@index([userId, createdAt])
+ @@index([emailNormalized, createdAt])
+}
+
+/// Blocks campaign mail. Kept after account deletion (userId set null).
+model EmailSuppression {
+ id String @id @default(cuid())
+ emailNormalized String @unique
+ reason String
+ source String
+ userId String?
+ createdAt DateTime @default(now())
+ user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
+
+ @@index([reason])
+ @@index([userId])
+}