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

@@ -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>