234 lines
6.7 KiB
TypeScript
234 lines
6.7 KiB
TypeScript
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 }
|
|
})
|