feat(email): one-click unsubscribe w mailach powiadomień (RFC 8058)
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
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>
This commit is contained in:
parent
3f1e66d3ca
commit
dcbf8b5db6
@ -51,6 +51,18 @@ def register_blueprints(app):
|
||||
except Exception as e:
|
||||
logger.error(f"Error registering push blueprint: {e}")
|
||||
|
||||
# Unsubscribe blueprint (one-click e-mail unsubscribe, signed tokens)
|
||||
try:
|
||||
from blueprints.unsubscribe import bp as unsub_bp
|
||||
from blueprints.unsubscribe.routes import exempt_from_csrf as unsub_exempt
|
||||
app.register_blueprint(unsub_bp)
|
||||
unsub_exempt(app)
|
||||
logger.info("Registered blueprint: unsubscribe (with CSRF exemption)")
|
||||
except ImportError as e:
|
||||
logger.debug(f"Blueprint unsubscribe not yet available: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error registering unsubscribe blueprint: {e}")
|
||||
|
||||
# Community blueprints - register directly (not nested)
|
||||
# to preserve endpoint names like 'calendar_index' instead of 'community.calendar.calendar_index'
|
||||
try:
|
||||
|
||||
@ -638,7 +638,8 @@ def ask_question(classified_id):
|
||||
if author and author.email and author.notify_email_classified_question is not False:
|
||||
send_classified_question_email(
|
||||
classified_id, classified.title, questioner_name, content,
|
||||
author.email, author.name or author.email.split('@')[0])
|
||||
author.email, author.name or author.email.split('@')[0],
|
||||
author_id=author.id)
|
||||
# Web Push (opt-in via notify_push_classified_question)
|
||||
if author and author.notify_push_classified_question is not False:
|
||||
from blueprints.push.push_service import send_push
|
||||
@ -723,7 +724,8 @@ def answer_question(classified_id, question_id):
|
||||
if q_author and q_author.email and q_author.notify_email_classified_answer is not False:
|
||||
send_classified_answer_email(
|
||||
classified_id, classified.title, answerer_name, answer,
|
||||
q_author.email, q_author.name or q_author.email.split('@')[0])
|
||||
q_author.email, q_author.name or q_author.email.split('@')[0],
|
||||
questioner_id=q_author.id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to send classified answer notification: {e}")
|
||||
|
||||
|
||||
@ -313,6 +313,7 @@ def _send_message_email_notifications(db, conv_id, conversation, message, conten
|
||||
email_type='message_notification',
|
||||
user_id=user.id,
|
||||
recipient_name=user.name,
|
||||
notification_type='messages',
|
||||
)
|
||||
|
||||
except ImportError:
|
||||
|
||||
6
blueprints/unsubscribe/__init__.py
Normal file
6
blueprints/unsubscribe/__init__.py
Normal file
@ -0,0 +1,6 @@
|
||||
"""One-click unsubscribe blueprint — /unsubscribe."""
|
||||
from flask import Blueprint
|
||||
|
||||
bp = Blueprint('unsubscribe', __name__)
|
||||
|
||||
from . import routes # noqa: E402, F401
|
||||
91
blueprints/unsubscribe/routes.py
Normal file
91
blueprints/unsubscribe/routes.py
Normal file
@ -0,0 +1,91 @@
|
||||
"""Unsubscribe endpoints — bez logowania, weryfikacja przez signed token.
|
||||
|
||||
GET /unsubscribe?t=<token> — strona potwierdzenia (klik z maila)
|
||||
POST /unsubscribe — faktyczne wyłączenie (formularz lub
|
||||
List-Unsubscribe-Post z klienta pocztowego)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from flask import request, render_template, jsonify, abort
|
||||
|
||||
from database import SessionLocal, User
|
||||
from utils.unsubscribe_tokens import (
|
||||
verify_unsubscribe_token,
|
||||
column_for_type,
|
||||
label_for_type,
|
||||
)
|
||||
from . import bp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _apply_unsubscribe(token: str):
|
||||
"""Apply the unsubscribe. Returns (ok: bool, label: str | None, user_email: str | None)."""
|
||||
parsed = verify_unsubscribe_token(token)
|
||||
if not parsed:
|
||||
return False, None, None
|
||||
user_id, ntype = parsed
|
||||
col = column_for_type(ntype)
|
||||
if not col:
|
||||
return False, None, None
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user or not hasattr(user, col):
|
||||
return False, None, None
|
||||
setattr(user, col, False)
|
||||
db.commit()
|
||||
logger.info("unsubscribe applied user=%s type=%s", user_id, ntype)
|
||||
return True, label_for_type(ntype), user.email
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error("unsubscribe error: %s", e)
|
||||
return False, None, None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/unsubscribe', methods=['GET'])
|
||||
def unsubscribe_view():
|
||||
"""Strona potwierdzenia (lub od razu wyłączenie dla List-Unsubscribe-Post)."""
|
||||
token = request.args.get('t', '').strip()
|
||||
parsed = verify_unsubscribe_token(token) if token else None
|
||||
if not parsed:
|
||||
return render_template('unsubscribe.html',
|
||||
status='invalid',
|
||||
label=None,
|
||||
token=None), 400
|
||||
user_id, ntype = parsed
|
||||
return render_template('unsubscribe.html',
|
||||
status='confirm',
|
||||
label=label_for_type(ntype),
|
||||
token=token)
|
||||
|
||||
|
||||
@bp.route('/unsubscribe', methods=['POST'])
|
||||
def unsubscribe_apply():
|
||||
"""Faktyczne wyłączenie — formularz z GET lub RFC 8058 One-Click."""
|
||||
token = (request.form.get('t') or request.args.get('t') or '').strip()
|
||||
# RFC 8058 One-Click: body = "List-Unsubscribe=One-Click"
|
||||
if not token:
|
||||
body = request.get_data(as_text=True) or ''
|
||||
if 'List-Unsubscribe=One-Click' in body:
|
||||
token = request.args.get('t', '').strip()
|
||||
|
||||
ok, label, _ = _apply_unsubscribe(token)
|
||||
# RFC 8058 One-Click expects plain 200
|
||||
if request.headers.get('Content-Type', '').startswith('application/x-www-form-urlencoded') \
|
||||
and 'List-Unsubscribe=One-Click' in (request.get_data(as_text=True) or ''):
|
||||
return ('OK', 200) if ok else ('Invalid token', 400)
|
||||
|
||||
return render_template('unsubscribe.html',
|
||||
status='done' if ok else 'invalid',
|
||||
label=label,
|
||||
token=None), 200 if ok else 400
|
||||
|
||||
|
||||
def exempt_from_csrf(app):
|
||||
csrf = app.extensions.get('csrf')
|
||||
if csrf:
|
||||
csrf.exempt(bp)
|
||||
@ -38,25 +38,12 @@ class EmailService:
|
||||
body_text: str,
|
||||
body_html: Optional[str] = None,
|
||||
from_address: Optional[str] = None,
|
||||
bcc: Optional[List[str]] = None
|
||||
bcc: Optional[List[str]] = None,
|
||||
extra_headers: Optional[dict] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Send email via SMTP
|
||||
return self._send_via_smtp(subject, body_html or body_text, to, bcc=bcc, extra_headers=extra_headers)
|
||||
|
||||
Args:
|
||||
to: List of recipient email addresses
|
||||
subject: Email subject
|
||||
body_text: Plain text email body
|
||||
body_html: HTML email body (optional)
|
||||
from_address: Sender email (optional, defaults to configured mail_from)
|
||||
bcc: List of BCC recipient email addresses (optional)
|
||||
|
||||
Returns:
|
||||
True if sent successfully, False otherwise
|
||||
"""
|
||||
return self._send_via_smtp(subject, body_html or body_text, to, bcc=bcc)
|
||||
|
||||
def _send_via_smtp(self, subject, body, to, sender_name=None, bcc=None):
|
||||
def _send_via_smtp(self, subject, body, to, sender_name=None, bcc=None, extra_headers=None):
|
||||
"""Send email via SMTP (OVH Zimbra)."""
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
@ -81,6 +68,9 @@ class EmailService:
|
||||
msg['To'] = ', '.join(to) if isinstance(to, list) else to
|
||||
if bcc:
|
||||
msg['Bcc'] = ', '.join(bcc) if isinstance(bcc, list) else bcc
|
||||
if extra_headers:
|
||||
for k, v in extra_headers.items():
|
||||
msg[k] = v
|
||||
|
||||
msg.attach(MIMEText(body, 'html', 'utf-8'))
|
||||
|
||||
@ -128,6 +118,39 @@ def init_email_service():
|
||||
return False
|
||||
|
||||
|
||||
PUBLIC_BASE_URL = os.getenv('PUBLIC_BASE_URL', 'https://nordabiznes.pl').rstrip('/')
|
||||
|
||||
|
||||
def _build_unsubscribe_footer(notification_type: str, user_id: int):
|
||||
"""Return (footer_html, footer_text, unsubscribe_url) or (None, None, None)."""
|
||||
try:
|
||||
from utils.unsubscribe_tokens import generate_unsubscribe_token, NOTIFICATION_TYPE_TO_COLUMN
|
||||
if notification_type not in NOTIFICATION_TYPE_TO_COLUMN:
|
||||
return None, None, None
|
||||
token = generate_unsubscribe_token(user_id, notification_type)
|
||||
url = f"{PUBLIC_BASE_URL}/unsubscribe?t={token}"
|
||||
footer_html = (
|
||||
'<hr style="border:none;border-top:1px solid #e2e8f0;margin:28px 0 16px">'
|
||||
'<p style="color:#94a3b8;font-size:12px;line-height:1.5;margin:0">'
|
||||
f'Nie chcesz otrzymywać takich e-maili? '
|
||||
f'<a href="{url}" style="color:#64748b;text-decoration:underline">'
|
||||
f'Wyłącz ten typ powiadomień jednym kliknięciem</a>.'
|
||||
f' Preferencje dla wszystkich typów: '
|
||||
f'<a href="{PUBLIC_BASE_URL}/konto/prywatnosc" style="color:#64748b;text-decoration:underline">'
|
||||
f'panel konta</a>.'
|
||||
'</p>'
|
||||
)
|
||||
footer_text = (
|
||||
"\n\n---\n"
|
||||
f"Aby wyłączyć ten typ powiadomień e-mail, otwórz: {url}\n"
|
||||
f"Pełne preferencje: {PUBLIC_BASE_URL}/konto/prywatnosc\n"
|
||||
)
|
||||
return footer_html, footer_text, url
|
||||
except Exception as e:
|
||||
logger.debug("unsubscribe footer build failed: %s", e)
|
||||
return None, None, None
|
||||
|
||||
|
||||
def send_email(
|
||||
to: List[str],
|
||||
subject: str,
|
||||
@ -137,7 +160,8 @@ def send_email(
|
||||
email_type: str = 'notification',
|
||||
user_id: Optional[int] = None,
|
||||
recipient_name: Optional[str] = None,
|
||||
bcc: Optional[List[str]] = None
|
||||
bcc: Optional[List[str]] = None,
|
||||
notification_type: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Send email using the global Email Service instance
|
||||
@ -172,7 +196,27 @@ def send_email(
|
||||
# Don't BCC someone who is already a direct recipient
|
||||
bcc = [addr for addr in bcc if addr not in to]
|
||||
|
||||
result = _email_service.send_mail(to, subject, body_text, body_html, from_address, bcc=bcc)
|
||||
# Unsubscribe footer + RFC 8058 List-Unsubscribe headers (dla powiadomień user-facing)
|
||||
extra_headers = None
|
||||
if notification_type and user_id:
|
||||
html_footer, text_footer, unsub_url = _build_unsubscribe_footer(notification_type, user_id)
|
||||
if unsub_url:
|
||||
if body_html:
|
||||
# Dodaj przed zamykającym </body> lub po prostu doklejaj
|
||||
if '</body>' in body_html.lower():
|
||||
idx = body_html.lower().rfind('</body>')
|
||||
body_html = body_html[:idx] + html_footer + body_html[idx:]
|
||||
else:
|
||||
body_html = body_html + html_footer
|
||||
if body_text:
|
||||
body_text = body_text + text_footer
|
||||
extra_headers = {
|
||||
'List-Unsubscribe': f'<{unsub_url}>',
|
||||
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
|
||||
}
|
||||
|
||||
result = _email_service.send_mail(to, subject, body_text, body_html, from_address,
|
||||
bcc=bcc, extra_headers=extra_headers)
|
||||
|
||||
# Log email to database
|
||||
_log_email(
|
||||
|
||||
@ -100,7 +100,8 @@ Portal NordaBiznes.pl"""
|
||||
body_html=body_html,
|
||||
email_type='classified_expiry',
|
||||
user_id=author.id,
|
||||
recipient_name=author_name
|
||||
recipient_name=author_name,
|
||||
notification_type='classified_expiry'
|
||||
)
|
||||
|
||||
status = "wysłano" if success else "BŁĄD"
|
||||
|
||||
64
templates/unsubscribe.html
Normal file
64
templates/unsubscribe.html
Normal file
@ -0,0 +1,64 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Wyłączanie powiadomień e-mail — Norda Biznes{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div style="max-width:560px; margin:40px auto; background:#fff; border-radius:12px; padding:40px 32px; box-shadow:0 2px 8px rgba(0,0,0,0.06); text-align:center">
|
||||
|
||||
{% if status == 'confirm' %}
|
||||
<svg width="48" height="48" fill="none" stroke="#233e6d" stroke-width="2" viewBox="0 0 24 24" style="margin-bottom:16px">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<h1 style="font-size:22px; color:#233e6d; margin:0 0 12px">Wyłączyć ten typ powiadomień?</h1>
|
||||
<p style="color:#555; line-height:1.5; margin-bottom:24px">
|
||||
Chcesz wyłączyć e-maile tego typu:<br>
|
||||
<strong style="color:#1f2937">{{ label }}</strong>
|
||||
</p>
|
||||
<p style="color:#6b7280; font-size:13px; line-height:1.5; margin-bottom:24px">
|
||||
Powiadomienia w portalu i na urządzeniu (push) pozostaną bez zmian.
|
||||
Preferencje możesz w każdej chwili zmienić w panelu konta → Prywatność.
|
||||
</p>
|
||||
<form method="POST" action="{{ url_for('unsubscribe.unsubscribe_apply') }}" style="margin:0">
|
||||
<input type="hidden" name="t" value="{{ token }}">
|
||||
<button type="submit" style="background:#dc2626; color:#fff; border:none; padding:12px 28px; border-radius:8px; font-size:15px; font-weight:500; cursor:pointer">
|
||||
Tak, wyłącz te powiadomienia
|
||||
</button>
|
||||
<div style="margin-top:16px">
|
||||
<a href="/" style="color:#6b7280; font-size:13px; text-decoration:none">Anuluj i wróć na portal</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% elif status == 'done' %}
|
||||
<svg width="56" height="56" fill="none" stroke="#10b981" stroke-width="2" viewBox="0 0 24 24" style="margin-bottom:16px">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
|
||||
</svg>
|
||||
<h1 style="font-size:22px; color:#10b981; margin:0 0 12px">Powiadomienia wyłączone</h1>
|
||||
<p style="color:#555; line-height:1.5; margin-bottom:24px">
|
||||
Nie będziesz już otrzymywać e-maili typu <strong>{{ label }}</strong>.
|
||||
</p>
|
||||
<p style="color:#6b7280; font-size:13px; line-height:1.5; margin-bottom:24px">
|
||||
Przypomnienia na urządzeniu (push) i powiadomienia w portalu działają bez zmian.
|
||||
Aby przywrócić ten typ e-maili, wejdź w panel konta.
|
||||
</p>
|
||||
<a href="{{ url_for('auth.konto_prywatnosc') if current_user.is_authenticated else '/login' }}"
|
||||
style="display:inline-block; background:#233e6d; color:#fff; padding:10px 20px; border-radius:8px; font-size:14px; text-decoration:none">
|
||||
Zarządzaj ustawieniami powiadomień
|
||||
</a>
|
||||
|
||||
{% else %}
|
||||
<svg width="48" height="48" fill="none" stroke="#dc2626" stroke-width="2" viewBox="0 0 24 24" style="margin-bottom:16px">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||
</svg>
|
||||
<h1 style="font-size:22px; color:#dc2626; margin:0 0 12px">Link nieprawidłowy</h1>
|
||||
<p style="color:#555; line-height:1.5; margin-bottom:24px">
|
||||
Ten link nie działa — mógł zostać uszkodzony lub już wykorzystany.<br>
|
||||
Zaloguj się i zmień ustawienia powiadomień w panelu konta.
|
||||
</p>
|
||||
<a href="{{ url_for('auth.konto_prywatnosc') if current_user.is_authenticated else '/login' }}"
|
||||
style="display:inline-block; background:#233e6d; color:#fff; padding:10px 20px; border-radius:8px; font-size:14px; text-decoration:none">
|
||||
Przejdź do ustawień
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -384,7 +384,7 @@ def create_classified_interest_notification(classified_id, classified_title, int
|
||||
)
|
||||
|
||||
|
||||
def send_classified_question_email(classified_id, classified_title, questioner_name, question_content, author_email, author_name):
|
||||
def send_classified_question_email(classified_id, classified_title, questioner_name, question_content, author_email, author_name, author_id=None):
|
||||
"""Send email to classified author about a new question."""
|
||||
from email_service import send_email, _email_v3_wrap
|
||||
|
||||
@ -439,14 +439,16 @@ Norda Biznes Partner - https://nordabiznes.pl
|
||||
body_text=body_text,
|
||||
body_html=body_html,
|
||||
email_type='classified_notification',
|
||||
recipient_name=author_name
|
||||
recipient_name=author_name,
|
||||
user_id=author_id,
|
||||
notification_type='classified_question' if author_id else None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send classified question email to {author_email}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def send_classified_answer_email(classified_id, classified_title, answerer_name, answer_content, questioner_email, questioner_name):
|
||||
def send_classified_answer_email(classified_id, classified_title, answerer_name, answer_content, questioner_email, questioner_name, questioner_id=None):
|
||||
"""Send email to question author about an answer."""
|
||||
from email_service import send_email, _email_v3_wrap
|
||||
|
||||
@ -501,7 +503,9 @@ Norda Biznes Partner - https://nordabiznes.pl
|
||||
body_text=body_text,
|
||||
body_html=body_html,
|
||||
email_type='classified_notification',
|
||||
recipient_name=questioner_name
|
||||
recipient_name=questioner_name,
|
||||
user_id=questioner_id,
|
||||
notification_type='classified_answer' if questioner_id else None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send classified answer email to {questioner_email}: {e}")
|
||||
|
||||
60
utils/unsubscribe_tokens.py
Normal file
60
utils/unsubscribe_tokens.py
Normal file
@ -0,0 +1,60 @@
|
||||
"""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)
|
||||
Loading…
Reference in New Issue
Block a user