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; }