feat: dark email design + Telegram notification for contact form
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:
@@ -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">
|
||||
<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)} · ${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 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)}`,
|
||||
});
|
||||
},
|
||||
},
|
||||
@@ -234,33 +203,20 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
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'
|
||||
error: "Fehler beim Senden der E-Mail",
|
||||
details: err instanceof Error ? err.message : 'Unbekannter Fehler',
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, "<").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 {
|
||||
// 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) },
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -46,48 +204,26 @@ export async function POST(request: NextRequest) {
|
||||
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"
|
||||
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');
|
||||
if (verificationAttempts >= 3) {
|
||||
return NextResponse.json({ error: "E-Mail-Server-Verbindung fehlgeschlagen" }, { status: 500 });
|
||||
}
|
||||
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, "<br>");
|
||||
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", // Send to your contact email
|
||||
to: "contact@dk0.dev",
|
||||
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.
|
||||
`,
|
||||
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<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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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({
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user