*/ private array $mailConfig; /** @var array */ private array $appConfig; private FormSchema $schema; private SubmissionFormatter $formatter; private PdfGenerator $pdfGenerator; public function __construct() { $this->mailConfig = Bootstrap::config('mail'); $this->appConfig = Bootstrap::config('app'); $this->schema = new FormSchema(); $this->formatter = new SubmissionFormatter($this->schema); $this->pdfGenerator = new PdfGenerator($this->formatter, $this->schema); } public function sendOtpMail(string $email, string $code, int $ttlSeconds): bool { $email = strtolower(trim($email)); if ($email === '') { return false; } $subject = (string) ($this->mailConfig['subjects']['otp'] ?? 'Ihr Sicherheitscode'); $textBody = $this->renderOtpText($code, $ttlSeconds); $htmlBody = $this->renderOtpHtml($code, $ttlSeconds); try { $mail = $this->createMailBuilder(); $mail->setTo($email) ->setSubject($subject) ->setTextBody($textBody) ->setHtmlBody($htmlBody); if (!$mail->send()) { Bootstrap::log('mail', 'Versand OTP fehlgeschlagen: ' . $email . ' - ' . $mail->getErrorInfo()); return false; } } catch (\Throwable $e) { Bootstrap::log('mail', 'Versand OTP fehlgeschlagen: ' . $email . ' - ' . $e->getMessage()); return false; } return true; } /** @param array $submission */ public function sendSubmissionMails(array $submission): void { $formDataPdf = $this->pdfGenerator->generateFormDataPdf($submission); $minorSignaturePdf = $this->pdfGenerator->generateMinorSignaturePdf($submission); $isMinorSubmission = $this->isMinorSubmission($submission); try { $this->sendAdminMails($submission, $formDataPdf, $isMinorSubmission); $this->sendApplicantMail($submission, $minorSignaturePdf); } finally { if ($formDataPdf !== null) { @unlink($formDataPdf); } if ($minorSignaturePdf !== null) { @unlink($minorSignaturePdf); } } } private function sendAdminMails( array $submission, ?string $formDataPdf, bool $isMinorSubmission, ): void { $recipients = (array) ($this->mailConfig['recipients'] ?? []); $subject = (string) ($this->mailConfig['subjects']['admin'] ?? 'Neuer Mitgliedsantrag'); $attachmentInfo = $this->collectAdminUploadAttachments($submission); $uploadWarning = null; if ($attachmentInfo['nachweise_skipped']) { $uploadWarning = $this->buildNachweiseOversizeWarning((int) $attachmentInfo['nachweise_total_bytes']); Bootstrap::log('mail', 'Qualifikationsnachweise nicht angehängt (zu groß): ' . $uploadWarning); } $htmlBody = $this->renderAdminHtml($submission, $isMinorSubmission, $uploadWarning); $textBody = $this->renderAdminText($submission, $isMinorSubmission, $uploadWarning); $formData = (array) ($submission['form_data'] ?? []); $ccEmails = []; $notifications = (array) ($this->schema->raw()['additional_notifications'] ?? []); $validator = new \App\Form\Validator($this->schema); foreach ($notifications as $notification) { if (!isset($notification['condition']) || !is_array($notification['condition'])) { continue; } if ($validator->evaluateCondition($notification['condition'], $formData)) { $ccs = (array) ($notification['cc'] ?? []); foreach ($ccs as $cc) { if (is_string($cc) && filter_var($cc, FILTER_VALIDATE_EMAIL)) { $ccEmails[] = trim($cc); } } } } foreach ($recipients as $recipient) { if (!is_string($recipient) || $recipient === '') { continue; } try { $mail = $this->createMailBuilder(); $mail->setTo($recipient) ->setSubject($subject) ->setHtmlBody($htmlBody) ->setTextBody($textBody); foreach ($ccEmails as $ccEmail) { $mail->addCc($ccEmail); } if ($formDataPdf !== null) { $mail->addAttachment($formDataPdf, 'Antragsdaten.pdf', 'application/pdf'); } foreach ($attachmentInfo['attachments'] as $att) { $mail->addAttachment($att['path'], $att['filename'], $att['mime']); } if (!$mail->send()) { Bootstrap::log('mail', 'Versand an Admin fehlgeschlagen: ' . $recipient . ' - ' . $mail->getErrorInfo()); } } catch (\Throwable $e) { Bootstrap::log('mail', 'Versand an Admin fehlgeschlagen: ' . $recipient . ' - ' . $e->getMessage()); } } } /** @param array $submission */ private function sendApplicantMail(array $submission, ?string $minorSignaturePdf): void { $email = (string) ($submission['email'] ?? ''); if ($email === '') { return; } $isMinorSubmission = $this->isMinorSubmission($submission); $subject = (string) ($this->mailConfig['subjects']['applicant'] ?? 'Bestätigung Mitgliedsantrag'); $htmlBody = $this->renderApplicantHtml($submission, $isMinorSubmission); $textBody = $this->renderApplicantText($submission, $isMinorSubmission); try { $mail = $this->createMailBuilder(); $mail->setTo($email) ->setSubject($subject) ->setHtmlBody($htmlBody) ->setTextBody($textBody); if ($minorSignaturePdf !== null && is_file($minorSignaturePdf)) { $mail->addAttachment($minorSignaturePdf, 'Einverstaendniserklaerung-Minderjaehrige.pdf', 'application/pdf'); } if (!$mail->send()) { Bootstrap::log('mail', 'Versand an Antragsteller fehlgeschlagen: ' . $email . ' - ' . $mail->getErrorInfo()); } } catch (\Throwable $e) { Bootstrap::log('mail', 'Versand an Antragsteller fehlgeschlagen: ' . $email . ' - ' . $e->getMessage()); } } // --------------------------------------------------------------- // Mail builder // --------------------------------------------------------------- private function createMailBuilder(): MimeMailBuilder { $from = (string) ($this->mailConfig['from'] ?? 'no-reply@example.org'); $fromName = (string) ($this->mailConfig['from_name'] ?? ''); return (new MimeMailBuilder())->setFrom($from, $fromName); } // --------------------------------------------------------------- // Admin email rendering // --------------------------------------------------------------- /** @param array $submission */ private function renderAdminHtml(array $submission, bool $isMinorSubmission = false, ?string $uploadWarning = null): string { $steps = $this->formatter->formatSteps($submission); $uploads = $this->formatter->formatUploads($submission); $h = ''; $h .= '

