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

32
PLAYSTORE_REVIEW.md Normal file
View File

@@ -0,0 +1,32 @@
# Google Play: Zugang für die App-Prüfung
In der Play Console unter **Richtlinien und Programme → App-Inhalte → App-Zugriff / Anmeldedaten** angeben, dass einige Funktionen eine Anmeldung erfordern. Eine neue Anleitung mit Titel `Harheimer TC review account` anlegen.
## Konto auf dem Backend der eingereichten App einrichten
Nach Deployment dieser Änderungen im Projektverzeichnis des entsprechenden Servers ausführen:
```bash
node scripts/setup-playstore-review.js
```
Das Skript liest die dortige `.env` einschließlich `ENCRYPTION_KEY`, sichert die bisherige Benutzerdatei und erstellt ein aktives, ausgeblendetes Mitgliedskonto. Standardadresse: `playstore-review@harheimertc.invalid`. Dies ist bewusst kein echtes Postfach. Das zufällig erzeugte Passwort wird einmal ausgegeben und muss sicher abgelegt werden. Bei erneutem Aufruf bleibt es unverändert. Optional können `PLAYSTORE_REVIEW_EMAIL` und `PLAYSTORE_REVIEW_PASSWORD` gesetzt werden; ein explizites Passwort aktualisiert das bestehende Review-Konto. Kein reguläres Konto wird umgewandelt.
In der Play Console die ausgegebene E-Mail-Adresse und das Passwort eintragen. Zusätzliche Anweisungen auf Englisch:
> Open the app and go to Login. Sign in with the email address and password provided above. This account is already activated and provides access to the member features. No registration, email verification, one-time code or approval is required. Please stay signed in while testing forms. Outgoing emails triggered by this account are suppressed by the server. When a form asks for an email address, use the review account email provided above, including when testing while signed out.
Die Zugangsdaten dauerhaft gültig halten. Das Konto ist ein Mitglied, kein CMS-Administrator.
## Versandsperre
Alle SMTP-Versandstellen verwenden dieselbe serverseitige Sperre:
- Für authentifizierte Konten mit `accountType: playstore_review` oder der konfigurierten `PLAYSTORE_REVIEW_EMAIL` wird kein SMTP-Versand ausgeführt, auch bei abweichender Formularadresse.
- Mails an die Review-Adresse sowie Kontaktmails mit dieser Antwortadresse werden auch ohne Anmeldung unterdrückt. Registrierung und Mitgliedsanträge prüfen zusätzlich die Formularadresse. Gemischte Empfängerlisten mit einem Review-Konto werden vollständig unterdrückt.
- `MAIL_DELIVERY_DISABLED=true` unterdrückt sämtliche SMTP-Mails einer Installation. Die PM2-Testkonfiguration setzt dies standardmäßig; Produktion bleibt ohne diese Einstellung aktiv. Nach Konfigurationsänderungen den Backend-Prozess mit aktualisierter Umgebung neu starten.
- `DEBUG=true` allein verhindert keinen Mailversand.
Die Versandsperre ist keine isolierte Demo-Datenbank: Formulare können weiterhin Daten speichern und Push-Benachrichtigungen auslösen. Ohne Anmeldung und mit einer fremden Formularadresse lässt sich eine Anfrage keinem Review-Konto zuordnen. Für vollständig mailfreie Tests daher eine separate Testinstanz mit `MAIL_DELIVERY_DISABLED=true` verwenden und sicherstellen, dass der eingereichte App-Build dieses Backend nutzt.
Google-Anforderungen: https://support.google.com/googleplay/android-developer/answer/15748846

View File

@@ -80,8 +80,8 @@ private fun CompactNavigation(
navigationState: NavigationUiState = NavigationUiState(),
) {
val routeSection = menuSection(selectedRoute)
val sectionOverride = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf<MenuSection?>(null) }
val section = routeSection ?: sectionOverride.value
val sectionOverride = androidx.compose.runtime.remember(selectedRoute) { androidx.compose.runtime.mutableStateOf<MenuSection?>(null) }
val section = sectionOverride.value ?: routeSection
val subItems = submenu(section, navigationState)
val mainScroll = rememberScrollState()

View File

