fix(andreknie.de): harden personal data lifecycle #2
@@ -8,6 +8,7 @@ import talkRequestRouter from './routes/talk-request.js'
|
||||
import newsletterRouter from './routes/newsletter.js'
|
||||
import resourceDownloadRouter from './routes/resource-download.js'
|
||||
import speakerCvRouter from './routes/speaker-cv.js'
|
||||
import { isMailerReady } from './services/mailer.js'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const PORT = process.env.PORT || 3003
|
||||
@@ -30,7 +31,11 @@ app.use(express.json({ limit: '10kb' }))
|
||||
|
||||
// Routes
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ status: 'ok', time: new Date().toISOString() })
|
||||
res.json({
|
||||
status: isMailerReady() ? 'ok' : 'degraded',
|
||||
mailer: { ready: isMailerReady() },
|
||||
time: new Date().toISOString(),
|
||||
})
|
||||
})
|
||||
|
||||
app.use('/api/contact', contactRouter)
|
||||
@@ -45,6 +50,10 @@ app.use((err, _req, res, _next) => {
|
||||
res.status(500).json({ error: 'Interner Serverfehler.' })
|
||||
})
|
||||
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[andreknie.de Backend] Running on port ${PORT}`)
|
||||
})
|
||||
}
|
||||
|
||||
export { app }
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { beforeAll, afterAll, describe, expect, it } from 'vitest'
|
||||
|
||||
let server
|
||||
let baseUrl
|
||||
|
||||
beforeAll(async () => {
|
||||
const { app } = await import('./index.js')
|
||||
server = app.listen(0)
|
||||
await new Promise(resolve => server.once('listening', resolve))
|
||||
baseUrl = `http://127.0.0.1:${server.address().port}`
|
||||
})
|
||||
|
||||
afterAll(() => server?.close())
|
||||
|
||||
describe('backend mail safety', () => {
|
||||
it('reports degraded health without SMTP configuration', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/health`)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.status).toBe('degraded')
|
||||
expect(body.mailer).toEqual({ ready: false })
|
||||
expect(JSON.stringify(body)).not.toContain('SMTP_PASS')
|
||||
})
|
||||
|
||||
it('does not report success when contact mail cannot be sent', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/contact`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Test', email: 'test@example.invalid', message: 'Synthetic test' }),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(503)
|
||||
expect((await response.json()).error).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Router } from 'express'
|
||||
import { contactLimiter } from '../middleware/rateLimiter.js'
|
||||
import { antiSpam } from '../middleware/antiSpam.js'
|
||||
import { createToken, verifyToken } from '../services/confirmationToken.js'
|
||||
import { sendConfirmationEmail, sendStakeholderNotification } from '../services/mailer.js'
|
||||
import { createToken, reserveToken, completeToken, releaseToken } from '../services/confirmationToken.js'
|
||||
import { sendConfirmationEmail, sendStakeholderNotification, MailerNotReadyError } from '../services/mailer.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -11,27 +11,34 @@ router.post('/', contactLimiter, antiSpam, async (req, res) => {
|
||||
const errors = {}
|
||||
if (!name || name.trim().length === 0) errors.name = 'Name ist erforderlich.'
|
||||
if (name && name.length > 100) errors.name = 'Max. 100 Zeichen.'
|
||||
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) errors.email = 'Gültige E-Mail erforderlich.'
|
||||
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) errors.email = 'Gueltige E-Mail erforderlich.'
|
||||
if (!message || message.trim().length === 0) errors.message = 'Nachricht ist erforderlich.'
|
||||
if (message && message.length > 2000) errors.message = 'Max. 2000 Zeichen.'
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
return res.status(400).json({ errors })
|
||||
}
|
||||
if (Object.keys(errors).length > 0) return res.status(400).json({ errors })
|
||||
|
||||
try {
|
||||
const token = await createToken('contact', { name, email, message }, 24)
|
||||
await sendConfirmationEmail(email, 'contact', token)
|
||||
res.status(201).json({ ok: true, message: 'Bestätigungs-E-Mail gesendet.' })
|
||||
res.status(201).json({ ok: true, message: 'Bestaetigungs-E-Mail gesendet.' })
|
||||
} catch (error) {
|
||||
res.status(error instanceof MailerNotReadyError ? 503 : 502).json({ error: 'Die Anfrage konnte derzeit nicht versendet werden.' })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/confirm/:token', async (req, res) => {
|
||||
const baseUrl = process.env.BASE_URL || ''
|
||||
const entry = await verifyToken(req.params.token)
|
||||
if (!entry) {
|
||||
return res.redirect(baseUrl + '/bestaetigung?status=error')
|
||||
}
|
||||
const entry = await reserveToken(req.params.token)
|
||||
if (!entry) return res.redirect(baseUrl + '/bestaetigung?status=error')
|
||||
|
||||
try {
|
||||
await sendStakeholderNotification('contact', entry.data)
|
||||
await completeToken(req.params.token)
|
||||
res.redirect(baseUrl + '/bestaetigung?status=success')
|
||||
} catch (error) {
|
||||
await releaseToken(req.params.token)
|
||||
res.status(error instanceof MailerNotReadyError ? 503 : 502).redirect(baseUrl + '/bestaetigung?status=error')
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -5,8 +5,8 @@ import { fileURLToPath } from 'url'
|
||||
import { Mutex } from 'async-mutex'
|
||||
import { newsletterLimiter } from '../middleware/rateLimiter.js'
|
||||
import { antiSpam } from '../middleware/antiSpam.js'
|
||||
import { createToken, verifyToken } from '../services/confirmationToken.js'
|
||||
import { sendConfirmationEmail } from '../services/mailer.js'
|
||||
import { createToken, reserveToken, completeToken, releaseToken } from '../services/confirmationToken.js'
|
||||
import { sendConfirmationEmail, MailerNotReadyError } from '../services/mailer.js'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const SUBS_FILE = join(__dirname, '..', 'data', 'subscribers.json')
|
||||
@@ -27,38 +27,46 @@ const router = Router()
|
||||
router.post('/', newsletterLimiter, antiSpam, async (req, res) => {
|
||||
const { email } = req.body
|
||||
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
return res.status(400).json({ errors: { email: 'Gültige E-Mail erforderlich.' } })
|
||||
return res.status(400).json({ errors: { email: 'Gueltige E-Mail erforderlich.' } })
|
||||
}
|
||||
|
||||
// Check for existing subscription
|
||||
const subs = loadSubscribers()
|
||||
if (subs.find(s => s.email === email && s.active)) {
|
||||
return res.status(409).json({ error: 'Diese E-Mail ist bereits abonniert.' })
|
||||
}
|
||||
|
||||
try {
|
||||
const token = await createToken('newsletter', { email }, 48)
|
||||
await sendConfirmationEmail(email, 'newsletter', token)
|
||||
res.status(201).json({ ok: true, message: 'Bestätigungs-E-Mail gesendet.' })
|
||||
res.status(201).json({ ok: true, message: 'Bestaetigungs-E-Mail gesendet.' })
|
||||
} catch (error) {
|
||||
res.status(error instanceof MailerNotReadyError ? 503 : 502).json({ error: 'Die Anfrage konnte derzeit nicht versendet werden.' })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/confirm/:token', async (req, res) => {
|
||||
const baseUrl = process.env.BASE_URL || ''
|
||||
const entry = await verifyToken(req.params.token)
|
||||
if (!entry) {
|
||||
return res.redirect(baseUrl + '/bestaetigung?status=error')
|
||||
}
|
||||
const entry = await reserveToken(req.params.token)
|
||||
if (!entry) return res.redirect(baseUrl + '/bestaetigung?status=error')
|
||||
|
||||
try {
|
||||
await subsMutex.runExclusive(async () => {
|
||||
const subs = loadSubscribers()
|
||||
if (!subs.find(s => s.email === entry.data.email && s.active)) {
|
||||
subs.push({
|
||||
email: entry.data.email,
|
||||
subscribedAt: new Date().toISOString(),
|
||||
active: true,
|
||||
})
|
||||
saveSubscribers(subs)
|
||||
}
|
||||
})
|
||||
|
||||
await completeToken(req.params.token)
|
||||
res.redirect(baseUrl + '/bestaetigung?status=success')
|
||||
} catch {
|
||||
await releaseToken(req.params.token)
|
||||
res.status(502).redirect(baseUrl + '/bestaetigung?status=error')
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -38,15 +38,22 @@ router.post('/', resourceLimiter, antiSpam, async (req, res) => {
|
||||
}
|
||||
|
||||
const lead = { name, email, message, timestamp: new Date().toISOString() }
|
||||
try {
|
||||
const result = await sendSpeakerCv(email, lead, PDF_PATH)
|
||||
if (result.pdfMissing) {
|
||||
return res.status(502).json({ error: 'Der Speaker CV ist derzeit nicht verfügbar.' })
|
||||
}
|
||||
|
||||
await leadsMutex.runExclusive(async () => {
|
||||
const leads = loadLeads()
|
||||
leads.push(lead)
|
||||
saveLeads(leads)
|
||||
})
|
||||
|
||||
await sendSpeakerCv(email, lead, PDF_PATH)
|
||||
|
||||
res.status(201).json({ ok: true, message: 'Danke! Der Speaker CV ist auf dem Weg in dein Postfach.' })
|
||||
} catch {
|
||||
res.status(503).json({ error: 'Der Versand ist derzeit nicht verfügbar.' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Router } from 'express'
|
||||
import { talkRequestLimiter } from '../middleware/rateLimiter.js'
|
||||
import { antiSpam } from '../middleware/antiSpam.js'
|
||||
import { createToken, verifyToken } from '../services/confirmationToken.js'
|
||||
import { sendConfirmationEmail, sendStakeholderNotification } from '../services/mailer.js'
|
||||
import { createToken, reserveToken, completeToken, releaseToken } from '../services/confirmationToken.js'
|
||||
import { sendConfirmationEmail, sendStakeholderNotification, MailerNotReadyError } from '../services/mailer.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -10,29 +10,36 @@ router.post('/', talkRequestLimiter, antiSpam, async (req, res) => {
|
||||
const { name, email, event_name, topic, message } = req.body
|
||||
const errors = {}
|
||||
if (!name || name.trim().length === 0) errors.name = 'Name ist erforderlich.'
|
||||
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) errors.email = 'Gültige E-Mail erforderlich.'
|
||||
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) errors.email = 'Gueltige E-Mail erforderlich.'
|
||||
if (!event_name || event_name.trim().length === 0) errors.event_name = 'Eventname ist erforderlich.'
|
||||
if (!topic || topic.trim().length === 0) errors.topic = 'Thema ist erforderlich.'
|
||||
if (!message || message.trim().length === 0) errors.message = 'Nachricht ist erforderlich.'
|
||||
if (message && message.length > 2000) errors.message = 'Max. 2000 Zeichen.'
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
return res.status(400).json({ errors })
|
||||
}
|
||||
if (Object.keys(errors).length > 0) return res.status(400).json({ errors })
|
||||
|
||||
try {
|
||||
const token = await createToken('talk-request', { name, email, event_name, topic, message }, 48)
|
||||
await sendConfirmationEmail(email, 'talk-request', token)
|
||||
res.status(201).json({ ok: true, message: 'Bestätigungs-E-Mail gesendet.' })
|
||||
res.status(201).json({ ok: true, message: 'Bestaetigungs-E-Mail gesendet.' })
|
||||
} catch (error) {
|
||||
res.status(error instanceof MailerNotReadyError ? 503 : 502).json({ error: 'Die Anfrage konnte derzeit nicht versendet werden.' })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/confirm/:token', async (req, res) => {
|
||||
const baseUrl = process.env.BASE_URL || ''
|
||||
const entry = await verifyToken(req.params.token)
|
||||
if (!entry) {
|
||||
return res.redirect(baseUrl + '/bestaetigung?status=error')
|
||||
}
|
||||
const entry = await reserveToken(req.params.token)
|
||||
if (!entry) return res.redirect(baseUrl + '/bestaetigung?status=error')
|
||||
|
||||
try {
|
||||
await sendStakeholderNotification('talk-request', entry.data)
|
||||
await completeToken(req.params.token)
|
||||
res.redirect(baseUrl + '/bestaetigung?status=success')
|
||||
} catch (error) {
|
||||
await releaseToken(req.params.token)
|
||||
res.status(error instanceof MailerNotReadyError ? 503 : 502).redirect(baseUrl + '/bestaetigung?status=error')
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -9,7 +9,6 @@ const DATA_DIR = join(__dirname, '..', 'data')
|
||||
const TOKENS_FILE = join(DATA_DIR, 'pending-tokens.json')
|
||||
const tokenMutex = new Mutex()
|
||||
|
||||
// Ensure data directory exists
|
||||
mkdirSync(DATA_DIR, { recursive: true })
|
||||
|
||||
function loadTokens() {
|
||||
@@ -22,13 +21,6 @@ function saveTokens(tokens) {
|
||||
writeFileSync(TOKENS_FILE, JSON.stringify(tokens, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a confirmation token.
|
||||
* @param {string} type - 'contact' | 'talk-request' | 'newsletter'
|
||||
* @param {object} data - The form data to store
|
||||
* @param {number} expiresInHours - Token expiration (default: 48h)
|
||||
* @returns {string} The generated token
|
||||
*/
|
||||
export async function createToken(type, data, expiresInHours = 48) {
|
||||
const token = crypto.randomUUID()
|
||||
await tokenMutex.runExclusive(async () => {
|
||||
@@ -39,51 +31,64 @@ export async function createToken(type, data, expiresInHours = 48) {
|
||||
data,
|
||||
createdAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + expiresInHours * 60 * 60 * 1000).toISOString(),
|
||||
confirmed: false,
|
||||
status: 'pending',
|
||||
})
|
||||
saveTokens(tokens)
|
||||
})
|
||||
return token
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify and consume a token.
|
||||
* @returns {object|null} The stored data if valid, null if expired/invalid
|
||||
*/
|
||||
export async function verifyToken(token) {
|
||||
/** Reserve a valid token for exactly one confirmation flow. */
|
||||
export async function reserveToken(token) {
|
||||
return await tokenMutex.runExclusive(async () => {
|
||||
const tokens = loadTokens()
|
||||
const idx = tokens.findIndex(t => t.token === token && !t.confirmed)
|
||||
const idx = tokens.findIndex(t => t.token === token && (t.status || (t.confirmed ? 'completed' : 'pending')) === 'pending')
|
||||
if (idx === -1) return null
|
||||
|
||||
const entry = tokens[idx]
|
||||
if (new Date(entry.expiresAt) < new Date()) {
|
||||
// Expired — remove it
|
||||
if (new Date(entry.expiresAt) <= new Date()) {
|
||||
tokens.splice(idx, 1)
|
||||
saveTokens(tokens)
|
||||
return null
|
||||
}
|
||||
|
||||
// Mark as confirmed
|
||||
tokens[idx].confirmed = true
|
||||
tokens[idx].status = 'processing'
|
||||
saveTokens(tokens)
|
||||
return entry
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired tokens (run periodically).
|
||||
*/
|
||||
/** Remove a successfully processed token and its personal payload. */
|
||||
export async function completeToken(token) {
|
||||
await tokenMutex.runExclusive(async () => {
|
||||
const tokens = loadTokens()
|
||||
const remaining = tokens.filter(t => t.token !== token)
|
||||
if (remaining.length !== tokens.length) saveTokens(remaining)
|
||||
})
|
||||
}
|
||||
|
||||
/** Return a failed confirmation to pending so it can be retried. */
|
||||
export async function releaseToken(token) {
|
||||
await tokenMutex.runExclusive(async () => {
|
||||
const tokens = loadTokens()
|
||||
const entry = tokens.find(t => t.token === token)
|
||||
if (!entry) return
|
||||
entry.status = 'pending'
|
||||
saveTokens(tokens)
|
||||
})
|
||||
}
|
||||
|
||||
// Compatibility for code that only needs the reservation behavior.
|
||||
export const verifyToken = reserveToken
|
||||
|
||||
export async function cleanupExpiredTokens() {
|
||||
await tokenMutex.runExclusive(async () => {
|
||||
const tokens = loadTokens()
|
||||
const now = new Date()
|
||||
const valid = tokens.filter(t => new Date(t.expiresAt) > now || t.confirmed)
|
||||
if (valid.length !== tokens.length) {
|
||||
saveTokens(valid)
|
||||
}
|
||||
const valid = tokens.filter(t => !t.confirmed && new Date(t.expiresAt) > now)
|
||||
if (valid.length !== tokens.length) saveTokens(valid)
|
||||
})
|
||||
}
|
||||
|
||||
// Run cleanup every hour
|
||||
setInterval(cleanupExpiredTokens, 60 * 60 * 1000)
|
||||
const cleanupTimer = setInterval(cleanupExpiredTokens, 60 * 60 * 1000)
|
||||
cleanupTimer.unref?.()
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { existsSync, readFileSync, unlinkSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { createToken, reserveToken, completeToken, releaseToken, cleanupExpiredTokens } from './confirmationToken.js'
|
||||
|
||||
const tokensFile = join(process.cwd(), 'server', 'data', 'pending-tokens.json')
|
||||
|
||||
function removeTokensFile() {
|
||||
if (existsSync(tokensFile)) unlinkSync(tokensFile)
|
||||
}
|
||||
|
||||
beforeEach(removeTokensFile)
|
||||
afterEach(removeTokensFile)
|
||||
|
||||
describe('confirmation token lifecycle', () => {
|
||||
it('creates a pending token and reserves it once', async () => {
|
||||
const token = await createToken('contact', { name: 'Test', email: 'test@example.invalid' }, 1)
|
||||
const first = await reserveToken(token)
|
||||
const second = await reserveToken(token)
|
||||
|
||||
expect(first.status).toBe('processing')
|
||||
expect(second).toBeNull()
|
||||
expect(JSON.parse(readFileSync(tokensFile, 'utf8')).find(entry => entry.token === token).status).toBe('processing')
|
||||
})
|
||||
|
||||
it('removes an expired token instead of returning it', async () => {
|
||||
const token = await createToken('contact', { email: 'expired@example.invalid' }, -1)
|
||||
|
||||
expect(await reserveToken(token)).toBeNull()
|
||||
expect(JSON.parse(readFileSync(tokensFile, 'utf8'))).toEqual([])
|
||||
})
|
||||
|
||||
it('removes a successfully processed token and its payload', async () => {
|
||||
const token = await createToken('contact', { email: 'done@example.invalid' })
|
||||
await reserveToken(token)
|
||||
await completeToken(token)
|
||||
|
||||
expect(JSON.parse(readFileSync(tokensFile, 'utf8'))).toEqual([])
|
||||
})
|
||||
|
||||
it('releases a failed processing attempt for a controlled retry', async () => {
|
||||
const token = await createToken('contact', { email: 'retry@example.invalid' })
|
||||
await reserveToken(token)
|
||||
await releaseToken(token)
|
||||
|
||||
expect((await reserveToken(token)).status).toBe('processing')
|
||||
})
|
||||
|
||||
it('allows exactly one parallel reservation', async () => {
|
||||
const token = await createToken('contact', { email: 'parallel@example.invalid' })
|
||||
const results = await Promise.all([reserveToken(token), reserveToken(token)])
|
||||
|
||||
expect(results.filter(Boolean)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('cleans expired pending and processing entries', async () => {
|
||||
const pending = await createToken('contact', { email: 'pending@example.invalid' }, -1)
|
||||
const processing = await createToken('contact', { email: 'processing@example.invalid' }, -1)
|
||||
await reserveToken(processing)
|
||||
await cleanupExpiredTokens()
|
||||
|
||||
expect(pending).toBeTruthy()
|
||||
expect(JSON.parse(readFileSync(tokensFile, 'utf8'))).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,18 @@ const BASE_URL = process.env.BASE_URL || 'https://andreknie.de'
|
||||
|
||||
let transporter = null
|
||||
|
||||
export class MailerNotReadyError extends Error {
|
||||
constructor() {
|
||||
super('Mailer ist nicht konfiguriert.')
|
||||
this.name = 'MailerNotReadyError'
|
||||
this.code = 'MAILER_NOT_READY'
|
||||
}
|
||||
}
|
||||
|
||||
export function isMailerReady() {
|
||||
return Boolean(SMTP_HOST && SMTP_USER && SMTP_PASS)
|
||||
}
|
||||
|
||||
function getTransporter() {
|
||||
if (!transporter && SMTP_HOST && SMTP_USER && SMTP_PASS) {
|
||||
transporter = nodemailer.createTransport({
|
||||
@@ -23,10 +35,7 @@ function getTransporter() {
|
||||
|
||||
export async function sendConfirmationEmail(to, type, token) {
|
||||
const t = getTransporter()
|
||||
if (!t) {
|
||||
console.log(`[Mailer] SMTP not configured. Would send ${type} confirmation to ${to}`)
|
||||
return
|
||||
}
|
||||
if (!t) throw new MailerNotReadyError()
|
||||
|
||||
const confirmUrl = `${BASE_URL}/api/${type}/confirm/${token}`
|
||||
const subjects = {
|
||||
@@ -45,10 +54,7 @@ export async function sendConfirmationEmail(to, type, token) {
|
||||
|
||||
export async function sendStakeholderNotification(type, data) {
|
||||
const t = getTransporter()
|
||||
if (!t) {
|
||||
console.log(`[Mailer] SMTP not configured. Would notify stakeholder about ${type}`)
|
||||
return
|
||||
}
|
||||
if (!t) throw new MailerNotReadyError()
|
||||
|
||||
const subjects = {
|
||||
contact: `Neue Kontaktanfrage von ${data.name}`,
|
||||
@@ -80,11 +86,7 @@ export async function sendSpeakerCv(to, lead, pdfPath) {
|
||||
const t = getTransporter()
|
||||
const pdfMissing = !pdfPath || !existsSync(pdfPath)
|
||||
|
||||
if (!t) {
|
||||
console.log(`[Mailer] SMTP not configured. Would send speaker CV to ${to} (pdfMissing=${pdfMissing})`)
|
||||
await notifyStakeholderSpeakerCv(lead, pdfMissing)
|
||||
return { sent: false, pdfMissing }
|
||||
}
|
||||
if (!t) throw new MailerNotReadyError()
|
||||
|
||||
if (!pdfMissing) {
|
||||
await t.sendMail({
|
||||
@@ -112,10 +114,7 @@ async function notifyStakeholderSpeakerCv(lead, pdfMissing) {
|
||||
: 'CV wurde automatisch an den Anfragenden gesendet.',
|
||||
].join('\n')
|
||||
|
||||
if (!t) {
|
||||
console.log(`[Mailer] SMTP not configured. Would notify stakeholder about speaker-cv lead:\n${body}`)
|
||||
return
|
||||
}
|
||||
if (!t) throw new MailerNotReadyError()
|
||||
|
||||
await t.sendMail({
|
||||
from: SMTP_USER,
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isMailerReady, MailerNotReadyError, sendConfirmationEmail } from './mailer.js'
|
||||
|
||||
describe('mailer readiness', () => {
|
||||
it('reports missing SMTP configuration without exposing credentials', async () => {
|
||||
expect(isMailerReady()).toBe(false)
|
||||
await expect(sendConfirmationEmail('test@example.invalid', 'contact', 'synthetic-token'))
|
||||
.rejects.toBeInstanceOf(MailerNotReadyError)
|
||||
await expect(sendConfirmationEmail('test@example.invalid', 'contact', 'synthetic-token'))
|
||||
.rejects.toMatchObject({ code: 'MAILER_NOT_READY' })
|
||||
})
|
||||
})
|
||||
@@ -14,32 +14,29 @@ export default function Datenschutz() {
|
||||
E-Mail: <a href="mailto:kontakt@d-hive.de">kontakt@d-hive.de</a>
|
||||
</p>
|
||||
|
||||
<p><strong style={{ color: 'var(--text-primary)' }}>2. Erhebung und Verarbeitung personenbezogener Daten</strong></p>
|
||||
<p>Die Nutzung dieser Webseite ist in der Regel ohne Angabe personenbezogener Daten möglich. Personenbezogene Daten werden nur erhoben, wenn Sie diese aktiv über Formulare (Kontakt, Vortragsanfrage, Newsletter, Resource-Download) übermitteln.</p>
|
||||
<p><strong style={{ color: 'var(--text-primary)' }}>2. Erhebung und Zweck</strong></p>
|
||||
<p>Diese Website kann grundsätzlich ohne Angabe personenbezogener Daten genutzt werden. Daten werden erhoben, wenn Sie ein Formular für eine Kontaktaufnahme, Vortragsanfrage, Newsletter-Anmeldung, einen Ressourcen-Download oder die Speaker-CV-Anfrage absenden.</p>
|
||||
<p>Die Daten werden ausschließlich zur Bearbeitung der jeweiligen Anfrage, zur Zustellung angeforderter Inhalte oder zur Verwaltung des Newsletter-Abonnements verwendet. Eine Weitergabe erfolgt nur, soweit sie für diesen Zweck erforderlich ist oder Sie zugestimmt haben.</p>
|
||||
|
||||
<p><strong style={{ color: 'var(--text-primary)' }}>3. Zweck der Datenverarbeitung</strong></p>
|
||||
<p>Ihre Daten werden ausschließlich zur Bearbeitung Ihrer Anfrage bzw. zur Zustellung des Newsletters verwendet. Eine Weitergabe an Dritte ohne Ihre ausdrückliche Zustimmung erfolgt nicht.</p>
|
||||
<p><strong style={{ color: 'var(--text-primary)' }}>3. Kontakt- und Vortragsanfragen</strong></p>
|
||||
<p>Name, E-Mail-Adresse und Nachricht werden bis zur Bestätigung in einer temporären Token-Datei gespeichert. Der Bestätigungslink ist 24 Stunden (Kontakt) beziehungsweise 48 Stunden (Vortragsanfrage) gültig. Nicht bestätigte, abgelaufene und erfolgreich verarbeitete Anfragen werden einschließlich ihrer Formulardaten gelöscht. Bei einem technischen Fehler bleibt die Anfrage vorübergehend für einen kontrollierten erneuten Verarbeitungsversuch erhalten.</p>
|
||||
|
||||
<p><strong style={{ color: 'var(--text-primary)' }}>4. Rechtsgrundlage</strong></p>
|
||||
<p>Die Verarbeitung erfolgt auf Grundlage Ihrer Einwilligung (Art. 6 Abs. 1 lit. a DSGVO) bzw. zur Vertragsanbahnung (Art. 6 Abs. 1 lit. b DSGVO).</p>
|
||||
<p><strong style={{ color: 'var(--text-primary)' }}>4. Newsletter (Double-Opt-In)</strong></p>
|
||||
<p>Für die Newsletter-Anmeldung wird Ihre E-Mail-Adresse bis zur Bestätigung temporär gespeichert. Nach erfolgreicher Bestätigung wird sie als aktives Abonnement gespeichert, bis Sie sich abmelden. Eine Abmeldung können Sie jederzeit per E-Mail an <a href="mailto:kontakt@d-hive.de">kontakt@d-hive.de</a> mit dem Hinweis „Newsletter abbestellen“ veranlassen.</p>
|
||||
|
||||
<p><strong style={{ color: 'var(--text-primary)' }}>5. Newsletter (Double Opt-In)</strong></p>
|
||||
<p>Bei Anmeldung zum Newsletter wird Ihre E-Mail-Adresse gespeichert. Die Anmeldung erfolgt im Double-Opt-In-Verfahren: Sie erhalten eine Bestätigungs-E-Mail und müssen die Anmeldung aktiv bestätigen. Sie können das Abonnement jederzeit widerrufen.</p>
|
||||
<p><strong style={{ color: 'var(--text-primary)' }}>5. Ressourcen-Downloads</strong></p>
|
||||
<p>Für einen Ressourcen-Download werden Name, E-Mail-Adresse, Unternehmen, angeforderte Ressource und Zeitpunkt gespeichert. Die Daten dienen der Bearbeitung der Download-Anfrage und der Beziehungspflege. Sie werden gelöscht, sobald der Zweck entfällt oder Sie die Löschung unter <a href="mailto:datenschutz@d-hive.de">datenschutz@d-hive.de</a> anfordern.</p>
|
||||
|
||||
<p><strong style={{ color: 'var(--text-primary)' }}>6. Kontaktformulare</strong></p>
|
||||
<p>Bei Nutzung der Kontakt- oder Vortragsanfrage-Formulare werden die übermittelten Daten (Name, E-Mail, Nachricht) zur Bearbeitung der Anfrage gespeichert. Eine Bestätigungs-E-Mail wird zur Verifizierung versendet. Nicht bestätigte Anfragen werden nach 48 Stunden gelöscht.</p>
|
||||
<p><strong style={{ color: 'var(--text-primary)' }}>6. Speaker-CV-Anfrage</strong></p>
|
||||
<p>Für die Speaker-CV-Anfrage werden Name, E-Mail-Adresse, Nachricht und Zeitpunkt gespeichert. Die Daten werden nach erfolgreichem Versand des CV zur Bearbeitung und Beziehungspflege gespeichert und gelöscht, sobald der Zweck entfällt oder Sie die Löschung unter <a href="mailto:datenschutz@d-hive.de">datenschutz@d-hive.de</a> anfordern.</p>
|
||||
|
||||
<p><strong style={{ color: 'var(--text-primary)' }}>7. Resource-Downloads & Speaker CV</strong></p>
|
||||
<p>Für den Download bestimmter Ressourcen sowie für die Anforderung des Speaker CV werden Name, E-Mail und Ihre Nachricht erhoben. Diese Daten werden zur Bearbeitung Ihrer Anfrage, zur Zusendung der angeforderten Unterlagen und zur Beziehungspflege verwendet.</p>
|
||||
<p><strong style={{ color: 'var(--text-primary)' }}>7. Rechtsgrundlage und Ihre Rechte</strong></p>
|
||||
<p>Die Verarbeitung erfolgt je nach Vorgang auf Grundlage Ihrer Einwilligung oder zur Bearbeitung einer von Ihnen angefragten Leistung. Sie haben das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit. Wenden Sie sich dafür an <a href="mailto:datenschutz@d-hive.de">datenschutz@d-hive.de</a>.</p>
|
||||
|
||||
<p><strong style={{ color: 'var(--text-primary)' }}>8. Hosting</strong></p>
|
||||
<p>Diese Webseite wird auf Servern in der EU gehostet. Es erfolgt keine Datenübertragung in Drittländer.</p>
|
||||
<p><strong style={{ color: 'var(--text-primary)' }}>8. Hosting und Webanalyse</strong></p>
|
||||
<p>Diese Webseite wird auf Servern in der EU gehostet. Es werden keine Analyse-Cookies gesetzt. Zur statistischen Auswertung wird die selbstgehostete Open-Source-Software Umami unter <strong>stats.andreknie.de</strong> eingesetzt. Der Tracker ist auf andreknie.de und www.andreknie.de begrenzt, respektiert die Do-Not-Track-Einstellung und arbeitet ohne Cookies; die IP-Adresse wird anonymisiert.</p>
|
||||
|
||||
<p><strong style={{ color: 'var(--text-primary)' }}>9. Ihre Rechte</strong></p>
|
||||
<p>Sie haben das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit. Wenden Sie sich an: <a href="mailto:datenschutz@d-hive.de">datenschutz@d-hive.de</a></p>
|
||||
|
||||
<p><strong style={{ color: 'var(--text-primary)' }}>10. Cookies und Webanalyse</strong></p>
|
||||
<p>Diese Webseite verwendet <strong>keine</strong> Cookies zur Analyse Ihres Nutzerverhaltens. Um die Nutzung unserer Website statistisch auszuwerten und das Angebot zu verbessern, setzen wir die selbstgehostete Open-Source-Software <strong>Umami</strong> unter <strong>stats.andreknie.de</strong> ein. Der Tracker ist auf andreknie.de und www.andreknie.de begrenzt, respektiert die Do-Not-Track-Einstellung Ihres Browsers und übermittelt keine Suchparameter oder URL-Hash-Werte. Umami arbeitet ohne Cookies; Ihre IP-Adresse wird anonymisiert. Ein Rückschluss auf Ihre Person ist nicht möglich.</p>
|
||||
<p style={{ marginTop: '32px', fontSize: '0.9rem' }}>Die konkreten Aufbewahrungs- und Löschfristen sollten vor dem Produktivbetrieb fachlich und rechtlich geprüft werden.</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user