185 lines
6.2 KiB
PHP
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;
|
|
}
|
|
}
|
|
}
|