@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -45,13 +46,13 @@ fun NavGraph(
LaunchedEffect(currentRoute) {
navigationViewModel.refreshSession()
}
LaunchedEffect(currentRoute, navigationState.loggedIn) {
navigationViewModel.setLiveTickerActive(
navigationState.loggedIn && (currentRoute == "intern" || currentRoute?.startsWith("intern/") == true || currentRoute == "cms" || currentRoute?.startsWith("cms/") == true),
)
LaunchedEffect(navigationState.loggedIn) {
navigationViewModel.setLiveTickerActive(navigationState.loggedIn)
}
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val persistentNavigation = maxWidth >= 600.dp
BoxWithConstraints(modifier = Modifier.fillMaxSize().systemBarsPadding()) {
val tabletNavigation = maxWidth >= 600.dp
// Signed-in phones share the same menu and ticker above every screen.
val persistentNavigation = tabletNavigation || navigationState.loggedIn
Column(modifier = Modifier.fillMaxSize()) {
navigationState.connectionNote?.let { message ->
Surface(color = Color(0xFFFFF4E5), modifier = Modifier.fillMaxWidth()) {
@@ -74,14 +75,16 @@ fun NavGraph(
}
}
},
webTabletNavigation = true,
webTabletNavigation = tabletNavigation,
navigationState = navigationState,
)
}
LiveTickerBanner(
matches = navigationState.liveTickerMatches,
loaded = navigationState.liveTickerLoaded,
)
if (navigationState.loggedIn) {
LiveTickerBanner(
matches = navigationState.liveTickerMatches,
loaded = navigationState.liveTickerLoaded,
)
}
NavHost(
navController = navController,
startDestination = startDestination,

View File

@@ -8,8 +8,8 @@ LOCAL_API_BASE_URL=https://harheimertc.tsschulz.de/
PRODUCTION_API_BASE_URL=https://harheimertc.de/
# Android app versioning for Play Store uploads
ANDROID_VERSION_CODE=39
ANDROID_VERSION_NAME=0.11.1
ANDROID_VERSION_CODE=40
ANDROID_VERSION_NAME=0.11.2
# Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping.
RELEASE_MINIFY_ENABLED=false

View File

@@ -1,8 +1,8 @@
<template>
<div class="h-screen flex flex-col overflow-hidden">
<div class="h-screen flex flex-col overflow-hidden pt-20">
<Navigation />
<LiveTickerBanner />
<main class="flex-1 overflow-y-auto pt-20 pb-20 sm:pb-0">
<main class="min-h-0 flex-1 overflow-y-auto pb-20 sm:pb-0">
<NuxtPage />
</main>
<Footer />

View File

@@ -1,7 +1,7 @@
<template>
<section
v-if="isInternal && matches.length"
class="fixed top-20 z-40 w-full border-y border-primary-200 bg-primary-50 text-primary-950 shadow-sm"
v-if="authStore.isLoggedIn && matches.length"
class="relative z-40 w-full shrink-0 border-y border-primary-200 bg-primary-50 text-primary-950 shadow-sm"
>
<div class="mx-auto flex max-w-7xl items-center gap-3 px-4 py-2 text-sm sm:px-6">
<button
@@ -60,18 +60,15 @@
</template>
<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { useAuthStore } from '~/stores/auth'
const route = useRoute()
const authStore = useAuthStore()
const matches = ref([])
const soundEnabled = ref(false)
const paused = ref(false)
const tickerTrack = ref(null)
const tickerDuration = ref(30)
const isInternal = computed(() => authStore.isLoggedIn && (route.path.startsWith('/mitgliederbereich') || route.path.startsWith('/cms')))
let timer
let audioContext
let tickerLoaded = false
@@ -127,7 +124,7 @@ function toggleSound() {
}
async function load() {
if (!isInternal.value) {
if (!authStore.isLoggedIn) {
matches.value = []
tickerLoaded = false
previousStates = new Map()
@@ -135,6 +132,7 @@ async function load() {
}
try {
const nextMatches = (await $fetch('/api/mitgliederbereich/live-ticker')).matches || []
if (!authStore.isLoggedIn) return
const nextStates = stateByMeeting(nextMatches)
if (tickerLoaded && soundEnabled.value && hasRelevantChange(nextStates)) playPing()
previousStates = nextStates
@@ -148,7 +146,7 @@ onMounted(() => {
timer = setInterval(load, 30_000)
})
onUnmounted(() => clearInterval(timer))
watch(isInternal, load)
watch(() => authStore.isLoggedIn, load)
</script>
<style scoped>

View File

@@ -14,6 +14,11 @@ SMTP_PORT=587
SMTP_USER=tsschulz@tsschulz.de
SMTP_PASS=your_app_password_here
# Unterdrückt ALLE SMTP-Mails dieser Installation (für lokale Tests empfohlen).
MAIL_DELIVERY_DISABLED=true
# Optional: Review-Adresse; Einrichtung siehe PLAYSTORE_REVIEW.md
PLAYSTORE_REVIEW_EMAIL=playstore-review@harheimertc.invalid
# Alternative für GMX:
# SMTP_HOST=mail.gmx.net
# SMTP_PORT=587

View File

@@ -16,6 +16,9 @@ function createEnv(port) {
// Secrets/Config (loaded from .env above, if present)
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,
JWT_SECRET: process.env.JWT_SECRET,
MAIL_DELIVERY_DISABLED: process.env.MAIL_DELIVERY_DISABLED,
PLAYSTORE_REVIEW_EMAIL: process.env.PLAYSTORE_REVIEW_EMAIL,
HIDDEN_USER_EMAILS: process.env.HIDDEN_USER_EMAILS,
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: process.env.SMTP_PORT,
SMTP_USER: process.env.SMTP_USER,

View File

@@ -18,6 +18,9 @@ function createEnv(port) {
// Secrets/Config (loaded from .env above, if present)
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,
JWT_SECRET: process.env.JWT_SECRET,
MAIL_DELIVERY_DISABLED: process.env.MAIL_DELIVERY_DISABLED || 'true',
PLAYSTORE_REVIEW_EMAIL: process.env.PLAYSTORE_REVIEW_EMAIL,
HIDDEN_USER_EMAILS: process.env.HIDDEN_USER_EMAILS,
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: process.env.SMTP_PORT,
SMTP_USER: process.env.SMTP_USER,

View File

@@ -1,6 +1,6 @@
{
"name": "harheimertc-website",
"version": "1.9.1",
"version": "1.9.2",
"description": "Moderne Webseite für den Harheimer Tischtennis Club",
"private": true,
"type": "module",

View File

@@ -0,0 +1,42 @@
#!/usr/bin/env node
import 'dotenv/config'
import { promises as fs } from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
import { decryptObject } from '../server/utils/encryption.js'
import { hashPassword, writeUsers } from '../server/utils/auth.js'
// Run in the target installation's root, with its encryption key loaded.
const email = String(process.env.PLAYSTORE_REVIEW_EMAIL || 'playstore-review@harheimertc.invalid').trim().toLowerCase()
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw new Error('Ungültige PLAYSTORE_REVIEW_EMAIL')
if (!process.env.ENCRYPTION_KEY) throw new Error('ENCRYPTION_KEY der Zielinstallation fehlt')
const root = process.cwd().endsWith('.output') ? path.resolve('..') : process.cwd()
const file = path.join(root, 'server/data/users.json')
// Fail closed: never replace an unreadable user database with an empty list.
const raw = await fs.readFile(file, 'utf8')
let users
try { users = JSON.parse(raw) } catch { users = decryptObject(raw, process.env.ENCRYPTION_KEY) }
if (!Array.isArray(users)) throw new Error('Ungültiges Format der Benutzerdaten')
let user = users.find(candidate => String(candidate.email || '').trim().toLowerCase() === email)
if (user && user.accountType !== 'playstore_review') throw new Error('E-Mail wird bereits von einem regulären Konto verwendet')
if (user && !process.env.PLAYSTORE_REVIEW_PASSWORD) {
console.log(`Review-Konto existiert bereits: ${email}. Passwort bleibt unverändert.`)
} else {
const password = process.env.PLAYSTORE_REVIEW_PASSWORD || crypto.randomBytes(24).toString('base64url')
if (password.length < 16) throw new Error('Review-Passwort muss mindestens 16 Zeichen haben')
if (!user) {
user = { id: crypto.randomUUID(), email, created: new Date().toISOString() }
users.push(user)
}
Object.assign(user, {
name: 'Google Play Review', password: await hashPassword(password),
roles: ['mitglied'], active: true, hidden: true, accountType: 'playstore_review',
visibility: { showBirthday: false }
})
delete user.role
await fs.copyFile(file, `${file}.before-playstore-${Date.now()}`)
if (!await writeUsers(users)) throw new Error('Review-Konto konnte nicht gespeichert werden')
console.log(`E-Mail: ${email}`)
if (!process.env.PLAYSTORE_REVIEW_PASSWORD) console.log(`Passwort (sicher ablegen): ${password}`)
else console.log('Passwort: aus PLAYSTORE_REVIEW_PASSWORD gesetzt')
}

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)
}
}
}

View File

@@ -5,6 +5,7 @@ import crypto from 'crypto'
vi.mock('../server/utils/auth.js', () => {
return {
getTokenFromEvent: vi.fn(() => undefined),
readUsers: vi.fn(),
writeUsers: vi.fn(),
verifyPassword: vi.fn(),

View File

@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createEvent, mockSuccessReadBody } from './setup'
vi.mock('../server/utils/auth.js', () => ({
getTokenFromEvent: vi.fn(() => undefined),
getUserFromToken: vi.fn(),
readUsers: vi.fn(),
writeUsers: vi.fn(),

View File

@@ -0,0 +1,69 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createEvent } from './setup'
vi.mock('nodemailer', () => ({
default: { createTransport: vi.fn(() => ({ sendMail: vi.fn().mockResolvedValue({ messageId: 'real' }) })) }
}))
vi.mock('../server/utils/auth.js', () => ({
readUsers: vi.fn().mockResolvedValue([]),
getTokenFromEvent: vi.fn(),
getUserFromToken: vi.fn()
}))
import nodemailer from 'nodemailer'
import * as auth from '../server/utils/auth.js'
import { createMailTransport } from '../server/utils/mail-transport.js'
import { sendMembershipEmail, sendRegistrationNotification } from '../server/utils/email-service.js'
describe('Mail delivery gate', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubEnv('MAIL_DELIVERY_DISABLED', '')
vi.stubEnv('PLAYSTORE_REVIEW_EMAIL', '')
auth.readUsers.mockResolvedValue([])
auth.getTokenFromEvent.mockReturnValue(undefined)
})
afterEach(() => vi.unstubAllEnvs())
it('does not create an SMTP transport when all delivery is disabled', async () => {
vi.stubEnv('MAIL_DELIVERY_DISABLED', 'TRUE')
expect(await createMailTransport(createEvent(), {}).sendMail({ to: 'board@example.com' }))
.toMatchObject({ suppressed: true, accepted: [] })
expect(nodemailer.createTransport).not.toHaveBeenCalled()
})
it('suppresses board mail for an authenticated review user with another form address', async () => {
auth.getTokenFromEvent.mockReturnValue('verified-by-auth')
auth.getUserFromToken.mockResolvedValue({ accountType: 'playstore_review' })
await createMailTransport(createEvent(), {}).sendMail({ to: 'board@example.com', replyTo: 'other@example.com' })
expect(auth.getUserFromToken).toHaveBeenCalledWith('verified-by-auth')
expect(nodemailer.createTransport).not.toHaveBeenCalled()
})
it('suppresses signed-out contact mail using the configured review reply address', async () => {
vi.stubEnv('PLAYSTORE_REVIEW_EMAIL', 'review@example.com')
await createMailTransport(undefined, {}).sendMail({ to: 'board@example.com', replyTo: 'Review <REVIEW@example.com>' })
expect(nodemailer.createTransport).not.toHaveBeenCalled()
})
it('checks stored review accounts in cc and bcc as well', async () => {
auth.readUsers.mockResolvedValue([{ email: 'review@example.com', accountType: 'playstore_review' }])
await createMailTransport(undefined, {}).sendMail({ to: 'member@example.com', bcc: [{ address: 'review@example.com' }] })
expect(nodemailer.createTransport).not.toHaveBeenCalled()
})
it('suppresses membership and registration notifications before SMTP/config loading', async () => {
vi.stubEnv('PLAYSTORE_REVIEW_EMAIL', 'review@example.com')
expect(await sendMembershipEmail({ email: 'review@example.com' }, '/unused.pdf')).toMatchObject({ success: true, suppressed: true })
expect(await sendRegistrationNotification({ email: 'review@example.com' })).toMatchObject({ success: true, suppressed: true })
expect(nodemailer.createTransport).not.toHaveBeenCalled()
})
it('delivers ordinary member mail and does not trust a client demo flag', async () => {
const event = createEvent({ body: { demo: true, accountType: 'playstore_review' } })
const mail = { to: 'member@example.com', text: 'Hello' }
expect(await createMailTransport(event, { host: 'smtp.example.com' }).sendMail(mail)).toEqual({ messageId: 'real' })
expect(nodemailer.createTransport).toHaveBeenCalledWith({ host: 'smtp.example.com' })
expect(nodemailer.createTransport.mock.results[0].value.sendMail).toHaveBeenCalledWith(mail)
})
})

View File

@@ -17,6 +17,7 @@ vi.mock('../server/utils/news.js', () => ({
}))
vi.mock('../server/utils/auth.js', () => ({
getTokenFromEvent: vi.fn(() => undefined),
readUsers: vi.fn(),
migrateUserRoles: vi.fn((user) => {
if (!user) return user