From bdb68479d571c98ac6887addeff46d1ff183492c Mon Sep 17 00:00:00 2001 From: Pontoporeia Date: Thu, 30 Apr 2026 12:16:52 +0200 Subject: [PATCH] smtp: typed probe errors with per-field UI highlighting on save --- TODO.md | 17 +- app/public/admin/actions/settings.php | 7 +- app/public/admin/parametres.php | 6 +- app/public/assets/css/admin.css | 14 + app/src/SmtpRelay.php | 436 +++++++++++++++----------- app/storage/logs/form-submissions.log | 1 + app/templates/admin/parametres.php | 58 +++- 7 files changed, 341 insertions(+), 198 deletions(-) diff --git a/TODO.md b/TODO.md index 0a491bf..3d5ee62 100644 --- a/TODO.md +++ b/TODO.md @@ -65,9 +65,20 @@ ## 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 +- [x] Add `SmtpProbeException` with `field` property for structured error classification +- [x] Add `SmtpRelay::test()` — returns `{ok, error, field}` with field = input id to highlight +- [x] `smtpProbe()` throws typed exceptions per failure point: + - connect fail → name resolution error → `smtp_host` + - connect fail → port refused → `smtp_port` + - connect fail → timeout → `smtp_host` + - bad greeting / timeout after connect → `smtp_host` / `smtp_port` + - STARTTLS not supported / TLS negotiation fail → `smtp_encryption` + - AUTH rejected, code 535 → `smtp_password`; other auth failures → `smtp_username` +- [x] `actions/settings.php`: store `$_SESSION['_flash_smtp_field']` on probe failure +- [x] `parametres.php` controller: consume + clear `_flash_smtp_field` into `$smtpErrorField` +- [x] Template: `aria-invalid`, `aria-describedby`, inline `` per field +- [x] JS: scroll + focus the offending field on page load +- [x] CSS: red `border-bottom` on `[aria-invalid]`, `.param-field-error` error text style ## Répertoire layout diff --git a/app/public/admin/actions/settings.php b/app/public/admin/actions/settings.php index 26cbd1f..ef38f87 100644 --- a/app/public/admin/actions/settings.php +++ b/app/public/admin/actions/settings.php @@ -51,9 +51,12 @@ if ($section === 'formulaire') { // 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 ✓"); + App::flash('success', "Paramètres SMTP mis à jour — connexion validée ✓"); } else { - App::flash('error', "Paramètres sauvegardés, mais le test de connexion SMTP a échoué : " . $test['error']); + App::flash('error', "Paramètres sauvegardés, mais le test de connexion a échoué : " . $test['error']); + if ($test['field'] !== null) { + $_SESSION['_flash_smtp_field'] = $test['field']; + } } } else { App::flash('error', "Section inconnue."); diff --git a/app/public/admin/parametres.php b/app/public/admin/parametres.php index ff9e3dd..fede7a0 100644 --- a/app/public/admin/parametres.php +++ b/app/public/admin/parametres.php @@ -13,8 +13,10 @@ require_once APP_ROOT . '/src/SmtpRelay.php'; $db = new Database(); $siteSettings = $db->getAllSettings(); $stats = $db->getThesesStats(); -$smtpSettings = SmtpRelay::getSettings($db); -$smtpConfigured = SmtpRelay::isConfigured($db); +$smtpSettings = SmtpRelay::getSettings($db); +$smtpConfigured = SmtpRelay::isConfigured($db); +$smtpErrorField = $_SESSION['_flash_smtp_field'] ?? null; +unset($_SESSION['_flash_smtp_field']); // ── System section ──────────────────────────────────────────────────────────── require_once APP_ROOT . '/src/SystemCache.php'; diff --git a/app/public/assets/css/admin.css b/app/public/assets/css/admin.css index d586ae5..e5ea1eb 100644 --- a/app/public/assets/css/admin.css +++ b/app/public/assets/css/admin.css @@ -1275,6 +1275,20 @@ padding: 0 var(--space-xs); } +/* SMTP field validation error state */ +.param-grid input[aria-invalid="true"], +.param-grid select[aria-invalid="true"] { + border-bottom-color: var(--search-error-border, #c0392b); +} + +.param-field-error { + display: block; + font-size: var(--step--2); + color: var(--search-error-border, #c0392b); + margin-top: var(--space-3xs); + line-height: 1.3; +} + /* ── Settings page sections — legacy aliases (kept for any remaining use) ─ */ .admin-settings-section { border: 1px solid var(--border-primary); diff --git a/app/src/SmtpRelay.php b/app/src/SmtpRelay.php index 14e9a8a..9c7a4fa 100644 --- a/app/src/SmtpRelay.php +++ b/app/src/SmtpRelay.php @@ -1,13 +1,30 @@ $port, ':encryption' => $encryption, ':username' => trim($merged['username']), - ':password' => $merged['password'], // keep as-is + ':password' => $merged['password'], ':from_email' => trim($merged['from_email']), ':from_name' => trim($merged['from_name']), ]); @@ -88,16 +102,220 @@ class SmtpRelay { } // ----------------------------------------------------------------------- - // Send helpers (transport wired later — stub implementation now) + // Credential probe (validates without sending any message) + // ----------------------------------------------------------------------- + + /** + * Test SMTP credentials without sending any message. + * + * Connects, does EHLO + optional STARTTLS, authenticates, then QUITs. + * No MAIL FROM / RCPT TO / DATA — nothing lands in any mailbox. + * + * Returns ['ok' => bool, 'error' => string, 'field' => ?string]. + * `field` is the HTML input id to highlight on failure: + * 'smtp_host' | 'smtp_port' | 'smtp_encryption' | 'smtp_username' | 'smtp_password' | null + * + * @return array{ok:bool, error:string, field:?string} + */ + public static function test(Database $db): array + { + $s = self::getSettings($db); + if ($s['host'] === '') { + return ['ok' => false, 'error' => 'Hôte SMTP non configuré.', 'field' => 'smtp_host']; + } + + try { + self::smtpProbe($s); + return ['ok' => true, 'error' => '', 'field' => null]; + } catch (SmtpProbeException $e) { + return ['ok' => false, 'error' => $e->getMessage(), 'field' => $e->field]; + } catch (\Throwable $e) { + return ['ok' => false, 'error' => $e->getMessage(), 'field' => null]; + } + } + + /** + * Low-level SMTP probe: connect → EHLO → STARTTLS → AUTH → QUIT. + * Throws SmtpProbeException with a `field` hint on every failure point. + */ + 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) { + $isNameFail = ( + stripos($errstr, 'name or service') !== false || + stripos($errstr, 'resolve') !== false || + stripos($errstr, 'getaddrinfo') !== false || + stripos($errstr, 'nodename') !== false + ); + $isRefused = ($errno === 111 || stripos($errstr, 'refused') !== false); + $isTimeout = ($errno === 110 || stripos($errstr, 'timed') !== false || $errstr === ''); + + if ($isNameFail) { + throw new SmtpProbeException( + "Hôte introuvable « {$host} » — vérifiez l'adresse du serveur SMTP.", + 'smtp_host' + ); + } + if ($isRefused) { + throw new SmtpProbeException( + "Connexion refusée sur le port {$port} — vérifiez le port et le mode de chiffrement.", + 'smtp_port' + ); + } + throw new SmtpProbeException( + $isTimeout + ? "Délai dépassé en tentant de joindre {$host}:{$port} — hôte ou port incorrect ?" + : "Connexion impossible à {$host}:{$port} — {$errstr} [{$errno}]", + $isTimeout ? 'smtp_host' : null + ); + } + stream_set_timeout($sock, $timeout); + + $read = function () use ($sock): 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(); + }; + + // Greeting + $greeting = $read(); + if (strncmp($greeting, '220', 3) !== 0) { + $meta = stream_get_meta_data($sock); + if ($meta['timed_out']) { + throw new SmtpProbeException( + "Délai dépassé en attendant la salutation SMTP — hôte ou port incorrect ?", + 'smtp_port' + ); + } + throw new SmtpProbeException( + "Réponse inattendue du serveur : " . json_encode(trim($greeting)), + 'smtp_host' + ); + } + + $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()); + } + + // STARTTLS upgrade + if ($encryption === 'tls') { + $stResp = $send('STARTTLS'); + if (strncmp($stResp, '220', 3) !== 0) { + throw new SmtpProbeException( + "Le serveur ne supporte pas STARTTLS — essayez SSL (port 465) ou \"Aucun\".", + 'smtp_encryption' + ); + } + if (!stream_socket_enable_crypto($sock, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { + throw new SmtpProbeException( + "Échec de la négociation TLS — essayez SSL (port 465) ou vérifiez le port.", + 'smtp_encryption' + ); + } + $ehloResp = $send('EHLO ' . gethostname()); + $caps = $parseEhlo($ehloResp); + } + + // AUTH + if ($s['username'] !== '') { + $authLine = ''; + foreach ($caps as $cap) { + if (strncmp($cap, 'AUTH', 4) === 0) { $authLine = $cap; break; } + } + $mechanisms = preg_split('/[\s=]+/', $authLine); + + try { + if (in_array('PLAIN', $mechanisms, true)) { + $token = base64_encode("\0{$s['username']}\0{$s['password']}"); + $resp = $send("AUTH PLAIN {$token}"); + if (strncmp($resp, '235', 3) !== 0) { + $code = substr(trim($resp), 0, 3); + $field = ($code === '535') ? 'smtp_password' : 'smtp_username'; + throw new SmtpProbeException( + "Authentification refusée : " . trim($resp), + $field + ); + } + } else { + // AUTH LOGIN challenge/response + $r1 = $send('AUTH LOGIN'); + if (strncmp($r1, '334', 3) !== 0) { + throw new SmtpProbeException( + "Le serveur n'accepte pas AUTH LOGIN : " . trim($r1), + 'smtp_username' + ); + } + $r2 = $send(base64_encode($s['username'])); + if (strncmp($r2, '334', 3) !== 0) { + throw new SmtpProbeException( + "Nom d'utilisateur refusé : " . trim($r2), + 'smtp_username' + ); + } + $r3 = $send(base64_encode($s['password'])); + if (strncmp($r3, '235', 3) !== 0) { + throw new SmtpProbeException( + "Mot de passe refusé : " . trim($r3), + 'smtp_password' + ); + } + } + } catch (SmtpProbeException $e) { + @fclose($sock); + throw $e; + } + } + + $send('QUIT'); + fclose($sock); + } + + // ----------------------------------------------------------------------- + // Send // ----------------------------------------------------------------------- /** * Send an e-mail using the stored SMTP credentials. * - * Uses a native PHP socket SMTP client — no external dependencies. - * Supports STARTTLS (port 587 / encryption=tls) and direct SSL (port 465 / - * encryption=ssl). Falls back to plain if encryption=none. - * * @param string $to Recipient e-mail address * @param string $subject Subject line * @param string $body HTML body @@ -117,7 +335,6 @@ class SmtpRelay { return false; } - // Build MIME message $boundary = 'xamxam_' . bin2hex(random_bytes(8)); $date = date('r'); $fromHdr = ($s['from_name'] ?? '') !== '' @@ -162,20 +379,15 @@ class SmtpRelay { } /** - * Low-level native SMTP socket client. - * - * @param array $s SMTP settings row - * @param string $to Envelope recipient - * @param string $rawMessage Full RFC 2822 message (headers + body) + * Low-level native SMTP socket client (full send path). */ private static function smtpSend(array $s, string $to, string $rawMessage): bool { $host = $s['host']; $port = (int) $s['port']; - $encryption = $s['encryption']; // 'tls' | 'ssl' | 'none' + $encryption = $s['encryption']; $timeout = 15; - // For direct SSL (port 465) open with ssl:// wrapper $connectHost = ($encryption === 'ssl') ? "ssl://{$host}" : $host; $errno = 0; $errstr = ''; @@ -199,7 +411,6 @@ class SmtpRelay { $buf = ''; while (($line = fgets($sock, 512)) !== false) { $buf .= $line; - // 4th char is ' ' when it's the last line of a multi-line reply if (isset($line[3]) && $line[3] === ' ') break; $meta = stream_get_meta_data($sock); if ($meta['timed_out']) break; @@ -218,167 +429,13 @@ class SmtpRelay { return $resp; }; - // Greeting $greeting = $read(); if (strncmp($greeting, '220', 3) !== 0) { $meta = stream_get_meta_data($sock); - $detail = $meta['timed_out'] ? '(socket timed out — no data received)' : '(received: ' . json_encode($greeting) . ')'; + $detail = $meta['timed_out'] ? '(timed out)' : '(received: ' . json_encode($greeting) . ')'; throw new \RuntimeException("SMTP bad greeting {$detail}"); } - $parseEhlo = function (string $resp): array { - // Returns list of capability tokens, e.g. ['STARTTLS','AUTH PLAIN LOGIN',...] - $caps = []; - foreach (explode("\n", $resp) as $line) { - $line = rtrim($line); - if (strlen($line) > 4) { - $caps[] = strtoupper(substr($line, 4)); - } - } - return $caps; - }; - - // EHLO - $ehloResp = $send('EHLO ' . gethostname()); - $caps = strncmp($ehloResp, '250', 3) === 0 ? $parseEhlo($ehloResp) : []; - if (empty($caps)) { - $send('HELO ' . gethostname()); - } - - // STARTTLS upgrade - if ($encryption === 'tls') { - $expect('STARTTLS', '220'); - if (!stream_socket_enable_crypto($sock, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { - throw new \RuntimeException('SMTP STARTTLS crypto negotiation failed (check server cert / CA bundle)'); - } - // Re-EHLO after TLS — refresh capabilities - $ehloResp = $send('EHLO ' . gethostname()); - $caps = $parseEhlo($ehloResp); - } - - // AUTH — pick mechanism from server capabilities - if ($s['username'] !== '') { - // Find the AUTH line, e.g. "AUTH PLAIN LOGIN" or "AUTH=PLAIN LOGIN" - $authLine = ''; - foreach ($caps as $cap) { - if (strncmp($cap, 'AUTH', 4) === 0) { $authLine = $cap; break; } - } - $mechanisms = preg_split('/[\s=]+/', $authLine); - // Prefer PLAIN, fall back to LOGIN - if (in_array('PLAIN', $mechanisms, true)) { - // AUTH PLAIN: single base64(\0user\0pass) - $token = base64_encode("\0{$s['username']}\0{$s['password']}"); - $expect("AUTH PLAIN {$token}", '235'); - } else { - // AUTH LOGIN: challenge/response - $expect('AUTH LOGIN', '334'); - $expect(base64_encode($s['username']), '334'); - $expect(base64_encode($s['password']), '235'); - } - } - - // Envelope - $expect("MAIL FROM:<{$s['from_email']}>", '250'); - $expect("RCPT TO:<{$to}>", '250'); - $expect('DATA', '354'); - - // Message — dot-stuff lines starting with '.' - $stuffed = preg_replace('/^\./m', '..', $rawMessage); - $resp = $send($stuffed . "\r\n."); - if (strncmp($resp, '250', 3) !== 0) { - throw new \RuntimeException("SMTP DATA rejected: {$resp}"); - } - - $send('QUIT'); - fclose($sock); - 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) { @@ -397,7 +454,7 @@ class SmtpRelay { 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)'); + throw new \RuntimeException('SMTP STARTTLS crypto negotiation failed'); } $ehloResp = $send('EHLO ' . gethostname()); $caps = $parseEhlo($ehloResp); @@ -419,13 +476,27 @@ class SmtpRelay { } } + $expect("MAIL FROM:<{$s['from_email']}>", '250'); + $expect("RCPT TO:<{$to}>", '250'); + $expect('DATA', '354'); + + $stuffed = preg_replace('/^\./m', '..', $rawMessage); + $resp = $send($stuffed . "\r\n."); + if (strncmp($resp, '250', 3) !== 0) { + throw new \RuntimeException("SMTP DATA rejected: {$resp}"); + } + $send('QUIT'); fclose($sock); + return true; } + // ----------------------------------------------------------------------- + // Queue stub + // ----------------------------------------------------------------------- + /** * Queue (persist) an e-mail for deferred sending. - * * Stub — will create a `mail_queue` table in a future migration. */ public static function queue( @@ -436,19 +507,14 @@ class SmtpRelay { string $plain = '' ): void { // TODO: INSERT INTO mail_queue … - // Placeholder so callers exist now and wire up later. } // ----------------------------------------------------------------------- // Internal // ----------------------------------------------------------------------- - /** - * Strip HTML tags to produce a rough plain-text fallback. - */ private static function htmlToPlain(string $html): string { $text = strip_tags($html); - // Collapse multiple whitespace lines $text = preg_replace('/\n{3,}/', "\n\n", $text); return trim($text); } diff --git a/app/storage/logs/form-submissions.log b/app/storage/logs/form-submissions.log index 13ff8f9..72786ae 100644 --- a/app/storage/logs/form-submissions.log +++ b/app/storage/logs/form-submissions.log @@ -1,3 +1,4 @@ {"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"} +{"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"} diff --git a/app/templates/admin/parametres.php b/app/templates/admin/parametres.php index 98c75ec..228e530 100644 --- a/app/templates/admin/parametres.php +++ b/app/templates/admin/parametres.php @@ -183,7 +183,27 @@ -
+ when this field is the culprit + $smtpFieldErr = function(string $id) use ($smtpErrorField): string { + return $smtpErrorField === $id ? ' aria-invalid="true"' : ''; + }; + $smtpFieldMsg = function(string $id, string $msg) use ($smtpErrorField): string { + return $smtpErrorField === $id + ? '' . htmlspecialchars($msg) . '' + : ''; + }; + // Human-readable hints per field (brief — the full message is in the toast) + $smtpHints = [ + 'smtp_host' => 'Vérifiez l’adresse du serveur SMTP.', + 'smtp_port' => 'Vérifiez le numéro de port.', + 'smtp_encryption' => 'Vérifiez le mode de chiffrement.', + 'smtp_username' => 'Vérifiez le nom d’utilisateur.', + 'smtp_password' => 'Mot de passe incorrect.', + ]; + ?> + > @@ -192,29 +212,41 @@ + placeholder="smtp.example.com" + + > +
+ min="1" max="65535" + + > +
- + > +
+ value="" + + > +
@@ -222,7 +254,10 @@ + placeholder="Laissez vide pour ne pas modifier" + + > +
@@ -507,6 +542,17 @@ function fallbackCopy(text, btn) { } catch(e) {} document.body.removeChild(ta); } +// Focus the SMTP field that caused the probe error +(function () { + var form = document.querySelector('form[data-smtp-error-field]'); + if (!form) return; + var fieldId = form.getAttribute('data-smtp-error-field'); + var el = fieldId ? document.getElementById(fieldId) : null; + if (!el) return; + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + el.focus(); +}()); + // Update active tab class after each HTMX swap on #sys-tab-panel document.body.addEventListener('htmx:afterSwap', function(evt) { if (evt.detail.target && evt.detail.target.id === 'sys-tab-panel') {