Files
deskhub/DeskHub-Webseite/includes/mail_queue.php
T
2026-10-28 07:54:38 +00:00

121 lines
5.7 KiB
PHP

<?php
/**
* includes/mail_queue.php
*
* Warteschlange für ausgehende Mails. Scheitert der Versand (SMTP-Server nicht erreichbar, falsche Zugangsdaten, Port gesperrt …),
* wird die Mail hier gespeichert und vom Cron-Job "process_mail_queue" mit wachsenden Abständen erneut versucht. So gehen
* Antworten, Eingangsbestätigungen und Passwort-Mails bei einer Störung nicht verloren.
*
* Tabelle mail_queue (Migration php_0064): status = pending (wartet), sent (nachträglich gesendet), failed (aufgegeben).
*/
const MAIL_QUEUE_MAX_ATTEMPTS = 10;
const MAIL_QUEUE_MAX_AGE_HOURS = 48;
const MAIL_QUEUE_MAX_PENDING = 2000;
/** Minuten bis zum nächsten Versuch nach dem 1., 2., 3. … Fehlversuch (der letzte Wert gilt für alle weiteren). */
const MAIL_QUEUE_BACKOFF_MINUTES = [5, 10, 20, 40, 60, 120, 240, 360];
/** Ist die Warteschlange eingeschaltet? (Standard: ja; Einstellung mail_queue_enabled = 0 schaltet sie ab) */
function mail_queue_enabled(): bool
{
return get_setting('mail_queue_enabled', '1') === '1';
}
/** Merkt sich pro Anfrage, ob der letzte send_mail()-Aufruf wirklich versucht hat zu senden (nicht nur "Mailversand aus"). */
function mail_attempted(?bool $set = null): bool
{
static $v = false;
if ($set !== null) $v = $set;
return $v;
}
/** Wurde die zuletzt fehlgeschlagene Mail in die Warteschlange gelegt? */
function mail_last_queued(?bool $set = null): bool
{
static $v = false;
if ($set !== null) $v = $set;
return $v;
}
/** Legt eine Mail in die Warteschlange. @return int|null ID, null wenn nicht möglich (Tabelle fehlt, Warteschlange voll) */
function mail_queue_add(PDO $pdo, string $to, string $subject, string $htmlBody, ?string $fromHeader, string $error): ?int
{
try {
$pending = (int) $pdo->query("SELECT COUNT(*) FROM mail_queue WHERE status = 'pending'")->fetchColumn();
if ($pending >= MAIL_QUEUE_MAX_PENDING) return null;
$now = db_now();
$pdo->prepare("INSERT INTO mail_queue (to_email, from_header, subject, body, status, attempts, next_attempt_at, last_error, created_at) VALUES (?,?,?,?, 'pending', 1, ?, ?, ?)")
->execute([mb_substr($to, 0, 255), $fromHeader !== null && $fromHeader !== '' ? mb_substr($fromHeader, 0, 255) : null, mb_substr($subject, 0, 500), $htmlBody,
date('Y-m-d H:i:s', time() + MAIL_QUEUE_BACKOFF_MINUTES[0] * 60), mb_substr($error, 0, 400), $now]);
return (int) $pdo->lastInsertId();
} catch (Throwable $e) {
error_log('mail_queue_add: ' . $e->getMessage());
return null;
}
}
/**
* Versucht fällige Mails erneut zu senden (höchstens $limit pro Lauf). Jede Mail wird vorher atomar "reserviert", damit zwei
* gleichzeitige Läufe sie nicht doppelt senden.
* @return array{sent:int, failed:int, retry:int, gave_up:int}
*/
function mail_queue_run(PDO $pdo, int $limit = 20): array
{
$res = ['sent' => 0, 'failed' => 0, 'retry' => 0, 'gave_up' => 0];
try {
$now = db_now();
$due = $pdo->prepare("SELECT id FROM mail_queue WHERE status = 'pending' AND next_attempt_at <= ? ORDER BY id LIMIT " . max(1, $limit));
$due->execute([$now]);
$ids = $due->fetchAll(PDO::FETCH_COLUMN);
} catch (Throwable $e) {
return $res;
}
foreach ($ids as $id) {
// Reservieren: nur wer die Zeile verschiebt, darf senden
$claim = $pdo->prepare("UPDATE mail_queue SET next_attempt_at = ? WHERE id = ? AND status = 'pending' AND next_attempt_at <= ?");
$claim->execute([date('Y-m-d H:i:s', time() + 600), $id, db_now()]);
if ($claim->rowCount() !== 1) continue;
$s = $pdo->prepare('SELECT * FROM mail_queue WHERE id = ?');
$s->execute([$id]);
$m = $s->fetch();
if (!$m) continue;
$ok = send_mail($m['to_email'], $m['subject'], $m['body'], $m['from_header'] ?: null, false);
$attempts = (int) $m['attempts'] + 1;
if ($ok) {
$pdo->prepare("UPDATE mail_queue SET status = 'sent', attempts = ?, sent_at = ?, last_error = NULL WHERE id = ?")->execute([$attempts, db_now(), $id]);
$res['sent']++;
continue;
}
$err = mb_substr(smtp_last_error() !== '' ? smtp_last_error() : 'Versand fehlgeschlagen', 0, 400);
$tooOld = time() - (int) strtotime((string) $m['created_at']) > MAIL_QUEUE_MAX_AGE_HOURS * 3600;
if ($attempts >= MAIL_QUEUE_MAX_ATTEMPTS || $tooOld) {
$pdo->prepare("UPDATE mail_queue SET status = 'failed', attempts = ?, last_error = ? WHERE id = ?")->execute([$attempts, $err, $id]);
$res['gave_up']++;
} else {
$wait = MAIL_QUEUE_BACKOFF_MINUTES[min($attempts - 1, count(MAIL_QUEUE_BACKOFF_MINUTES) - 1)];
$pdo->prepare("UPDATE mail_queue SET attempts = ?, last_error = ?, next_attempt_at = ? WHERE id = ?")->execute([$attempts, $err, date('Y-m-d H:i:s', time() + $wait * 60), $id]);
$res['retry']++;
}
$res['failed']++;
}
// Aufräumen: Erledigtes und Aufgegebenes nach 30 Tagen löschen
try {
$pdo->prepare("DELETE FROM mail_queue WHERE status IN ('sent','failed') AND created_at < ?")->execute([date('Y-m-d H:i:s', time() - 30 * 86400)]);
} catch (Throwable $e) {
}
return $res;
}
/** @return array{pending:int, failed:int, sent:int} Anzahl nach Status (für Anzeige und Gesundheitsprüfung) */
function mail_queue_counts(PDO $pdo): array
{
$out = ['pending' => 0, 'failed' => 0, 'sent' => 0];
try {
foreach ($pdo->query('SELECT status, COUNT(*) AS n FROM mail_queue GROUP BY status')->fetchAll() as $r) {
if (isset($out[$r['status']])) $out[$r['status']] = (int) $r['n'];
}
} catch (Throwable $e) {
}
return $out;
}