Files
deskhub/DeskHub-Webseite/includes/smtp_mailer.php
T
2026-10-16 19:12:18 +00:00

185 lines
6.2 KiB
PHP

<?php
/**
* Einfacher SMTP-Client ohne externe Abhängigkeiten (kein Composer/PHPMailer).
*
* Grund: PHP mail() ist auf vielen Hosting-Paketen unzuverlässig (hängt vom
* lokal konfigurierten Sendmail/Postfix ab, landet oft im Spam-Ordner ohne
* korrekte SPF/DKIM-Anbindung des Hosters). Mit eigenen SMTP-Zugangsdaten
* (z.B. vom E-Mail-Provider der Domain) wird stattdessen direkt und
* zuverlässig über deren Mailserver versendet.
*
* Unterstützt: SMTP mit STARTTLS (Port 587), implizites SSL (Port 465),
* unverschlüsselt (Port 25, nicht empfohlen) sowie AUTH LOGIN.
*/
class SmtpMailer
{
private string $host;
private int $port;
private string $encryption; // 'none' | 'tls' | 'ssl'
private string $username;
private string $password;
private int $timeout;
private $socket = null;
private string $lastError = '';
public function __construct(string $host, int $port, string $encryption, string $username, string $password, int $timeout = 12)
{
$this->host = $host;
$this->port = $port;
$this->encryption = $encryption;
$this->username = $username;
$this->password = $password;
$this->timeout = $timeout;
}
public function getLastError(): string
{
return $this->lastError;
}
/**
* Sendet eine E-Mail. $to/$from können "Name <adresse>" oder nur die
* Adresse sein. $htmlBody wird als text/html (UTF-8, Base64) versendet.
*/
public function send(string $to, string $from, string $subject, string $htmlBody): bool
{
try {
$this->connect();
$this->ehlo();
if ($this->encryption === 'tls') {
$this->startTls();
$this->ehlo(); // EHLO muss nach STARTTLS wiederholt werden
}
if ($this->username !== '') {
$this->authLogin();
}
$fromEmail = $this->extractEmail($from);
$toEmail = $this->extractEmail($to);
$this->command("MAIL FROM:<{$fromEmail}>", [250]);
$this->command("RCPT TO:<{$toEmail}>", [250, 251]);
$this->command('DATA', [354]);
$headers = $this->buildHeaders($to, $from, $subject);
$encodedBody = chunk_split(base64_encode($htmlBody), 76, "\r\n");
$payload = $headers . "\r\n" . $encodedBody . "\r\n.";
$this->write($payload);
$this->readResponse([250]);
$this->command('QUIT', [221], false);
$this->close();
return true;
} catch (\Throwable $e) {
$this->lastError = $e->getMessage();
$this->close();
return false;
}
}
private function connect(): void
{
$prefix = $this->encryption === 'ssl' ? 'ssl://' : 'tcp://';
$this->socket = @stream_socket_client(
"{$prefix}{$this->host}:{$this->port}",
$errno, $errstr, $this->timeout,
STREAM_CLIENT_CONNECT, stream_context_create(['ssl' => ['verify_peer' => true, 'verify_peer_name' => true]])
);
if (!$this->socket) {
throw new \RuntimeException("Verbindung zu {$this->host}:{$this->port} fehlgeschlagen: {$errstr} ({$errno})");
}
stream_set_timeout($this->socket, $this->timeout);
$this->readResponse([220]);
}
private function ehlo(): void
{
$domain = $_SERVER['SERVER_NAME'] ?? 'localhost';
$this->command("EHLO {$domain}", [250]);
}
private function startTls(): void
{
$this->command('STARTTLS', [220]);
$ok = @stream_socket_enable_crypto($this->socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT);
if (!$ok) {
throw new \RuntimeException('STARTTLS-Verschlüsselung konnte nicht aktiviert werden.');
}
}
private function authLogin(): void
{
$this->command('AUTH LOGIN', [334]);
$this->command(base64_encode($this->username), [334]);
$this->command(base64_encode($this->password), [235]);
}
private function buildHeaders(string $to, string $from, string $subject): string
{
$encodedSubject = '=?UTF-8?B?' . base64_encode($subject) . '?=';
$headers = [];
$headers[] = "Date: " . date('r');
$headers[] = "To: {$to}";
$headers[] = "From: {$from}";
$headers[] = "Subject: {$encodedSubject}";
$headers[] = "MIME-Version: 1.0";
$headers[] = "Content-Type: text/html; charset=UTF-8";
$headers[] = "Content-Transfer-Encoding: base64";
return implode("\r\n", $headers) . "\r\n";
}
/** Extrahiert nur die reine Adresse aus "Name <adresse@example.com>" für die SMTP-Hüllkommandos */
private function extractEmail(string $addr): string
{
if (preg_match('/<([^>]+)>/', $addr, $m)) {
return trim($m[1]);
}
return trim($addr);
}
private function command(string $cmd, array $expectedCodes, bool $expectResponse = true): void
{
$this->write($cmd);
if ($expectResponse) {
$this->readResponse($expectedCodes);
}
}
private function write(string $data): void
{
if (@fwrite($this->socket, $data . "\r\n") === false) {
throw new \RuntimeException('Schreiben zum SMTP-Server fehlgeschlagen.');
}
}
private function readResponse(array $expectedCodes): string
{
$response = '';
$code = null;
while (!feof($this->socket)) {
$line = fgets($this->socket, 515);
if ($line === false) break;
$response .= $line;
// Letzte Zeile einer mehrzeiligen Antwort hat ein Leerzeichen statt "-" nach dem Code
if (preg_match('/^(\d{3})([ -])/', $line, $m)) {
$code = (int) $m[1];
if ($m[2] === ' ') break;
} else {
break;
}
}
if ($code === null || !in_array($code, $expectedCodes, true)) {
throw new \RuntimeException("Unerwartete SMTP-Antwort: " . trim($response));
}
return $response;
}
private function close(): void
{
if ($this->socket) {
@fclose($this->socket);
$this->socket = null;
}
}
}