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
+4 -1
View File
@@ -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() {
+3 -2
View File
@@ -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: {
+16 -4
View File
@@ -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;
+13
View File
@@ -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;
}
// --------------------------------------------------------------------------- //
+2 -1
View File
@@ -58,6 +58,7 @@ import type {
VolunteerUpdate,
EditionStats,
AttendanceUpdate,
TestEmailRequest,
TestEmailResult,
} from './schema';
@@ -226,7 +227,7 @@ export interface PlanningApi {
body: PlanningNotificationRequest,
): Promise<PlanningNotificationResult>;
/** Email de test, envoyé tout de suite (COORDINATION). */
sendTest(editionId: string, email: string): Promise<TestEmailResult>;
sendTest(editionId: string, body: TestEmailRequest): Promise<TestEmailResult>;
};
auth: {
login(editionId: string, body: LoginRequest): Promise<TokenResponse>;
+6 -1
View File
@@ -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",
+46 -6
View File
@@ -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() {
</div>
</Panel>
<TestEmailPanel />
<TestEmailPanel subject={subject} body={body} />
<Panel shadow="lg">
<PanelHeader hatched>{t('config.mail.previewTitle')}</PanelHeader>
@@ -128,19 +129,34 @@ const RESULT_TONE: Record<TestEmailResult['status'], string> = {
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 (
<Panel shadow="lg">
<PanelHeader>{t('config.mail.test.title')}</PanelHeader>
<form
onSubmit={(e) => {
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]"
/>
</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}>
{test.isPending ? t('config.mail.test.sending') : t('config.mail.test.send')}
</Btn>
@@ -163,6 +198,11 @@ function TestEmailPanel() {
<div className="font-display text-[12px] font-extrabold leading-snug">
{t(`config.mail.test.result.${result.status}`)}
</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 ? (
<div className="mt-1 break-words font-mono text-[10.5px] leading-snug">
{t('config.mail.test.serverSaid', { detail: result.detail })}