Neuer Mitgliedsantrag

'; $h .= '

Eingereicht: ' . $this->esc($this->formatTimestamp($submission)) . '
'; $h .= 'E-Mail: ' . $this->esc((string) ($submission['email'] ?? '')) . '

'; if ($isMinorSubmission) { $h .= $this->renderMinorAdminNoticeHtml(); } if ($uploadWarning !== null && $uploadWarning !== '') { $h .= '
' . 'Hinweis zu Anhängen: ' . nl2br($this->esc($uploadWarning)) . '
'; } foreach ($steps as $step) { $h .= '

' . $this->esc($step['title']) . '

'; $h .= ''; foreach ($step['fields'] as $field) { $h .= ''; $h .= ''; } $h .= '
' . $this->esc($field['label']) . '' . nl2br($this->esc($field['value'])) . '
'; } if ($uploads !== []) { $h .= '

Hochgeladene Dateien

    '; foreach ($uploads as $group) { foreach ($group['files'] as $name) { $h .= '
  • ' . $this->esc($group['label']) . ': ' . $this->esc($name) . '
  • '; } } $h .= '
'; } $h .= ''; return $h; } /** @param array $submission */ private function renderAdminText(array $submission, bool $isMinorSubmission = false, ?string $uploadWarning = null): string { $steps = $this->formatter->formatSteps($submission); $uploads = $this->formatter->formatUploads($submission); $t = "NEUER MITGLIEDSANTRAG\n"; $t .= 'Eingereicht: ' . $this->formatTimestamp($submission) . "\n"; $t .= 'E-Mail: ' . (string) ($submission['email'] ?? '') . "\n\n"; if ($isMinorSubmission) { $t .= $this->renderMinorAdminNoticeText() . "\n\n"; } if ($uploadWarning !== null && $uploadWarning !== '') { $t .= "HINWEIS ZU ANHÄNGEN\n"; $t .= $uploadWarning . "\n\n"; } foreach ($steps as $step) { $t .= strtoupper($step['title']) . "\n" . str_repeat('-', 40) . "\n"; foreach ($step['fields'] as $field) { $t .= $field['label'] . ': ' . $field['value'] . "\n"; } $t .= "\n"; } if ($uploads !== []) { $t .= "HOCHGELADENE DATEIEN\n" . str_repeat('-', 40) . "\n"; foreach ($uploads as $group) { foreach ($group['files'] as $name) { $t .= '- ' . $group['label'] . ': ' . $name . "\n"; } } } return $t; } // --------------------------------------------------------------- // Applicant email rendering // --------------------------------------------------------------- /** @param array $submission */ private function renderApplicantHtml(array $submission, bool $isMinorSubmission = false): string { $steps = $this->formatter->formatSteps($submission); $uploads = $this->formatter->formatUploads($submission); $h = ''; $h .= '

