nordabiz/utils/unsubscribe_tokens.py
Maciej Pienczyn dcbf8b5db6
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
feat(email): one-click unsubscribe w mailach powiadomień (RFC 8058)
Każdy e-mail powiadomieniowy ma teraz:
(1) link w stopce "Wyłącz ten typ powiadomień jednym kliknięciem"
(2) nagłówki List-Unsubscribe + List-Unsubscribe-Post dla klientów
    pocztowych (Gmail/Apple Mail pokażą natywny przycisk Unsubscribe)

Implementacja:
- utils/unsubscribe_tokens.py: signed token (itsdangerous, SECRET_KEY)
  niosący user_id + notification_type, bez wygasania
- blueprints/unsubscribe: GET /unsubscribe?t=TOKEN → strona potwierdzenia,
  POST /unsubscribe → faktyczne wyłączenie flagi notify_email_<type>
- email_service.send_email() dostał parametr notification_type. Jeśli
  przekazany razem z user_id, footer + headery są doklejane
- Aktualizowane wywołania: message_notification (messages),
  classified_question/answer (B2B Q&A), classified_expiry (skrypt cron)

Prefetch safety: GET pokazuje stronę z przyciskiem "Tak, wyłącz",
wyłączenie następuje po POST. RFC 8058 One-Click (POST bez formularza
z Content-Type application/x-www-form-urlencoded + body
"List-Unsubscribe=One-Click") obsługuje klientów pocztowych.

D.2/D.3 dorzucą kolejne notification_type (forum, broadcast, events).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 17:56:36 +02:00

61 lines
2.1 KiB
Python

"""Signed tokens do one-click unsubscribe z maili powiadomień.
Token niesie user_id + notification_type + podpis HMAC (przez itsdangerous).
Bez wygasania — user może kliknąć stary mail po tygodniach.
URL: /unsubscribe?t=<token>
"""
import os
from itsdangerous import URLSafeSerializer, BadSignature
# Mapa: notification_type -> nazwa kolumny User
NOTIFICATION_TYPE_TO_COLUMN = {
'messages': 'notify_email_messages',
'classified_question': 'notify_email_classified_question',
'classified_answer': 'notify_email_classified_answer',
'classified_expiry': 'notify_email_classified_expiry',
# D.2/D.3 dorzucą: forum_reply, forum_quote, announcements,
# board_meetings, event_invites, event_reminders
}
# Friendly labels dla strony potwierdzenia
NOTIFICATION_TYPE_LABELS = {
'messages': 'Nowe wiadomości prywatne',
'classified_question': 'Pytanie do Twojego ogłoszenia B2B',
'classified_answer': 'Odpowiedź na Twoje pytanie B2B',
'classified_expiry': 'Przypomnienie o wygasającym ogłoszeniu B2B',
}
def _serializer():
secret = os.getenv('SECRET_KEY') or os.getenv('FLASK_SECRET_KEY') or 'nordabiz-dev-fallback'
return URLSafeSerializer(secret, salt='unsub-v1')
def generate_unsubscribe_token(user_id: int, notification_type: str) -> str:
if notification_type not in NOTIFICATION_TYPE_TO_COLUMN:
raise ValueError(f"Unknown notification_type: {notification_type}")
return _serializer().dumps({'u': user_id, 't': notification_type})
def verify_unsubscribe_token(token: str):
"""Return (user_id: int, notification_type: str) or None if invalid."""
try:
payload = _serializer().loads(token)
uid = int(payload.get('u', 0))
ntype = payload.get('t', '')
if not uid or ntype not in NOTIFICATION_TYPE_TO_COLUMN:
return None
return uid, ntype
except (BadSignature, ValueError, TypeError):
return None
def column_for_type(notification_type: str):
return NOTIFICATION_TYPE_TO_COLUMN.get(notification_type)
def label_for_type(notification_type: str):
return NOTIFICATION_TYPE_LABELS.get(notification_type, notification_type)