Replaced the minimal plain-HTML notification email (sent to jh@unixweb.de) with a fully branded HTML email that matches the existing confirmation email layout. Changes: - Added `buildNotificationEmail()` function in `artifacts/api-server/src/routes/contact.ts` - Same visual structure as `buildConfirmationEmail()`: blue header with JH monogram, white card body, divider, branded footer with links - Sender details (name, e-mail, subject, message) are displayed in a structured field table inside the card body, with subtle background shading and label rows - "Absender antworten →" CTA button links to `mailto:{email}?subject=Re: {subject}` so Joachim can reply to the sender in one click - Subject field row is conditionally rendered (omitted when no subject was provided) - All user-supplied values are escaped through the existing `escapeHtml()` helper - The first `sendTransacEmail` call now passes `buildNotificationEmail(...)` instead of the inline minimal HTML string No schema, API contract, or dependency changes. Typecheck passes cleanly after running codegen to resolve a pre-existing import gap in @workspace/api-zod. Replit-Task-Id: 601aa064-22da-4553-9ad0-a15f82563eb6
370 lines
16 KiB
TypeScript
370 lines
16 KiB
TypeScript
import { Router, type IRouter } from "express";
|
||
import rateLimit from "express-rate-limit";
|
||
import { BrevoClient } from "@getbrevo/brevo";
|
||
import { SendContactMessageBody } from "@workspace/api-zod";
|
||
|
||
const router: IRouter = Router();
|
||
|
||
const contactRateLimit = rateLimit({
|
||
windowMs: 60 * 60 * 1000,
|
||
limit: 5,
|
||
standardHeaders: "draft-8",
|
||
legacyHeaders: false,
|
||
message: {
|
||
success: false,
|
||
message:
|
||
"Zu viele Anfragen. Bitte versuchen Sie es in einer Stunde erneut.",
|
||
},
|
||
});
|
||
|
||
function getBrevoClient() {
|
||
const apiKey = process.env.BREVO_API_KEY;
|
||
if (!apiKey) throw new Error("BREVO_API_KEY is not set");
|
||
return new BrevoClient({ apiKey });
|
||
}
|
||
|
||
router.post("/contact", contactRateLimit, async (req, res) => {
|
||
const parsed = SendContactMessageBody.safeParse(req.body);
|
||
|
||
if (!parsed.success) {
|
||
res.status(400).json({
|
||
success: false,
|
||
message: "Ungültige Eingabe. Bitte überprüfen Sie Ihre Angaben.",
|
||
});
|
||
return;
|
||
}
|
||
|
||
const { name, email, subject, message } = parsed.data;
|
||
|
||
try {
|
||
const brevo = getBrevoClient();
|
||
|
||
await brevo.transactionalEmails.sendTransacEmail({
|
||
sender: { name: "Kontaktformular Portfolio", email: "jh@unixweb.de" },
|
||
to: [{ email: "jh@unixweb.de", name: "Joachim Hummel" }],
|
||
replyTo: { email, name },
|
||
subject: subject ? `[Portfolio] ${subject}` : `[Portfolio] Neue Anfrage von ${name}`,
|
||
textContent: [
|
||
`Name: ${name}`,
|
||
`E-Mail: ${email}`,
|
||
subject ? `Betreff: ${subject}` : "",
|
||
"",
|
||
message,
|
||
]
|
||
.filter((l) => l !== undefined)
|
||
.join("\n"),
|
||
htmlContent: buildNotificationEmail({ name, email, subject, message }),
|
||
});
|
||
|
||
req.log.info({ to: "jh@unixweb.de", from: email }, "Contact message sent via Brevo");
|
||
|
||
await brevo.transactionalEmails.sendTransacEmail({
|
||
sender: { name: "Joachim Hummel", email: "jh@unixweb.de" },
|
||
to: [{ email, name }],
|
||
subject: "Ihre Anfrage ist angekommen – vielen Dank!",
|
||
textContent: [
|
||
`Hallo ${name},`,
|
||
"",
|
||
"vielen Dank für Ihre Nachricht! Ich habe Ihre Anfrage erhalten und melde mich in der Regel innerhalb von 1–2 Werktagen bei Ihnen.",
|
||
"",
|
||
"Mit freundlichen Grüßen",
|
||
"Joachim Hummel",
|
||
"",
|
||
"—",
|
||
"jh@unixweb.de",
|
||
].join("\n"),
|
||
htmlContent: buildConfirmationEmail(name),
|
||
});
|
||
|
||
req.log.info({ to: email }, "Confirmation email sent to sender via Brevo");
|
||
res.json({ success: true, message: "Ihre Nachricht wurde erfolgreich gesendet." });
|
||
} catch (err) {
|
||
req.log.error({ err }, "Failed to send contact email via Brevo");
|
||
res.status(500).json({
|
||
success: false,
|
||
message:
|
||
"Die Nachricht konnte nicht gesendet werden. Bitte versuchen Sie es später erneut.",
|
||
});
|
||
}
|
||
});
|
||
|
||
function escapeHtml(text: string): string {
|
||
return text
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
function buildNotificationEmail({
|
||
name,
|
||
email,
|
||
subject,
|
||
message,
|
||
}: {
|
||
name: string;
|
||
email: string;
|
||
subject?: string;
|
||
message: string;
|
||
}): string {
|
||
const safeName = escapeHtml(name);
|
||
const safeEmail = escapeHtml(email);
|
||
const safeSubject = subject ? escapeHtml(subject) : null;
|
||
const safeMessage = escapeHtml(message);
|
||
|
||
const brandBlue = "#3f4ff4";
|
||
const textDark = "#0f172a";
|
||
const textMid = "#334155";
|
||
const textMuted = "#64748b";
|
||
const textLight = "#94a3b8";
|
||
const borderColor = "#e2e8f0";
|
||
const bgPage = "#f1f5f9";
|
||
const bgCard = "#ffffff";
|
||
const bgField = "#f8fafc";
|
||
|
||
return `<!DOCTYPE html>
|
||
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||
<title>Neue Kontaktanfrage</title>
|
||
</head>
|
||
<body style="margin:0;padding:0;background-color:${bgPage};font-family:'Inter','Helvetica Neue',Helvetica,Arial,sans-serif;-webkit-font-smoothing:antialiased;">
|
||
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="background-color:${bgPage};">
|
||
<tr>
|
||
<td align="center" style="padding:48px 16px 40px;">
|
||
|
||
<table width="560" cellpadding="0" cellspacing="0" border="0" role="presentation" style="max-width:560px;width:100%;">
|
||
|
||
<!-- ── Header ── -->
|
||
<tr>
|
||
<td align="center" style="background-color:${brandBlue};border-radius:12px 12px 0 0;padding:32px 40px 28px;">
|
||
<table cellpadding="0" cellspacing="0" border="0" role="presentation">
|
||
<tr>
|
||
<td align="center" style="width:44px;height:44px;background-color:rgba(255,255,255,0.18);border-radius:10px;font-size:18px;font-weight:700;color:#ffffff;letter-spacing:-0.5px;line-height:44px;">
|
||
JH
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
<p style="margin:10px 0 0;font-size:13px;font-weight:600;color:rgba(255,255,255,0.80);letter-spacing:1.5px;text-transform:uppercase;">Joachim Hummel</p>
|
||
</td>
|
||
</tr>
|
||
|
||
<!-- ── Body ── -->
|
||
<tr>
|
||
<td style="background-color:${bgCard};padding:40px 40px 32px;">
|
||
<p style="margin:0 0 6px;font-size:22px;font-weight:700;color:${textDark};line-height:1.3;">Neue Kontaktanfrage</p>
|
||
<p style="margin:0 0 28px;font-size:13px;font-weight:500;color:${brandBlue};letter-spacing:0.3px;">Jemand hat das Kontaktformular ausgefüllt.</p>
|
||
|
||
<!-- Sender details -->
|
||
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="border:1px solid ${borderColor};border-radius:8px;overflow:hidden;margin-bottom:20px;">
|
||
<!-- Name -->
|
||
<tr>
|
||
<td style="padding:12px 16px;background-color:${bgField};border-bottom:1px solid ${borderColor};">
|
||
<p style="margin:0 0 2px;font-size:11px;font-weight:600;color:${textLight};letter-spacing:1px;text-transform:uppercase;">Name</p>
|
||
<p style="margin:0;font-size:15px;font-weight:600;color:${textDark};">${safeName}</p>
|
||
</td>
|
||
</tr>
|
||
<!-- Email -->
|
||
<tr>
|
||
<td style="padding:12px 16px;background-color:${bgField};border-bottom:1px solid ${borderColor};">
|
||
<p style="margin:0 0 2px;font-size:11px;font-weight:600;color:${textLight};letter-spacing:1px;text-transform:uppercase;">E-Mail</p>
|
||
<p style="margin:0;font-size:15px;color:${textMid};"><a href="mailto:${safeEmail}" style="color:${brandBlue};text-decoration:none;">${safeEmail}</a></p>
|
||
</td>
|
||
</tr>
|
||
${
|
||
safeSubject
|
||
? `<!-- Subject -->
|
||
<tr>
|
||
<td style="padding:12px 16px;background-color:${bgField};border-bottom:1px solid ${borderColor};">
|
||
<p style="margin:0 0 2px;font-size:11px;font-weight:600;color:${textLight};letter-spacing:1px;text-transform:uppercase;">Betreff</p>
|
||
<p style="margin:0;font-size:15px;color:${textDark};">${safeSubject}</p>
|
||
</td>
|
||
</tr>`
|
||
: ""
|
||
}
|
||
<!-- Message -->
|
||
<tr>
|
||
<td style="padding:12px 16px;background-color:${bgCard};">
|
||
<p style="margin:0 0 6px;font-size:11px;font-weight:600;color:${textLight};letter-spacing:1px;text-transform:uppercase;">Nachricht</p>
|
||
<p style="margin:0;font-size:15px;line-height:1.75;color:${textMid};white-space:pre-wrap;">${safeMessage}</p>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
|
||
<!-- CTA button -->
|
||
<table cellpadding="0" cellspacing="0" border="0" role="presentation">
|
||
<tr>
|
||
<td align="center" style="background-color:${brandBlue};border-radius:8px;">
|
||
<a href="mailto:${safeEmail}?subject=Re%3A%20${safeSubject ? encodeURIComponent(subject ?? "") : encodeURIComponent(`Ihre Anfrage`)}" style="display:inline-block;padding:13px 28px;font-size:14px;font-weight:600;color:#ffffff;text-decoration:none;letter-spacing:0.2px;border-radius:8px;">Absender antworten →</a>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</td>
|
||
</tr>
|
||
|
||
<!-- ── Divider ── -->
|
||
<tr>
|
||
<td style="background-color:${bgCard};padding:0 40px;">
|
||
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation">
|
||
<tr>
|
||
<td style="font-size:0;line-height:0;border-bottom:1px solid ${borderColor};"> </td>
|
||
</tr>
|
||
</table>
|
||
</td>
|
||
</tr>
|
||
|
||
<!-- ── Footer ── -->
|
||
<tr>
|
||
<td style="background-color:${bgCard};border-radius:0 0 12px 12px;padding:24px 40px 32px;">
|
||
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation">
|
||
<tr>
|
||
<td>
|
||
<p style="margin:0 0 2px;font-size:14px;font-weight:700;color:${textDark};">Joachim Hummel</p>
|
||
<p style="margin:0 0 14px;font-size:13px;color:${textMuted};">Webentwicklung & Softwarelösungen</p>
|
||
<p style="margin:0 0 10px;font-size:13px;color:${textLight};">
|
||
<a href="mailto:jh@unixweb.de" style="color:${brandBlue};text-decoration:none;">jh@unixweb.de</a>
|
||
<span style="color:${borderColor};"> • </span>
|
||
<a href="https://joachim-hummel.de" target="_blank" style="color:${brandBlue};text-decoration:none;">joachim-hummel.de</a>
|
||
</p>
|
||
<p style="margin:0;font-size:13px;color:${textLight};">
|
||
<a href="https://blog.unixweb.de" target="_blank" style="color:${brandBlue};text-decoration:none;">Blog</a>
|
||
<span style="color:${borderColor};"> • </span>
|
||
<a href="https://n8n.io/creators/jhummel/" target="_blank" style="color:${brandBlue};text-decoration:none;">n8n Creators</a>
|
||
</p>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</td>
|
||
</tr>
|
||
|
||
<!-- ── Legal note ── -->
|
||
<tr>
|
||
<td align="center" style="padding:20px 0 0;">
|
||
<p style="margin:0;font-size:12px;color:${textLight};line-height:1.6;">Diese E-Mail wurde automatisch durch das Kontaktformular generiert.</p>
|
||
</td>
|
||
</tr>
|
||
|
||
</table>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</body>
|
||
</html>`;
|
||
}
|
||
|
||
function buildConfirmationEmail(name: string): string {
|
||
const safeName = escapeHtml(name);
|
||
const brandBlue = "#3f4ff4";
|
||
const textDark = "#0f172a";
|
||
const textMid = "#334155";
|
||
const textMuted = "#64748b";
|
||
const textLight = "#94a3b8";
|
||
const borderColor = "#e2e8f0";
|
||
const bgPage = "#f1f5f9";
|
||
const bgCard = "#ffffff";
|
||
|
||
return `<!DOCTYPE html>
|
||
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||
<title>Anfrage erhalten</title>
|
||
</head>
|
||
<body style="margin:0;padding:0;background-color:${bgPage};font-family:'Inter','Helvetica Neue',Helvetica,Arial,sans-serif;-webkit-font-smoothing:antialiased;">
|
||
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="background-color:${bgPage};">
|
||
<tr>
|
||
<td align="center" style="padding:48px 16px 40px;">
|
||
|
||
<table width="560" cellpadding="0" cellspacing="0" border="0" role="presentation" style="max-width:560px;width:100%;">
|
||
|
||
<!-- ── Header ── -->
|
||
<tr>
|
||
<td align="center" style="background-color:${brandBlue};border-radius:12px 12px 0 0;padding:32px 40px 28px;">
|
||
<table cellpadding="0" cellspacing="0" border="0" role="presentation">
|
||
<tr>
|
||
<td align="center" style="width:44px;height:44px;background-color:rgba(255,255,255,0.18);border-radius:10px;font-size:18px;font-weight:700;color:#ffffff;letter-spacing:-0.5px;line-height:44px;">
|
||
JH
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
<p style="margin:10px 0 0;font-size:13px;font-weight:600;color:rgba(255,255,255,0.80);letter-spacing:1.5px;text-transform:uppercase;">Joachim Hummel</p>
|
||
</td>
|
||
</tr>
|
||
|
||
<!-- ── Body ── -->
|
||
<tr>
|
||
<td style="background-color:${bgCard};padding:40px 40px 32px;">
|
||
<p style="margin:0 0 6px;font-size:22px;font-weight:700;color:${textDark};line-height:1.3;">Vielen Dank, ${safeName}!</p>
|
||
<p style="margin:0 0 20px;font-size:13px;font-weight:500;color:${brandBlue};letter-spacing:0.3px;">Ihre Anfrage ist eingegangen.</p>
|
||
<p style="margin:0 0 16px;font-size:15px;line-height:1.75;color:${textMid};">Schön, dass Sie sich gemeldet haben. Ich habe Ihre Nachricht erhalten und werde mich in der Regel innerhalb von <strong style="color:${textDark};">1–2 Werktagen</strong> bei Ihnen melden.</p>
|
||
<p style="margin:0 0 32px;font-size:15px;line-height:1.75;color:${textMid};">Bis dahin können Sie gerne mein Portfolio besuchen oder mir direkt eine E-Mail schicken.</p>
|
||
|
||
<!-- CTA button -->
|
||
<table cellpadding="0" cellspacing="0" border="0" role="presentation">
|
||
<tr>
|
||
<td align="center" style="background-color:${brandBlue};border-radius:8px;">
|
||
<a href="https://joachim-hummel.de" target="_blank" style="display:inline-block;padding:13px 28px;font-size:14px;font-weight:600;color:#ffffff;text-decoration:none;letter-spacing:0.2px;border-radius:8px;">Portfolio ansehen →</a>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</td>
|
||
</tr>
|
||
|
||
<!-- ── Divider ── -->
|
||
<tr>
|
||
<td style="background-color:${bgCard};padding:0 40px;">
|
||
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation">
|
||
<tr>
|
||
<td style="font-size:0;line-height:0;border-bottom:1px solid ${borderColor};"> </td>
|
||
</tr>
|
||
</table>
|
||
</td>
|
||
</tr>
|
||
|
||
<!-- ── Footer ── -->
|
||
<tr>
|
||
<td style="background-color:${bgCard};border-radius:0 0 12px 12px;padding:24px 40px 32px;">
|
||
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation">
|
||
<tr>
|
||
<td>
|
||
<p style="margin:0 0 2px;font-size:14px;font-weight:700;color:${textDark};">Joachim Hummel</p>
|
||
<p style="margin:0 0 14px;font-size:13px;color:${textMuted};">Webentwicklung & Softwarelösungen</p>
|
||
<!-- Contact links -->
|
||
<p style="margin:0 0 10px;font-size:13px;color:${textLight};">
|
||
<a href="mailto:jh@unixweb.de" style="color:${brandBlue};text-decoration:none;">jh@unixweb.de</a>
|
||
<span style="color:${borderColor};"> • </span>
|
||
<a href="https://joachim-hummel.de" target="_blank" style="color:${brandBlue};text-decoration:none;">joachim-hummel.de</a>
|
||
</p>
|
||
<!-- Social links -->
|
||
<p style="margin:0;font-size:13px;color:${textLight};">
|
||
<a href="https://blog.unixweb.de" target="_blank" style="color:${brandBlue};text-decoration:none;">Blog</a>
|
||
<span style="color:${borderColor};"> • </span>
|
||
<a href="https://n8n.io/creators/jhummel/" target="_blank" style="color:${brandBlue};text-decoration:none;">n8n Creators</a>
|
||
</p>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</td>
|
||
</tr>
|
||
|
||
<!-- ── Legal note ── -->
|
||
<tr>
|
||
<td align="center" style="padding:20px 0 0;">
|
||
<p style="margin:0;font-size:12px;color:${textLight};line-height:1.6;">Diese E-Mail wurde automatisch versendet – bitte antworten Sie nicht direkt darauf.</p>
|
||
</td>
|
||
</tr>
|
||
|
||
</table>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</body>
|
||
</html>`;
|
||
}
|
||
|
||
export default router;
|