From fcc42c8d437d6812a1434286666e43678ed11836 Mon Sep 17 00:00:00 2001 From: AdamThomas Date: Tue, 15 Sep 2026 19:23:21 +0200 Subject: [PATCH] Email : ne plus journaliser l'adresse du destinataire en cas de refus SMTP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit En cherchant pourquoi les logs d'envoi d'email semblaient invisibles, on a plutôt trouvé un problème RGPD : `SMTPRecipientsRefused` embarque l'adresse du destinataire dans ses `args` (`{adresse: (code, message)}`), et `logger.error(..., exc)` la journalisait telle quelle via `str(exc)` — en violation directe de la règle « pas de log des noms/emails/téléphones ». `_smtp_error_summary()` n'en extrait que les codes/messages serveur. Constaté au passage (pas un bug) : le mécanisme de logs lui-même fonctionne bien — `docker compose logs -f backend` affiche bien les lignes `app.email` dès qu'une vraie requête HTTP déclenche l'envoi (testé en direct sur l'édition de démo). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM --- backend/app/email/sender.py | 18 ++++++++++++++- backend/tests/test_email.py | 46 +++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/backend/app/email/sender.py b/backend/app/email/sender.py index b7c7358..3547751 100644 --- a/backend/app/email/sender.py +++ b/backend/app/email/sender.py @@ -16,6 +16,22 @@ from app.config import Settings, get_settings logger = logging.getLogger("app.email") +def _smtp_error_summary(exc: smtplib.SMTPException) -> str: + """Résumé RGPD-safe d'une erreur SMTP : codes/messages serveur seulement. + + Certaines exceptions de `smtplib` embarquent l'adresse du destinataire + dans leurs `args` (notamment `SMTPRecipientsRefused.recipients`, un + `{adresse: (code, message)}`) — `str(exc)` les journaliserait telles + quelles. On n'en garde que les codes/messages, jamais les adresses. + """ + if isinstance(exc, smtplib.SMTPRecipientsRefused): + codes = sorted({code for code, _msg in exc.recipients.values()}) + return f"destinataire(s) refusé(s), code(s) {codes}" + if isinstance(exc, smtplib.SMTPResponseException): + return f"{exc.smtp_code} {exc.smtp_error!r}" + return exc.__class__.__name__ + + def build_message(to: str, subject: str, body: str) -> EmailMessage: message = EmailMessage() message["From"] = get_settings().smtp_from @@ -84,7 +100,7 @@ def deliver_many(messages: list[EmailMessage]) -> None: logger.error( "Email refusé par le serveur (From=%s) : %s", message["From"], - exc, + _smtp_error_summary(exc), ) # RGPD : ni destinataire ni corps, seulement le compte et le sujet. logger.info( diff --git a/backend/tests/test_email.py b/backend/tests/test_email.py index b7c9bdc..c5f1e5c 100644 --- a/backend/tests/test_email.py +++ b/backend/tests/test_email.py @@ -53,6 +53,52 @@ def test_smtp_is_used_when_enabled(monkeypatch): assert ("send", "z@y.fr", "Sujet 2") in events +def test_recipient_refusal_does_not_log_the_email_address(monkeypatch, caplog): + # RGPD : `SMTPRecipientsRefused` embarque l'adresse dans ses `args` — + # elle ne doit jamais atterrir dans les logs, seulement le(s) code(s). + fake = SimpleNamespace( + email_enabled=True, + smtp_host="mail.test", + smtp_port=587, + smtp_user="u", + smtp_password="p", + smtp_starttls=True, + smtp_ssl=False, + smtp_from="Festival ", + ) + monkeypatch.setattr(sender, "get_settings", lambda: fake) + recipient = "jean.dupont@example.fr" + + class FakeSMTP: + def __init__(self, host, port, timeout=None): + pass + + def __enter__(self): + return self + + def __exit__(self, *_a): + return False + + def starttls(self, context=None): + pass + + def login(self, user, _password): + pass + + def send_message(self, message): + raise sender.smtplib.SMTPRecipientsRefused( + {recipient: (450, b"Recipient address rejected: Domain not found")} + ) + + monkeypatch.setattr(sender.smtplib, "SMTP", FakeSMTP) + + with caplog.at_level("ERROR", logger="app.email"): + sender.send(recipient, "Sujet", "Corps") + + assert recipient not in caplog.text + assert "450" in caplog.text + + def test_render_magic_link_uses_translations(): from app.email import render_magic_link