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