190 lines
6.0 KiB
TypeScript
190 lines
6.0 KiB
TypeScript
/**
|
||
* 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<void> {
|
||
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<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);
|
||
}
|