+
+
${opts.bodyHtml}
-
-
- `.trim();
+`;
+}
+
+function messageCard(label: string, html: string, accentColor: string = B.mint): string {
+ return `
+
+
+ ${label}
+
+
${html}
+
`;
+}
+
+function ctaButton(text: string, href: string): string {
+ return `
+
`;
}
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: `
-
+
Hey ${safeName},
- danke für deine Nachricht — ich habe sie erhalten und melde mich so schnell wie möglich bei dir zurück.
-
-
-
-
-
- `.trim(),
+ danke für deine Nachricht — ich habe sie erhalten und melde mich so schnell wie möglich bei dir zurück. 🙌
+
+${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: `
-
+
Hey ${safeName},
- mega — danke für die Projekt-Anfrage. Ich schaue mir deine Nachricht an und komme mit Rückfragen/Ideen auf dich zu.
-
-
-
-
-
Deine Projekt-Nachricht
-
-
- ${safeMsg}
-
-
-
-
- `.trim(),
+ mega — danke für die Projekt-Anfrage! Ich schaue mir alles an und melde mich bald mit Ideen und Rückfragen. 🚀
+
+${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: `
-
+
Hey ${safeName},
- kurze Bestätigung: deine Nachricht ist angekommen. Ich melde mich bald zurück.
-
-
-
- `.trim(),
+ kurze Bestätigung: deine Nachricht ist angekommen. Ich melde mich bald zurück. ⚡
+
+${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: `
-
+
Hey ${safeName},
- hier ist meine Antwort:
+ ich habe mir deine Nachricht angeschaut — hier ist meine Antwort:
+
+${messageCard("Antwort von Dennis", nl2br(responseMessage), B.mint)}
+
+${messageCard("Deine ursprüngliche Nachricht", nl2br(originalMessage), "#2a2a2a")}
-
-
-
-
- ${safeResponse}
-
-
-
-
-
-
Deine ursprüngliche Nachricht
-
-
- ${safeOriginal}
-
-
- `.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
;
- html = emailTemplates[nonReplyTemplate].template(name, originalMessage);
- }
+ const html = template === "reply"
+ ? emailTemplates.reply.template(name, originalMessage, response || "")
+ : emailTemplates[template as Exclude].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((resolve, reject) => {
- transport.sendMail(mailOptions, function (err, info) {
- if (!err) {
- resolve(info.response);
- } else {
- reject(err.message);
- }
- });
+ const result = await new Promise((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 });
}
}
diff --git a/app/api/email/route.tsx b/app/api/email/route.tsx
index 006f0b7..2b11d40 100644
--- a/app/api/email/route.tsx
+++ b/app/api/email/route.tsx
@@ -5,12 +5,8 @@ import Mail from "nodemailer/lib/mailer";
import { checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
import { prisma } from "@/lib/prisma";
-// 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();
+ return input.slice(0, maxLength).replace(/[<>]/g, '').trim();
}
function escapeHtml(input: string): string {
@@ -22,19 +18,181 @@ function escapeHtml(input: string): string {
.replace(/'/g, "'");
}
+function escapeHtmlTg(input: string): string {
+ return input.replace(/&/g, "&").replace(//g, ">");
+}
+
+async function sendTelegramNotification(data: {
+ name: string;
+ email: string;
+ subject: string;
+ message: string;
+ sentAt: string;
+}): Promise {
+ 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 = [
+ "🔔 Neue Kontaktanfrage",
+ "",
+ `👤 Name: ${escapeHtmlTg(data.name)}`,
+ `📧 Email: ${escapeHtmlTg(data.email)}`,
+ `📌 Betreff: ${escapeHtmlTg(data.subject)}`,
+ "",
+ "💬 Nachricht:",
+ `${escapeHtmlTg(preview)}`,
+ "",
+ `🕐 ${escapeHtmlTg(data.sentAt)}`,
+ ].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 `
+
+
+
+
+ Neue Kontaktanfrage
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ dk0.dev · Portfolio Kontakt
+
+
+ Neue Kontaktanfrage
+
+
+ ${escapeHtml(sentAt)}
+
+
+
+ dk0.dev
+
+
+
+
+
+
+
+
+
+
+ ${escapeHtml(initial)}
+
+
+
${escapeHtml(name)}
+
${escapeHtml(email)}
+
+
+
+
+
+
+
+ ${escapeHtml(subject)}
+
+
+
+
+
+
+
+ Nachricht
+
+
+ ${messageHtml}
+
+
+
+
+
+
+
+
+
+
+ Automatisch generiert ·
dk0.dev
+
+
+ contact@dk0.dev
+
+
+
+
+
+
+
+`;
+}
+
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
+ 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)
- }
+ headers: { 'Content-Type': 'application/json', ...getRateLimitHeaders(ip, 5, 60000) },
}
);
}
@@ -45,49 +203,27 @@ export async function POST(request: NextRequest) {
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 },
- );
+ 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 },
- );
+ 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 },
- );
+ 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 },
- );
+ return NextResponse.json({ error: "Eingabe zu lang" }, { status: 400 });
}
const user = process.env.MY_EMAIL ?? "";
@@ -95,265 +231,101 @@ 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 = {
host: "mail.dk0.dev",
port: 587,
- secure: false, // Port 587 uses STARTTLS, not SSL/TLS
+ secure: false,
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
- // TLS hardening (allow insecure/self-signed only when explicitly enabled)
+ 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" },
+ process.env.SMTP_ALLOW_INSECURE_TLS === "true" || process.env.SMTP_ALLOW_SELF_SIGNED === "true"
+ ? { rejectUnauthorized: false }
+ : { rejectUnauthorized: true, minVersion: "TLSv1.2" },
};
- // 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) {
+ while (verificationAttempts < 3) {
try {
verificationAttempts++;
await transport.verify();
- verificationSuccess = true;
+ break;
} 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 },
- );
+ if (verificationAttempts >= 3) {
+ 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'
+ 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, "
");
const initial = (name.trim()[0] || "?").toUpperCase();
const replyHref = `mailto:${email}?subject=${encodeURIComponent(`Re: ${subject}`)}`;
+ const messageHtml = escapeHtml(message).replace(/\n/g, "
");
const mailOptions: Mail.Options = {
from: `"Portfolio Contact" <${user}>`,
- to: "contact@dk0.dev", // Send to your contact email
+ to: "contact@dk0.dev",
replyTo: email,
- subject: `Portfolio Kontakt: ${subject}`,
- html: `
-
-
-
-
-
- Neue Kontaktanfrage - Portfolio
-
-
-
-
-
-
-
-
- Dennis Konkol
-
-
- dk0.dev
-
-
-
-
- Neue Kontaktanfrage
-
-
- Eingegangen am ${sentAt}
-
-
-
-
-
-
-
-
-
-
- ${escapeHtml(initial)}
-
-
-
- ${safeName}
-
-
- E-Mail: ${safeEmail}
- Betreff: ${safeSubject}
-
-
-
-
-
-
-
-
- ${safeMessageHtml}
-
-
-
-
-
-
-
-
-
-
- Automatisch generiert von
dk0.dev
-
-
-
-
-
-
- `,
- text: `
-Neue Kontaktanfrage von deinem Portfolio
-
-Von: ${name} (${email})
-Betreff: ${subject}
-
-Nachricht:
-${message}
-
----
-Diese E-Mail wurde automatisch von dk0.dev generiert.
- `,
+ 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}`,
};
- // Sending email
-
- // Email sending with retry logic
let sendAttempts = 0;
- const maxSendAttempts = 3;
- let sendSuccess = false;
let result = '';
- while (sendAttempts < maxSendAttempts && !sendSuccess) {
+ while (sendAttempts < 3) {
try {
sendAttempts++;
- // Email send attempt
-
- const sendMailPromise = () =>
- new Promise((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 new Promise((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);
+ }
});
-
- result = await sendMailPromise();
- sendSuccess = true;
- // Email process completed successfully
+ });
+ break;
} catch (sendError) {
- if (process.env.NODE_ENV === 'development') {
- console.error(`Email send attempt ${sendAttempts} failed:`, sendError);
+ if (sendAttempts >= 3) {
+ throw new Error(`Failed to send email after 3 attempts: ${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
+
+ // 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
- }
- });
- // Contact saved to database
+ 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 database:', dbError);
- }
- // Don't fail the email send if DB save fails
+ if (process.env.NODE_ENV === 'development') console.error('Error saving contact to DB:', dbError);
}
-
- return NextResponse.json({
- message: "E-Mail erfolgreich gesendet",
- messageId: result
- });
-
+
+ return NextResponse.json({ message: "E-Mail erfolgreich gesendet", messageId: result });
+
} catch (err) {
console.error("❌ Unexpected error in email API:", err);
- return NextResponse.json({
+ return NextResponse.json({
error: "Fehler beim Senden der E-Mail",
- details: err instanceof Error ? err.message : 'Unbekannter Fehler'
+ details: err instanceof Error ? err.message : 'Unbekannter Fehler',
}, { status: 500 });
}
}