fix: show live ticker across app and website and suppress review emails
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 5m18s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped

This commit is contained in:
Torsten Schulz (local)
2026-10-29 21:49:51 +01:00
parent 10a37180de
commit 472bec4519
29 changed files with 280 additions and 70 deletions

View File

@@ -1,4 +1,4 @@
import nodemailer from 'nodemailer'
import { createMailTransport } from '../../../../utils/mail-transport.js'
import { readUsers, writeUsers } from '../../../../utils/auth.js'
import { assertRateLimit, getClientIp, registerRateLimitFailure, registerRateLimitSuccess } from '../../../../utils/rate-limit.js'
import { generateRecoveryToken, hashRecoveryToken, pruneRecoveryTokens } from '../../../../utils/passkey-recovery.js'
@@ -70,7 +70,7 @@ export default defineEventHandler(async (event) => {
const smtpPass = process.env.SMTP_PASS
if (smtpUser && smtpPass) {
const transporter = nodemailer.createTransport({
const transporter = createMailTransport(event, {
host: process.env.SMTP_HOST || 'smtp.gmail.com',
port: process.env.SMTP_PORT || 587,
secure: false,

View File

@@ -262,7 +262,7 @@ export default defineEventHandler(async (event) => {
// Send notification emails through the same central recipient logic as password registration.
try {
await sendRegistrationNotification({ name, email, phone })
await sendRegistrationNotification({ name, email, phone }, event)
} catch (emailError) {
console.error('E-Mail-Versand fehlgeschlagen:', emailError)
}

View File

@@ -66,7 +66,7 @@ export default defineEventHandler(async (event) => {
// Send notification to Vorstand/admin via central email service
try {
await sendRegistrationNotification({ name, email, phone })
await sendRegistrationNotification({ name, email, phone }, event)
} catch (emailError) {
console.error('Registrierungs-Benachrichtigung fehlgeschlagen:', emailError)
}

View File

@@ -1,5 +1,5 @@
import { readUsers, writeUsers } from '../../utils/auth.js'
import nodemailer from 'nodemailer'
import { createMailTransport } from '../../utils/mail-transport.js'
import crypto from 'crypto'
import { assertRateLimit, getClientIp, registerRateLimitFailure, registerRateLimitSuccess } from '../../utils/rate-limit.js'
import { writeAuditLog } from '../../utils/audit-log.js'
@@ -152,7 +152,7 @@ export default defineEventHandler(async (event) => {
}
await logStep('token_storage', 'completed', { userId: user.id })
const transporter = nodemailer.createTransport({
const transporter = createMailTransport(event, {
host: process.env.SMTP_HOST || 'smtp.gmail.com',
port: process.env.SMTP_PORT || 587,
secure: process.env.SMTP_SECURE === 'true',

View File

@@ -1,13 +1,13 @@
import nodemailer from 'nodemailer'
import { createMailTransport } from '../../../../utils/mail-transport.js'
import { getUserFromToken, hasAnyRole } from '../../../../utils/auth.js'
import { addContactReply, readContactRequests } from '../../../../utils/contact-requests.js'
function createTransporter() {
function createTransporter(event) {
const smtpUser = process.env.SMTP_USER
const smtpPass = process.env.SMTP_PASS || process.env.EMAIL_PASSWORD
if (!smtpUser || !smtpPass) return null
return nodemailer.createTransport({
return createMailTransport(event, {
host: process.env.SMTP_HOST || 'smtp.gmail.com',
port: Number(process.env.SMTP_PORT || 587),
secure: process.env.SMTP_SECURE === 'true',
@@ -43,7 +43,7 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 404, statusMessage: 'Anfrage nicht gefunden' })
}
const transporter = createTransporter()
const transporter = createTransporter(event)
if (!transporter) {
throw createError({
statusCode: 500,

View File

@@ -1,5 +1,5 @@
import { getUserFromToken, readUsers, writeUsers, hasAnyRole, migrateUserRoles } from '../../../utils/auth.js'
import nodemailer from 'nodemailer'
import { createMailTransport } from '../../../utils/mail-transport.js'
import { writeAuditLog } from '../../../utils/audit-log.js'
export default defineEventHandler(async (event) => {
@@ -58,7 +58,7 @@ export default defineEventHandler(async (event) => {
console.warn(`SMTP_USER=${smtpUser ? 'gesetzt' : 'FEHLT'}, SMTP_PASS=${smtpPass ? 'gesetzt' : 'FEHLT'}`)
// Continue without sending email
} else {
const transporter = nodemailer.createTransport({
const transporter = createMailTransport(event, {
host: process.env.SMTP_HOST || 'smtp.gmail.com',
port: process.env.SMTP_PORT || 587,
secure: false,

View File

@@ -1,4 +1,4 @@
import nodemailer from 'nodemailer'
import { createMailTransport } from '../utils/mail-transport.js'
import { promises as fs } from 'fs'
import { createContactRequest } from '../utils/contact-requests.js'
import { readUsers, migrateUserRoles, isHiddenUser } from '../utils/auth.js'
@@ -97,12 +97,12 @@ async function collectRecipients(config) {
throw new Error('Keine E-Mail-Empfänger in config.json konfiguriert.')
}
function createTransporter() {
function createTransporter(event) {
const smtpUser = process.env.SMTP_USER
const smtpPass = process.env.SMTP_PASS || process.env.EMAIL_PASSWORD
if (!smtpUser || !smtpPass) return null
return nodemailer.createTransport({
return createMailTransport(event, {
host: process.env.SMTP_HOST || 'smtp.gmail.com',
port: Number(process.env.SMTP_PORT || 587),
secure: process.env.SMTP_SECURE === 'true',
@@ -144,7 +144,7 @@ export default defineEventHandler(async (event) => {
const config = await loadConfig()
const recipients = await collectRecipients(config)
const transporter = createTransporter()
const transporter = createTransporter(event)
if (!transporter) {
return {

View File

@@ -626,7 +626,7 @@ export default defineEventHandler(async (event) => {
let emailResult
if (usedTemplate) {
// E-Mail senden via zentralen Service (pass full path)
emailResult = await sendMembershipEmailUtil(data, finalPdfPath)
emailResult = await sendMembershipEmailUtil(data, finalPdfPath, event)
// Antragsdaten verschlüsselt speichern
const encryptedData = JSON.stringify(data)
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
@@ -687,7 +687,7 @@ export default defineEventHandler(async (event) => {
await fs.copyFile(pdfPath, finalPdfPath)
// E-Mail senden via zentralen Service (pass full path)
emailResult = await sendMembershipEmailUtil(data, finalPdfPath)
emailResult = await sendMembershipEmailUtil(data, finalPdfPath, event)
// Antragsdaten verschlüsselt speichern
const encryptedData = JSON.stringify(data)
@@ -720,7 +720,7 @@ export default defineEventHandler(async (event) => {
const fallbackFilename = await generateSimplePDF(data, filename, event)
// E-Mail senden (Fallback)
const emailResult = await sendMembershipEmailUtil(data, path.join(uploadsDir, `${filename}.txt`))
const emailResult = await sendMembershipEmailUtil(data, path.join(uploadsDir, `${filename}.txt`), event)
console.log('LaTeX nicht verfügbar, verwende Fallback-Lösung')
console.log('E-Mail würde gesendet werden an:', emailResult.recipients || [])

View File

@@ -2,7 +2,7 @@ import fs from 'fs/promises'
import path from 'path'
import { getUserFromToken, hasAnyRole } from '../../../utils/auth.js'
import { getRecipientsByGroup, getNewsletterSubscribers, generateUnsubscribeToken } from '../../../utils/newsletter.js'
import nodemailer from 'nodemailer'
import { createMailTransport } from '../../../utils/mail-transport.js'
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
// filename is always a hardcoded constant (e.g., 'newsletter.json'), never user input
@@ -178,7 +178,7 @@ export default defineEventHandler(async (event) => {
})
}
const transporter = nodemailer.createTransport({
const transporter = createMailTransport(event, {
host: process.env.SMTP_HOST || 'smtp.gmail.com',
port: process.env.SMTP_PORT || 587,
secure: false,

View File

@@ -3,7 +3,7 @@ import path from 'path'
import { getUserFromToken, hasAnyRole } from '../../../../../utils/auth.js'
import { randomUUID } from 'crypto'
import { getRecipientsByGroup, getNewsletterSubscribers, generateUnsubscribeToken } from '../../../../../utils/newsletter.js'
import nodemailer from 'nodemailer'
import { createMailTransport } from '../../../../../utils/mail-transport.js'
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
// filename is always a hardcoded constant (e.g., 'newsletter-posts.json'), never user input
@@ -222,7 +222,7 @@ export default defineEventHandler(async (event) => {
})
}
const transporter = nodemailer.createTransport({
const transporter = createMailTransport(event, {
host: process.env.SMTP_HOST || 'smtp.gmail.com',
port: process.env.SMTP_PORT || 587,
secure: false,

View File

@@ -1,7 +1,7 @@
import { getUserFromToken, hasAnyRole } from '../../../../../utils/auth.js'
import { readSubscribers, writeSubscribers } from '../../../../../utils/newsletter.js'
import { randomUUID } from 'crypto'
import nodemailer from 'nodemailer'
import { createMailTransport } from '../../../../../utils/mail-transport.js'
import crypto from 'crypto'
import fs from 'fs/promises'
import path from 'path'
@@ -98,7 +98,7 @@ export default defineEventHandler(async (event) => {
})
} else {
// Bestätigungsmail erneut senden mit individueller Nachricht
await sendConfirmationEmail(existing.email, existing.name || name, existing.confirmationToken, group.name, customMessage, user.name)
await sendConfirmationEmail(event, existing.email, existing.name || name, existing.confirmationToken, group.name, customMessage, user.name)
return {
success: true,
message: 'Eine Bestätigungsmail wurde an die E-Mail-Adresse gesendet'
@@ -124,7 +124,7 @@ export default defineEventHandler(async (event) => {
})
} else {
// Bestätigungsmail erneut senden mit individueller Nachricht
await sendConfirmationEmail(existingEmail.email, existingEmail.name || name, existingEmail.confirmationToken, group.name, customMessage, user.name)
await sendConfirmationEmail(event, existingEmail.email, existingEmail.name || name, existingEmail.confirmationToken, group.name, customMessage, user.name)
return {
success: true,
message: 'Eine Bestätigungsmail wurde an die E-Mail-Adresse gesendet'
@@ -150,7 +150,7 @@ export default defineEventHandler(async (event) => {
}
} else {
// Bestätigungsmail senden mit individueller Nachricht
await sendConfirmationEmail(existingEmail.email, existingEmail.name, existingEmail.confirmationToken, group.name, customMessage, user.name)
await sendConfirmationEmail(event, existingEmail.email, existingEmail.name, existingEmail.confirmationToken, group.name, customMessage, user.name)
return {
success: true,
message: 'Eine Bestätigungsmail wurde an die E-Mail-Adresse gesendet'
@@ -178,7 +178,7 @@ export default defineEventHandler(async (event) => {
await writeSubscribers(subscribers)
// Bestätigungsmail senden mit individueller Nachricht
await sendConfirmationEmail(email, name, confirmationToken, group.name, customMessage, user.name)
await sendConfirmationEmail(event, email, name, confirmationToken, group.name, customMessage, user.name)
return {
success: true,
@@ -196,7 +196,7 @@ export default defineEventHandler(async (event) => {
}
})
async function sendConfirmationEmail(email, name, token, groupName, customMessage = null, inviterName = null) {
async function sendConfirmationEmail(event, email, name, token, groupName, customMessage = null, inviterName = null) {
const smtpUser = process.env.SMTP_USER
const smtpPass = process.env.SMTP_PASS
@@ -208,7 +208,7 @@ async function sendConfirmationEmail(email, name, token, groupName, customMessag
const baseUrl = process.env.NUXT_PUBLIC_BASE_URL || 'http://localhost:3100'
const confirmationUrl = `${baseUrl}/newsletter/confirm?token=${token}`
const transporter = nodemailer.createTransport({
const transporter = createMailTransport(event, {
host: process.env.SMTP_HOST || 'smtp.gmail.com',
port: process.env.SMTP_PORT || 587,
secure: false,

View File

@@ -1,6 +1,6 @@
import { readSubscribers, writeSubscribers } from '../../utils/newsletter.js'
import { randomUUID } from 'crypto'
import nodemailer from 'nodemailer'
import { createMailTransport } from '../../utils/mail-transport.js'
import crypto from 'crypto'
import fs from 'fs/promises'
import path from 'path'
@@ -129,7 +129,7 @@ export default defineEventHandler(async (event) => {
})
} else {
// Bestätigungsmail erneut senden
await sendConfirmationEmail(existing.email, existing.name || name, existing.confirmationToken, group.name)
await sendConfirmationEmail(event, existing.email, existing.name || name, existing.confirmationToken, group.name)
registerRateLimitSuccess(event, { name: 'newsletter:subscribe:email', keyParts: [emailKey] })
return {
success: true,
@@ -158,7 +158,7 @@ export default defineEventHandler(async (event) => {
})
} else {
// Bestätigungsmail erneut senden
await sendConfirmationEmail(existingEmail.email, existingEmail.name || name, existingEmail.confirmationToken, group.name)
await sendConfirmationEmail(event, existingEmail.email, existingEmail.name || name, existingEmail.confirmationToken, group.name)
registerRateLimitSuccess(event, { name: 'newsletter:subscribe:email', keyParts: [emailKey] })
return {
success: true,
@@ -186,7 +186,7 @@ export default defineEventHandler(async (event) => {
}
} else {
// Bestätigungsmail senden
await sendConfirmationEmail(existingEmail.email, existingEmail.name, existingEmail.confirmationToken, group.name)
await sendConfirmationEmail(event, existingEmail.email, existingEmail.name, existingEmail.confirmationToken, group.name)
registerRateLimitSuccess(event, { name: 'newsletter:subscribe:email', keyParts: [emailKey] })
return {
success: true,
@@ -215,7 +215,7 @@ export default defineEventHandler(async (event) => {
await writeSubscribers(subscribers)
// Bestätigungsmail senden
await sendConfirmationEmail(email, name, confirmationToken, group.name)
await sendConfirmationEmail(event, email, name, confirmationToken, group.name)
registerRateLimitSuccess(event, { name: 'newsletter:subscribe:email', keyParts: [emailKey] })
return {
@@ -234,7 +234,7 @@ export default defineEventHandler(async (event) => {
}
})
async function sendConfirmationEmail(email, name, token, groupName) {
async function sendConfirmationEmail(event, email, name, token, groupName) {
const smtpUser = process.env.SMTP_USER
const smtpPass = process.env.SMTP_PASS
@@ -246,7 +246,7 @@ async function sendConfirmationEmail(email, name, token, groupName) {
const baseUrl = process.env.NUXT_PUBLIC_BASE_URL || 'http://localhost:3100'
const confirmationUrl = `${baseUrl}/newsletter/confirm?token=${token}`
const transporter = nodemailer.createTransport({
const transporter = createMailTransport(event, {
host: process.env.SMTP_HOST || 'smtp.gmail.com',
port: process.env.SMTP_PORT || 587,
secure: false,

View File

@@ -3,7 +3,7 @@
* Clean Code: Single Responsibility Principle
*/
import nodemailer from 'nodemailer'
import { createMailTransport, shouldSuppressMail } from './mail-transport.js'
import fs from 'fs/promises'
import path from 'path'
import { getServerDataPath } from './paths.js'
@@ -103,7 +103,7 @@ async function getEmailRecipients(data, config) {
* Creates email transporter
* @returns {Object} Nodemailer transporter
*/
function createTransporter() {
function createTransporter(event) {
const smtpUser = process.env.SMTP_USER
const smtpPass = process.env.SMTP_PASS
@@ -114,7 +114,7 @@ function createTransporter() {
)
}
return nodemailer.createTransport({
return createMailTransport(event, {
host: process.env.SMTP_HOST || 'localhost',
port: parseInt(process.env.SMTP_PORT) || 587,
secure: process.env.SMTP_SECURE === 'true',
@@ -131,13 +131,14 @@ function createTransporter() {
* @param {string} pdfPath - Path to generated PDF
* @returns {Promise<Object>} Email result
*/
export async function sendMembershipEmail(data, pdfPath) {
export async function sendMembershipEmail(data, pdfPath, event) {
try {
if (await shouldSuppressMail(event, [data.email])) return { success: true, suppressed: true, recipients: [], message: 'Testmodus: E-Mail-Versand unterdrückt.' }
const config = await loadConfig()
const recipients = await getEmailRecipients(data, config)
// Create transporter
const transporter = createTransporter()
const transporter = createTransporter(event)
// Email content
const subject = `Neuer Mitgliedschaftsantrag - ${data.vorname} ${data.nachname}`
@@ -187,13 +188,14 @@ Das ausgefüllte Formular ist als Anhang verfügbar.`
* Sends a simple registration notification to Vorstand/admin and a confirmation to user.
* @param {Object} data - { name, email, phone }
*/
export async function sendRegistrationNotification(data) {
export async function sendRegistrationNotification(data, event) {
try {
if (await shouldSuppressMail(event, [data.email])) return { success: true, suppressed: true, recipients: [], message: 'Testmodus: E-Mail-Versand unterdrückt.' }
const config = await loadConfig()
const recipients = await getEmailRecipients(data, config)
// Create transporter
const transporter = createTransporter()
const transporter = createTransporter(event)
// Notify Vorstand/admin
const adminSubject = 'Neue Registrierung - Harheimer TC'

View File

@@ -0,0 +1,50 @@
import nodemailer from 'nodemailer'
import * as auth from './auth.js'
const normalize = value => String(value || '').trim().toLowerCase()
const enabled = value => ['1', 'true', 'yes', 'on'].includes(normalize(value))
export function isReviewUser(user) {
if (!user) return false
const email = normalize(user.email)
return normalize(user.accountType) === 'playstore_review' ||
Boolean(email && email === normalize(process.env.PLAYSTORE_REVIEW_EMAIL))
}
function addresses(values) {
return values.flat(Infinity).filter(Boolean).flatMap(value => {
if (typeof value === 'object') return [normalize(value.address)]
return String(value).split(',').map(part => normalize(part.match(/<([^>]+)>/)?.[1] || part))
})
}
export async function shouldSuppressMail(event, emails = []) {
if (enabled(process.env.MAIL_DELIVERY_DISABLED)) return true
// Use the authenticated account, never a client-provided demo flag.
if (event) {
const token = auth.getTokenFromEvent(event)
if (token && isReviewUser(await auth.getUserFromToken(token))) return true
}
const targets = addresses(emails)
if (!targets.length) return false
if (targets.includes(normalize(process.env.PLAYSTORE_REVIEW_EMAIL))) return true
const users = await auth.readUsers()
return users.some(user => isReviewUser(user) && targets.includes(normalize(user.email)))
}
// All SMTP entry points go through this gate, including newsletter batches.
// Create the real transport lazily so suppressed mail cannot open a connection.
export function createMailTransport(event, options) {
let transport
return {
async sendMail(mail) {
if (await shouldSuppressMail(event, [mail.to, mail.cc, mail.bcc, mail.replyTo])) {
return { suppressed: true, messageId: null, accepted: [], rejected: [] }
}
transport ||= nodemailer.createTransport(options)
return transport.sendMail(mail)
}
}
}