367 lines
15 KiB
TypeScript
367 lines
15 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 { PrismaClient } from '@prisma/client';
|
|
import { checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
|
|
|
|
const prisma = new PrismaClient();
|
|
|
|
// Sanitize input to prevent XSS
|
|
function sanitizeInput(input: string, maxLength: number = 10000): string {
|
|
return input
|
|
.slice(0, maxLength)
|
|
.replace(/[<>]/g, '') // Remove potential HTML tags
|
|
.trim();
|
|
}
|
|
|
|
function escapeHtml(input: string): string {
|
|
return input
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
export async function POST(request: NextRequest) {
|
|
try {
|
|
// Rate limiting (defensive: headers may be undefined in tests)
|
|
const ip = request.headers?.get?.('x-forwarded-for') ?? request.headers?.get?.('x-real-ip') ?? 'unknown';
|
|
if (!checkRateLimit(ip, 5, 60000)) { // 5 emails per minute per IP
|
|
return NextResponse.json(
|
|
{ error: 'Zu viele Anfragen. Bitte versuchen Sie es später erneut.' },
|
|
{
|
|
status: 429,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...getRateLimitHeaders(ip, 5, 60000)
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
const body = (await request.json()) as {
|
|
email: string;
|
|
name: string;
|
|
subject: string;
|
|
message: string;
|
|
};
|
|
|
|
// Sanitize and validate input
|
|
const email = sanitizeInput(body.email || '', 255);
|
|
const name = sanitizeInput(body.name || '', 100);
|
|
const subject = sanitizeInput(body.subject || '', 200);
|
|
const message = sanitizeInput(body.message || '', 5000);
|
|
|
|
// Email request received
|
|
|
|
// Validate input
|
|
if (!email || !name || !subject || !message) {
|
|
console.error('❌ Validation failed: Missing required fields');
|
|
return NextResponse.json(
|
|
{ error: "Alle Felder sind erforderlich" },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
// Validate email format
|
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
if (!emailRegex.test(email)) {
|
|
console.error('❌ Validation failed: Invalid email format');
|
|
return NextResponse.json(
|
|
{ error: "Ungültige E-Mail-Adresse" },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
// Validate message length
|
|
if (message.length < 10) {
|
|
console.error('❌ Validation failed: Message too short');
|
|
return NextResponse.json(
|
|
{ error: "Nachricht muss mindestens 10 Zeichen lang sein" },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
// Validate field lengths
|
|
if (name.length > 100 || subject.length > 200 || message.length > 5000) {
|
|
return NextResponse.json(
|
|
{ error: "Eingabe zu lang" },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
const user = process.env.MY_EMAIL ?? "";
|
|
const pass = process.env.MY_PASSWORD ?? "";
|
|
|
|
console.log('🔑 Environment check:', {
|
|
hasEmail: !!user,
|
|
hasPassword: !!pass,
|
|
emailHost: user.split('@')[1] || 'unknown'
|
|
});
|
|
|
|
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, // Port 587 uses STARTTLS, not SSL/TLS
|
|
requireTLS: true,
|
|
auth: {
|
|
type: "login",
|
|
user,
|
|
pass,
|
|
},
|
|
// Increased timeout settings for better reliability
|
|
connectionTimeout: 30000, // 30 seconds
|
|
greetingTimeout: 30000, // 30 seconds
|
|
socketTimeout: 60000, // 60 seconds
|
|
// Additional TLS options for better compatibility
|
|
tls: {
|
|
rejectUnauthorized: false, // Allow self-signed certificates
|
|
ciphers: 'SSLv3'
|
|
}
|
|
};
|
|
|
|
// Creating transport with configured options
|
|
|
|
const transport = nodemailer.createTransport(transportOptions);
|
|
|
|
// Verify transport configuration with retry logic
|
|
let verificationAttempts = 0;
|
|
const maxVerificationAttempts = 3;
|
|
let verificationSuccess = false;
|
|
|
|
while (verificationAttempts < maxVerificationAttempts && !verificationSuccess) {
|
|
try {
|
|
verificationAttempts++;
|
|
await transport.verify();
|
|
verificationSuccess = true;
|
|
} catch (verifyError) {
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.error(`SMTP verification attempt ${verificationAttempts} failed:`, verifyError);
|
|
}
|
|
|
|
if (verificationAttempts >= maxVerificationAttempts) {
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.error('All SMTP verification attempts failed');
|
|
}
|
|
return NextResponse.json(
|
|
{ error: "E-Mail-Server-Verbindung fehlgeschlagen" },
|
|
{ status: 500 },
|
|
);
|
|
}
|
|
|
|
// Wait before retry
|
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
}
|
|
}
|
|
|
|
const brandUrl = "https://dk0.dev";
|
|
const sentAt = new Date().toLocaleString('de-DE', {
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
});
|
|
|
|
const safeName = escapeHtml(name);
|
|
const safeEmail = escapeHtml(email);
|
|
const safeSubject = escapeHtml(subject);
|
|
const safeMessageHtml = escapeHtml(message).replace(/\n/g, "<br>");
|
|
const initial = (name.trim()[0] || "?").toUpperCase();
|
|
const replyHref = `mailto:${email}?subject=${encodeURIComponent(`Re: ${subject}`)}`;
|
|
|
|
const mailOptions: Mail.Options = {
|
|
from: `"Portfolio Contact" <${user}>`,
|
|
to: "contact@dk0.dev", // Send to your contact email
|
|
replyTo: email,
|
|
subject: `Portfolio Kontakt: ${subject}`,
|
|
html: `
|
|
<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Neue Kontaktanfrage - Portfolio</title>
|
|
</head>
|
|
<body style="margin:0;padding:0;background-color:#fdfcf8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;color:#292524;">
|
|
<div style="max-width:640px;margin:0 auto;padding:28px 14px;">
|
|
<div style="background:#ffffff;border:1px solid #e7e5e4;border-radius:20px;overflow:hidden;box-shadow:0 18px 50px rgba(0,0,0,0.08);">
|
|
<!-- Top bar -->
|
|
<div style="background:#292524;padding:22px 26px;">
|
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:16px;">
|
|
<div style="font-weight:700;font-size:16px;letter-spacing:-0.01em;color:#fdfcf8;">
|
|
Dennis Konkol
|
|
</div>
|
|
<div style="font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,'Liberation Mono','Courier New',monospace;font-weight:700;font-size:14px;color:#fdfcf8;">
|
|
dk<span style="color:#ef4444;">0</span>.dev
|
|
</div>
|
|
</div>
|
|
<div style="margin-top:10px;">
|
|
<div style="font-size:22px;font-weight:800;letter-spacing:-0.02em;color:#fdfcf8;">
|
|
Neue Kontaktanfrage
|
|
</div>
|
|
<div style="margin-top:4px;font-size:13px;color:#d6d3d1;">
|
|
Eingegangen am ${sentAt}
|
|
</div>
|
|
</div>
|
|
<div style="height:3px;background:#a7f3d0;margin-top:18px;border-radius:999px;"></div>
|
|
</div>
|
|
|
|
<!-- Content -->
|
|
<div style="padding:26px;">
|
|
<!-- Sender -->
|
|
<div style="display:flex;align-items:flex-start;gap:14px;">
|
|
<div style="width:44px;height:44px;border-radius:14px;background:#f3f1e7;border:1px solid #e7e5e4;display:flex;align-items:center;justify-content:center;font-weight:800;color:#292524;">
|
|
${escapeHtml(initial)}
|
|
</div>
|
|
<div style="flex:1;min-width:0;">
|
|
<div style="font-size:18px;font-weight:800;letter-spacing:-0.01em;color:#292524;line-height:1.2;">
|
|
${safeName}
|
|
</div>
|
|
<div style="margin-top:6px;font-size:13px;color:#78716c;line-height:1.4;">
|
|
<span style="font-weight:700;color:#44403c;">E-Mail:</span> ${safeEmail}<br>
|
|
<span style="font-weight:700;color:#44403c;">Betreff:</span> ${safeSubject}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Message -->
|
|
<div style="margin-top:18px;background:#fdfcf8;border:1px solid #e7e5e4;border-radius:16px;overflow:hidden;">
|
|
<div style="padding:14px 16px;background:#f3f1e7;border-bottom:1px solid #e7e5e4;">
|
|
<div style="font-size:12px;letter-spacing:0.08em;text-transform:uppercase;font-weight:800;color:#57534e;">
|
|
Nachricht
|
|
</div>
|
|
</div>
|
|
<div style="padding:16px;line-height:1.65;color:#292524;font-size:15px;border-left:4px solid #a7f3d0;">
|
|
${safeMessageHtml}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- CTA -->
|
|
<div style="margin-top:22px;text-align:center;">
|
|
<a href="${escapeHtml(replyHref)}"
|
|
style="display:inline-block;background:#292524;color:#fdfcf8;text-decoration:none;padding:12px 18px;border-radius:999px;font-weight:800;font-size:14px;">
|
|
Antworten
|
|
</a>
|
|
<div style="margin-top:10px;font-size:12px;color:#78716c;">
|
|
Oder antworte direkt auf diese E-Mail.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Footer -->
|
|
<div style="padding:18px 26px;background:#fdfcf8;border-top:1px solid #e7e5e4;">
|
|
<div style="font-size:12px;color:#78716c;line-height:1.5;">
|
|
Automatisch generiert von <a href="${brandUrl}" style="color:#292524;text-decoration:underline;">dk0.dev</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`,
|
|
text: `
|
|
Neue Kontaktanfrage von deinem Portfolio
|
|
|
|
Von: ${name} (${email})
|
|
Betreff: ${subject}
|
|
|
|
Nachricht:
|
|
${message}
|
|
|
|
---
|
|
Diese E-Mail wurde automatisch von dk0.dev generiert.
|
|
`,
|
|
};
|
|
|
|
// Sending email
|
|
|
|
// Email sending with retry logic
|
|
let sendAttempts = 0;
|
|
const maxSendAttempts = 3;
|
|
let sendSuccess = false;
|
|
let result = '';
|
|
|
|
while (sendAttempts < maxSendAttempts && !sendSuccess) {
|
|
try {
|
|
sendAttempts++;
|
|
// Email send attempt
|
|
|
|
const sendMailPromise = () =>
|
|
new Promise<string>((resolve, reject) => {
|
|
transport.sendMail(mailOptions, function (err, info) {
|
|
if (!err) {
|
|
// Email sent successfully
|
|
resolve(info.response);
|
|
} else {
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.error("Error sending email:", err);
|
|
}
|
|
reject(err.message);
|
|
}
|
|
});
|
|
});
|
|
|
|
result = await sendMailPromise();
|
|
sendSuccess = true;
|
|
// Email process completed successfully
|
|
} catch (sendError) {
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.error(`Email send attempt ${sendAttempts} failed:`, sendError);
|
|
}
|
|
|
|
if (sendAttempts >= maxSendAttempts) {
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.error('All email send attempts failed');
|
|
}
|
|
throw new Error(`Failed to send email after ${maxSendAttempts} attempts: ${sendError}`);
|
|
}
|
|
|
|
// Wait before retry
|
|
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
}
|
|
}
|
|
|
|
// Save contact to database
|
|
try {
|
|
await prisma.contact.create({
|
|
data: {
|
|
name,
|
|
email,
|
|
subject,
|
|
message,
|
|
responded: false
|
|
}
|
|
});
|
|
// Contact saved to database
|
|
} catch (dbError) {
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.error('Error saving contact to database:', dbError);
|
|
}
|
|
// Don't fail the email send if DB save fails
|
|
}
|
|
|
|
return NextResponse.json({
|
|
message: "E-Mail erfolgreich gesendet",
|
|
messageId: result
|
|
});
|
|
|
|
} catch (err) {
|
|
console.error("❌ Unexpected error in email API:", err);
|
|
return NextResponse.json({
|
|
error: "Fehler beim Senden der E-Mail",
|
|
details: err instanceof Error ? err.message : 'Unbekannter Fehler'
|
|
}, { status: 500 });
|
|
}
|
|
}
|