mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-05-06 19:19:19 +02:00
smtp: typed probe errors with per-field UI highlighting on save
This commit is contained in:
17
TODO.md
17
TODO.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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 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.',
|
||||||
|
];
|
||||||
|
?>
|
||||||
|
<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') {
|
||||||
|
|||||||
Reference in New Issue
Block a user