diff --git a/CLAUDE.md b/CLAUDE.md index 1efb329..ef1a695 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -608,9 +608,16 @@ ni corps**). Config : `SMTP_HOST/PORT/USER/PASSWORD/STARTTLS/SSL/FROM`, - **Email de test depuis l'appli** : bloc « Tester l'envoi d'emails » de l'onglet « Messages » de la configuration (`ConfigMail.tsx`) — une adresse et un bouton. `POST /editions/{id}/notifications/test` (`{email}`, - COORDINATION) envoie **tout de suite** (pas en tâche de fond) un email de - test, même mise en forme et même logo que les vrais, et renvoie le - résultat réel (`app/email/sender.py:deliver_test`) : `sent`, `disabled` + COORDINATION) envoie **tout de suite** (pas en tâche de fond) le + **récapitulatif de planning tel que les bénévoles le recevront** : le + modèle en cours d'édition (`subject`/`body`, case « avec le lien de + connexion » = même corps que le bouton du même nom), variables remplies + avec le planning d'un vrai bénévole (`volunteer_id`, à défaut le premier + par nom qui a un créneau, à défaut un exemple fictif), sujet préfixé + « [Test] », même mise en forme et même logo — code partagé avec l'envoi + réel (`notifications.py:_planning_email`). `{lien_connexion}` y mène à la + page de connexion **sans jeton** (jamais de vrai lien magique vers la + boîte de test). Renvoie le résultat réel (`app/email/sender.py:deliver_test`) : `sent`, `disabled` (`EMAIL_ENABLED=false`), `connection_failed`, `rejected`, avec la réponse du serveur SMTP (jamais l'adresse du destinataire), le serveur et l'expéditeur configurés — sans accès au serveur ni à ses logs. @@ -1081,6 +1088,7 @@ Aucune dépendance locale requise hors Docker. | 65 | Feuilles de poste compactées : bénévoles deux par ligne (vignette 10 mm, nom et téléphone superposés), titres, en-têtes de créneau et marges resserrés — sur un jeu réaliste de 6 feuilles, 11 pages ⇒ 6 (une par poste et par jour) | à valider | | 66 | Ajout manuel d'un bénévole sans champ obligatoire (prénom et nom facultatifs, côté formulaire et API) ; un bénévole sans nom s'affiche « Sans nom » / « ? » dans les listes, la grille, la vue par bénévole, les badges et l'écran référent | à valider | | 67 | Email de test : adresse + bouton dans l'onglet « Messages » de la configuration, envoi immédiat et résultat réel affiché (envoyé, envoi désactivé, connexion refusée, message refusé, avec la réponse du serveur SMTP) — `POST .../notifications/test` | à valider | +| 68 | L'email de test est le récapitulatif de planning compilé tel qu'envoyé aux bénévoles (modèle en cours d'édition, variables remplies avec le planning d'un bénévole au choix, option lien de connexion — sans vrai jeton —, sujet « [Test] … ») | à valider | **Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur de formulaire, IA. Frontend : squelette + 6 écrans + diff --git a/backend/app/api/notifications.py b/backend/app/api/notifications.py index 1aa5f0a..8675c60 100644 --- a/backend/app/api/notifications.py +++ b/backend/app/api/notifications.py @@ -45,6 +45,41 @@ def _login_link(session: Session, edition: Edition, volunteer: Volunteer) -> str return build_magic_link_url(issue_magic_link_token(session, account)) +def _planning_email( + *, + subject_template: str, + body_template: str, + first_name: str, + last_name: str, + shifts: list[Shift], + positions: dict[uuid.UUID, Position], + login_link: str, +) -> tuple[str, str]: + """Sujet et corps du récapitulatif de planning d'un bénévole — partagé + par l'envoi réel et l'email de test.""" + shifts = sorted(shifts, 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") + return render_planning( + subject_template=subject_template, + body_template=body_template, + first_name=first_name, + last_name=last_name, + shift_count=len(shifts), + hours=round(minutes / 60, 1), + shifts_text=shifts_text, + login_link=login_link, + ) + + @router.post("/test", response_model=TestEmailResult) def send_test_email( payload: TestEmailRequest, @@ -52,17 +87,54 @@ def send_test_email( _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`).""" + """Envoie tout de suite à l'adresse saisie le récapitulatif de planning + **tel que les bénévoles le recevront** : le modèle en cours d'édition + (`subject`/`body`), variables remplies avec les données d'un vrai + bénévole, même mise en forme et même logo — sujet préfixé « [Test] ». + Renvoie le résultat réel du serveur SMTP, pour vérifier à la fois le + contenu et la configuration d'envoi depuis l'écran « Messages ». + `{lien_connexion}` y est remplacé par l'adresse de la page de connexion, + **sans jeton** : un vrai lien magique ouvrirait le compte du bénévole à + quiconque lit la boîte de test.""" 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"}) + + 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) + + volunteers = list( + session.exec( + select(Volunteer) + .where(Volunteer.edition_id == edition.id) + .order_by(Volunteer.last_name, Volunteer.first_name) + ) + ) + example = next((v for v in volunteers if v.id == payload.volunteer_id), None) or next( + (v for v in volunteers if shifts_by_volunteer.get(v.id)), + volunteers[0] if volunteers else None, + ) + subject, body = _planning_email( + subject_template=payload.subject or t("email.planning.default_subject"), + body_template=payload.body or t("email.planning.default_body"), + first_name=example.first_name if example else t("email.test.example_first_name"), + last_name=example.last_name if example else t("email.test.example_last_name"), + shifts=shifts_by_volunteer.get(example.id, []) if example else [], + positions=positions, + login_link=build_magic_link_url("").split("?")[0], + ) message = build_message( email, - t("email.test.subject"), - t("email.test.body", edition=edition.name or str(edition.year)), + t("email.test.subject_prefix", subject=subject), + body, logo=edition_logo(session, edition.id), ) status, detail = deliver_test(message) @@ -72,6 +144,10 @@ def send_test_email( detail=detail, smtp_host=f"{settings.smtp_host}:{settings.smtp_port}", smtp_from=settings.smtp_from, + example_volunteer_id=example.id if example else None, + example_volunteer_name=( + f"{example.first_name} {example.last_name}".strip() if example else "" + ), ) @@ -115,30 +191,13 @@ def send_planning_notification( 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, body = _planning_email( 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, + shifts=shifts_by_volunteer.get(volunteer.id, []), + positions=positions, login_link=( _login_link(session, edition, volunteer) if wants_login_link diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py index 1796966..c75daa2 100644 --- a/backend/app/api/schemas.py +++ b/backend/app/api/schemas.py @@ -518,6 +518,14 @@ class PlanningNotificationResult(ApiModel): class TestEmailRequest(ApiModel): email: str + # Modèle du récapitulatif de planning en cours d'édition (mêmes + # variables que `PlanningNotificationRequest`) ; absent = modèle par + # défaut. + subject: str | None = None + body: str | None = None + # Bénévole dont les données remplissent les variables ; absent = le + # premier (par nom) qui a au moins un créneau. + volunteer_id: uuid.UUID | None = None class TestEmailResult(ApiModel): @@ -529,6 +537,10 @@ class TestEmailResult(ApiModel): detail: str | None = None smtp_host: str smtp_from: str + # Bénévole dont les données ont rempli le modèle (`None` : exemple + # fictif, l'édition n'a encore aucun bénévole). + example_volunteer_id: uuid.UUID | None = None + example_volunteer_name: str # --------------------------------------------------------------------------- # diff --git a/backend/app/i18n/locales/fr.json b/backend/app/i18n/locales/fr.json index fceff4e..b50d3ee 100644 --- a/backend/app/i18n/locales/fr.json +++ b/backend/app/i18n/locales/fr.json @@ -53,8 +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.", + "email.test.subject_prefix": "[Test] {subject}", + "email.test.example_first_name": "Camille", + "email.test.example_last_name": "Exemple", "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}", diff --git a/backend/tests/test_api_test_email.py b/backend/tests/test_api_test_email.py index 5746720..0685f21 100644 --- a/backend/tests/test_api_test_email.py +++ b/backend/tests/test_api_test_email.py @@ -81,3 +81,64 @@ def test_invalid_address_is_rejected(client, edition, email): def test_is_coordinator_only(edition, make_account, as_account): lead = as_account(make_account(Role.LEAD)) assert lead.post(_url(edition), json={"email": "moi@exemple.fr"}).status_code == 403 + + +def _send(client, edition, monkeypatch, **payload): + captured = [] + monkeypatch.setattr( + "app.api.notifications.deliver_test", + lambda message: captured.append(message) or ("sent", None), + ) + body = client.post(_url(edition), json={"email": "moi@exemple.fr", **payload}).json() + return body, captured[0] + + +def test_test_email_is_the_compiled_planning_email(client, edition, session, monkeypatch): + from app.models import Assignment + from tests.factories import dt_at, make_position, make_shift, make_volunteer + from tests.mail import plain_text + + make_volunteer(session, edition, first_name="Zoé", last_name="Zébu") # sans créneau + alex = make_volunteer(session, edition, first_name="Alex", last_name="Martin") + shift = make_shift(session, edition, make_position(session, edition, name="Bar"), + start=dt_at(10, 18), end=dt_at(10, 22)) + session.add(Assignment(edition_id=edition.id, volunteer_id=alex.id, shift_id=shift.id)) + session.commit() + + body, message = _send( + client, edition, monkeypatch, + subject="Planning de {prenom}", + body="Salut {prenom} {nom} : {nb_creneaux} créneau(x), {nb_heures} h\n{creneaux}", + ) + # Premier bénévole (par nom) qui a un créneau, pas Zoé. + assert body["example_volunteer_name"] == "Alex Martin" + assert message["Subject"] == "[Test] Planning de Alex" + text = plain_text(message) + assert "Salut Alex Martin : 1 créneau(x), 4.0 h" in text + assert "Bar" in text + assert "{" not in text + + +def test_login_link_in_test_email_carries_no_token(client, edition, session, monkeypatch): + from sqlmodel import select + + from app.models import MagicLinkToken + from tests.factories import make_volunteer + from tests.mail import plain_text + + make_volunteer(session, edition) + _, message = _send(client, edition, monkeypatch, body="Connexion : {lien_connexion}") + assert "token=" not in plain_text(message) + assert session.exec(select(MagicLinkToken)).all() == [] + + +def test_chosen_volunteer_and_fictitious_example(client, edition, session, monkeypatch): + from tests.factories import make_volunteer + + body, message = _send(client, edition, monkeypatch, subject="Pour {prenom}") + assert body["example_volunteer_id"] is None + assert message["Subject"] == "[Test] Pour Camille" + + sam = make_volunteer(session, edition, first_name="Sam", last_name="Nuit") + body, _ = _send(client, edition, monkeypatch, volunteer_id=str(sam.id)) + assert body["example_volunteer_id"] == str(sam.id) diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts index 16eb525..9c2dc7e 100644 --- a/frontend/src/api/hooks.ts +++ b/frontend/src/api/hooks.ts @@ -39,6 +39,7 @@ import type { VolunteerUpdate, AssignmentRead, AttendanceStatus, + TestEmailRequest, } from './schema'; const edition = EDITION_ID; @@ -587,7 +588,9 @@ export function useGeneratePositionSheets() { } export function useSendTestEmail() { - return useMutation({ mutationFn: (email: string) => api.notifications.sendTest(edition, email) }); + return useMutation({ + mutationFn: (body: TestEmailRequest) => api.notifications.sendTest(edition, body), + }); } export function useSendPlanningNotification() { diff --git a/frontend/src/api/http-client.ts b/frontend/src/api/http-client.ts index 0100ef9..05f6f38 100644 --- a/frontend/src/api/http-client.ts +++ b/frontend/src/api/http-client.ts @@ -31,6 +31,7 @@ import type { VolunteerCreate, VolunteerUpdate, AttendanceUpdate, + TestEmailRequest, } from './schema'; import { getAuthToken, notifyUnauthorized } from './session'; import { ApiError, type PlanningApi } from './types'; @@ -378,10 +379,10 @@ export function createHttpApi(baseUrl: string): PlanningApi { method: 'POST', body: JSON.stringify(body), }), - sendTest: (editionId, email: string) => + sendTest: (editionId, body: TestEmailRequest) => request(`${base(editionId)}/notifications/test`, { method: 'POST', - body: JSON.stringify({ email }), + body: JSON.stringify(body), }), }, auth: { diff --git a/frontend/src/api/mock/mock-client.ts b/frontend/src/api/mock/mock-client.ts index caba23f..15e870d 100644 --- a/frontend/src/api/mock/mock-client.ts +++ b/frontend/src/api/mock/mock-client.ts @@ -2054,10 +2054,22 @@ export function createMockApi(): PlanningApi { notifications: { // Pas de serveur SMTP en démo : rien n'est envoyé, comme // `EMAIL_ENABLED=false` côté serveur. - sendTest: (_edition, email) => { - const address = email.trim(); - if (!/^[^\s@]+@[^\s@]+$/.test(address)) throw new ApiError(422, 'notifications.invalid_email'); - return delay({ status: 'disabled' as const, detail: null, smtp_host: 'localhost:25', smtp_from: 'noreply@festival.local' }); + sendTest: (_edition, body) => { + if (!/^[^\s@]+@[^\s@]+$/.test(body.email.trim())) { + throw new ApiError(422, 'notifications.invalid_email'); + } + const assigned = new Set(state.assignments.map((a) => a.volunteer_id)); + const sorted = [...state.volunteers].sort((a, b) => a.last_name.localeCompare(b.last_name, 'fr')); + const example = + sorted.find((v) => v.id === body.volunteer_id) ?? sorted.find((v) => assigned.has(v.id)) ?? sorted[0]; + return delay({ + status: 'disabled' as const, + detail: null, + smtp_host: 'localhost:25', + smtp_from: 'noreply@festival.local', + example_volunteer_id: example?.id ?? null, + example_volunteer_name: example ? `${example.first_name} ${example.last_name}`.trim() : '', + }); }, sendPlanning: (_edition, body: PlanningNotificationRequest) => { const ids = body.volunteer_ids ?? null; diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 1b13ae8..d514c4a 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -611,6 +611,19 @@ export interface TestEmailResult { detail: string | null; smtp_host: string; smtp_from: string; + /** Bénévole dont les données ont rempli le modèle (`null` : exemple + * fictif). */ + example_volunteer_id: string | null; + example_volunteer_name: string; +} + +/** Email de test : le récapitulatif de planning en cours d'édition, rempli + * avec les données d'un bénévole, envoyé à `email`. */ +export interface TestEmailRequest { + email: string; + subject?: string | null; + body?: string | null; + volunteer_id?: string | null; } // --------------------------------------------------------------------------- // diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index c8716a2..eed84df 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -58,6 +58,7 @@ import type { VolunteerUpdate, EditionStats, AttendanceUpdate, + TestEmailRequest, TestEmailResult, } from './schema'; @@ -226,7 +227,7 @@ export interface PlanningApi { body: PlanningNotificationRequest, ): Promise; /** Email de test, envoyé tout de suite (COORDINATION). */ - sendTest(editionId: string, email: string): Promise; + sendTest(editionId: string, body: TestEmailRequest): Promise; }; auth: { login(editionId: string, body: LoginRequest): Promise; diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 2b192c5..c5ddd5e 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -199,7 +199,12 @@ "config.tab.grid": "POSTES & CRÉNEAUX", "config.tab.volunteers": "BÉNÉVOLES & IMPORT", "config.mail.test.title": "Tester l'envoi d'emails", - "config.mail.test.help": "Envoie tout de suite un email de test à l'adresse de votre choix, pour vérifier que les emails partent bien (liens de connexion, confirmations d'inscription, plannings).", + "config.mail.test.help": "Envoie tout de suite à l'adresse de votre choix le récapitulatif ci-contre tel que les bénévoles le recevront : sujet (précédé de « [Test] ») et texte en cours de saisie, variables remplies avec le planning d'un bénévole. Le lien de connexion y mène à la page de connexion, sans ouvrir le compte de ce bénévole.", + "config.mail.test.example": "Rempli avec le planning de", + "config.mail.test.exampleAuto": "Automatique (premier bénévole avec un créneau)", + "config.mail.test.withLink": "Avec le lien de connexion (comme « Envoyer avec lien de connexion »)", + "config.mail.test.filledWith": "Contenu rempli avec le planning de {name}.", + "config.mail.test.filledWithExample": "Aucun bénévole : contenu rempli avec un exemple fictif.", "config.mail.test.email": "Adresse de test", "config.mail.test.placeholder": "moi@exemple.fr", "config.mail.test.send": "Envoyer un email de test", diff --git a/frontend/src/pages/config/ConfigMail.tsx b/frontend/src/pages/config/ConfigMail.tsx index e6787d3..b3dec0a 100644 --- a/frontend/src/pages/config/ConfigMail.tsx +++ b/frontend/src/pages/config/ConfigMail.tsx @@ -1,11 +1,12 @@ import { useState } from 'react'; -import { useSendPlanningNotification, useSendTestEmail } from '@/api/hooks'; +import { useSendPlanningNotification, useSendTestEmail, useVolunteers } from '@/api/hooks'; import type { TestEmailResult } from '@/api/schema'; import { ErrorNote } from '@/components/state'; import { Btn, FieldLabel, HelpTip, Panel, PanelHeader } from '@/components/ui'; import { t } from '@/i18n'; import { cx } from '@/lib/cx'; +import { volunteerName } from '@/lib/volunteer'; const MAIL_VARIABLES = [ '{prenom}', @@ -102,7 +103,7 @@ export function ConfigMail() { - + {t('config.mail.previewTitle')} @@ -128,19 +129,34 @@ const RESULT_TONE: Record = { rejected: 'bg-orange', }; -/** Envoie un email de test à l'adresse saisie et affiche le résultat réel - * du serveur SMTP (envoyé, envoi désactivé, connexion ou message refusé). */ -function TestEmailPanel() { +/** Envoie à l'adresse saisie le récapitulatif **tel que les bénévoles le + * recevront** (le modèle en cours d'édition, variables remplies avec les + * données d'un bénévole) et affiche le résultat réel du serveur SMTP. */ +function TestEmailPanel({ subject, body }: { subject: string; body: string }) { const [email, setEmail] = useState(''); + // '' = choix automatique (premier bénévole qui a un créneau). + const [volunteerId, setVolunteerId] = useState(''); + const [withLink, setWithLink] = useState(false); + const volunteers = useVolunteers(); const test = useSendTestEmail(); const result = test.data; + const options = [...(volunteers.data ?? [])].sort((a, b) => + volunteerName(a).localeCompare(volunteerName(b), 'fr'), + ); return ( {t('config.mail.test.title')}
{ e.preventDefault(); - if (email.trim()) test.mutate(email.trim()); + if (!email.trim()) return; + test.mutate({ + email: email.trim(), + subject, + // Même corps que le bouton « avec lien de connexion » si coché. + body: withLink ? withLoginLink(body) : body, + volunteer_id: volunteerId || null, + }); }} className="flex flex-col gap-3 p-[13px]" > @@ -155,6 +171,25 @@ function TestEmailPanel() { className="w-full border-2 border-ink bg-cream p-[9px] font-mono text-[12px]" /> + + {test.isPending ? t('config.mail.test.sending') : t('config.mail.test.send')} @@ -163,6 +198,11 @@ function TestEmailPanel() {
{t(`config.mail.test.result.${result.status}`)}
+
+ {result.example_volunteer_id + ? t('config.mail.test.filledWith', { name: result.example_volunteer_name || t('volunteer.unnamed') }) + : t('config.mail.test.filledWithExample')} +
{result.detail ? (
{t('config.mail.test.serverSaid', { detail: result.detail })}