SmtpRelay: replace mail() stub with native socket SMTP client

This commit is contained in:
Pontoporeia
2026-04-22 10:53:27 +02:00
parent b448d0d40c
commit a3849a8e69
3 changed files with 163 additions and 29 deletions

View File

@@ -5,4 +5,5 @@
- [x] Replace JS toast system with pure HTMX toast fragment (top-right, CSS-only auto-fade) - [x] Replace JS toast system with pure HTMX toast fragment (top-right, CSS-only auto-fade)
- [x] Separate admin views from controllers: move all HTML to `templates/admin/*.php`, fragments to `templates/admin/partials/` - [x] Separate admin views from controllers: move all HTML to `templates/admin/*.php`, fragments to `templates/admin/partials/`
- [x] Add SMTP test email button in parametres.php (action + CSS) - [x] Add SMTP test email button in parametres.php (action + CSS)
- [x] Fix SmtpRelay::send() — replace broken mail() stub with native PHP socket SMTP client (STARTTLS/SSL, AUTH PLAIN/LOGIN)
- [x] Lock body scroll on all pages (admin + public); only `main` or inner element scrolls - [x] Lock body scroll on all pages (admin + public); only `main` or inner element scrolls

View File

@@ -94,16 +94,15 @@ class SmtpRelay {
/** /**
* Send an e-mail using the stored SMTP credentials. * Send an e-mail using the stored SMTP credentials.
* *
* Currently uses PHP's `mail()` as a passthrough so the rest of the * Uses a native PHP socket SMTP client — no external dependencies.
* application can call `SmtpRelay::send(…)` everywhere. * Supports STARTTLS (port 587 / encryption=tls) and direct SSL (port 465 /
* The actual SMTP transport layer will be wired in a later iteration * encryption=ssl). Falls back to plain if encryption=none.
* (e.g. replace this body with PHPMailer / Symfony Mailer).
* *
* @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
* @param string $plain Plain-text alternative (optional) * @param string $plain Plain-text alternative (optional)
* @return bool True on send request acceptance; false on failure * @return bool True on successful delivery acceptance; false on failure
*/ */
public static function send( public static function send(
Database $db, Database $db,
@@ -112,41 +111,175 @@ class SmtpRelay {
string $body, string $body,
string $plain = '' string $plain = ''
): bool { ): bool {
$settings = self::getSettings($db); $s = self::getSettings($db);
if ($settings['from_email'] === '') { if ($s['from_email'] === '') {
error_log('[SmtpRelay] send() aborted — no from_email configured'); error_log('[SmtpRelay] send() aborted — no from_email configured');
return false; return false;
} }
// Build MIME multipart headers // Build MIME message
$boundary = 'posterg_' . md5((string) random_int(0, PHP_INT_MAX) . microtime(true)); $boundary = 'posterg_' . bin2hex(random_bytes(8));
$headers = "From: {$settings['from_name']} <{$settings['from_email']}>\r\n"; $date = date('r');
$headers .= "Reply-To: {$settings['from_email']}\r\n"; $fromHdr = $s['from_name'] !== ''
$headers .= "MIME-Version: 1.0\r\n"; ? "=?UTF-8?B?" . base64_encode($s['from_name']) . "?= <{$s['from_email']}>"
: $s['from_email'];
$subjectHdr = '=?UTF-8?B?' . base64_encode($subject) . '?=';
if ($plain !== '') { $hasPlain = ($plain !== '');
$headers .= "Content-Type: multipart/alternative; boundary=\"{$boundary}\"\r\n"; if ($hasPlain) {
$message = "--{$boundary}\r\n"; $msgBody = "--{$boundary}\r\n";
$message .= "Content-Type: text/plain; charset=UTF-8\r\n\r\n"; $msgBody .= "Content-Type: text/plain; charset=UTF-8\r\n";
$message .= self::htmlToPlain($body) . "\r\n\r\n"; $msgBody .= "Content-Transfer-Encoding: base64\r\n\r\n";
$message .= "--{$boundary}\r\n"; $msgBody .= chunk_split(base64_encode($plain)) . "\r\n";
$message .= "Content-Type: text/html; charset=UTF-8\r\n\r\n"; $msgBody .= "--{$boundary}\r\n";
$message .= $body . "\r\n\r\n"; $msgBody .= "Content-Type: text/html; charset=UTF-8\r\n";
$message .= "--{$boundary}--"; $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 { } else {
$headers .= "Content-Type: text/html; charset=UTF-8\r\n"; $msgBody = chunk_split(base64_encode($body));
$message = $body; $ctHdr = 'text/html; charset=UTF-8';
} }
// TODO: replace with real SMTP transport (PHPMailer / Symfony Mailer) $rawMessage =
// The stored credentials ($settings) will be passed to the mailer then. "Date: {$date}\r\n" .
$ok = mail($to, $subject, $message, $headers); "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;
if (!$ok) { try {
error_log("[SmtpRelay] mail() returned false for {$to}"); return self::smtpSend($s, $to, $rawMessage);
} catch (\Throwable $e) {
error_log('[SmtpRelay] ' . $e->getMessage());
return false;
}
} }
return $ok; /**
* Low-level native SMTP socket client.
*
* @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
{
$host = $s['host'];
$port = (int) $s['port'];
$encryption = $s['encryption']; // 'tls' | 'ssl' | 'none'
$timeout = 15;
// For direct SSL (port 465) open with ssl:// wrapper
$connectHost = ($encryption === 'ssl') ? "ssl://{$host}" : $host;
$errno = 0; $errstr = '';
$sock = @stream_socket_client(
"{$connectHost}:{$port}", $errno, $errstr, $timeout
);
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)) {
$buf .= $line;
// 4th char is ' ' when it's the last line of a multi-line reply
if (isset($line[3]) && $line[3] === ' ') 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
$greeting = $read();
if (strncmp($greeting, '220', 3) !== 0) {
throw new \RuntimeException("SMTP bad greeting: {$greeting}");
}
$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');
}
// 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;
} }
/** /**

Binary file not shown.