From b58d96cd5eeb7b788e25ea847f423c3b4204d952 Mon Sep 17 00:00:00 2001 From: Robert Wartenberg Date: Mon, 31 Aug 2026 14:27:19 +0200 Subject: [PATCH] =?UTF-8?q?Implementiere=20echtes=20sendmail=20f=C3=BCr=20?= =?UTF-8?q?den=20Server.=20Lokal=20gibts=20kein=20mail=20und=20keine=20out?= =?UTF-8?q?box=20mehr?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- development-system/README.md | 17 ++--- development-system/database/README.md | 9 ++- .../migrations/006_remove_booking_outbox.sql | 6 ++ development-system/docker-compose.yml | 2 +- .../perspektivwerk/booking-admin/index.php | 13 +--- .../public/perspektivwerk/booking/backend.php | 76 ++++++++++++++----- .../public/perspektivwerk/booking/booking.js | 2 +- .../public/perspektivwerk/js/booking.js | 2 +- .../scripts/Deploy-Perspektivwerk.bat | 9 ++- .../tests/booking-backend-config.test.php | 17 +++++ .../tests/booking-migrations.test.mjs | 7 ++ 11 files changed, 109 insertions(+), 51 deletions(-) create mode 100644 development-system/database/migrations/006_remove_booking_outbox.sql diff --git a/development-system/README.md b/development-system/README.md index 47b0c23..001bba1 100644 --- a/development-system/README.md +++ b/development-system/README.md @@ -105,13 +105,12 @@ sowie klar erkennbare Demo-Termine. Die API prüft Kapazitäten innerhalb einer Transaktion und sperrt den Termin dabei mit `SELECT … FOR UPDATE`; parallele Anmeldungen können dadurch die Kapazität nicht überschreiten. -Die Demo speichert eine Anfrage zunächst als `pending_confirmation`. Sie -erstellt einen zeitlich begrenzten Bestätigungslink, bestätigt danach atomar -gegen die verfügbare Kapazität und erzeugt eine zweite Bestätigungsnachricht. -Bestätigungs- und Erinnerungsnachrichten liegen standardmäßig im lokalen -Mailausgang der Testverwaltung. Mit `BOOKING_MAIL_TRANSPORT=mail` versucht der -Job den Versand über die PHP-Funktion `mail()`; SMTP-Konfiguration und echter -Mailbetrieb gehören erst in die Produktiventscheidung. +Die Demo speichert eine Anfrage zunächst als `pending_confirmation` und +übergibt die Bestätigungsnachricht direkt an das Server-Sendmail. Der Link ist +30 Minuten gültig; sein Aufruf bestätigt die Teilnahme atomar und löst die +zweite Nachricht mit ICS-Anhang aus. Ohne Cronjob werden keine Erinnerungen +verschickt. Lokal ist der Mailtransport bewusst mit +`BOOKING_MAIL_TRANSPORT=disabled` deaktiviert. Der Browser erhält ein CSRF-Token per gleicher Sitzung; die API validiert alle Pflichtdaten serverseitig und begrenzt Anmeldungen pro Sitzung. Dies ist eine @@ -174,10 +173,8 @@ Die Testverwaltung läuft unabhängig von Contao unter - Termin anlegen, bearbeiten sowie aktivieren/deaktivieren, - Übersicht und Entfernen von Teilnahmen, -- lokaler Mailausgang mit kopierbarem Bestätigungslink, - Anzeige bestätigter Teilnahmen nach frei wählbarem Zeitraum, -- abonnierbaren ICS-Feed je Projekt, -- manuellen Lauf der Ablauf- und Erinnerungsjobs. +- abonnierbaren ICS-Feed je Projekt. Der Verwaltungsbereich hat absichtlich keine Benutzerverwaltung und darf nicht unverändert außerhalb der Testumgebung bereitgestellt werden. diff --git a/development-system/database/README.md b/development-system/database/README.md index 2fa01d9..b20523b 100644 --- a/development-system/database/README.md +++ b/development-system/database/README.md @@ -54,9 +54,9 @@ MariaDB-/MySQL-Client ausgeführt. Zugangsdaten gehören dabei in die Betriebskonfiguration und niemals in die SQL-Datei oder das Repository. Die erste Migration erstellt `sufw_booking_events` und `sufw_booking_registrations` sowie neun ungefährliche Demo-Termine für beide -Instanzen. `002_booking_workflow.sql` ergänzt Bestätigungstoken, lokalen -Mailausgang und pro Projekt einen beim Import zufällig erzeugten, -widerrufbaren ICS-Feed-Token. `003_booking_demo_data.sql` legt zusätzlich +Instanzen. `002_booking_workflow.sql` ergänzt Bestätigungstoken, den später +entfernten Test-Mailausgang und pro Projekt einen beim Import zufällig +erzeugten, widerrufbaren ICS-Feed-Token. `003_booking_demo_data.sql` legt zusätzlich bestätigte Demo-Teilnahmen an und hält Beispieltermine jeweils einen Monat in der Vergangenheit und Zukunft. Diese letzte Datei ist ausschließlich für die lokale Entwicklungs- und Testdatenbank bestimmt und darf nicht auf dem @@ -65,3 +65,6 @@ die Telefonnummer der Ansprechperson und ersetzt die Kontakte der stabilen Demo-Termine durch Carina Hollederer und Sarah van den Heuvel. `005_booking_demo_july_august.sql` ergänzt ausschließlich lokal weitere bestätigte Demo-Termine und -Teilnahmen für Juli und August. +`006_remove_booking_outbox.sql` entfernt den nicht mehr verwendeten +Test-Mailausgang; Buchungsnachrichten werden direkt an das Server-Sendmail +übergeben. diff --git a/development-system/database/migrations/006_remove_booking_outbox.sql b/development-system/database/migrations/006_remove_booking_outbox.sql new file mode 100644 index 0000000..e86915f --- /dev/null +++ b/development-system/database/migrations/006_remove_booking_outbox.sql @@ -0,0 +1,6 @@ +-- SUFW Terminbuchung: Der Test-Mailausgang wird durch direkten Sendmail-Versand ersetzt. +-- Diese Migration entfernt ausschließlich die nicht mehr verwendete Outbox-Tabelle. + +DROP TABLE IF EXISTS `sufw_booking_outbox`; + +INSERT IGNORE INTO `sufw_booking_schema_migrations` (`version`) VALUES ('006_remove_booking_outbox'); diff --git a/development-system/docker-compose.yml b/development-system/docker-compose.yml index 59e401f..2d91bbd 100644 --- a/development-system/docker-compose.yml +++ b/development-system/docker-compose.yml @@ -10,7 +10,7 @@ services: CONTAO_ENVIRONMENT: dev DATABASE_URL: mysql://perspektivwerk:perspektivwerk@db:3306/perspektivwerk?serverVersion=mariadb-10.11.0 BOOKING_BASE_URL: http://localhost:8080/perspektivwerk - BOOKING_MAIL_TRANSPORT: database + BOOKING_MAIL_TRANSPORT: disabled command: - sh - -c diff --git a/development-system/public/perspektivwerk/booking-admin/index.php b/development-system/public/perspektivwerk/booking-admin/index.php index 074ede5..c667a2b 100644 --- a/development-system/public/perspektivwerk/booking-admin/index.php +++ b/development-system/public/perspektivwerk/booking-admin/index.php @@ -68,10 +68,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $database->prepare("UPDATE sufw_booking_registrations SET status = 'removed', removed_at = UTC_TIMESTAMP() WHERE id = :id AND status IN ('pending_confirmation', 'confirmed')")->execute(['id' => $_POST['id'] ?? '']); adminBack($project, 'registrations', 'Teilnahme entfernt; die Kapazität ist wieder frei.'); } - if ($action === 'run_jobs') { - $result = bookingRunJobs($database); - adminBack($project, $view, "Jobs ausgeführt: {$result['expired']} abgelaufen, {$result['remindersConsidered']} Erinnerungen geprüft."); - } } $titles = $database->prepare('SELECT DISTINCT title FROM sufw_booking_events WHERE project = :project ORDER BY title'); @@ -101,12 +97,6 @@ if ($view === 'registrations') { $statement->execute(['project' => $project]); $registrations = $statement->fetchAll(); } -$outbox = []; -if ($view === 'events') { - $statement = $database->prepare("SELECT o.*, r.name FROM sufw_booking_outbox o JOIN sufw_booking_registrations r ON r.id = o.registration_id JOIN sufw_booking_events e ON e.id = r.event_id WHERE e.project = :project ORDER BY o.created_at DESC LIMIT 20"); - $statement->execute(['project' => $project]); - $outbox = $statement->fetchAll(); -} $nextMonth = new DateTimeImmutable('first day of next month'); $defaultFrom = $nextMonth->format('Y-m-01'); $defaultTo = $nextMonth->format('Y-m-t'); @@ -116,7 +106,7 @@ $defaultTo = $nextMonth->format('Y-m-t');
-

