121 lines
5.7 KiB
PHP
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;
|
|
}
|