Files
colibre/docs/superpowers/plans/2026-06-24-brevo-transactional-email.md
2026-06-24 15:19:42 +02:00

16 KiB

Migration des emails transactionnels vers Brevo — Plan d'implémentation

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Remplacer le transport SMTP/Flask-Mail des emails transactionnels (vérification d'adresse, reset de mot de passe) par l'API Brevo et ses templates hébergés, sans changer l'interface publique du mailer.

Architecture: src/auth/mailer.py est réécrit pour construire un client Brevo (SDK v5) et envoyer via client.transactional_emails.send_transac_email(template_id, params, sender, to). Les deux fonctions publiques (send_verification_email, send_reset_email) gardent leur signature, donc src/auth/routes.py n'est pas touché. Seul l'appel d'init dans setup.py change (init_mailer() sans app).

Tech Stack: Python, Flask, SDK brevo-python v5 (5.0.0rc1), pytest, monkeypatch.

Global Constraints

  • Imports internes toujours préfixés src. (ex: from src.utils import logger).
  • SDK épinglé strictement : brevo-python==5.0.0rc1 (pré-release ; un specifier qui pointe exactement une rc autorise pip à l'installer sans --pre global).
  • API v5 vérifiée par introspection :
    • from brevo import Brevo, SendTransacEmailRequestSender, SendTransacEmailRequestToItem
    • from brevo.core.api_error import ApiError
    • Brevo(api_key: str, headers: dict | None = None)
    • client.transactional_emails.send_transac_email(template_id=int, params=dict, sender=SendTransacEmailRequestSender, to=[SendTransacEmailRequestToItem], headers=dict|None)
    • SendTransacEmailRequestSender(email=..., name=...), SendTransacEmailRequestToItem(email=..., name=...)
  • Tests unitaires hermétiques : aucun appel réseau (le client est monkeypatché).
  • Commits fréquents, un par tâche.
  • Lancer les tests avec uv run pytest (pas source .venv/bin/activate).
  • Le hook pre-commit (prettier/ruff) peut reformater des fichiers : si un commit échoue pour cause de reformatage, git add les fichiers modifiés et recommitter.

File Structure

Fichier Responsabilité Action
pyproject.toml Dépendances Modifier (swap flask-mail → brevo-python)
src/auth/mailer.py Envoi des emails transactionnels via Brevo Réécrire
src/auth/setup.py Initialisation du blueprint auth Modifier (1 ligne)
tests/auth/test_mailer.py Tests unitaires du mailer (mock) Réécrire
tests/auth/test_mailer_integration.py Test d'intégration sandbox optionnel Créer
.template.env Documentation des variables d'env Modifier
src/auth/templates/emails/* Anciens templates Jinja Supprimer

Task 1: Basculer la dépendance vers le SDK Brevo

Files:

  • Modify: pyproject.toml:25

Interfaces:

  • Consumes: (rien)

  • Produces: le paquet brevo importable dans l'environnement.

  • Step 1: Remplacer la dépendance dans pyproject.toml

Remplacer la ligne 25 :

"flask-mail",

par :

"brevo-python==5.0.0rc1",
  • Step 2: Réinstaller les dépendances du projet

Run: uv pip install -e . --group=dev Expected: installation réussie, brevo-python 5.0.0rc1 installé, flask-mail retiré.

  • Step 3: Vérifier que le SDK s'importe avec les symboles attendus

Run:

uv run python -c "from brevo import Brevo, SendTransacEmailRequestSender, SendTransacEmailRequestToItem; from brevo.core.api_error import ApiError; print('brevo v5 OK')"

Expected: affiche brevo v5 OK sans erreur.

  • Step 4: Commit
git add pyproject.toml
git commit -m "build(brevo): remplacer flask-mail par brevo-python v5 (#87)"

Task 2: Réécrire le mailer pour utiliser l'API Brevo (TDD)

Files:

  • Test: tests/auth/test_mailer.py (réécriture complète)
  • Modify: src/auth/mailer.py (réécriture complète)
  • Modify: src/auth/setup.py:39

Interfaces:

  • Consumes: brevo.Brevo, brevo.SendTransacEmailRequestSender, brevo.SendTransacEmailRequestToItem, brevo.core.api_error.ApiError, src.utils.logger.

  • Produces (interface publique, inchangée pour routes.py) :

    • init_mailer() -> None
    • send_verification_email(email: str, token: str) -> None
    • send_reset_email(email: str, token: str) -> None
  • Variables d'env lues : BREVO_API_KEY, BREVO_SANDBOX, BREVO_TEMPLATE_VERIFY_ID, BREVO_TEMPLATE_RESET_ID, MAIL_FROM, MAIL_FROM_NAME, APP_BASE_URL.

  • Step 1: Réécrire le test du mailer (mock du client Brevo)

Remplacer tout le contenu de tests/auth/test_mailer.py par :

import pytest

from src.auth import mailer


class _FakeTransac:
    def __init__(self):
        self.calls = []

    def send_transac_email(self, **kwargs):
        self.calls.append(kwargs)


class _FakeClient:
    def __init__(self):
        self.transactional_emails = _FakeTransac()


@pytest.fixture
def fake_client(monkeypatch):
    client = _FakeClient()
    monkeypatch.setattr(mailer, "_client", client)
    monkeypatch.setenv("APP_BASE_URL", "http://localhost:8050")
    monkeypatch.setenv("BREVO_TEMPLATE_VERIFY_ID", "11")
    monkeypatch.setenv("BREVO_TEMPLATE_RESET_ID", "22")
    monkeypatch.setenv("MAIL_FROM", "noreply@decp.info")
    monkeypatch.setenv("MAIL_FROM_NAME", "decp.info")
    return client


def test_send_verification_email(fake_client):
    mailer.send_verification_email("a@b.c", "TOKEN123")
    calls = fake_client.transactional_emails.calls
    assert len(calls) == 1
    call = calls[0]
    assert call["template_id"] == 11
    assert call["to"][0].email == "a@b.c"
    assert call["sender"].email == "noreply@decp.info"
    assert "/auth/verify-email?token=TOKEN123" in call["params"]["link"]


def test_send_reset_email(fake_client):
    mailer.send_reset_email("a@b.c", "RESET456")
    calls = fake_client.transactional_emails.calls
    assert len(calls) == 1
    call = calls[0]
    assert call["template_id"] == 22
    assert "reinitialiser-mot-de-passe?token=RESET456" in call["params"]["link"]


def test_init_mailer_builds_client(monkeypatch):
    monkeypatch.setenv("BREVO_API_KEY", "test-key")
    monkeypatch.delenv("BREVO_SANDBOX", raising=False)
    monkeypatch.setattr(mailer, "_client", None)
    mailer.init_mailer()
    assert mailer._client is not None


def test_send_without_init_raises(monkeypatch):
    monkeypatch.setattr(mailer, "_client", None)
    monkeypatch.setenv("BREVO_TEMPLATE_VERIFY_ID", "11")
    with pytest.raises(AssertionError):
        mailer.send_verification_email("a@b.c", "TOKEN123")
  • Step 2: Lancer les tests pour les voir échouer

Run: uv run pytest tests/auth/test_mailer.py -v Expected: FAIL — l'ancien mailer.py importe flask_mail (désinstallé en Task 1) et n'expose pas _client ; erreurs d'import / d'attribut.

  • Step 3: Réécrire src/auth/mailer.py

Remplacer tout le contenu de src/auth/mailer.py par :

import os

from brevo import (
    Brevo,
    SendTransacEmailRequestSender,
    SendTransacEmailRequestToItem,
)
from brevo.core.api_error import ApiError

from src.utils import logger

_client: Brevo | None = None


def init_mailer() -> None:
    """Construit le client Brevo à partir des variables d'environnement."""
    global _client
    api_key = os.getenv("BREVO_API_KEY", "")
    sandbox = os.getenv("BREVO_SANDBOX", "").lower() == "true"
    headers = {"X-Sib-Sandbox": "drop"} if sandbox else None
    _client = Brevo(api_key=api_key, headers=headers)


def _base_url() -> str:
    return os.getenv("APP_BASE_URL", "http://localhost:8050").rstrip("/")


def _sender() -> SendTransacEmailRequestSender:
    return SendTransacEmailRequestSender(
        email=os.getenv("MAIL_FROM", "noreply@decp.info"),
        name=os.getenv("MAIL_FROM_NAME", "decp.info"),
    )


def _template_id(env_var: str) -> int:
    raw = os.getenv(env_var, "")
    if not raw:
        raise RuntimeError(f"{env_var} non défini (template Brevo)")
    return int(raw)


def _send_template(template_id: int, recipient: str, params: dict) -> None:
    assert _client is not None, "Mailer non initialisé (init_mailer() non appelé)"
    try:
        _client.transactional_emails.send_transac_email(
            template_id=template_id,
            params=params,
            sender=_sender(),
            to=[SendTransacEmailRequestToItem(email=recipient)],
        )
    except ApiError:
        logger.exception(
            "Échec d'envoi Brevo (template %s) à %s", template_id, recipient
        )
        raise


def send_verification_email(email: str, token: str) -> None:
    link = f"{_base_url()}/auth/verify-email?token={token}"
    _send_template(
        _template_id("BREVO_TEMPLATE_VERIFY_ID"), email, {"link": link}
    )


def send_reset_email(email: str, token: str) -> None:
    link = f"{_base_url()}/reinitialiser-mot-de-passe?token={token}"
    _send_template(
        _template_id("BREVO_TEMPLATE_RESET_ID"), email, {"link": link}
    )
  • Step 4: Mettre à jour l'appel d'init dans setup.py

Dans src/auth/setup.py, ligne 39, remplacer :

    mailer.init_mailer(app)

par :

    mailer.init_mailer()
  • Step 5: Lancer les tests du mailer pour les voir passer

Run: uv run pytest tests/auth/test_mailer.py -v Expected: PASS (4 tests).

  • Step 6: Lancer toute la suite auth pour vérifier l'absence de régression

Run: uv run pytest tests/auth/ -v Expected: PASS (aucune dépendance résiduelle à flask-mail ; routes.py inchangé fonctionne).

  • Step 7: Commit
git add src/auth/mailer.py src/auth/setup.py tests/auth/test_mailer.py
git commit -m "feat(brevo): envoyer les emails transactionnels via l'API Brevo v5 (#87)"

Task 3: Nettoyer la configuration et les templates Jinja

Files:

  • Modify: .template.env
  • Delete: src/auth/templates/emails/verify_email.html, verify_email.txt, reset_password.html, reset_password.txt (tout le dossier src/auth/templates/)

Interfaces:

  • Consumes: (rien — mailer.py n'utilise plus de templates locaux après Task 2)

  • Produces: (rien)

  • Step 1: Vérifier que les variables legacy ne sont pas utilisées ailleurs

Run: grep -rn "SENDER_SERVER_DOMAIN\|LOGIN_EMAIL\|FROM_EMAIL\|TO_EMAIL" . --include=*.py Expected: aucun résultat. (Si des résultats apparaissent hors .template.env, NE PAS supprimer ces variables et le signaler.)

  • Step 2: Mettre à jour .template.env

Dans la section SMTP de .template.env, supprimer les lignes :

SENDER_SERVER_DOMAIN="mail.example.com" # serveur SMTP
LOGIN_EMAIL="connect@example.fr"        # adresse utilisée pour se connecter au serveur SMTP
FROM_EMAIL="from@example.com"           # adresse d'envoi des emails (From)
TO_EMAIL="to@example.com"               # adresse de destination des emails (To)
# SMTP pour envoi d'emails (vérification email, reset mot de passe)
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_USE_TLS=True
MAIL_SUPPRESS_SEND=  # laisser vide pour hériter de DEVELOPMENT ; mettre false pour forcer l'envoi en mode dev

et les remplacer par :

# Brevo — envoi des emails transactionnels (vérification email, reset mot de passe)
BREVO_API_KEY=                       # clé API transactionnelle Brevo
BREVO_TEMPLATE_VERIFY_ID=            # ID numérique du template "vérification d'adresse"
BREVO_TEMPLATE_RESET_ID=            # ID numérique du template "réinitialisation mot de passe"
BREVO_SANDBOX=                       # mettre "true" pour valider sans délivrer (dev / intégration)
MAIL_FROM=noreply@decp.info          # expéditeur (doit être un expéditeur vérifié dans Brevo)
MAIL_FROM_NAME=decp.info             # nom d'expéditeur

Conserver la variable APP_BASE_URL si elle est déjà présente ailleurs dans le fichier ; sinon l'ajouter :

APP_BASE_URL=http://localhost:8050   # base des liens dans les emails
  • Step 3: Supprimer les templates Jinja d'email devenus inutiles

Run: git rm -r src/auth/templates Expected: les 4 fichiers emails/*.{html,txt} sont supprimés.

  • Step 4: Vérifier qu'aucun code ne référence encore ces templates

Run: grep -rn "verify_email\|reset_password\|render_template\|jinja_loader\|MAIL_SUPPRESS_SEND\|flask_mail" src/ tests/ --include=*.py Expected: aucun résultat.

  • Step 5: Relancer la suite auth complète

Run: uv run pytest tests/auth/ -v Expected: PASS.

  • Step 6: Commit
git add .template.env src/auth/templates
git commit -m "chore(brevo): retirer la config SMTP et les templates Jinja d'email (#87)"

Task 4: Test d'intégration sandbox optionnel

Files:

  • Create: tests/auth/test_mailer_integration.py

Interfaces:

  • Consumes: interface publique mailer.init_mailer(), mailer.send_verification_email(email, token).

  • Produces: un test @pytest.mark.integration skippé sauf si BREVO_API_KEY est défini.

  • Step 1: Créer le test d'intégration

Créer tests/auth/test_mailer_integration.py :

import os

import pytest

from src.auth import mailer

pytestmark = pytest.mark.integration


@pytest.mark.skipif(
    not os.getenv("BREVO_API_KEY"),
    reason="BREVO_API_KEY absent — test d'intégration Brevo ignoré",
)
def test_send_verification_email_sandbox(monkeypatch):
    # Force le mode sandbox : Brevo valide la requête sans délivrer.
    monkeypatch.setenv("BREVO_SANDBOX", "true")
    monkeypatch.setenv("APP_BASE_URL", "http://localhost:8050")
    mailer.init_mailer()
    # Ne doit pas lever : l'API Brevo accepte la requête en sandbox.
    mailer.send_verification_email(
        os.getenv("MAIL_FROM", "noreply@decp.info"), "INTEGRATION_TOKEN"
    )
  • Step 2: Enregistrer le marker integration (si absent)

Vérifier la présence du marker dans pyproject.toml :

Run: grep -n "integration" pyproject.toml

Si aucune section [tool.pytest.ini_options] avec markers ne déclare integration, l'ajouter. Exemple à insérer/compléter dans pyproject.toml :

[tool.pytest.ini_options]
markers = [
    "integration: tests touchant des services externes (Brevo) ; skippés par défaut en CI",
]

(Si la section existe déjà, n'ajouter que la ligne integration: ... dans la liste markers existante, sans dupliquer la section.)

  • Step 3: Vérifier que le test est bien collecté puis skippé sans clé

Run: uv run pytest tests/auth/test_mailer_integration.py -v Expected: 1 test SKIPPED (motif « BREVO_API_KEY absent »), aucun appel réseau.

  • Step 4: Vérifier que la suite par défaut reste verte

Run: uv run pytest tests/auth/ -v Expected: PASS, avec le test d'intégration SKIPPED.

  • Step 5: Commit
git add tests/auth/test_mailer_integration.py pyproject.toml
git commit -m "test(brevo): test d'intégration sandbox optionnel (#87)"

Notes de vérification finale (manuel, hors CI)

Avant de merger, vérification manuelle réelle :

  1. Renseigner .env avec BREVO_API_KEY, BREVO_TEMPLATE_VERIFY_ID, BREVO_TEMPLATE_RESET_ID, MAIL_FROM (expéditeur vérifié Brevo) et BREVO_SANDBOX=true.
  2. Lancer python run.py, déclencher une inscription, vérifier dans les logs Brevo (tableau de bord) que la requête est reçue (sandbox = acceptée, non délivrée).
  3. Passer BREVO_SANDBOX= (vide), refaire une inscription avec une vraie adresse, confirmer la réception et que {{ params.link }} est correctement substitué dans le template.
  4. Confirmer la valeur du header sandbox (X-Sib-Sandbox: drop) si le comportement diffère de l'attendu.