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((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((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, 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) => { 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(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 } })