docs(brevo): plan d'implémentation + maj spec avec l'API v5 vérifiée (#87)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Colin Maudry
2026-06-24 15:19:42 +02:00
parent f41ee64c32
commit 99abdfed0b
2 changed files with 496 additions and 19 deletions
@@ -0,0 +1,452 @@
# 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 :
```toml
"flask-mail",
```
par :
```toml
"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:
```bash
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**
```bash
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 :
```python
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 :
```python
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 :
```python
mailer.init_mailer(app)
```
par :
```python
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**
```bash
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**
```bash
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` :
```python
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` :
```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**
```bash
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.
@@ -13,8 +13,25 @@ délivrabilité (le mail de reset est-il arrivé ?), expose au risque de suspens
la boîte mail en cas de pic, et délivre moins bien. la boîte mail en cas de pic, et délivre moins bien.
On migre vers **Brevo** (compte gratuit existant) en utilisant son **API On migre vers **Brevo** (compte gratuit existant) en utilisant son **API
transactionnelle** via le SDK officiel `brevo-python` (v5) et ses **templates transactionnelle** via le SDK officiel `brevo-python` **v5** (`5.0.0rc1`, une
hébergés**. Brevo est une société française, données en UE (bon pour le RGPD). pré-release / release candidate, à installer avec `--pre` et épinglée strictement)
et ses **templates hébergés**. Brevo est une société française, données en UE
(bon pour le RGPD).
L'API v5 (vérifiée par introspection du paquet) :
```python
from brevo import Brevo, SendTransacEmailRequestSender, SendTransacEmailRequestToItem
from brevo.core.api_error import ApiError
client = Brevo(api_key="...", headers={"X-Sib-Sandbox": "drop"}) # headers optionnels
client.transactional_emails.send_transac_email(
template_id=123,
params={"link": "https://..."},
sender=SendTransacEmailRequestSender(email="noreply@decp.info", name="decp.info"),
to=[SendTransacEmailRequestToItem(email="dest@example.com")],
)
```
## Objectif ## Objectif
@@ -53,12 +70,12 @@ send_reset_email(email: str, token: str) -> None
### Composants de `src/auth/mailer.py` (réécrit) ### Composants de `src/auth/mailer.py` (réécrit)
| Fonction | Rôle | | Fonction | Rôle |
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `init_mailer()` | Construit le client Brevo (`TransactionalEmailsApi`) depuis `BREVO_API_KEY`. Plus de paramètre `app`. Stocke le client au niveau module. | | `init_mailer()` | Construit le client `Brevo(api_key=...)` depuis `BREVO_API_KEY` (+ header sandbox si `BREVO_SANDBOX=true`). Plus de paramètre `app`. Stocke le client au niveau module. |
| `_send_template(template_id, recipient, params)` | Construit un `SendSmtpEmail(to, template_id, params, sender)`, applique le mode sandbox si activé, appelle `send_transac_email`, log + remonte les `ApiException`. | | `_send_template(template_id, recipient, params)` | Appelle `client.transactional_emails.send_transac_email(template_id, params, sender=SendTransacEmailRequestSender(...), to=[SendTransacEmailRequestToItem(email=recipient)])`, log + remonte les `ApiError`. |
| `send_verification_email(email, token)` | Construit le lien `{base}/auth/verify-email?token=...` et appelle `_send_template(VERIFY_ID, email, {"link": link})`. | | `send_verification_email(email, token)` | Construit le lien `{base}/auth/verify-email?token=...` et appelle `_send_template(VERIFY_ID, email, {"link": link})`. |
| `send_reset_email(email, token)` | Construit le lien `{base}/reinitialiser-mot-de-passe?token=...` et appelle `_send_template(RESET_ID, email, {"link": link})`. | | `send_reset_email(email, token)` | Construit le lien `{base}/reinitialiser-mot-de-passe?token=...` et appelle `_send_template(RESET_ID, email, {"link": link})`. |
Disparaît : toute la logique Jinja (`jinja_loader.searchpath`, `render_template`, Disparaît : toute la logique Jinja (`jinja_loader.searchpath`, `render_template`,
les templates `.txt`/`.html`), `MAIL_SUPPRESS_SEND`, l'objet Flask-Mail. les templates `.txt`/`.html`), `MAIL_SUPPRESS_SEND`, l'objet Flask-Mail.
@@ -102,7 +119,7 @@ reste possible et prioritaire.
## Gestion d'erreur ## Gestion d'erreur
- `_send_template` enveloppe l'appel dans un `try/except ApiException` : log - `_send_template` enveloppe l'appel dans un `try/except ApiError` : log
(niveau error, sans la clé API) puis `raise` pour que l'appelant gère. (niveau error, sans la clé API) puis `raise` pour que l'appelant gère.
- Si `init_mailer()` n'a pas été appelé ou `BREVO_API_KEY` absente : `assert`/erreur - Si `init_mailer()` n'a pas été appelé ou `BREVO_API_KEY` absente : `assert`/erreur
explicite, comme l'actuel « Mailer non initialisé ». explicite, comme l'actuel « Mailer non initialisé ».
@@ -111,10 +128,10 @@ reste possible et prioritaire.
Réconciliation validée : Réconciliation validée :
- **Tests unitaires** (`tests/auth/test_mailer.py`, réécrit) : on **mocke** - **Tests unitaires** (`tests/auth/test_mailer.py`, réécrit) : on **mocke** la méthode
`TransactionalEmailsApi.send_transac_email` (monkeypatch) et on vérifie le payload `transactional_emails.send_transac_email` du client (monkeypatch) et on capture les
envoyé pour chaque fonction : kwargs passés pour chaque fonction :
- `to == [{"email": "a@b.c"}]` - `to[0].email == "a@b.c"`
- `template_id == BREVO_TEMPLATE_VERIFY_ID` / `..._RESET_ID` - `template_id == BREVO_TEMPLATE_VERIFY_ID` / `..._RESET_ID`
- `params["link"]` contient le bon chemin et le token - `params["link"]` contient le bon chemin et le token
- Aucun appel réseau, tourne en CI sans clé. - Aucun appel réseau, tourne en CI sans clé.
@@ -124,13 +141,21 @@ Réconciliation validée :
- **Test d'intégration optionnel** : `@pytest.mark.integration`, skippé par défaut, - **Test d'intégration optionnel** : `@pytest.mark.integration`, skippé par défaut,
exécuté seulement si `BREVO_API_KEY` est présent, tape la vraie API en sandbox. exécuté seulement si `BREVO_API_KEY` est présent, tape la vraie API en sandbox.
## Points à vérifier en implémentation (ne pas présumer) ## Faits vérifiés / points résiduels
- Nom exact du package/import du SDK v5 (`brevo_python` vs autre) et des classes **Vérifié par introspection de `brevo-python==5.0.0rc1`** :
(`TransactionalEmailsApi`, `SendSmtpEmail`, `Configuration`, `ApiClient`,
`ApiException`) — confirmer sur la doc/repo getbrevo/brevo-python v5. - Install : `pip install --pre "brevo-python==5.0.0rc1"` (package PyPI `brevo-python`,
- Mécanisme exact du mode **sandbox** Brevo (header `X-Sib-Sandbox` et sa valeur, import `brevo`).
ou réglage compte) — confirmer côté doc Brevo. - Imports : `from brevo import Brevo, SendTransacEmailRequestSender, SendTransacEmailRequestToItem` ; `from brevo.core.api_error import ApiError`.
- Client : `Brevo(api_key=..., headers={...})` ; envoi via
`client.transactional_emails.send_transac_email(template_id=int, params=dict, sender=..., to=[...], headers=dict|None)`.
**À confirmer en implémentation (ne pas bloquer) :**
- Valeur exacte du header sandbox Brevo (`X-Sib-Sandbox: drop` est la valeur
documentée pour « accepter mais ne pas délivrer ») — à confirmer avec un envoi
réel en sandbox.
- Usage éventuel des variables legacy `SENDER_SERVER_DOMAIN`/`LOGIN_EMAIL`/ - Usage éventuel des variables legacy `SENDER_SERVER_DOMAIN`/`LOGIN_EMAIL`/
`FROM_EMAIL`/`TO_EMAIL` ailleurs dans le code avant toute suppression. `FROM_EMAIL`/`TO_EMAIL` ailleurs dans le code avant toute suppression.