273 lines
9.2 KiB
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);
|
|
}
|
|
}
|