Email de test et ajout manuel sans champ obligatoire
ci-cd / build-backend (push) Successful in 1m25s
ci-cd / build-frontend (push) Successful in 21s
ci-cd / deploy (push) Successful in 13s

Étape 67 : adresse + bouton dans l'onglet « Messages » pour envoyer tout
de suite un email de test, résultat réel affiché (envoyé, désactivé,
connexion ou message refusé, réponse du serveur SMTP) —
POST .../notifications/test.

Étape 66 : prénom et nom facultatifs à l'ajout manuel d'un bénévole ;
un bénévole sans nom s'affiche « Sans nom » / « ? » partout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-28 19:34:24 +01:00
co-authored by Claude Opus 5.5
parent 5c2cc0a6c3
commit 461742eb78
22 changed files with 346 additions and 28 deletions
+35 -1
View File
@@ -4,18 +4,22 @@ import logging
import uuid
from collections import defaultdict
from fastapi import APIRouter, BackgroundTasks, Depends
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlmodel import Session, select
from app.api.deps import get_edition
from app.api.schemas import (
PlanningNotificationRequest,
PlanningNotificationResult,
TestEmailRequest,
TestEmailResult,
)
from app.auth.deps import require_coordinator
from app.auth.magic_link import build_magic_link_url, issue_magic_link_token
from app.config import get_settings
from app.db import get_session
from app.email import edition_logo, render_planning, send_many
from app.email.sender import build_message, deliver_test
from app.i18n import t
from app.models import Account, Assignment, Edition, Position, Shift, Volunteer
@@ -41,6 +45,36 @@ def _login_link(session: Session, edition: Edition, volunteer: Volunteer) -> str
return build_magic_link_url(issue_magic_link_token(session, account))
@router.post("/test", response_model=TestEmailResult)
def send_test_email(
payload: TestEmailRequest,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> TestEmailResult:
"""Envoie tout de suite un email de test à l'adresse saisie (même mise
en forme et même logo que les vrais emails) et renvoie le résultat réel
du serveur SMTP — pour vérifier la configuration depuis l'écran
« Messages » sans accès au serveur (`python -m app.email.check`)."""
email = payload.email.strip()
if "@" not in email or email.startswith("@") or email.endswith("@") or " " in email:
raise HTTPException(status_code=422, detail={"code": "notifications.invalid_email"})
message = build_message(
email,
t("email.test.subject"),
t("email.test.body", edition=edition.name or str(edition.year)),
logo=edition_logo(session, edition.id),
)
status, detail = deliver_test(message)
settings = get_settings()
return TestEmailResult(
status=status,
detail=detail,
smtp_host=f"{settings.smtp_host}:{settings.smtp_port}",
smtp_from=settings.smtp_from,
)
@router.post("/planning", response_model=PlanningNotificationResult)
def send_planning_notification(
payload: PlanningNotificationRequest,
+19 -2
View File
@@ -60,8 +60,10 @@ class EditionUpdate(ApiModel):
# Volunteer #
# --------------------------------------------------------------------------- #
class VolunteerCreate(ApiModel):
first_name: str
last_name: str
# Facultatifs à l'ajout manuel par la coordination (fiche complétée plus
# tard) ; le formulaire public et l'import les exigent toujours.
first_name: str = ""
last_name: str = ""
email: str | None = None
phone: str | None = None
comment: str | None = None
@@ -514,6 +516,21 @@ class PlanningNotificationResult(ApiModel):
recipients: list[uuid.UUID] = Field(default_factory=list)
class TestEmailRequest(ApiModel):
email: str
class TestEmailResult(ApiModel):
"""`status` : `sent` / `disabled` / `connection_failed` / `rejected` —
voir `app/email/sender.py:deliver_test`. `detail` : message du serveur
SMTP en cas d'échec."""
status: str
detail: str | None = None
smtp_host: str
smtp_from: str
# --------------------------------------------------------------------------- #
# Formulaire public d'inscription #
# --------------------------------------------------------------------------- #
+33
View File
@@ -145,6 +145,39 @@ def deliver_many(messages: list[EmailMessage]) -> None:
logger.info("Emails remis : %d/%d", sent, len(messages))
def deliver_test(message: EmailMessage) -> tuple[str, str | None]:
"""Envoi d'un email de test, **synchrone** et sans « best effort » :
renvoie `(statut, détail)` au lieu de seulement journaliser, pour que la
coordination voie tout de suite ce qui ne va pas (`POST
.../notifications/test`). Statuts : `sent`, `disabled`
(`EMAIL_ENABLED=false`), `connection_failed` (connexion ou
authentification), `rejected` (le serveur a refusé le message). Détail :
message du serveur ou type d'erreur — jamais l'adresse du destinataire
(`_smtp_error_summary`)."""
settings = get_settings()
if not settings.email_enabled:
logger.info("Email de test non envoyé (EMAIL_ENABLED=false)")
return "disabled", None
try:
smtp = open_smtp(settings)
except smtplib.SMTPException as exc:
logger.error("Email de test : connexion SMTP refusée : %s", _smtp_error_summary(exc))
return "connection_failed", _smtp_error_summary(exc)
except OSError as exc:
logger.error("Email de test : SMTP injoignable : %s", exc)
return "connection_failed", f"{exc.__class__.__name__} : {exc}"
with smtp:
try:
smtp.send_message(message)
except smtplib.SMTPException as exc:
logger.error(
"Email de test refusé (From=%s) : %s", message["From"], _smtp_error_summary(exc)
)
return "rejected", _smtp_error_summary(exc)
logger.info("Email de test remis")
return "sent", None
def send(
to: str,
subject: str,
+3
View File
@@ -53,6 +53,9 @@
"email.registration_confirmation_paid.mecenat_receipt_note": "Tu trouveras en pièce jointe ton reçu fiscal au titre des dons (cerfa n° 11580*05), à conserver pour ta déclaration de revenus.",
"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.test.subject": "Email de test — gestion des bénévoles",
"email.test.body": "Bonjour,\n\nCet email de test confirme que l'envoi d'emails fonctionne pour {edition}.\n\nRien à faire de plus.",
"notifications.invalid_email": "Adresse email invalide.",
"email.planning.no_shifts": "Aucun créneau ne t'est affecté pour le moment.",
"email.planning.shift_line": "- {position} : {start} → {end}",
"badge.image_type_not_supported": "L'image doit être au format PNG ou JPEG.",