feat: email retry page on 550 rejection; confirmation_email optional in admin form

This commit is contained in:
Pontoporeia
2026-04-30 13:44:59 +02:00
parent 898a87789b
commit da53bf5d7a
9 changed files with 260 additions and 14 deletions

View File

@@ -19,6 +19,9 @@
## Bug fixes
- [x] **smtp-test.php** — wrap `SmtpRelay::send()` in `try/catch SmtpSendException` so SMTP delivery failures (e.g. 550 recipient rejected) surface as a proper flash error instead of an uncaught exception/silent crash
- [x] **partage email retry** — on 550 recipient-rejected, redirect to `/partage/retry-email` instead of `recapitulatif`; student can correct address and resend or skip
- [x] **ThesisCreateController**`confirmation_email` is now optional (empty = skip send)
- [x] **admin/add.php template** — email confirmation field marked optional, label and hint updated
## Previously completed
- [x] Multi-file upload for thesis files (basic)

View File

@@ -1008,3 +1008,104 @@ a.recap-file-name:hover {
.form-help-block ol { margin: 0 0 var(--space-xs); padding-left: var(--space-m); }
.form-help-block li { margin-bottom: var(--space-3xs); }
.form-help-block a { color: var(--accent-primary); }
/* ── E-mail retry page ───────────────────────────────────────────────────── */
.partage-retry-email {
display: flex;
flex-direction: column;
gap: var(--space-l);
max-width: 600px;
margin: 0 auto;
}
.retry-email-section {
border-top: 1px solid var(--border-primary);
padding-top: var(--space-m);
display: flex;
flex-direction: column;
gap: var(--space-m);
}
.retry-email-section h2 {
font-size: var(--step-0);
font-weight: 600;
margin: 0;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-secondary);
}
.retry-smtp-detail {
display: block;
margin-top: var(--space-2xs);
font-size: var(--step--2);
color: var(--text-secondary);
word-break: break-all;
}
.retry-email-form {
display: flex;
flex-direction: column;
gap: var(--space-m);
}
.retry-email-form .field-wrap {
display: flex;
flex-direction: column;
gap: var(--space-2xs);
}
.retry-email-form label {
font-size: var(--step--1);
font-weight: 600;
}
.retry-email-form input[type="email"] {
padding: var(--space-xs) var(--space-s);
border: 1px solid var(--border-primary);
border-radius: 4px;
font-size: var(--step-0);
width: 100%;
box-sizing: border-box;
}
.retry-email-form input.input-error {
border-color: var(--error, #c00);
}
.retry-email-actions {
display: flex;
gap: var(--space-s);
flex-wrap: wrap;
}
.btn-primary {
padding: var(--space-xs) var(--space-m);
background: var(--accent-primary);
color: #fff;
border: none;
border-radius: 4px;
font-size: var(--step-0);
cursor: pointer;
font-weight: 600;
}
.btn-primary:hover {
background: var(--accent-secondary, var(--accent-primary));
transform: translateY(-1px);
}
.btn-secondary {
padding: var(--space-xs) var(--space-m);
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border-primary);
border-radius: 4px;
font-size: var(--step-0);
cursor: pointer;
}
.btn-secondary:hover {
background: color-mix(in srgb, var(--text-secondary) 8%, transparent);
}

View File

@@ -447,9 +447,6 @@ function handleShareLinkSubmission(string $slug): void
'share_slug' => $slug,
]);
// Send confirmation e-mail (non-blocking; failure doesn't stop redirect)
$emailSent = StudentEmail::sendConfirmation(Database::getInstance(), $thesisId, $_POST);
// Mark the link as used
$shareLinkModel = new ShareLink(Database::getInstance());
$shareLinkModel->incrementUsage($link['id']);
@@ -457,7 +454,22 @@ function handleShareLinkSubmission(string $slug): void
// Clean up share-specific session data
unset($_SESSION[$shareCsrfKey]);
unset($_SESSION['share_verified_' . $slug]);
$_SESSION['share_email_sent'] = $emailSent;
// Send confirmation e-mail — on delivery failure, redirect to retry page
$emailError = null;
try {
$emailSent = StudentEmail::sendConfirmation(Database::getInstance(), $thesisId, $_POST);
$_SESSION['share_email_sent'] = $emailSent;
} catch (SmtpSendException $e) {
if ($e->isRecipientRejected()) {
$_SESSION['share_email_retry_thesis'] = $thesisId;
$_SESSION['share_email_retry_error'] = $e->smtpResponse;
header('Location: /partage/retry-email?id=' . urlencode((string)$thesisId));
exit();
}
// Non-recipient errors (relay down, etc.) — skip email silently
$_SESSION['share_email_sent'] = false;
}
// Redirect to thanks page
header('Location: /partage/recapitulatif?id=' . urlencode((string)$thesisId));

