| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619 |
- <?php
- declare(strict_types=1);
- namespace App\Mail;
- use App\App\Bootstrap;
- use App\Form\FormSchema;
- final class Mailer
- {
- private const NACHWEISE_FIELD_KEY = 'qualifikationsnachweise';
- private const NACHWEISE_MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
- /** @var array<string, mixed> */
- private array $mailConfig;
- /** @var array<string, mixed> */
- 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<string, mixed> $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<string, mixed> $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<string, mixed> $submission */
- private function renderAdminHtml(array $submission, bool $isMinorSubmission = false, ?string $uploadWarning = null): string
- {
- $steps = $this->formatter->formatSteps($submission);
- $uploads = $this->formatter->formatUploads($submission);
- $h = '<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body style="font-family:Arial,sans-serif;color:#333;max-width:700px;margin:0 auto">';
- $h .= '<h2 style="color:#c0392b">Neuer Mitgliedsantrag</h2>';
- $h .= '<p>Eingereicht: ' . $this->esc($this->formatTimestamp($submission)) . '<br>';
- $h .= 'E-Mail: ' . $this->esc((string) ($submission['email'] ?? '')) . '</p>';
- if ($isMinorSubmission) {
- $h .= $this->renderMinorAdminNoticeHtml();
- }
- if ($uploadWarning !== null && $uploadWarning !== '') {
- $h .= '<div style="background:#fff3cd;border:1px solid #f0c36d;padding:10px 12px;margin:12px 0">'
- . '<strong>Hinweis zu Anhängen:</strong> ' . nl2br($this->esc($uploadWarning))
- . '</div>';
- }
- foreach ($steps as $step) {
- $h .= '<h3 style="border-bottom:2px solid #c0392b;padding-bottom:4px">' . $this->esc($step['title']) . '</h3>';
- $h .= '<table style="width:100%;border-collapse:collapse">';
- foreach ($step['fields'] as $field) {
- $h .= '<tr><td style="padding:4px 8px;border-bottom:1px solid #eee;font-weight:bold;vertical-align:top;width:40%">'
- . $this->esc($field['label']) . '</td>';
- $h .= '<td style="padding:4px 8px;border-bottom:1px solid #eee;vertical-align:top">'
- . nl2br($this->esc($field['value'])) . '</td></tr>';
- }
- $h .= '</table>';
- }
- if ($uploads !== []) {
- $h .= '<h3 style="border-bottom:2px solid #c0392b;padding-bottom:4px">Hochgeladene Dateien</h3><ul>';
- foreach ($uploads as $group) {
- foreach ($group['files'] as $name) {
- $h .= '<li><strong>' . $this->esc($group['label']) . ':</strong> ' . $this->esc($name) . '</li>';
- }
- }
- $h .= '</ul>';
- }
- $h .= '</body></html>';
- return $h;
- }
- /** @param array<string, mixed> $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<string, mixed> $submission */
- private function renderApplicantHtml(array $submission, bool $isMinorSubmission = false): string
- {
- $steps = $this->formatter->formatSteps($submission);
- $uploads = $this->formatter->formatUploads($submission);
- $h = '<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body style="font-family:Arial,sans-serif;color:#333;max-width:700px;margin:0 auto">';
- $h .= '<h2 style="color:#c0392b">Vielen Dank für Ihren Mitgliedsantrag!</h2>';
- $h .= '<p>Ihre Daten wurden erfolgreich übermittelt. Nachfolgend finden Sie eine Zusammenfassung.</p>';
- if ($isMinorSubmission) {
- $h .= '<p><strong>Hinweis für Minderjährige:</strong> '
- . 'Das angehängte Formular ist auszudrucken, von Antragsteller/in und Erziehungsberechtigten zu unterschreiben '
- . 'und persönlich einzureichen.</p>';
- }
- foreach ($steps as $step) {
- $h .= '<h3>' . $this->esc($step['title']) . '</h3>';
- $h .= '<table style="width:100%;border-collapse:collapse">';
- foreach ($step['fields'] as $field) {
- $h .= '<tr><td style="padding:3px 6px;border-bottom:1px solid #eee;font-weight:bold;vertical-align:top;width:40%">'
- . $this->esc($field['label']) . '</td>';
- $h .= '<td style="padding:3px 6px;border-bottom:1px solid #eee;vertical-align:top">'
- . nl2br($this->esc($field['value'])) . '</td></tr>';
- }
- $h .= '</table>';
- }
- if ($uploads !== []) {
- $h .= '<h3>Hochgeladene Dateien</h3><ul>';
- foreach ($uploads as $group) {
- foreach ($group['files'] as $name) {
- $h .= '<li>' . $this->esc($group['label']) . ': ' . $this->esc($name) . '</li>';
- }
- }
- $h .= '</ul>';
- }
- $h .= '<p style="color:#888;font-size:0.9em">Bei Rückfragen kontaktieren Sie bitte den Verein.</p>';
- $h .= '</body></html>';
- return $h;
- }
- /** @param array<string, mixed> $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<string, mixed> $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 '<div style="background:#fff3cd;border:1px solid #f0c36d;padding:10px 12px;margin:12px 0">'
- . '<strong>Wichtiger Hinweis (Minderjährig):</strong> '
- . 'Die Unterschrift eines Erziehungsberechtigten liegt digital noch nicht vor. '
- . 'Bitte die Bearbeitung erst nach Eingang des handschriftlich unterschriebenen Formulars fortsetzen.'
- . '</div>';
- }
- 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<string, mixed> $submission
- * @return array{
- * attachments: array<int, array{path: string, filename: string, mime: string}>,
- * 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<string, mixed> $submission
- * @param array<string, mixed> $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<string, mixed> $submission
- * @param array<string, mixed> $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<string, mixed> $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 = '<p>Ihr Sicherheitscode lautet: <strong>{{code}}</strong></p><p>Der Code ist {{ttl_minutes}} Minuten gültig.</p>';
- }
- 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,
- ]);
- }
- }
|