Terminbuchung · Testverwaltung

+

Terminbuchung · Testverwaltung

@@ -130,7 +120,6 @@ select[data-dropdown-indicator]{appearance:none;padding-right:2.5rem;background:

Abbrechen

Termine

TerminZeitKapazitätStatus


bis
/ bestätigtBearbeiten
-

Lokaler Mailausgang

·

Teilnahmen ·

PersonTerminStatus



diff --git a/development-system/public/perspektivwerk/booking/backend.php b/development-system/public/perspektivwerk/booking/backend.php index 4285b19..99da9d5 100644 --- a/development-system/public/perspektivwerk/booking/backend.php +++ b/development-system/public/perspektivwerk/booking/backend.php @@ -55,10 +55,59 @@ function bookingProjectTitle(string $project): string return $project === 'produktionsschule' ? 'Produktionsschule' : 'PerspektivWerk'; } -function bookingQueueMail(PDO $database, string $registrationId, string $kind, string $email, string $subject, string $body): void +function bookingMailTransport(?string $configuredTransport = null): string { - $statement = $database->prepare('INSERT IGNORE INTO sufw_booking_outbox (registration_id, kind, recipient_email, subject, body) VALUES (:registrationId, :kind, :email, :subject, :body)'); - $statement->execute(compact('registrationId', 'kind', 'email', 'subject', 'body')); + $transport = $configuredTransport ?? getenv('BOOKING_MAIL_TRANSPORT'); + if (!is_string($transport) || $transport === '') return 'sendmail'; + return $transport; +} + +function bookingIcsCalendar(array $event): string +{ + $timezone = new DateTimeZone('Europe/Berlin'); + $utc = new DateTimeZone('UTC'); + $start = new DateTimeImmutable((string) $event['starts_at'], $timezone); + $end = new DateTimeImmutable((string) $event['ends_at'], $timezone); + $lines = [ + 'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//SUFW//Terminbuchung//DE', 'CALSCALE:GREGORIAN', + 'BEGIN:VEVENT', + 'UID:'.bookingIcsEscape((string) $event['id']).'@sufw-booking', + 'DTSTAMP:'.gmdate('Ymd\\THis\\Z'), + 'DTSTART:'.$start->setTimezone($utc)->format('Ymd\\THis\\Z'), + 'DTEND:'.$end->setTimezone($utc)->format('Ymd\\THis\\Z'), + 'SUMMARY:'.bookingIcsEscape((string) $event['title']), + 'DESCRIPTION:'.bookingIcsEscape(($event['format'] ?? '') === 'telefonisch' ? 'Telefonischer Termin' : 'Termin vor Ort'), + 'END:VEVENT', 'END:VCALENDAR', + ]; + return implode("\r\n", $lines)."\r\n"; +} + +function bookingSendMail(string $email, string $subject, string $textBody, ?string $calendar = null): void +{ + $transport = bookingMailTransport(); + if ($transport === 'disabled') return; + if ($transport !== 'sendmail') throw new RuntimeException('Unsupported booking mail transport.'); + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) throw new InvalidArgumentException('Invalid mail recipient.'); + + $sender = 'sufw-buchung@boogiedev.net'; + $headers = ['From' => $sender, 'Reply-To' => $sender, 'MIME-Version' => '1.0']; + if ($calendar === null) { + $headers['Content-Type'] = 'text/plain; charset=UTF-8'; + $message = $textBody; + } else { + $boundary = '=_sufw_'.bin2hex(random_bytes(16)); + $headers['Content-Type'] = 'multipart/mixed; boundary="'.$boundary.'"'; + $message = '--'.$boundary."\r\n" + ."Content-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n" + .$textBody."\r\n--".$boundary."\r\n" + ."Content-Type: text/calendar; charset=UTF-8; method=PUBLISH; name=\"termin.ics\"\r\n" + ."Content-Transfer-Encoding: base64\r\nContent-Disposition: attachment; filename=\"termin.ics\"\r\n\r\n" + .chunk_split(base64_encode($calendar), 76, "\r\n") + .'--'.$boundary."--\r\n"; + } + if (!mail($email, $subject, $message, $headers, '-f'.$sender)) { + throw new RuntimeException('The confirmation email could not be handed to sendmail.'); + } } function bookingNormaliseEvent(array $event): array @@ -132,8 +181,8 @@ function bookingCreateRegistration(PDO $database, array $payload): array $link = bookingBaseUrl().'/booking/confirm.php?token='.rawurlencode($token); $subject = 'Bitte bestätigen Sie Ihre Terminanfrage'; $body = "Guten Tag $name,\n\nbitte bestätigen Sie Ihre Anfrage für \"{$event['title']}\" am {$event['starts_at']} innerhalb von 30 Minuten:\n$link\n\nKontakt für Rückfragen: {$event['contact_name']} ({$event['contact_email']})."; - bookingQueueMail($database, $registrationId, 'confirmation_request', $email, $subject, $body); $database->commit(); + bookingSendMail($email, $subject, $body); return ['registrationId' => $registrationId, 'expiresAt' => gmdate('c', time() + 1800)]; } catch (Throwable $exception) { if ($database->inTransaction()) $database->rollBack(); @@ -147,7 +196,7 @@ function bookingConfirmRegistration(PDO $database, string $token): array $database->beginTransaction(); try { $statement = $database->prepare(<<<'SQL' - SELECT r.*, e.title, e.project, e.starts_at, e.ends_at, e.capacity, e.contact_name, e.contact_email + SELECT r.*, e.title, e.project, e.starts_at, e.ends_at, e.capacity, e.format, e.contact_name, e.contact_email FROM sufw_booking_registrations r JOIN sufw_booking_events e ON e.id = r.event_id WHERE r.confirmation_token_hash = :tokenHash FOR UPDATE SQL); @@ -170,8 +219,8 @@ function bookingConfirmRegistration(PDO $database, string $token): array $history->execute(['email' => $registration['email'], 'name' => $registration['name']]); $appointments = implode("\n", array_map(static fn (array $row): string => "- {$row['title']} am {$row['starts_at']}", $history->fetchAll())); $body = "Guten Tag {$registration['name']},\n\nIhr Termin ist bestätigt:\n{$registration['title']} am {$registration['starts_at']} bis {$registration['ends_at']}.\n\nIhre weiteren bestätigten Termine:\n$appointments\n\nBei Stornierungswunsch wenden Sie sich an {$registration['contact_name']} ({$registration['contact_email']})."; - bookingQueueMail($database, $registration['id'], 'confirmation', $registration['email'], 'Ihr Termin ist bestätigt', $body); $database->commit(); + bookingSendMail($registration['email'], 'Ihr Termin ist bestätigt', $body, bookingIcsCalendar($registration)); return $registration; } catch (Throwable $exception) { if ($database->inTransaction()) $database->rollBack(); @@ -179,21 +228,6 @@ function bookingConfirmRegistration(PDO $database, string $token): array } } -function bookingRunJobs(PDO $database): array -{ - $expired = $database->exec("UPDATE sufw_booking_registrations SET status = 'expired' WHERE status = 'pending_confirmation' AND confirmation_expires_at < UTC_TIMESTAMP()"); - $reminders = $database->query(<<<'SQL' - SELECT r.id, r.email, r.name, e.title, e.starts_at, e.contact_name, e.contact_email - FROM sufw_booking_registrations r JOIN sufw_booking_events e ON e.id = r.event_id - WHERE r.status = 'confirmed' AND DATE(e.starts_at) = DATE_ADD(UTC_DATE(), INTERVAL 2 DAY) - SQL)->fetchAll(); - foreach ($reminders as $reminder) { - $body = "Guten Tag {$reminder['name']},\n\ndies ist die Erinnerung an Ihren Termin \"{$reminder['title']}\" am {$reminder['starts_at']}.\n\nKontakt: {$reminder['contact_name']} ({$reminder['contact_email']})."; - bookingQueueMail($database, $reminder['id'], 'reminder', $reminder['email'], 'Erinnerung an Ihren Termin', $body); - } - return ['expired' => $expired, 'remindersConsidered' => count($reminders)]; -} - function bookingIcsEscape(string $value): string { return str_replace(["\\", ";", ",", "\n"], ["\\\\", "\\;", "\\,", "\\n"], $value); diff --git a/development-system/public/perspektivwerk/booking/booking.js b/development-system/public/perspektivwerk/booking/booking.js index da950f4..cf0fd87 100644 --- a/development-system/public/perspektivwerk/booking/booking.js +++ b/development-system/public/perspektivwerk/booking/booking.js @@ -89,7 +89,7 @@ function openConfirmationDialog(container, event) { const dialog = document.createElement('dialog'); dialog.className = 'sufw-booking__dialog'; dialog.setAttribute('aria-labelledby', 'sufw-booking-confirmation-title'); - dialog.innerHTML = `

E-Mail bestätigen

Fast geschafft

${escapeHtml(bookingConfirmationMessage(event.title))}

${escapeHtml(event.project === 'perspektivwerk' ? 'PerspektivWerk' : 'Produktionsschule')}
${escapeHtml(dateFormatter.format(parseDate(event.startsAt.slice(0, 10))))} · ${formatEventTime(event)} · ${escapeHtml(event.title)}

In der lokalen Demo liegt die Nachricht im Mailausgang des Verwaltungsbereichs bereit.

`; + dialog.innerHTML = `

E-Mail bestätigen

Fast geschafft

${escapeHtml(bookingConfirmationMessage(event.title))}

${escapeHtml(event.project === 'perspektivwerk' ? 'PerspektivWerk' : 'Produktionsschule')}
${escapeHtml(dateFormatter.format(parseDate(event.startsAt.slice(0, 10))))} · ${formatEventTime(event)} · ${escapeHtml(event.title)}

Bitte prüfen Sie Ihr E-Mail-Postfach und bestätigen Sie dort Ihre Anfrage.

`; dialog.querySelector('[data-close-confirmation]').addEventListener('click', () => dialog.close()); dialog.addEventListener('close', () => dialog.remove()); container.append(dialog); diff --git a/development-system/public/perspektivwerk/js/booking.js b/development-system/public/perspektivwerk/js/booking.js index da950f4..cf0fd87 100644 --- a/development-system/public/perspektivwerk/js/booking.js +++ b/development-system/public/perspektivwerk/js/booking.js @@ -89,7 +89,7 @@ function openConfirmationDialog(container, event) { const dialog = document.createElement('dialog'); dialog.className = 'sufw-booking__dialog'; dialog.setAttribute('aria-labelledby', 'sufw-booking-confirmation-title'); - dialog.innerHTML = `

E-Mail bestätigen

Fast geschafft

${escapeHtml(bookingConfirmationMessage(event.title))}

${escapeHtml(event.project === 'perspektivwerk' ? 'PerspektivWerk' : 'Produktionsschule')}
${escapeHtml(dateFormatter.format(parseDate(event.startsAt.slice(0, 10))))} · ${formatEventTime(event)} · ${escapeHtml(event.title)}

In der lokalen Demo liegt die Nachricht im Mailausgang des Verwaltungsbereichs bereit.

`; + dialog.innerHTML = `

E-Mail bestätigen

Fast geschafft

${escapeHtml(bookingConfirmationMessage(event.title))}

${escapeHtml(event.project === 'perspektivwerk' ? 'PerspektivWerk' : 'Produktionsschule')}
${escapeHtml(dateFormatter.format(parseDate(event.startsAt.slice(0, 10))))} · ${formatEventTime(event)} · ${escapeHtml(event.title)}

Bitte prüfen Sie Ihr E-Mail-Postfach und bestätigen Sie dort Ihre Anfrage.

`; dialog.querySelector('[data-close-confirmation]').addEventListener('click', () => dialog.close()); dialog.addEventListener('close', () => dialog.remove()); container.append(dialog); diff --git a/development-system/scripts/Deploy-Perspektivwerk.bat b/development-system/scripts/Deploy-Perspektivwerk.bat index 0677ba1..479d5f0 100644 --- a/development-system/scripts/Deploy-Perspektivwerk.bat +++ b/development-system/scripts/Deploy-Perspektivwerk.bat @@ -5,8 +5,13 @@ set "SOURCE=%~dp0..\public\perspektivwerk" set "TARGET=boogie:html/perspektivwerk/" set "CONFIG=%~dp0..\public\booking-config.php" +if not exist "%CONFIG%" ( + echo Konfiguration fehlt: %CONFIG% + exit /b 1 +) + echo Lade den Perspektivwerk-DocumentRoot und die Konfiguration nach %TARGET% hoch ... -scp -r "%SOURCE%" "%TARGET%" +scp -r "%SOURCE%" "%CONFIG%" "%TARGET%" if errorlevel 1 ( echo Upload fehlgeschlagen. @@ -21,4 +26,4 @@ if errorlevel 1 ( exit /b 1 ) -echo Upload abgeschlossen. \ No newline at end of file +echo Upload abgeschlossen. diff --git a/development-system/tests/booking-backend-config.test.php b/development-system/tests/booking-backend-config.test.php index 05b3e6d..1eab603 100644 --- a/development-system/tests/booking-backend-config.test.php +++ b/development-system/tests/booking-backend-config.test.php @@ -11,6 +11,11 @@ function expectSame(string $expected, string $actual, string $message): void } } +function expectContains(string $needle, string $haystack, string $message): void +{ + if (!str_contains($haystack, $needle)) throw new RuntimeException($message); +} + $configFile = tempnam(sys_get_temp_dir(), 'sufw-booking-config-'); if ($configFile === false) throw new RuntimeException('Temporary configuration file could not be created.'); @@ -27,6 +32,18 @@ try { bookingDatabaseUrl('mysql://environment:secret@database:3306/booking', $configFile), 'DATABASE_URL must take precedence over the configuration file.' ); + expectSame('disabled', bookingMailTransport('disabled'), 'Local mail delivery must be explicitly disabled.'); + + $calendar = bookingIcsCalendar([ + 'id' => '44c5d0f9-6c98-4b04-8d25-c32834d21a61', + 'project' => 'perspektivwerk', + 'title' => 'Erstgespräch PerspektivWerk', + 'starts_at' => '2026-09-14 09:00:00', + 'ends_at' => '2026-09-14 10:00:00', + 'format' => 'vor_ort', + ]); + expectContains('BEGIN:VCALENDAR', $calendar, 'The confirmation attachment must be an iCalendar document.'); + expectContains('UID:44c5d0f9-6c98-4b04-8d25-c32834d21a61@sufw-booking', $calendar, 'The iCalendar attachment must contain the event UID.'); } finally { unlink($configFile); } diff --git a/development-system/tests/booking-migrations.test.mjs b/development-system/tests/booking-migrations.test.mjs index aa5505f..6a776cb 100644 --- a/development-system/tests/booking-migrations.test.mjs +++ b/development-system/tests/booking-migrations.test.mjs @@ -33,6 +33,13 @@ test('modelliert Bestätigung, Mailausgang und ICS-Feeds als zweite Migration', assert.match(migration, /UNIQUE KEY `sufw_booking_outbox_registration_kind`/); }); +test('entfernt den Test-Mailausgang mit einer Folgemigration', async () => { + const migration = await readFile(new URL('../database/migrations/006_remove_booking_outbox.sql', import.meta.url), 'utf8'); + + assert.match(migration, /DROP TABLE IF EXISTS `sufw_booking_outbox`/); + assert.match(migration, /INSERT IGNORE INTO `sufw_booking_schema_migrations` \(`version`\) VALUES \('006_remove_booking_outbox'\)/); +}); + test('liefert lokale Demo-Termine und bestätigte Teilnahmen vor und nach dem heutigen Datum', async () => { const migration = await readFile(new URL('../database/migrations/003_booking_demo_data.sql', import.meta.url), 'utf8');