From 1369e831ad27319d613eea03f2f754c3f11da523 Mon Sep 17 00:00:00 2001 From: MBO-Tech-IT Date: Sun, 26 Apr 2026 14:56:36 +0200 Subject: [PATCH] feat: integrate email-system module (nodemailer + queue) Adds lib/mailer.ts (sendeKontaktEmail, MBO-Tech-IT branding), lib/email-queue.ts (Supabase-fallback, graceful no-op when not configured), lib/supabase.ts (stub for later integration), admin API routes for SMTP-test and queue management, and instrumentation.ts for the queue worker. Excludes modules/ from TypeScript compilation. Co-Authored-By: Claude Sonnet 4.6 --- app/api/admin/email-queue/route.ts | 84 +++++++++++++ app/api/admin/smtp-test/route.ts | 68 +++++++++++ instrumentation.ts | 7 ++ lib/email-queue.ts | 189 +++++++++++++++++++++++++++++ lib/mailer.ts | 107 ++++++++++++++++ lib/supabase.ts | 11 ++ package-lock.json | 21 ++++ package.json | 10 +- tsconfig.json | 2 +- 9 files changed, 494 insertions(+), 5 deletions(-) create mode 100644 app/api/admin/email-queue/route.ts create mode 100644 app/api/admin/smtp-test/route.ts create mode 100644 instrumentation.ts create mode 100644 lib/email-queue.ts create mode 100644 lib/mailer.ts create mode 100644 lib/supabase.ts diff --git a/app/api/admin/email-queue/route.ts b/app/api/admin/email-queue/route.ts new file mode 100644 index 0000000..cd5c38f --- /dev/null +++ b/app/api/admin/email-queue/route.ts @@ -0,0 +1,84 @@ +import { NextResponse } from "next/server"; + +export const dynamic = "force-dynamic"; + +// Optionaler Schutz via ADMIN_SECRET env-Variable. +// Wird durch lib/admin-auth ersetzt wenn Modul 02-admin-auth integriert ist. +function checkAuth(request: Request): NextResponse | null { + const secret = process.env.ADMIN_SECRET; + if (!secret) return null; + const auth = request.headers.get("authorization"); + if (auth !== `Bearer ${secret}`) { + return NextResponse.json({ error: "Nicht autorisiert" }, { status: 401 }); + } + return null; +} + +function isSupabaseConfigured() { + return !!(process.env.SUPABASE_URL && process.env.SUPABASE_SERVICE_ROLE_KEY); +} + +function supabaseNotReadyResponse() { + return NextResponse.json( + { error: "Supabase nicht konfiguriert – Email-Queue nicht verfügbar" }, + { status: 503 } + ); +} + +// GET: Queue-Status abrufen +export async function GET(request: Request) { + const authError = checkAuth(request); + if (authError) return authError; + if (!isSupabaseConfigured()) return supabaseNotReadyResponse(); + + const { createServiceClient } = await import("@/lib/supabase"); + const supabase = createServiceClient(); + const { data, error } = await supabase + .from("email_queue") + .select("id, created_at, next_retry_at, retry_count, status, error_last, mail_to, subject") + .order("created_at", { ascending: false }) + .limit(50); + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }); + return NextResponse.json(data); +} + +// POST: Alle pending Mails sofort neu versuchen +export async function POST(request: Request) { + const authError = checkAuth(request); + if (authError) return authError; + if (!isSupabaseConfigured()) return supabaseNotReadyResponse(); + + const { createServiceClient } = await import("@/lib/supabase"); + const supabase = createServiceClient(); + const { error, count } = await supabase + .from("email_queue") + .update({ next_retry_at: new Date().toISOString() }) + .eq("status", "pending"); + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }); + + try { + const { startEmailQueueWorker } = await import("@/lib/email-queue"); + startEmailQueueWorker(); + } catch {} + + return NextResponse.json({ ok: true, updated: count ?? 0 }); +} + +// DELETE: Fehlgeschlagene Mails löschen +export async function DELETE(request: Request) { + const authError = checkAuth(request); + if (authError) return authError; + if (!isSupabaseConfigured()) return supabaseNotReadyResponse(); + + const { createServiceClient } = await import("@/lib/supabase"); + const supabase = createServiceClient(); + const { error, count } = await supabase + .from("email_queue") + .delete() + .eq("status", "failed"); + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }); + return NextResponse.json({ ok: true, deleted: count ?? 0 }); +} diff --git a/app/api/admin/smtp-test/route.ts b/app/api/admin/smtp-test/route.ts new file mode 100644 index 0000000..c620abf --- /dev/null +++ b/app/api/admin/smtp-test/route.ts @@ -0,0 +1,68 @@ +import { NextResponse } from "next/server"; +import nodemailer from "nodemailer"; + +export const dynamic = "force-dynamic"; + +// Optionaler Schutz via ADMIN_SECRET env-Variable. +// Wird durch lib/admin-auth ersetzt wenn Modul 02-admin-auth integriert ist. +function checkAuth(request: Request): NextResponse | null { + const secret = process.env.ADMIN_SECRET; + if (!secret) return null; + const auth = request.headers.get("authorization"); + if (auth !== `Bearer ${secret}`) { + return NextResponse.json({ error: "Nicht autorisiert" }, { status: 401 }); + } + return null; +} + +export async function GET(request: Request) { + const authError = checkAuth(request); + if (authError) return authError; + + const config = { + host: process.env.SMTP_HOST ?? "(nicht gesetzt)", + port: Number(process.env.SMTP_PORT ?? 587), + user: process.env.SMTP_USER ?? "(nicht gesetzt)", + from: process.env.SMTP_FROM ?? "(nicht gesetzt)", + to: process.env.SMTP_TO ?? "(nicht gesetzt)", + }; + + const transport = nodemailer.createTransport({ + host: config.host, + port: config.port, + secure: false, + auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }, + connectionTimeout: 10000, + greetingTimeout: 8000, + socketTimeout: 12000, + tls: { rejectUnauthorized: false }, + }); + + try { + await transport.verify(); + await transport.sendMail({ + from: `"MBO Tech IT TEST" <${process.env.SMTP_FROM}>`, + to: process.env.SMTP_TO, + subject: `✓ SMTP-Test erfolgreich – ${new Date().toLocaleString("de-DE")}`, + text: "Diese Test-Mail wurde automatisch von /api/admin/smtp-test gesendet.\n\nSMTP-Verbindung und Mail-Versand funktionieren korrekt.", + html: `

