From 440466ccf940e6796a2e5dc3a0fe2c410886a917 Mon Sep 17 00:00:00 2001 From: AdamThomas Date: Wed, 28 Oct 2026 19:44:14 +0100 Subject: [PATCH] =?UTF-8?q?Email=20de=20test=20:=20r=C3=A9capitulatif=20de?= =?UTF-8?q?=20planning=20compil=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 14 ++- backend/app/api/notifications.py | 111 +++++++++++++++++------ backend/app/api/schemas.py | 12 +++ backend/app/i18n/locales/fr.json | 5 +- backend/tests/test_api_test_email.py | 61 +++++++++++++ frontend/src/api/hooks.ts | 5 +- frontend/src/api/http-client.ts | 5 +- frontend/src/api/mock/mock-client.ts | 20 +++- frontend/src/api/schema.ts | 13 +++ frontend/src/api/types.ts | 3 +- frontend/src/i18n/locales/fr.json | 7 +- frontend/src/pages/config/ConfigMail.tsx | 52 +++++++++-- 12 files changed, 262 insertions(+), 46 deletions(-) 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 })}