View File

@@ -0,0 +1,122 @@
<?php
/**
* E-mail retry page for share-link submissions.
*
* Shown when the confirmation e-mail bounced with a 550 recipient-rejected
* error. The student can correct their address and resend, or skip.
*/
// Always boot — this page is a direct-response route (loaded before
// Dispatcher calls App::boot()), so we must start the session ourselves.
App::boot();
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/SmtpRelay.php';
require_once APP_ROOT . '/src/StudentEmail.php';
$thesisId = isset($_GET['id']) ? (int)$_GET['id'] : 0;
// Guard: only allow access when the session retry token matches
$sessionThesisId = $_SESSION['share_email_retry_thesis'] ?? null;
if ($thesisId <= 0 || $sessionThesisId !== $thesisId) {
header('Location: /');
exit;
}
$smtpError = $_SESSION['share_email_retry_error'] ?? '';
// ── POST: retry send ──────────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Skip button
if (isset($_POST['skip'])) {
unset($_SESSION['share_email_retry_thesis'], $_SESSION['share_email_retry_error']);
$_SESSION['share_email_sent'] = false;
header('Location: /partage/recapitulatif?id=' . urlencode((string)$thesisId));
exit;
}
$newEmail = trim($_POST['confirmation_email'] ?? '');
$inputError = null;
if ($newEmail === '' || filter_var($newEmail, FILTER_VALIDATE_EMAIL) === false) {
$inputError = 'Veuillez saisir une adresse e-mail valide.';
} else {
$db = Database::getInstance();
try {
$sent = StudentEmail::sendConfirmation($db, $thesisId, ['confirmation_email' => $newEmail]);
unset($_SESSION['share_email_retry_thesis'], $_SESSION['share_email_retry_error']);
$_SESSION['share_email_sent'] = $sent;
header('Location: /partage/recapitulatif?id=' . urlencode((string)$thesisId));
exit;
} catch (SmtpSendException $e) {
// Update the displayed error with the fresh response
$smtpError = $e->smtpResponse;
$inputError = 'Le serveur a de nouveau rejeté cette adresse. Vérifiez l\'adresse et réessayez.';
$_SESSION['share_email_retry_error'] = $smtpError;
}
}
}
$pageTitle = 'Corriger l\'adresse e-mail';
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($pageTitle) ?></title>
<link rel="apple-touch-icon" sizes="152x152" href="/assets/favicon/apple-touch-icon-152x152.png">
<link rel="apple-touch-icon" sizes="167x167" href="/assets/favicon/apple-touch-icon-167x167.png">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon-180x180.png">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png">
<link rel="shortcut icon" href="/assets/favicon/favicon.ico">
<link rel="stylesheet" href="<?= App::assetV('/assets/css/common.css') ?>">
<link rel="stylesheet" href="<?= App::assetV('/assets/css/form.css') ?>">
</head>
<body class="student-body">
<main id="main-content" class="partage-retry-email">
<div class="thanks-success">
<h1>✅ Votre TFE a bien été enregistré !</h1>
<p class="thanks-message">Il y a cependant eu un problème lors de l'envoi de l'e-mail de confirmation.</p>
</div>
<section class="retry-email-section">
<h2>Corriger l'adresse e-mail</h2>
<div class="flash-error" role="alert">
<strong>L'adresse e-mail a été rejetée par le serveur.</strong><br>
<?php if ($smtpError !== ''): ?>
<code class="retry-smtp-detail"><?= htmlspecialchars($smtpError) ?></code>
<?php endif; ?>
</div>
<?php if (!empty($inputError)): ?>
<div class="flash-error" role="alert"><?= htmlspecialchars($inputError) ?></div>
<?php endif; ?>
<p>Votre TFE est enregistré — vous pouvez corriger votre adresse ci-dessous pour recevoir le récapitulatif, ou continuer sans e-mail.</p>
<form method="post" action="/partage/retry-email?id=<?= urlencode((string)$thesisId) ?>" class="retry-email-form">
<div class="field-wrap">
<label for="confirmation_email">Adresse e-mail corrigée</label>
<input
type="email"
id="confirmation_email"
name="confirmation_email"
placeholder="ton.email@exemple.be"
autofocus
required
class="<?= !empty($inputError) ? 'input-error' : '' ?>"
>
</div>
<div class="retry-email-actions">
<button type="submit" class="btn-primary">Renvoyer l'e-mail</button>
<button type="submit" name="skip" value="1" class="btn-secondary">Continuer sans e-mail</button>
</div>
</form>
</section>
</main>
</body>
</html>

