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('unsubscribe.successTitle')}

+

+ {t('unsubscribe.successBody')} +

+
+ + {t('unsubscribe.backToSignIn')} + +
+ ) + } + + if (status === 'invalid') { + return ( +
+
+ +
+
+

{t('unsubscribe.invalidTitle')}

+

+ {t('unsubscribe.invalidBody')} +

+
+ + {t('unsubscribe.openSettings')} + +
+ ) + } + + return ( +
+
+ +
+
+

{t('unsubscribe.title')}

+

+ {t('unsubscribe.body')} +

+
+
{ + e.preventDefault() + void confirm() + }} + className="space-y-3" + > + + + {t('unsubscribe.keep')} + +
+
+ ) +} + +export default function UnsubscribePage() { + return ( + + + + ) +} diff --git a/memento-note/app/(main)/settings/general/general-settings-client.tsx b/memento-note/app/(main)/settings/general/general-settings-client.tsx index dae77911..c93db1b4 100644 --- a/memento-note/app/(main)/settings/general/general-settings-client.tsx +++ b/memento-note/app/(main)/settings/general/general-settings-client.tsx @@ -6,7 +6,8 @@ import { useLanguage } from '@/lib/i18n' import { updateAISettings } from '@/app/actions/ai-settings' import { toast } from 'sonner' import { useRouter, useSearchParams } from 'next/navigation' -import { Globe, Bell, Shield, Brain, HelpCircle } from 'lucide-react' +import { Globe, Bell, Shield, Brain, HelpCircle, Mail } from 'lucide-react' +import { updateMyMarketingPreference } from '@/app/actions/marketing-preference' import { motion } from 'motion/react' import { openCookiePreferences } from '@/lib/consent/cookie-consent' import { useAiConsent } from '@/components/legal/ai-consent-provider' @@ -21,10 +22,14 @@ interface GeneralSettingsClientProps { desktopNotifications: boolean autoSave: boolean } + initialMarketing?: { + optedIn: boolean + blocked: boolean + } } -export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClientProps) { - const { t, setLanguage: setContextLanguage } = useLanguage() +export function GeneralSettingsClient({ initialSettings, initialMarketing }: GeneralSettingsClientProps) { + const { t, language: uiLanguage, setLanguage: setContextLanguage } = useLanguage() const { hasAiConsent, revokeConsent, requestAiConsent } = useAiConsent() const router = useRouter() const searchParams = useSearchParams() @@ -45,6 +50,9 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient const [emailNotifications, setEmailNotifications] = useState(initialSettings.emailNotifications ?? false) const [desktopNotifications, setDesktopNotifications] = useState(initialSettings.desktopNotifications ?? false) const [autoSave, setAutoSave] = useState(initialSettings.autoSave ?? true) + const [marketingOptIn, setMarketingOptIn] = useState(initialMarketing?.optedIn === true) + const [marketingBlocked] = useState(initialMarketing?.blocked === true) + const [marketingSaving, setMarketingSaving] = useState(false) const handleLanguageChange = async (value: string) => { setLanguage(value) @@ -80,6 +88,20 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient toast.success(t('settings.settingsSaved')) } + const handleMarketingChange = async (enabled: boolean) => { + if (marketingBlocked) return + setMarketingSaving(true) + setMarketingOptIn(enabled) + const result = await updateMyMarketingPreference(enabled, uiLanguage) + setMarketingSaving(false) + if (!result.ok) { + setMarketingOptIn(!enabled) + toast.error(t(result.error === 'blocked' ? 'settings.marketingBlocked' : 'settings.settingsSaveFailed')) + return + } + toast.success(t('settings.settingsSaved')) + } + return ( +
+
+
+
+ +
+
+

{t('settings.marketingTitle')}

+

+ {t('settings.marketingDescription')} +

+
+
+
+
) } 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]) +}