Kontaktformular-Dienst (Node + nodemailer), self-hosted für Coolify

- POST /api/kontakt: validiert, Honeypot-Spamschutz, Rate-Limit (5/10min)
- Versand per SMTP über eigenes Postfach, keine Speicherung/DB
- CORS auf die eigene Website beschränkt (ALLOWED_ORIGIN)
- Konfiguration komplett über Environment-Variablen (.env.example)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
kerimatilganandClaude Opus 4.8 committed 2026-07-03 15:13:38 +02:00
commit a617846aa9
6 files changed
+994

No files matched your search

+108
View File
@@ -0,0 +1,108 @@
// Kontaktformular-Dienst für atilgan-gartenbau.de
// Nimmt das Formular per POST entgegen und verschickt es als E-Mail über das
// eigene Postfach (SMTP). Speichert nichts – die Daten verlassen den eigenen
// Server nur als E-Mail an den Betreiber.
import express from 'express';
import cors from 'cors';
import rateLimit from 'express-rate-limit';
import nodemailer from 'nodemailer';
const {
PORT = 3000,
ALLOWED_ORIGIN = '', // z. B. "https://atilgan-gartenbau.de,https://www.atilgan-gartenbau.de"
SMTP_HOST,
SMTP_PORT = 587,
SMTP_SECURE = 'false', // "true" für Port 465
SMTP_USER,
SMTP_PASS,
MAIL_TO = 'info@adnan-atilgan.de',
MAIL_FROM, // Absender; wenn leer, wird SMTP_USER genutzt
MAIL_SUBJECT_PREFIX = '[Webseite] ',
} = process.env;
const app = express();
app.set('trust proxy', 1); // hinter Coolify/Reverse-Proxy korrekte IP
app.use(express.json({ limit: '32kb' }));
// --- CORS: nur die eigene Website darf das Formular abschicken ---
const allowList = ALLOWED_ORIGIN.split(',').map((s) => s.trim()).filter(Boolean);
app.use(
cors({
origin(origin, cb) {
// Anfragen ohne Origin (z. B. Health-Checks) erlauben
if (!origin || allowList.length === 0 || allowList.includes(origin)) return cb(null, true);
return cb(new Error('Origin nicht erlaubt'));
},
methods: ['POST', 'OPTIONS'],
})
);
// --- Rate-Limit gegen Spam/Missbrauch ---
const limiter = rateLimit({
windowMs: 10 * 60 * 1000, // 10 Minuten
max: 5, // max. 5 Nachrichten pro IP und Fenster
standardHeaders: true,
legacyHeaders: false,
message: { ok: false, error: 'Zu viele Anfragen. Bitte versuchen Sie es später erneut.' },
});
// --- SMTP-Transport ---
const transporter = nodemailer.createTransport({
host: SMTP_HOST,
port: Number(SMTP_PORT),
secure: String(SMTP_SECURE) === 'true',
auth: SMTP_USER ? { user: SMTP_USER, pass: SMTP_PASS } : undefined,
});
const isEmail = (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v || '');
const clean = (v) => String(v || '').trim().slice(0, 5000);
// --- Health-Check ---
app.get('/', (_req, res) => res.json({ ok: true, service: 'kontakt-api' }));
app.get('/health', (_req, res) => res.json({ ok: true }));
// --- Formular-Endpunkt ---
app.post('/api/kontakt', limiter, async (req, res) => {
try {
const b = req.body || {};
// Honeypot: echtes Formular lässt dieses Feld leer; Bots füllen es aus
if (clean(b.website)) return res.json({ ok: true }); // still & unauffällig „erfolgreich"
const name = clean(b.name);
const email = clean(b.email);
const telefon = clean(b.telefon);
const betreff = clean(b.betreff) || 'Anfrage über die Webseite';
const nachricht = clean(b.nachricht);
if (!name || !nachricht || !isEmail(email)) {
return res.status(400).json({ ok: false, error: 'Bitte Name, gültige E-Mail und Nachricht angeben.' });
}
const text =
`Neue Nachricht über das Kontaktformular\n\n` +
`Name: ${name}\n` +
`E-Mail: ${email}\n` +
`Telefon: ${telefon || '-'}\n` +
`Betreff: ${betreff}\n\n` +
`Nachricht:\n${nachricht}\n`;
await transporter.sendMail({
from: MAIL_FROM || SMTP_USER,
to: MAIL_TO,
replyTo: `${name} <${email}>`,
subject: `${MAIL_SUBJECT_PREFIX}${betreff}`,
text,
});
return res.json({ ok: true });
} catch (err) {
console.error('Mailversand fehlgeschlagen:', err.message);
return res.status(500).json({ ok: false, error: 'Nachricht konnte nicht gesendet werden.' });
}
});
app.listen(PORT, () => {
console.log(`kontakt-api läuft auf Port ${PORT}`);
});