MBO-Tech-IT-Webseite/modules/01-email-system/files/lib/mailer.ts

933 lines
42 KiB
TypeScript
Raw Permalink 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 nodemailer from "nodemailer";
import { queueEmail } from "./email-queue";
// Port 587 = STARTTLS (bestätigt erreichbar vom Server + Docker-Container)
// Port 465 = SSL/TLS (auf diesem Server geblockt nicht verwenden)
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT ?? 587),
secure: false, // STARTTLS auf Port 587
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
connectionTimeout: 15000, // 15 s (war default 2 min schlägt jetzt schneller fehl)
greetingTimeout: 10000,
socketTimeout: 20000,
tls: {
rejectUnauthorized: false,
ciphers: "SSLv3", // Kompatibilitätsmodus für ältere SMTP-Server
},
});
export interface AnfrageEmailData {
anfrageId: string;
firma: string;
telefon: string;
email: string;
positionen: {
maschineName: string;
mietbeginn: string;
mietende: string;
gesamtTage: number;
lieferung: boolean;
lieferadresse: string;
anmerkung: string;
tagessatz: number | null;
preisStufe?: "tag" | "woche" | "monat" | null;
zubehoer?: {
id: string;
name: string;
preisTag: number | null;
preisWoche?: number | null;
preisMonat?: number | null;
}[];
}[];
}
// ─── Preisberechnung ──────────────────────────────────────────────────────
const VERSICHERUNG_PROZENT = 7.5;
const MWST_PROZENT = 19;
function fmt(n: number) {
return n.toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function formatDatum(iso: string) {
return new Date(iso).toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
/** Alle Sonntage zwischen von und bis (inkl.) als formatierte Strings */
function getSonntage(von: string, bis: string): string[] {
const result: string[] = [];
const end = new Date(bis);
for (let d = new Date(von); d <= end; d.setDate(d.getDate() + 1)) {
if (d.getDay() === 0) {
result.push(
new Date(d).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit" })
);
}
}
return result;
}
/** Kalendertage (inkl. Sonntage) zwischen von und bis */
export function getKalenderTage(von: string, bis: string): number {
const start = new Date(von);
const end = new Date(bis);
return Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) + 1;
}
function zubehoerTagessatz(
z: { preisTag: number | null; preisWoche?: number | null; preisMonat?: number | null },
stufe: string | null | undefined
): number | null {
if (stufe === "monat" && z.preisMonat != null) return z.preisMonat;
if (stufe === "woche" && z.preisWoche != null) return z.preisWoche;
return z.preisTag;
}
function positionNetto(p: AnfrageEmailData["positionen"][number]): number {
const maschine = p.tagessatz != null ? p.tagessatz * p.gesamtTage : 0;
const zubehoer = (p.zubehoer ?? []).reduce((sum, z) => {
const rate = zubehoerTagessatz(z, p.preisStufe);
return sum + (rate != null ? rate * p.gesamtTage : 0);
}, 0);
return maschine + zubehoer;
}
function buildPreisBlock(positionen: AnfrageEmailData["positionen"]) {
const gesamtNetto = positionen.reduce((s, p) => s + positionNetto(p), 0);
const versicherungBetrag = gesamtNetto * (VERSICHERUNG_PROZENT / 100);
const mwstBetrag = (gesamtNetto + versicherungBetrag) * (MWST_PROZENT / 100);
const gesamtBrutto = gesamtNetto + versicherungBetrag + mwstBetrag;
const posRows = positionen.map((p, i) => {
const hatPreis = p.tagessatz != null;
const netto = hatPreis ? positionNetto(p) : null;
const zubehoerMitPreis = (p.zubehoer ?? []).filter(
(z) => zubehoerTagessatz(z, p.preisStufe) != null
);
// Sonntage berechnen
const kalenderTage = getKalenderTage(p.mietbeginn, p.mietende);
const sonntage = getSonntage(p.mietbeginn, p.mietende);
const hatSonntage = sonntage.length > 0 && kalenderTage !== p.gesamtTage;
const zubehoerRows = zubehoerMitPreis
.map((z) => {
const rate = zubehoerTagessatz(z, p.preisStufe)!;
return `<tr>
<td style="padding:2px 12px 2px 28px;color:#64748b;font-size:12px" colspan="2">↳ ${z.name}</td>
<td style="padding:2px 12px;font-family:monospace;font-size:12px;color:#475569;text-align:right">${fmt(rate * p.gesamtTage)} €</td>
</tr>`;
})
.join("");
const sonntageHtml = hatSonntage
? `<span style="font-size:11px;color:#94a3b8;display:block">
${kalenderTage} Kalendertage &ndash; ${sonntage.length} Sonntag${sonntage.length > 1 ? "e" : ""} nicht berechnet
(${sonntage.join(", ")})
</span>`
: "";
const details = [
p.tagessatz ? `${p.tagessatz} €/Tag · ${p.gesamtTage} berechnete Tag${p.gesamtTage !== 1 ? "e" : ""}` : "",
p.lieferung ? `Lieferung: ${p.lieferadresse}` : "",
p.anmerkung ? `Anmerkung: ${p.anmerkung}` : "",
]
.filter(Boolean)
.map((d) => `<span style="font-size:11px;color:#94a3b8;display:block">${d}</span>`)
.join("");
return `<tr style="background:${i % 2 === 0 ? "#f8fafc" : "#fff"}">
<td style="padding:8px 12px;font-weight:600;color:#1c1917;border-bottom:1px solid #e2e8f0;font-size:13px">
${i + 1}. ${p.maschineName}
</td>
<td style="padding:8px 12px;color:#475569;font-size:13px;border-bottom:1px solid #e2e8f0">
${formatDatum(p.mietbeginn)} ${formatDatum(p.mietende)}
${details}
${sonntageHtml}
</td>
<td style="padding:8px 12px;font-family:monospace;font-weight:600;color:#1c1917;border-bottom:1px solid #e2e8f0;text-align:right;white-space:nowrap;font-size:13px">
${netto != null ? `${fmt(netto)}` : "Auf Anfrage"}
</td>
</tr>${zubehoerRows}`;
}).join("");
const html = `
<table style="width:100%;border-collapse:collapse">
<thead>
<tr style="background:#1c1917">
<th style="padding:8px 12px;text-align:left;color:#f7d334;font-weight:600;font-size:13px">Maschine / Gerät</th>
<th style="padding:8px 12px;text-align:left;color:rgba(255,255,255,0.75);font-weight:500;font-size:12px">Zeitraum &amp; Details</th>
<th style="padding:8px 12px;text-align:right;color:rgba(255,255,255,0.75);font-weight:500;font-size:12px">Mietpreis</th>
</tr>
</thead>
<tbody>${posRows}</tbody>
</table>
<table style="width:100%;border-collapse:collapse;margin-top:2px">
<tr>
<td style="padding:6px 12px;color:#64748b;font-size:13px">Zwischensumme (netto)</td>
<td style="padding:6px 12px;font-family:monospace;text-align:right;font-size:13px">${fmt(gesamtNetto)} €</td>
</tr>
<tr>
<td style="padding:6px 12px;color:#64748b;font-size:13px">+ Versicherung (${VERSICHERUNG_PROZENT} %)</td>
<td style="padding:6px 12px;font-family:monospace;text-align:right;font-size:13px">${fmt(versicherungBetrag)} €</td>
</tr>
<tr style="border-top:1px solid #e2e8f0">
<td style="padding:6px 12px;color:#475569;font-size:13px">Summe netto inkl. Versicherung</td>
<td style="padding:6px 12px;font-family:monospace;text-align:right;font-size:13px">${fmt(gesamtNetto + versicherungBetrag)} €</td>
</tr>
<tr>
<td style="padding:6px 12px;color:#64748b;font-size:13px">+ MwSt. (${MWST_PROZENT} %)</td>
<td style="padding:6px 12px;font-family:monospace;text-align:right;font-size:13px">${fmt(mwstBetrag)} €</td>
</tr>
<tr style="border-top:2px solid #f7d334">
<td style="padding:10px 12px;font-weight:700;font-size:15px;color:#1c1917">Gesamtbetrag (brutto)</td>
<td style="padding:10px 12px;font-family:monospace;font-weight:700;font-size:15px;color:#1c1917;text-align:right">${fmt(gesamtBrutto)} €</td>
</tr>
</table>`;
const text =
positionen
.map((p, i) => {
const netto = positionNetto(p);
const kalenderTage = getKalenderTage(p.mietbeginn, p.mietende);
const sonntage = getSonntage(p.mietbeginn, p.mietende);
const hatSonntage = sonntage.length > 0 && kalenderTage !== p.gesamtTage;
const lines = [
`${i + 1}. ${p.maschineName}`,
` ${formatDatum(p.mietbeginn)} ${formatDatum(p.mietende)}`,
hatSonntage
? ` ${kalenderTage} Kalendertage ${sonntage.length} Sonntag${sonntage.length > 1 ? "e" : ""} nicht berechnet (${sonntage.join(", ")})`
: null,
p.tagessatz
? ` ${p.tagessatz} €/Tag × ${p.gesamtTage} Tage = ${fmt(p.tagessatz * p.gesamtTage)} € Maschinenmiete`
: " Preis auf Anfrage",
...(p.zubehoer ?? [])
.filter((z) => zubehoerTagessatz(z, p.preisStufe) != null)
.map((z) => {
const rate = zubehoerTagessatz(z, p.preisStufe)!;
return ` + ${z.name}: ${fmt(rate * p.gesamtTage)}`;
}),
p.lieferung ? ` Lieferung: ${p.lieferadresse}` : null,
p.anmerkung ? ` Anmerkung: ${p.anmerkung}` : null,
` Positionssumme: ${fmt(netto)}`,
].filter((l): l is string => l != null && l !== "");
return lines.join("\n");
})
.join("\n\n") +
`\n\n${"─".repeat(40)}\nZwischensumme (netto): ${fmt(gesamtNetto)}\n+ Versicherung ${VERSICHERUNG_PROZENT} %: ${fmt(versicherungBetrag)}\n+ MwSt. ${MWST_PROZENT} %: ${fmt(mwstBetrag)}\n${"─".repeat(40)}\nGesamtbetrag (brutto): ${fmt(gesamtBrutto)}`;
return { html, text, gesamtNetto, versicherungBetrag, mwstBetrag, gesamtBrutto };
}
// ─── Robuster Send mit Queue-Fallback ────────────────────────────────────
interface MailOptions {
from: string;
to: string | undefined;
replyTo?: string;
subject: string;
text: string;
html: string;
}
async function sendWithFallback(options: MailOptions, label: string) {
if (!options.to) {
console.error(`[Mailer] Kein Empfänger für "${label}" Mail übersprungen`);
return;
}
try {
await transporter.sendMail(options);
console.log(`[Mailer] ✓ Mail "${label}" an "${options.to}" gesendet`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[Mailer] ✗ Mail "${label}" fehlgeschlagen (${msg}) in Queue gespeichert`);
await queueEmail({
mail_from: options.from,
mail_to: options.to,
reply_to: options.replyTo,
subject: options.subject,
html: options.html,
body_text: options.text,
});
}
}
// ─── Kontaktformular ──────────────────────────────────────────────────────
export interface KontaktEmailData {
name: string;
anrede?: string;
telefon: string;
email: string;
betreff: string;
nachricht?: string;
}
export async function sendeKontaktEmail(data: KontaktEmailData) {
const anrede = data.anrede
? `${data.anrede.charAt(0).toUpperCase() + data.anrede.slice(1)} `
: "";
const html = `
<!DOCTYPE html>
<html lang="de">
<head><meta charset="UTF-8"></head>
<body style="font-family:system-ui,sans-serif;color:#1c1917;max-width:600px;margin:0 auto;padding:0">
<div style="background:#1c1917;padding:20px 24px">
<h1 style="color:#f7d334;margin:0;font-size:20px;font-weight:700">Mietpark Hahn</h1>
<p style="color:rgba(255,255,255,0.7);margin:4px 0 0;font-size:13px">Neue Kontaktanfrage</p>
</div>
<div style="padding:24px;background:#fff">
<h2 style="margin:0 0 16px;font-size:18px">Kontaktanfrage von ${anrede}${data.name}</h2>
<table style="width:100%;border-collapse:collapse;margin-bottom:24px">
<tr><td style="padding:6px 0;color:#64748b;width:120px">Name</td><td style="padding:6px 0;font-weight:600">${anrede}${data.name}</td></tr>
<tr><td style="padding:6px 0;color:#64748b">Telefon</td><td style="padding:6px 0"><a href="tel:${data.telefon}" style="color:#1c1917">${data.telefon}</a></td></tr>
<tr><td style="padding:6px 0;color:#64748b">E-Mail</td><td style="padding:6px 0"><a href="mailto:${data.email}" style="color:#1c1917">${data.email}</a></td></tr>
<tr><td style="padding:6px 0;color:#64748b">Betreff</td><td style="padding:6px 0">${data.betreff}</td></tr>
</table>
${data.nachricht ? `<div style="padding:12px 16px;background:#f8fafc;border-left:3px solid #f7d334"><p style="margin:0;font-size:14px;white-space:pre-wrap">${data.nachricht}</p></div>` : ""}
</div>
</body>
</html>`;
await sendWithFallback({
from: `"Mietpark Hahn" <${process.env.SMTP_FROM}>`,
to: process.env.SMTP_TO,
replyTo: data.email,
subject: `Kontaktanfrage: ${anrede}${data.name} ${data.betreff} Mietpark Hahn`,
text: `Neue Kontaktanfrage\n\nName: ${anrede}${data.name}\nTelefon: ${data.telefon}\nE-Mail: ${data.email}\nBetreff: ${data.betreff}${data.nachricht ? `\n\nNachricht:\n${data.nachricht}` : ""}`,
html,
}, `Kontaktanfrage ${data.name}`);
}
// ─── Kunden-Eingangsbestätigung ───────────────────────────────────────────
export async function sendeKundenEingangsbestaetigung(data: AnfrageEmailData) {
const { html: preisHtml, text: preisText } = buildPreisBlock(data.positionen);
// Build a simple equipment list
const equipmentHtml = `
<div style="margin-bottom:24px">
<h3 style="margin:0 0 12px;font-size:13px;text-transform:uppercase;letter-spacing:0.05em;color:#64748b">
Ihre gemieteten Geräte
</h3>
<ul style="margin:0;padding-left:20px;list-style:none">
${data.positionen
.map((p) => `
<li style="margin:0 0 6px;padding:0;font-size:14px;color:#1c1917">
<span style="display:inline-block;width:6px;height:6px;background:#f7d334;border-radius:50%;margin-right:8px;vertical-align:middle"></span>
<strong>${p.maschineName}</strong>
<span style="color:#64748b"> · ${formatDatum(p.mietbeginn)} bis ${formatDatum(p.mietende)}</span>
</li>`)
.join("")}
</ul>
</div>
`;
const html = `
<!DOCTYPE html>
<html lang="de">
<head><meta charset="UTF-8"></head>
<body style="font-family:system-ui,sans-serif;color:#1c1917;max-width:600px;margin:0 auto;padding:0">
<div style="background:#1c1917;padding:20px 24px">
<h1 style="color:#f7d334;margin:0;font-size:20px;font-weight:700">Mietpark Hahn</h1>
<p style="color:rgba(255,255,255,0.7);margin:4px 0 0;font-size:13px">Ihre Mietanfrage ist eingegangen</p>
</div>
<div style="padding:24px;background:#fff">
<h2 style="margin:0 0 8px;font-size:18px">Vielen Dank für Ihre Anfrage!</h2>
<p style="color:#475569;margin:0 0 24px;font-size:14px">
Guten Tag ${data.firma},<br>
wir haben Ihre Mietanfrage erhalten und werden uns schnellstmöglich bei Ihnen melden.
</p>
${equipmentHtml}
<h3 style="margin:0 0 12px;font-size:15px;border-top:2px solid #f7d334;padding-top:16px">
Detaillierte Preisübersicht
</h3>
${preisHtml}
<div style="margin-top:20px;padding:14px 16px;background:#fef3c7;border-left:3px solid #f7d334">
<p style="margin:0;font-size:13px;color:#92400e">
<strong>Hinweis:</strong> Die angezeigten Preise sind Mietpreise inkl. ${VERSICHERUNG_PROZENT} % Versicherung und ${MWST_PROZENT} % MwSt.
Die Bestätigung erfolgt nach Prüfung durch den Verleih.
</p>
</div>
<p style="margin-top:20px;font-size:13px;color:#64748b">
Bei Fragen erreichen Sie uns unter
<a href="tel:${process.env.COMPANY_PHONE ?? ""}" style="color:#1c1917">${process.env.COMPANY_PHONE ?? ""}</a>
oder per E-Mail an
<a href="mailto:${process.env.SMTP_FROM ?? ""}" style="color:#1c1917">${process.env.SMTP_FROM ?? ""}</a>
</p>
</div>
<div style="padding:12px 24px;background:#f8fafc;border-top:1px solid #e2e8f0">
<p style="margin:0 0 8px;font-size:11px;color:#94a3b8">
Mit der Nutzung unserer Dienste akzeptieren Sie unsere
<a href="${process.env.APP_URL ?? "https://www.mietparkhahn.de"}/agb" style="color:#64748b">Allgemeinen Geschäftsbedingungen</a>.
</p>
<p style="margin:0;font-size:11px;color:#94a3b8">
Mietpark Hahn · Anfrage-ID: ${data.anfrageId}
</p>
</div>
</body>
</html>`;
const equipmentList = data.positionen
.map((p) => `${p.maschineName} (${formatDatum(p.mietbeginn)} bis ${formatDatum(p.mietende)})`)
.join("\n");
await sendWithFallback({
from: `"Mietpark Hahn" <${process.env.SMTP_FROM}>`,
to: data.email,
subject: `Ihre Mietanfrage ist eingegangen Mietpark Hahn`,
text: `Guten Tag ${data.firma},\n\nvielen Dank für Ihre Mietanfrage. Wir werden uns schnellstmöglich bei Ihnen melden.\n\nIhre gemieteten Geräte:\n${equipmentList}\n\nDetaillierte Preisübersicht:\n\n${preisText}\n\nHinweis: Die angezeigten Preise sind Mietpreise inkl. ${VERSICHERUNG_PROZENT} % Versicherung und ${MWST_PROZENT} % MwSt. Die Bestätigung erfolgt nach Prüfung durch den Verleih.\n\nMit freundlichen Grüßen\nMietpark Hahn\nAnfrage-ID: ${data.anfrageId}\n\nUnsere AGB finden Sie unter: ${process.env.APP_URL ?? "https://www.mietparkhahn.de"}/agb`,
html,
}, `Kundeneingang ${data.firma}`);
}
// ─── Admin-Benachrichtigung (Vermieter) ───────────────────────────────────
export async function sendeAnfrageEmail(data: AnfrageEmailData) {
const { html: preisHtml, text: preisText } = buildPreisBlock(data.positionen);
// Action-Tokens für direkte Email-Links generieren
const { createActionToken } = await import("@/lib/admin-auth");
const [tokenBestaetigt, tokenAbgelehnt, tokenAbgeschlossen] = await Promise.all([
createActionToken(data.anfrageId, "bestaetigt"),
createActionToken(data.anfrageId, "abgelehnt"),
createActionToken(data.anfrageId, "abgeschlossen"),
]);
const baseUrl = process.env.APP_URL ?? "https://www.mietparkhahn.de";
const urlBestaetigt = `${baseUrl}/api/admin/anfragen-action?token=${tokenBestaetigt}`;
const urlAbgelehnt = `${baseUrl}/api/admin/anfragen-action?token=${tokenAbgelehnt}`;
const urlAbgeschlossen = `${baseUrl}/api/admin/anfragen-action?token=${tokenAbgeschlossen}`;
const html = `
<!DOCTYPE html>
<html lang="de">
<head><meta charset="UTF-8"></head>
<body style="font-family:system-ui,sans-serif;color:#1c1917;max-width:600px;margin:0 auto;padding:0">
<div style="background:#1c1917;padding:20px 24px">
<h1 style="color:#f7d334;margin:0;font-size:20px;font-weight:700">Mietpark Hahn</h1>
<p style="color:rgba(255,255,255,0.7);margin:4px 0 0;font-size:13px">Neue Mietanfrage eingegangen</p>
</div>
<div style="padding:24px;background:#fff">
<h2 style="margin:0 0 16px;font-size:18px">Neue Mietanfrage</h2>
<h3 style="margin:0 0 10px;font-size:13px;text-transform:uppercase;letter-spacing:0.05em;color:#64748b">
Kontaktdaten Kunde
</h3>
<table style="width:100%;border-collapse:collapse;margin-bottom:24px;font-size:13px;background:#f8fafc;border:1px solid #e2e8f0">
<tr>
<td style="padding:8px 12px;color:#64748b;width:130px;border-bottom:1px solid #e2e8f0">Firma / Name</td>
<td style="padding:8px 12px;font-weight:600;border-bottom:1px solid #e2e8f0">${data.firma}</td>
</tr>
<tr>
<td style="padding:8px 12px;color:#64748b;border-bottom:1px solid #e2e8f0">Telefon</td>
<td style="padding:8px 12px;border-bottom:1px solid #e2e8f0">
<a href="tel:${data.telefon}" style="color:#1c1917;font-weight:600">${data.telefon}</a>
</td>
</tr>
<tr>
<td style="padding:8px 12px;color:#64748b">E-Mail</td>
<td style="padding:8px 12px">
<a href="mailto:${data.email}" style="color:#1c1917">${data.email}</a>
</td>
</tr>
</table>
<h3 style="margin:0 0 12px;font-size:13px;text-transform:uppercase;letter-spacing:0.05em;color:#64748b;border-top:1px solid #e2e8f0;padding-top:16px">
Angefragte Maschinen &amp; Preisübersicht
</h3>
${preisHtml}
<div style="margin:24px 0 0;display:flex;gap:12px;flex-wrap:wrap">
<a href="${urlBestaetigt}"
style="background:#16a34a;color:#fff;padding:10px 20px;border-radius:4px;font-weight:600;font-size:14px;text-decoration:none;display:inline-block">
✓ Bestätigen
</a>
<a href="${urlAbgelehnt}"
style="background:#dc2626;color:#fff;padding:10px 20px;border-radius:4px;font-weight:600;font-size:14px;text-decoration:none;display:inline-block">
✕ Ablehnen
</a>
<a href="${urlAbgeschlossen}"
style="background:#64748b;color:#fff;padding:10px 20px;border-radius:4px;font-weight:600;font-size:14px;text-decoration:none;display:inline-block">
✓ Abschließen
</a>
</div>
<div style="margin-top:20px;padding:12px 16px;background:#f8fafc;border-left:3px solid #f7d334">
<p style="margin:0;font-size:12px;color:#64748b">
Anfrage-ID: <code style="background:#e2e8f0;padding:1px 4px;border-radius:2px">${data.anfrageId}</code><br>
<a href="${baseUrl}/admin/anfragen/${data.anfrageId}"
style="color:#1c1917;font-weight:600">→ Anfrage im Admin öffnen</a>
</p>
</div>
</div>
</body>
</html>`;
const posText = `Kontakt:\nFirma/Name: ${data.firma}\nTelefon: ${data.telefon}\nE-Mail: ${data.email}\n\nMaschinen & Preise:\n\n${preisText}`;
const actionLinks = `\nQuickaktion aus Email:\n✓ Bestätigen: ${urlBestaetigt}\n✕ Ablehnen: ${urlAbgelehnt}\n✓ Abschließen: ${urlAbgeschlossen}`;
await sendWithFallback({
from: `"Mietpark Hahn" <${process.env.SMTP_FROM}>`,
to: process.env.SMTP_TO,
subject: `Neue Mietanfrage: ${data.firma} ${data.positionen.length} Gerät${data.positionen.length !== 1 ? "e" : ""} Mietpark Hahn`,
text: `Neue Mietanfrage\n\n${posText}\n\nAdmin: ${baseUrl}/admin/anfragen/${data.anfrageId}${actionLinks}`,
html,
}, `Admin-Anfrage ${data.firma}`);
}
// ─── Kunden-Statusbenachrichtigung ────────────────────────────────────────
export interface StatusEmailData {
anfrageId: string;
firma: string;
email: string;
neuerStatus: "bestaetigt" | "abgelehnt" | "abgeschlossen";
notizen?: string;
positionen?: {
maschineName: string;
mietbeginn: string;
mietende: string;
gesamtTage: number;
lieferung: boolean;
lieferadresse: string;
anmerkung: string;
tagessatz: number | null;
preisStufe?: "tag" | "woche" | "monat" | null;
zubehoer?: {
id: string;
name: string;
preisTag: number | null;
preisWoche?: number | null;
preisMonat?: number | null;
}[];
}[];
}
const STATUS_TEXTE: Record<
StatusEmailData["neuerStatus"],
{ betreff: string; headline: string; text: string; farbe: string }
> = {
bestaetigt: {
betreff: "Ihre Mietanfrage wurde bestätigt Mietpark Hahn",
headline: "Ihre Anfrage ist bestätigt!",
text: "Wir freuen uns, Ihnen mitteilen zu können, dass Ihre Mietanfrage bestätigt wurde. Wir setzen uns zur Vorbereitung mit Ihnen in Verbindung.",
farbe: "#16a34a",
},
abgelehnt: {
betreff: "Zu Ihrer Mietanfrage Mietpark Hahn",
headline: "Zu Ihrer Mietanfrage",
text: "Leider können wir Ihre Mietanfrage im angefragten Zeitraum nicht erfüllen. Bitte kontaktieren Sie uns für alternative Termine.",
farbe: "#dc2626",
},
abgeschlossen: {
betreff: "Ihre Miete wurde abgeschlossen Mietpark Hahn",
headline: "Vielen Dank!",
text: "Ihre Miete wurde erfolgreich abgeschlossen. Wir hoffen, Sie waren mit unserem Service zufrieden und freuen uns auf Ihre nächste Anfrage.",
farbe: "#475569",
},
};
export async function sendeKundenStatusEmail(data: StatusEmailData) {
const info = STATUS_TEXTE[data.neuerStatus];
// Geräte-Übersicht (falls vorhanden)
const equipmentHtml = data.positionen ? `
<div style="margin-bottom:24px">
<h3 style="margin:0 0 12px;font-size:13px;text-transform:uppercase;letter-spacing:0.05em;color:#64748b">
Ihre gemieteten Geräte
</h3>
<ul style="margin:0;padding-left:20px;list-style:none">
${data.positionen
.map((p) => `
<li style="margin:0 0 6px;padding:0;font-size:14px;color:#1c1917">
<span style="display:inline-block;width:6px;height:6px;background:#f7d334;border-radius:50%;margin-right:8px;vertical-align:middle"></span>
<strong>${p.maschineName}</strong>
<span style="color:#64748b"> · ${formatDatum(p.mietbeginn)} bis ${formatDatum(p.mietende)}</span>
</li>`)
.join("")}
</ul>
</div>
` : "";
// Preisblock (falls vorhanden)
const preisBlock = data.positionen ? buildPreisBlock(data.positionen) : null;
const preisHtml = preisBlock?.html ?? "";
const html = `
<!DOCTYPE html>
<html lang="de">
<head><meta charset="UTF-8"></head>
<body style="font-family:system-ui,sans-serif;color:#1c1917;max-width:600px;margin:0 auto;padding:0">
<div style="background:#1c1917;padding:20px 24px">
<h1 style="color:#f7d334;margin:0;font-size:20px;font-weight:700">Mietpark Hahn</h1>
<p style="color:rgba(255,255,255,0.7);margin:4px 0 0;font-size:13px">Update zu Ihrer Mietanfrage</p>
</div>
<div style="padding:24px;background:#fff">
<div style="border-left:4px solid ${info.farbe};padding-left:16px;margin-bottom:20px">
<h2 style="margin:0 0 8px;font-size:18px">${info.headline}</h2>
<p style="margin:0;color:#475569;font-size:14px">Guten Tag ${data.firma},<br>${info.text}</p>
</div>
${equipmentHtml}
${data.positionen && preisHtml ? `
<h3 style="margin:0 0 12px;font-size:15px;border-top:2px solid #f7d334;padding-top:16px">
Detaillierte Preisübersicht
</h3>
${preisHtml}
` : ""}
${
data.notizen
? `<div style="padding:12px 16px;background:#f8fafc;border:1px solid #e2e8f0;margin-bottom:20px;margin-top:20px">
<p style="margin:0 0 4px;font-size:12px;color:#64748b;font-weight:600">NACHRICHT VOM VERLEIH</p>
<p style="margin:0;font-size:14px;color:#1c1917;white-space:pre-wrap">${data.notizen}</p>
</div>`
: ""
}
<p style="margin-top:20px;font-size:13px;color:#64748b">
Bei Fragen erreichen Sie uns unter
<a href="mailto:${process.env.SMTP_FROM}" style="color:#1c1917">${process.env.SMTP_FROM ?? ""}</a>
</p>
</div>
<div style="padding:12px 24px;background:#f8fafc;border-top:1px solid #e2e8f0">
<p style="margin:0;font-size:11px;color:#94a3b8">
Mit der Nutzung unserer Dienste akzeptieren Sie unsere
<a href="${process.env.APP_URL ?? "https://www.mietparkhahn.de"}/agb" style="color:#64748b">Allgemeinen Geschäftsbedingungen</a>.
</p>
</div>
</body>
</html>`;
const equipmentList = data.positionen
? data.positionen
.map((p) => `${p.maschineName} (${formatDatum(p.mietbeginn)} bis ${formatDatum(p.mietende)})`)
.join("\n")
: "";
await sendWithFallback({
from: `"Mietpark Hahn" <${process.env.SMTP_FROM}>`,
to: data.email,
subject: info.betreff,
text: `Guten Tag ${data.firma},\n\n${info.text}${equipmentList ? `\n\nIhre gemieteten Geräte:\n${equipmentList}` : ""}${data.notizen ? `\n\nNachricht vom Verleih:\n${data.notizen}` : ""}\n\nMit freundlichen Grüßen\nMietpark Hahn\n\nUnsere AGB finden Sie unter: ${process.env.APP_URL ?? "https://www.mietparkhahn.de"}/agb`,
html,
}, `Status-${data.neuerStatus} ${data.firma}`);
}
// ─── Registrierungsbestätigung ─────────────────────────────────────────────
export async function sendeRegistrierungsBestaetigung(data: {
email: string;
firma?: string;
bestaetigungsLink: string;
}) {
const name = data.firma || data.email;
const html = `
<!DOCTYPE html>
<html lang="de">
<head><meta charset="UTF-8"></head>
<body style="font-family:system-ui,sans-serif;color:#1c1917;max-width:600px;margin:0 auto;padding:0">
<div style="background:#1c1917;padding:20px 24px">
<h1 style="color:#f7d334;margin:0;font-size:20px;font-weight:700">Mietpark Hahn</h1>
<p style="color:rgba(255,255,255,0.7);margin:4px 0 0;font-size:13px">E-Mail-Adresse bestätigen</p>
</div>
<div style="padding:24px;background:#fff">
<p style="margin:0 0 16px">Guten Tag ${name},</p>
<p style="margin:0 0 24px;color:#475569">
vielen Dank für Ihre Registrierung bei Mietpark Hahn. Bitte bestätigen Sie Ihre
E-Mail-Adresse, um Zugang zu Ihrem Kundenbereich zu erhalten.
</p>
<a href="${data.bestaetigungsLink}"
style="display:inline-block;background:#f7d334;color:#1c1917;font-weight:700;padding:12px 28px;text-decoration:none;border-radius:4px;font-size:15px">
E-Mail-Adresse bestätigen →
</a>
<p style="margin:24px 0 0;font-size:12px;color:#94a3b8">
Dieser Link ist 24 Stunden gültig. Falls Sie sich nicht registriert haben, können Sie
diese E-Mail ignorieren.
</p>
</div>
<div style="padding:12px 24px;background:#f8fafc;border-top:1px solid #e2e8f0">
<p style="margin:0;font-size:11px;color:#94a3b8">
Mit der Nutzung unserer Dienste akzeptieren Sie unsere
<a href="${process.env.APP_URL ?? "https://www.mietparkhahn.de"}/agb" style="color:#64748b">Allgemeinen Geschäftsbedingungen</a>.
</p>
</div>
</body>
</html>`;
await sendWithFallback({
from: `"Mietpark Hahn" <${process.env.SMTP_FROM}>`,
to: data.email,
subject: "Bitte bestätigen Sie Ihre E-Mail-Adresse Mietpark Hahn",
text: `Guten Tag ${name},\n\nbitte bestätigen Sie Ihre E-Mail-Adresse:\n\n${data.bestaetigungsLink}\n\nDieser Link ist 24 Stunden gültig.\n\nMit freundlichen Grüßen\nMietpark Hahn\n\nUnsere AGB finden Sie unter: ${process.env.APP_URL ?? "https://www.mietparkhahn.de"}/agb`,
html,
}, `Registrierung ${name}`);
}
// ─── Maschinen-Bedarfscheck ────────────────────────────────────────────────
export async function sendeBedarfscheckAnKunde(data: {
name: string;
email: string;
}) {
const html = `
<!DOCTYPE html>
<html lang="de">
<head><meta charset="UTF-8"></head>
<body style="font-family:system-ui,sans-serif;color:#1c1917;max-width:600px;margin:0 auto;padding:0">
<div style="background:#1c1917;padding:20px 24px">
<h1 style="color:#f7d334;margin:0;font-size:20px;font-weight:700">Mietpark Hahn</h1>
<p style="color:rgba(255,255,255,0.7);margin:4px 0 0;font-size:13px">Ihr kostenloser Maschinen-Bedarfscheck</p>
</div>
<div style="padding:24px;background:#fff">
<p style="margin:0 0 20px">Guten Tag ${data.name},</p>
<p style="margin:0 0 24px;color:#475569;font-size:14px">
vielen Dank für Ihre Anfrage! Hier sind die Antworten zu den 7 wichtigsten Fragen vor der Maschinen-Miete:
</p>
<ol style="margin:0 0 24px;padding-left:20px;color:#1c1917">
<li style="margin-bottom:16px;font-size:14px;line-height:1.5">
<strong>Welche Maschinenklasse passt zu meinem Projekt?</strong><br>
<span style="color:#475569">Minibagger (1,53t) für Garten- und enge Baustellenarbeiten, Kettenbagger (516t) für Baugruben und Schachtarbeiten, Radlader für Materialtransport und Verdichtung.</span>
</li>
<li style="margin-bottom:16px;font-size:14px;line-height:1.5">
<strong>Welches Zubehör brauche ich?</strong><br>
<span style="color:#475569">Tieflöffel für Erde, Grabenräumlöffel für Leitungsgräben, Abbruchhammer für Beton/Asphalt, Greifer für Schüttgut. Die genaue Auswahl beraten wir gerne persönlich.</span>
</li>
<li style="margin-bottom:16px;font-size:14px;line-height:1.5">
<strong>Wie plane ich Mietdauer & Lieferung richtig?</strong><br>
<span style="color:#475569">Immer 1 Puffertag einkalkulieren. Lieferung mindestens 1 Werktag vorab anfragen. In Frühjahr/Herbst frühzeitig reservieren, da die Nachfrage hoch ist.</span>
</li>
<li style="margin-bottom:16px;font-size:14px;line-height:1.5">
<strong>Was muss ich bei der Übergabe prüfen?</strong><br>
<span style="color:#475569">Betriebsstunden und Ölstände notieren, Schäden fotografieren, Bedienungsanleitung mitnehmen. Dies schützt Sie vor Haftungsansprüchen.</span>
</li>
<li style="margin-bottom:16px;font-size:14px;line-height:1.5">
<strong>Welche Versicherung ist sinnvoll?</strong><br>
<span style="color:#475569">Privatpersonen sollten ihre Haftpflichtversicherung prüfen. Firmen benötigen Baugeräteversicherung. Eine Maschinenbruchversicherung über uns ist optional, aber empfohlen.</span>
</li>
<li style="margin-bottom:16px;font-size:14px;line-height:1.5">
<strong>Wie spare ich durch Wochenmiete?</strong><br>
<span style="color:#475569">Wochentarife sind ca. 3040% günstiger als 5× Tagessatz. Schon ab 4 Tagen Einsatz lohnt sich die Wochenmiete gegenüber Tagesmietung.</span>
</li>
<li style="margin-bottom:0;font-size:14px;line-height:1.5">
<strong>Was tun, wenn die Maschine ausfällt?</strong><br>
<span style="color:#475569">Sofort den Verleih anrufen. Wenn kein Verschulden vorliegt, entstehen keine Kosten während des Ausfalls. Ersatz wird schnellstmöglich bereitgestellt.</span>
</li>
</ol>
<div style="padding:16px;background:#fef3c7;border-left:3px solid #f7d334;margin:24px 0">
<p style="margin:0;font-size:13px;color:#92400e">
<strong>Nächste Schritte:</strong> Rufen Sie uns an oder stellen Sie eine Anfrage mit den gewünschten Maschinen. Unser Team prüft sofort die Verfügbarkeit und meldet sich bei Ihnen!
</p>
</div>
<p style="margin:20px 0 0;font-size:13px;color:#64748b">
Bei Fragen erreichen Sie uns unter
<a href="tel:${process.env.COMPANY_PHONE ?? ""}" style="color:#1c1917">${process.env.COMPANY_PHONE ?? ""}</a>
oder per E-Mail an
<a href="mailto:${process.env.SMTP_FROM ?? ""}" style="color:#1c1917">${process.env.SMTP_FROM ?? ""}</a>
</p>
</div>
<div style="padding:12px 24px;background:#f8fafc;border-top:1px solid #e2e8f0">
<p style="margin:0;font-size:11px;color:#94a3b8">
Mit der Nutzung unserer Dienste akzeptieren Sie unsere
<a href="${process.env.APP_URL ?? "https://www.mietparkhahn.de"}/agb" style="color:#64748b">Allgemeinen Geschäftsbedingungen</a>.
</p>
</div>
</body>
</html>`;
const text = `Guten Tag ${data.name},
vielen Dank für Ihre Anfrage! Hier sind die Antworten zu den 7 wichtigsten Fragen vor der Maschinen-Miete:
1. Welche Maschinenklasse passt zu meinem Projekt?
Minibagger (1,53t) für Garten- und enge Baustellenarbeiten, Kettenbagger (516t) für Baugruben und Schachtarbeiten, Radlader für Materialtransport und Verdichtung.
2. Welches Zubehör brauche ich?
Tieflöffel für Erde, Grabenräumlöffel für Leitungsgräben, Abbruchhammer für Beton/Asphalt, Greifer für Schüttgut. Die genaue Auswahl beraten wir gerne persönlich.
3. Wie plane ich Mietdauer & Lieferung richtig?
Immer 1 Puffertag einkalkulieren. Lieferung mindestens 1 Werktag vorab anfragen. In Frühjahr/Herbst frühzeitig reservieren, da die Nachfrage hoch ist.
4. Was muss ich bei der Übergabe prüfen?
Betriebsstunden und Ölstände notieren, Schäden fotografieren, Bedienungsanleitung mitnehmen. Dies schützt Sie vor Haftungsansprüchen.
5. Welche Versicherung ist sinnvoll?
Privatpersonen sollten ihre Haftpflichtversicherung prüfen. Firmen benötigen Baugeräteversicherung. Eine Maschinenbruchversicherung über uns ist optional, aber empfohlen.
6. Wie spare ich durch Wochenmiete?
Wochentarife sind ca. 3040% günstiger als 5× Tagessatz. Schon ab 4 Tagen Einsatz lohnt sich die Wochenmiete gegenüber Tagesmietung.
7. Was tun, wenn die Maschine ausfällt?
Sofort den Verleih anrufen. Wenn kein Verschulden vorliegt, entstehen keine Kosten während des Ausfalls. Ersatz wird schnellstmöglich bereitgestellt.
Nächste Schritte: Rufen Sie uns an oder stellen Sie eine Anfrage mit den gewünschten Maschinen.
Mit freundlichen Grüßen
Mietpark Hahn
Unsere AGB finden Sie unter: ${process.env.APP_URL ?? "https://www.mietparkhahn.de"}/agb`;
await sendWithFallback({
from: `"Mietpark Hahn" <${process.env.SMTP_FROM}>`,
to: data.email,
subject: "Ihr kostenloser Maschinen-Bedarfscheck Mietpark Hahn",
text,
html,
}, `Bedarfscheck ${data.name}`);
}
export async function sendeBedarfscheckAnVermieter(data: {
name: string;
email: string;
telefon: string;
adresse: string | null;
}) {
const html = `
<!DOCTYPE html>
<html lang="de">
<head><meta charset="UTF-8"></head>
<body style="font-family:system-ui,sans-serif;color:#1c1917;max-width:600px;margin:0 auto;padding:0">
<div style="background:#1c1917;padding:20px 24px">
<h1 style="color:#f7d334;margin:0;font-size:20px;font-weight:700">Mietpark Hahn</h1>
<p style="color:rgba(255,255,255,0.7);margin:4px 0 0;font-size:13px">Neuer Lead aus Bedarfscheck</p>
</div>
<div style="padding:24px;background:#fff">
<h2 style="margin:0 0 16px;font-size:18px">Neuer Lead aus Maschinen-Bedarfscheck</h2>
<h3 style="margin:0 0 10px;font-size:13px;text-transform:uppercase;letter-spacing:0.05em;color:#64748b">
Kontaktdaten
</h3>
<table style="width:100%;border-collapse:collapse;margin-bottom:24px;font-size:13px;background:#f8fafc;border:1px solid #e2e8f0">
<tr>
<td style="padding:8px 12px;color:#64748b;width:130px;border-bottom:1px solid #e2e8f0">Name / Firma</td>
<td style="padding:8px 12px;font-weight:600;border-bottom:1px solid #e2e8f0">${data.name}</td>
</tr>
<tr>
<td style="padding:8px 12px;color:#64748b;border-bottom:1px solid #e2e8f0">E-Mail</td>
<td style="padding:8px 12px;border-bottom:1px solid #e2e8f0">
<a href="mailto:${data.email}" style="color:#1c1917">${data.email}</a>
</td>
</tr>
<tr>
<td style="padding:8px 12px;color:#64748b;border-bottom:1px solid #e2e8f0">Telefon</td>
<td style="padding:8px 12px;border-bottom:1px solid #e2e8f0">
<a href="tel:${data.telefon}" style="color:#1c1917;font-weight:600">${data.telefon}</a>
</td>
</tr>
${
data.adresse
? `<tr>
<td style="padding:8px 12px;color:#64748b">Adresse</td>
<td style="padding:8px 12px">${data.adresse}</td>
</tr>`
: ""
}
</table>
<div style="padding:12px 16px;background:#f8fafc;border-left:3px solid #f7d334">
<p style="margin:0;font-size:12px;color:#64748b">
Quelle: Maschinen-Bedarfscheck (Lead Magnet)
</p>
</div>
</div>
</body>
</html>`;
const text = `Neuer Lead aus Maschinen-Bedarfscheck
Kontaktdaten:
Name/Firma: ${data.name}
E-Mail: ${data.email}
Telefon: ${data.telefon}
${data.adresse ? `Adresse: ${data.adresse}` : ""}
Quelle: Maschinen-Bedarfscheck`;
await sendWithFallback({
from: `"Mietpark Hahn" <${process.env.SMTP_FROM}>`,
to: process.env.SMTP_TO,
subject: `Neuer Lead: ${data.name} Maschinen-Bedarfscheck`,
text,
html,
}, `Lead ${data.name}`);
}
// ─── Email-Änderungsbestätigung (Admin-Profil) ────────────────────────────────
export async function sendeEmailAenderungsBestaetigung(data: {
adminEmail: string;
adminName: string;
bestaetigungsLink: string;
}) {
const html = `
<!DOCTYPE html>
<html lang="de">
<head><meta charset="UTF-8"></head>
<body style="font-family:system-ui,sans-serif;color:#1c1917;max-width:600px;margin:0 auto;padding:0">
<div style="background:#1c1917;padding:20px 24px">
<h1 style="color:#f7d334;margin:0;font-size:20px;font-weight:700">Mietpark Hahn</h1>
<p style="color:rgba(255,255,255,0.7);margin:4px 0 0;font-size:13px">Admin-Bereich: E-Mail-Änderung</p>
</div>
<div style="padding:24px;background:#fff">
<p style="margin:0 0 16px">Guten Tag ${data.adminName},</p>
<p style="margin:0 0 24px;color:#475569">
Sie haben eine neue E-Mail-Adresse für Ihr Admin-Konto hinterlegt.
Bitte bestätigen Sie diese Änderung, um die neue E-Mail-Adresse zu aktivieren.
</p>
<a href="${data.bestaetigungsLink}"
style="display:inline-block;background:#f7d334;color:#1c1917;font-weight:700;padding:12px 28px;text-decoration:none;border-radius:4px;font-size:15px">
E-Mail-Adresse bestätigen →
</a>
<p style="margin:24px 0 0;font-size:12px;color:#94a3b8">
<strong>Wichtig:</strong> Ihre bisherige E-Mail-Adresse bleibt aktiv, bis Sie diesen Link klicken.
Dieser Link ist 24 Stunden gültig.
</p>
<p style="margin:16px 0 0;font-size:12px;color:#94a3b8">
Falls Sie diese E-Mail nicht angefordert haben, können Sie sie ignorieren.
Ihre E-Mail-Adresse wird nicht geändert.
</p>
</div>
<div style="padding:12px 24px;background:#f8fafc;border-top:1px solid #e2e8f0">
<p style="margin:0;font-size:11px;color:#94a3b8">
Mit der Nutzung unserer Dienste akzeptieren Sie unsere
<a href="${process.env.APP_URL ?? "https://www.mietparkhahn.de"}/agb" style="color:#64748b">Allgemeinen Geschäftsbedingungen</a>.
</p>
</div>
</body>
</html>`;
const text = `Guten Tag ${data.adminName},
Sie haben eine neue E-Mail-Adresse für Ihr Admin-Konto hinterlegt.
Bitte bestätigen Sie diese Änderung unter folgendem Link:
${data.bestaetigungsLink}
Dieser Link ist 24 Stunden gültig.
WICHTIG: Ihre bisherige E-Mail-Adresse bleibt aktiv, bis Sie den Link oben bestätigen.
Mit freundlichen Grüßen
Mietpark Hahn
Unsere AGB finden Sie unter: ${process.env.APP_URL ?? "https://www.mietparkhahn.de"}/agb`;
await sendWithFallback({
from: `"Mietpark Hahn" <${process.env.SMTP_FROM}>`,
to: data.adminEmail,
subject: "Neue E-Mail-Adresse bestätigen Mietpark Hahn",
text,
html,
}, `Email-Änderung ${data.adminName}`);
}