MBO-Tech-IT-Webseite/lib/email-queue.ts

191 lines
6.0 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<boolean> {
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<Array<QueuedMail & { id: string; retry_count: number }>> {
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<QueuedMail & { id: string; retry_count: number }>;
} 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<boolean> {
if (!isSupabaseConfigured()) {
console.warn(
`[EmailQueue] Supabase nicht konfiguriert Mail nicht gespeichert: "${mail.subject}" → "${mail.mail_to}"`
);
return false;
}
const saved = await dbInsert(mail);
if (saved) {
console.log(`[EmailQueue] "${mail.subject}" → "${mail.mail_to}" in Queue gespeichert`);
processQueue().catch(() => {});
}
return saved;
}
// ─── 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<void> {
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);
}