From 79762570295cac83cf8e067d8814c4b784ce872d Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 20 Apr 2026 17:31:00 +0200 Subject: [PATCH] src/auth/mailer.py : envoi d'emails HTML+texte (#73) Co-Authored-By: Claude Sonnet 4.6 --- src/auth/mailer.py | 72 +++++++++++++++++++ src/auth/templates/emails/reset_password.html | 18 +++++ src/auth/templates/emails/reset_password.txt | 11 +++ src/auth/templates/emails/verify_email.html | 20 ++++++ src/auth/templates/emails/verify_email.txt | 11 +++ tests/auth/test_mailer.py | 42 +++++++++++ 6 files changed, 174 insertions(+) create mode 100644 src/auth/mailer.py create mode 100644 src/auth/templates/emails/reset_password.html create mode 100644 src/auth/templates/emails/reset_password.txt create mode 100644 src/auth/templates/emails/verify_email.html create mode 100644 src/auth/templates/emails/verify_email.txt create mode 100644 tests/auth/test_mailer.py diff --git a/src/auth/mailer.py b/src/auth/mailer.py new file mode 100644 index 0000000..f1e981a --- /dev/null +++ b/src/auth/mailer.py @@ -0,0 +1,72 @@ +import os +from pathlib import Path + +from flask import Flask, render_template +from flask_mail import Mail, Message + +TEMPLATES_DIR = Path(__file__).parent / "templates" + +_mail: Mail | None = None + + +def init_mailer(app: Flask) -> None: + global _mail + app.config["MAIL_SERVER"] = os.getenv("SMTP_HOST", "") + app.config["MAIL_PORT"] = int(os.getenv("SMTP_PORT", "587")) + app.config["MAIL_USERNAME"] = os.getenv("SMTP_USERNAME") or None + app.config["MAIL_PASSWORD"] = os.getenv("SMTP_PASSWORD") or None + app.config["MAIL_USE_TLS"] = os.getenv("SMTP_USE_TLS", "True").lower() == "true" + app.config["MAIL_DEFAULT_SENDER"] = os.getenv("MAIL_FROM", "noreply@decp.info") + app.config["MAIL_SUPPRESS_SEND"] = ( + os.getenv("DEVELOPMENT", "False").lower() == "true" + ) + # Inclure les templates du package auth dans la recherche Jinja + if hasattr(app.jinja_loader, "searchpath"): + app.jinja_loader.searchpath.append(str(TEMPLATES_DIR)) + else: + import jinja2 + + app.jinja_loader = jinja2.ChoiceLoader( + [app.jinja_loader, jinja2.FileSystemLoader(str(TEMPLATES_DIR))] + ) + _mail = Mail(app) + + +def _base_url() -> str: + return os.getenv("APP_BASE_URL", "http://localhost:8050").rstrip("/") + + +def _send( + subject: str, + recipient: str, + txt_template: str, + html_template: str, + **ctx, +) -> None: + assert _mail is not None, "Mailer non initialisé (init_mailer(app) non appelé)" + msg = Message(subject=subject, recipients=[recipient]) + msg.body = render_template(txt_template, **ctx) + msg.html = render_template(html_template, **ctx) + _mail.send(msg) + + +def send_verification_email(email: str, token: str) -> None: + link = f"{_base_url()}/verification-email?token={token}" + _send( + "Vérification de votre adresse email — decp.info", + email, + "emails/verify_email.txt", + "emails/verify_email.html", + link=link, + ) + + +def send_reset_email(email: str, token: str) -> None: + link = f"{_base_url()}/reinitialiser-mot-de-passe?token={token}" + _send( + "Réinitialisation de votre mot de passe — decp.info", + email, + "emails/reset_password.txt", + "emails/reset_password.html", + link=link, + ) diff --git a/src/auth/templates/emails/reset_password.html b/src/auth/templates/emails/reset_password.html new file mode 100644 index 0000000..29f8848 --- /dev/null +++ b/src/auth/templates/emails/reset_password.html @@ -0,0 +1,18 @@ + + + +

decp.info

+

Bonjour,

