Files
2026-11-04 07:55:19 +00:00

202 lines
8.1 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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) {
// Bei SSL-Fehlern bleibt $errstr oft leer; der eigentliche Grund steht dann in der letzten PHP-Warnung
$reason = trim((string) $errstr);
if ($reason === '') {
$last = (string) (error_get_last()['message'] ?? '');
$reason = trim(preg_replace('/^stream_socket_client\(\):\s*/', '', $last));
}
if ($reason === '') $reason = $this->encryption === 'ssl'
? 'keine Antwort. Prüfen, ob Port und Verschlüsselung zusammenpassen (SSL = 465, STARTTLS = 587) und ob der Server ausgehend verbinden darf'
: 'keine Antwort (Zeitüberschreitung oder Sperre durch eine Firewall)';
if ($this->encryption === 'ssl' && $this->port === 587) $reason .= ' – Port 587 ist für STARTTLS gedacht, bei „SSL/TLS“ gehört Port 465 dazu';
elseif ($this->encryption === 'tls' && $this->port === 465) $reason .= ' – Port 465 ist für SSL/TLS gedacht, bei „STARTTLS“ gehört Port 587 dazu';
elseif (stripos($reason, 'certificate') !== false || stripos($reason, 'verify') !== false) $reason .= ' – möglicherweise fehlt auf diesem Server die Liste vertrauenswürdiger Zertifizierungsstellen (CA-Bundle: curl.cainfo/openssl.cafile in der PHP-Konfiguration)';
throw new \RuntimeException("Verbindung zu {$this->host}:{$this->port} fehlgeschlagen: {$reason}" . ($errno ? " ({$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[] = "X-DeskHub-Mail: 1"; // erkennt der E-Mail-Abruf wieder und macht daraus kein Ticket
// Mails zu einem Ticket bekommen eine Message-ID mit der Ticketnummer: Antwortet der Kunde, nennt sein Mailprogramm sie in
// In-Reply-To/References, und der E-Mail-Abruf ordnet die Antwort auch dann dem Ticket zu, wenn der Betreff geändert wurde.
$mid = function_exists('mail_ticket_message_id') ? mail_ticket_message_id($subject, preg_match('/@([^>\s]+)>?\s*$/', $from, $hm) ? $hm[1] : ($_SERVER['SERVER_NAME'] ?? 'localhost')) : null;
if ($mid !== null) $headers[] = "Message-ID: {$mid}";
$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;
}
}
}