From b750aca2f5028c85272a9c6533163efd62de33df Mon Sep 17 00:00:00 2001 From: Pontoporeia Date: Thu, 30 Apr 2026 12:10:41 +0200 Subject: [PATCH] smtp: probe credentials on save (connect+auth+quit, no message sent) --- TODO.md | 6 ++ app/public/admin/actions/settings.php | 9 +- app/src/SmtpRelay.php | 129 ++++++++++++++++++++++++++ app/storage/logs/form-submissions.log | 1 + 4 files changed, 144 insertions(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index 39f3e15..0a491bf 100644 --- a/TODO.md +++ b/TODO.md @@ -63,6 +63,12 @@ - [ ] Verify TCP reachability from XAMXAM VM to LDAP server (port 636) - [ ] See `docs/LDAP_AUTH_PLAN.md` for full phase-by-phase plan +## SMTP credential validation + +- [x] Add `SmtpRelay::test()` — connect + EHLO + STARTTLS + AUTH + QUIT, no message sent +- [x] Add `SmtpRelay::smtpProbe()` — private low-level probe (mirrors smtpSend without envelope/data) +- [x] Wire into `actions/settings.php` SMTP branch: probe immediately after save, flash success or error with detail + ## Répertoire layout - [x] Make column headings sticky/non-scrollable; only `ul` scrolls per column diff --git a/app/public/admin/actions/settings.php b/app/public/admin/actions/settings.php index e303692..26cbd1f 100644 --- a/app/public/admin/actions/settings.php +++ b/app/public/admin/actions/settings.php @@ -47,7 +47,14 @@ if ($section === 'formulaire') { $smtpData['password'] = $pwd; } SmtpRelay::updateSettings($db, $smtpData); - App::flash('success', "Paramètres SMTP mis à jour."); + + // Immediately probe the server to validate credentials + $test = SmtpRelay::test($db); + if ($test['ok']) { + App::flash('success', "Paramètres SMTP mis à jour — connexion validée avec succès ✓"); + } else { + App::flash('error', "Paramètres sauvegardés, mais le test de connexion SMTP a échoué : " . $test['error']); + } } else { App::flash('error', "Section inconnue."); } diff --git a/app/src/SmtpRelay.php b/app/src/SmtpRelay.php index 0394c47..14e9a8a 100644 --- a/app/src/SmtpRelay.php +++ b/app/src/SmtpRelay.php @@ -294,6 +294,135 @@ class SmtpRelay { return true; } + /** + * Test the stored SMTP credentials without sending any message. + * + * Opens a socket, performs EHLO, upgrades to TLS if configured, authenticates, + * then immediately sends QUIT. No MAIL FROM / RCPT TO / DATA is issued, + * so nothing lands in any mailbox. + * + * @return array{ok:bool, error:string} + */ + public static function test(Database $db): array + { + $s = self::getSettings($db); + if ($s['host'] === '') { + return ['ok' => false, 'error' => 'Hôte SMTP non configuré.']; + } + + try { + self::smtpProbe($s); + return ['ok' => true, 'error' => '']; + } catch (\Throwable $e) { + return ['ok' => false, 'error' => $e->getMessage()]; + } + } + + /** + * Like smtpSend() but stops after AUTH — no envelope, no message. + */ + private static function smtpProbe(array $s): void + { + $host = $s['host']; + $port = (int) $s['port']; + $encryption = $s['encryption']; + $timeout = 10; + + $connectHost = ($encryption === 'ssl') ? "ssl://{$host}" : $host; + + $errno = 0; $errstr = ''; + $ctx = stream_context_create([ + 'ssl' => [ + 'verify_peer' => false, + 'verify_peer_name' => false, + 'allow_self_signed' => true, + ], + ]); + $sock = @stream_socket_client( + "{$connectHost}:{$port}", $errno, $errstr, $timeout, + STREAM_CLIENT_CONNECT, $ctx + ); + if ($sock === false) { + throw new \RuntimeException("Connexion impossible à {$host}:{$port} — {$errstr} [{$errno}]"); + } + stream_set_timeout($sock, $timeout); + + $read = function () use ($sock, $timeout): string { + $buf = ''; + while (($line = fgets($sock, 512)) !== false) { + $buf .= $line; + if (isset($line[3]) && $line[3] === ' ') break; + $meta = stream_get_meta_data($sock); + if ($meta['timed_out']) break; + } + return $buf; + }; + $send = function (string $cmd) use ($sock, $read): string { + fwrite($sock, $cmd . "\r\n"); + return $read(); + }; + $expect = function (string $cmd, string $code) use ($send): string { + $resp = $send($cmd); + if (strncmp($resp, $code, strlen($code)) !== 0) { + throw new \RuntimeException("Réponse SMTP inattendue pour '{$cmd}' : " . trim($resp)); + } + return $resp; + }; + + // Greeting + $greeting = $read(); + if (strncmp($greeting, '220', 3) !== 0) { + $meta = stream_get_meta_data($sock); + $detail = $meta['timed_out'] + ? '(délai dépassé — aucune donnée reçue)' + : '(reçu : ' . json_encode(trim($greeting)) . ')'; + throw new \RuntimeException("Salutation SMTP invalide {$detail}"); + } + + $parseEhlo = function (string $resp): array { + $caps = []; + foreach (explode("\n", $resp) as $line) { + $line = rtrim($line); + if (strlen($line) > 4) $caps[] = strtoupper(substr($line, 4)); + } + return $caps; + }; + + $ehloResp = $send('EHLO ' . gethostname()); + $caps = strncmp($ehloResp, '250', 3) === 0 ? $parseEhlo($ehloResp) : []; + if (empty($caps)) { + $send('HELO ' . gethostname()); + } + + if ($encryption === 'tls') { + $expect('STARTTLS', '220'); + if (!stream_socket_enable_crypto($sock, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { + throw new \RuntimeException('Échec de la négociation TLS (vérifiez le certificat du serveur)'); + } + $ehloResp = $send('EHLO ' . gethostname()); + $caps = $parseEhlo($ehloResp); + } + + if ($s['username'] !== '') { + $authLine = ''; + foreach ($caps as $cap) { + if (strncmp($cap, 'AUTH', 4) === 0) { $authLine = $cap; break; } + } + $mechanisms = preg_split('/[\s=]+/', $authLine); + if (in_array('PLAIN', $mechanisms, true)) { + $token = base64_encode("\0{$s['username']}\0{$s['password']}"); + $expect("AUTH PLAIN {$token}", '235'); + } else { + $expect('AUTH LOGIN', '334'); + $expect(base64_encode($s['username']), '334'); + $expect(base64_encode($s['password']), '235'); + } + } + + $send('QUIT'); + fclose($sock); + } + /** * Queue (persist) an e-mail for deferred sending. * diff --git a/app/storage/logs/form-submissions.log b/app/storage/logs/form-submissions.log index cfee131..13ff8f9 100644 --- a/app/storage/logs/form-submissions.log +++ b/app/storage/logs/form-submissions.log @@ -1,2 +1,3 @@ {"source":"partage","action":"submit","status":"success","thesis_id":15,"identifier":"2025-012","author":"Emma Renard","share_slug":"20260429-DZESJT6X","timestamp":"2026-04-30T09:20:16+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":16,"identifier":"2025-013","author":"Emma Renard","share_slug":"20260429-DZESJT6X","timestamp":"2026-04-30T09:35: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"} +{"source":"partage","action":"submit","status":"success","thesis_id":17,"identifier":"2025-014","author":"Théo Marchand","share_slug":"20260429-DZESJT6X","timestamp":"2026-04-30T09:48:20+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"}