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
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>
61 lines
2.1 KiB
Python
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)
|