feat(mail-service): initialize Mail-Service and integrate online nomination workflow

- Created `MailServiceApplication` with Spring Boot setup.
- Added `MailPollingService` for IMAP polling, `TurnierNr` extraction, and auto-reply functionality.
- Implemented structured email sending for online nominations via `OnlineNennungFormular`.
- Updated frontend with `Erfolgsscreen` for nomination confirmation and fallback handling.
- Added build configurations for Mail-Service and frontend nomination module.
- Documented phase-based roadmap for Online-Nennung and Mail-Service rollout.
This commit is contained in:
2026-04-14 14:59:11 +02:00
parent 5f87eed86a
commit adfa97978e
9 changed files with 497 additions and 5 deletions
@@ -0,0 +1,36 @@
plugins {
alias(libs.plugins.kotlinJvm)
alias(libs.plugins.spring.boot)
alias(libs.plugins.spring.dependencyManagement)
alias(libs.plugins.kotlinSpring)
}
springBoot {
mainClass.set("at.mocode.mail.service.MailServiceApplicationKt")
}
dependencies {
// Interne Module
implementation(projects.platform.platformDependencies)
implementation(projects.core.coreUtils)
implementation(projects.core.coreDomain)
// Spring Boot Starters
implementation(libs.spring.boot.starter.web)
implementation(libs.spring.boot.starter.validation)
implementation(libs.spring.boot.starter.actuator)
implementation(libs.spring.boot.starter.mail)
implementation(libs.jackson.module.kotlin)
implementation(libs.spring.cloud.starter.consul.discovery)
implementation(libs.micrometer.tracing.bridge.brave)
implementation(libs.zipkin.reporter.brave)
implementation(libs.zipkin.sender.okhttp3)
// Testing
testImplementation(projects.platform.platformTesting)
testImplementation(libs.spring.boot.starter.test)
}
tasks.test {
useJUnitPlatform()
}
@@ -0,0 +1,114 @@
package at.mocode.mail.service
import jakarta.mail.Flags
import jakarta.mail.Folder
import jakarta.mail.Session
import jakarta.mail.internet.InternetAddress
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.mail.SimpleMailMessage
import org.springframework.mail.javamail.JavaMailSender
import org.springframework.scheduling.annotation.EnableScheduling
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
import java.util.*
@Service
@EnableScheduling
class MailPollingService(
private val mailSender: JavaMailSender,
@Value("\${spring.mail.host}") private val imapHost: String,
@Value("\${spring.mail.port}") private val imapPort: Int,
@Value("\${spring.mail.username}") private val username: String,
@Value("\${spring.mail.password}") private val password: String
) {
private val logger = LoggerFactory.getLogger(MailPollingService::class.java)
@Scheduled(fixedDelay = 60000) // Alle 60 Sekunden pollen
fun pollMails() {
if (password.isBlank()) {
logger.warn("Mail-Passwort nicht gesetzt. Polling übersprungen.")
return
}
try {
val props = Properties()
props["mail.store.protocol"] = "imaps"
props["mail.imaps.host"] = imapHost
props["mail.imaps.port"] = imapPort.toString()
props["mail.imaps.ssl.enable"] = "true"
val session = Session.getInstance(props)
val store = session.getStore("imaps")
store.connect(imapHost, username, password)
val inbox = store.getFolder("INBOX")
inbox.open(Folder.READ_WRITE)
// Nur ungelesene Nachrichten
val messages = inbox.getMessages()
logger.info("Gefundene Nachrichten in INBOX: ${messages.size}")
for (message in messages) {
if (!message.isSet(Flags.Flag.SEEN)) {
val recipients = message.getRecipients(jakarta.mail.Message.RecipientType.TO)
val toAddress = (recipients?.firstOrNull() as? InternetAddress)?.address ?: ""
logger.info("Neue Mail empfangen von: ${message.from?.firstOrNull()} an: $toAddress")
// Turnier-Nr extrahieren: meldestelle-26128@mo-code.at
val turnierNr = extractTurnierNr(toAddress)
if (turnierNr != null) {
logger.info("Nennung für Turnier $turnierNr erkannt.")
// TODO: Payload parsen und in DB speichern (Tenant-Routing)
// Auto-Reply senden
sendAutoReply(message.from?.firstOrNull()?.toString() ?: "", turnierNr)
// Mail als gelesen markieren
message.setFlag(Flags.Flag.SEEN, true)
} else {
logger.warn("Keine Turnier-Nr in Adresse $toAddress gefunden. Mail wird ignoriert.")
}
}
}
inbox.close(false)
store.close()
} catch (e: Exception) {
logger.error("Fehler beim Mail-Polling: ${e.message}", e)
}
}
private fun extractTurnierNr(address: String): String? {
val regex = Regex("meldestelle-(\\d+)@.*")
val match = regex.find(address)
return match?.groupValues?.get(1)
}
private fun sendAutoReply(to: String, turnierNr: String) {
try {
val message = SimpleMailMessage()
message.from = username
message.setTo(to)
message.subject = "Eingangsbestätigung: Ihre Nennung für Turnier $turnierNr"
message.text = """
Sehr geehrte Damen und Herren,
vielen Dank für Ihre Online-Nennung für das Turnier $turnierNr.
Ihre Nennung ist erfolgreich in unserem System eingegangen und wird nun von der Meldestelle geprüft.
Sobald die Nennung final verarbeitet wurde, erhalten Sie eine weitere Bestätigung.
Mit freundlichen Grüßen,
Ihre Turniermeldestelle
""".trimIndent()
mailSender.send(message)
logger.info("Auto-Reply an $to für Turnier $turnierNr gesendet.")
} catch (e: Exception) {
logger.error("Fehler beim Senden des Auto-Replies: ${e.message}")
}
}
}
@@ -0,0 +1,11 @@
package at.mocode.mail.service
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
@SpringBootApplication
class MailServiceApplication
fun main(args: Array<String>) {
runApplication<MailServiceApplication>(*args)
}
@@ -0,0 +1,32 @@
spring:
application:
name: mail-service
mail:
host: ${MAIL_HOST:imap.world4you.com}
port: ${MAIL_PORT:993}
username: ${MAIL_USERNAME:online-nennen@mo-code.at}
password: ${MAIL_PASSWORD:}
properties:
mail:
store:
protocol: imaps
imaps:
host: ${MAIL_HOST:imap.world4you.com}
port: ${MAIL_PORT:993}
ssl:
enable: true
smtp:
auth: true
starttls:
enable: true
host-smtp: ${SMTP_HOST:smtp.world4you.com}
port-smtp: ${SMTP_PORT:587}
server:
port: 8085
management:
endpoints:
web:
exposure:
include: "health,info,prometheus"
@@ -0,0 +1,45 @@
# Roadmap: Online-Nennung & Mail-Service (Phase 5)
## 🏗️ [Lead Architect] | 14. April 2026
Dieses Dokument beschreibt die Umsetzung der Online-Nennung für das Turnier in Neumarkt (24. April 2026).
Ziel ist ein schlankes Web-Formular, das strukturierte E-Mails an den `Mail-Service` sendet, welcher diese verarbeitet und in der Desktop-Zentrale zur manuellen Übernahme bereitstellt.
---
### Phase 1: E-Mail-Infrastruktur (Vorbereitung) ✅
* [x] Definition des Adress-Schemas: `meldestelle-[Turnier-Nr]@mo-code.at`.
* [x] Konfiguration der World4You SMTP/IMAP Zugangsdaten.
* [x] Mailpit Integration für lokale Tests (bereits in `dc-ops.yaml`).
### Phase 2: Das Web-Formular (WasmJS Frontend) 🏗️
* [ ] **Basis-UI:** Erstellung des Formulars gemäß Spezifikation (Reiter, Pferd, Lizenz, Bewerbe).
* [ ] **Validierung:** Implementierung der Pflichtfeld-Prüfung (Buttonsperre bis alles ok).
* [ ] **Mail-Versand:** Integration des SMTP-Clients (oder API-Call an Backend), um die strukturierte E-Mail zu senden.
* [ ] **DSGVO:** Checkbox und Hinweistext einbauen.
### Phase 3: Mail-Service (Backend-Verarbeitung) 🏗️
* [ ] **Polling:** Implementierung des IMAP-Pollers (imap.world4you.com).
* [ ] **Parsing:** Extraktion der Turnier-Nummer aus dem `To`-Header und Mapping auf das Datenbank-Schema (Tenant).
* [ ] **Auto-Reply:** Automatisches Versenden der Eingangsbestätigung an den Absender.
* [ ] **Persistence:** Speichern der eingegangenen "Nennungs-Mails" in einer temporären Tabelle für den `registration-context`.
### Phase 4: Desktop-Zentrale Integration 🏗️
* [ ] **UI-Tab:** Neuer Reiter "Nennungs-Eingang" in der Turnierverwaltung.
* [ ] **Vorschau:** Anzeige der eingegangenen Mails mit Details (Reiter, Pferd, Bewerbe).
* [ ] **Übernahme:** "Übernehmen"-Button, der die Daten in die Turnieranmeldung vor-ausfüllt.
* [ ] **Abschluss:** Manueller "Bestätigen"-Button zum Versenden der finalen Bestätigungsmail.
### Phase 5: End-to-End Test & Deployment 🚀 (Deadline: 21.04.2026)
* [ ] Test-Nennung über Web-Formular (Mailpit).
* [ ] Verifikation der Schema-Zuordnung im Backend.
* [ ] Live-Test mit `online-nennen@mo-code.at`.
* [ ] Go-Live für Neumarkt.
---
### Meilensteine
1. **16.04.:** Web-Formular ist funktionsfähig (Senden möglich).
2. **18.04.:** Mail-Service verarbeitet Mails und sendet Auto-Antworten.
3. **20.04.:** Desktop-UI zur Übernahme ist fertig.
4. **24.04.:** Erstes Turnier (Neumarkt) startet mit Online-Nenn-System.
@@ -4,7 +4,7 @@ import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
/** /**
* Feature-Modul: Nennungs-Maske (Desktop-only) * Feature-Modul: Nennungs-Maske (Desktop-only)
* Kapselt die gesamte UI und Logik für die Nennungserfassung am Turnier. * kapselt die gesamte UI und Logik für die Nennungserfassung am Turnier.
*/ */
plugins { plugins {
alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.kotlinMultiplatform)
@@ -0,0 +1,221 @@
package at.mocode.frontend.features.nennung.presentation.web
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.theme.AppColors
import at.mocode.frontend.features.nennung.domain.Bewerb
import at.mocode.frontend.features.nennung.domain.NennungMockData
@Composable
fun OnlineNennungFormular(
turnierNr: String,
onNennenAbgeschickt: (NennungPayload) -> Unit,
onBack: () -> Unit
) {
var vorname by remember { mutableStateOf("") }
var nachname by remember { mutableStateOf("") }
var lizenz by remember { mutableStateOf("Lizenzfrei") }
var pferdName by remember { mutableStateOf("") }
var pferdAlter by remember { mutableStateOf("2020") }
var email by remember { mutableStateOf("") }
var telefon by remember { mutableStateOf("") }
var bemerkungen by remember { mutableStateOf("") }
var dsgvoAkzeptiert by remember { mutableStateOf(false) }
val ausgewaehlteBewerbe = remember { mutableStateListOf<Bewerb>() }
val lizenzen = listOf("Lizenzfrei", "R1", "R2", "R3", "R4", "RS1", "RS2")
val jahre = (2000..2022).map { it.toString() }.reversed()
val isEmailValid = email.contains("@") && email.contains(".")
val canSubmit = vorname.isNotBlank() &&
nachname.isNotBlank() &&
pferdName.isNotBlank() &&
isEmailValid &&
ausgewaehlteBewerbe.isNotEmpty() &&
dsgvoAkzeptiert
LazyColumn(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
Text("Online-Nennung für Turnier $turnierNr", style = MaterialTheme.typography.headlineSmall)
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
}
// Reiter Daten
item {
Text("Reiter", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = vorname,
onValueChange = { vorname = it },
label = { Text("Vorname *") },
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = nachname,
onValueChange = { nachname = it },
label = { Text("Nachname *") },
modifier = Modifier.weight(1f)
)
}
}
item {
Text("Lizenz", style = MaterialTheme.typography.titleSmall)
var expanded by remember { mutableStateOf(false) }
Box {
OutlinedButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) {
Text(lizenz)
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
lizenzen.forEach { l ->
DropdownMenuItem(
text = { Text(l) },
onClick = { lizenz = l; expanded = false }
)
}
}
}
}
// Pferd Daten
item {
Text("Pferd", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
OutlinedTextField(
value = pferdName,
onValueChange = { pferdName = it },
label = { Text("Pferd-Name oder Kopfnummer *") },
modifier = Modifier.fillMaxWidth()
)
}
item {
Text("Geburtsjahr Pferd", style = MaterialTheme.typography.titleSmall)
var expanded by remember { mutableStateOf(false) }
Box {
OutlinedButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) {
Text(pferdAlter)
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
jahre.forEach { j ->
DropdownMenuItem(
text = { Text(j) },
onClick = { pferdAlter = j; expanded = false }
)
}
}
}
}
// Kontakt
item {
Text("Kontakt", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text("E-Mail Adresse *") },
modifier = Modifier.fillMaxWidth(),
isError = email.isNotBlank() && !isEmailValid
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = telefon,
onValueChange = { telefon = it },
label = { Text("Telefonnummer (optional)") },
modifier = Modifier.fillMaxWidth()
)
}
// Bewerbe
item {
Text("Bewerbe / Prüfungen * (Mind. 1 wählen)", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
NennungMockData.bewerbe.forEach { bewerb ->
val isSelected = ausgewaehlteBewerbe.any { it.nr == bewerb.nr }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().clickable {
if (isSelected) {
val item = ausgewaehlteBewerbe.find { it.nr == bewerb.nr }
if (item != null) ausgewaehlteBewerbe.remove(item)
} else {
ausgewaehlteBewerbe.add(bewerb)
}
}.padding(vertical = 4.dp)
) {
Checkbox(checked = isSelected, onCheckedChange = null)
Spacer(Modifier.width(8.dp))
Text("Bewerb ${bewerb.nr}: ${bewerb.name} (${bewerb.tag})")
}
}
}
// Wünsche
item {
OutlinedTextField(
value = bemerkungen,
onValueChange = { bemerkungen = it },
label = { Text("Bemerkungen / Wünsche") },
modifier = Modifier.fillMaxWidth().height(100.dp),
maxLines = 4
)
}
// DSGVO
item {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = dsgvoAkzeptiert, onCheckedChange = { dsgvoAkzeptiert = it })
Text(
"Ich stimme zu, dass meine Daten zum Zweck der Nennung verarbeitet werden.",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 8.dp)
)
}
}
// Buttons
item {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.padding(vertical = 16.dp)) {
OutlinedButton(onClick = onBack, modifier = Modifier.weight(1f)) {
Text("Abbrechen")
}
Button(
onClick = {
onNennenAbgeschickt(
NennungPayload(
vorname, nachname, lizenz, pferdName, pferdAlter,
email, telefon, ausgewaehlteBewerbe.toList(), bemerkungen
)
)
},
enabled = canSubmit,
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(containerColor = AppColors.Success)
) {
Text("Jetzt Nennen")
}
}
}
}
}
data class NennungPayload(
val vorname: String,
val nachname: String,
val lizenz: String,
val pferdName: String,
val pferdAlter: String,
val email: String,
val telefon: String,
val bewerbe: List<Bewerb>,
val bemerkungen: String
)
@@ -15,6 +15,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.theme.AppColors import at.mocode.frontend.core.designsystem.theme.AppColors
import at.mocode.frontend.features.billing.presentation.BillingViewModel import at.mocode.frontend.features.billing.presentation.BillingViewModel
import at.mocode.frontend.features.nennung.presentation.web.NennungPayload
import at.mocode.frontend.features.nennung.presentation.web.OnlineNennungFormular
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -44,10 +46,17 @@ fun WebMainScreen() {
currentScreen = WebScreen.Nennung(vId, tId) currentScreen = WebScreen.Nennung(vId, tId)
} }
) )
is WebScreen.Nennung -> NennungWebFormular( is WebScreen.Nennung -> OnlineNennungFormular(
veranstaltungId = screen.veranstaltungId, turnierNr = screen.turnierId.toString(),
turnierId = screen.turnierId, onNennenAbgeschickt = { payload ->
billingViewModel = billingViewModel, // Hier wird später der Mail-Versand oder API-Call integriert
println("Nennung abgeschickt: $payload")
currentScreen = WebScreen.Erfolg(payload.email)
},
onBack = { currentScreen = WebScreen.Landing }
)
is WebScreen.Erfolg -> Erfolgsscreen(
email = screen.email,
onBack = { currentScreen = WebScreen.Landing } onBack = { currentScreen = WebScreen.Landing }
) )
} }
@@ -58,6 +67,27 @@ fun WebMainScreen() {
sealed class WebScreen { sealed class WebScreen {
data object Landing : WebScreen() data object Landing : WebScreen()
data class Nennung(val veranstaltungId: Long, val turnierId: Long) : WebScreen() data class Nennung(val veranstaltungId: Long, val turnierId: Long) : WebScreen()
data class Erfolg(val email: String) : WebScreen()
}
@Composable
fun Erfolgsscreen(email: String, onBack: () -> Unit) {
Box(modifier = Modifier.fillMaxSize().padding(16.dp), contentAlignment = Alignment.Center) {
Card(
colors = CardDefaults.cardColors(containerColor = AppColors.PrimaryContainer),
modifier = Modifier.fillMaxWidth().padding(16.dp)
) {
Column(modifier = Modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text("Nennung erfolgreich eingegangen!", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(16.dp))
Text("Eine Bestätigungsmail wurde an $email gesendet.", style = MaterialTheme.typography.bodyLarge)
Spacer(Modifier.height(24.dp))
Button(onClick = onBack) {
Text("Zurück zur Startseite")
}
}
}
}
} }
@Composable @Composable
+3
View File
@@ -100,6 +100,9 @@ include(":backend:services:masterdata:masterdata-service")
include(":backend:services:billing:billing-domain") include(":backend:services:billing:billing-domain")
include(":backend:services:billing:billing-service") include(":backend:services:billing:billing-service")
// --- MAIL (Mail-Service für Online-Nennungen) ---
include(":backend:services:mail:mail-service")
// --- PING (Ping Service) --- // --- PING (Ping Service) ---
include(":backend:services:ping:ping-service") include(":backend:services:ping:ping-service")