Email de test : récapitulatif de planning compilé
ci-cd / build-backend (push) Successful in 46s
ci-cd / deploy (push) Successful in 12s
ci-cd / build-frontend (push) Successful in 21s

L'email de test reprend le modèle en cours d'édition, balises remplies
avec le planning d'un bénévole au choix (premier avec un créneau par
défaut), option lien de connexion sans vrai jeton, sujet « [Test] … » —
même fonction de rendu que l'envoi réel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-28 19:44:14 +01:00
co-authored by Claude Opus 5.5
parent 461742eb78
commit 440466ccf9
12 changed files with 262 additions and 46 deletions
+11 -3
View File
@@ -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 - **Email de test depuis l'appli** : bloc « Tester l'envoi d'emails » de
l'onglet « Messages » de la configuration (`ConfigMail.tsx`) — une l'onglet « Messages » de la configuration (`ConfigMail.tsx`) — une
adresse et un bouton. `POST /editions/{id}/notifications/test` (`{email}`, adresse et un bouton. `POST /editions/{id}/notifications/test` (`{email}`,
COORDINATION) envoie **tout de suite** (pas en tâche de fond) un email de COORDINATION) envoie **tout de suite** (pas en tâche de fond) le
test, même mise en forme et même logo que les vrais, et renvoie le **récapitulatif de planning tel que les bénévoles le recevront** : le
résultat réel (`app/email/sender.py:deliver_test`) : `sent`, `disabled` 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 (`EMAIL_ENABLED=false`), `connection_failed`, `rejected`, avec la réponse
du serveur SMTP (jamais l'adresse du destinataire), le serveur et du serveur SMTP (jamais l'adresse du destinataire), le serveur et
l'expéditeur configurés — sans accès au serveur ni à ses logs. 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 | | 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 | | 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 | | 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 **Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, IA. Frontend : squelette + 6 écrans + de formulaire, IA. Frontend : squelette + 6 écrans +
+85 -26
View File
@@ -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)) 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) @router.post("/test", response_model=TestEmailResult)
def send_test_email( def send_test_email(
payload: TestEmailRequest, payload: TestEmailRequest,
@@ -52,17 +87,54 @@ def send_test_email(
_guard: Account = Depends(require_coordinator), _guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session), session: Session = Depends(get_session),
) -> TestEmailResult: ) -> TestEmailResult:
"""Envoie tout de suite un email de test à l'adresse saisie (même mise """Envoie tout de suite à l'adresse saisie le récapitulatif de planning
en forme et même logo que les vrais emails) et renvoie le résultat réel **tel que les bénévoles le recevront** : le modèle en cours d'édition
du serveur SMTP — pour vérifier la configuration depuis l'écran (`subject`/`body`), variables remplies avec les données d'un vrai
« Messages » sans accès au serveur (`python -m app.email.check`).""" 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() email = payload.email.strip()
if "@" not in email or email.startswith("@") or email.endswith("@") or " " in email: if "@" not in email or email.startswith("@") or email.endswith("@") or " " in email:
raise HTTPException(status_code=422, detail={"code": "notifications.invalid_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( message = build_message(
email, email,
t("email.test.subject"), t("email.test.subject_prefix", subject=subject),
t("email.test.body", edition=edition.name or str(edition.year)), body,
logo=edition_logo(session, edition.id), logo=edition_logo(session, edition.id),
) )
status, detail = deliver_test(message) status, detail = deliver_test(message)
@@ -72,6 +144,10 @@ def send_test_email(
detail=detail, detail=detail,
smtp_host=f"{settings.smtp_host}:{settings.smtp_port}", smtp_host=f"{settings.smtp_host}:{settings.smtp_port}",
smtp_from=settings.smtp_from, 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: if not volunteer.email:
skipped_no_email += 1 skipped_no_email += 1
continue continue
shifts = sorted( subject, body = _planning_email(
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, subject_template=subject_template,
body_template=body_template, body_template=body_template,
first_name=volunteer.first_name, first_name=volunteer.first_name,
last_name=volunteer.last_name, last_name=volunteer.last_name,
shift_count=len(shifts), shifts=shifts_by_volunteer.get(volunteer.id, []),
hours=round(minutes / 60, 1), positions=positions,
shifts_text=shifts_text,
login_link=( login_link=(
_login_link(session, edition, volunteer) _login_link(session, edition, volunteer)
if wants_login_link if wants_login_link
+12
View File
@@ -518,6 +518,14 @@ class PlanningNotificationResult(ApiModel):
class TestEmailRequest(ApiModel): class TestEmailRequest(ApiModel):
email: str 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): class TestEmailResult(ApiModel):
@@ -529,6 +537,10 @@ class TestEmailResult(ApiModel):
detail: str | None = None detail: str | None = None
smtp_host: str smtp_host: str
smtp_from: 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
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
+3 -2
View File
@@ -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.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_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.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.subject_prefix": "[Test] {subject}",
"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.example_first_name": "Camille",
"email.test.example_last_name": "Exemple",
"notifications.invalid_email": "Adresse email invalide.", "notifications.invalid_email": "Adresse email invalide.",
"email.planning.no_shifts": "Aucun créneau ne t'est affecté pour le moment.", "email.planning.no_shifts": "Aucun créneau ne t'est affecté pour le moment.",
"email.planning.shift_line": "- {position} : {start} → {end}", "email.planning.shift_line": "- {position} : {start} → {end}",
+61
View File
@@ -81,3 +81,64 @@ def test_invalid_address_is_rejected(client, edition, email):
def test_is_coordinator_only(edition, make_account, as_account): def test_is_coordinator_only(edition, make_account, as_account):
lead = as_account(make_account(Role.LEAD)) lead = as_account(make_account(Role.LEAD))
assert lead.post(_url(edition), json={"email": "moi@exemple.fr"}).status_code == 403 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)
+4 -1
View File
@@ -39,6 +39,7 @@ import type {
VolunteerUpdate, VolunteerUpdate,
AssignmentRead, AssignmentRead,
AttendanceStatus, AttendanceStatus,
TestEmailRequest,
} from './schema'; } from './schema';
const edition = EDITION_ID; const edition = EDITION_ID;
@@ -587,7 +588,9 @@ export function useGeneratePositionSheets() {
} }
export function useSendTestEmail() { 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() { export function useSendPlanningNotification() {
+3 -2
View File
@@ -31,6 +31,7 @@ import type {
VolunteerCreate, VolunteerCreate,
VolunteerUpdate, VolunteerUpdate,
AttendanceUpdate, AttendanceUpdate,
TestEmailRequest,
} from './schema'; } from './schema';
import { getAuthToken, notifyUnauthorized } from './session'; import { getAuthToken, notifyUnauthorized } from './session';
import { ApiError, type PlanningApi } from './types'; import { ApiError, type PlanningApi } from './types';
@@ -378,10 +379,10 @@ export function createHttpApi(baseUrl: string): PlanningApi {
method: 'POST', method: 'POST',
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
sendTest: (editionId, email: string) => sendTest: (editionId, body: TestEmailRequest) =>
request(`${base(editionId)}/notifications/test`, { request(`${base(editionId)}/notifications/test`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ email }), body: JSON.stringify(body),
}), }),
}, },
auth: { auth: {
+16 -4
View File
@@ -2054,10 +2054,22 @@ export function createMockApi(): PlanningApi {
notifications: { notifications: {
// Pas de serveur SMTP en démo : rien n'est envoyé, comme // Pas de serveur SMTP en démo : rien n'est envoyé, comme
// `EMAIL_ENABLED=false` côté serveur. // `EMAIL_ENABLED=false` côté serveur.
sendTest: (_edition, email) => { sendTest: (_edition, body) => {
const address = email.trim(); if (!/^[^\s@]+@[^\s@]+$/.test(body.email.trim())) {
if (!/^[^\s@]+@[^\s@]+$/.test(address)) throw new ApiError(422, 'notifications.invalid_email'); throw new ApiError(422, 'notifications.invalid_email');
return delay({ status: 'disabled' as const, detail: null, smtp_host: 'localhost:25', smtp_from: 'noreply@festival.local' }); }
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) => { sendPlanning: (_edition, body: PlanningNotificationRequest) => {
const ids = body.volunteer_ids ?? null; const ids = body.volunteer_ids ?? null;
+13
View File
@@ -611,6 +611,19 @@ export interface TestEmailResult {
detail: string | null; detail: string | null;
smtp_host: string; smtp_host: string;
smtp_from: 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;
} }
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
+2 -1
View File
@@ -58,6 +58,7 @@ import type {
VolunteerUpdate, VolunteerUpdate,
EditionStats, EditionStats,
AttendanceUpdate, AttendanceUpdate,
TestEmailRequest,
TestEmailResult, TestEmailResult,
} from './schema'; } from './schema';
@@ -226,7 +227,7 @@ export interface PlanningApi {
body: PlanningNotificationRequest, body: PlanningNotificationRequest,
): Promise<PlanningNotificationResult>; ): Promise<PlanningNotificationResult>;
/** Email de test, envoyé tout de suite (COORDINATION). */ /** Email de test, envoyé tout de suite (COORDINATION). */
sendTest(editionId: string, email: string): Promise<TestEmailResult>; sendTest(editionId: string, body: TestEmailRequest): Promise<TestEmailResult>;
}; };
auth: { auth: {
login(editionId: string, body: LoginRequest): Promise<TokenResponse>; login(editionId: string, body: LoginRequest): Promise<TokenResponse>;
+6 -1
View File
@@ -199,7 +199,12 @@
"config.tab.grid": "POSTES & CRÉNEAUX", "config.tab.grid": "POSTES & CRÉNEAUX",
"config.tab.volunteers": "BÉNÉVOLES & IMPORT", "config.tab.volunteers": "BÉNÉVOLES & IMPORT",
"config.mail.test.title": "Tester l'envoi d'emails", "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.email": "Adresse de test",
"config.mail.test.placeholder": "moi@exemple.fr", "config.mail.test.placeholder": "moi@exemple.fr",
"config.mail.test.send": "Envoyer un email de test", "config.mail.test.send": "Envoyer un email de test",
+46 -6
View File
@@ -1,11 +1,12 @@
import { useState } from 'react'; import { useState } from 'react';
import { useSendPlanningNotification, useSendTestEmail } from '@/api/hooks'; import { useSendPlanningNotification, useSendTestEmail, useVolunteers } from '@/api/hooks';
import type { TestEmailResult } from '@/api/schema'; import type { TestEmailResult } from '@/api/schema';
import { ErrorNote } from '@/components/state'; import { ErrorNote } from '@/components/state';
import { Btn, FieldLabel, HelpTip, Panel, PanelHeader } from '@/components/ui'; import { Btn, FieldLabel, HelpTip, Panel, PanelHeader } from '@/components/ui';
import { t } from '@/i18n'; import { t } from '@/i18n';
import { cx } from '@/lib/cx'; import { cx } from '@/lib/cx';
import { volunteerName } from '@/lib/volunteer';
const MAIL_VARIABLES = [ const MAIL_VARIABLES = [
'{prenom}', '{prenom}',
@@ -102,7 +103,7 @@ export function ConfigMail() {
</div> </div>
</Panel> </Panel>
<TestEmailPanel /> <TestEmailPanel subject={subject} body={body} />
<Panel shadow="lg"> <Panel shadow="lg">
<PanelHeader hatched>{t('config.mail.previewTitle')}</PanelHeader> <PanelHeader hatched>{t('config.mail.previewTitle')}</PanelHeader>
@@ -128,19 +129,34 @@ const RESULT_TONE: Record<TestEmailResult['status'], string> = {
rejected: 'bg-orange', rejected: 'bg-orange',
}; };
/** Envoie un email de test à l'adresse saisie et affiche le résultat réel /** Envoie à l'adresse saisie le récapitulatif **tel que les bénévoles le
* du serveur SMTP (envoyé, envoi désactivé, connexion ou message refusé). */ * recevront** (le modèle en cours d'édition, variables remplies avec les
function TestEmailPanel() { * 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(''); 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 test = useSendTestEmail();
const result = test.data; const result = test.data;
const options = [...(volunteers.data ?? [])].sort((a, b) =>
volunteerName(a).localeCompare(volunteerName(b), 'fr'),
);
return ( return (
<Panel shadow="lg"> <Panel shadow="lg">
<PanelHeader>{t('config.mail.test.title')}</PanelHeader> <PanelHeader>{t('config.mail.test.title')}</PanelHeader>
<form <form
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); 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]" 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]" className="w-full border-2 border-ink bg-cream p-[9px] font-mono text-[12px]"
/> />
</label> </label>
<label className="block">
<FieldLabel>{t('config.mail.test.example')}</FieldLabel>
<select
value={volunteerId}
onChange={(e) => setVolunteerId(e.target.value)}
className="w-full border-2 border-ink bg-cream p-[8px] font-display text-[12.5px] font-bold"
>
<option value="">{t('config.mail.test.exampleAuto')}</option>
{options.map((v) => (
<option key={v.id} value={v.id}>
{volunteerName(v)}
</option>
))}
</select>
</label>
<label className="flex cursor-pointer items-center gap-2 font-display text-[12px] font-bold">
<input type="checkbox" checked={withLink} onChange={(e) => setWithLink(e.target.checked)} />
{t('config.mail.test.withLink')}
</label>
<Btn type="submit" disabled={!email.trim() || test.isPending}> <Btn type="submit" disabled={!email.trim() || test.isPending}>
{test.isPending ? t('config.mail.test.sending') : t('config.mail.test.send')} {test.isPending ? t('config.mail.test.sending') : t('config.mail.test.send')}
</Btn> </Btn>
@@ -163,6 +198,11 @@ function TestEmailPanel() {
<div className="font-display text-[12px] font-extrabold leading-snug"> <div className="font-display text-[12px] font-extrabold leading-snug">
{t(`config.mail.test.result.${result.status}`)} {t(`config.mail.test.result.${result.status}`)}
</div> </div>
<div className="mt-1 font-mono text-[10.5px] leading-snug">
{result.example_volunteer_id
? t('config.mail.test.filledWith', { name: result.example_volunteer_name || t('volunteer.unnamed') })
: t('config.mail.test.filledWithExample')}
</div>
{result.detail ? ( {result.detail ? (
<div className="mt-1 break-words font-mono text-[10.5px] leading-snug"> <div className="mt-1 break-words font-mono text-[10.5px] leading-snug">
{t('config.mail.test.serverSaid', { detail: result.detail })} {t('config.mail.test.serverSaid', { detail: result.detail })}