diff --git a/.env.example b/.env.example index df352a5..680c171 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,19 @@ POSTGRES_DB=benevoles # À régénérer en production : openssl rand -hex 32 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 + # Frontend (service `frontend` du docker-compose). # 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 diff --git a/CLAUDE.md b/CLAUDE.md index 9f8e1cd..bf3bcdf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 **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 @@ -195,8 +214,8 @@ Sémantique figée : ### Hors périmètre (le modèle doit juste pouvoir les accueillir sans refonte) -Solveur OR-Tools · envoi d'emails / SMS · constructeur de formulaire · -pointage sur site · fonctions d'IA. +Solveur OR-Tools · SMS · constructeur de formulaire · pointage sur site · +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 │ │ │ # health, auth, accounts, imports, │ │ │ # volunteers, positions, shifts, -│ │ │ # assignments, matrix -│ │ ├── auth/ # lien magique + JWT + dépendances de rôle +│ │ │ # assignments, matrix, notifications +│ │ ├── auth/ # mot de passe + lien magique + JWT + rôles +│ │ ├── email/ # SMTP (sender) + rendu des emails (templates) │ │ ├── importing/ # import CSV/Excel (parser, values, service) │ │ ├── models/ # tables SQLModel │ │ ├── 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`, `SSH_KEY`, `DEPLOY_PATH`, `GHCR_TOKEN`. - 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 | | 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 | -| 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 -périmètre** (modèle prêt à les accueillir) : envoi réel d'emails/SMS (le lien -magique est journalisé, pas envoyé), solveur OR-Tools, constructeur de -formulaire, pointage sur site, IA. Le frontend (React/Vite/TS/Tailwind) reste -à démarrer. +**Reste hors périmètre** (modèle prêt à les accueillir) : solveur OR-Tools, +SMS, constructeur de formulaire, pointage sur site, IA. Frontend : +squelette + 6 écrans + connexion en place (voir `frontend/`). diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index b42b211..5b49e61 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -7,7 +7,6 @@ Deux systèmes, selon le compte : comptes sans mot de passe — référents et bénévoles. """ -import datetime as dt import logging import uuid @@ -24,10 +23,12 @@ from app.api.schemas import ( VerifyRequest, ) 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.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.db import get_session +from app.email import render_magic_link, send from app.models import Account, MagicLinkToken, local_now router = APIRouter(tags=["auth"]) @@ -130,24 +131,21 @@ def request_magic_link( Réponse identique dans tous les cas (pas de divulgation).""" account = _account_by_email(session, edition_id, payload.email) if account is not None and account.password_hash is None: - raw = new_raw_token() - token = MagicLinkToken( - 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) + settings = get_settings() + raw = issue_magic_link_token(session, account) session.commit() - # STUB : en production, ce lien part par email. Ici on le journalise - # (identifiants uniquement, aucune donnée nominative). - logger.info( - "STUB lien magique — compte=%s édition=%s expire=%s jeton=%s", - account.id, - edition_id, - token.expires_at.isoformat(), - raw, + subject, body = render_magic_link( + 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( + "Lien magique (email désactivé) — compte=%s jeton=%s", + account.id, + raw, + ) return {"status": "accepted"} diff --git a/backend/app/api/notifications.py b/backend/app/api/notifications.py new file mode 100644 index 0000000..b6251e3 --- /dev/null +++ b/backend/app/api/notifications.py @@ -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, + ) diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py index edec713..926f19b 100644 --- a/backend/app/api/schemas.py +++ b/backend/app/api/schemas.py @@ -233,3 +233,21 @@ class AccountCreate(ApiModel): volunteer_id: uuid.UUID | None = None # Requis pour un compte COORDINATION, interdit sinon. 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) diff --git a/backend/app/auth/magic_link.py b/backend/app/auth/magic_link.py new file mode 100644 index 0000000..9a98fa6 --- /dev/null +++ b/backend/app/auth/magic_link.py @@ -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}" diff --git a/backend/app/config.py b/backend/app/config.py index c6e0015..2189925 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -24,6 +24,21 @@ class Settings(BaseSettings): magic_link_ttl_minutes: int = 30 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 " + log_level: str = "INFO" cors_origins: list[str] = ["http://localhost:5173"] diff --git a/backend/app/email/__init__.py b/backend/app/email/__init__.py new file mode 100644 index 0000000..26bd469 --- /dev/null +++ b/backend/app/email/__init__.py @@ -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"] diff --git a/backend/app/email/sender.py b/backend/app/email/sender.py new file mode 100644 index 0000000..dfbf5b7 --- /dev/null +++ b/backend/app/email/sender.py @@ -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]) diff --git a/backend/app/email/templates.py b/backend/app/email/templates.py new file mode 100644 index 0000000..f770583 --- /dev/null +++ b/backend/app/email/templates.py @@ -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) diff --git a/backend/app/i18n/locales/fr.json b/backend/app/i18n/locales/fr.json index aaa6871..faf27e0 100644 --- a/backend/app/i18n/locales/fr.json +++ b/backend/app/i18n/locales/fr.json @@ -44,5 +44,14 @@ "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.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}" } diff --git a/backend/app/main.py b/backend/app/main.py index 8b921b2..2270b44 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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.imports import router as import_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.shifts import router as shift_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(assignment_router) app.include_router(matrix_router) +app.include_router(notification_router) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 039e25d..73311ec 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -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 diff --git a/backend/tests/test_api_notifications.py b/backend/tests/test_api_notifications.py new file mode 100644 index 0000000..27f32b2 --- /dev/null +++ b/backend/tests/test_api_notifications.py @@ -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 diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index d115936..3c67604 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -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( diff --git a/backend/tests/test_email.py b/backend/tests/test_email.py new file mode 100644 index 0000000..b7c9bdc --- /dev/null +++ b/backend/tests/test_email.py @@ -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 ", + ) + 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 diff --git a/deploy/README.md b/deploy/README.md index 19d0161..28f618a 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -27,7 +27,20 @@ cat > .env <<'EOF' POSTGRES_USER=benevoles POSTGRES_PASSWORD= POSTGRES_DB=benevoles -JWT_SECRET= +JWT_SECRET= +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= +SMTP_STARTTLS=true +SMTP_SSL=false +SMTP_FROM=Festival EOF ``` diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index 7a58494..0513c5b 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -2,7 +2,8 @@ name: benevoles # 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, -# 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: db: @@ -26,6 +27,15 @@ services: DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} ENVIRONMENT: production 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 depends_on: db: diff --git a/docker-compose.yml b/docker-compose.yml index 4f374a8..cffb495 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,6 +26,15 @@ services: TEST_DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-benevoles}:${POSTGRES_PASSWORD:-benevoles}@db:5432/benevoles_test ENVIRONMENT: development 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 } TZ: Europe/Paris ports: - "8000:8000"