M3 Mailversand und Public-Landingpage vorbereiten

- Mail-Transport fuer Reset- und Verifizierungslinks ergaenzen
- Public-Landingpage mit Hero-Asset und App-CTAs bauen
- Mail-Flow-Test, Smoke-Abdeckung und SaaS-Doku aktualisieren
This commit is contained in:
2026-07-13 20:38:01 +02:00
parent 9f2e1bdfb1
commit b0e21bbb0a
12 changed files with 462 additions and 20 deletions
+151
View File
@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/bootstrap.php';
function saas_mail_transport(): string
{
$default = app_is_dev() ? 'log' : 'mail';
return strtolower((string)app_env('APP_MAIL_TRANSPORT', $default));
}
function saas_mail_from(): string
{
return (string)app_env('APP_MAIL_FROM', 'noreply@kaffeeliste.local');
}
function saas_mail_from_name(): string
{
return (string)app_env('APP_MAIL_FROM_NAME', 'Kaffeeliste');
}
function saas_app_base_url(): string
{
$configured = app_env('APP_BASE_URL');
if ($configured !== null) {
return rtrim($configured, '/');
}
if (PHP_SAPI !== 'cli' && !empty($_SERVER['HTTP_HOST'])) {
$scheme = app_is_https() ? 'https' : 'http';
return $scheme . '://' . rtrim((string)$_SERVER['HTTP_HOST'], '/');
}
return 'http://localhost';
}
function saas_app_url(string $path): string
{
return saas_app_base_url() . '/' . ltrim($path, '/');
}
function saas_mail_headers(): string
{
$fromName = str_replace(["\r", "\n"], '', saas_mail_from_name());
$from = str_replace(["\r", "\n"], '', saas_mail_from());
return implode("\r\n", [
"From: {$fromName} <{$from}>",
"Reply-To: {$from}",
'Content-Type: text/plain; charset=UTF-8',
]);
}
function saas_log_mail(string $to, string $subject, string $body): array
{
$logDir = (string)app_env('APP_MAIL_LOG_DIR', APP_ROOT . '/var/mail');
if (!is_dir($logDir)) {
@mkdir($logDir, 0700, true);
}
if (!is_dir($logDir) || !is_writable($logDir)) {
return [
'ok' => false,
'transport' => 'log',
'error' => 'mail log directory is not writable',
];
}
$file = rtrim($logDir, '/') . '/mail_' . date('Ymd_His') . '_' . bin2hex(random_bytes(6)) . '.txt';
$content = "To: {$to}\nSubject: {$subject}\n\n{$body}\n";
if (file_put_contents($file, $content) === false) {
return [
'ok' => false,
'transport' => 'log',
'error' => 'mail log write failed',
];
}
return [
'ok' => true,
'transport' => 'log',
'path' => $file,
];
}
function saas_send_mail(string $to, string $subject, string $body): array
{
$to = trim($to);
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) {
return [
'ok' => false,
'transport' => saas_mail_transport(),
'error' => 'invalid recipient',
];
}
if (saas_mail_transport() === 'log') {
return saas_log_mail($to, $subject, $body);
}
$sent = mail($to, $subject, $body, saas_mail_headers());
return [
'ok' => $sent,
'transport' => 'mail',
'error' => $sent ? null : 'mail transport failed',
];
}
function saas_send_password_reset_mail(string $to, string $token): array
{
$link = saas_app_url('passwort-zuruecksetzen.php?token=' . urlencode($token));
$body = implode("\n", [
'Hallo,',
'',
'fuer dein Kaffeeliste-Konto wurde ein Passwort-Reset angefordert.',
'Oeffne diesen Link, um ein neues Passwort zu setzen:',
'',
$link,
'',
'Der Link ist zeitlich begrenzt und kann nur einmal verwendet werden.',
'Falls du den Reset nicht angefordert hast, kannst du diese Nachricht ignorieren.',
'',
'Deine Kaffeeliste',
]);
return saas_send_mail($to, 'Kaffeeliste Passwort zuruecksetzen', $body);
}
function saas_send_email_verification_mail(string $to, string $token): array
{
$link = saas_app_url('email-verifizieren.php?token=' . urlencode($token));
$body = implode("\n", [
'Hallo,',
'',
'bitte bestaetige deine E-Mail-Adresse fuer Kaffeeliste.',
'Oeffne dazu diesen Link:',
'',
$link,
'',
'Der Link ist zeitlich begrenzt und kann nur einmal verwendet werden.',
'',
'Deine Kaffeeliste',
]);
return saas_send_mail($to, 'Kaffeeliste E-Mail bestaetigen', $body);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

+10
View File
@@ -26,6 +26,12 @@ export DEV_AUTH_EMAIL="admin@test.local"
export DEV_AUTH_NAME="Test Admin"
# Optional: Standard ist var/sessions im Repository.
export APP_SESSION_PATH="/path/to/writable/sessions"
# Optional fuer Passwort-Reset und E-Mail-Verifikation.
export APP_BASE_URL="http://127.0.0.1:8080"
export APP_MAIL_TRANSPORT="log"
export APP_MAIL_LOG_DIR="/path/to/writable/mail-log"
export APP_MAIL_FROM="noreply@example.test"
export APP_MAIL_FROM_NAME="Kaffeeliste"
```
## Testdatenbank initialisieren
@@ -63,6 +69,10 @@ der Port ueber die Ports-Ansicht weitergeleitet werden.
## Hinweise
- Der Dev-Login umgeht AD/LDAP und nutzt `DEV_AUTH_EMAIL`.
- Der Dev-Mailversand nutzt standardmaessig `APP_MAIL_TRANSPORT=log` und legt
Nachrichten unter `var/mail` ab. Fuer Produktion kann zunaechst
`APP_MAIL_TRANSPORT=mail` mit passendem `APP_BASE_URL` und Absender gesetzt
werden; eine SMTP-/Provider-Anbindung bleibt ein spaeterer Betriebsausbau.
- Die Tabellen werden ueber `database/migrations/` angelegt.
- `database/mysql-dev-schema.sql` bleibt als historische Dev-Schema-Baseline
erhalten; der aktive Weg ist `scripts/migrate.php`.
+2 -2
View File
@@ -1,6 +1,6 @@
# M2 Technisches Fundament
Stand: 2026-07-12
Stand: 2026-07-13
M2 fuehrt die technischen Grundbausteine fuer den SaaS-Umbau ein, ohne das
Legacy-Verhalten oder das bestehende Design zu veraendern.
@@ -140,7 +140,7 @@ Die Skripte erwarten die bekannten Dev-Umgebungsvariablen `DB_HOST`, `DB_NAME`,
eine PayPal-Alias-Dublette und hinterlaesst keine Datei in `var/uploads`.
- CSV-Upload negative Tests: Nicht-CSV-Dateien werden abgewiesen.
- Golden Master weiterhin gruen mit 104 Assertions.
- HTTP-Smoke weiterhin gruen mit 21 sicheren Seiten inklusive Login,
- HTTP-Smoke weiterhin gruen mit 22 sicheren Seiten inklusive Landingpage, Login,
Registrierung, Passwort-Reset, E-Mail-Verifikation und geschuetzter
Mandant-Einstellungen sowie geschuetzter Mandantenauswahl.
+34 -5
View File
@@ -1,6 +1,6 @@
# M3 SaaS-Basis Vorbereitung
Stand: 2026-07-12
Stand: 2026-07-13
M3 fuehrt Mandanten, Benutzer, Mitgliedschaften und Grundeinstellungen additiv
ein. Die bestehende Legacy-App bleibt dabei lauffaehig und wird noch nicht auf
@@ -53,6 +53,7 @@ scripts/check-m3-auth-flow.php
scripts/check-m3-settings-flow.php
scripts/check-m3-password-email-flow.php
scripts/check-m3-tenant-resolution-flow.php
scripts/check-m3-mail-flow.php
```
Wichtige Regeln:
@@ -122,6 +123,7 @@ Umgesetzte Dateien:
```text
app/database.php
app/saas-auth.php
app/saas-mail.php
login.php
register.php
konto.php
@@ -131,6 +133,8 @@ passwort-vergessen.php
passwort-zuruecksetzen.php
email-verifikation-senden.php
email-verifizieren.php
landing.php
assets/images/landing-hero.png
```
Umgesetzter Umfang:
@@ -151,13 +155,33 @@ Umgesetzter Umfang:
- Passwort-Reset erzeugt Single-Use-Tokens und speichert nur Token-Hashes.
- E-Mail-Verifikation erzeugt Single-Use-Tokens und setzt
`users.email_verified_at`.
- Bis zum produktiven Mailversand werden Links nur im Dev-Modus angezeigt
beziehungsweise in CLI-Checks direkt verwendet.
- Reset- und Verifikationslinks werden ueber `app/saas-mail.php` versendet.
Der Dev-Standard schreibt Mails nach `var/mail`; produktiv kann auf PHP
`mail()` umgestellt werden.
- Dev-Links werden weiterhin nur im Dev-Modus angezeigt, damit lokale Checks
ohne echten Postausgang reproduzierbar bleiben.
Noch offen im M3-Auth-Scope:
- Weitergehende Rollenmatrix fuer spaetere SaaS-Seiten.
- Produktiver Mailversand fuer Reset- und Verifikationslinks.
- Produktive SMTP-/Provider-Anbindung inklusive Bounce-/Fehlerprotokoll.
## Public-Landingpage
Umgesetzte Dateien:
```text
landing.php
assets/images/landing-hero.png
```
Umgesetzter Umfang:
- Oeffentliche Landingpage ohne Legacy-DB-Zugriff.
- Hero mit generiertem Bild-Asset, bestehender Typografie und gruenem Akzent.
- CTA zu Login und Registrierung.
- Kurzabschnitt zu Stricherfassung, Mandantenfaehigkeit und Webspace-Betrieb.
- Die geschuetzte App-Sidebar bleibt von der Public-Seite getrennt.
## Tenant-Aufloesung
@@ -212,7 +236,10 @@ Umgesetzter Umfang:
- Owner-/Admin-Grundeinstellungen koennen aktualisiert werden: erfuellt.
- Passwort-Reset und E-Mail-Verifikation funktionieren mit Single-Use-Tokens:
erfuellt.
- Reset- und Verifikationslinks werden im Dev-/Testmodus als Mail-Log erzeugt:
erfuellt.
- Mandantenauswahl funktioniert fuer User mit mehreren Mandanten: erfuellt.
- Oeffentliche Landingpage ist per HTTP-Smoke erreichbar: erfuellt.
- Golden-Master und HTTP-Smoke bleiben gruen: erfuellt.
## Risiken
@@ -243,5 +270,7 @@ Umgesetzter Umfang:
14. Mandantenauswahl und zentrale Session-Aufloesung bauen: erledigt.
15. Tenant-Resolution-Kontrollskript schreiben: erledigt.
16. Golden-Master und HTTP-Smoke ausfuehren: erledigt.
17. Produktiven Mailversand oder Landingpage vorbereiten:
17. Mail-Transport-Abstraktion und Mail-Flow-Kontrollskript bauen: erledigt.
18. Public-Landingpage mit erstem Hero-Asset vorbereiten: erledigt.
19. M3-Abschlusscheck dokumentieren und M4-Datenmigration starten:
naechster Schritt.
+17 -13
View File
@@ -1,6 +1,6 @@
# SaaS-Umstrukturierungsplan Kaffeeliste
Stand: 2026-07-11
Stand: 2026-07-13
Dieses Dokument beschreibt den geplanten Umbau der bestehenden Kaffeelisten-App zu
einer mehrkundenfaehigen SaaS-Anwendung mit oeffentlicher Landingpage,
@@ -283,11 +283,11 @@ Uebersicht:
| M0 | Baseline | Bestand, Sicherheit und Designreferenz sind dokumentiert |
| M1 | Golden Master | Legacy-Ergebnisse sind als Vergleichsbasis eingefroren; HTTP-Smoke prueft sichere Seiten |
| M2 | Technisches Fundament | Abgeschlossen: Migrationen, Bootstrap, Session, CSRF-Helper und Legacy-Schreibseitenschutz stehen |
| M3 | SaaS-Basis | Tenants, User, Registrierung, Login und Rollen funktionieren |
| M3 | SaaS-Basis | Tenants, User, Registrierung, Login, Rollen, Mail-Links und zentrale Mandantenauswahl funktionieren |
| M4 | Datenmigration | Legacy-Daten sind tenant-sicher im Zielmodell abgebildet |
| M5 | App-Kern | Dashboard, Striche, Einzahlungen, Mitglieder und Liste laufen |
| M6 | Betriebsflows | Import, Export, Mail und Jahresprozesse sind auditierbar |
| M7 | Landingpage | Werbliche Seite und Registrierung sind oeffentlich nutzbar |
| M7 | Landingpage | Erste werbliche Seite ist oeffentlich nutzbar; spaetere Ausbaustufen folgen |
| M8 | Haertung | Betrieb, Datenschutz, Monitoring und Isolation sind geprueft |
| M9 | Cutover | Produktivumstellung ist vorbereitet und Legacy ist read-only |
@@ -421,16 +421,19 @@ Schritte:
Flow erledigt.
- Login und Logout bauen: erster Flow erledigt.
- Passwort-Reset und E-Mail-Verifikation bauen: Dev-Flow mit Single-Use-Tokens
erledigt.
und Mail-Transport-Abstraktion erledigt.
- Tenant-Aufloesung definieren: zentrale App-Domain mit Session-Kontext und
Mandantenauswahl erledigt; feste Domains vorbereitet, keine Wildcards.
- Rollenpruefung zentralisieren: erster Owner/Admin-Check erledigt.
- Erste Admin-/Owner-Seite fuer Grundeinstellungen: erledigt.
- Erste Public-Landingpage mit CTA zu Login und Registrierung: erledigt.
Ergebnis:
- Ein neuer Kunde kann sich registrieren und in seine eigene App gelangen.
- Rollen und Tenant-Kontext sind serverseitig verfuegbar.
- Reset- und Verifikationslinks koennen im Dev-Modus geloggt und produktiv ueber
einen konfigurierten Mail-Transport versendet werden.
Abhaengigkeiten:
@@ -515,10 +518,11 @@ zu verwischen.
Schritte:
- Public-Layout mit bestehender Typografie und gruenem Akzent bauen.
- Landingpage-Inhalte erstellen.
- Public-Layout mit bestehender Typografie und gruenem Akzent bauen. Erster
Stand als `landing.php` erledigt.
- Landingpage-Inhalte erstellen. Erster Stand erledigt.
- Demo-Screenshot oder Demo-Ansicht einbinden.
- CTA zu Registrierung und Login.
- CTA zu Registrierung und Login. Erledigt.
- FAQ-Auszug strukturieren.
- Keine App-Sidebar im Public-Bereich.
@@ -588,12 +592,12 @@ umgebaut werden.
MVP-Paket:
1. Tenant, User, Login, Registrierung.
2. Teilnehmer, Settings, Ledger.
3. Dashboard, Striche, Einzahlungen, Gesamtuebersicht.
4. Mitgliederverwaltung.
5. Hinweise.
6. CSV-Import, Export.
7. Landingpage.
2. Mail-Links, zentrale Mandantenauswahl und erste Landingpage.
3. Teilnehmer, Settings, Ledger.
4. Dashboard, Striche, Einzahlungen, Gesamtuebersicht.
5. Mitgliederverwaltung.
6. Hinweise.
7. CSV-Import, Export.
Spaeter:
+4
View File
@@ -1,6 +1,7 @@
<?php
require_once __DIR__ . '/functions.php';
require_once __DIR__ . '/app/saas-mail.php';
$pdo = app_db_pdo();
$user = saas_require_login();
@@ -20,6 +21,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (saas_should_show_auth_links() && !empty($result['token'])) {
$verificationLink = 'email-verifizieren.php?token=' . urlencode((string)$result['token']);
}
if (!empty($result['token'])) {
saas_send_email_verification_mail((string)$user['email'], (string)$result['token']);
}
}
} else {
$errors = $result['errors'];
+163
View File
@@ -0,0 +1,163 @@
<!DOCTYPE HTML>
<html lang="de">
<head>
<title>Kaffeeliste</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
<link rel="stylesheet" href="assets/css/main.css" />
<style>
body {
background: #ffffff;
}
.landing-shell {
min-height: 100vh;
}
.landing-hero {
min-height: 82vh;
background-image: url("assets/images/landing-hero.png");
background-size: cover;
background-position: center right;
position: relative;
display: flex;
align-items: center;
padding: 4rem 6vw;
}
.landing-hero:before {
content: "";
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.68);
}
.landing-hero-content {
position: relative;
max-width: 42rem;
}
.landing-hero h1 {
margin-bottom: 0.35em;
}
.landing-hero p {
color: #3d4449;
font-size: 1.15em;
max-width: 34rem;
}
.landing-nav {
position: absolute;
top: 1.5rem;
left: 6vw;
right: 6vw;
z-index: 2;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.landing-nav strong {
color: #3d4449;
font-family: "Roboto Slab", serif;
font-size: 1.1em;
}
.landing-section {
padding: 4rem 6vw;
}
.landing-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1.25rem;
max-width: 72rem;
}
.landing-feature {
border: solid 1px rgba(210, 215, 217, 0.75);
border-radius: 8px;
padding: 1.5rem;
background: #ffffff;
}
.landing-feature h3 {
margin-bottom: 0.5rem;
}
.landing-feature p {
margin-bottom: 0;
}
@media screen and (max-width: 980px) {
.landing-hero {
min-height: 78vh;
padding: 5.5rem 2rem 3rem 2rem;
background-position: center right 30%;
}
.landing-nav {
left: 2rem;
right: 2rem;
}
.landing-grid {
grid-template-columns: 1fr;
}
.landing-section {
padding: 3rem 2rem;
}
}
</style>
</head>
<body class="is-preload">
<div class="landing-shell">
<section class="landing-hero">
<nav class="landing-nav" aria-label="Hauptnavigation">
<strong>Kaffeeliste</strong>
<ul class="actions">
<li><a href="login.php" class="button">Login</a></li>
<li><a href="register.php" class="button primary">Registrieren</a></li>
</ul>
</nav>
<div class="landing-hero-content">
<h1>Kaffeeliste</h1>
<p>Die digitale Kaffeekasse fuer Teams, Bueros und Vereine: Striche, Einzahlungen und offene Betraege bleiben nachvollziehbar an einem Ort.</p>
<ul class="actions">
<li><a href="register.php" class="button primary big">Kundenkonto anlegen</a></li>
<li><a href="login.php" class="button big">Zur App</a></li>
</ul>
</div>
</section>
<section class="landing-section">
<header class="major">
<h2>Alles rund um die Kaffeekasse</h2>
</header>
<div class="landing-grid">
<article class="landing-feature">
<h3>Striche erfassen</h3>
<p>Teilnehmer und Admins behalten Verbrauch, Einzahlungen und Salden im Blick.</p>
</article>
<article class="landing-feature">
<h3>Mandantenfaehig</h3>
<p>Jeder Kunde arbeitet in seinem eigenen Bereich mit eigenen Einstellungen.</p>
</article>
<article class="landing-feature">
<h3>Webspace-tauglich</h3>
<p>Der Start erfolgt ueber eine zentrale App-Adresse mit Login und Mandantenauswahl.</p>
</article>
</div>
</section>
</div>
<script src="assets/js/jquery.min.js"></script>
<script src="assets/js/browser.min.js"></script>
<script src="assets/js/breakpoints.min.js"></script>
<script src="assets/js/util.js"></script>
<script src="assets/js/main.js"></script>
</body>
</html>
+4
View File
@@ -1,6 +1,7 @@
<?php
require_once __DIR__ . '/functions.php';
require_once __DIR__ . '/app/saas-mail.php';
$pdo = app_db_pdo();
$email = trim((string)($_POST['email'] ?? ''));
@@ -18,6 +19,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (saas_should_show_auth_links() && !empty($result['token'])) {
$resetLink = 'passwort-zuruecksetzen.php?token=' . urlencode((string)$result['token']);
}
if (!empty($result['token'])) {
saas_send_password_reset_mail($email, (string)$result['token']);
}
} else {
$errors = $result['errors'];
}
+4
View File
@@ -1,6 +1,7 @@
<?php
require_once __DIR__ . '/functions.php';
require_once __DIR__ . '/app/saas-mail.php';
$pdo = app_db_pdo();
$errors = [];
@@ -24,6 +25,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
]);
if ($result['ok']) {
if (!empty($result['email_verification_token'])) {
saas_send_email_verification_mail($values['email'], (string)$result['email_verification_token']);
}
saas_session_login($result['identity']);
if (saas_should_show_auth_links() && !empty($result['email_verification_token'])) {
$_SESSION['saas_dev_email_verification_token'] = (string)$result['email_verification_token'];
+68
View File
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../app/saas-mail.php';
function mail_check_assert(string $label, bool $condition, array &$failures, int &$passes): void
{
if ($condition) {
$passes++;
echo "PASS {$label}\n";
return;
}
$failures[] = $label;
echo "FAIL {$label}\n";
}
$logDir = APP_ROOT . '/var/test-mail';
if (!is_dir($logDir)) {
mkdir($logDir, 0700, true);
}
foreach (glob($logDir . '/*.txt') ?: [] as $file) {
unlink($file);
}
putenv('APP_MAIL_TRANSPORT=log');
putenv('APP_MAIL_LOG_DIR=' . $logDir);
putenv('APP_BASE_URL=https://app.example.test');
putenv('APP_MAIL_FROM=noreply@example.test');
putenv('APP_MAIL_FROM_NAME=Kaffeeliste Test');
$failures = [];
$passes = 0;
$reset = saas_send_password_reset_mail('mail-flow@test.local', str_repeat('a', 64));
mail_check_assert('password reset mail logs successfully', $reset['ok'] === true, $failures, $passes);
mail_check_assert('password reset mail file exists', isset($reset['path']) && is_file($reset['path']), $failures, $passes);
if (isset($reset['path']) && is_file($reset['path'])) {
$content = file_get_contents($reset['path']) ?: '';
mail_check_assert('password reset mail contains app url', str_contains($content, 'https://app.example.test/passwort-zuruecksetzen.php?token='), $failures, $passes);
}
$verification = saas_send_email_verification_mail('mail-flow@test.local', str_repeat('b', 64));
mail_check_assert('email verification mail logs successfully', $verification['ok'] === true, $failures, $passes);
mail_check_assert('email verification mail file exists', isset($verification['path']) && is_file($verification['path']), $failures, $passes);
if (isset($verification['path']) && is_file($verification['path'])) {
$content = file_get_contents($verification['path']) ?: '';
mail_check_assert('email verification mail contains app url', str_contains($content, 'https://app.example.test/email-verifizieren.php?token='), $failures, $passes);
}
$invalid = saas_send_mail('not-an-email', 'Test', 'Body');
mail_check_assert('invalid recipient is rejected', $invalid['ok'] === false, $failures, $passes);
foreach (glob($logDir . '/*.txt') ?: [] as $file) {
unlink($file);
}
if ($failures !== []) {
echo "\nM3 mail flow check failed with " . count($failures) . " failure(s):\n";
foreach ($failures as $failure) {
echo "- {$failure}\n";
}
exit(1);
}
echo "\nM3 mail flow check passed with {$passes} assertions.\n";
+5
View File
@@ -6,6 +6,11 @@ $baseUrl = rtrim((string)(getenv('SMOKE_BASE_URL') ?: 'http://127.0.0.1:8080'),
$issuePattern = '~(?:Deprecated|Warning|Fatal error|Parse error|Notice|Uncaught (?:Error|Exception))~i';
$checks = [
[
'label' => 'Landingpage',
'path' => 'landing.php',
'contains' => ['Kaffeeliste', 'Kundenkonto anlegen', 'Mandantenfaehig'],
],
[
'label' => 'Dashboard',
'path' => 'index.php',