+

+ Vous avez demandé la réinitialisation de votre mot de passe decp.info. + Pour choisir un nouveau mot de passe, cliquez sur le lien ci-dessous : +

+

Réinitialiser mon mot de passe

+

+ Ou copiez cette URL dans votre navigateur : {{ link }} +

+

Ce lien est valide pendant 1 heure.

+

Si vous n'êtes pas à l'origine de cette demande, ignorez cet email.

+

— decp.info

+ + diff --git a/src/auth/templates/emails/reset_password.txt b/src/auth/templates/emails/reset_password.txt new file mode 100644 index 0000000..2878ab6 --- /dev/null +++ b/src/auth/templates/emails/reset_password.txt @@ -0,0 +1,11 @@ +Bonjour, + +Vous avez demandé la réinitialisation de votre mot de passe decp.info. Pour choisir un nouveau mot de passe, ouvrez le lien suivant : + +{{ link }} + +Ce lien est valide pendant 1 heure. + +Si vous n'êtes pas à l'origine de cette demande, ignorez cet email. + +— decp.info diff --git a/src/auth/templates/emails/verify_email.html b/src/auth/templates/emails/verify_email.html new file mode 100644 index 0000000..926523d --- /dev/null +++ b/src/auth/templates/emails/verify_email.html @@ -0,0 +1,20 @@ + + + +

decp.info

+

Bonjour,

+

+ Pour vérifier votre adresse email et activer votre compte decp.info, + cliquez sur le lien ci-dessous : +

+

Vérifier mon adresse email

+

+ Ou copiez cette URL dans votre navigateur : {{ link }} +

+

Ce lien est valide pendant 24 heures.

+

+ Si vous n'êtes pas à l'origine de cette inscription, ignorez cet email. +

+

— decp.info

+ + diff --git a/src/auth/templates/emails/verify_email.txt b/src/auth/templates/emails/verify_email.txt new file mode 100644 index 0000000..0c5b95f --- /dev/null +++ b/src/auth/templates/emails/verify_email.txt @@ -0,0 +1,11 @@ +Bonjour, + +Pour vérifier votre adresse email et activer votre compte decp.info, ouvrez le lien suivant : + +{{ link }} + +Ce lien est valide pendant 24 heures. + +Si vous n'êtes pas à l'origine de cette inscription, ignorez cet email. + +— decp.info diff --git a/tests/auth/test_mailer.py b/tests/auth/test_mailer.py new file mode 100644 index 0000000..37a74a3 --- /dev/null +++ b/tests/auth/test_mailer.py @@ -0,0 +1,42 @@ +import pytest +from flask import Flask +from flask_mail import Mail + +from src.auth import mailer + + +@pytest.fixture +def mail_app(monkeypatch, tmp_path): + app = Flask( + __name__, + template_folder=str( + (__import__("pathlib").Path(mailer.__file__).parent / "templates").resolve() + ), + ) + app.config["MAIL_SUPPRESS_SEND"] = True + app.config["MAIL_DEFAULT_SENDER"] = "noreply@decp.info" + app.config["TESTING"] = True + mail = Mail(app) + monkeypatch.setattr(mailer, "_mail", mail) + monkeypatch.setenv("APP_BASE_URL", "http://localhost:8050") + return app + + +def test_send_verification_email(mail_app): + with mail_app.app_context(), mail_app.test_request_context(): + with mail_app.extensions["mail"].record_messages() as outbox: + mailer.send_verification_email("a@b.c", "TOKEN123") + assert len(outbox) == 1 + msg = outbox[0] + assert msg.recipients == ["a@b.c"] + assert "verification-email?token=TOKEN123" in msg.body + assert "verification-email?token=TOKEN123" in msg.html + + +def test_send_reset_email(mail_app): + with mail_app.app_context(), mail_app.test_request_context(): + with mail_app.extensions["mail"].record_messages() as outbox: + mailer.send_reset_email("a@b.c", "RESET456") + assert len(outbox) == 1 + msg = outbox[0] + assert "reinitialiser-mot-de-passe?token=RESET456" in msg.body