smtp: probe credentials on save (connect+auth+quit, no message sent)

This commit is contained in:
Pontoporeia
2026-04-30 12:10:41 +02:00
parent 56c8d54435
commit b750aca2f5
4 changed files with 144 additions and 1 deletions

View File

@@ -63,6 +63,12 @@
- [ ] Verify TCP reachability from XAMXAM VM to LDAP server (port 636) - [ ] Verify TCP reachability from XAMXAM VM to LDAP server (port 636)
- [ ] See `docs/LDAP_AUTH_PLAN.md` for full phase-by-phase plan - [ ] 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 ## Répertoire layout
- [x] Make column headings sticky/non-scrollable; only `ul` scrolls per column - [x] Make column headings sticky/non-scrollable; only `ul` scrolls per column

View File

@@ -47,7 +47,14 @@ if ($section === 'formulaire') {
$smtpData['password'] = $pwd; $smtpData['password'] = $pwd;
} }
SmtpRelay::updateSettings($db, $smtpData); 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 { } else {
App::flash('error', "Section inconnue."); App::flash('error', "Section inconnue.");
} }

View File

@@ -294,6 +294,135 @@ class SmtpRelay {
return true; 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. * Queue (persist) an e-mail for deferred sending.
* *

View File

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