feat: dark email design + Telegram notification for contact form
Some checks failed
CI / CD / deploy-dev (push) Has been cancelled
CI / CD / deploy-production (push) Has been cancelled
CI / CD / test-build (push) Has been cancelled

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>
This commit is contained in:
2026-03-07 00:17:39 +01:00
parent bdf02b2a3a
commit 63960f7581
2 changed files with 333 additions and 444 deletions

View File

@@ -4,15 +4,12 @@ import SMTPTransport from "nodemailer/lib/smtp-transport";
import Mail from "nodemailer/lib/mailer";
import { checkRateLimit, getRateLimitHeaders, getClientIp, requireSessionAuth } from "@/lib/auth";
const BRAND = {
const B = {
siteUrl: "https://dk0.dev",
email: "contact@dk0.dev",
bg: "#FDFCF8",
sand: "#F3F1E7",
border: "#E7E5E4",
text: "#292524",
muted: "#78716C",
mint: "#A7F3D0",
sky: "#BAE6FD",
purple: "#E9D5FF",
red: "#EF4444",
};
@@ -26,58 +23,86 @@ function escapeHtml(input: string): string {
}
function nl2br(input: string): string {
return input.replace(/\r\n|\r|\n/g, "<br>");
return escapeHtml(input).replace(/\r\n|\r|\n/g, "<br>");
}
function baseEmail(opts: { title: string; subtitle: string; bodyHtml: string }) {
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",
year: "numeric", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit",
});
return `
<!DOCTYPE html>
return `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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
<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)} &middot; ${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 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;">
<!-- Body -->
<div style="padding:28px;">
${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>
<!-- 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>
`.trim();
</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 = {
@@ -85,31 +110,16 @@ const emailTemplates = {
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",
preheader: "Nachricht erhalten",
bodyHtml: `
<div style="font-size:15px;line-height:1.65;color:${BRAND.text};">
<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.
</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(),
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)}`,
});
},
},
@@ -117,31 +127,16 @@ const emailTemplates = {
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",
preheader: "Ich melde mich zeitnah",
bodyHtml: `
<div style="font-size:15px;line-height:1.65;color:${BRAND.text};">
<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 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(),
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)}`,
});
},
},
@@ -149,25 +144,15 @@ const emailTemplates = {
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",
preheader: "Kurze Bestätigung",
bodyHtml: `
<div style="font-size:15px;line-height:1.65;color:${BRAND.text};">
<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.
</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(),
kurze Bestätigung: deine Nachricht ist angekommen. Ich melde mich bald zurück.
</p>
${messageCard("Deine Nachricht", nl2br(originalMessage))}`,
});
},
},
@@ -175,35 +160,19 @@ const emailTemplates = {
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",
title: `Hey ${safeName}!`,
preheader: "Antwort von Dennis",
bodyHtml: `
<div style="font-size:15px;line-height:1.65;color:${BRAND.text};">
<p style="font-size:15px;line-height:1.7;color:#d1d5db;margin:0 0 20px;">
Hey ${safeName},<br><br>
hier ist meine Antwort:
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>
<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(),
${ctaButton("Portfolio ansehen →", B.siteUrl)}`,
});
},
},
@@ -231,36 +200,23 @@ export async function POST(request: NextRequest) {
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 },
);
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 },
);
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 },
);
return NextResponse.json({ error: "Ungültiges Template" }, { status: 400 });
}
const user = process.env.MY_EMAIL ?? "";
@@ -268,10 +224,7 @@ export async function POST(request: NextRequest) {
if (!user || !pass) {
console.error("❌ Missing email/password environment variables");
return NextResponse.json(
{ error: "E-Mail-Server nicht konfiguriert" },
{ status: 500 },
);
return NextResponse.json({ error: "E-Mail-Server nicht konfiguriert" }, { status: 500 });
}
const transportOptions: SMTPTransport.Options = {
@@ -279,86 +232,50 @@ export async function POST(request: NextRequest) {
port: 587,
secure: false,
requireTLS: true,
auth: {
type: "login",
user,
pass,
},
auth: { type: "login", user, pass },
connectionTimeout: 30000,
greetingTimeout: 30000,
socketTimeout: 60000,
tls: {
rejectUnauthorized: false,
ciphers: 'SSLv3'
}
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 },
);
} catch {
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 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: to,
replyTo: "contact@dk0.dev",
to,
replyTo: B.email,
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
`,
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 sendMailPromise = () =>
new Promise<string>((resolve, reject) => {
transport.sendMail(mailOptions, function (err, info) {
if (!err) {
resolve(info.response);
} else {
reject(err.message);
}
});
const result = await new Promise<string>((resolve, reject) => {
transport.sendMail(mailOptions, (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
});
return NextResponse.json({ message: "E-Mail erfolgreich gesendet", template, messageId: result });
} catch (err) {
return NextResponse.json({
error: "Fehler beim Senden der Template-E-Mail",
details: err instanceof Error ? err.message : 'Unbekannter Fehler'
return NextResponse.json({
error: "Fehler beim Senden der E-Mail",
details: err instanceof Error ? err.message : 'Unbekannter Fehler',
}, { status: 500 });
}
}