View File

@@ -325,14 +325,13 @@ class ThesisCreateController
}
}
// Confirmation e-mail (required)
// Confirmation e-mail (optional)
$confirmationEmail = trim($post['confirmation_email'] ?? '');
if ($confirmationEmail === '') {
throw new Exception("L'adresse e-mail de confirmation est requise.");
}
$confirmationEmail = filter_var($confirmationEmail, FILTER_VALIDATE_EMAIL);
if ($confirmationEmail === false) {
throw new Exception("L'adresse e-mail de confirmation n'est pas valide.");
if ($confirmationEmail !== '') {
$confirmationEmail = filter_var($confirmationEmail, FILTER_VALIDATE_EMAIL);
if ($confirmationEmail === false) {
throw new Exception("L'adresse e-mail de confirmation n'est pas valide.");
}
}
return compact(

View File

@@ -122,6 +122,13 @@ class Dispatcher {
};
}
// /partage/retry-email (GET: show retry form, POST: resend)
if ($path === '/partage/retry-email') {
return function() {
require APP_ROOT . '/public/partage/retry-email.php';
};
}
// /partage/*
if (preg_match('#^/partage(/.*)?$#', $path)) {
return function() {

View File

@@ -102,9 +102,8 @@ class StudentEmail {
try {
$result = SmtpRelay::send($db, $to, $subject, $htmlBody);
} catch (SmtpSendException $e) {
// Confirmation email failure must not abort the successful submission.
error_log("[StudentEmail] SMTP error sending to {$to} for thesis #{$thesisId}: " . $e->getMessage());
return false;
throw $e; // re-throw so callers can react (e.g. redirect to retry page)
}
if ($result) {

View File

@@ -4,3 +4,6 @@
{"source":"partage","action":"submit","status":"success","thesis_id":18,"identifier":"2025-015","author":"Théo Marchand","share_slug":"20260429-DZESJT6X","timestamp":"2026-04-30T10:13:43+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"}
{"source":"partage","action":"submit","status":"success","thesis_id":19,"identifier":"2025-016","author":"Lila Dubois, Karim Nassar","share_slug":"20260429-DZESJT6X","timestamp":"2026-04-30T11:27:07+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"}
{"source":"partage","action":"submit","status":"success","thesis_id":20,"identifier":"2025-017","author":"Théo Marchand","share_slug":"20260429-DZESJT6X","timestamp":"2026-04-30T11:37:11+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"}
{"source":"partage","action":"submit","status":"success","thesis_id":21,"identifier":"2025-018","author":"Théo Marchand","share_slug":"20260429-DZESJT6X","timestamp":"2026-04-30T11:41:38+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"}
{"source":"partage","action":"submit","status":"success","thesis_id":22,"identifier":"2025-019","author":"Lila Dubois, Karim Nassar","share_slug":"20260429-DZESJT6X","timestamp":"2026-04-30T11:45:36+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"}
{"source":"partage","action":"submit","status":"success","thesis_id":23,"identifier":"2025-020","author":"Zoé Lambert","share_slug":"20260429-DZESJT6X","timestamp":"2026-04-30T11:46:49+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"}

View File

@@ -43,7 +43,7 @@
<!-- ═══════════════════ E-mail de confirmation ═══════════════ -->
<fieldset>
<legend>E-mail de confirmation</legend>
<?php $name = 'confirmation_email'; $label = 'Adresse e-mail * :'; $value = old('confirmation_email'); $type = 'email'; $required = true; $placeholder = 'ton.email@exemple.be'; $hint = 'Nécessaire pour recevoir le récapitulatif de ta soumission.'; $attrs = withAutofocus('confirmation_email'); include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<?php $name = 'confirmation_email'; $label = 'Adresse e-mail :'; $value = old('confirmation_email'); $type = 'email'; $required = false; $placeholder = 'ton.email@exemple.be'; $hint = 'Optionnel — pour envoyer un récapitulatif de la soumission.'; $attrs = withAutofocus('confirmation_email'); include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
</fieldset>
<div class="form-footer">