392 lines
13 KiB
PHP
392 lines
13 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
require_once __DIR__ . '/bootstrap.php';
|
||
require_once __DIR__ . '/legal.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 {
|
||
$pdo->exec('DELETE FROM outbound_emails WHERE created_at < DATE_SUB(NOW(), INTERVAL 180 DAY)');
|
||
$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);
|
||
}
|
||
|
||
function saas_send_registration_contract_mail(string $to, string $tenantName, string $tenantSlug, string $customerType): array
|
||
{
|
||
$body = implode("\n", [
|
||
'Vertragsbestätigung Kaffeeliste',
|
||
'',
|
||
'Kunde: ' . $tenantName,
|
||
'Kundenkürzel: ' . $tenantSlug,
|
||
'Kundentyp: ' . ($customerType === 'consumer' ? 'Verbraucher' : 'Unternehmer'),
|
||
'Tarif: Kostenlos, 0,00 € pro Monat, unbefristet, jederzeit kündbar',
|
||
'Vertragsschluss: ' . date('d.m.Y H:i') . ' Uhr (Europe/Berlin)',
|
||
'',
|
||
'Nachfolgend erhältst du die bei Vertragsschluss geltenden Texte auf einem dauerhaften Datenträger.',
|
||
'',
|
||
'================ AGB ================',
|
||
app_legal_document_text('terms'),
|
||
'',
|
||
'================ AVV ================',
|
||
app_legal_document_text('dpa'),
|
||
'',
|
||
'========== WIDERRUFSBELEHRUNG ==========',
|
||
app_legal_document_text('withdrawal_information'),
|
||
]);
|
||
|
||
return saas_send_mail($to, 'Kaffeeliste Vertragsbestätigung – kostenloser Tarif', $body);
|
||
}
|
||
|
||
function saas_send_paid_contract_confirmation(PDO $pdo, int $tenantId, string $planCode): array
|
||
{
|
||
require_once __DIR__ . '/billing.php';
|
||
$stmt = $pdo->prepare(
|
||
"SELECT t.name, t.slug, t.customer_type, u.email
|
||
FROM tenants t
|
||
JOIN tenant_memberships tm ON tm.tenant_id = t.id AND tm.role = 'owner' AND tm.status = 'active'
|
||
JOIN users u ON u.id = tm.user_id AND u.status = 'active'
|
||
WHERE t.id = ? ORDER BY tm.id LIMIT 1"
|
||
);
|
||
$stmt->execute([$tenantId]);
|
||
$contract = $stmt->fetch();
|
||
$plan = billing_plans()[$planCode] ?? null;
|
||
if ($contract === false || $plan === null) {
|
||
return ['ok' => false, 'transport' => saas_mail_transport(), 'error' => 'contract confirmation data missing'];
|
||
}
|
||
|
||
$body = implode("\n", [
|
||
'Vertragsbestätigung Kaffeeliste – kostenpflichtiger Tarif',
|
||
'',
|
||
'Kunde: ' . $contract['name'],
|
||
'Kundenkürzel: ' . $contract['slug'],
|
||
'Kundentyp: ' . ($contract['customer_type'] === 'consumer' ? 'Verbraucher' : 'Unternehmer'),
|
||
'Tarif: ' . $plan['label'],
|
||
'Gesamtpreis: ' . number_format(((int)$plan['price_cents']) / 100, 2, ',', '') . ' € pro Monat',
|
||
'Laufzeit: unbefristet; monatliche Abrechnung; keine längere Mindestlaufzeit',
|
||
'Kündigung: jederzeit zum Ende der laufenden Abrechnungsperiode',
|
||
'Bestätigung: ' . date('d.m.Y H:i') . ' Uhr (Europe/Berlin)',
|
||
'',
|
||
'================ AGB ================',
|
||
app_legal_document_text('terms'),
|
||
'',
|
||
'========== WIDERRUFSBELEHRUNG ==========',
|
||
app_legal_document_text('withdrawal_information'),
|
||
]);
|
||
|
||
return saas_send_mail((string)$contract['email'], 'Kaffeeliste Vertragsbestätigung – ' . $plan['label'], $body);
|
||
}
|
||
|
||
/**
|
||
* Textkoerper der automatischen Zahlungserinnerung an ein Mitglied mit
|
||
* offenem Betrag oberhalb der Warnschwelle des Mandanten.
|
||
*/
|
||
function saas_render_payment_reminder_mail_body(string $displayName, int $debtCents, array $tenantSettings, string $dashboardUrl): string
|
||
{
|
||
$amount = saas_format_money_cents($debtCents) . ' €';
|
||
$lines = [
|
||
"Hallo {$displayName},",
|
||
'',
|
||
"auf deinem Kaffeelisten-Konto ist aktuell ein offener Betrag von {$amount} aufgelaufen.",
|
||
"Bitte gleiche ihn 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($debtCents / 100, 2, '.', '');
|
||
$lines[] = '';
|
||
$lines[] = "Direkt bezahlen: {$paypalLink}";
|
||
}
|
||
|
||
$lines[] = '';
|
||
$lines[] = "Deinen aktuellen Stand findest du hier: {$dashboardUrl}";
|
||
$lines[] = '';
|
||
$lines[] = 'Deine Kaffeeliste';
|
||
|
||
return implode("\n", $lines);
|
||
}
|
||
|
||
/**
|
||
* Liefert das Datum der letzten Zahlungserinnerung je Teilnehmer eines
|
||
* Mandanten (nur tatsaechlich versendete). Fuer die Intervall-Pruefung im
|
||
* Erinnerungs-Cron, damit niemand haeufiger als eingestellt erinnert wird.
|
||
*
|
||
* @return array<int, string> participant_id => letztes sent_at ('Y-m-d H:i:s')
|
||
*/
|
||
function saas_fetch_last_payment_reminders(PDO $pdo, int $tenantId): array
|
||
{
|
||
$stmt = $pdo->prepare(
|
||
"SELECT participant_id, MAX(sent_at) AS last_sent
|
||
FROM outbound_emails
|
||
WHERE tenant_id = ?
|
||
AND template = 'payment_reminder'
|
||
AND status = 'sent'
|
||
AND participant_id IS NOT NULL
|
||
GROUP BY participant_id"
|
||
);
|
||
$stmt->execute([$tenantId]);
|
||
|
||
$map = [];
|
||
foreach ($stmt->fetchAll() as $row) {
|
||
$map[(int)$row['participant_id']] = (string)$row['last_sent'];
|
||
}
|
||
|
||
return $map;
|
||
}
|