Files
me-joachimhummel-de/artifacts/api-server/src/routes/contact.ts
joachimhummel 3ef76a4433 Task #17: Send a branded notification email to Joachim on contact form submission
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
2026-05-15 17:11:43 +00:00

370 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 12 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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
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 &rarr;</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};">&nbsp;</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 &amp; 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};">&nbsp;&bull;&nbsp;</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};">&nbsp;&bull;&nbsp;</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};">12 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 &rarr;</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};">&nbsp;</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 &amp; 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};">&nbsp;&bull;&nbsp;</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};">&nbsp;&bull;&nbsp;</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;