Steuerkram/src/Services/SmtpMailer.php

273 lines
9.2 KiB
PHP

<?php
declare(strict_types=1);
namespace Steuerkram\Services;
/**
* Schlanker SMTP-Client ohne externe Abhängigkeiten.
* Unterstützt STARTTLS (port 587), SSL/TLS (port 465) und unverschlüsselt.
* AUTH LOGIN und AUTH PLAIN. Bis zu n Empfänger. Einen Anhang (binary string).
*/
class SmtpMailer
{
/** @var resource */
private $sock;
public function __construct(
private readonly string $host,
private readonly int $port,
private readonly string $encryption, // 'tls' | 'ssl' | 'none'
private readonly string $user,
private readonly string $pass,
private readonly string $fromEmail,
private readonly string $fromName,
) {}
/**
* @param string[] $to Liste von Empfänger-Adressen (max. 3)
* @param string $subject
* @param string $body Plain-Text-Body
* @param string|null $attachContent Binärer Inhalt des Anhangs
* @param string|null $attachName Dateiname des Anhangs
* @throws \RuntimeException
*/
public function send(
array $to,
string $subject,
string $body,
?string $attachContent = null,
?string $attachName = null,
): void {
foreach ($to as $addr) {
if (filter_var($addr, FILTER_VALIDATE_EMAIL) === false) {
throw new \RuntimeException('Ungültige E-Mail-Adresse: ' . $addr);
}
}
$this->connect();
$this->authenticate();
$this->sendMessage($to, $subject, $body, $attachContent, $attachName);
$this->cmd('QUIT');
fclose($this->sock);
}
// ── Verbindung ───────────────────────────────────────────────
private function connect(): void
{
[$address, $ctx] = $this->buildConnectTarget();
$sock = stream_socket_client(
$address . ':' . $this->port,
$errno, $errstr, 15,
STREAM_CLIENT_CONNECT,
$ctx
);
if (!$sock) {
throw new \RuntimeException("SMTP-Verbindung fehlgeschlagen: {$errstr} ({$errno})");
}
$this->sock = $sock;
stream_set_timeout($this->sock, 15);
$this->expect(220);
$this->ehlo();
if ($this->encryption === 'tls') {
$this->cmd('STARTTLS');
$this->expect(220);
if (!stream_socket_enable_crypto($this->sock, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
throw new \RuntimeException('STARTTLS-Aushandlung fehlgeschlagen.');
}
$this->ehlo();
}
}
/**
* Löst den SMTP-Host per SsrfGuard::resolvePinnedHost() auf und pinnt die
* dabei geprüfte(n) IP-Adresse(n) für den eigentlichen Verbindungsaufbau:
* stream_socket_client() verbindet direkt zur IP statt den Hostnamen
* erneut über den System-Resolver aufzulösen. Ohne dieses Pinning könnte
* ein Angreifer mit kurzer DNS-TTL zwischen Prüfung und Connect auf eine
* private/interne Adresse umschwenken (DNS-Rebinding/TOCTOU, Issue #193).
* Für SNI und Zertifikatsprüfung bleibt der ursprüngliche Hostname über
* den Stream-Context ('ssl' => 'peer_name') erhalten.
*
* @return array{0:string,1:resource} [Verbindungsziel "scheme://ip", Stream-Context]
*/
private function buildConnectTarget(): array
{
$ips = SsrfGuard::resolvePinnedHost($this->host);
$ip = str_contains($ips[0], ':') ? '[' . $ips[0] . ']' : $ips[0];
$scheme = $this->encryption === 'ssl' ? 'ssl' : 'tcp';
$address = $scheme . '://' . $ip;
$ctx = stream_context_create([
'ssl' => [
'verify_peer' => true,
'verify_peer_name' => true,
'allow_self_signed' => false,
'peer_name' => $this->host,
],
]);
return [$address, $ctx];
}
private function ehlo(): void
{
$this->cmd('EHLO ' . (gethostname() ?: 'localhost'));
$this->expectMulti(250);
}
// ── Authentifizierung ────────────────────────────────────────
private function authenticate(): void
{
$this->cmd('AUTH LOGIN');
$this->expect(334);
$this->cmd(base64_encode($this->user));
$this->expect(334);
$this->cmd(base64_encode($this->pass));
$this->expect(235);
}
// ── Nachricht ────────────────────────────────────────────────
private function sendMessage(
array $to,
string $subject,
string $body,
?string $attachContent,
?string $attachName,
): void {
$this->cmd('MAIL FROM:<' . $this->sanitizeAddress($this->fromEmail) . '>');
$this->expect(250);
foreach ($to as $addr) {
$this->cmd('RCPT TO:<' . $addr . '>');
$this->expect(250);
}
$this->cmd('DATA');
$this->expect(354);
$raw = $this->buildMessage($to, $subject, $body, $attachContent, $attachName);
// Dot-Stuffing: Zeilen, die mit '.' beginnen, bekommen einen zweiten Punkt
$raw = preg_replace('/^\.$/m', '..', $raw);
fwrite($this->sock, $raw . "\r\n.\r\n");
$this->expect(250);
}
private function buildMessage(
array $to,
string $subject,
string $body,
?string $attachContent,
?string $attachName,
): string {
$date = date('r');
$fromHdr = $this->encodeHeader($this->fromName) . ' <' . $this->sanitizeAddress($this->fromEmail) . '>';
$toHdr = implode(', ', $to);
$subjHdr = $this->encodeHeader($subject);
$msgId = '<' . uniqid('sk', true) . '@' . (gethostname() ?: 'localhost') . '>';
if ($attachContent === null) {
return implode("\r\n", [
'Date: ' . $date,
'Message-ID: ' . $msgId,
'From: ' . $fromHdr,
'To: ' . $toHdr,
'Subject: ' . $subjHdr,
'MIME-Version: 1.0',
'Content-Type: text/plain; charset=UTF-8',
'Content-Transfer-Encoding: quoted-printable',
'',
$this->quotedPrintable($body),
]);
}
$boundary = '----=_Part_' . bin2hex(random_bytes(8));
$b64 = chunk_split(base64_encode($attachContent), 76, "\r\n");
$safeName = $this->encodeHeader($attachName ?? 'Anhang.pdf');
return implode("\r\n", [
'Date: ' . $date,
'Message-ID: ' . $msgId,
'From: ' . $fromHdr,
'To: ' . $toHdr,
'Subject: ' . $subjHdr,
'MIME-Version: 1.0',
'Content-Type: multipart/mixed; boundary="' . $boundary . '"',
'',
'--' . $boundary,
'Content-Type: text/plain; charset=UTF-8',
'Content-Transfer-Encoding: quoted-printable',
'',
$this->quotedPrintable($body),
'',
'--' . $boundary,
'Content-Type: application/pdf; name="' . $safeName . '"',
'Content-Transfer-Encoding: base64',
'Content-Disposition: attachment; filename="' . $safeName . '"',
'',
$b64,
'--' . $boundary . '--',
]);
}
// ── Hilfsmethoden ────────────────────────────────────────────
private function cmd(string $line): void
{
fwrite($this->sock, $line . "\r\n");
}
private function expect(int $code): string
{
$response = fgets($this->sock, 512);
if ($response === false) {
throw new \RuntimeException('SMTP: Keine Antwort vom Server.');
}
if ((int)$response !== $code) {
throw new \RuntimeException('SMTP: Unerwartete Antwort: ' . trim($response));
}
return $response;
}
/** Liest mehrzeilige SMTP-Antworten (z.B. nach EHLO) */
private function expectMulti(int $code): void
{
do {
$line = fgets($this->sock, 512);
if ($line === false) throw new \RuntimeException('SMTP: Verbindung unterbrochen.');
if ((int)$line !== $code) throw new \RuntimeException('SMTP: ' . trim($line));
} while ($line[3] === '-');
}
/** Entfernt eingebettete CRLF, damit die Adresse nicht als SMTP-Kommando/Header-Injection missbraucht werden kann. */
private function sanitizeAddress(string $value): string
{
return str_replace(["\r", "\n"], '', $value);
}
private function encodeHeader(string $value): string
{
$value = str_replace(["\r", "\n"], '', $value);
if (mb_detect_encoding($value, 'ASCII', true)) {
return $value;
}
return '=?UTF-8?B?' . base64_encode($value) . '?=';
}
private function quotedPrintable(string $text): string
{
return quoted_printable_encode($text);
}
}