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

@@ -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);