É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:
AdamThomas
2026-09-10 08:08:55 +02:00
co-authored by Claude Sonnet 5
parent 977bd4dc34
commit b268e308ab
19 changed files with 581 additions and 33 deletions
+13
View File
@@ -9,6 +9,19 @@ POSTGRES_DB=benevoles
# À régénérer en production : openssl rand -hex 32 # À régénérer en production : openssl rand -hex 32
JWT_SECRET=dev-insecure-secret-change-me-please-32b JWT_SECRET=dev-insecure-secret-change-me-please-32b
# URL publique du frontend (pour les liens dans les emails).
PUBLIC_BASE_URL=http://localhost:5173
# Envoi d'emails. EMAIL_ENABLED=false ⇒ les emails sont seulement journalisés.
EMAIL_ENABLED=false
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_STARTTLS=true
SMTP_SSL=false
SMTP_FROM=Festival des bénévoles <no-reply@example.test>
# Frontend (service `frontend` du docker-compose). # Frontend (service `frontend` du docker-compose).
# mock (défaut) : jeu de démonstration en mémoire, aucun backend requis. # mock (défaut) : jeu de démonstration en mémoire, aucun backend requis.
# http : API réelle — renseigner alors VITE_EDITION_ID avec l'UUID # http : API réelle — renseigner alors VITE_EDITION_ID avec l'UUID
+31 -11
View File
@@ -142,6 +142,25 @@ La coordination crée les comptes via `POST /editions/{id}/accounts` : un
`COORDINATOR` **exige** un `password` (≥ 10) ; un `LEAD` / `VOLUNTEER` le `COORDINATOR` **exige** un `password` (≥ 10) ; un `LEAD` / `VOLUNTEER` le
**refuse** (lien magique). Un bénévole sans email n'a pas de `Account`. **refuse** (lien magique). Un bénévole sans email n'a pas de `Account`.
### Emails (`app/email/`)
Envoi par **SMTP** (auto-hébergé). `EMAIL_ENABLED=false` (défaut dev/tests) ⇒
les emails sont seulement journalisés (`app.email`, INFO, **sans destinataire
ni corps** — RGPD). Config : `SMTP_HOST/PORT/USER/PASSWORD/STARTTLS/SSL/FROM`,
`PUBLIC_BASE_URL` (pour les liens).
- **Lien magique** : `POST .../auth/magic-link` envoie l'email contenant
`PUBLIC_BASE_URL` + `magic_link_path` + `?token=`. Sans SMTP, le jeton (non
nominatif) reste journalisé pour le dev.
- **Récapitulatif de planning** : `POST /editions/{id}/notifications/planning`
(COORDINATION), corps `{ subject?, body?, volunteer_ids? }`. `subject`/`body`
sont des **modèles** avec `{prenom}`, `{nom}`, `{nb_creneaux}`, `{nb_heures}`,
`{creneaux}`, `{lien_connexion}` (si présent ⇒ un lien magique personnel est
émis pour le compte du bénévole). `volunteer_ids` absent ⇒ tous les bénévoles
de l'édition qui ont un email. Envoi en tâche de fond (`BackgroundTasks`,
une connexion SMTP réutilisée). Réponse : `{ queued, skipped_no_email,
recipients }`.
--- ---
## 3. Moteur de validation des affectations — cœur du produit ## 3. Moteur de validation des affectations — cœur du produit
@@ -195,8 +214,8 @@ Sémantique figée :
### Hors périmètre (le modèle doit juste pouvoir les accueillir sans refonte) ### Hors périmètre (le modèle doit juste pouvoir les accueillir sans refonte)
Solveur OR-Tools · envoi d'emails / SMS · constructeur de formulaire · Solveur OR-Tools · SMS · constructeur de formulaire · pointage sur site ·
pointage sur site · fonctions d'IA. fonctions d'IA. *(L'envoi d'emails par SMTP est en place — voir § 2.)*
--- ---
@@ -225,8 +244,9 @@ pointage sur site · fonctions d'IA.
│ │ ├── api/ # routers + schemas Pydantic + deps │ │ ├── api/ # routers + schemas Pydantic + deps
│ │ │ # health, auth, accounts, imports, │ │ │ # health, auth, accounts, imports,
│ │ │ # volunteers, positions, shifts, │ │ │ # volunteers, positions, shifts,
│ │ │ # assignments, matrix │ │ │ # assignments, matrix, notifications
│ │ ├── auth/ # lien magique + JWT + dépendances de rôle │ │ ├── auth/ # mot de passe + lien magique + JWT + rôles
│ │ ├── email/ # SMTP (sender) + rendu des emails (templates)
│ │ ├── importing/ # import CSV/Excel (parser, values, service) │ │ ├── importing/ # import CSV/Excel (parser, values, service)
│ │ ├── models/ # tables SQLModel │ │ ├── models/ # tables SQLModel
│ │ ├── validation/ # moteur pur (models, engine) + loader (base) │ │ ├── validation/ # moteur pur (models, engine) + loader (base)
@@ -292,7 +312,8 @@ Aucune dépendance locale requise hors Docker.
- Secrets requis (voir `deploy/README.md`) : `SSH_HOST`, `SSH_USER`, - Secrets requis (voir `deploy/README.md`) : `SSH_HOST`, `SSH_USER`,
`SSH_KEY`, `DEPLOY_PATH`, `GHCR_TOKEN`. `SSH_KEY`, `DEPLOY_PATH`, `GHCR_TOKEN`.
- Le VPS porte un fichier `.env` (non versionné) avec `POSTGRES_USER`, - Le VPS porte un fichier `.env` (non versionné) avec `POSTGRES_USER`,
`POSTGRES_PASSWORD`, `POSTGRES_DB`, `JWT_SECRET`. `POSTGRES_PASSWORD`, `POSTGRES_DB`, `JWT_SECRET`, `PUBLIC_BASE_URL` et les
variables `EMAIL_ENABLED` / `SMTP_*` (voir `deploy/README.md`).
--- ---
@@ -308,10 +329,9 @@ Aucune dépendance locale requise hors Docker.
| 6 | Jeu de données de démonstration (`app/seed.py`) | validée | | 6 | Jeu de données de démonstration (`app/seed.py`) | validée |
| 7 | Authentification : lien magique + JWT + application des rôles | validée | | 7 | Authentification : lien magique + JWT + application des rôles | validée |
| 8 | Second système : connexion mot de passe (bcrypt) pour la coordination | validée | | 8 | Second système : connexion mot de passe (bcrypt) pour la coordination | validée |
| 9 | Suppression du statut d'affectation (plus d'étape de confirmation) | à valider | | 9 | Suppression du statut d'affectation (plus d'étape de confirmation) | validée |
| 10 | Envoi d'emails par SMTP : lien magique + récapitulatif de planning | à valider |
Premier incrément backend + authentification terminés. **Reste hors **Reste hors périmètre** (modèle prêt à les accueillir) : solveur OR-Tools,
périmètre** (modèle prêt à les accueillir) : envoi réel d'emails/SMS (le lien SMS, constructeur de formulaire, pointage sur site, IA. Frontend :
magique est journalisé, pas envoyé), solveur OR-Tools, constructeur de squelette + 6 écrans + connexion en place (voir `frontend/`).
formulaire, pointage sur site, IA. Le frontend (React/Vite/TS/Tailwind) reste
à démarrer.
+13 -15
View File
@@ -7,7 +7,6 @@ Deux systèmes, selon le compte :
comptes sans mot de passe — référents et bénévoles. comptes sans mot de passe — référents et bénévoles.
""" """
import datetime as dt
import logging import logging
import uuid import uuid
@@ -24,10 +23,12 @@ from app.api.schemas import (
VerifyRequest, VerifyRequest,
) )
from app.auth.deps import get_current_account from app.auth.deps import get_current_account
from app.auth.magic_link import build_magic_link_url, issue_magic_link_token
from app.auth.passwords import MIN_LENGTH, hash_password, verify_password from app.auth.passwords import MIN_LENGTH, hash_password, verify_password
from app.auth.tokens import create_access_token, hash_token, new_raw_token from app.auth.tokens import create_access_token, hash_token
from app.config import get_settings from app.config import get_settings
from app.db import get_session from app.db import get_session
from app.email import render_magic_link, send
from app.models import Account, MagicLinkToken, local_now from app.models import Account, MagicLinkToken, local_now
router = APIRouter(tags=["auth"]) router = APIRouter(tags=["auth"])
@@ -130,22 +131,19 @@ def request_magic_link(
Réponse identique dans tous les cas (pas de divulgation).""" Réponse identique dans tous les cas (pas de divulgation)."""
account = _account_by_email(session, edition_id, payload.email) account = _account_by_email(session, edition_id, payload.email)
if account is not None and account.password_hash is None: if account is not None and account.password_hash is None:
raw = new_raw_token() settings = get_settings()
token = MagicLinkToken( raw = issue_magic_link_token(session, account)
account_id=account.id,
token_hash=hash_token(raw),
expires_at=local_now()
+ dt.timedelta(minutes=get_settings().magic_link_ttl_minutes),
)
session.add(token)
session.commit() session.commit()
# STUB : en production, ce lien part par email. Ici on le journalise subject, body = render_magic_link(
# (identifiants uniquement, aucune donnée nominative). link=build_magic_link_url(raw),
minutes=settings.magic_link_ttl_minutes,
)
send(account.email, subject, body)
if not settings.email_enabled:
# Aide au dev sans SMTP : le jeton (non nominatif) est journalisé.
logger.info( logger.info(
"STUB lien magique — compte=%s édition=%s expire=%s jeton=%s", "Lien magique (email désactivé) — compte=%s jeton=%s",
account.id, account.id,
edition_id,
token.expires_at.isoformat(),
raw, raw,
) )
return {"status": "accepted"} return {"status": "accepted"}
+120
View File
@@ -0,0 +1,120 @@
"""Envoi d'emails d'information aux bénévoles (récapitulatif de planning)."""
import uuid
from collections import defaultdict
from fastapi import APIRouter, BackgroundTasks, Depends
from sqlmodel import Session, select
from app.api.deps import get_edition
from app.api.schemas import (
PlanningNotificationRequest,
PlanningNotificationResult,
)
from app.auth.deps import require_coordinator
from app.auth.magic_link import build_magic_link_url, issue_magic_link_token
from app.db import get_session
from app.email import render_planning, send_many
from app.i18n import t
from app.models import Account, Assignment, Edition, Position, Shift, Volunteer
router = APIRouter(
prefix="/editions/{edition_id}/notifications", tags=["notifications"]
)
_LOGIN_PLACEHOLDER = "{lien_connexion}"
def _login_link(session: Session, edition: Edition, volunteer: Volunteer) -> str:
"""Lien magique personnel si le bénévole a un compte, sinon l'accueil."""
account = session.exec(
select(Account)
.where(Account.edition_id == edition.id)
.where(Account.volunteer_id == volunteer.id)
.where(Account.password_hash.is_(None))
).first()
if account is None:
return build_magic_link_url("").split("?")[0]
return build_magic_link_url(issue_magic_link_token(session, account))
@router.post("/planning", response_model=PlanningNotificationResult)
def send_planning_notification(
payload: PlanningNotificationRequest,
background: BackgroundTasks,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> PlanningNotificationResult:
subject_template = payload.subject or t("email.planning.default_subject")
body_template = payload.body or t("email.planning.default_body")
wants_login_link = _LOGIN_PLACEHOLDER in subject_template or (
_LOGIN_PLACEHOLDER in body_template
)
stmt = select(Volunteer).where(Volunteer.edition_id == edition.id)
if payload.volunteer_ids is not None:
stmt = stmt.where(Volunteer.id.in_(payload.volunteer_ids))
volunteers = list(session.exec(stmt.order_by(Volunteer.last_name)))
positions = {
p.id: p
for p in session.exec(
select(Position).where(Position.edition_id == edition.id)
)
}
shifts_by_volunteer: dict[uuid.UUID, list[Shift]] = defaultdict(list)
for volunteer_id, shift in session.exec(
select(Assignment.volunteer_id, Shift)
.join(Shift, Shift.id == Assignment.shift_id)
.where(Assignment.edition_id == edition.id)
):
shifts_by_volunteer[volunteer_id].append(shift)
items: list[tuple[str, str, str]] = []
recipients: list[uuid.UUID] = []
skipped_no_email = 0
for volunteer in volunteers:
if not volunteer.email:
skipped_no_email += 1
continue
shifts = sorted(
shifts_by_volunteer.get(volunteer.id, []),
key=lambda s: s.starts_at,
)
minutes = sum(
(s.ends_at - s.starts_at).total_seconds() / 60 for s in shifts
)
shifts_text = "\n".join(
t(
"email.planning.shift_line",
position=positions[s.position_id].name,
start=s.starts_at.strftime("%d/%m %H:%M"),
end=s.ends_at.strftime("%d/%m %H:%M"),
)
for s in shifts
) or t("email.planning.no_shifts")
subject, body = render_planning(
subject_template=subject_template,
body_template=body_template,
first_name=volunteer.first_name,
last_name=volunteer.last_name,
shift_count=len(shifts),
hours=round(minutes / 60, 1),
shifts_text=shifts_text,
login_link=(
_login_link(session, edition, volunteer)
if wants_login_link
else ""
),
)
items.append((volunteer.email, subject, body))
recipients.append(volunteer.id)
session.commit() # persiste les éventuels MagicLinkToken émis
background.add_task(send_many, items)
return PlanningNotificationResult(
queued=len(items),
skipped_no_email=skipped_no_email,
recipients=recipients,
)
+18
View File
@@ -233,3 +233,21 @@ class AccountCreate(ApiModel):
volunteer_id: uuid.UUID | None = None volunteer_id: uuid.UUID | None = None
# Requis pour un compte COORDINATION, interdit sinon. # Requis pour un compte COORDINATION, interdit sinon.
password: str | None = None password: str | None = None
# --------------------------------------------------------------------------- #
# Notifications par email #
# --------------------------------------------------------------------------- #
class PlanningNotificationRequest(ApiModel):
# Modèle saisi par la coordination. Variables : {prenom}, {nom},
# {nb_creneaux}, {nb_heures}, {creneaux}, {lien_connexion}.
subject: str | None = None
body: str | None = None
# None = tous les bénévoles de l'édition qui ont un email.
volunteer_ids: list[uuid.UUID] | None = None
class PlanningNotificationResult(ApiModel):
queued: int
skipped_no_email: int
recipients: list[uuid.UUID] = Field(default_factory=list)
+32
View File
@@ -0,0 +1,32 @@
"""Émission d'un jeton de lien magique et construction de l'URL publique.
Partagé par l'endpoint d'authentification et par les emails de planning."""
import datetime as dt
from sqlmodel import Session
from app.auth.tokens import hash_token, new_raw_token
from app.config import get_settings
from app.models import Account, MagicLinkToken, local_now
def issue_magic_link_token(session: Session, account: Account) -> str:
"""Crée un `MagicLinkToken` pour le compte et renvoie le jeton en clair.
N'effectue pas de commit."""
raw = new_raw_token()
session.add(
MagicLinkToken(
account_id=account.id,
token_hash=hash_token(raw),
expires_at=local_now()
+ dt.timedelta(minutes=get_settings().magic_link_ttl_minutes),
)
)
return raw
def build_magic_link_url(raw_token: str) -> str:
settings = get_settings()
base = settings.public_base_url.rstrip("/")
return f"{base}{settings.magic_link_path}?token={raw_token}"
+15
View File
@@ -24,6 +24,21 @@ class Settings(BaseSettings):
magic_link_ttl_minutes: int = 30 magic_link_ttl_minutes: int = 30
session_ttl_hours: int = 12 session_ttl_hours: int = 12
# URL publique du frontend, pour construire les liens des emails.
public_base_url: str = "http://localhost:5173"
magic_link_path: str = "/connexion"
# Envoi d'emails (SMTP). email_enabled=False ⇒ les envois sont seulement
# journalisés (dev, tests).
email_enabled: bool = False
smtp_host: str = "localhost"
smtp_port: int = 587
smtp_user: str = ""
smtp_password: str = ""
smtp_starttls: bool = True
smtp_ssl: bool = False
smtp_from: str = "Festival des bénévoles <no-reply@example.test>"
log_level: str = "INFO" log_level: str = "INFO"
cors_origins: list[str] = ["http://localhost:5173"] cors_origins: list[str] = ["http://localhost:5173"]
+10
View File
@@ -0,0 +1,10 @@
"""Envoi d'emails par SMTP.
`Settings.email_enabled=False` ⇒ les messages sont seulement journalisés
(sans destinataire ni corps — RGPD). Voir CLAUDE.md.
"""
from app.email.sender import send, send_many
from app.email.templates import render_magic_link, render_planning
__all__ = ["render_magic_link", "render_planning", "send", "send_many"]
+64
View File
@@ -0,0 +1,64 @@
"""Construction et remise des emails.
`deliver_many` est le point d'injection unique (remplacé dans les tests)."""
import logging
import smtplib
import ssl
from collections.abc import Iterable
from email.message import EmailMessage
from app.config import get_settings
logger = logging.getLogger("app.email")
def build_message(to: str, subject: str, body: str) -> EmailMessage:
message = EmailMessage()
message["From"] = get_settings().smtp_from
message["To"] = to
message["Subject"] = subject
message.set_content(body)
return message
def deliver_many(messages: list[EmailMessage]) -> None:
settings = get_settings()
if not settings.email_enabled:
# RGPD : ni destinataire ni corps dans les logs.
logger.info(
"Emails non envoyés (EMAIL_ENABLED=false) — %d message(s)",
len(messages),
)
return
if not messages:
return
context = ssl.create_default_context()
if settings.smtp_ssl:
client = smtplib.SMTP_SSL(
settings.smtp_host, settings.smtp_port, timeout=30, context=context
)
else:
client = smtplib.SMTP(
settings.smtp_host, settings.smtp_port, timeout=30
)
with client as smtp:
if settings.smtp_starttls and not settings.smtp_ssl:
smtp.starttls(context=context)
if settings.smtp_user:
smtp.login(settings.smtp_user, settings.smtp_password)
for message in messages:
try:
smtp.send_message(message)
except smtplib.SMTPException:
logger.warning("Échec d'envoi d'un email (destinataire masqué)")
def send(to: str, subject: str, body: str) -> None:
deliver_many([build_message(to, subject, body)])
def send_many(items: Iterable[tuple[str, str, str]]) -> None:
"""`items` = itérable de `(destinataire, sujet, corps)`."""
deliver_many([build_message(*item) for item in items])
+50
View File
@@ -0,0 +1,50 @@
"""Rendu des emails. Les textes vivent dans `locales/fr.json` (`email.*`)."""
from app.i18n import t
# Variables autorisées dans un modèle d'email de planning saisi par la
# coordination.
_PLANNING_PLACEHOLDERS = (
"prenom",
"nom",
"nb_creneaux",
"nb_heures",
"creneaux",
"lien_connexion",
)
def render_magic_link(*, link: str, minutes: int) -> tuple[str, str]:
return (
t("email.magic_link.subject"),
t("email.magic_link.body", link=link, minutes=minutes),
)
def _fill(template: str, values: dict[str, object]) -> str:
out = template
for key in _PLANNING_PLACEHOLDERS:
out = out.replace("{" + key + "}", str(values.get(key, "")))
return out
def render_planning(
*,
subject_template: str,
body_template: str,
first_name: str,
last_name: str,
shift_count: int,
hours: float,
shifts_text: str,
login_link: str,
) -> tuple[str, str]:
values: dict[str, object] = {
"prenom": first_name,
"nom": last_name,
"nb_creneaux": shift_count,
"nb_heures": hours,
"creneaux": shifts_text,
"lien_connexion": login_link,
}
return _fill(subject_template, values), _fill(body_template, values)
+10 -1
View File
@@ -44,5 +44,14 @@
"validation.OUTSIDE_AVAILABILITY": "Hors des disponibilités déclarées ({date}, {day_part_code}).", "validation.OUTSIDE_AVAILABILITY": "Hors des disponibilités déclarées ({date}, {day_part_code}).",
"validation.DAILY_HOURS_EXCEEDED": "Plus de 8 h d'affectation cumulées le {date}.", "validation.DAILY_HOURS_EXCEEDED": "Plus de 8 h d'affectation cumulées le {date}.",
"validation.INSUFFICIENT_REST": "Moins de 8 h de repos entre deux créneaux.", "validation.INSUFFICIENT_REST": "Moins de 8 h de repos entre deux créneaux.",
"validation.MISSING_REQUIRED_SKILL": "Compétence requise absente : {skill_code}." "validation.MISSING_REQUIRED_SKILL": "Compétence requise absente : {skill_code}.",
"notification.no_recipients": "Aucun bénévole ciblé.",
"email.magic_link.subject": "Ton lien de connexion",
"email.magic_link.body": "Bonjour,\n\nVoici ton lien de connexion, valable {minutes} minutes :\n\n{link}\n\nSi tu n'es pas à l'origine de cette demande, ignore simplement cet email.",
"email.planning.default_subject": "Ton planning bénévole",
"email.planning.default_body": "Bonjour {prenom},\n\nTon planning a été mis à jour : {nb_creneaux} créneau(x), soit {nb_heures} h au total.\n\n{creneaux}\n\nÀ très vite sur le festival !",
"email.planning.no_shifts": "Aucun créneau ne t'est affecté pour le moment.",
"email.planning.shift_line": "- {position} : {start} → {end}"
} }
+2
View File
@@ -7,6 +7,7 @@ from app.api.auth import router as auth_router
from app.api.health import router as health_router from app.api.health import router as health_router
from app.api.imports import router as import_router from app.api.imports import router as import_router
from app.api.matrix import router as matrix_router from app.api.matrix import router as matrix_router
from app.api.notifications import router as notification_router
from app.api.positions import router as position_router from app.api.positions import router as position_router
from app.api.shifts import router as shift_router from app.api.shifts import router as shift_router
from app.api.volunteers import router as volunteer_router from app.api.volunteers import router as volunteer_router
@@ -36,3 +37,4 @@ app.include_router(position_router)
app.include_router(shift_router) app.include_router(shift_router)
app.include_router(assignment_router) app.include_router(assignment_router)
app.include_router(matrix_router) app.include_router(matrix_router)
app.include_router(notification_router)
+11
View File
@@ -141,3 +141,14 @@ def raw_client(session):
with TestClient(app) as c: with TestClient(app) as c:
yield c yield c
app.dependency_overrides.clear() 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
+92
View File
@@ -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
+1 -1
View File
@@ -31,7 +31,7 @@ def test_full_flow_magic_link_then_jwt_then_me(
_url(edition, "magic-link"), json={"email": "REF@example.fr"} _url(edition, "magic-link"), json={"email": "REF@example.fr"}
) )
assert resp.status_code == 202 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() raw_token = log_line.split("jeton=")[-1].strip()
verified = raw_client.post( verified = raw_client.post(
+62
View File
@@ -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
+14 -1
View File
@@ -27,7 +27,20 @@ cat > .env <<'EOF'
POSTGRES_USER=benevoles POSTGRES_USER=benevoles
POSTGRES_PASSWORD=<mot de passe fort> POSTGRES_PASSWORD=<mot de passe fort>
POSTGRES_DB=benevoles POSTGRES_DB=benevoles
JWT_SECRET=<secret fort> JWT_SECRET=<secret fort ; openssl rand -hex 32>
PUBLIC_BASE_URL=https://benevoles.mon-festival.fr
# Emails (récapitulatifs de planning + liens de connexion). Laisser
# EMAIL_ENABLED=false tant que le SMTP n'est pas prêt : les emails sont alors
# seulement journalisés.
EMAIL_ENABLED=true
SMTP_HOST=smtp.mon-festival.fr
SMTP_PORT=587
SMTP_USER=no-reply@mon-festival.fr
SMTP_PASSWORD=<mot de passe SMTP>
SMTP_STARTTLS=true
SMTP_SSL=false
SMTP_FROM=Festival <no-reply@mon-festival.fr>
EOF EOF
``` ```
+11 -1
View File
@@ -2,7 +2,8 @@ name: benevoles
# Déployé sur le VPS. Les variables sont lues depuis un fichier .env situé à # Déployé sur le VPS. Les variables sont lues depuis un fichier .env situé à
# côté de ce fichier (non versionné) : POSTGRES_USER, POSTGRES_PASSWORD, # côté de ce fichier (non versionné) : POSTGRES_USER, POSTGRES_PASSWORD,
# POSTGRES_DB, JWT_SECRET. BACKEND_IMAGE est exporté par le workflow de déploiement. # POSTGRES_DB, JWT_SECRET, PUBLIC_BASE_URL et les variables SMTP_* / EMAIL_ENABLED.
# BACKEND_IMAGE est exporté par le workflow de déploiement.
services: services:
db: db:
@@ -26,6 +27,15 @@ services:
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
ENVIRONMENT: production ENVIRONMENT: production
JWT_SECRET: ${JWT_SECRET} JWT_SECRET: ${JWT_SECRET}
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL}
EMAIL_ENABLED: ${EMAIL_ENABLED:-false}
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_STARTTLS: ${SMTP_STARTTLS:-true}
SMTP_SSL: ${SMTP_SSL:-false}
SMTP_FROM: ${SMTP_FROM:-}
TZ: Europe/Paris TZ: Europe/Paris
depends_on: depends_on:
db: db:
+9
View File
@@ -26,6 +26,15 @@ services:
TEST_DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-benevoles}:${POSTGRES_PASSWORD:-benevoles}@db:5432/benevoles_test TEST_DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-benevoles}:${POSTGRES_PASSWORD:-benevoles}@db:5432/benevoles_test
ENVIRONMENT: development ENVIRONMENT: development
JWT_SECRET: ${JWT_SECRET:-dev-insecure-secret-change-me-please-32b} JWT_SECRET: ${JWT_SECRET:-dev-insecure-secret-change-me-please-32b}
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-http://localhost:5173}
EMAIL_ENABLED: ${EMAIL_ENABLED:-false}
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_STARTTLS: ${SMTP_STARTTLS:-true}
SMTP_SSL: ${SMTP_SSL:-false}
SMTP_FROM: ${SMTP_FROM:-Festival des bénévoles <no-reply@example.test>}
TZ: Europe/Paris TZ: Europe/Paris
ports: ports:
- "8000:8000" - "8000:8000"