Implementiere echtes sendmail für den Server. Lokal gibts kein mail und keine outbox mehr
This commit is contained in:
1 parent
6c16e16354
commit
b58d96cd5e
11 files changed
+109
-51
No files matched your search
@@ -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');
|
||||
<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}}
|
||||
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; ?>
|
||||
<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>
|
||||
<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>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'): ?>
|
||||
<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'): ?>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = `<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.addEventListener('close', () => dialog.remove());
|
||||
container.append(dialog);
|
||||
|
||||
@@ -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 = `<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.addEventListener('close', () => dialog.remove());
|
||||
container.append(dialog);
|
||||
|
||||
Reference in new issue
Block a user