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>
282 lines
11 KiB
TypeScript
282 lines
11 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 B = {
|
|
siteUrl: "https://dk0.dev",
|
|
email: "contact@dk0.dev",
|
|
mint: "#A7F3D0",
|
|
sky: "#BAE6FD",
|
|
purple: "#E9D5FF",
|
|
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 escapeHtml(input).replace(/\r\n|\r|\n/g, "<br>");
|
|
}
|
|
|
|
function baseEmail(opts: { title: string; preheader: string; bodyHtml: string }): 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:#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;">
|
|
<div style="background:#141414;border-radius:24px;overflow:hidden;border:1px solid #222;">
|
|
|
|
<!-- Header -->
|
|
<div style="background:#111;border-bottom:1px solid #1e1e1e;">
|
|
<div style="height:3px;background:linear-gradient(90deg,${B.mint} 0%,${B.sky} 50%,${B.purple} 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;">
|
|
${escapeHtml(opts.preheader)} · ${sentAt}
|
|
</div>
|
|
<div style="font-size:26px;font-weight:900;color:#f3f4f6;letter-spacing:-0.03em;line-height:1.15;">
|
|
${escapeHtml(opts.title)}
|
|
</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:${B.red};">0</span>.dev
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Body -->
|
|
<div style="padding:28px;">
|
|
${opts.bodyHtml}
|
|
</div>
|
|
|
|
<!-- Footer -->
|
|
<div style="padding:16px 28px;background:#0c0c0c;border-top:1px solid #1a1a1a;">
|
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;">
|
|
<div style="font-size:11px;color:#374151;">
|
|
<a href="${B.siteUrl}" style="color:#4b5563;text-decoration:none;">${B.siteUrl}</a>
|
|
</div>
|
|
<div style="font-size:11px;color:#374151;">
|
|
<a href="mailto:${B.email}" style="color:#4b5563;text-decoration:none;">${B.email}</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
function messageCard(label: string, html: string, accentColor: string = B.mint): string {
|
|
return `
|
|
<div style="background:#0f0f0f;border:1px solid #1e1e1e;border-left:3px solid ${accentColor};border-radius:0 12px 12px 0;overflow:hidden;">
|
|
<div style="padding:10px 16px;background:#161616;border-bottom:1px solid #1e1e1e;">
|
|
<span style="font-size:10px;letter-spacing:0.14em;text-transform:uppercase;font-weight:700;color:#4b5563;">${label}</span>
|
|
</div>
|
|
<div style="padding:16px 18px;font-size:15px;line-height:1.75;color:#d1d5db;">${html}</div>
|
|
</div>`;
|
|
}
|
|
|
|
function ctaButton(text: string, href: string): string {
|
|
return `
|
|
<div style="margin-top:24px;text-align:center;">
|
|
<a href="${href}" style="display:inline-block;background:linear-gradient(135deg,${B.mint},${B.sky});color:#111;text-decoration:none;padding:14px 32px;border-radius:12px;font-weight:800;font-size:15px;letter-spacing:-0.01em;">
|
|
${text}
|
|
</a>
|
|
</div>`;
|
|
}
|
|
|
|
const emailTemplates = {
|
|
welcome: {
|
|
subject: "Vielen Dank für deine Nachricht! 👋",
|
|
template: (name: string, originalMessage: string) => {
|
|
const safeName = escapeHtml(name);
|
|
return baseEmail({
|
|
title: `Danke, ${safeName}!`,
|
|
preheader: "Nachricht erhalten",
|
|
bodyHtml: `
|
|
<p style="font-size:15px;line-height:1.7;color:#d1d5db;margin:0 0 20px;">
|
|
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. 🙌
|
|
</p>
|
|
${messageCard("Deine Nachricht", nl2br(originalMessage))}
|
|
${ctaButton("Portfolio ansehen →", B.siteUrl)}`,
|
|
});
|
|
},
|
|
},
|
|
project: {
|
|
subject: "Projekt-Anfrage erhalten! 🚀",
|
|
template: (name: string, originalMessage: string) => {
|
|
const safeName = escapeHtml(name);
|
|
return baseEmail({
|
|
title: `Projekt-Anfrage: danke, ${safeName}!`,
|
|
preheader: "Ich melde mich zeitnah",
|
|
bodyHtml: `
|
|
<p style="font-size:15px;line-height:1.7;color:#d1d5db;margin:0 0 20px;">
|
|
Hey ${safeName},<br><br>
|
|
mega — danke für die Projekt-Anfrage! Ich schaue mir alles an und melde mich bald mit Ideen und Rückfragen. 🚀
|
|
</p>
|
|
${messageCard("Deine Projekt-Anfrage", nl2br(originalMessage), B.sky)}
|
|
${ctaButton("Mein Portfolio ansehen →", B.siteUrl)}`,
|
|
});
|
|
},
|
|
},
|
|
quick: {
|
|
subject: "Danke für deine Nachricht! ⚡",
|
|
template: (name: string, originalMessage: string) => {
|
|
const safeName = escapeHtml(name);
|
|
return baseEmail({
|
|
title: `Danke, ${safeName}!`,
|
|
preheader: "Kurze Bestätigung",
|
|
bodyHtml: `
|
|
<p style="font-size:15px;line-height:1.7;color:#d1d5db;margin:0 0 20px;">
|
|
Hey ${safeName},<br><br>
|
|
kurze Bestätigung: deine Nachricht ist angekommen. Ich melde mich bald zurück. ⚡
|
|
</p>
|
|
${messageCard("Deine Nachricht", nl2br(originalMessage))}`,
|
|
});
|
|
},
|
|
},
|
|
reply: {
|
|
subject: "Antwort auf deine Nachricht 📧",
|
|
template: (name: string, originalMessage: string, responseMessage: string) => {
|
|
const safeName = escapeHtml(name);
|
|
return baseEmail({
|
|
title: `Hey ${safeName}!`,
|
|
preheader: "Antwort von Dennis",
|
|
bodyHtml: `
|
|
<p style="font-size:15px;line-height:1.7;color:#d1d5db;margin:0 0 20px;">
|
|
Hey ${safeName},<br><br>
|
|
ich habe mir deine Nachricht angeschaut — hier ist meine Antwort:
|
|
</p>
|
|
${messageCard("Antwort von Dennis", nl2br(responseMessage), B.mint)}
|
|
<div style="margin-top:16px;">
|
|
${messageCard("Deine ursprüngliche Nachricht", nl2br(originalMessage), "#2a2a2a")}
|
|
</div>
|
|
${ctaButton("Portfolio ansehen →", B.siteUrl)}`,
|
|
});
|
|
},
|
|
},
|
|
};
|
|
|
|
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;
|
|
|
|
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 });
|
|
}
|
|
|
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
if (!emailRegex.test(to)) {
|
|
return NextResponse.json({ error: "Ungültige E-Mail-Adresse" }, { status: 400 });
|
|
}
|
|
|
|
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);
|
|
|
|
try {
|
|
await transport.verify();
|
|
} catch {
|
|
return NextResponse.json({ error: "E-Mail-Server-Verbindung fehlgeschlagen" }, { status: 500 });
|
|
}
|
|
|
|
const selectedTemplate = emailTemplates[template];
|
|
const html = template === "reply"
|
|
? emailTemplates.reply.template(name, originalMessage, response || "")
|
|
: emailTemplates[template as Exclude<typeof template, "reply">].template(name, originalMessage);
|
|
|
|
const mailOptions: Mail.Options = {
|
|
from: `"Dennis Konkol" <${user}>`,
|
|
to,
|
|
replyTo: B.email,
|
|
subject: selectedTemplate.subject,
|
|
html,
|
|
text: template === "reply"
|
|
? `Hey ${name}!\n\nAntwort:\n${response}\n\nDeine ursprüngliche Nachricht:\n${originalMessage}\n\n-- Dennis Konkol\n${B.siteUrl}`
|
|
: `Hey ${name}!\n\nDanke für deine Nachricht:\n${originalMessage}\n\nIch melde mich bald!\n\n-- Dennis Konkol\n${B.siteUrl}`,
|
|
};
|
|
|
|
const result = await new Promise<string>((resolve, reject) => {
|
|
transport.sendMail(mailOptions, (err, info) => {
|
|
if (!err) resolve(info.response);
|
|
else reject(err.message);
|
|
});
|
|
});
|
|
|
|
return NextResponse.json({ message: "E-Mail erfolgreich gesendet", template, messageId: result });
|
|
|
|
} catch (err) {
|
|
return NextResponse.json({
|
|
error: "Fehler beim Senden der E-Mail",
|
|
details: err instanceof Error ? err.message : 'Unbekannter Fehler',
|
|
}, { status: 500 });
|
|
}
|
|
}
|