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

@@ -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.
*