From d5195e7f9f04fa5e4f6d640d5ec6ac8192489850 Mon Sep 17 00:00:00 2001 From: Clemens Creutzburg Date: Wed, 15 Jul 2026 16:13:38 +0200 Subject: [PATCH] M6: Mailversand als nachvollziehbaren Versandjob mit Dry-Run/Log mailversenden.php hatte keine Zugriffskontrolle, versendete bei jedem GET-Request sofort echte Mails und nutzte PHPMailer, dessen Quelldateien im Repo gar nicht vorhanden waren (nur composer.json/Lizenz) - der Aufruf waere also ohnehin mit Fatal Error abgebrochen. Zusaetzlich waren SMTP- Host, Absender, PayPal-Link und FAQ-URL fest auf einen Alt-Kunden (AOK) codiert. - Ersetzt PHPMailer durch die bestehende saas_send_mail()-Abstraktion aus M3 (Transport log/mail je nach APP_MAIL_TRANSPORT) statt eine fehlende Abhaengigkeit nachzuvendoren. - Neue Tabelle outbound_emails protokolliert jeden Versandversuch: Mandant, Mitglied, Vorlage, Betreff, Status, Fehler - das Versandlog. - Formular hat eine standardmaessig aktive Dry-Run-Checkbox; im Dry-Run wird nur geloggt, saas_send_mail() nicht aufgerufen. - Mailtext ist jetzt tenant-generisch (Saldo, optionaler PayPal-Link nur wenn der Mandant PayPal aktiviert hat, eigener Dashboard-Link) statt hartcodierter Alt-Kunden-Inhalte. - Zugriffskontrolle ergaenzt (owner/admin/treasurer + Legacy-Fallback). - http-smoke.php: mailversenden.php ist jetzt reguel --- app/saas-mail.php | 79 ++++++ .../migrations/0009_saas_outbound_emails.sql | 23 ++ docs/m6-import-export-mail.md | 54 ++++- docs/saas-umstrukturierungsplan.md | 9 +- mailversenden.php | 229 +++++++++--------- scripts/http-smoke.php | 9 +- 6 files changed, 286 insertions(+), 117 deletions(-) create mode 100644 database/migrations/0009_saas_outbound_emails.sql diff --git a/app/saas-mail.php b/app/saas-mail.php index 4080471..6e147a7 100644 --- a/app/saas-mail.php +++ b/app/saas-mail.php @@ -131,6 +131,85 @@ function saas_send_password_reset_mail(string $to, string $token): array return saas_send_mail($to, 'Kaffeeliste Passwort zurücksetzen', $body); } +/** + * Renders the tenant-generic balance notification body. Replaces the old + * mailversenden.php text, which hardcoded one customer's SMTP host, + * PayPal link and FAQ URL and therefore only ever worked for that one + * tenant. + */ +function saas_render_balance_mail_body(string $displayName, int $balanceCents, array $tenantSettings, string $dashboardUrl): string +{ + $amount = saas_format_money_cents(abs($balanceCents)) . ' €'; + $lines = ["Hallo {$displayName},", '']; + + if ($balanceCents < 0) { + $lines[] = "dein aktueller Kaffeelisten-Stand ist im Minus: {$amount}."; + $lines[] = "Bitte gleiche den Betrag bei Gelegenheit aus."; + if ((int)($tenantSettings['paypal_enabled'] ?? 0) === 1 && trim((string)($tenantSettings['paypal_url_template'] ?? '')) !== '') { + $paypalLink = trim((string)$tenantSettings['paypal_url_template']) . number_format(abs($balanceCents) / 100, 2, '.', ''); + $lines[] = ''; + $lines[] = "Direkt bezahlen: {$paypalLink}"; + } + } else { + $lines[] = "dein aktuelles Guthaben in der Kaffeeliste beträgt {$amount}."; + } + + $lines[] = ''; + $lines[] = "Deinen aktuellen Stand findest du hier: {$dashboardUrl}"; + $lines[] = ''; + $lines[] = 'Deine Kaffeeliste'; + + return implode("\n", $lines); +} + +function saas_log_outbound_email( + PDO $pdo, + int $tenantId, + ?int $participantId, + string $template, + string $subject, + string $status, + ?string $error, + ?int $createdByUserId +): int { + $stmt = $pdo->prepare( + 'INSERT INTO outbound_emails + (tenant_id, participant_id, template, subject, status, sent_at, error, created_by_user_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)' + ); + $stmt->execute([ + $tenantId, + $participantId, + $template, + $subject, + $status, + in_array($status, ['sent', 'failed'], true) ? date('Y-m-d H:i:s') : null, + $error, + $createdByUserId, + ]); + + return (int)$pdo->lastInsertId(); +} + +/** + * @return list + */ +function saas_fetch_outbound_email_log(PDO $pdo, int $tenantId, int $limit = 50): array +{ + $limit = max(1, min($limit, 200)); + $stmt = $pdo->prepare( + "SELECT o.id, o.participant_id, p.display_name, o.template, o.subject, o.status, o.sent_at, o.error, o.created_at + FROM outbound_emails o + LEFT JOIN participants p ON p.id = o.participant_id + WHERE o.tenant_id = ? + ORDER BY o.created_at DESC, o.id DESC + LIMIT {$limit}" + ); + $stmt->execute([$tenantId]); + + return $stmt->fetchAll(); +} + function saas_send_invite_mail(string $to, string $tenantName, string $role, string $token): array { $link = saas_app_url('passwort-zuruecksetzen.php?token=' . urlencode($token)); diff --git a/database/migrations/0009_saas_outbound_emails.sql b/database/migrations/0009_saas_outbound_emails.sql new file mode 100644 index 0000000..0a1982d --- /dev/null +++ b/database/migrations/0009_saas_outbound_emails.sql @@ -0,0 +1,23 @@ +CREATE TABLE IF NOT EXISTS outbound_emails ( + id INT AUTO_INCREMENT PRIMARY KEY, + tenant_id INT NOT NULL, + participant_id INT NULL, + template VARCHAR(60) NOT NULL, + subject VARCHAR(255) NOT NULL, + status VARCHAR(30) NOT NULL, + sent_at DATETIME NULL, + error VARCHAR(500) NULL, + created_by_user_id INT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_outbound_emails_tenant_created (tenant_id, created_at), + KEY idx_outbound_emails_participant (participant_id), + CONSTRAINT fk_outbound_emails_tenant + FOREIGN KEY (tenant_id) REFERENCES tenants(id) + ON DELETE CASCADE, + CONSTRAINT fk_outbound_emails_participant + FOREIGN KEY (participant_id) REFERENCES participants(id) + ON DELETE SET NULL, + CONSTRAINT fk_outbound_emails_created_by_user + FOREIGN KEY (created_by_user_id) REFERENCES users(id) + ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/docs/m6-import-export-mail.md b/docs/m6-import-export-mail.md index 5209ca8..a3a1b5f 100644 --- a/docs/m6-import-export-mail.md +++ b/docs/m6-import-export-mail.md @@ -99,10 +99,60 @@ Live getestet: gültiges zweiseitiges PDF (58 KB) mit korrektem stichprobenartig verifiziert (unkomprimierte Testausgabe), Vieltrinker-/ Wenigtrinker-Aufteilung stimmt mit den Ledger-Daten überein. +## Mailversand + +Umgesetzte Dateien: + +```text +database/migrations/0009_saas_outbound_emails.sql +app/saas-mail.php (Vorlage + Versandlog) +mailversenden.php +scripts/http-smoke.php +``` + +Ziel: Nachvollziehbarer Versandjob mit Dry-Run und Versandlog statt +sofortigem, ungeprüftem Versand. + +Umfang: + +- `mailversenden.php` hatte zuvor keine Zugriffskontrolle, versendete auf + jeden GET-Request sofort echte E-Mails und nutzte PHPMailer, dessen + Quelldateien im Repo gar nicht vorhanden sind (nur `composer.json` und + Lizenzdateien) – der Aufruf wäre also ohnehin mit einem Fatal Error + abgebrochen. Zusätzlich waren SMTP-Host, Absender, PayPal-Link und + FAQ-URL fest auf einen einzelnen Alt-Kunden (AOK) codiert. +- Statt PHPMailer zu vendoren, nutzt der Versand jetzt die in M3 bereits + gebaute Mail-Abstraktion (`saas_send_mail()`), die je nach + `APP_MAIL_TRANSPORT` entweder wirklich per `mail()` verschickt oder + (Standard im Dev-Modus) in `var/mail` protokolliert. Damit entfällt die + fehlende Abhängigkeit vollständig, und "Dry-Run" beziehungsweise + "Log" sind bereits strukturell dieselbe Mechanik. +- Neue Tabelle `outbound_emails` protokolliert jeden Versandversuch: + Mandant, Mitglied, Vorlage, Betreff, Status (`dry_run`/`sent`/`failed`), + Zeitpunkt, Fehlermeldung. Das ist das im Plan geforderte Versandlog. +- Echter zweistufiger Schutz: Das Formular hat eine standardmäßig aktive + "Dry-Run"-Checkbox. Im Dry-Run wird für jeden Empfänger ein Log-Eintrag + geschrieben, aber `saas_send_mail()` nicht aufgerufen. Erst mit + deaktivierter Checkbox wird wirklich versendet. +- Der Mailtext ist jetzt tenant-generisch (`saas_render_balance_mail_body()`): + Saldo, optionaler PayPal-Link nur wenn der Mandant PayPal aktiviert hat, + Link zum eigenen Dashboard über `saas_app_url()`. Keine hartcodierten + Alt-Kunden-Inhalte mehr. +- Zugriffskontrolle ergänzt (owner/admin/treasurer + Legacy-Fallback). +- `scripts/http-smoke.php`: `mailversenden.php` ist jetzt ein regulärer + Check statt eines übersprungenen unsicheren GET-Aufrufs, da GET keine + Seiteneffekte mehr hat. + +Live getestet: GET zeigt nur das Formular (kein Versand), Dry-Run +protokolliert alle aktiven Mitglieder ohne `var/mail`-Dateien zu erzeugen, +Live-Versand erzeugt für jeden Empfänger eine Log-Datei mit korrekt +personalisiertem Inhalt (Guthaben- und Schuldenfall geprüft), Versandlog in +der UI zeigt die Einträge. Testdaten anschließend entfernt. + ## Prüfstatus - Golden Master: grün mit 104 Assertions. - M4 Ledger-Migration: grün mit 73 Assertions. - M4 Ledger-Service: grün mit 115 Assertions. -- HTTP-Smoke: grün mit 24 geprüften Seiten (PDF-Export jetzt regulär statt - bekannter offener Punkt). +- HTTP-Smoke: grün mit 25 geprüften Seiten (PDF-Export und Mailversand + jetzt regulär statt bekannter offener Punkte). diff --git a/docs/saas-umstrukturierungsplan.md b/docs/saas-umstrukturierungsplan.md index d419d32..e7df204 100644 --- a/docs/saas-umstrukturierungsplan.md +++ b/docs/saas-umstrukturierungsplan.md @@ -306,7 +306,7 @@ Nicht tun: | M3 | SaaS-Basis | Abgeschlossen: Tenants, User, Registrierung, Login, Rollen, Mail-Links und zentrale Mandantenauswahl funktionieren | | M4 | Datenmigration | Gestartet: Ledger-Tabelle, Legacy-Backfill, Paritätscheck, Ledger-Service und Preview sind umgesetzt | | M5 | App-Kern | Weit fortgeschritten: Kernseiten lesen und schreiben tenant-sicher gegen das Ledger, inklusive Hinweise und rollenbasiertem Zugang; offen ist ein eigener Zahlungs-Screen | -| M6 | Betriebsflows | Gestartet: CSV-Import und PDF-Export stehen; Mail und Jahresprozesse offen | +| M6 | Betriebsflows | Weit fortgeschritten: CSV-Import, PDF-Export und Mailversand stehen; Jahresprozesse offen | | M7 | Landingpage | Public-Seite und Auth-Seiten sind im gemeinsamen Stil nutzbar; spätere Ausbaustufen folgen | | M8 | Härtung | Betrieb, Datenschutz, Monitoring und Isolation sind geprüft | | M9 | Cutover | Produktivumstellung ist vorbereitet und Legacy ist read-only | @@ -579,7 +579,12 @@ Schritte: Zugriffskontrolle; das ist behoben, die Seite liest jetzt tenant-sicher aus dem Ledger. - Mailversand als nachvollziehbaren Versandjob mit Dry-Run und Versandlog - gestalten. + gestalten: erledigt. `mailversenden.php` versendete zuvor ungeprüft bei + jedem GET-Request echte Mails über PHPMailer (dessen Quelldateien im Repo + gar nicht vorhanden waren) mit fest codierten Alt-Kunden-Inhalten (SMTP, + PayPal-Link, FAQ-URL). Ersetzt durch die bestehende `saas_send_mail()`- + Abstraktion aus M3, eine neue `outbound_emails`-Tabelle als Versandlog + und eine standardmäßig aktive Dry-Run-Option. - Jahresauswertung beziehungsweise Jahresbuchungen tenant-sicher abbilden. Ergebnis: diff --git a/mailversenden.php b/mailversenden.php index 3f2fad2..aa1c5b0 100644 --- a/mailversenden.php +++ b/mailversenden.php @@ -1,6 +1,9 @@
"; - - if($gesamtstand > 0){ - $body .= "hier ist dein aktueller negativer Kaffeekassenstand.
-Bitte zahle folgenden Betrag in die Kaffeekasse ein: {$stand} Euro.
-
Es kann nur noch per Paypal bezahlt werden.
-Dein Link: https://www.paypal.me/kaffeekassesb3/{$stand}
-Bezahle immer über die Freunde-Funktion von Paypal. Ansonsten stellen wir 20% des eingezahlten Betrags als Gebühr in Rechnung.
-"; - - }else{ - $body .= "hier ist dein aktuelles Guthaben in der Kaffeeliste: {$stand} Euro.
-
"; +if (!$hasAccess && $saasUser === null && checkKaffeelisteAdmin($conn, $mailadress)) { + $tenant = ledger_fetch_default_tenant($pdo); + if ($tenant !== null) { + $tenantId = (int)$tenant['id']; + $hasAccess = true; } - - $body .= " -Deinen aktuellen Stand findest du auch hier: https://kaffeeliste.arge.aok/
-
-Hast du Fragen oder Probleme? Dann lies immer zu erst unsere FAQs. Danke!
-
-Vielen Dank!
-Deine Kaffeekasse
-
-Dein Guthaben ist auf dem nächsten Ausdruck der Kaffeeliste einsehbar. Um die Umwelt zu schonen erfolgt ein neuer Ausdruck erst, wenn die Liste voll ist.
-"; - - - - // PHPMailer initialisieren - $mail = new PHPMailer(true); +} - try { - //Server settings - $mail->SMTPDebug = SMTP::DEBUG_OFF; // Disable debugging - $mail->Timeout = 120; - - $mail->isSMTP(); - $mail->Host = 'smtpv.aoknds.aok'; // SMTP server - - ## Hier wird absichtlich keine Authentifizierung genutzt. - - - #$mail->SMTPAuth = true; - #$mail->Username = ''; // SMTP username - #$mail->Password = ''; // SMTP password - #$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; // Enable TLS encryption; `PHPMailer::ENCRYPTION_SMTPS` also accepted - #$mail->Port = 587; // TCP port to connect to +if (!$hasAccess) { + echo "

Kein Zugriff

"; + include "footer.php"; + exit; +} - //Recipients - $mail->setFrom('kaffeelistesb3@nds.aok.de', 'Kaffeeliste ARGE'); - $mail->addAddress($email, $name); // Add a recipient +$ergebnisse = null; +$dryRun = true; - // Content - $mail->isHTML(true); // Set email format to HTML - $mail->Subject = "Kaffeeliste ARGE - Dein Stand"; - $mail->Body = utf8_decode($body); +if ($_SERVER["REQUEST_METHOD"] === "POST") { + $dryRun = !empty($_POST['dry_run']); + $settings = saas_fetch_tenant_settings($pdo, $tenantId); + $dashboardUrl = saas_app_url('index.php'); + $createdByUserId = $saasUser['user_id'] ?? null; - $mail->send(); - echo "E-Mail erfolgreich an $name gesendet.
"; - } catch (Exception $e) { - echo "E-Mail konnte nicht gesendet werden. Fehler: {$mail->ErrorInfo}
"; - } + $teilnehmer = ledger_fetch_participant_summaries($pdo, $tenantId, ['active_only' => true]); + $ergebnisse = []; - + foreach ($teilnehmer as $person) { + $email = trim((string)($person['email'] ?? '')); + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + $ergebnisse[] = ['name' => $person['display_name'], 'email' => $email, 'status' => 'ohne_email']; + continue; + } + + $subject = 'Kaffeeliste - Dein aktueller Stand'; + $body = saas_render_balance_mail_body( + $person['display_name'], + (int)$person['balance_cents'], + $settings, + $dashboardUrl + ); + + if ($dryRun) { + saas_log_outbound_email($pdo, $tenantId, $person['participant_id'], 'balance_notice', $subject, 'dry_run', null, $createdByUserId); + $ergebnisse[] = ['name' => $person['display_name'], 'email' => $email, 'status' => 'dry_run']; + continue; + } + + $sendResult = saas_send_mail($email, $subject, $body); + if ($sendResult['ok']) { + saas_log_outbound_email($pdo, $tenantId, $person['participant_id'], 'balance_notice', $subject, 'sent', null, $createdByUserId); + $ergebnisse[] = ['name' => $person['display_name'], 'email' => $email, 'status' => 'sent']; + } else { + saas_log_outbound_email($pdo, $tenantId, $person['participant_id'], 'balance_notice', $subject, 'failed', $sendResult['error'] ?? 'unbekannter Fehler', $createdByUserId); + $ergebnisse[] = ['name' => $person['display_name'], 'email' => $email, 'status' => 'failed']; + } + } +} + +$log = saas_fetch_outbound_email_log($pdo, $tenantId, 20); + +function mailversenden_status_label(string $status): string +{ + return match ($status) { + 'sent' => 'Versendet', + 'failed' => 'Fehlgeschlagen', + 'dry_run' => 'Dry-Run (nicht versendet)', + 'ohne_email' => 'Keine E-Mail-Adresse', + default => $status, + }; } ?> - +

Info-Mail versenden

+

Verschickt an alle aktiven Mitglieder eine E-Mail mit dem aktuellen +Kaffeelisten-Stand. Im Dry-Run wird nichts wirklich versendet, aber jeder +Empfänger wird im Versandlog unten protokolliert.

+ + +

Ergebnis dieses Laufs ()

+ + + + + + + + + +
NameE-MailStatus
+ + +
"> + +
+ + +
+ +
+ +

Versandlog (letzte 20)

+ + + + + + + + + + + + + + +
DatumMitgliedBetreffStatusFehler
Noch kein Versand protokolliert.
+ + - \ No newline at end of file + diff --git a/scripts/http-smoke.php b/scripts/http-smoke.php index e897d89..1f35677 100644 --- a/scripts/http-smoke.php +++ b/scripts/http-smoke.php @@ -137,16 +137,17 @@ $checks = [ 'path' => 'exportKaffeeliste.php', 'contains' => ['%PDF'], ], + [ + 'label' => 'Mailversand', + 'path' => 'mailversenden.php', + 'contains' => ['Info-Mail versenden', 'Dry-Run', 'Versandlog'], + ], ]; $knownIssueChecks = [ ]; $skippedUnsafe = [ - [ - 'path' => 'mailversenden.php', - 'reason' => 'GET kann bei vollständiger PHPMailer-Installation E-Mails versenden.', - ], [ 'path' => 'jahresauswertung.php', 'reason' => 'GET kann Jahresbuchungen schreiben und E-Mails versenden; PHPMailer-Abhängigkeit ist zudem unvollständig.',