Files
kaffeekasse-saas/app/saas-mail.php
T
clemens d5195e7f9f 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</EOF>
2026-07-15 16:13:38 +02:00

251 lines
7.4 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_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);
}