From dcbf8b5db65364604e3a5604cafc23a68485c06d Mon Sep 17 00:00:00 2001 From: Maciej Pienczyn Date: Tue, 14 Apr 2026 17:56:36 +0200 Subject: [PATCH] =?UTF-8?q?feat(email):=20one-click=20unsubscribe=20w=20ma?= =?UTF-8?q?ilach=20powiadomie=C5=84=20(RFC=208058)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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_ - 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) --- blueprints/__init__.py | 12 +++ blueprints/community/classifieds/routes.py | 6 +- blueprints/messages/message_routes.py | 1 + blueprints/unsubscribe/__init__.py | 6 ++ blueprints/unsubscribe/routes.py | 91 ++++++++++++++++++++++ email_service.py | 82 ++++++++++++++----- scripts/classified_expiry_notifier.py | 3 +- templates/unsubscribe.html | 64 +++++++++++++++ utils/notifications.py | 12 ++- utils/unsubscribe_tokens.py | 60 ++++++++++++++ 10 files changed, 311 insertions(+), 26 deletions(-) create mode 100644 blueprints/unsubscribe/__init__.py create mode 100644 blueprints/unsubscribe/routes.py create mode 100644 templates/unsubscribe.html create mode 100644 utils/unsubscribe_tokens.py diff --git a/blueprints/__init__.py b/blueprints/__init__.py index df8ff87..311ec5f 100644 --- a/blueprints/__init__.py +++ b/blueprints/__init__.py @@ -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: diff --git a/blueprints/community/classifieds/routes.py b/blueprints/community/classifieds/routes.py index 63a6a84..5007f51 100644 --- a/blueprints/community/classifieds/routes.py +++ b/blueprints/community/classifieds/routes.py @@ -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}") diff --git a/blueprints/messages/message_routes.py b/blueprints/messages/message_routes.py index a888a8f..4ee4e6a 100644 --- a/blueprints/messages/message_routes.py +++ b/blueprints/messages/message_routes.py @@ -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: diff --git a/blueprints/unsubscribe/__init__.py b/blueprints/unsubscribe/__init__.py new file mode 100644 index 0000000..5c363d6 --- /dev/null +++ b/blueprints/unsubscribe/__init__.py @@ -0,0 +1,6 @@ +"""One-click unsubscribe blueprint — /unsubscribe.""" +from flask import Blueprint + +bp = Blueprint('unsubscribe', __name__) + +from . import routes # noqa: E402, F401 diff --git a/blueprints/unsubscribe/routes.py b/blueprints/unsubscribe/routes.py new file mode 100644 index 0000000..b45d960 --- /dev/null +++ b/blueprints/unsubscribe/routes.py @@ -0,0 +1,91 @@ +"""Unsubscribe endpoints — bez logowania, weryfikacja przez signed token. + +GET /unsubscribe?t= — 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) diff --git a/email_service.py b/email_service.py index ad96b1b..510c92c 100644 --- a/email_service.py +++ b/email_service.py @@ -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 = ( + '
' + '

' + f'Nie chcesz otrzymywać takich e-maili? ' + f'' + f'Wyłącz ten typ powiadomień jednym kliknięciem.' + f' Preferencje dla wszystkich typów: ' + f'' + f'panel konta.' + '

' + ) + 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 lub po prostu doklejaj + if '' in body_html.lower(): + idx = body_html.lower().rfind('') + 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( diff --git a/scripts/classified_expiry_notifier.py b/scripts/classified_expiry_notifier.py index 0442193..8f57ad1 100644 --- a/scripts/classified_expiry_notifier.py +++ b/scripts/classified_expiry_notifier.py @@ -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" diff --git a/templates/unsubscribe.html b/templates/unsubscribe.html new file mode 100644 index 0000000..48616c4 --- /dev/null +++ b/templates/unsubscribe.html @@ -0,0 +1,64 @@ +{% extends "base.html" %} + +{% block title %}Wyłączanie powiadomień e-mail — Norda Biznes{% endblock %} + +{% block content %} +
+ + {% if status == 'confirm' %} + + + +

Wyłączyć ten typ powiadomień?

+

+ Chcesz wyłączyć e-maile tego typu:
+ {{ label }} +

+

+ Powiadomienia w portalu i na urządzeniu (push) pozostaną bez zmian. + Preferencje możesz w każdej chwili zmienić w panelu konta → Prywatność. +

+
+ + + +
+ + {% elif status == 'done' %} + + + +

Powiadomienia wyłączone

+

+ Nie będziesz już otrzymywać e-maili typu {{ label }}. +

+

+ Przypomnienia na urządzeniu (push) i powiadomienia w portalu działają bez zmian. + Aby przywrócić ten typ e-maili, wejdź w panel konta. +

+ + Zarządzaj ustawieniami powiadomień + + + {% else %} + + + +

Link nieprawidłowy

+

+ Ten link nie działa — mógł zostać uszkodzony lub już wykorzystany.
+ Zaloguj się i zmień ustawienia powiadomień w panelu konta. +

+ + Przejdź do ustawień + + {% endif %} + +
+{% endblock %} diff --git a/utils/notifications.py b/utils/notifications.py index 3d5cf9b..9532e0f 100644 --- a/utils/notifications.py +++ b/utils/notifications.py @@ -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}") diff --git a/utils/unsubscribe_tokens.py b/utils/unsubscribe_tokens.py new file mode 100644 index 0000000..cc48259 --- /dev/null +++ b/utils/unsubscribe_tokens.py @@ -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= +""" + +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)