Étape 10 : envoi d'emails par SMTP
- app/email/ : sender (smtplib, STARTTLS ou SSL, connexion réutilisée pour un
lot) + templates (rendu, textes dans locales/fr.json). EMAIL_ENABLED=false
(défaut) ⇒ journalisé seulement, sans destinataire ni corps (RGPD).
- Lien magique : POST .../auth/magic-link envoie désormais l'email
(PUBLIC_BASE_URL + magic_link_path + ?token=). Helper app/auth/magic_link.py
partagé. Sans SMTP, le jeton non nominatif reste journalisé pour le dev.
- Récapitulatif de planning : POST /editions/{id}/notifications/planning
(COORDINATION). subject/body sont des modèles ({prenom}, {nom}, {nb_creneaux},
{nb_heures}, {creneaux}, {lien_connexion} → lien magique personnel émis).
Cible tous les bénévoles avec email ou une liste. Envoi en BackgroundTasks.
- Config : PUBLIC_BASE_URL, EMAIL_ENABLED, SMTP_* ; .env.example,
docker-compose (dev + prod), deploy/README.
- 9 tests (sender activé/désactivé, rendu, notifications : contenu, gabarit par
défaut, lien magique embarqué, sous-ensemble, réservé COORDINATION).
137 tests au total ; ruff + alembic check verts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
977bd4dc34
commit
b268e308ab
@@ -141,3 +141,14 @@ def raw_client(session):
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def outbox(monkeypatch) -> list:
|
||||
"""Capture les emails au lieu de les remettre (SMTP désactivé de toute
|
||||
façon en test ; ceci permet de les inspecter)."""
|
||||
box: list = []
|
||||
monkeypatch.setattr(
|
||||
"app.email.sender.deliver_many", lambda messages: box.extend(messages)
|
||||
)
|
||||
return box
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
from sqlmodel import select
|
||||
|
||||
from app.models import Assignment, MagicLinkToken, Role
|
||||
from tests.factories import dt_at, make_position, make_shift, make_volunteer
|
||||
|
||||
|
||||
def _url(edition):
|
||||
return f"/editions/{edition.id}/notifications/planning"
|
||||
|
||||
|
||||
def test_one_email_per_volunteer_with_an_address(client, edition, session, outbox):
|
||||
position = make_position(session, edition, name="Bar")
|
||||
s1 = make_shift(session, edition, position, start=dt_at(10, 9), end=dt_at(10, 13))
|
||||
s2 = make_shift(session, edition, position, start=dt_at(11, 9), end=dt_at(11, 12))
|
||||
ana = make_volunteer(session, edition, first_name="Ana", email="ana@e.fr")
|
||||
make_volunteer(session, edition, first_name="Bob", email="bob@e.fr")
|
||||
make_volunteer(session, edition, first_name="Cid", email=None)
|
||||
session.add_all(
|
||||
[
|
||||
Assignment(edition_id=edition.id, volunteer_id=ana.id, shift_id=s1.id),
|
||||
Assignment(edition_id=edition.id, volunteer_id=ana.id, shift_id=s2.id),
|
||||
]
|
||||
)
|
||||
session.commit()
|
||||
|
||||
resp = client.post(
|
||||
_url(edition),
|
||||
json={
|
||||
"subject": "Planning de {prenom}",
|
||||
"body": "Tu as {nb_creneaux} créneau(x), {nb_heures} h.\n{creneaux}",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["queued"] == 2
|
||||
assert body["skipped_no_email"] == 1
|
||||
assert len(outbox) == 2
|
||||
|
||||
by_to = {m["To"]: m for m in outbox}
|
||||
assert by_to["ana@e.fr"]["Subject"] == "Planning de Ana"
|
||||
ana_body = by_to["ana@e.fr"].get_content()
|
||||
assert "Tu as 2 créneau(x), 7.0 h." in ana_body
|
||||
assert "Bar" in ana_body
|
||||
assert "Tu as 0 créneau(x), 0.0 h." in by_to["bob@e.fr"].get_content()
|
||||
|
||||
|
||||
def test_default_template_is_used_when_body_omitted(client, edition, session, outbox):
|
||||
make_volunteer(session, edition, first_name="Ana", email="ana@e.fr")
|
||||
resp = client.post(_url(edition), json={})
|
||||
assert resp.status_code == 200
|
||||
assert outbox[0].get_content().startswith("Bonjour Ana,")
|
||||
|
||||
|
||||
def test_magic_link_is_embedded_when_placeholder_present(
|
||||
client, edition, session, make_account, outbox
|
||||
):
|
||||
ana = make_volunteer(session, edition, first_name="Ana", email="ana@e.fr")
|
||||
make_account(Role.VOLUNTEER, volunteer_id=ana.id, email="ana@e.fr")
|
||||
|
||||
resp = client.post(
|
||||
_url(edition), json={"body": "Connecte-toi : {lien_connexion}"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "/connexion?token=" in outbox[0].get_content()
|
||||
assert len(session.exec(select(MagicLinkToken)).all()) == 1
|
||||
|
||||
|
||||
def test_no_magic_link_token_without_placeholder(
|
||||
client, edition, session, make_account, outbox
|
||||
):
|
||||
ana = make_volunteer(session, edition, first_name="Ana", email="ana@e.fr")
|
||||
make_account(Role.VOLUNTEER, volunteer_id=ana.id, email="ana@e.fr")
|
||||
client.post(_url(edition), json={"body": "Bonjour {prenom}"})
|
||||
assert session.exec(select(MagicLinkToken)).all() == []
|
||||
|
||||
|
||||
def test_can_target_a_subset(client, edition, session, outbox):
|
||||
ana = make_volunteer(session, edition, email="ana@e.fr")
|
||||
make_volunteer(session, edition, email="bob@e.fr")
|
||||
resp = client.post(
|
||||
_url(edition), json={"volunteer_ids": [str(ana.id)]}
|
||||
)
|
||||
assert resp.json()["queued"] == 1
|
||||
assert outbox[0]["To"] == "ana@e.fr"
|
||||
|
||||
|
||||
def test_planning_notification_is_coordinator_only(
|
||||
edition, session, make_account, as_account, outbox
|
||||
):
|
||||
lead = make_account(Role.LEAD, email="lead@e.fr")
|
||||
resp = as_account(lead).post(_url(edition), json={})
|
||||
assert resp.status_code == 403
|
||||
@@ -31,7 +31,7 @@ def test_full_flow_magic_link_then_jwt_then_me(
|
||||
_url(edition, "magic-link"), json={"email": "REF@example.fr"}
|
||||
)
|
||||
assert resp.status_code == 202
|
||||
log_line = next(m for m in caplog.messages if "STUB lien magique" in m)
|
||||
log_line = next(m for m in caplog.messages if "Lien magique" in m)
|
||||
raw_token = log_line.split("jeton=")[-1].strip()
|
||||
|
||||
verified = raw_client.post(
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.email import sender
|
||||
|
||||
|
||||
def test_disabled_delivery_is_a_noop():
|
||||
# email_enabled=False par défaut en test : aucune exception, pas de SMTP.
|
||||
sender.send("a@b.fr", "Sujet", "Corps")
|
||||
sender.send_many([("a@b.fr", "S", "B"), ("c@d.fr", "S", "B")])
|
||||
|
||||
|
||||
def test_smtp_is_used_when_enabled(monkeypatch):
|
||||
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 <no-reply@test>",
|
||||
)
|
||||
monkeypatch.setattr(sender, "get_settings", lambda: fake)
|
||||
events: list = []
|
||||
|
||||
class FakeSMTP:
|
||||
def __init__(self, host, port, timeout=None):
|
||||
events.append(("connect", host, port))
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_a):
|
||||
return False
|
||||
|
||||
def starttls(self, context=None):
|
||||
events.append(("starttls",))
|
||||
|
||||
def login(self, user, _password):
|
||||
events.append(("login", user))
|
||||
|
||||
def send_message(self, message):
|
||||
events.append(("send", message["To"], message["Subject"]))
|
||||
|
||||
monkeypatch.setattr(sender.smtplib, "SMTP", FakeSMTP)
|
||||
|
||||
sender.send_many([("x@y.fr", "Sujet 1", "B"), ("z@y.fr", "Sujet 2", "B")])
|
||||
|
||||
assert ("connect", "mail.test", 587) in events
|
||||
assert ("starttls",) in events
|
||||
assert ("login", "u") in events
|
||||
assert ("send", "x@y.fr", "Sujet 1") in events
|
||||
assert ("send", "z@y.fr", "Sujet 2") in events
|
||||
|
||||
|
||||
def test_render_magic_link_uses_translations():
|
||||
from app.email import render_magic_link
|
||||
|
||||
subject, body = render_magic_link(link="https://x/connexion?token=abc", minutes=30)
|
||||
assert subject
|
||||
assert "https://x/connexion?token=abc" in body
|
||||
assert "30" in body
|
||||
Reference in New Issue
Block a user