202 lines
8.1 KiB
PHP
202 lines
8.1 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) {
|
||
// 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;
|
||
}
|
||
}
|
||
}
|