KI-AGENT: Kontaktformular und Datenschutzseite ergänzt

This commit is contained in:
2026-05-22 15:03:56 +02:00
parent 0bd0120ec2
commit 76764eb4c3
8 changed files with 590 additions and 9 deletions

View File

@@ -0,0 +1,233 @@
import net from 'node:net'
import tls from 'node:tls'
import { Buffer } from 'node:buffer'
type ContactPayload = {
company?: string
email?: string
message?: string
name?: string
privacy?: boolean
website?: string
}
class SmtpClient {
private buffer = ''
private socket: net.Socket | tls.TLSSocket
private waiters: Array<() => void> = []
constructor(socket: net.Socket | tls.TLSSocket) {
this.socket = socket
this.attach(socket)
}
private attach(socket: net.Socket | tls.TLSSocket) {
socket.setEncoding('utf8')
socket.on('data', (chunk) => {
this.buffer += chunk
this.waiters.splice(0).forEach((resolve) => resolve())
})
}
private waitForData() {
return new Promise<void>((resolve, reject) => {
const onError = (error: Error) => {
cleanup()
reject(error)
}
const onClose = () => {
cleanup()
reject(new Error('SMTP-Verbindung wurde geschlossen.'))
}
const cleanup = () => {
this.socket.off('error', onError)
this.socket.off('close', onClose)
}
this.socket.once('error', onError)
this.socket.once('close', onClose)
this.waiters.push(() => {
cleanup()
resolve()
})
})
}
private async readLine() {
while (!this.buffer.includes('\r\n')) {
await this.waitForData()
}
const index = this.buffer.indexOf('\r\n')
const line = this.buffer.slice(0, index)
this.buffer = this.buffer.slice(index + 2)
return line
}
private async readResponse() {
const lines: string[] = []
while (true) {
const line = await this.readLine()
lines.push(line)
if (/^\d{3} /.test(line)) {
const code = Number(line.slice(0, 3))
return { code, message: lines.join('\n') }
}
}
}
async connect(expected = 220) {
const response = await this.readResponse()
this.expect(response, expected)
}
async command(command: string, expected: number | number[]) {
this.socket.write(`${command}\r\n`)
const response = await this.readResponse()
this.expect(response, expected)
return response
}
async data(content: string) {
this.socket.write(`${content.replace(/^\./gm, '..')}\r\n.\r\n`)
const response = await this.readResponse()
this.expect(response, 250)
}
async startTls(host: string) {
this.socket = tls.connect({
rejectUnauthorized: process.env.SMTP_REJECT_UNAUTHORIZED !== 'false',
servername: host,
socket: this.socket
})
this.buffer = ''
this.attach(this.socket)
await new Promise<void>((resolve, reject) => {
this.socket.once('secureConnect', resolve)
this.socket.once('error', reject)
})
}
close() {
this.socket.end()
}
private expect(response: { code: number, message: string }, expected: number | number[]) {
const expectedCodes = Array.isArray(expected) ? expected : [expected]
if (!expectedCodes.includes(response.code)) {
throw new Error(`SMTP-Fehler ${response.code}: ${response.message}`)
}
}
}
const cleanHeader = (value: string) => value.replace(/[\r\n]+/g, ' ').trim()
const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
const encodeHeader = (value: string) => {
const safeValue = cleanHeader(value)
return /^[\x20-\x7e]*$/.test(safeValue)
? safeValue
: `=?UTF-8?B?${Buffer.from(safeValue, 'utf8').toString('base64')}?=`
}
const createEmail = (payload: Required<Pick<ContactPayload, 'email' | 'message' | 'name'>> & Pick<ContactPayload, 'company'>, from: string, to: string) => {
const subject = `FEDEO Kontaktanfrage von ${payload.name}`
const lines = [
`Name: ${payload.name}`,
`E-Mail: ${payload.email}`,
`Unternehmen: ${payload.company || '-'}`,
'',
payload.message
]
return [
`From: ${encodeHeader('FEDEO Webseite')} <${cleanHeader(from)}>`,
`To: ${cleanHeader(to)}`,
`Reply-To: ${cleanHeader(payload.email)}`,
`Subject: ${encodeHeader(subject)}`,
'MIME-Version: 1.0',
'Content-Type: text/plain; charset=UTF-8',
'Content-Transfer-Encoding: 8bit',
'',
lines.join('\n')
].join('\r\n')
}
const sendMail = async (payload: Required<Pick<ContactPayload, 'email' | 'message' | 'name'>> & Pick<ContactPayload, 'company'>) => {
const host = process.env.SMTP_HOST
const user = process.env.SMTP_USER
const password = process.env.SMTP_PASSWORD
const to = process.env.CONTACT_TO
if (!host || !user || !password || !to) {
throw createError({
statusCode: 500,
statusMessage: 'Das Kontaktformular ist noch nicht vollständig konfiguriert.'
})
}
const secure = process.env.SMTP_SECURE === 'true'
const port = Number(process.env.SMTP_PORT || (secure ? 465 : 587))
const from = process.env.SMTP_FROM || user
const client = new SmtpClient(secure
? tls.connect({ host, port, rejectUnauthorized: process.env.SMTP_REJECT_UNAUTHORIZED !== 'false', servername: host })
: net.connect({ host, port })
)
try {
await client.connect()
await client.command(`EHLO ${process.env.SMTP_HELO || 'fedeo.de'}`, 250)
if (!secure) {
await client.command('STARTTLS', 220)
await client.startTls(host)
await client.command(`EHLO ${process.env.SMTP_HELO || 'fedeo.de'}`, 250)
}
await client.command('AUTH LOGIN', 334)
await client.command(Buffer.from(user).toString('base64'), 334)
await client.command(Buffer.from(password).toString('base64'), 235)
await client.command(`MAIL FROM:<${cleanHeader(from)}>`, 250)
await client.command(`RCPT TO:<${cleanHeader(to)}>`, [250, 251])
await client.command('DATA', 354)
await client.data(createEmail(payload, from, to))
await client.command('QUIT', 221)
} finally {
client.close()
}
}
export default defineEventHandler(async (event) => {
const body = await readBody<ContactPayload>(event)
const name = body.name?.trim() || ''
const email = body.email?.trim() || ''
const company = body.company?.trim() || ''
const message = body.message?.trim() || ''
if (body.website) {
return { ok: true }
}
if (!name || name.length > 120) {
throw createError({ statusCode: 400, statusMessage: 'Bitte gib deinen Namen an.' })
}
if (!email || !isValidEmail(email) || email.length > 180) {
throw createError({ statusCode: 400, statusMessage: 'Bitte gib eine gültige E-Mail-Adresse an.' })
}
if (!message || message.length < 10 || message.length > 5000) {
throw createError({ statusCode: 400, statusMessage: 'Bitte beschreibe dein Anliegen etwas genauer.' })
}
if (!body.privacy) {
throw createError({ statusCode: 400, statusMessage: 'Bitte bestätige die Datenschutzhinweise.' })
}
await sendMail({ company, email, message, name })
return { ok: true }
})