Vielen Dank für Ihren Mitgliedsantrag!

'; $h .= '

Ihre Daten wurden erfolgreich übermittelt. Nachfolgend finden Sie eine Zusammenfassung.

'; if ($isMinorSubmission) { $h .= '

Hinweis für Minderjährige: ' . 'Das angehängte Formular ist auszudrucken, von Antragsteller/in und Erziehungsberechtigten zu unterschreiben ' . 'und persönlich einzureichen.

'; } foreach ($steps as $step) { $h .= '

' . $this->esc($step['title']) . '

'; $h .= ''; foreach ($step['fields'] as $field) { $h .= ''; $h .= ''; } $h .= '
' . $this->esc($field['label']) . '' . nl2br($this->esc($field['value'])) . '
'; } if ($uploads !== []) { $h .= '

Hochgeladene Dateien

    '; foreach ($uploads as $group) { foreach ($group['files'] as $name) { $h .= '
  • ' . $this->esc($group['label']) . ': ' . $this->esc($name) . '
  • '; } } $h .= '
'; } $h .= '

Bei Rückfragen kontaktieren Sie bitte den Verein.

'; $h .= ''; return $h; } /** @param array $submission */ private function renderApplicantText(array $submission, bool $isMinorSubmission = false): string { $steps = $this->formatter->formatSteps($submission); $uploads = $this->formatter->formatUploads($submission); $t = "Vielen Dank für Ihren Mitgliedsantrag!\n\n"; $t .= "Ihre Daten wurden erfolgreich übermittelt. Nachfolgend finden Sie eine Zusammenfassung.\n\n"; if ($isMinorSubmission) { $t .= "Hinweis für Minderjährige: Das angehängte Formular ist auszudrucken, von Antragsteller/in und " . "Erziehungsberechtigten zu unterschreiben und persönlich einzureichen.\n\n"; } foreach ($steps as $step) { $t .= strtoupper($step['title']) . "\n"; foreach ($step['fields'] as $field) { $t .= $field['label'] . ': ' . $field['value'] . "\n"; } $t .= "\n"; } if ($uploads !== []) { $t .= "HOCHGELADENE DATEIEN\n"; foreach ($uploads as $group) { foreach ($group['files'] as $name) { $t .= '- ' . $group['label'] . ': ' . $name . "\n"; } } $t .= "\n"; } $t .= "Bei Rückfragen kontaktieren Sie bitte den Verein.\n"; return $t; } // --------------------------------------------------------------- // Utilities // --------------------------------------------------------------- /** @param array $submission */ private function formatTimestamp(array $submission): string { $ts = (string) ($submission['submitted_at'] ?? ''); $parsed = strtotime($ts); return $parsed !== false ? date('d.m.Y H:i', $parsed) : $ts; } private function esc(string $value): string { return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); } private function renderMinorAdminNoticeHtml(): string { return '
' . 'Wichtiger Hinweis (Minderjährig): ' . 'Die Unterschrift eines Erziehungsberechtigten liegt digital noch nicht vor. ' . 'Bitte die Bearbeitung erst nach Eingang des handschriftlich unterschriebenen Formulars fortsetzen.' . '
'; } private function renderMinorAdminNoticeText(): string { return "WICHTIGER HINWEIS (MINDERJÄHRIG)\n" . 'Die Unterschrift eines Erziehungsberechtigten liegt digital noch nicht vor. ' . 'Bitte die Bearbeitung erst nach Eingang des handschriftlich unterschriebenen Formulars fortsetzen.'; } /** * @param array $submission * @return array{ * attachments: array, * nachweise_skipped: bool, * nachweise_total_bytes: int * } */ private function collectAdminUploadAttachments(array $submission): array { $uploads = (array) ($submission['uploads'] ?? []); $uploadFields = $this->schema->getUploadFields(); $attachments = []; $nachweiseAttachments = []; $nachweiseTotalBytes = 0; foreach ($uploadFields as $fieldKey => $_fieldDef) { $files = $uploads[$fieldKey] ?? []; if (!is_array($files)) { continue; } foreach ($files as $file) { if (!is_array($file)) { continue; } $resolved = $this->resolveUploadAttachment($submission, $file); if ($resolved === null) { continue; } $entry = [ 'path' => $resolved['path'], 'filename' => $resolved['filename'], 'mime' => $resolved['mime'], ]; if ($fieldKey === self::NACHWEISE_FIELD_KEY) { $nachweiseAttachments[] = $entry; $nachweiseTotalBytes += $resolved['size']; continue; } $attachments[] = $entry; } } $nachweiseSkipped = $nachweiseTotalBytes > self::NACHWEISE_MAX_ATTACHMENT_BYTES; if (!$nachweiseSkipped) { $attachments = array_merge($attachments, $nachweiseAttachments); } return [ 'attachments' => $attachments, 'nachweise_skipped' => $nachweiseSkipped, 'nachweise_total_bytes' => $nachweiseTotalBytes, ]; } /** * @param array $submission * @param array $file * @return array{path: string, filename: string, mime: string, size: int}|null */ private function resolveUploadAttachment(array $submission, array $file): ?array { $path = $this->resolveSubmissionUploadPath($submission, $file); if ($path === null || !is_file($path)) { return null; } $filename = trim((string) ($file['original_filename'] ?? basename($path))); if ($filename === '') { $filename = basename($path); } $mime = trim((string) ($file['mime'] ?? '')); if ($mime === '') { $detected = @mime_content_type($path); $mime = is_string($detected) && $detected !== '' ? $detected : 'application/octet-stream'; } $size = (int) ($file['size'] ?? 0); if ($size <= 0) { $size = (int) (filesize($path) ?: 0); } return [ 'path' => $path, 'filename' => $filename, 'mime' => $mime, 'size' => $size, ]; } /** * @param array $submission * @param array $file */ private function resolveSubmissionUploadPath(array $submission, array $file): ?string { $baseUploads = rtrim((string) ($this->appConfig['storage']['uploads'] ?? ''), '/'); if ($baseUploads === '') { return null; } $relativePath = str_replace(['..', '\\'], '', (string) ($file['relative_path'] ?? '')); if ($relativePath !== '') { $candidate = $baseUploads . '/' . ltrim($relativePath, '/'); if (is_file($candidate)) { return $candidate; } } $storedDir = str_replace(['..', '\\'], '', (string) ($file['stored_dir'] ?? '')); $storedFilename = str_replace(['..', '\\'], '', (string) ($file['stored_filename'] ?? '')); if ($storedDir === '' || $storedFilename === '') { return null; } $storedDir = ltrim($storedDir, '/'); $candidates = [$baseUploads . '/' . $storedDir . '/' . $storedFilename]; $appKey = trim((string) ($submission['application_key'] ?? '')); if ($appKey !== '' && !str_starts_with($storedDir, $appKey . '/')) { $candidates[] = $baseUploads . '/' . $appKey . '/' . $storedDir . '/' . $storedFilename; } foreach ($candidates as $candidate) { if (is_file($candidate)) { return $candidate; } } return null; } private function buildNachweiseOversizeWarning(int $totalBytes): string { $totalMb = $this->formatMegabytes($totalBytes); $limitMb = $this->formatMegabytes(self::NACHWEISE_MAX_ATTACHMENT_BYTES); return 'Die Qualifikationsnachweise wurden nicht als E-Mail-Anhang versendet, weil die Gesamtgröße mit ' . $totalMb . ' MB das Limit von ' . $limitMb . ' MB überschreitet. ' . 'Bitte laden Sie diese Dateien über die Admin-Seite herunter.'; } private function formatMegabytes(int $bytes): string { return number_format(max(0, $bytes) / 1024 / 1024, 1, ',', '.'); } /** @param array $submission */ private function isMinorSubmission(array $submission): bool { if (array_key_exists('is_minor_submission', $submission)) { return (bool) $submission['is_minor_submission']; } $formData = (array) ($submission['form_data'] ?? []); return $this->deriveIsMinorFromBirthdate((string) ($formData['geburtsdatum'] ?? '')); } private function deriveIsMinorFromBirthdate(string $birthdate): bool { $birthdate = trim($birthdate); if ($birthdate === '') { return false; } $date = \DateTimeImmutable::createFromFormat('!Y-m-d', $birthdate); if (!$date || $date->format('Y-m-d') !== $birthdate) { return false; } $today = new \DateTimeImmutable('today'); if ($date > $today) { return false; } return $date->diff($today)->y < 18; } private function renderOtpText(string $code, int $ttlSeconds): string { $configured = (string) ($this->mailConfig['otp']['text_template'] ?? ''); $template = trim($configured); if ($template === '') { $template = "Ihr Sicherheitscode lautet: {{code}}\nDer Code ist {{ttl_minutes}} Minuten gültig."; } return $this->replaceOtpTemplateVars($template, $code, $ttlSeconds, false); } private function renderOtpHtml(string $code, int $ttlSeconds): string { $configured = (string) ($this->mailConfig['otp']['html_template'] ?? ''); $template = trim($configured); if ($template === '') { $template = '

Ihr Sicherheitscode lautet: {{code}}

Der Code ist {{ttl_minutes}} Minuten gültig.

'; } return $this->replaceOtpTemplateVars($template, $code, $ttlSeconds, true); } private function replaceOtpTemplateVars(string $template, string $code, int $ttlSeconds, bool $htmlContext): string { $minutes = (string) max(1, (int) ceil($ttlSeconds / 60)); $projectName = (string) ($this->appConfig['project_name'] ?? 'Mitgliedsantrag'); $safeCode = trim($code); $safeProjectName = trim($projectName); if ($htmlContext) { $safeCode = $this->esc($safeCode); $safeProjectName = $this->esc($safeProjectName); } return strtr($template, [ '{{code}}' => $safeCode, '{{ttl_seconds}}' => (string) max(1, $ttlSeconds), '{{ttl_minutes}}' => $minutes, '{{project_name}}' => $safeProjectName, ]); } }