Implementiere echtes sendmail für den Server. Lokal gibts kein mail und keine outbox mehr

This commit is contained in:
Robert Wartenberg committed 2026-08-31 14:27:19 +02:00
1 parent 6c16e16354
commit b58d96cd5e
11 files changed
+109 -51

No files matched your search

+7 -10
View File
@@ -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 Transaktion und sperrt den Termin dabei mit `SELECT … FOR UPDATE`; parallele
Anmeldungen können dadurch die Kapazität nicht überschreiten. Anmeldungen können dadurch die Kapazität nicht überschreiten.
Die Demo speichert eine Anfrage zunächst als `pending_confirmation`. Sie Die Demo speichert eine Anfrage zunächst als `pending_confirmation` und
erstellt einen zeitlich begrenzten Bestätigungslink, bestätigt danach atomar übergibt die Bestätigungsnachricht direkt an das Server-Sendmail. Der Link ist
gegen die verfügbare Kapazität und erzeugt eine zweite Bestätigungsnachricht. 30 Minuten gültig; sein Aufruf bestätigt die Teilnahme atomar und löst die
Bestätigungs- und Erinnerungsnachrichten liegen standardmäßig im lokalen zweite Nachricht mit ICS-Anhang aus. Ohne Cronjob werden keine Erinnerungen
Mailausgang der Testverwaltung. Mit `BOOKING_MAIL_TRANSPORT=mail` versucht der verschickt. Lokal ist der Mailtransport bewusst mit
Job den Versand über die PHP-Funktion `mail()`; SMTP-Konfiguration und echter `BOOKING_MAIL_TRANSPORT=disabled` deaktiviert.
Mailbetrieb gehören erst in die Produktiventscheidung.
Der Browser erhält ein CSRF-Token per gleicher Sitzung; die API validiert alle Der Browser erhält ein CSRF-Token per gleicher Sitzung; die API validiert alle
Pflichtdaten serverseitig und begrenzt Anmeldungen pro Sitzung. Dies ist eine 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, - Termin anlegen, bearbeiten sowie aktivieren/deaktivieren,
- Übersicht und Entfernen von Teilnahmen, - Übersicht und Entfernen von Teilnahmen,
- lokaler Mailausgang mit kopierbarem Bestätigungslink,
- Anzeige bestätigter Teilnahmen nach frei wählbarem Zeitraum, - Anzeige bestätigter Teilnahmen nach frei wählbarem Zeitraum,
- abonnierbaren ICS-Feed je Projekt, - abonnierbaren ICS-Feed je Projekt.
- manuellen Lauf der Ablauf- und Erinnerungsjobs.
Der Verwaltungsbereich hat absichtlich keine Benutzerverwaltung Der Verwaltungsbereich hat absichtlich keine Benutzerverwaltung
und darf nicht unverändert außerhalb der Testumgebung bereitgestellt werden. und darf nicht unverändert außerhalb der Testumgebung bereitgestellt werden.
+6 -3
View File
@@ -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 Betriebskonfiguration und niemals in die SQL-Datei oder das Repository. Die
erste Migration erstellt `sufw_booking_events` und erste Migration erstellt `sufw_booking_events` und
`sufw_booking_registrations` sowie neun ungefährliche Demo-Termine für beide `sufw_booking_registrations` sowie neun ungefährliche Demo-Termine für beide
Instanzen. `002_booking_workflow.sql` ergänzt Bestätigungstoken, lokalen Instanzen. `002_booking_workflow.sql` ergänzt Bestätigungstoken, den später
Mailausgang und pro Projekt einen beim Import zufällig erzeugten, entfernten Test-Mailausgang und pro Projekt einen beim Import zufällig
widerrufbaren ICS-Feed-Token. `003_booking_demo_data.sql` legt zusätzlich 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 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 der Vergangenheit und Zukunft. Diese letzte Datei ist ausschließlich für die
lokale Entwicklungs- und Testdatenbank bestimmt und darf nicht auf dem 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. Demo-Termine durch Carina Hollederer und Sarah van den Heuvel.
`005_booking_demo_july_august.sql` ergänzt ausschließlich lokal weitere `005_booking_demo_july_august.sql` ergänzt ausschließlich lokal weitere
bestätigte Demo-Termine und -Teilnahmen für Juli und August. 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.
@@ -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');
+1 -1
View File
@@ -10,7 +10,7 @@ services:
CONTAO_ENVIRONMENT: dev CONTAO_ENVIRONMENT: dev
DATABASE_URL: mysql://perspektivwerk:perspektivwerk@db:3306/perspektivwerk?serverVersion=mariadb-10.11.0 DATABASE_URL: mysql://perspektivwerk:perspektivwerk@db:3306/perspektivwerk?serverVersion=mariadb-10.11.0
BOOKING_BASE_URL: http://localhost:8080/perspektivwerk BOOKING_BASE_URL: http://localhost:8080/perspektivwerk
BOOKING_MAIL_TRANSPORT: database BOOKING_MAIL_TRANSPORT: disabled
command: command:
- sh - sh
- -c - -c
@@ -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'] ?? '']); $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.'); 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'); $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]); $statement->execute(['project' => $project]);
$registrations = $statement->fetchAll(); $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'); $nextMonth = new DateTimeImmutable('first day of next month');
$defaultFrom = $nextMonth->format('Y-m-01'); $defaultFrom = $nextMonth->format('Y-m-01');
$defaultTo = $nextMonth->format('Y-m-t'); $defaultTo = $nextMonth->format('Y-m-t');
@@ -116,7 +106,7 @@ $defaultTo = $nextMonth->format('Y-m-t');
<style> <style>
:root{--green:#08752a;--bg:#f3f6f3;--line:#cad2cb;--ink:#202020}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.45 Arial,sans-serif}.shell{max-width:1180px;margin:auto;padding:1.5rem}header{display:flex;gap:1rem;justify-content:space-between;align-items:start;flex-wrap:wrap}h1,h2{color:var(--green)}h1{margin:0}.warning,.notice{padding:.75rem 1rem;border-left:4px solid #a36a00;background:#fff8e7}.notice{border-color:var(--green);background:#eaf6ed}.tabs{display:flex;flex-wrap:wrap;gap:.5rem;margin:1rem 0}.tabs a,.button,button{display:inline-block;padding:.65rem .85rem;border:1px solid var(--green);background:#fff;color:var(--green);font:inherit;text-decoration:none;cursor:pointer}.tabs a.active,button.primary{background:var(--green);color:#fff}.grid{display:grid;gap:1.25rem;grid-template-columns:1.25fr .75fr;margin-top:1.25rem}.card{background:#fff;border:1px solid var(--line);padding:1rem;overflow:auto}table{border-collapse:collapse;width:100%;min-width:680px}th,td{padding:.6rem;border-bottom:1px solid var(--line);text-align:left;vertical-align:top}small{color:#555}.status{font-weight:bold}.confirmed{color:#08752a}.pending_confirmation{color:#a36a00}.removed,.expired{color:#8a1515}form.inline{display:inline}label{display:grid;gap:.25rem;font-weight:bold}input,select{font:inherit;padding:.55rem;border:1px solid #68736a;width:100%}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.8rem}.wide{grid-column:1/-1}pre{white-space:pre-wrap;max-width:42rem;font:13px/1.35 ui-monospace,monospace}.date-time-trigger{width:100%;min-height:2.75rem;text-align:left}.picker-dialog{width:min(25rem,calc(100% - 2rem));border:0;padding:0;box-shadow:0 .75rem 2rem #0006}.picker-dialog::backdrop{background:#0008}.picker-content{padding:1rem}.picker-head,.picker-actions{display:flex;justify-content:space-between;gap:.5rem;align-items:center}.picker-month{font-weight:bold;text-transform:capitalize}.picker-week,.picker-days{display:grid;grid-template-columns:repeat(7,1fr);gap:.25rem;text-align:center}.picker-week{margin:1rem 0 .25rem;font-size:.8rem;font-weight:bold}.picker-days button{min-height:2.4rem;padding:.25rem;border:1px solid transparent}.picker-days button.selected{background:var(--green);color:#fff}.picker-days button:disabled{visibility:hidden}.picker-time{display:grid;grid-template-columns:1fr 1fr;gap:.75rem;margin:1rem 0}.export-results{margin-top:1rem}.export-results li{margin:.5rem 0;white-space:nowrap;overflow-x:auto}@media(max-width:760px){.grid,.form-grid{grid-template-columns:1fr}.shell{padding:1rem}} :root{--green:#08752a;--bg:#f3f6f3;--line:#cad2cb;--ink:#202020}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.45 Arial,sans-serif}.shell{max-width:1180px;margin:auto;padding:1.5rem}header{display:flex;gap:1rem;justify-content:space-between;align-items:start;flex-wrap:wrap}h1,h2{color:var(--green)}h1{margin:0}.warning,.notice{padding:.75rem 1rem;border-left:4px solid #a36a00;background:#fff8e7}.notice{border-color:var(--green);background:#eaf6ed}.tabs{display:flex;flex-wrap:wrap;gap:.5rem;margin:1rem 0}.tabs a,.button,button{display:inline-block;padding:.65rem .85rem;border:1px solid var(--green);background:#fff;color:var(--green);font:inherit;text-decoration:none;cursor:pointer}.tabs a.active,button.primary{background:var(--green);color:#fff}.grid{display:grid;gap:1.25rem;grid-template-columns:1.25fr .75fr;margin-top:1.25rem}.card{background:#fff;border:1px solid var(--line);padding:1rem;overflow:auto}table{border-collapse:collapse;width:100%;min-width:680px}th,td{padding:.6rem;border-bottom:1px solid var(--line);text-align:left;vertical-align:top}small{color:#555}.status{font-weight:bold}.confirmed{color:#08752a}.pending_confirmation{color:#a36a00}.removed,.expired{color:#8a1515}form.inline{display:inline}label{display:grid;gap:.25rem;font-weight:bold}input,select{font:inherit;padding:.55rem;border:1px solid #68736a;width:100%}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.8rem}.wide{grid-column:1/-1}pre{white-space:pre-wrap;max-width:42rem;font:13px/1.35 ui-monospace,monospace}.date-time-trigger{width:100%;min-height:2.75rem;text-align:left}.picker-dialog{width:min(25rem,calc(100% - 2rem));border:0;padding:0;box-shadow:0 .75rem 2rem #0006}.picker-dialog::backdrop{background:#0008}.picker-content{padding:1rem}.picker-head,.picker-actions{display:flex;justify-content:space-between;gap:.5rem;align-items:center}.picker-month{font-weight:bold;text-transform:capitalize}.picker-week,.picker-days{display:grid;grid-template-columns:repeat(7,1fr);gap:.25rem;text-align:center}.picker-week{margin:1rem 0 .25rem;font-size:.8rem;font-weight:bold}.picker-days button{min-height:2.4rem;padding:.25rem;border:1px solid transparent}.picker-days button.selected{background:var(--green);color:#fff}.picker-days button:disabled{visibility:hidden}.picker-time{display:grid;grid-template-columns:1fr 1fr;gap:.75rem;margin:1rem 0}.export-results{margin-top:1rem}.export-results li{margin:.5rem 0;white-space:nowrap;overflow-x:auto}@media(max-width:760px){.grid,.form-grid{grid-template-columns:1fr}.shell{padding:1rem}}
select[data-dropdown-indicator]{appearance:none;padding-right:2.5rem;background:#fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='%2308752a' d='m3 6 5 5 5-5z'/%3E%3C/svg%3E") no-repeat right .75rem center/1rem}</style></head><body><main class="shell"> select[data-dropdown-indicator]{appearance:none;padding-right:2.5rem;background:#fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='%2308752a' d='m3 6 5 5 5-5z'/%3E%3C/svg%3E") no-repeat right .75rem center/1rem}</style></head><body><main class="shell">
<header><div><h1>Terminbuchung · Testverwaltung</h1></div><form method="post"><input type="hidden" name="csrf" value="<?=h($csrf)?>"><input type="hidden" name="project" value="<?=h($project)?>"><input type="hidden" name="action" value="run_jobs"><button>Jobs ausführen</button></form></header> <header><div><h1>Terminbuchung · Testverwaltung</h1></div></header>
<?php if ($notice !== ''): ?><p class="notice"><?=h($notice)?></p><?php endif; ?> <?php if ($notice !== ''): ?><p class="notice"><?=h($notice)?></p><?php endif; ?>
<nav class="tabs"><a class="<?= $view === 'events' && $project === 'perspektivwerk' ? 'active' : '' ?>" href="?project=perspektivwerk&view=events">PerspektivWerk</a><a class="<?= $view === 'events' && $project === 'produktionsschule' ? 'active' : '' ?>" href="?project=produktionsschule&view=events">Produktionsschule</a><a class="<?= $view === 'registrations' ? 'active' : '' ?>" href="?project=<?=h($project)?>&view=registrations">Teilnahmen</a><a class="<?= $view === 'export' ? 'active' : '' ?>" href="?project=<?=h($project)?>&view=export">Export</a></nav> <nav class="tabs"><a class="<?= $view === 'events' && $project === 'perspektivwerk' ? 'active' : '' ?>" href="?project=perspektivwerk&view=events">PerspektivWerk</a><a class="<?= $view === 'events' && $project === 'produktionsschule' ? 'active' : '' ?>" href="?project=produktionsschule&view=events">Produktionsschule</a><a class="<?= $view === 'registrations' ? 'active' : '' ?>" href="?project=<?=h($project)?>&view=registrations">Teilnahmen</a><a class="<?= $view === 'export' ? 'active' : '' ?>" href="?project=<?=h($project)?>&view=export">Export</a></nav>
@@ -130,7 +120,6 @@ select[data-dropdown-indicator]{appearance:none;padding-right:2.5rem;background:
<label class="wide"><span><input type="checkbox" name="introduction_required" <?=!empty($edit['introduction_required']) ? 'checked' : ''?>> Einführungsveranstaltung erforderlich (PS-Probearbeiten)</span></label></div><p><button class="primary">Termin speichern</button><?php if ($edit): ?> <a class="button" href="?project=<?=h($project)?>&view=events">Abbrechen</a><?php endif; ?></p></form></article> <label class="wide"><span><input type="checkbox" name="introduction_required" <?=!empty($edit['introduction_required']) ? 'checked' : ''?>> Einführungsveranstaltung erforderlich (PS-Probearbeiten)</span></label></div><p><button class="primary">Termin speichern</button><?php if ($edit): ?> <a class="button" href="?project=<?=h($project)?>&view=events">Abbrechen</a><?php endif; ?></p></form></article>
<aside class="card"><h2>Kalenderfeed</h2><p>Nur bestätigte Termine, ohne Teilnehmendendaten:</p><p><a href="<?=h(bookingBaseUrl().'/booking/ical.php?project='.$project.'&token='.$feedToken)?>">ICS-Feed öffnen</a></p></aside></section> <aside class="card"><h2>Kalenderfeed</h2><p>Nur bestätigte Termine, ohne Teilnehmendendaten:</p><p><a href="<?=h(bookingBaseUrl().'/booking/ical.php?project='.$project.'&token='.$feedToken)?>">ICS-Feed öffnen</a></p></aside></section>
<section class="card"><h2>Termine</h2><table><thead><tr><th>Termin</th><th>Zeit</th><th>Kapazität</th><th>Status</th><th></th></tr></thead><tbody><?php foreach ($events as $event): ?><tr><td><strong><?=h($event['title'])?></strong><br><small><?=h($event['format'])?><?=!empty($event['introduction_required']) ? ' · Einführung erforderlich' : ''?></small></td><td><?=h($event['starts_at'])?><br>bis <?=h($event['ends_at'])?></td><td><?=h($event['confirmed_count'])?> / <?=h($event['capacity'])?> bestätigt</td><td><?= $event['is_active'] ? 'Aktiv' : 'Inaktiv' ?></td><td><a class="button" href="?project=<?=h($project)?>&view=events&edit=<?=h($event['id'])?>">Bearbeiten</a> <form class="inline" method="post"><input type="hidden" name="csrf" value="<?=h($csrf)?>"><input type="hidden" name="project" value="<?=h($project)?>"><input type="hidden" name="action" value="toggle_event"><input type="hidden" name="id" value="<?=h($event['id'])?>"><button><?= $event['is_active'] ? 'Deaktivieren' : 'Aktivieren' ?></button></form></td></tr><?php endforeach; ?></tbody></table></section> <section class="card"><h2>Termine</h2><table><thead><tr><th>Termin</th><th>Zeit</th><th>Kapazität</th><th>Status</th><th></th></tr></thead><tbody><?php foreach ($events as $event): ?><tr><td><strong><?=h($event['title'])?></strong><br><small><?=h($event['format'])?><?=!empty($event['introduction_required']) ? ' · Einführung erforderlich' : ''?></small></td><td><?=h($event['starts_at'])?><br>bis <?=h($event['ends_at'])?></td><td><?=h($event['confirmed_count'])?> / <?=h($event['capacity'])?> bestätigt</td><td><?= $event['is_active'] ? 'Aktiv' : 'Inaktiv' ?></td><td><a class="button" href="?project=<?=h($project)?>&view=events&edit=<?=h($event['id'])?>">Bearbeiten</a> <form class="inline" method="post"><input type="hidden" name="csrf" value="<?=h($csrf)?>"><input type="hidden" name="project" value="<?=h($project)?>"><input type="hidden" name="action" value="toggle_event"><input type="hidden" name="id" value="<?=h($event['id'])?>"><button><?= $event['is_active'] ? 'Deaktivieren' : 'Aktivieren' ?></button></form></td></tr><?php endforeach; ?></tbody></table></section>
<section class="card"><h2>Lokaler Mailausgang</h2><?php foreach ($outbox as $mail): ?><article><strong><?=h($mail['kind'])?></strong> · <?=h($mail['recipient_email'])?><br><small><?=h($mail['subject'])?></small><pre><?=h($mail['body'])?></pre></article><?php endforeach; ?></section>
<?php elseif ($view === 'registrations'): ?> <?php elseif ($view === 'registrations'): ?>
<section class="card"><h2>Teilnahmen · <?=h(bookingProjectTitle($project))?></h2><table><thead><tr><th>Person</th><th>Termin</th><th>Status</th><th></th></tr></thead><tbody><?php foreach ($registrations as $registration): ?><tr><td><?=h($registration['name'])?><br><small><?=h($registration['email'])?><br><?=h($registration['phone'])?></small></td><td><?=h($registration['title'])?><br><small><?=h($registration['starts_at'])?></small></td><td class="status <?=h($registration['status'])?>"><?=h($registration['status'])?></td><td><?php if (in_array($registration['status'], ['pending_confirmation','confirmed'], true)): ?><form method="post"><input type="hidden" name="csrf" value="<?=h($csrf)?>"><input type="hidden" name="project" value="<?=h($project)?>"><input type="hidden" name="action" value="remove_registration"><input type="hidden" name="id" value="<?=h($registration['id'])?>"><button>Entfernen</button></form><?php endif; ?></td></tr><?php endforeach; ?></tbody></table></section> <section class="card"><h2>Teilnahmen · <?=h(bookingProjectTitle($project))?></h2><table><thead><tr><th>Person</th><th>Termin</th><th>Status</th><th></th></tr></thead><tbody><?php foreach ($registrations as $registration): ?><tr><td><?=h($registration['name'])?><br><small><?=h($registration['email'])?><br><?=h($registration['phone'])?></small></td><td><?=h($registration['title'])?><br><small><?=h($registration['starts_at'])?></small></td><td class="status <?=h($registration['status'])?>"><?=h($registration['status'])?></td><td><?php if (in_array($registration['status'], ['pending_confirmation','confirmed'], true)): ?><form method="post"><input type="hidden" name="csrf" value="<?=h($csrf)?>"><input type="hidden" name="project" value="<?=h($project)?>"><input type="hidden" name="action" value="remove_registration"><input type="hidden" name="id" value="<?=h($registration['id'])?>"><button>Entfernen</button></form><?php endif; ?></td></tr><?php endforeach; ?></tbody></table></section>
<?php elseif ($view === 'export'): ?> <?php elseif ($view === 'export'): ?>
@@ -55,10 +55,59 @@ function bookingProjectTitle(string $project): string
return $project === 'produktionsschule' ? 'Produktionsschule' : 'PerspektivWerk'; 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)'); $transport = $configuredTransport ?? getenv('BOOKING_MAIL_TRANSPORT');
$statement->execute(compact('registrationId', 'kind', 'email', 'subject', 'body')); 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 function bookingNormaliseEvent(array $event): array
@@ -132,8 +181,8 @@ function bookingCreateRegistration(PDO $database, array $payload): array
$link = bookingBaseUrl().'/booking/confirm.php?token='.rawurlencode($token); $link = bookingBaseUrl().'/booking/confirm.php?token='.rawurlencode($token);
$subject = 'Bitte bestätigen Sie Ihre Terminanfrage'; $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']})."; $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(); $database->commit();
bookingSendMail($email, $subject, $body);
return ['registrationId' => $registrationId, 'expiresAt' => gmdate('c', time() + 1800)]; return ['registrationId' => $registrationId, 'expiresAt' => gmdate('c', time() + 1800)];
} catch (Throwable $exception) { } catch (Throwable $exception) {
if ($database->inTransaction()) $database->rollBack(); if ($database->inTransaction()) $database->rollBack();
@@ -147,7 +196,7 @@ function bookingConfirmRegistration(PDO $database, string $token): array
$database->beginTransaction(); $database->beginTransaction();
try { try {
$statement = $database->prepare(<<<'SQL' $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 FROM sufw_booking_registrations r JOIN sufw_booking_events e ON e.id = r.event_id
WHERE r.confirmation_token_hash = :tokenHash FOR UPDATE WHERE r.confirmation_token_hash = :tokenHash FOR UPDATE
SQL); SQL);
@@ -170,8 +219,8 @@ function bookingConfirmRegistration(PDO $database, string $token): array
$history->execute(['email' => $registration['email'], 'name' => $registration['name']]); $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())); $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']})."; $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(); $database->commit();
bookingSendMail($registration['email'], 'Ihr Termin ist bestätigt', $body, bookingIcsCalendar($registration));
return $registration; return $registration;
} catch (Throwable $exception) { } catch (Throwable $exception) {
if ($database->inTransaction()) $database->rollBack(); 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 function bookingIcsEscape(string $value): string
{ {
return str_replace(["\\", ";", ",", "\n"], ["\\\\", "\\;", "\\,", "\\n"], $value); return str_replace(["\\", ";", ",", "\n"], ["\\\\", "\\;", "\\,", "\\n"], $value);
@@ -89,7 +89,7 @@ function openConfirmationDialog(container, event) {
const dialog = document.createElement('dialog'); const dialog = document.createElement('dialog');
dialog.className = 'sufw-booking__dialog'; dialog.className = 'sufw-booking__dialog';
dialog.setAttribute('aria-labelledby', 'sufw-booking-confirmation-title'); dialog.setAttribute('aria-labelledby', 'sufw-booking-confirmation-title');
dialog.innerHTML = `<section class="sufw-booking__form"><header class="sufw-booking__form-header"><p class="sufw-booking__eyebrow">E-Mail bestätigen</p><h2 id="sufw-booking-confirmation-title">Fast geschafft</h2><p>${escapeHtml(bookingConfirmationMessage(event.title))}</p></header><p class="sufw-booking__appointment"><strong>${escapeHtml(event.project === 'perspektivwerk' ? 'PerspektivWerk' : 'Produktionsschule')}</strong><br>${escapeHtml(dateFormatter.format(parseDate(event.startsAt.slice(0, 10))))} · ${formatEventTime(event)} · ${escapeHtml(event.title)}</p><p>In der lokalen Demo liegt die Nachricht im Mailausgang des Verwaltungsbereichs bereit.</p><div class="sufw-booking__form-actions"><button type="button" data-close-confirmation>Schließen</button></div></section>`; dialog.innerHTML = `<section class="sufw-booking__form"><header class="sufw-booking__form-header"><p class="sufw-booking__eyebrow">E-Mail bestätigen</p><h2 id="sufw-booking-confirmation-title">Fast geschafft</h2><p>${escapeHtml(bookingConfirmationMessage(event.title))}</p></header><p class="sufw-booking__appointment"><strong>${escapeHtml(event.project === 'perspektivwerk' ? 'PerspektivWerk' : 'Produktionsschule')}</strong><br>${escapeHtml(dateFormatter.format(parseDate(event.startsAt.slice(0, 10))))} · ${formatEventTime(event)} · ${escapeHtml(event.title)}</p><p>Bitte prüfen Sie Ihr E-Mail-Postfach und bestätigen Sie dort Ihre Anfrage.</p><div class="sufw-booking__form-actions"><button type="button" data-close-confirmation>Schließen</button></div></section>`;
dialog.querySelector('[data-close-confirmation]').addEventListener('click', () => dialog.close()); dialog.querySelector('[data-close-confirmation]').addEventListener('click', () => dialog.close());
dialog.addEventListener('close', () => dialog.remove()); dialog.addEventListener('close', () => dialog.remove());
container.append(dialog); container.append(dialog);
@@ -89,7 +89,7 @@ function openConfirmationDialog(container, event) {
const dialog = document.createElement('dialog'); const dialog = document.createElement('dialog');
dialog.className = 'sufw-booking__dialog'; dialog.className = 'sufw-booking__dialog';
dialog.setAttribute('aria-labelledby', 'sufw-booking-confirmation-title'); dialog.setAttribute('aria-labelledby', 'sufw-booking-confirmation-title');
dialog.innerHTML = `<section class="sufw-booking__form"><header class="sufw-booking__form-header"><p class="sufw-booking__eyebrow">E-Mail bestätigen</p><h2 id="sufw-booking-confirmation-title">Fast geschafft</h2><p>${escapeHtml(bookingConfirmationMessage(event.title))}</p></header><p class="sufw-booking__appointment"><strong>${escapeHtml(event.project === 'perspektivwerk' ? 'PerspektivWerk' : 'Produktionsschule')}</strong><br>${escapeHtml(dateFormatter.format(parseDate(event.startsAt.slice(0, 10))))} · ${formatEventTime(event)} · ${escapeHtml(event.title)}</p><p>In der lokalen Demo liegt die Nachricht im Mailausgang des Verwaltungsbereichs bereit.</p><div class="sufw-booking__form-actions"><button type="button" data-close-confirmation>Schließen</button></div></section>`; dialog.innerHTML = `<section class="sufw-booking__form"><header class="sufw-booking__form-header"><p class="sufw-booking__eyebrow">E-Mail bestätigen</p><h2 id="sufw-booking-confirmation-title">Fast geschafft</h2><p>${escapeHtml(bookingConfirmationMessage(event.title))}</p></header><p class="sufw-booking__appointment"><strong>${escapeHtml(event.project === 'perspektivwerk' ? 'PerspektivWerk' : 'Produktionsschule')}</strong><br>${escapeHtml(dateFormatter.format(parseDate(event.startsAt.slice(0, 10))))} · ${formatEventTime(event)} · ${escapeHtml(event.title)}</p><p>Bitte prüfen Sie Ihr E-Mail-Postfach und bestätigen Sie dort Ihre Anfrage.</p><div class="sufw-booking__form-actions"><button type="button" data-close-confirmation>Schließen</button></div></section>`;
dialog.querySelector('[data-close-confirmation]').addEventListener('click', () => dialog.close()); dialog.querySelector('[data-close-confirmation]').addEventListener('click', () => dialog.close());
dialog.addEventListener('close', () => dialog.remove()); dialog.addEventListener('close', () => dialog.remove());
container.append(dialog); container.append(dialog);
@@ -5,8 +5,13 @@ set "SOURCE=%~dp0..\public\perspektivwerk"
set "TARGET=boogie:html/perspektivwerk/" set "TARGET=boogie:html/perspektivwerk/"
set "CONFIG=%~dp0..\public\booking-config.php" 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 ... echo Lade den Perspektivwerk-DocumentRoot und die Konfiguration nach %TARGET% hoch ...
scp -r "%SOURCE%" "%TARGET%" scp -r "%SOURCE%" "%CONFIG%" "%TARGET%"
if errorlevel 1 ( if errorlevel 1 (
echo Upload fehlgeschlagen. echo Upload fehlgeschlagen.
@@ -21,4 +26,4 @@ if errorlevel 1 (
exit /b 1 exit /b 1
) )
echo Upload abgeschlossen. echo Upload abgeschlossen.
@@ -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-'); $configFile = tempnam(sys_get_temp_dir(), 'sufw-booking-config-');
if ($configFile === false) throw new RuntimeException('Temporary configuration file could not be created.'); 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), bookingDatabaseUrl('mysql://environment:secret@database:3306/booking', $configFile),
'DATABASE_URL must take precedence over the configuration file.' '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 { } finally {
unlink($configFile); unlink($configFile);
} }
@@ -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`/); 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 () => { 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'); const migration = await readFile(new URL('../database/migrations/003_booking_demo_data.sql', import.meta.url), 'utf8');