From 89b7ab476e661959b6d5489c49a06338d07bbc59 Mon Sep 17 00:00:00 2001 From: Pontoporeia Date: Thu, 30 Apr 2026 12:40:14 +0200 Subject: [PATCH] Handle SMTP 550 recipient-rejected errors with structured SmtpSendException MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add SmtpSendException with smtpCode/smtpResponse/isRecipientRejected() - smtpSend() $expect closure throws SmtpSendException (with code) instead of RuntimeException - SmtpRelay::send() re-throws SmtpSendException so callers can inspect it - request-access.php (new): catch 550 → roll back token+approval, return HTTP 422 with FR user message - request-access.php (resend): catch 550 → HTTP 422 instead of silently claiming success - StudentEmail::sendConfirmation(): catch SmtpSendException → log+false (submission not aborted) - admin/actions/access-request.php: catch SmtpSendException post-approval → flash warning (recipient-rejected vs transient) --- TODO.md | 10 +++++ app/public/admin/actions/access-request.php | 13 +++++-- app/public/request-access.php | 43 ++++++++++++++++++++- app/src/SmtpRelay.php | 35 ++++++++++++++++- app/src/StudentEmail.php | 8 +++- 5 files changed, 103 insertions(+), 6 deletions(-) diff --git a/TODO.md b/TODO.md index 3710017..97829bf 100644 --- a/TODO.md +++ b/TODO.md @@ -106,6 +106,16 @@ - [x] Minimal horizontal padding inside columns (`var(--space-2xs)`) - [x] Align all column headings to the same baseline row (2-row grid via `display: contents`) +## SMTP 550 recipient-rejected handling + +- [x] Add `SmtpSendException` — carries `smtpCode` + `smtpResponse`; `isRecipientRejected()` for 550–554 +- [x] `smtpSend()` `$expect` closure throws `SmtpSendException` (with code) instead of plain `RuntimeException` +- [x] `SmtpRelay::send()` re-throws `SmtpSendException` so callers can react +- [x] `request-access.php` (new auto-approve): catch 550 → roll back token + approval, return HTTP 422 with user-facing message +- [x] `request-access.php` (resend path): catch 550 → return HTTP 422 instead of silent "access approved" +- [x] `StudentEmail::sendConfirmation()`: catch `SmtpSendException` → log + return false (submission must not be aborted) +- [x] `admin/actions/access-request.php`: catch `SmtpSendException` after approval → flash warning distinguishing recipient-rejected vs transient + ## CSS refactor - [x] Move semantic HTML element baseline styles into common.css diff --git a/app/public/admin/actions/access-request.php b/app/public/admin/actions/access-request.php index c9f48f9..353e19c 100644 --- a/app/public/admin/actions/access-request.php +++ b/app/public/admin/actions/access-request.php @@ -52,9 +52,16 @@ try { $body = buildApprovalEmail($thesisTitle, $thesisAuthors, $accessUrl, $notes); $plain = strip_tags($body); - SmtpRelay::send($db, $request['email'], $subject, $body, $plain); - - App::flash('success', "Demande approuvée. Email envoyé à {$request['email']}."); + try { + SmtpRelay::send($db, $request['email'], $subject, $body, $plain); + App::flash('success', "Demande approuvée. Email envoyé à {$request['email']}."); + } catch (SmtpSendException $e) { + error_log('[access-request] Email delivery failed after approval: ' . $e->getMessage()); + $smtpMsg = $e->isRecipientRejected() + ? "Demande approuvée, mais l'email n'a pas pu être délivré : adresse inconnue ({$request['email']})." + : "Demande approuvée, mais l'envoi de l'email a échoué (erreur SMTP). L'utilisateur devra relancer une demande."; + App::flash('warning', $smtpMsg); + } } elseif ($action === 'reject') { $db->rejectAccessRequest($requestId, $notes); diff --git a/app/public/request-access.php b/app/public/request-access.php index 2ceffd0..6db2f8c 100644 --- a/app/public/request-access.php +++ b/app/public/request-access.php @@ -20,6 +20,7 @@ require_once __DIR__ . '/../bootstrap.php'; require_once APP_ROOT . '/src/Database.php'; require_once APP_ROOT . '/src/RateLimit.php'; require_once APP_ROOT . '/src/SmtpRelay.php'; +// SmtpSendException is defined in SmtpRelay.php header('Content-Type: application/json'); @@ -114,6 +115,18 @@ if ($existingRequest) { 'message' => 'Un nouvel email d\'accès vous a été envoyé.', 'status' => 'resent', ]); + } catch (SmtpSendException $e) { + error_log('Access request resend failed: ' . $e->getMessage()); + if ($e->isRecipientRejected()) { + http_response_code(422); + echo json_encode([ + 'success' => false, + 'message' => "L'adresse e-mail « {$email} » est introuvable sur le serveur de messagerie de l'ERG. Vérifiez l'orthographe ou utilisez une autre adresse.", + 'status' => 'recipient_rejected', + ]); + } else { + echo json_encode(['success' => true, 'message' => 'Votre accès est déjà approuvé. Si vous n\'avez pas reçu l\'email, contactez l\'administrateur.']); + } } catch (Exception $e) { error_log('Access request resend failed: ' . $e->getMessage()); echo json_encode(['success' => true, 'message' => 'Votre accès est déjà approuvé. Si vous n\'avez pas reçu l\'email, contactez l\'administrateur.']); @@ -152,7 +165,31 @@ try { $body = buildAutoApprovalEmail($thesis['title'], $thesis['authors'] ?? '', $accessUrl); $plain = htmlToPlain($body); - SmtpRelay::send($db, $email, $subject, $body, $plain); + try { + SmtpRelay::send($db, $email, $subject, $body, $plain); + } catch (SmtpSendException $e) { + if ($e->isRecipientRejected()) { + // SMTP server does not know this address — roll back the approval + // so the user can retry with a valid address. + $db->getPDO()->exec( + "DELETE FROM file_access_tokens WHERE request_id = {$requestId}" + ); + $db->getPDO()->exec( + "UPDATE file_access_requests + SET status = 'rejected', admin_notes = 'Adresse e-mail inconnue du serveur de messagerie (550)' + WHERE id = {$requestId}" + ); + http_response_code(422); + echo json_encode([ + 'success' => false, + 'message' => "L'adresse e-mail « {$email} » est introuvable sur le serveur de messagerie de l'ERG. Vérifiez l'orthographe ou utilisez une autre adresse.", + 'status' => 'recipient_rejected', + ]); + exit; + } + // Transient send failure — access is approved, email may arrive later + error_log("[request-access] Email delivery failed for approved request #{$requestId}: " . $e->getMessage()); + } http_response_code(200); echo json_encode([ @@ -201,6 +238,10 @@ try { ]); } +} catch (SmtpSendException $e) { + error_log('Access request SMTP failure: ' . $e->getMessage()); + http_response_code(500); + echo json_encode(['success' => false, 'message' => 'Erreur lors de l\'envoi de l\'email. Veuillez réessayer.']); } catch (Exception $e) { error_log('Access request failed: ' . $e->getMessage()); http_response_code(500); diff --git a/app/src/SmtpRelay.php b/app/src/SmtpRelay.php index 2dbb2b1..c1390a8 100644 --- a/app/src/SmtpRelay.php +++ b/app/src/SmtpRelay.php @@ -16,6 +16,31 @@ class SmtpProbeException extends \RuntimeException { } } +/** + * Structured exception for SMTP send failures. + * Carries the 3-digit SMTP response code and the full server response. + * + * Notable codes: + * 550 / 551 / 553 — recipient address rejected (unknown user, policy, etc.) + * 421 / 450 / 451 — transient failures (try again later) + * 530 / 535 — authentication failure + */ +class SmtpSendException extends \RuntimeException { + public function __construct( + string $message, + public readonly int $smtpCode = 0, + public readonly string $smtpResponse = '' + ) { + parent::__construct($message); + } + + /** True when the SMTP server permanently rejected the recipient address. */ + public function isRecipientRejected(): bool + { + return in_array($this->smtpCode, [550, 551, 552, 553, 554], true); + } +} + /** * SMTP Relay — credentials stored in the DB, sending via a native PHP socket * client (no external dependencies). @@ -391,6 +416,9 @@ class SmtpRelay { try { return self::smtpSend($s, $to, $rawMessage); + } catch (SmtpSendException $e) { + error_log('[SmtpRelay] ' . $e->getMessage()); + throw $e; // propagate structured exception so callers can react } catch (\Throwable $e) { error_log('[SmtpRelay] ' . $e->getMessage()); return false; @@ -463,7 +491,12 @@ class SmtpRelay { $expect = function (string $cmd, string $code) use ($send): string { $resp = $send($cmd); if (strncmp($resp, $code, strlen($code)) !== 0) { - throw new \RuntimeException("SMTP unexpected response to '{$cmd}': {$resp}"); + $respCode = (int) substr(trim($resp), 0, 3); + throw new SmtpSendException( + "SMTP unexpected response to '{$cmd}': " . trim($resp), + $respCode, + trim($resp) + ); } return $resp; }; diff --git a/app/src/StudentEmail.php b/app/src/StudentEmail.php index 9011c12..12cde81 100644 --- a/app/src/StudentEmail.php +++ b/app/src/StudentEmail.php @@ -99,7 +99,13 @@ class StudentEmail { $subject = 'Merci — ton TFE a bien été enregistré'; $htmlBody = self::buildHtml($thesis); - $result = SmtpRelay::send($db, $to, $subject, $htmlBody); + 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; + } if ($result) { error_log("[StudentEmail] Confirmation sent to {$to} for thesis #{$thesisId}");