feat(mail-service): introduce persistence and REST support for Nennungen

- Added `NennungRepository` with methods for saving, updating status, and retrieving entries.
- Created `NennungController` to expose REST endpoints for Nennungen.
- Defined `NennungTable` schema with relevant fields and indices.
- Extended `MailPollingService` to parse incoming emails into `NennungEntity` and persist them.
- Updated `build.gradle.kts` with database dependencies and H2 configuration for local dev.
- Refined frontend layout in `OnlineNennungFormular` for improved usability and responsiveness.
This commit is contained in:
2026-04-14 16:50:24 +02:00
parent 4de44623c2
commit da3b57a91d
10 changed files with 662 additions and 257 deletions
@@ -11,6 +11,7 @@ springBoot {
dependencies {
// Interne Module
implementation(platform(projects.platform.platformBom))
implementation(projects.platform.platformDependencies)
implementation(projects.core.coreUtils)
implementation(projects.core.coreDomain)
@@ -20,7 +21,20 @@ dependencies {
implementation(libs.spring.boot.starter.validation)
implementation(libs.spring.boot.starter.actuator)
implementation(libs.spring.boot.starter.mail)
implementation(libs.spring.boot.starter.jdbc)
implementation(libs.jackson.module.kotlin)
implementation(libs.jackson.datatype.jsr310)
// Database & Exposed
implementation(libs.exposed.core)
implementation(libs.exposed.dao)
implementation(libs.exposed.jdbc)
implementation(libs.exposed.java.time)
implementation(libs.exposed.json)
implementation(libs.exposed.kotlin.datetime)
implementation(libs.h2.driver)
implementation(libs.postgresql.driver)
implementation(libs.hikari.cp)
implementation(libs.spring.cloud.starter.consul.discovery)
implementation(libs.micrometer.tracing.bridge.brave)
implementation(libs.zipkin.reporter.brave)
@@ -1,99 +1,150 @@
package at.mocode.mail.service
import at.mocode.mail.service.persistence.NennungEntity
import at.mocode.mail.service.persistence.NennungRepository
import at.mocode.mail.service.persistence.NennungTable
import com.fasterxml.jackson.databind.ObjectMapper
import jakarta.mail.Flags
import jakarta.mail.Folder
import jakarta.mail.Session
import jakarta.mail.internet.InternetAddress
import org.jetbrains.exposed.v1.jdbc.SchemaUtils
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.springframework.transaction.annotation.Transactional
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.context.event.ApplicationReadyEvent
import org.springframework.context.event.EventListener
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.*
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@OptIn(ExperimentalUuidApi::class)
@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 mailSender: JavaMailSender,
private val nennungRepository: NennungRepository,
private val objectMapper: ObjectMapper,
@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)
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
}
@EventListener(ApplicationReadyEvent::class)
@Transactional
fun initSchema() {
transaction {
SchemaUtils.create(NennungTable)
}
logger.info("Datenbankschema für Mail-Service initialisiert.")
}
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"
@Scheduled(fixedDelay = 60000) // Alle 60 Sekunden pollen
fun pollMails() {
if (password.isBlank()) {
logger.warn("Mail-Passwort nicht gesetzt. Polling übersprungen.")
return
}
val session = Session.getInstance(props)
val store = session.getStore("imaps")
store.connect(imapHost, username, password)
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 inbox = store.getFolder("INBOX")
inbox.open(Folder.READ_WRITE)
val session = Session.getInstance(props)
val store = session.getStore("imaps")
store.connect(imapHost, username, password)
// Nur ungelesene Nachrichten
val messages = inbox.getMessages()
logger.info("Gefundene Nachrichten in INBOX: ${messages.size}")
val inbox = store.getFolder("INBOX")
inbox.open(Folder.READ_WRITE)
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 ?: ""
// Nur ungelesene Nachrichten
val messages = inbox.getMessages()
logger.info("Gefundene Nachrichten in INBOX: ${messages.size}")
logger.info("Neue Mail empfangen von: ${message.from?.firstOrNull()} an: $toAddress")
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 ?: ""
// Turnier-Nr extrahieren: meldestelle-26128@mo-code.at
val turnierNr = extractTurnierNr(toAddress)
logger.info("Neue Mail empfangen von: ${message.from?.firstOrNull()} an: $toAddress")
if (turnierNr != null) {
logger.info("Nennung für Turnier $turnierNr erkannt.")
// TODO: Payload parsen und in DB speichern (Tenant-Routing)
// Turnier-Nr extrahieren: meldestelle-26128@mo-code.at
val turnierNr = extractTurnierNr(toAddress)
// Auto-Reply senden
sendAutoReply(message.from?.firstOrNull()?.toString() ?: "", turnierNr)
if (turnierNr != null) {
logger.info("Nennung für Turnier $turnierNr erkannt.")
// Mail als gelesen markieren
message.setFlag(Flags.Flag.SEEN, true)
} else {
logger.warn("Keine Turnier-Nr in Adresse $toAddress gefunden. Mail wird ignoriert.")
}
}
try {
val content = message.content.toString()
val entity = NennungEntity(
id = Uuid.random(),
turnierNr = turnierNr,
status = "NEU",
vorname = extractValue(content, "Vorname") ?: "Unbekannt",
nachname = extractValue(content, "Nachname") ?: "Unbekannt",
lizenz = extractValue(content, "Lizenz") ?: "LF",
pferdName = extractValue(content, "Pferd") ?: "Unbekannt",
pferdAlter = extractValue(content, "Alter") ?: "2020",
email = (message.from?.firstOrNull() as? InternetAddress)?.address ?: "unbekannt@test.at",
telefon = extractValue(content, "Telefon"),
bewerbe = extractValue(content, "Bewerbe") ?: "[]",
bemerkungen = extractValue(content, "Bemerkungen")
)
nennungRepository.save(entity)
logger.info("Nennung für ${entity.vorname} ${entity.nachname} erfolgreich persistiert.")
// Auto-Reply senden
sendAutoReply(entity.email, turnierNr)
} catch (e: Exception) {
logger.error("Fehler beim Parsen/Speichern der Nennung: ${e.message}")
}
inbox.close(false)
store.close()
} catch (e: Exception) {
logger.error("Fehler beim Mail-Polling: ${e.message}", e)
// Mail als gelesen markieren
message.setFlag(Flags.Flag.SEEN, true)
} else {
logger.warn("Keine Turnier-Nr in Adresse $toAddress gefunden. Mail wird ignoriert.")
}
}
}
}
private fun extractTurnierNr(address: String): String? {
val regex = Regex("meldestelle-(\\d+)@.*")
val match = regex.find(address)
return match?.groupValues?.get(1)
inbox.close(false)
store.close()
} catch (e: Exception) {
logger.error("Fehler beim Mail-Polling: ${e.message}", e)
}
}
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 = """
private fun extractTurnierNr(address: String): String? {
val regex = Regex("meldestelle-(\\d+)@.*")
val match = regex.find(address)
return match?.groupValues?.get(1)
}
private fun extractValue(content: String, key: String): String? {
val regex = Regex("$key:\\s*(.*)")
return regex.find(content)?.groupValues?.get(1)?.trim()
}
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.
@@ -105,10 +156,10 @@ class MailPollingService(
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}")
}
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}")
}
}
}
@@ -7,5 +7,5 @@ import org.springframework.boot.runApplication
class MailServiceApplication
fun main(args: Array<String>) {
runApplication<MailServiceApplication>(*args)
runApplication<MailServiceApplication>(*args)
}
@@ -0,0 +1,29 @@
@file:OptIn(ExperimentalUuidApi::class)
package at.mocode.mail.service
import at.mocode.mail.service.persistence.NennungEntity
import at.mocode.mail.service.persistence.NennungRepository
import org.springframework.web.bind.annotation.*
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@RestController
@RequestMapping("/api/mail/nennungen")
class NennungController(
private val nennungRepository: NennungRepository
) {
@GetMapping
fun getAllNennungen(): List<NennungEntity> {
return nennungRepository.findAll()
}
@PutMapping("/{id}/status")
fun updateStatus(
@PathVariable id: String,
@RequestBody newStatus: String
) {
nennungRepository.updateStatus(Uuid.parse(id), newStatus)
}
}
@@ -0,0 +1,81 @@
@file:OptIn(ExperimentalUuidApi::class)
package at.mocode.mail.service.persistence
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import org.springframework.stereotype.Repository
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.springframework.transaction.annotation.Transactional
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
data class NennungEntity(
val id: Uuid,
val turnierNr: String,
val status: String,
val vorname: String,
val nachname: String,
val lizenz: String,
val pferdName: String,
val pferdAlter: String,
val email: String,
val telefon: String?,
val bewerbe: String,
val bemerkungen: String?
)
@Repository
@Transactional
class NennungRepository {
fun save(nennung: NennungEntity) {
transaction {
NennungTable.insert {
it[id] = nennung.id
it[turnierNr] = nennung.turnierNr
it[status] = nennung.status
it[vorname] = nennung.vorname
it[nachname] = nennung.nachname
it[lizenz] = nennung.lizenz
it[pferdName] = nennung.pferdName
it[pferdAlter] = nennung.pferdAlter
it[email] = nennung.email
it[telefon] = nennung.telefon
it[bewerbe] = nennung.bewerbe
it[bemerkungen] = nennung.bemerkungen
}
}
}
fun updateStatus(id: Uuid, newStatus: String) {
transaction {
NennungTable.update({ NennungTable.id eq id }) {
it[status] = newStatus
}
}
}
fun findAll(): List<NennungEntity> {
return transaction {
NennungTable.selectAll().map {
NennungEntity(
id = it[NennungTable.id],
turnierNr = it[NennungTable.turnierNr],
status = it[NennungTable.status],
vorname = it[NennungTable.vorname],
nachname = it[NennungTable.nachname],
lizenz = it[NennungTable.lizenz],
pferdName = it[NennungTable.pferdName],
pferdAlter = it[NennungTable.pferdAlter],
email = it[NennungTable.email],
telefon = it[NennungTable.telefon],
bewerbe = it[NennungTable.bewerbe],
bemerkungen = it[NennungTable.bemerkungen]
)
}
}
}
}
@@ -0,0 +1,34 @@
@file:OptIn(ExperimentalUuidApi::class)
package at.mocode.mail.service.persistence
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.javatime.CurrentTimestamp
import org.jetbrains.exposed.v1.javatime.timestamp
import kotlin.uuid.ExperimentalUuidApi
object NennungTable : Table("nennungen") {
val id = uuid("id")
val turnierNr = varchar("turnier_nr", 20)
val status = varchar("status", 20) // NEU, GELESEN, UEBERNOMMEN
val eingangsdatum = timestamp("eingangsdatum").defaultExpression(CurrentTimestamp)
// Reiter Daten
val vorname = varchar("vorname", 100)
val nachname = varchar("nachname", 100)
val lizenz = varchar("lizenz", 50)
// Pferd Daten
val pferdName = varchar("pferd_name", 100)
val pferdAlter = varchar("pferd_alter", 10)
// Kontakt
val email = varchar("email", 150)
val telefon = varchar("telefon", 50).nullable()
// Payload (Bewerbe & Bemerkungen)
val bewerbe = text("bewerbe") // Kommagetrennte Liste der Bewerbs-Nummern
val bemerkungen = text("bemerkungen").nullable()
override val primaryKey = PrimaryKey(id)
}
@@ -1,6 +1,15 @@
spring:
application:
name: mail-service
datasource:
url: jdbc:h2:mem:maildb;DB_CLOSE_DELAY=-1
driver-class-name: org.h2.Driver
username: sa
password: ""
h2:
console:
enabled: true
path: /h2-console
mail:
host: ${MAIL_HOST:imap.world4you.com}
port: ${MAIL_PORT:993}