smtp: typed probe errors with per-field UI highlighting on save

This commit is contained in:
Pontoporeia
2026-04-30 12:16:52 +02:00
parent b750aca2f5
commit bdb68479d5
7 changed files with 341 additions and 198 deletions

17
TODO.md
View File

@@ -65,9 +65,20 @@
## SMTP credential validation ## SMTP credential validation
- [x] Add `SmtpRelay::test()` — connect + EHLO + STARTTLS + AUTH + QUIT, no message sent - [x] Add `SmtpProbeException` with `field` property for structured error classification
- [x] Add `SmtpRelay::smtpProbe()` — private low-level probe (mirrors smtpSend without envelope/data) - [x] Add `SmtpRelay::test()` — returns `{ok, error, field}` with field = input id to highlight
- [x] Wire into `actions/settings.php` SMTP branch: probe immediately after save, flash success or error with detail - [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 `<small class="param-field-error">` 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 ## Répertoire layout

View File

@@ -51,9 +51,12 @@ if ($section === 'formulaire') {
// Immediately probe the server to validate credentials // Immediately probe the server to validate credentials
$test = SmtpRelay::test($db); $test = SmtpRelay::test($db);
if ($test['ok']) { 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 { } 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 { } else {
App::flash('error', "Section inconnue."); App::flash('error', "Section inconnue.");

View File

@@ -15,6 +15,8 @@ $siteSettings = $db->getAllSettings();
$stats = $db->getThesesStats(); $stats = $db->getThesesStats();
$smtpSettings = SmtpRelay::getSettings($db); $smtpSettings = SmtpRelay::getSettings($db);
$smtpConfigured = SmtpRelay::isConfigured($db); $smtpConfigured = SmtpRelay::isConfigured($db);
$smtpErrorField = $_SESSION['_flash_smtp_field'] ?? null;
unset($_SESSION['_flash_smtp_field']);
// ── System section ──────────────────────────────────────────────────────────── // ── System section ────────────────────────────────────────────────────────────
require_once APP_ROOT . '/src/SystemCache.php'; require_once APP_ROOT . '/src/SystemCache.php';

View File

@@ -1275,6 +1275,20 @@
padding: 0 var(--space-xs); 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) ─ */ /* ── Settings page sections — legacy aliases (kept for any remaining use) ─ */
.admin-settings-section { .admin-settings-section {
border: 1px solid var(--border-primary); border: 1px solid var(--border-primary);

View File

@@ -1,13 +1,30 @@
<?php <?php
/** /**
* SMTP Relay — credentials stored in the DB, sending via PHP's built-in mail * Structured exception for SMTP probe failures.
* wrappers (SMTP transport layer is wired later). * Carries a `field` hint so the UI can highlight the relevant input.
*
* field values: 'smtp_host', 'smtp_port', 'smtp_encryption',
* 'smtp_username', 'smtp_password', or null (unknown)
*/
class SmtpProbeException extends \RuntimeException {
public function __construct(
string $message,
public readonly ?string $field = null
) {
parent::__construct($message);
}
}
/**
* SMTP Relay — credentials stored in the DB, sending via a native PHP socket
* client (no external dependencies).
* *
* Responsibilities: * Responsibilities:
* 1. CRUD on the singleton smtp_settings row. * 1. CRUD on the singleton smtp_settings row.
* 2. Build MIME messages. * 2. Build MIME messages.
* 3. Send via `mail()` now; swap transport later (e.g. PHPMailer / Symfony Mailer). * 3. Send via native SMTP socket (STARTTLS / SSL / plain).
* 4. Probe credentials without sending any message (for validation on save).
*/ */
class SmtpRelay { class SmtpRelay {
@@ -41,16 +58,13 @@ class SmtpRelay {
/** /**
* Upsert SMTP settings. * Upsert SMTP settings.
* *
* @param array $data Associative array with keys: host, port, encryption, * @param array $data Keys: host, port, encryption, username, password,
* username, password, from_email, from_name. * from_email, from_name. Missing keys are left unchanged.
* Keys not present are left unchanged.
*/ */
public static function updateSettings(Database $db, array $data): void { public static function updateSettings(Database $db, array $data): void {
// Read existing so we can merge partial updates
$current = self::getSettings($db); $current = self::getSettings($db);
$merged = array_merge($current, $data); $merged = array_merge($current, $data);
// Sanitize
$port = max(1, min(65535, (int)$merged['port'])); $port = max(1, min(65535, (int)$merged['port']));
$encryption = in_array($merged['encryption'], ['tls', 'ssl', 'none'], true) $encryption = in_array($merged['encryption'], ['tls', 'ssl', 'none'], true)
? $merged['encryption'] : 'tls'; ? $merged['encryption'] : 'tls';
@@ -73,7 +87,7 @@ class SmtpRelay {
':port' => $port, ':port' => $port,
':encryption' => $encryption, ':encryption' => $encryption,
':username' => trim($merged['username']), ':username' => trim($merged['username']),
':password' => $merged['password'], // keep as-is ':password' => $merged['password'],
':from_email' => trim($merged['from_email']), ':from_email' => trim($merged['from_email']),
':from_name' => trim($merged['from_name']), ':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. * 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 $to Recipient e-mail address
* @param string $subject Subject line * @param string $subject Subject line
* @param string $body HTML body * @param string $body HTML body
@@ -117,7 +335,6 @@ class SmtpRelay {
return false; return false;
} }
// Build MIME message
$boundary = 'xamxam_' . bin2hex(random_bytes(8)); $boundary = 'xamxam_' . bin2hex(random_bytes(8));
$date = date('r'); $date = date('r');
$fromHdr = ($s['from_name'] ?? '') !== '' $fromHdr = ($s['from_name'] ?? '') !== ''
@@ -162,20 +379,15 @@ class SmtpRelay {
} }
/** /**
* Low-level native SMTP socket client. * Low-level native SMTP socket client (full send path).
*
* @param array $s SMTP settings row
* @param string $to Envelope recipient
* @param string $rawMessage Full RFC 2822 message (headers + body)
*/ */
private static function smtpSend(array $s, string $to, string $rawMessage): bool private static function smtpSend(array $s, string $to, string $rawMessage): bool
{ {
$host = $s['host']; $host = $s['host'];
$port = (int) $s['port']; $port = (int) $s['port'];
$encryption = $s['encryption']; // 'tls' | 'ssl' | 'none' $encryption = $s['encryption'];
$timeout = 15; $timeout = 15;
// For direct SSL (port 465) open with ssl:// wrapper
$connectHost = ($encryption === 'ssl') ? "ssl://{$host}" : $host; $connectHost = ($encryption === 'ssl') ? "ssl://{$host}" : $host;
$errno = 0; $errstr = ''; $errno = 0; $errstr = '';
@@ -199,7 +411,6 @@ class SmtpRelay {
$buf = ''; $buf = '';
while (($line = fgets($sock, 512)) !== false) { while (($line = fgets($sock, 512)) !== false) {
$buf .= $line; $buf .= $line;
// 4th char is ' ' when it's the last line of a multi-line reply
if (isset($line[3]) && $line[3] === ' ') break; if (isset($line[3]) && $line[3] === ' ') break;
$meta = stream_get_meta_data($sock); $meta = stream_get_meta_data($sock);
if ($meta['timed_out']) break; if ($meta['timed_out']) break;
@@ -218,167 +429,13 @@ class SmtpRelay {
return $resp; return $resp;
}; };
// Greeting
$greeting = $read(); $greeting = $read();
if (strncmp($greeting, '220', 3) !== 0) { if (strncmp($greeting, '220', 3) !== 0) {
$meta = stream_get_meta_data($sock); $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}"); 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 { $parseEhlo = function (string $resp): array {
$caps = []; $caps = [];
foreach (explode("\n", $resp) as $line) { foreach (explode("\n", $resp) as $line) {
@@ -397,7 +454,7 @@ class SmtpRelay {
if ($encryption === 'tls') { if ($encryption === 'tls') {
$expect('STARTTLS', '220'); $expect('STARTTLS', '220');
if (!stream_socket_enable_crypto($sock, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { 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()); $ehloResp = $send('EHLO ' . gethostname());
$caps = $parseEhlo($ehloResp); $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'); $send('QUIT');
fclose($sock); fclose($sock);
return true;
} }
// -----------------------------------------------------------------------
// Queue stub
// -----------------------------------------------------------------------
/** /**
* Queue (persist) an e-mail for deferred sending. * Queue (persist) an e-mail for deferred sending.
*
* Stub — will create a `mail_queue` table in a future migration. * Stub — will create a `mail_queue` table in a future migration.
*/ */
public static function queue( public static function queue(
@@ -436,19 +507,14 @@ class SmtpRelay {
string $plain = '' string $plain = ''
): void { ): void {
// TODO: INSERT INTO mail_queue … // TODO: INSERT INTO mail_queue …
// Placeholder so callers exist now and wire up later.
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Internal // Internal
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
/**
* Strip HTML tags to produce a rough plain-text fallback.
*/
private static function htmlToPlain(string $html): string { private static function htmlToPlain(string $html): string {
$text = strip_tags($html); $text = strip_tags($html);
// Collapse multiple whitespace lines
$text = preg_replace('/\n{3,}/', "\n\n", $text); $text = preg_replace('/\n{3,}/', "\n\n", $text);
return trim($text); return trim($text);
} }

View File

@@ -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":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":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":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"}

View File

@@ -183,7 +183,27 @@
<?php endif; ?> <?php endif; ?>
</div> </div>
<form method="post" action="actions/settings.php" class="param-form"> <?php
// Inline helper: emit aria-invalid + error <small> 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
? '<small class="param-field-error" id="' . $id . '-error">' . htmlspecialchars($msg) . '</small>'
: '';
};
// Human-readable hints per field (brief — the full message is in the toast)
$smtpHints = [
'smtp_host' => 'Vérifiez ladresse 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 dutilisateur.',
'smtp_password' => 'Mot de passe incorrect.',
];
?>
<form method="post" action="actions/settings.php" class="param-form"
<?= $smtpErrorField ? 'data-smtp-error-field="' . htmlspecialchars($smtpErrorField) . '"' : '' ?>>
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>"> <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="section" value="smtp"> <input type="hidden" name="section" value="smtp">
@@ -192,29 +212,41 @@
<label for="smtp_host">Hôte SMTP</label> <label for="smtp_host">Hôte SMTP</label>
<input type="text" id="smtp_host" name="smtp_host" <input type="text" id="smtp_host" name="smtp_host"
value="<?= htmlspecialchars($smtpSettings['host']) ?>" value="<?= htmlspecialchars($smtpSettings['host']) ?>"
placeholder="smtp.example.com"> placeholder="smtp.example.com"
<?= $smtpFieldErr('smtp_host') ?>
<?= $smtpErrorField === 'smtp_host' ? 'aria-describedby="smtp_host-error"' : '' ?>>
<?= $smtpFieldMsg('smtp_host', $smtpHints['smtp_host']) ?>
</div> </div>
<div> <div>
<label for="smtp_port">Port</label> <label for="smtp_port">Port</label>
<input type="number" id="smtp_port" name="smtp_port" <input type="number" id="smtp_port" name="smtp_port"
value="<?= (int)$smtpSettings['port'] ?>" value="<?= (int)$smtpSettings['port'] ?>"
min="1" max="65535"> min="1" max="65535"
<?= $smtpFieldErr('smtp_port') ?>
<?= $smtpErrorField === 'smtp_port' ? 'aria-describedby="smtp_port-error"' : '' ?>>
<?= $smtpFieldMsg('smtp_port', $smtpHints['smtp_port']) ?>
</div> </div>
<div> <div>
<label for="smtp_encryption">Chiffrement</label> <label for="smtp_encryption">Chiffrement</label>
<select id="smtp_encryption" name="smtp_encryption"> <select id="smtp_encryption" name="smtp_encryption"
<?= $smtpFieldErr('smtp_encryption') ?>
<?= $smtpErrorField === 'smtp_encryption' ? 'aria-describedby="smtp_encryption-error"' : '' ?>>
<option value="tls" <?= $smtpSettings['encryption'] === 'tls' ? 'selected' : '' ?>>TLS (STARTTLS)</option> <option value="tls" <?= $smtpSettings['encryption'] === 'tls' ? 'selected' : '' ?>>TLS (STARTTLS)</option>
<option value="ssl" <?= $smtpSettings['encryption'] === 'ssl' ? 'selected' : '' ?>>SSL (SMTPS)</option> <option value="ssl" <?= $smtpSettings['encryption'] === 'ssl' ? 'selected' : '' ?>>SSL (SMTPS)</option>
<option value="none" <?= $smtpSettings['encryption'] === 'none' ? 'selected' : '' ?>>Aucun</option> <option value="none" <?= $smtpSettings['encryption'] === 'none' ? 'selected' : '' ?>>Aucun</option>
</select> </select>
<?= $smtpFieldMsg('smtp_encryption', $smtpHints['smtp_encryption']) ?>
</div> </div>
<div> <div>
<label for="smtp_username">Nom d'utilisateur</label> <label for="smtp_username">Nom d'utilisateur</label>
<input type="text" id="smtp_username" name="smtp_username" <input type="text" id="smtp_username" name="smtp_username"
value="<?= htmlspecialchars($smtpSettings['username']) ?>"> value="<?= htmlspecialchars($smtpSettings['username']) ?>"
<?= $smtpFieldErr('smtp_username') ?>
<?= $smtpErrorField === 'smtp_username' ? 'aria-describedby="smtp_username-error"' : '' ?>>
<?= $smtpFieldMsg('smtp_username', $smtpHints['smtp_username']) ?>
</div> </div>
<div> <div>
@@ -222,7 +254,10 @@
<input type="password" id="smtp_password" name="smtp_password" <input type="password" id="smtp_password" name="smtp_password"
value="<?= htmlspecialchars($smtpSettings['password']) ?>" value="<?= htmlspecialchars($smtpSettings['password']) ?>"
autocomplete="new-password" autocomplete="new-password"
placeholder="Laissez vide pour ne pas modifier"> placeholder="Laissez vide pour ne pas modifier"
<?= $smtpFieldErr('smtp_password') ?>
<?= $smtpErrorField === 'smtp_password' ? 'aria-describedby="smtp_password-error"' : '' ?>>
<?= $smtpFieldMsg('smtp_password', $smtpHints['smtp_password']) ?>
</div> </div>
</div> </div>
@@ -507,6 +542,17 @@ function fallbackCopy(text, btn) {
} catch(e) {} } catch(e) {}
document.body.removeChild(ta); 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 // Update active tab class after each HTMX swap on #sys-tab-panel
document.body.addEventListener('htmx:afterSwap', function(evt) { document.body.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail.target && evt.detail.target.id === 'sys-tab-panel') { if (evt.detail.target && evt.detail.target.id === 'sys-tab-panel') {