getPDO()->query( "SELECT host, port, encryption, username, password, from_email, from_name, notify_email FROM v_smtp_active LIMIT 1" ); $row = $stmt->fetch(); return $row ?: [ 'host' => '', 'port' => 587, 'encryption' => 'tls', 'username' => '', 'password' => '', 'from_email' => '', 'from_name' => 'XAMXAM', 'notify_email' => '', ]; } /** * Return the address that should receive admin notification emails. * Uses notify_email when set, falls back to from_email. */ public static function getNotifyEmail(Database $db): string { $s = self::getSettings($db); $notify = trim($s['notify_email'] ?? ''); return $notify !== '' ? $notify : trim($s['from_email'] ?? ''); } /** * Upsert SMTP settings. * * @param array $data Keys: host, port, encryption, username, password, * from_email, from_name. Missing keys are left unchanged. */ public static function updateSettings(Database $db, array $data): void { $current = self::getSettings($db); $merged = array_merge($current, $data); $port = max(1, min(65535, (int)$merged['port'])); $encryption = in_array($merged['encryption'], ['tls', 'ssl', 'none'], true) ? $merged['encryption'] : 'tls'; $stmt = $db->getPDO()->prepare( "UPDATE smtp_settings SET host = :host, port = :port, encryption = :encryption, username = :username, password = :password, from_email = :from_email, from_name = :from_name, notify_email = :notify_email, updated_at = CURRENT_TIMESTAMP WHERE id = 1" ); $stmt->execute([ ':host' => trim($merged['host']), ':port' => $port, ':encryption' => $encryption, ':username' => trim($merged['username']), ':password' => $merged['password'], ':from_email' => trim($merged['from_email']), ':from_name' => trim($merged['from_name']), ':notify_email' => trim($merged['notify_email'] ?? ''), ]); } /** * Check whether the SMTP relay is fully configured. */ public static function isConfigured(Database $db): bool { $s = self::getSettings($db); return $s['host'] !== '' && $s['username'] !== '' && $s['from_email'] !== ''; } // ----------------------------------------------------------------------- // 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' => true, 'verify_peer_name' => true, 'peer_name' => $host, 'cafile' => self::caBundlePath(), ], ]); $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' ); } stream_context_set_option($sock, 'ssl', 'peer_name', $host); stream_context_set_option($sock, 'ssl', 'verify_peer', true); stream_context_set_option($sock, 'ssl', 'verify_peer_name', true); stream_context_set_option($sock, 'ssl', 'cafile', self::caBundlePath()); if (!stream_socket_enable_crypto($sock, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { throw new SmtpProbeException( "Échec de la négociation TLS — certificat invalide ou port incorrect ?", '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. * * @param string $to Recipient e-mail address * @param string $subject Subject line * @param string $body HTML body * @param string $plain Plain-text alternative (optional) * @return bool True on successful delivery acceptance; false on failure */ public static function send( Database $db, string $to, string $subject, string $body, string $plain = '' ): bool { $s = self::getSettings($db); if ($s['from_email'] === '') { error_log('[SmtpRelay] send() aborted — no from_email configured'); return false; } $boundary = 'xamxam_' . bin2hex(random_bytes(8)); $date = date('r'); $fromHdr = ($s['from_name'] ?? '') !== '' ? "=?UTF-8?B?" . base64_encode($s['from_name']) . "?= <{$s['from_email']}>" : $s['from_email']; $subjectHdr = '=?UTF-8?B?' . base64_encode($subject) . '?='; $hasPlain = ($plain !== ''); if ($hasPlain) { $msgBody = "--{$boundary}\r\n"; $msgBody .= "Content-Type: text/plain; charset=UTF-8\r\n"; $msgBody .= "Content-Transfer-Encoding: base64\r\n\r\n"; $msgBody .= chunk_split(base64_encode($plain)) . "\r\n"; $msgBody .= "--{$boundary}\r\n"; $msgBody .= "Content-Type: text/html; charset=UTF-8\r\n"; $msgBody .= "Content-Transfer-Encoding: base64\r\n\r\n"; $msgBody .= chunk_split(base64_encode($body)) . "\r\n"; $msgBody .= "--{$boundary}--"; $ctHdr = "multipart/alternative; boundary=\"{$boundary}\""; } else { $msgBody = chunk_split(base64_encode($body)); $ctHdr = 'text/html; charset=UTF-8'; } $rawMessage = "Date: {$date}\r\n" . "From: {$fromHdr}\r\n" . "To: {$to}\r\n" . "Subject: {$subjectHdr}\r\n" . "MIME-Version: 1.0\r\n" . "Content-Type: {$ctHdr}\r\n" . (!$hasPlain ? "Content-Transfer-Encoding: base64\r\n" : '') . "\r\n" . $msgBody; try { return self::smtpSend($s, $to, $rawMessage); } catch (\Throwable $e) { error_log('[SmtpRelay] ' . $e->getMessage()); return false; } } /** * Low-level native SMTP socket client (full send path). */ /** * Sanitise an e-mail address for use in SMTP envelope commands. * Strips everything except the angle-bracket address to prevent * SMTP command injection via embedded CR/LF. */ private static function sanitiseEnvelope(string $addr): string { // Extract bare address if wrapped in display-name form if (preg_match('/<([^>\r\n]+)>/', $addr, $m)) { $addr = $m[1]; } // Remove any CR or LF characters return str_replace(["\r", "\n"], '', trim($addr)); } private static function smtpSend(array $s, string $to, string $rawMessage): bool { $host = $s['host']; $port = (int) $s['port']; $encryption = $s['encryption']; $timeout = 15; // Sanitise envelope addresses $envFrom = self::sanitiseEnvelope($s['from_email']); $envTo = self::sanitiseEnvelope($to); $connectHost = ($encryption === 'ssl') ? "ssl://{$host}" : $host; $errno = 0; $errstr = ''; $ctx = stream_context_create([ 'ssl' => [ 'verify_peer' => true, 'verify_peer_name' => true, 'peer_name' => $host, 'cafile' => self::caBundlePath(), ], ]); $sock = @stream_socket_client( "{$connectHost}:{$port}", $errno, $errstr, $timeout, STREAM_CLIENT_CONNECT, $ctx ); if ($sock === false) { throw new \RuntimeException("SMTP connect failed ({$connectHost}:{$port}): {$errstr} [{$errno}]"); } 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(); }; $expect = function (string $cmd, string $code) use ($send): string { $resp = $send($cmd); if (strncmp($resp, $code, strlen($code)) !== 0) { throw new \RuntimeException("SMTP unexpected response to '{$cmd}': {$resp}"); } return $resp; }; $greeting = $read(); if (strncmp($greeting, '220', 3) !== 0) { $meta = stream_get_meta_data($sock); $detail = $meta['timed_out'] ? '(timed out)' : '(received: ' . json_encode($greeting) . ')'; throw new \RuntimeException("SMTP bad greeting {$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'); stream_context_set_option($sock, 'ssl', 'peer_name', $host); stream_context_set_option($sock, 'ssl', 'verify_peer', true); stream_context_set_option($sock, 'ssl', 'verify_peer_name', true); stream_context_set_option($sock, 'ssl', 'cafile', self::caBundlePath()); if (!stream_socket_enable_crypto($sock, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { throw new \RuntimeException('SMTP STARTTLS crypto negotiation failed (invalid certificate?)'); } $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'); } } $expect("MAIL FROM:<{$envFrom}>", '250'); $expect("RCPT TO:<{$envTo}>", '250'); $expect('DATA', '354'); // RFC 5321 §4.5.2 dot-stuffing: prepend extra '.' to any line starting with '.' $normalised = str_replace("\r\n", "\n", $rawMessage); $normalised = str_replace("\n", "\r\n", $normalised); $stuffed = str_replace("\r\n.", "\r\n..", $normalised); $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( Database $db, string $to, string $subject, string $body, string $plain = '' ): void { // TODO: INSERT INTO mail_queue … } // ----------------------------------------------------------------------- // Internal // ----------------------------------------------------------------------- /** * Locate the system CA bundle for TLS peer verification. * PHP uses its compiled-in default when openssl.cafile is set in php.ini; * this provides an explicit path as a fallback for environments where * the INI value is missing. */ private static function caBundlePath(): ?string { // Honour php.ini if set $ini = ini_get('openssl.cafile'); if ($ini && file_exists($ini)) { return $ini; } // Common system locations (Debian/Ubuntu, RHEL/CentOS, Alpine) $candidates = [ '/etc/ssl/certs/ca-certificates.crt', '/etc/pki/tls/certs/ca-bundle.crt', '/etc/ssl/ca-bundle.pem', '/usr/local/share/certs/ca-root-nss.crt', ]; foreach ($candidates as $path) { if (file_exists($path)) return $path; } return null; // PHP will fall back to its compiled-in bundle } private static function htmlToPlain(string $html): string { $text = strip_tags($html); $text = preg_replace('/\n{3,}/', "\n\n", $text); return trim($text); } }