feat(api): require session authentication for admin routes and improve error handling fix(api): streamline project image generation by fetching data directly from the database fix(api): optimize project import/export functionality with session validation and improved error handling fix(api): enhance analytics dashboard and email manager with session token for admin requests fix(components): improve loading states and dynamic imports for better user experience chore(security): update Content Security Policy to avoid unsafe-eval in production chore(deps): update package.json scripts for consistent environment handling in linting and testing
365 lines
14 KiB
TypeScript
365 lines
14 KiB
TypeScript
import { type NextRequest, NextResponse } from "next/server";
|
|
import nodemailer from "nodemailer";
|
|
import SMTPTransport from "nodemailer/lib/smtp-transport";
|
|
import Mail from "nodemailer/lib/mailer";
|
|
import { checkRateLimit, getRateLimitHeaders, getClientIp, requireSessionAuth } from "@/lib/auth";
|
|
|
|
const BRAND = {
|
|
siteUrl: "https://dk0.dev",
|
|
email: "contact@dk0.dev",
|
|
bg: "#FDFCF8",
|
|
sand: "#F3F1E7",
|
|
border: "#E7E5E4",
|
|
text: "#292524",
|
|
muted: "#78716C",
|
|
mint: "#A7F3D0",
|
|
red: "#EF4444",
|
|
};
|
|
|
|
function escapeHtml(input: string): string {
|
|
return input
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
function nl2br(input: string): string {
|
|
return input.replace(/\r\n|\r|\n/g, "<br>");
|
|
}
|
|
|
|
function baseEmail(opts: { title: string; subtitle: string; bodyHtml: string }) {
|
|
const sentAt = new Date().toLocaleString("de-DE", {
|
|
year: "numeric",
|
|
month: "long",
|
|
day: "numeric",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
|
|
return `
|
|
<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>${escapeHtml(opts.title)}</title>
|
|
</head>
|
|
<body style="margin:0;padding:0;background-color:${BRAND.bg};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;color:${BRAND.text};">
|
|
<div style="max-width:640px;margin:0 auto;padding:28px 14px;">
|
|
<div style="background:#ffffff;border:1px solid ${BRAND.border};border-radius:20px;overflow:hidden;box-shadow:0 18px 50px rgba(0,0,0,0.08);">
|
|
<div style="background:${BRAND.text};padding:22px 26px;">
|
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:16px;">
|
|
<div style="font-weight:800;font-size:16px;color:${BRAND.bg};">Dennis Konkol</div>
|
|
<div style="font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,'Liberation Mono','Courier New',monospace;font-weight:800;font-size:14px;color:${BRAND.bg};">
|
|
dk<span style="color:${BRAND.red};">0</span>.dev
|
|
</div>
|
|
</div>
|
|
<div style="margin-top:10px;">
|
|
<div style="font-size:22px;font-weight:900;letter-spacing:-0.02em;color:${BRAND.bg};">${escapeHtml(opts.title)}</div>
|
|
<div style="margin-top:4px;font-size:13px;color:#d6d3d1;">${escapeHtml(opts.subtitle)} • ${sentAt}</div>
|
|
</div>
|
|
<div style="height:3px;background:${BRAND.mint};margin-top:18px;border-radius:999px;"></div>
|
|
</div>
|
|
|
|
<div style="padding:26px;">
|
|
${opts.bodyHtml}
|
|
</div>
|
|
|
|
<div style="padding:18px 26px;background:${BRAND.bg};border-top:1px solid ${BRAND.border};">
|
|
<div style="font-size:12px;color:${BRAND.muted};line-height:1.5;">
|
|
Automatisch generiert von <a href="${BRAND.siteUrl}" style="color:${BRAND.text};text-decoration:underline;">dk0.dev</a> •
|
|
<a href="mailto:${BRAND.email}" style="color:${BRAND.text};text-decoration:underline;">${BRAND.email}</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`.trim();
|
|
}
|
|
|
|
const emailTemplates = {
|
|
welcome: {
|
|
subject: "Vielen Dank für deine Nachricht! 👋",
|
|
template: (name: string, originalMessage: string) => {
|
|
const safeName = escapeHtml(name);
|
|
const safeMsg = nl2br(escapeHtml(originalMessage));
|
|
return baseEmail({
|
|
title: `Danke, ${safeName}!`,
|
|
subtitle: "Nachricht erhalten",
|
|
bodyHtml: `
|
|
<div style="font-size:15px;line-height:1.65;color:${BRAND.text};">
|
|
Hey ${safeName},<br><br>
|
|
danke für deine Nachricht — ich habe sie erhalten und melde mich so schnell wie möglich bei dir zurück.
|
|
</div>
|
|
|
|
<div style="margin-top:18px;background:${BRAND.bg};border:1px solid ${BRAND.border};border-radius:16px;overflow:hidden;">
|
|
<div style="padding:14px 16px;background:${BRAND.sand};border-bottom:1px solid ${BRAND.border};">
|
|
<div style="font-size:12px;letter-spacing:0.08em;text-transform:uppercase;font-weight:800;color:#57534e;">Deine Nachricht</div>
|
|
</div>
|
|
<div style="padding:16px;line-height:1.65;color:${BRAND.text};font-size:14px;border-left:4px solid ${BRAND.mint};">
|
|
${safeMsg}
|
|
</div>
|
|
</div>
|
|
|
|
<div style="margin-top:20px;text-align:center;">
|
|
<a href="${BRAND.siteUrl}" style="display:inline-block;background:${BRAND.text};color:${BRAND.bg};text-decoration:none;padding:12px 18px;border-radius:999px;font-weight:800;font-size:14px;">
|
|
Portfolio ansehen
|
|
</a>
|
|
</div>
|
|
`.trim(),
|
|
});
|
|
},
|
|
},
|
|
project: {
|
|
subject: "Projekt-Anfrage erhalten! 🚀",
|
|
template: (name: string, originalMessage: string) => {
|
|
const safeName = escapeHtml(name);
|
|
const safeMsg = nl2br(escapeHtml(originalMessage));
|
|
return baseEmail({
|
|
title: `Projekt-Anfrage: danke, ${safeName}!`,
|
|
subtitle: "Ich melde mich zeitnah",
|
|
bodyHtml: `
|
|
<div style="font-size:15px;line-height:1.65;color:${BRAND.text};">
|
|
Hey ${safeName},<br><br>
|
|
mega — danke für die Projekt-Anfrage. Ich schaue mir deine Nachricht an und komme mit Rückfragen/Ideen auf dich zu.
|
|
</div>
|
|
|
|
<div style="margin-top:18px;background:${BRAND.bg};border:1px solid ${BRAND.border};border-radius:16px;overflow:hidden;">
|
|
<div style="padding:14px 16px;background:${BRAND.sand};border-bottom:1px solid ${BRAND.border};">
|
|
<div style="font-size:12px;letter-spacing:0.08em;text-transform:uppercase;font-weight:800;color:#57534e;">Deine Projekt-Nachricht</div>
|
|
</div>
|
|
<div style="padding:16px;line-height:1.65;color:${BRAND.text};font-size:14px;border-left:4px solid ${BRAND.mint};">
|
|
${safeMsg}
|
|
</div>
|
|
</div>
|
|
|
|
<div style="margin-top:20px;text-align:center;">
|
|
<a href="mailto:${BRAND.email}" style="display:inline-block;background:${BRAND.text};color:${BRAND.bg};text-decoration:none;padding:12px 18px;border-radius:999px;font-weight:800;font-size:14px;">
|
|
Kontakt aufnehmen
|
|
</a>
|
|
</div>
|
|
`.trim(),
|
|
});
|
|
},
|
|
},
|
|
quick: {
|
|
subject: "Danke für deine Nachricht! ⚡",
|
|
template: (name: string, originalMessage: string) => {
|
|
const safeName = escapeHtml(name);
|
|
const safeMsg = nl2br(escapeHtml(originalMessage));
|
|
return baseEmail({
|
|
title: `Danke, ${safeName}!`,
|
|
subtitle: "Kurze Bestätigung",
|
|
bodyHtml: `
|
|
<div style="font-size:15px;line-height:1.65;color:${BRAND.text};">
|
|
Hey ${safeName},<br><br>
|
|
kurze Bestätigung: deine Nachricht ist angekommen. Ich melde mich bald zurück.
|
|
</div>
|
|
|
|
<div style="margin-top:18px;background:${BRAND.bg};border:1px solid ${BRAND.border};border-radius:16px;overflow:hidden;">
|
|
<div style="padding:14px 16px;background:${BRAND.sand};border-bottom:1px solid ${BRAND.border};">
|
|
<div style="font-size:12px;letter-spacing:0.08em;text-transform:uppercase;font-weight:800;color:#57534e;">Deine Nachricht</div>
|
|
</div>
|
|
<div style="padding:16px;line-height:1.65;color:${BRAND.text};font-size:14px;border-left:4px solid ${BRAND.mint};">
|
|
${safeMsg}
|
|
</div>
|
|
</div>
|
|
`.trim(),
|
|
});
|
|
},
|
|
},
|
|
reply: {
|
|
subject: "Antwort auf deine Nachricht 📧",
|
|
template: (name: string, originalMessage: string, responseMessage: string) => {
|
|
const safeName = escapeHtml(name);
|
|
const safeOriginal = nl2br(escapeHtml(originalMessage));
|
|
const safeResponse = nl2br(escapeHtml(responseMessage));
|
|
return baseEmail({
|
|
title: `Antwort für ${safeName}`,
|
|
subtitle: "Neue Nachricht",
|
|
bodyHtml: `
|
|
<div style="font-size:15px;line-height:1.65;color:${BRAND.text};">
|
|
Hey ${safeName},<br><br>
|
|
hier ist meine Antwort:
|
|
</div>
|
|
|
|
<div style="margin-top:14px;background:${BRAND.bg};border:1px solid ${BRAND.border};border-radius:16px;overflow:hidden;">
|
|
<div style="padding:14px 16px;background:${BRAND.sand};border-bottom:1px solid ${BRAND.border};">
|
|
<div style="font-size:12px;letter-spacing:0.08em;text-transform:uppercase;font-weight:800;color:#57534e;">Antwort</div>
|
|
</div>
|
|
<div style="padding:16px;line-height:1.65;color:${BRAND.text};font-size:14px;border-left:4px solid ${BRAND.mint};">
|
|
${safeResponse}
|
|
</div>
|
|
</div>
|
|
|
|
<div style="margin-top:16px;background:${BRAND.bg};border:1px solid ${BRAND.border};border-radius:16px;overflow:hidden;">
|
|
<div style="padding:14px 16px;background:${BRAND.sand};border-bottom:1px solid ${BRAND.border};">
|
|
<div style="font-size:12px;letter-spacing:0.08em;text-transform:uppercase;font-weight:800;color:#57534e;">Deine ursprüngliche Nachricht</div>
|
|
</div>
|
|
<div style="padding:16px;line-height:1.65;color:${BRAND.text};font-size:14px;border-left:4px solid ${BRAND.border};">
|
|
${safeOriginal}
|
|
</div>
|
|
</div>
|
|
`.trim(),
|
|
});
|
|
},
|
|
},
|
|
};
|
|
|
|
export async function POST(request: NextRequest) {
|
|
try {
|
|
const isAdminRequest = request.headers.get("x-admin-request") === "true";
|
|
if (!isAdminRequest) return NextResponse.json({ error: "Admin access required" }, { status: 403 });
|
|
const authError = requireSessionAuth(request);
|
|
if (authError) return authError;
|
|
|
|
const ip = getClientIp(request);
|
|
if (!checkRateLimit(ip, 10, 60000)) {
|
|
return NextResponse.json(
|
|
{ error: "Rate limit exceeded" },
|
|
{ status: 429, headers: { ...getRateLimitHeaders(ip, 10, 60000) } },
|
|
);
|
|
}
|
|
|
|
const body = (await request.json()) as {
|
|
to: string;
|
|
name: string;
|
|
template: 'welcome' | 'project' | 'quick' | 'reply';
|
|
originalMessage: string;
|
|
response?: string;
|
|
};
|
|
|
|
const { to, name, template, originalMessage, response } = body;
|
|
|
|
// Validate input
|
|
if (!to || !name || !template || !originalMessage) {
|
|
return NextResponse.json(
|
|
{ error: "Alle Felder sind erforderlich" },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
if (template === "reply" && (!response || !response.trim())) {
|
|
return NextResponse.json({ error: "Antworttext ist erforderlich" }, { status: 400 });
|
|
}
|
|
|
|
// Validate email format
|
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
if (!emailRegex.test(to)) {
|
|
console.error('❌ Validation failed: Invalid email format');
|
|
return NextResponse.json(
|
|
{ error: "Ungültige E-Mail-Adresse" },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
// Check if template exists
|
|
if (!emailTemplates[template]) {
|
|
return NextResponse.json(
|
|
{ error: "Ungültiges Template" },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
const user = process.env.MY_EMAIL ?? "";
|
|
const pass = process.env.MY_PASSWORD ?? "";
|
|
|
|
if (!user || !pass) {
|
|
console.error("❌ Missing email/password environment variables");
|
|
return NextResponse.json(
|
|
{ error: "E-Mail-Server nicht konfiguriert" },
|
|
{ status: 500 },
|
|
);
|
|
}
|
|
|
|
const transportOptions: SMTPTransport.Options = {
|
|
host: "mail.dk0.dev",
|
|
port: 587,
|
|
secure: false,
|
|
requireTLS: true,
|
|
auth: {
|
|
type: "login",
|
|
user,
|
|
pass,
|
|
},
|
|
connectionTimeout: 30000,
|
|
greetingTimeout: 30000,
|
|
socketTimeout: 60000,
|
|
tls: {
|
|
rejectUnauthorized: false,
|
|
ciphers: 'SSLv3'
|
|
}
|
|
};
|
|
|
|
const transport = nodemailer.createTransport(transportOptions);
|
|
|
|
// Verify transport configuration
|
|
try {
|
|
await transport.verify();
|
|
} catch (_verifyError) {
|
|
return NextResponse.json(
|
|
{ error: "E-Mail-Server-Verbindung fehlgeschlagen" },
|
|
{ status: 500 },
|
|
);
|
|
}
|
|
|
|
const selectedTemplate = emailTemplates[template];
|
|
let html: string;
|
|
if (template === "reply") {
|
|
html = emailTemplates.reply.template(name, originalMessage, response || "");
|
|
} else {
|
|
// Narrow the template type so TS knows this is not the 3-arg reply template
|
|
const nonReplyTemplate = template as Exclude<typeof template, "reply">;
|
|
html = emailTemplates[nonReplyTemplate].template(name, originalMessage);
|
|
}
|
|
const mailOptions: Mail.Options = {
|
|
from: `"Dennis Konkol" <${user}>`,
|
|
to: to,
|
|
replyTo: "contact@dk0.dev",
|
|
subject: selectedTemplate.subject,
|
|
html,
|
|
text: `
|
|
Hallo ${name}!
|
|
|
|
Vielen Dank für deine Nachricht:
|
|
${originalMessage}
|
|
|
|
${template === "reply" ? `\nAntwort:\n${response || ""}\n` : "\nIch werde mich so schnell wie möglich bei dir melden.\n"}
|
|
|
|
Beste Grüße,
|
|
Dennis Konkol
|
|
Software Engineer & Student
|
|
https://dki.one
|
|
contact@dk0.dev
|
|
`,
|
|
};
|
|
|
|
const sendMailPromise = () =>
|
|
new Promise<string>((resolve, reject) => {
|
|
transport.sendMail(mailOptions, function (err, info) {
|
|
if (!err) {
|
|
resolve(info.response);
|
|
} else {
|
|
reject(err.message);
|
|
}
|
|
});
|
|
});
|
|
|
|
const result = await sendMailPromise();
|
|
|
|
return NextResponse.json({
|
|
message: "Template-E-Mail erfolgreich gesendet",
|
|
template: template,
|
|
messageId: result
|
|
});
|
|
|
|
} catch (err) {
|
|
return NextResponse.json({
|
|
error: "Fehler beim Senden der Template-E-Mail",
|
|
details: err instanceof Error ? err.message : 'Unbekannter Fehler'
|
|
}, { status: 500 });
|
|
}
|
|
}
|