Files
xamxam/app/src/SmtpRelay.php

594 lines
22 KiB
PHP

<?php
/**
* Structured exception for SMTP probe failures.
* 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:
* 1. CRUD on the singleton smtp_settings row.
* 2. Build MIME messages.
* 3. Send via native SMTP socket (STARTTLS / SSL / plain).
* 4. Probe credentials without sending any message (for validation on save).
*/
class SmtpRelay {
// -----------------------------------------------------------------------
// DB operations
// -----------------------------------------------------------------------
/**
* Fetch current SMTP settings from the DB.
*
* @return array{host:string,port:int,encryption:string,username:string,password:string,from_email:string,from_name:string}
*/
public static function getSettings(Database $db): array {
$stmt = $db->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 <addr> 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);
}
}