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 <noreply@anthropic.com>
This commit is contained in:
MBO-Tech-IT 2026-04-26 14:56:36 +02:00
parent e7962d7c0c
commit 1369e831ad
9 changed files with 494 additions and 5 deletions

View File

@ -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 });
}

View File

@ -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: `<p style="font-family:sans-serif">Test von <code>/api/admin/smtp-test</code></p><p style="color:green;font-weight:bold">✓ SMTP-Verbindung und Mail-Versand funktionieren korrekt.</p>`,
});
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 }
);
}
}

7
instrumentation.ts Normal file
View File

@ -0,0 +1,7 @@
import { startEmailQueueWorker } from "@/lib/email-queue";
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
startEmailQueueWorker();
}
}

189
lib/email-queue.ts Normal file
View File

@ -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<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);
}

107
lib/mailer.ts Normal file
View File

@ -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 = `
<!DOCTYPE html>
<html lang="de">
<head><meta charset="UTF-8"></head>
<body style="font-family:system-ui,sans-serif;color:#e2e8f0;max-width:600px;margin:0 auto;padding:0">
<div style="background:#18212f;padding:20px 24px;border-bottom:2px solid #f97316">
<h1 style="color:#f97316;margin:0;font-size:20px;font-weight:700">MBO Tech IT</h1>
<p style="color:rgba(255,255,255,0.6);margin:4px 0 0;font-size:13px">Neue Kontaktanfrage</p>
</div>
<div style="padding:24px;background:#1e2a3b">
<h2 style="margin:0 0 16px;font-size:18px;color:#f8fafc">Kontaktanfrage von ${anrede}${data.name}</h2>
<table style="width:100%;border-collapse:collapse;margin-bottom:24px">
<tr><td style="padding:6px 0;color:#94a3b8;width:120px">Name</td><td style="padding:6px 0;font-weight:600;color:#f8fafc">${anrede}${data.name}</td></tr>
<tr><td style="padding:6px 0;color:#94a3b8">Telefon</td><td style="padding:6px 0"><a href="tel:${data.telefon}" style="color:#f97316">${data.telefon}</a></td></tr>
<tr><td style="padding:6px 0;color:#94a3b8">E-Mail</td><td style="padding:6px 0"><a href="mailto:${data.email}" style="color:#60a5fa">${data.email}</a></td></tr>
<tr><td style="padding:6px 0;color:#94a3b8">Betreff</td><td style="padding:6px 0;color:#f8fafc">${data.betreff}</td></tr>
</table>
${
data.nachricht
? `<div style="padding:12px 16px;background:#111925;border-left:3px solid #f97316"><p style="margin:0;font-size:14px;white-space:pre-wrap;color:#cbd5e1">${data.nachricht}</p></div>`
: ""
}
</div>
<div style="padding:12px 24px;background:#111925;border-top:1px solid rgba(255,255,255,0.1)">
<p style="margin:0;font-size:11px;color:#64748b">MBO Tech IT · ${process.env.APP_URL ?? "https://mbo-tech-it.de"}</p>
</div>
</body>
</html>`;
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}`
);
}

11
lib/supabase.ts Normal file
View File

@ -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."
);
}

21
package-lock.json generated
View File

@ -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",

View File

@ -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"
}
}

View File

@ -23,5 +23,5 @@
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"exclude": ["node_modules", "modules"]
}