rootPath = dirname(__DIR__, 2); } public function send(string $to, string $subject, string $textBody, array $headers = []): bool { $to = trim($to); $subject = trim($subject); $textBody = trim($textBody); if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) { throw new RuntimeException('Die Zieladresse für den Mailversand ist ungültig.'); } if ($subject === '' || $textBody === '') { throw new RuntimeException('Betreff und Inhalt für den Mailversand sind erforderlich.'); } $subjectLine = $this->encodeSubject($this->sanitizeHeaderValue($this->subjectPrefix() . $subject)); $headerLines = $this->buildHeaders($headers); $body = $this->normalizeBody($textBody); $sent = false; if (function_exists('mail')) { $sent = @mail($to, $subjectLine, $body, implode("\r\n", $headerLines)); } if ($sent) { return true; } if (!$this->usesLogFallback()) { throw new RuntimeException('Die E-Mail konnte nicht versendet werden.'); } $this->writeFallbackLog([ 'to' => $to, 'subject' => $this->subjectPrefix() . $subject, 'headers' => $headerLines, 'body' => $textBody, ]); return false; } private function buildHeaders(array $headers): array { $headerLines = [ 'MIME-Version: 1.0', 'Content-Type: text/plain; charset=UTF-8', 'Content-Transfer-Encoding: 8bit', 'X-Mailer: Kaffeeliste', ]; $from = trim((string) ($this->config['from'] ?? '')); if ($from !== '') { $headerLines[] = 'From: ' . $this->sanitizeHeaderValue($from); } $replyTo = trim((string) ($this->config['reply_to'] ?? '')); if ($replyTo !== '') { $headerLines[] = 'Reply-To: ' . $this->sanitizeHeaderValue($replyTo); } foreach ($headers as $name => $value) { if (is_int($name)) { $line = trim((string) $value); if ($line !== '' && str_contains($line, ':')) { [$rawName, $rawValue] = array_pad(explode(':', $line, 2), 2, ''); $this->appendHeader($headerLines, $rawName, $rawValue); } continue; } $this->appendHeader($headerLines, (string) $name, (string) $value); } return $headerLines; } private function appendHeader(array &$headerLines, string $name, string $value): void { $name = trim($name); if ($name === '' || preg_match('/[^A-Za-z0-9-]/', $name)) { return; } $headerLines[] = $name . ': ' . $this->sanitizeHeaderValue($value); } private function writeFallbackLog(array $payload): void { $path = $this->resolveLogPath(); $directory = dirname($path); if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) { throw new RuntimeException('Das Mail-Log-Verzeichnis konnte nicht erstellt werden.'); } $encodedPayload = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); if (!is_string($encodedPayload)) { $encodedPayload = '{"error":"mail payload could not be encoded"}'; } $entry = '[' . gmdate(DATE_ATOM) . "] mail_fallback\n" . $encodedPayload . "\n\n"; if (file_put_contents($path, $entry, FILE_APPEND | LOCK_EX) === false) { throw new RuntimeException('Das Mail-Fallback-Log konnte nicht geschrieben werden.'); } } private function resolveLogPath(): string { $configuredPath = trim((string) ($this->config['log_file'] ?? '')); if ($configuredPath === '') { return $this->rootPath . '/storage/logs/mail.log'; } if (str_starts_with($configuredPath, '/')) { return $configuredPath; } return $this->rootPath . '/' . ltrim($configuredPath, '/'); } private function usesLogFallback(): bool { return (bool) ($this->config['log_fallback'] ?? true); } private function subjectPrefix(): string { return (string) ($this->config['subject_prefix'] ?? ''); } private function sanitizeHeaderValue(string $value): string { return trim(str_replace(["\r", "\n"], ' ', $value)); } private function encodeSubject(string $subject): string { if ($subject === '') { return $subject; } if (function_exists('mb_encode_mimeheader')) { return mb_encode_mimeheader($subject, 'UTF-8', 'B', "\r\n"); } return $subject; } private function normalizeBody(string $textBody): string { return str_replace("\n", "\r\n", str_replace(["\r\n", "\r"], "\n", $textBody)); } }