Files
kaffeekasse-saas/app/saas-mail.php
T
clemensandClaude Sonnet 5 536ef2ead2 M6: Jahresabschluss als generisches Feature statt AOK-spezifischem Bonus-Skript
jahresauswertung.php verband sich bisher mit fest codierten (kaputten)
Zugangsdaten selbst zur Datenbank statt ueber config.php, hatte keine
Zugriffskontrolle und kein CSRF, und verteilte bei jedem Aufruf sofort
einen hart codierten Bonus-Topf (490 Striche a 0,20 Euro) per PHPMailer
(dessen Quelldateien im Repo fehlen) mit AOK-spezifischem Mailtext.

Nach Abstimmung mit dem Kunden als generisches, mandantenfaehiges Feature
neu gebaut statt nur deaktiviert oder rein lesend umgesetzt:

- Admin gibt einen frei waehlbaren Gesamtbetrag ein, das System verteilt
  ihn proportional zu den Jahresstrichen auf alle aktiven Mitglieder.
- Standardmaessig aktive Dry-Run-Checkbox zeigt die Verteilung, ohne zu
  buchen oder Mails zu verschicken.
- Bestaetigter Lauf bucht ueber dasselbe Zweig-Muster wie ueberall
  (Default-Mandant Dual-Write, andere Mandanten ledger_record_payment)
  und verschickt personalisierte Mails ueber saas_send_mail(), protokolliert
  im outbound_emails-Versandlog.
- Zugriffskontrolle ergaenzt (owner/admin/treasurer + Legacy-Fallback).
- http-smoke.php: jahresauswertung.php jetzt regulaerer Check statt
  uebersprungenem unsicherem Aufruf; damit sind keine Seiten mehr
  uebersprungen oder als bekannter offener Punkt markiert (26/26 gruen).

Live getestet: Dry-Run mit korrekter proportionaler Verteilung (Summe
ergibt exakt den Gesamtbetrag), Live-Lauf bucht und versendet korrekt,
Testdaten anschliessend vollstaendig entfernt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 17:21:34 +02:00

269 lines
8.0 KiB
PHP

<?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,',
'',
'für dein Kaffeeliste-Konto wurde ein Passwort-Reset angefordert.',
'Öffne 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 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<array{id: int, participant_id: ?int, display_name: ?string, template: string, subject: string, status: string, sent_at: ?string, error: ?string, created_at: string}>
*/
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_render_year_end_bonus_mail_body(string $displayName, int $yearMarks, int $bonusCents, string $dashboardUrl): string
{
$bonus = saas_format_money_cents($bonusCents) . ' €';
$lines = [
"Hallo {$displayName},",
'',
"vielen Dank für deine Nutzung der Kaffeeliste in diesem Jahr!",
"Du hast dieses Jahr {$yearMarks} Striche gemacht.",
"Dafür wurden dir {$bonus} auf deinem Kaffeelisten-Konto gutgeschrieben.",
'',
"Deinen aktuellen Stand findest du hier: {$dashboardUrl}",
'',
'Deine Kaffeeliste',
];
return implode("\n", $lines);
}
function saas_send_invite_mail(string $to, string $tenantName, string $role, string $token): array
{
$link = saas_app_url('passwort-zuruecksetzen.php?token=' . urlencode($token));
$body = implode("\n", [
'Hallo,',
'',
"du wurdest für \"{$tenantName}\" in der Kaffeeliste als {$role} eingeladen.",
'Öffne diesen Link, um ein Passwort zu setzen und dich anzumelden:',
'',
$link,
'',
'Der Link ist zeitlich begrenzt und kann nur einmal verwendet werden.',
'Falls du diese Einladung nicht erwartet hast, kannst du diese Nachricht ignorieren.',
'',
'Deine Kaffeeliste',
]);
return saas_send_mail($to, 'Einladung zur Kaffeeliste', $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 bestätige deine E-Mail-Adresse für Kaffeeliste.',
'Öffne 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 bestätigen', $body);
}