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 " 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 " 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; } } }