Test von /api/admin/smtp-test

✓ SMTP-Verbindung und Mail-Versand funktionieren korrekt.

`, + }); + return NextResponse.json({ + ok: true, + message: "SMTP-Verbindung erfolgreich – Test-Mail gesendet", + config: { ...config, pass: "***" }, + }); + } catch (err) { + const e = err as Error & { code?: string; command?: string }; + return NextResponse.json( + { + ok: false, + error: e.message, + code: e.code, + command: e.command, + config: { ...config, pass: "***" }, + }, + { status: 500 } + ); + } +} diff --git a/instrumentation.ts b/instrumentation.ts new file mode 100644 index 0000000..0efc67f --- /dev/null +++ b/instrumentation.ts @@ -0,0 +1,7 @@ +import { startEmailQueueWorker } from "@/lib/email-queue"; + +export async function register() { + if (process.env.NEXT_RUNTIME === "nodejs") { + startEmailQueueWorker(); + } +} diff --git a/lib/email-queue.ts b/lib/email-queue.ts new file mode 100644 index 0000000..2a38ff7 --- /dev/null +++ b/lib/email-queue.ts @@ -0,0 +1,189 @@ +/** + * 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); +} diff --git a/lib/mailer.ts b/lib/mailer.ts new file mode 100644 index 0000000..b136847 --- /dev/null +++ b/lib/mailer.ts @@ -0,0 +1,107 @@ +import nodemailer from "nodemailer"; +import { queueEmail } from "./email-queue"; + +// Port 587 = STARTTLS, Port 465 = SSL/TLS +const 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", + }, +}); + +export interface KontaktEmailData { + name: string; + anrede?: string; + telefon: string; + email: string; + betreff: string; + nachricht?: string; +} + +interface MailOptions { + from: string; + to: string | undefined; + replyTo?: string; + subject: string; + text: string; + html: string; +} + +async function sendWithFallback(options: MailOptions, label: string) { + if (!options.to) { + console.error(`[Mailer] Kein Empfänger für "${label}" – Mail übersprungen`); + return; + } + try { + await transporter.sendMail(options); + console.log(`[Mailer] ✓ Mail "${label}" an "${options.to}" gesendet`); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[Mailer] ✗ Mail "${label}" fehlgeschlagen (${msg}) – in Queue gespeichert`); + await queueEmail({ + mail_from: options.from, + mail_to: options.to, + reply_to: options.replyTo, + subject: options.subject, + html: options.html, + body_text: options.text, + }); + } +} + +export async function sendeKontaktEmail(data: KontaktEmailData) { + const anrede = data.anrede + ? `${data.anrede.charAt(0).toUpperCase() + data.anrede.slice(1)} ` + : ""; + + const html = ` + + + + +
+

MBO Tech IT

+

Neue Kontaktanfrage

+
+
+

Kontaktanfrage von ${anrede}${data.name}

+ + + + + +
Name${anrede}${data.name}
Telefon${data.telefon}
E-Mail${data.email}
Betreff${data.betreff}
+ ${ + data.nachricht + ? `

${data.nachricht}

` + : "" + } +
+
+

MBO Tech IT · ${process.env.APP_URL ?? "https://mbo-tech-it.de"}

+
+ +`; + + await sendWithFallback( + { + from: `"MBO Tech IT" <${process.env.SMTP_FROM}>`, + to: process.env.SMTP_TO, + replyTo: data.email, + subject: `Kontaktanfrage: ${anrede}${data.name} – ${data.betreff}`, + text: `Neue Kontaktanfrage\n\nName: ${anrede}${data.name}\nTelefon: ${data.telefon}\nE-Mail: ${data.email}\nBetreff: ${data.betreff}${data.nachricht ? `\n\nNachricht:\n${data.nachricht}` : ""}`, + html, + }, + `Kontaktanfrage ${data.name}` + ); +} diff --git a/lib/supabase.ts b/lib/supabase.ts new file mode 100644 index 0000000..11a8929 --- /dev/null +++ b/lib/supabase.ts @@ -0,0 +1,11 @@ +// STUB – wird ersetzt wenn Supabase eingerichtet ist (Modul: Supabase-Integration) +// Aktuell wirft createServiceClient() einen Fehler – email-queue.ts fängt diesen ab. + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type SupabaseServiceClient = any; + +export function createServiceClient(): SupabaseServiceClient { + throw new Error( + "Supabase noch nicht konfiguriert. lib/supabase.ts implementieren und @supabase/supabase-js installieren." + ); +} diff --git a/package-lock.json b/package-lock.json index fc6f675..c6cad83 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,11 +9,13 @@ "version": "0.1.0", "dependencies": { "next": "^15.2.0", + "nodemailer": "^8.0.6", "react": "^19.0.0", "react-dom": "^19.0.0" }, "devDependencies": { "@types/node": "^22", + "@types/nodemailer": "^8.0.0", "@types/react": "^19", "@types/react-dom": "^19", "autoprefixer": "^10", @@ -741,6 +743,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz", + "integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -1395,6 +1407,15 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.6.tgz", + "integrity": "sha512-Nm2XeuDwwy2wi5A+8jPWwQwNzcjNjhWdE3pVLoXEusxJqCnAPAgnBGkSmiLknbnWuOF9qraRpYZjfxqtKZ4tPw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", diff --git a/package.json b/package.json index 5ac286d..c3b6b95 100644 --- a/package.json +++ b/package.json @@ -10,16 +10,18 @@ }, "dependencies": { "next": "^15.2.0", + "nodemailer": "^8.0.6", "react": "^19.0.0", "react-dom": "^19.0.0" }, "devDependencies": { "@types/node": "^22", + "@types/nodemailer": "^8.0.0", "@types/react": "^19", "@types/react-dom": "^19", - "typescript": "^5", - "tailwindcss": "^3.4.0", + "autoprefixer": "^10", "postcss": "^8", - "autoprefixer": "^10" + "tailwindcss": "^3.4.0", + "typescript": "^5" } -} \ No newline at end of file +} diff --git a/tsconfig.json b/tsconfig.json index d8b9323..677ac40 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,5 +23,5 @@ } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "exclude": ["node_modules", "modules"] }