/** * Email-Queue: Speichert fehlgeschlagene E-Mails in der Datenbank * und versucht sie regelmäßig erneut zu senden. * * Retry-Strategie: exponentielles Backoff * Versuch 1 → sofort, Versuch 2 → 1 Min, Versuch 3 → 2 Min ... * bis max. 60 Min zwischen Versuchen, dann Status "failed" * * Supabase-Guard: Alle DB-Operationen sind No-Ops solange SUPABASE_URL * und SUPABASE_SERVICE_ROLE_KEY nicht gesetzt sind. */ import nodemailer from "nodemailer"; import { createServiceClient } from "./supabase"; export interface QueuedMail { mail_from: string; mail_to: string; reply_to?: string; subject: string; html: string; body_text: string; } function isSupabaseConfigured(): boolean { return !!(process.env.SUPABASE_URL && process.env.SUPABASE_SERVICE_ROLE_KEY); } function getDb() { return createServiceClient(); } // ─── Datenbank-Operationen ──────────────────────────────────────────────── async function dbInsert(mail: QueuedMail): Promise { if (!isSupabaseConfigured()) return false; try { const db = getDb(); const { error } = await db.from("email_queue").insert({ ...mail, status: "pending", retry_count: 0, next_retry_at: new Date().toISOString(), }); if (error) throw new Error(`Queue-Insert fehlgeschlagen: ${error.message}`); return true; } catch (err) { console.error("[EmailQueue] dbInsert fehlgeschlagen:", err); return false; } } async function dbFetchPending(): Promise> { if (!isSupabaseConfigured()) return []; try { const db = getDb(); const now = new Date().toISOString(); const { data, error } = await db .from("email_queue") .select("*") .eq("status", "pending") .lte("next_retry_at", now) .order("next_retry_at", { ascending: true }) .limit(20); if (error) { console.error("[EmailQueue] Fetch pending fehlgeschlagen:", error.message); return []; } return (data ?? []) as Array; } catch { return []; } } async function dbMarkSent(id: string) { if (!isSupabaseConfigured()) return; try { const db = getDb(); await db.from("email_queue").update({ status: "sent", error_last: null }).eq("id", id); } catch {} } async function dbMarkRetry(id: string, retryCount: number, error: string) { if (!isSupabaseConfigured()) return; try { const db = getDb(); const nextCount = retryCount + 1; const maxRetries = 10; if (nextCount >= maxRetries) { await db .from("email_queue") .update({ status: "failed", retry_count: nextCount, error_last: error.slice(0, 500) }) .eq("id", id); console.error(`[EmailQueue] Mail ${id} endgültig fehlgeschlagen nach ${nextCount} Versuchen`); return; } const minutesDelay = Math.min(Math.pow(2, nextCount - 1), 60); const nextRetry = new Date(Date.now() + minutesDelay * 60 * 1000).toISOString(); await db .from("email_queue") .update({ retry_count: nextCount, next_retry_at: nextRetry, error_last: error.slice(0, 500) }) .eq("id", id); console.warn( `[EmailQueue] Versuch ${nextCount}/${maxRetries} fehlgeschlagen – nächster Retry in ${minutesDelay} Min.` ); } catch {} } // ─── Queue-Eintrag speichern ─────────────────────────────────────────────── export async function queueEmail(mail: QueuedMail): Promise { if (!isSupabaseConfigured()) { console.warn( `[EmailQueue] Supabase nicht konfiguriert – Mail nicht gespeichert: "${mail.subject}" → "${mail.mail_to}"` ); return; } const saved = await dbInsert(mail); if (saved) { console.log(`[EmailQueue] "${mail.subject}" → "${mail.mail_to}" in Queue gespeichert`); processQueue().catch(() => {}); } } // ─── Worker ─────────────────────────────────────────────────────────────── let transporter: nodemailer.Transporter | null = null; function getTransporter() { if (!transporter) { transporter = nodemailer.createTransport({ host: process.env.SMTP_HOST, port: Number(process.env.SMTP_PORT ?? 587), secure: false, auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }, connectionTimeout: 15000, greetingTimeout: 10000, socketTimeout: 20000, tls: { rejectUnauthorized: false, ciphers: "SSLv3" }, }); } return transporter; } async function processQueue(): Promise { const pending = await dbFetchPending(); if (pending.length === 0) return; console.log(`[EmailQueue] Verarbeite ${pending.length} ausstehende Mail(s)...`); for (const mail of pending) { try { await getTransporter().sendMail({ from: mail.mail_from, to: mail.mail_to, replyTo: mail.reply_to, subject: mail.subject, html: mail.html, text: mail.body_text, }); await dbMarkSent(mail.id); console.log(`[EmailQueue] ✓ "${mail.subject}" → "${mail.mail_to}" gesendet`); } catch (err) { const msg = err instanceof Error ? err.message : String(err); await dbMarkRetry(mail.id, mail.retry_count, msg); } } } let workerStarted = false; export function startEmailQueueWorker(): void { if (workerStarted) return; workerStarted = true; if (!isSupabaseConfigured()) { console.warn("[EmailQueue] Worker nicht gestartet – Supabase nicht konfiguriert"); return; } console.log("[EmailQueue] Worker gestartet – prüft alle 60 Sekunden"); processQueue().catch((e) => console.error("[EmailQueue] Initialer Lauf fehlgeschlagen:", e)); setInterval(() => { processQueue().catch((e) => console.error("[EmailQueue] Lauf fehlgeschlagen:", e)); }, 60_000); }