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}
+
+ ${
+ 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"]
}