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

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:
Maciej Pienczyn 2026-04-14 17:56:36 +02:00
parent 3f1e66d3ca
commit dcbf8b5db6
10 changed files with 311 additions and 26 deletions

View File

@ -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:

View File

@ -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}")

View File

@ -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:

View File

@ -0,0 +1,6 @@
"""One-click unsubscribe blueprint — /unsubscribe."""
from flask import Blueprint
bp = Blueprint('unsubscribe', __name__)
from . import routes # noqa: E402, F401

View 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)

View File

@ -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(

View File

@ -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"

View 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 %}

View File

@ -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}")

View 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)