portfolio-page/backend/services/emailService.js

157 lines
4.2 KiB
JavaScript

import nodemailer from 'nodemailer'
import { env } from './env.js'
let cachedTransporter = null
function getTransporter() {
if (cachedTransporter) return cachedTransporter
const host = env('SMTP_HOST')
const user = env('SMTP_USER')
const pass = env('SMTP_PASS')
if (!host || !user || !pass) {
throw new Error('SMTP configuration is incomplete.')
}
const port = Number(env('SMTP_PORT', '587'))
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
throw new Error(`SMTP_PORT is not a valid port: ${env('SMTP_PORT')}`)
}
cachedTransporter = nodemailer.createTransport({
host,
port,
secure: port === 465, // implicit TLS on 465, STARTTLS otherwise
auth: { user, pass },
})
return cachedTransporter
}
/**
* Strips CR and LF so a submitted value can never inject extra mail headers
* when it is used in Subject or Reply-To.
*/
function singleLine(value) {
return String(value).replace(/[\r\n]+/g, ' ').trim()
}
/** Minimal HTML escaping - the payload is attacker-controlled by definition. */
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
}
const ROLE_LABELS = {
agency: 'Agentur / Dienstleister',
client: 'Endkunde (eigene Website)',
}
export async function sendAuditRequestEmail({
name,
company,
email,
url,
role,
motivation,
}) {
const transporter = getTransporter()
const to = env('AUDIT_RECIPIENT')
// Falls back to the authenticated mailbox when SMTP_FROM is left blank.
const from = env('SMTP_FROM', env('SMTP_USER'))
if (!to) {
throw new Error('AUDIT_RECIPIENT is not configured.')
}
const safeName = singleLine(name)
const safeCompany = singleLine(company)
const safeEmail = singleLine(email)
const roleLabel = ROLE_LABELS[role] ?? role
const textBody = [
`Name: ${safeName}`,
`Firma: ${safeCompany}`,
`E-Mail: ${safeEmail}`,
`Seite: ${singleLine(url)}`,
`Typ: ${roleLabel}`,
'B2B: bestätigt (§ 14 BGB)',
'',
'Motivation:',
motivation,
].join('\n')
const htmlBody = `
<h2>Neue Audit-Anfrage</h2>
<dl>
<dt><strong>Name</strong></dt><dd>${escapeHtml(safeName)}</dd>
<dt><strong>Firma</strong></dt><dd>${escapeHtml(safeCompany)}</dd>
<dt><strong>E-Mail</strong></dt><dd>${escapeHtml(safeEmail)}</dd>
<dt><strong>Seite</strong></dt><dd>${escapeHtml(singleLine(url))}</dd>
<dt><strong>Typ</strong></dt><dd>${escapeHtml(roleLabel)}</dd>
<dt><strong>B2B</strong></dt><dd>bestätigt (§ 14 BGB)</dd>
</dl>
<h3>Motivation</h3>
<p>${escapeHtml(motivation).replaceAll('\n', '<br>')}</p>
`
await transporter.sendMail({
from,
to,
replyTo: safeEmail,
subject: `Audit-Anfrage: ${safeCompany} (${safeName})`,
text: textBody,
html: htmlBody,
})
// Deliberately no recipient address, no payload - keep logs free of
// personal data (Art. 5 GDPR, data minimisation).
console.log('Audit request forwarded.')
}
export async function sendQuickcheckRequestEmail({ name, email }) {
const transporter = getTransporter()
const to = env('AUDIT_RECIPIENT')
const from = env('SMTP_FROM', env('SMTP_USER'))
if (!to) {
throw new Error('AUDIT_RECIPIENT is not configured.')
}
const safeName = singleLine(name || 'Nicht angegeben')
const safeEmail = singleLine(email)
await transporter.sendMail({
from,
to,
replyTo: safeEmail,
subject: `Schnellcheck-Anfrage: ${safeEmail}`,
text: [
'Neue Schnellcheck-Anfrage',
'',
`Name: ${safeName}`,
`E-Mail: ${safeEmail}`,
'B2B: bestätigt (§ 14 BGB)',
'Datenschutz: bestätigt',
].join('\n'),
html: `
<h2>Neue Schnellcheck-Anfrage</h2>
<dl>
<dt><strong>Name</strong></dt><dd>${escapeHtml(safeName)}</dd>
<dt><strong>E-Mail</strong></dt><dd>${escapeHtml(safeEmail)}</dd>
<dt><strong>B2B</strong></dt><dd>bestätigt (§ 14 BGB)</dd>
<dt><strong>Datenschutz</strong></dt><dd>bestätigt</dd>
</dl>
`,
})
console.log('Quickcheck request forwarded.')
}