Email de test : récapitulatif de planning compilé
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:
co-authored by
Claude Opus 5.5
parent
461742eb78
commit
440466ccf9
@@ -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 +
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
@@ -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}",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------------------------- //
|
// --------------------------------------------------------------------------- //
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 })}
|
||||||
|
|||||||
Reference in New Issue
Block a user