mailer.php 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Mail;
  4. use App\App\Bootstrap;
  5. use App\Form\FormSchema;
  6. final class Mailer
  7. {
  8. private const NACHWEISE_FIELD_KEY = 'qualifikationsnachweise';
  9. private const NACHWEISE_MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
  10. /** @var array<string, mixed> */
  11. private array $mailConfig;
  12. /** @var array<string, mixed> */
  13. private array $appConfig;
  14. private FormSchema $schema;
  15. private SubmissionFormatter $formatter;
  16. private PdfGenerator $pdfGenerator;
  17. public function __construct()
  18. {
  19. $this->mailConfig = Bootstrap::config('mail');
  20. $this->appConfig = Bootstrap::config('app');
  21. $this->schema = new FormSchema();
  22. $this->formatter = new SubmissionFormatter($this->schema);
  23. $this->pdfGenerator = new PdfGenerator($this->formatter, $this->schema);
  24. }
  25. public function sendOtpMail(string $email, string $code, int $ttlSeconds): bool
  26. {
  27. $email = strtolower(trim($email));
  28. if ($email === '') {
  29. return false;
  30. }
  31. $subject = (string) ($this->mailConfig['subjects']['otp'] ?? 'Ihr Sicherheitscode');
  32. $textBody = $this->renderOtpText($code, $ttlSeconds);
  33. $htmlBody = $this->renderOtpHtml($code, $ttlSeconds);
  34. try {
  35. $mail = $this->createMailBuilder();
  36. $mail->setTo($email)
  37. ->setSubject($subject)
  38. ->setTextBody($textBody)
  39. ->setHtmlBody($htmlBody);
  40. if (!$mail->send()) {
  41. Bootstrap::log('mail', 'Versand OTP fehlgeschlagen: ' . $email . ' - ' . $mail->getErrorInfo());
  42. return false;
  43. }
  44. } catch (\Throwable $e) {
  45. Bootstrap::log('mail', 'Versand OTP fehlgeschlagen: ' . $email . ' - ' . $e->getMessage());
  46. return false;
  47. }
  48. return true;
  49. }
  50. /** @param array<string, mixed> $submission */
  51. public function sendSubmissionMails(array $submission): void
  52. {
  53. $formDataPdf = $this->pdfGenerator->generateFormDataPdf($submission);
  54. $minorSignaturePdf = $this->pdfGenerator->generateMinorSignaturePdf($submission);
  55. $isMinorSubmission = $this->isMinorSubmission($submission);
  56. try {
  57. $this->sendAdminMails($submission, $formDataPdf, $isMinorSubmission);
  58. $this->sendApplicantMail($submission, $minorSignaturePdf);
  59. } finally {
  60. if ($formDataPdf !== null) {
  61. @unlink($formDataPdf);
  62. }
  63. if ($minorSignaturePdf !== null) {
  64. @unlink($minorSignaturePdf);
  65. }
  66. }
  67. }
  68. private function sendAdminMails(
  69. array $submission,
  70. ?string $formDataPdf,
  71. bool $isMinorSubmission,
  72. ): void {
  73. $recipients = (array) ($this->mailConfig['recipients'] ?? []);
  74. $subject = (string) ($this->mailConfig['subjects']['admin'] ?? 'Neuer Mitgliedsantrag');
  75. $attachmentInfo = $this->collectAdminUploadAttachments($submission);
  76. $uploadWarning = null;
  77. if ($attachmentInfo['nachweise_skipped']) {
  78. $uploadWarning = $this->buildNachweiseOversizeWarning((int) $attachmentInfo['nachweise_total_bytes']);
  79. Bootstrap::log('mail', 'Qualifikationsnachweise nicht angehängt (zu groß): ' . $uploadWarning);
  80. }
  81. $htmlBody = $this->renderAdminHtml($submission, $isMinorSubmission, $uploadWarning);
  82. $textBody = $this->renderAdminText($submission, $isMinorSubmission, $uploadWarning);
  83. $formData = (array) ($submission['form_data'] ?? []);
  84. $ccEmails = [];
  85. $notifications = (array) ($this->schema->raw()['additional_notifications'] ?? []);
  86. $validator = new \App\Form\Validator($this->schema);
  87. foreach ($notifications as $notification) {
  88. if (!isset($notification['condition']) || !is_array($notification['condition'])) {
  89. continue;
  90. }
  91. if ($validator->evaluateCondition($notification['condition'], $formData)) {
  92. $ccs = (array) ($notification['cc'] ?? []);
  93. foreach ($ccs as $cc) {
  94. if (is_string($cc) && filter_var($cc, FILTER_VALIDATE_EMAIL)) {
  95. $ccEmails[] = trim($cc);
  96. }
  97. }
  98. }
  99. }
  100. foreach ($recipients as $recipient) {
  101. if (!is_string($recipient) || $recipient === '') {
  102. continue;
  103. }
  104. try {
  105. $mail = $this->createMailBuilder();
  106. $mail->setTo($recipient)
  107. ->setSubject($subject)
  108. ->setHtmlBody($htmlBody)
  109. ->setTextBody($textBody);
  110. foreach ($ccEmails as $ccEmail) {
  111. $mail->addCc($ccEmail);
  112. }
  113. if ($formDataPdf !== null) {
  114. $mail->addAttachment($formDataPdf, 'Antragsdaten.pdf', 'application/pdf');
  115. }
  116. foreach ($attachmentInfo['attachments'] as $att) {
  117. $mail->addAttachment($att['path'], $att['filename'], $att['mime']);
  118. }
  119. if (!$mail->send()) {
  120. Bootstrap::log('mail', 'Versand an Admin fehlgeschlagen: ' . $recipient . ' - ' . $mail->getErrorInfo());
  121. }
  122. } catch (\Throwable $e) {
  123. Bootstrap::log('mail', 'Versand an Admin fehlgeschlagen: ' . $recipient . ' - ' . $e->getMessage());
  124. }
  125. }
  126. }
  127. /** @param array<string, mixed> $submission */
  128. private function sendApplicantMail(array $submission, ?string $minorSignaturePdf): void
  129. {
  130. $email = (string) ($submission['email'] ?? '');
  131. if ($email === '') {
  132. return;
  133. }
  134. $isMinorSubmission = $this->isMinorSubmission($submission);
  135. $subject = (string) ($this->mailConfig['subjects']['applicant'] ?? 'Bestätigung Mitgliedsantrag');
  136. $htmlBody = $this->renderApplicantHtml($submission, $isMinorSubmission);
  137. $textBody = $this->renderApplicantText($submission, $isMinorSubmission);
  138. try {
  139. $mail = $this->createMailBuilder();
  140. $mail->setTo($email)
  141. ->setSubject($subject)
  142. ->setHtmlBody($htmlBody)
  143. ->setTextBody($textBody);
  144. if ($minorSignaturePdf !== null && is_file($minorSignaturePdf)) {
  145. $mail->addAttachment($minorSignaturePdf, 'Einverstaendniserklaerung-Minderjaehrige.pdf', 'application/pdf');
  146. }
  147. if (!$mail->send()) {
  148. Bootstrap::log('mail', 'Versand an Antragsteller fehlgeschlagen: ' . $email . ' - ' . $mail->getErrorInfo());
  149. }
  150. } catch (\Throwable $e) {
  151. Bootstrap::log('mail', 'Versand an Antragsteller fehlgeschlagen: ' . $email . ' - ' . $e->getMessage());
  152. }
  153. }
  154. // ---------------------------------------------------------------
  155. // Mail builder
  156. // ---------------------------------------------------------------
  157. private function createMailBuilder(): MimeMailBuilder
  158. {
  159. $from = (string) ($this->mailConfig['from'] ?? 'no-reply@example.org');
  160. $fromName = (string) ($this->mailConfig['from_name'] ?? '');
  161. return (new MimeMailBuilder())->setFrom($from, $fromName);
  162. }
  163. // ---------------------------------------------------------------
  164. // Admin email rendering
  165. // ---------------------------------------------------------------
  166. /** @param array<string, mixed> $submission */
  167. private function renderAdminHtml(array $submission, bool $isMinorSubmission = false, ?string $uploadWarning = null): string
  168. {
  169. $steps = $this->formatter->formatSteps($submission);
  170. $uploads = $this->formatter->formatUploads($submission);
  171. $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">';
  172. $h .= '<h2 style="color:#c0392b">Neuer Mitgliedsantrag</h2>';
  173. $h .= '<p>Eingereicht: ' . $this->esc($this->formatTimestamp($submission)) . '<br>';
  174. $h .= 'E-Mail: ' . $this->esc((string) ($submission['email'] ?? '')) . '</p>';
  175. if ($isMinorSubmission) {
  176. $h .= $this->renderMinorAdminNoticeHtml();
  177. }
  178. if ($uploadWarning !== null && $uploadWarning !== '') {
  179. $h .= '<div style="background:#fff3cd;border:1px solid #f0c36d;padding:10px 12px;margin:12px 0">'
  180. . '<strong>Hinweis zu Anhängen:</strong> ' . nl2br($this->esc($uploadWarning))
  181. . '</div>';
  182. }
  183. foreach ($steps as $step) {
  184. $h .= '<h3 style="border-bottom:2px solid #c0392b;padding-bottom:4px">' . $this->esc($step['title']) . '</h3>';
  185. $h .= '<table style="width:100%;border-collapse:collapse">';
  186. foreach ($step['fields'] as $field) {
  187. $h .= '<tr><td style="padding:4px 8px;border-bottom:1px solid #eee;font-weight:bold;vertical-align:top;width:40%">'
  188. . $this->esc($field['label']) . '</td>';
  189. $h .= '<td style="padding:4px 8px;border-bottom:1px solid #eee;vertical-align:top">'
  190. . nl2br($this->esc($field['value'])) . '</td></tr>';
  191. }
  192. $h .= '</table>';
  193. }
  194. if ($uploads !== []) {
  195. $h .= '<h3 style="border-bottom:2px solid #c0392b;padding-bottom:4px">Hochgeladene Dateien</h3><ul>';
  196. foreach ($uploads as $group) {
  197. foreach ($group['files'] as $name) {
  198. $h .= '<li><strong>' . $this->esc($group['label']) . ':</strong> ' . $this->esc($name) . '</li>';
  199. }
  200. }
  201. $h .= '</ul>';
  202. }
  203. $h .= '</body></html>';
  204. return $h;
  205. }
  206. /** @param array<string, mixed> $submission */
  207. private function renderAdminText(array $submission, bool $isMinorSubmission = false, ?string $uploadWarning = null): string
  208. {
  209. $steps = $this->formatter->formatSteps($submission);
  210. $uploads = $this->formatter->formatUploads($submission);
  211. $t = "NEUER MITGLIEDSANTRAG\n";
  212. $t .= 'Eingereicht: ' . $this->formatTimestamp($submission) . "\n";
  213. $t .= 'E-Mail: ' . (string) ($submission['email'] ?? '') . "\n\n";
  214. if ($isMinorSubmission) {
  215. $t .= $this->renderMinorAdminNoticeText() . "\n\n";
  216. }
  217. if ($uploadWarning !== null && $uploadWarning !== '') {
  218. $t .= "HINWEIS ZU ANHÄNGEN\n";
  219. $t .= $uploadWarning . "\n\n";
  220. }
  221. foreach ($steps as $step) {
  222. $t .= strtoupper($step['title']) . "\n" . str_repeat('-', 40) . "\n";
  223. foreach ($step['fields'] as $field) {
  224. $t .= $field['label'] . ': ' . $field['value'] . "\n";
  225. }
  226. $t .= "\n";
  227. }
  228. if ($uploads !== []) {
  229. $t .= "HOCHGELADENE DATEIEN\n" . str_repeat('-', 40) . "\n";
  230. foreach ($uploads as $group) {
  231. foreach ($group['files'] as $name) {
  232. $t .= '- ' . $group['label'] . ': ' . $name . "\n";
  233. }
  234. }
  235. }
  236. return $t;
  237. }
  238. // ---------------------------------------------------------------
  239. // Applicant email rendering
  240. // ---------------------------------------------------------------
  241. /** @param array<string, mixed> $submission */
  242. private function renderApplicantHtml(array $submission, bool $isMinorSubmission = false): string
  243. {
  244. $steps = $this->formatter->formatSteps($submission);
  245. $uploads = $this->formatter->formatUploads($submission);
  246. $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">';
  247. $h .= '<h2 style="color:#c0392b">Vielen Dank für Ihren Mitgliedsantrag!</h2>';
  248. $h .= '<p>Ihre Daten wurden erfolgreich übermittelt. Nachfolgend finden Sie eine Zusammenfassung.</p>';
  249. if ($isMinorSubmission) {
  250. $h .= '<p><strong>Hinweis für Minderjährige:</strong> '
  251. . 'Das angehängte Formular ist auszudrucken, von Antragsteller/in und Erziehungsberechtigten zu unterschreiben '
  252. . 'und persönlich einzureichen.</p>';
  253. }
  254. foreach ($steps as $step) {
  255. $h .= '<h3>' . $this->esc($step['title']) . '</h3>';
  256. $h .= '<table style="width:100%;border-collapse:collapse">';
  257. foreach ($step['fields'] as $field) {
  258. $h .= '<tr><td style="padding:3px 6px;border-bottom:1px solid #eee;font-weight:bold;vertical-align:top;width:40%">'
  259. . $this->esc($field['label']) . '</td>';
  260. $h .= '<td style="padding:3px 6px;border-bottom:1px solid #eee;vertical-align:top">'
  261. . nl2br($this->esc($field['value'])) . '</td></tr>';
  262. }
  263. $h .= '</table>';
  264. }
  265. if ($uploads !== []) {
  266. $h .= '<h3>Hochgeladene Dateien</h3><ul>';
  267. foreach ($uploads as $group) {
  268. foreach ($group['files'] as $name) {
  269. $h .= '<li>' . $this->esc($group['label']) . ': ' . $this->esc($name) . '</li>';
  270. }
  271. }
  272. $h .= '</ul>';
  273. }
  274. $h .= '<p style="color:#888;font-size:0.9em">Bei Rückfragen kontaktieren Sie bitte den Verein.</p>';
  275. $h .= '</body></html>';
  276. return $h;
  277. }
  278. /** @param array<string, mixed> $submission */
  279. private function renderApplicantText(array $submission, bool $isMinorSubmission = false): string
  280. {
  281. $steps = $this->formatter->formatSteps($submission);
  282. $uploads = $this->formatter->formatUploads($submission);
  283. $t = "Vielen Dank für Ihren Mitgliedsantrag!\n\n";
  284. $t .= "Ihre Daten wurden erfolgreich übermittelt. Nachfolgend finden Sie eine Zusammenfassung.\n\n";
  285. if ($isMinorSubmission) {
  286. $t .= "Hinweis für Minderjährige: Das angehängte Formular ist auszudrucken, von Antragsteller/in und "
  287. . "Erziehungsberechtigten zu unterschreiben und persönlich einzureichen.\n\n";
  288. }
  289. foreach ($steps as $step) {
  290. $t .= strtoupper($step['title']) . "\n";
  291. foreach ($step['fields'] as $field) {
  292. $t .= $field['label'] . ': ' . $field['value'] . "\n";
  293. }
  294. $t .= "\n";
  295. }
  296. if ($uploads !== []) {
  297. $t .= "HOCHGELADENE DATEIEN\n";
  298. foreach ($uploads as $group) {
  299. foreach ($group['files'] as $name) {
  300. $t .= '- ' . $group['label'] . ': ' . $name . "\n";
  301. }
  302. }
  303. $t .= "\n";
  304. }
  305. $t .= "Bei Rückfragen kontaktieren Sie bitte den Verein.\n";
  306. return $t;
  307. }
  308. // ---------------------------------------------------------------
  309. // Utilities
  310. // ---------------------------------------------------------------
  311. /** @param array<string, mixed> $submission */
  312. private function formatTimestamp(array $submission): string
  313. {
  314. $ts = (string) ($submission['submitted_at'] ?? '');
  315. $parsed = strtotime($ts);
  316. return $parsed !== false ? date('d.m.Y H:i', $parsed) : $ts;
  317. }
  318. private function esc(string $value): string
  319. {
  320. return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
  321. }
  322. private function renderMinorAdminNoticeHtml(): string
  323. {
  324. return '<div style="background:#fff3cd;border:1px solid #f0c36d;padding:10px 12px;margin:12px 0">'
  325. . '<strong>Wichtiger Hinweis (Minderjährig):</strong> '
  326. . 'Die Unterschrift eines Erziehungsberechtigten liegt digital noch nicht vor. '
  327. . 'Bitte die Bearbeitung erst nach Eingang des handschriftlich unterschriebenen Formulars fortsetzen.'
  328. . '</div>';
  329. }
  330. private function renderMinorAdminNoticeText(): string
  331. {
  332. return "WICHTIGER HINWEIS (MINDERJÄHRIG)\n"
  333. . 'Die Unterschrift eines Erziehungsberechtigten liegt digital noch nicht vor. '
  334. . 'Bitte die Bearbeitung erst nach Eingang des handschriftlich unterschriebenen Formulars fortsetzen.';
  335. }
  336. /**
  337. * @param array<string, mixed> $submission
  338. * @return array{
  339. * attachments: array<int, array{path: string, filename: string, mime: string}>,
  340. * nachweise_skipped: bool,
  341. * nachweise_total_bytes: int
  342. * }
  343. */
  344. private function collectAdminUploadAttachments(array $submission): array
  345. {
  346. $uploads = (array) ($submission['uploads'] ?? []);
  347. $uploadFields = $this->schema->getUploadFields();
  348. $attachments = [];
  349. $nachweiseAttachments = [];
  350. $nachweiseTotalBytes = 0;
  351. foreach ($uploadFields as $fieldKey => $_fieldDef) {
  352. $files = $uploads[$fieldKey] ?? [];
  353. if (!is_array($files)) {
  354. continue;
  355. }
  356. foreach ($files as $file) {
  357. if (!is_array($file)) {
  358. continue;
  359. }
  360. $resolved = $this->resolveUploadAttachment($submission, $file);
  361. if ($resolved === null) {
  362. continue;
  363. }
  364. $entry = [
  365. 'path' => $resolved['path'],
  366. 'filename' => $resolved['filename'],
  367. 'mime' => $resolved['mime'],
  368. ];
  369. if ($fieldKey === self::NACHWEISE_FIELD_KEY) {
  370. $nachweiseAttachments[] = $entry;
  371. $nachweiseTotalBytes += $resolved['size'];
  372. continue;
  373. }
  374. $attachments[] = $entry;
  375. }
  376. }
  377. $nachweiseSkipped = $nachweiseTotalBytes > self::NACHWEISE_MAX_ATTACHMENT_BYTES;
  378. if (!$nachweiseSkipped) {
  379. $attachments = array_merge($attachments, $nachweiseAttachments);
  380. }
  381. return [
  382. 'attachments' => $attachments,
  383. 'nachweise_skipped' => $nachweiseSkipped,
  384. 'nachweise_total_bytes' => $nachweiseTotalBytes,
  385. ];
  386. }
  387. /**
  388. * @param array<string, mixed> $submission
  389. * @param array<string, mixed> $file
  390. * @return array{path: string, filename: string, mime: string, size: int}|null
  391. */
  392. private function resolveUploadAttachment(array $submission, array $file): ?array
  393. {
  394. $path = $this->resolveSubmissionUploadPath($submission, $file);
  395. if ($path === null || !is_file($path)) {
  396. return null;
  397. }
  398. $filename = trim((string) ($file['original_filename'] ?? basename($path)));
  399. if ($filename === '') {
  400. $filename = basename($path);
  401. }
  402. $mime = trim((string) ($file['mime'] ?? ''));
  403. if ($mime === '') {
  404. $detected = @mime_content_type($path);
  405. $mime = is_string($detected) && $detected !== '' ? $detected : 'application/octet-stream';
  406. }
  407. $size = (int) ($file['size'] ?? 0);
  408. if ($size <= 0) {
  409. $size = (int) (filesize($path) ?: 0);
  410. }
  411. return [
  412. 'path' => $path,
  413. 'filename' => $filename,
  414. 'mime' => $mime,
  415. 'size' => $size,
  416. ];
  417. }
  418. /**
  419. * @param array<string, mixed> $submission
  420. * @param array<string, mixed> $file
  421. */
  422. private function resolveSubmissionUploadPath(array $submission, array $file): ?string
  423. {
  424. $baseUploads = rtrim((string) ($this->appConfig['storage']['uploads'] ?? ''), '/');
  425. if ($baseUploads === '') {
  426. return null;
  427. }
  428. $relativePath = str_replace(['..', '\\'], '', (string) ($file['relative_path'] ?? ''));
  429. if ($relativePath !== '') {
  430. $candidate = $baseUploads . '/' . ltrim($relativePath, '/');
  431. if (is_file($candidate)) {
  432. return $candidate;
  433. }
  434. }
  435. $storedDir = str_replace(['..', '\\'], '', (string) ($file['stored_dir'] ?? ''));
  436. $storedFilename = str_replace(['..', '\\'], '', (string) ($file['stored_filename'] ?? ''));
  437. if ($storedDir === '' || $storedFilename === '') {
  438. return null;
  439. }
  440. $storedDir = ltrim($storedDir, '/');
  441. $candidates = [$baseUploads . '/' . $storedDir . '/' . $storedFilename];
  442. $appKey = trim((string) ($submission['application_key'] ?? ''));
  443. if ($appKey !== '' && !str_starts_with($storedDir, $appKey . '/')) {
  444. $candidates[] = $baseUploads . '/' . $appKey . '/' . $storedDir . '/' . $storedFilename;
  445. }
  446. foreach ($candidates as $candidate) {
  447. if (is_file($candidate)) {
  448. return $candidate;
  449. }
  450. }
  451. return null;
  452. }
  453. private function buildNachweiseOversizeWarning(int $totalBytes): string
  454. {
  455. $totalMb = $this->formatMegabytes($totalBytes);
  456. $limitMb = $this->formatMegabytes(self::NACHWEISE_MAX_ATTACHMENT_BYTES);
  457. return 'Die Qualifikationsnachweise wurden nicht als E-Mail-Anhang versendet, weil die Gesamtgröße mit '
  458. . $totalMb . ' MB das Limit von ' . $limitMb . ' MB überschreitet. '
  459. . 'Bitte laden Sie diese Dateien über die Admin-Seite herunter.';
  460. }
  461. private function formatMegabytes(int $bytes): string
  462. {
  463. return number_format(max(0, $bytes) / 1024 / 1024, 1, ',', '.');
  464. }
  465. /** @param array<string, mixed> $submission */
  466. private function isMinorSubmission(array $submission): bool
  467. {
  468. if (array_key_exists('is_minor_submission', $submission)) {
  469. return (bool) $submission['is_minor_submission'];
  470. }
  471. $formData = (array) ($submission['form_data'] ?? []);
  472. return $this->deriveIsMinorFromBirthdate((string) ($formData['geburtsdatum'] ?? ''));
  473. }
  474. private function deriveIsMinorFromBirthdate(string $birthdate): bool
  475. {
  476. $birthdate = trim($birthdate);
  477. if ($birthdate === '') {
  478. return false;
  479. }
  480. $date = \DateTimeImmutable::createFromFormat('!Y-m-d', $birthdate);
  481. if (!$date || $date->format('Y-m-d') !== $birthdate) {
  482. return false;
  483. }
  484. $today = new \DateTimeImmutable('today');
  485. if ($date > $today) {
  486. return false;
  487. }
  488. return $date->diff($today)->y < 18;
  489. }
  490. private function renderOtpText(string $code, int $ttlSeconds): string
  491. {
  492. $configured = (string) ($this->mailConfig['otp']['text_template'] ?? '');
  493. $template = trim($configured);
  494. if ($template === '') {
  495. $template = "Ihr Sicherheitscode lautet: {{code}}\nDer Code ist {{ttl_minutes}} Minuten gültig.";
  496. }
  497. return $this->replaceOtpTemplateVars($template, $code, $ttlSeconds, false);
  498. }
  499. private function renderOtpHtml(string $code, int $ttlSeconds): string
  500. {
  501. $configured = (string) ($this->mailConfig['otp']['html_template'] ?? '');
  502. $template = trim($configured);
  503. if ($template === '') {
  504. $template = '<p>Ihr Sicherheitscode lautet: <strong>{{code}}</strong></p><p>Der Code ist {{ttl_minutes}} Minuten gültig.</p>';
  505. }
  506. return $this->replaceOtpTemplateVars($template, $code, $ttlSeconds, true);
  507. }
  508. private function replaceOtpTemplateVars(string $template, string $code, int $ttlSeconds, bool $htmlContext): string
  509. {
  510. $minutes = (string) max(1, (int) ceil($ttlSeconds / 60));
  511. $projectName = (string) ($this->appConfig['project_name'] ?? 'Mitgliedsantrag');
  512. $safeCode = trim($code);
  513. $safeProjectName = trim($projectName);
  514. if ($htmlContext) {
  515. $safeCode = $this->esc($safeCode);
  516. $safeProjectName = $this->esc($safeProjectName);
  517. }
  518. return strtr($template, [
  519. '{{code}}' => $safeCode,
  520. '{{ttl_seconds}}' => (string) max(1, $ttlSeconds),
  521. '{{ttl_minutes}}' => $minutes,
  522. '{{project_name}}' => $safeProjectName,
  523. ]);
  524. }
  525. }