Notification email (to Dennis): - Complete dark-theme redesign: #0c0c0c bg, #141414 card, gradient top bar - Sender avatar with liquid-mint/sky gradient + initial letter - Subject displayed as pill badge - Message in styled blockquote with mint left border - Gradient "Direkt antworten" CTA button - replyTo header already set so email Reply goes directly to sender Telegram notification: - sendTelegramNotification() fires after successful email send (fire-and-forget) - Uses TELEGRAM_BOT_TOKEN + TELEGRAM_CHAT_ID env vars (silently skips if absent) - HTML-formatted message with emojis, name/email/subject/message preview - Inline keyboard button "Per E-Mail antworten" with pre-filled mailto link - Never blocks the contact form response if Telegram fails Reply email templates (respond/route.tsx): - Same dark design system as notification email - baseEmail() generates consistent header + footer - messageCard() helper for styled message blocks with colored left border - ctaButton() helper for gradient CTA buttons - Templates: welcome, project, quick, reply — all updated to dark theme Required new env vars: TELEGRAM_BOT_TOKEN=<from @BotFather> TELEGRAM_CHAT_ID=<your chat/user ID> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
332 lines
13 KiB
TypeScript
332 lines
13 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 } from '@/lib/auth';
|
|
import { prisma } from "@/lib/prisma";
|
|
|
|
function sanitizeInput(input: string, maxLength: number = 10000): string {
|
|
return input.slice(0, maxLength).replace(/[<>]/g, '').trim();
|
|
}
|
|
|
|
function escapeHtml(input: string): string {
|
|
return input
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
function escapeHtmlTg(input: string): string {
|
|
return input.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
}
|
|
|
|
async function sendTelegramNotification(data: {
|
|
name: string;
|
|
email: string;
|
|
subject: string;
|
|
message: string;
|
|
sentAt: string;
|
|
}): Promise<void> {
|
|
const token = process.env.TELEGRAM_BOT_TOKEN;
|
|
const chatId = process.env.TELEGRAM_CHAT_ID;
|
|
if (!token || !chatId) return;
|
|
|
|
const preview = data.message.length > 400
|
|
? data.message.slice(0, 400) + "…"
|
|
: data.message;
|
|
|
|
const text = [
|
|
"🔔 <b>Neue Kontaktanfrage</b>",
|
|
"",
|
|
`👤 <b>Name:</b> ${escapeHtmlTg(data.name)}`,
|
|
`📧 <b>Email:</b> ${escapeHtmlTg(data.email)}`,
|
|
`📌 <b>Betreff:</b> ${escapeHtmlTg(data.subject)}`,
|
|
"",
|
|
"💬 <b>Nachricht:</b>",
|
|
`<i>${escapeHtmlTg(preview)}</i>`,
|
|
"",
|
|
`🕐 <i>${escapeHtmlTg(data.sentAt)}</i>`,
|
|
].join("\n");
|
|
|
|
try {
|
|
await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
chat_id: chatId,
|
|
text,
|
|
parse_mode: "HTML",
|
|
reply_markup: {
|
|
inline_keyboard: [[
|
|
{
|
|
text: "📧 Per E-Mail antworten",
|
|
url: `mailto:${data.email}?subject=${encodeURIComponent("Re: " + data.subject)}`,
|
|
},
|
|
]],
|
|
},
|
|
}),
|
|
signal: AbortSignal.timeout(5000),
|
|
});
|
|
} catch {
|
|
// Never fail the contact form because of Telegram
|
|
}
|
|
}
|
|
|
|
function buildNotificationEmail(opts: {
|
|
name: string;
|
|
email: string;
|
|
subject: string;
|
|
messageHtml: string;
|
|
initial: string;
|
|
replyHref: string;
|
|
sentAt: string;
|
|
}): string {
|
|
const { name, email, subject, messageHtml, initial, replyHref, sentAt } = opts;
|
|
return `<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
<title>Neue Kontaktanfrage</title>
|
|
</head>
|
|
<body style="margin:0;padding:0;background-color:#0c0c0c;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
|
|
|
|
<div style="max-width:600px;margin:0 auto;padding:24px 16px 40px;">
|
|
|
|
<!-- Card -->
|
|
<div style="background:#141414;border-radius:24px;overflow:hidden;border:1px solid #222;">
|
|
|
|
<!-- Header -->
|
|
<div style="background:#111;padding:0 0 0 0;border-bottom:1px solid #1e1e1e;">
|
|
<!-- Gradient bar -->
|
|
<div style="height:3px;background:linear-gradient(90deg,#a7f3d0 0%,#bae6fd 50%,#e9d5ff 100%);"></div>
|
|
|
|
<div style="padding:28px 28px 24px;">
|
|
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px;">
|
|
<div>
|
|
<div style="font-size:10px;letter-spacing:0.15em;text-transform:uppercase;color:#555;font-weight:700;margin-bottom:8px;">
|
|
dk0.dev · Portfolio Kontakt
|
|
</div>
|
|
<div style="font-size:26px;font-weight:900;color:#f3f4f6;letter-spacing:-0.03em;line-height:1.15;">
|
|
Neue Kontaktanfrage
|
|
</div>
|
|
<div style="margin-top:6px;font-size:13px;color:#4b5563;">
|
|
${escapeHtml(sentAt)}
|
|
</div>
|
|
</div>
|
|
<div style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px;font-weight:800;color:#374151;flex-shrink:0;padding-top:4px;">
|
|
dk<span style="color:#ef4444;">0</span>.dev
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sender -->
|
|
<div style="padding:24px 28px;border-bottom:1px solid #1e1e1e;">
|
|
<div style="display:flex;align-items:center;gap:16px;">
|
|
<!-- Avatar -->
|
|
<div style="width:52px;height:52px;border-radius:16px;background:linear-gradient(135deg,#a7f3d0,#bae6fd);display:flex;align-items:center;justify-content:center;font-size:22px;font-weight:900;color:#111;flex-shrink:0;">
|
|
${escapeHtml(initial)}
|
|
</div>
|
|
<div style="min-width:0;">
|
|
<div style="font-size:18px;font-weight:800;color:#f9fafb;letter-spacing:-0.02em;">${escapeHtml(name)}</div>
|
|
<div style="font-size:13px;color:#6b7280;margin-top:3px;">${escapeHtml(email)}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Subject pill -->
|
|
<div style="margin-top:16px;">
|
|
<span style="display:inline-flex;align-items:center;gap:7px;background:#1c1c1c;border:1px solid #2a2a2a;border-radius:100px;padding:6px 14px;">
|
|
<span style="width:6px;height:6px;border-radius:50%;background:#a7f3d0;display:inline-block;flex-shrink:0;"></span>
|
|
<span style="font-size:13px;font-weight:600;color:#d1d5db;">${escapeHtml(subject)}</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Message -->
|
|
<div style="padding:24px 28px;border-bottom:1px solid #1e1e1e;">
|
|
<div style="font-size:10px;letter-spacing:0.14em;text-transform:uppercase;font-weight:700;color:#4b5563;margin-bottom:12px;">
|
|
Nachricht
|
|
</div>
|
|
<div style="background:#0f0f0f;border:1px solid #1e1e1e;border-left:3px solid #a7f3d0;border-radius:0 12px 12px 0;padding:18px 20px;font-size:15px;line-height:1.75;color:#d1d5db;">
|
|
${messageHtml}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- CTA -->
|
|
<div style="padding:24px 28px;border-bottom:1px solid #1e1e1e;">
|
|
<a href="${escapeHtml(replyHref)}"
|
|
style="display:block;text-align:center;background:linear-gradient(135deg,#a7f3d0,#bae6fd);color:#111;text-decoration:none;padding:14px 24px;border-radius:12px;font-weight:800;font-size:15px;letter-spacing:-0.01em;">
|
|
Direkt antworten →
|
|
</a>
|
|
<div style="margin-top:10px;text-align:center;font-size:12px;color:#374151;">
|
|
Oder einfach auf diese E-Mail antworten — Reply-To ist bereits gesetzt.
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Footer -->
|
|
<div style="padding:16px 28px;background:#0c0c0c;">
|
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;">
|
|
<div style="font-size:11px;color:#374151;">
|
|
Automatisch generiert · <a href="https://dk0.dev" style="color:#4b5563;text-decoration:none;">dk0.dev</a>
|
|
</div>
|
|
<div style="font-size:11px;color:#374151;">
|
|
contact@dk0.dev
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
export async function POST(request: NextRequest) {
|
|
try {
|
|
const ip = request.headers?.get?.('x-forwarded-for') ?? request.headers?.get?.('x-real-ip') ?? 'unknown';
|
|
if (!checkRateLimit(ip, 5, 60000)) {
|
|
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;
|
|
};
|
|
|
|
const email = sanitizeInput(body.email || '', 255);
|
|
const name = sanitizeInput(body.name || '', 100);
|
|
const subject = sanitizeInput(body.subject || '', 200);
|
|
const message = sanitizeInput(body.message || '', 5000);
|
|
|
|
if (!email || !name || !subject || !message) {
|
|
return NextResponse.json({ error: "Alle Felder sind erforderlich" }, { status: 400 });
|
|
}
|
|
|
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
if (!emailRegex.test(email)) {
|
|
return NextResponse.json({ error: "Ungültige E-Mail-Adresse" }, { status: 400 });
|
|
}
|
|
|
|
if (message.length < 10) {
|
|
return NextResponse.json({ error: "Nachricht muss mindestens 10 Zeichen lang sein" }, { status: 400 });
|
|
}
|
|
|
|
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 ?? "";
|
|
|
|
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:
|
|
process.env.SMTP_ALLOW_INSECURE_TLS === "true" || process.env.SMTP_ALLOW_SELF_SIGNED === "true"
|
|
? { rejectUnauthorized: false }
|
|
: { rejectUnauthorized: true, minVersion: "TLSv1.2" },
|
|
};
|
|
|
|
const transport = nodemailer.createTransport(transportOptions);
|
|
|
|
let verificationAttempts = 0;
|
|
while (verificationAttempts < 3) {
|
|
try {
|
|
verificationAttempts++;
|
|
await transport.verify();
|
|
break;
|
|
} catch (verifyError) {
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.error(`SMTP verification attempt ${verificationAttempts} failed:`, verifyError);
|
|
}
|
|
if (verificationAttempts >= 3) {
|
|
return NextResponse.json({ error: "E-Mail-Server-Verbindung fehlgeschlagen" }, { status: 500 });
|
|
}
|
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
}
|
|
}
|
|
|
|
const sentAt = new Date().toLocaleString('de-DE', {
|
|
year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit',
|
|
});
|
|
|
|
const initial = (name.trim()[0] || "?").toUpperCase();
|
|
const replyHref = `mailto:${email}?subject=${encodeURIComponent(`Re: ${subject}`)}`;
|
|
const messageHtml = escapeHtml(message).replace(/\n/g, "<br>");
|
|
|
|
const mailOptions: Mail.Options = {
|
|
from: `"Portfolio Contact" <${user}>`,
|
|
to: "contact@dk0.dev",
|
|
replyTo: email,
|
|
subject: `📬 Neue Anfrage: ${subject}`,
|
|
html: buildNotificationEmail({ name, email, subject, messageHtml, initial, replyHref, sentAt }),
|
|
text: `Neue Kontaktanfrage\n\nVon: ${name} (${email})\nBetreff: ${subject}\n\n${message}\n\n---\nEingegangen: ${sentAt}`,
|
|
};
|
|
|
|
let sendAttempts = 0;
|
|
let result = '';
|
|
|
|
while (sendAttempts < 3) {
|
|
try {
|
|
sendAttempts++;
|
|
result = await new Promise<string>((resolve, reject) => {
|
|
transport.sendMail(mailOptions, (err, info) => {
|
|
if (!err) resolve(info.response);
|
|
else {
|
|
if (process.env.NODE_ENV === 'development') console.error("Error sending email:", err);
|
|
reject(err.message);
|
|
}
|
|
});
|
|
});
|
|
break;
|
|
} catch (sendError) {
|
|
if (sendAttempts >= 3) {
|
|
throw new Error(`Failed to send email after 3 attempts: ${sendError}`);
|
|
}
|
|
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
}
|
|
}
|
|
|
|
// Telegram notification — fire and forget, never blocks the response
|
|
sendTelegramNotification({ name, email, subject, message, sentAt }).catch(() => {});
|
|
|
|
// Save to DB
|
|
try {
|
|
await prisma.contact.create({ data: { name, email, subject, message, responded: false } });
|
|
} catch (dbError) {
|
|
if (process.env.NODE_ENV === 'development') console.error('Error saving contact to DB:', dbError);
|
|
}
|
|
|
|
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 });
|
|
}
|
|
}
|