É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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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"}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}"
|
||||||
@@ -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"]
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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])
|
||||||
@@ -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)
|
||||||
@@ -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}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"}
|
_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(
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user