From adfa97978eb5019294215dbd297e061ff50e0a26 Mon Sep 17 00:00:00 2001 From: Stefan Mogeritsch Date: Tue, 14 Apr 2026 14:59:11 +0200 Subject: [PATCH] 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. --- .../mail/mail-service/build.gradle.kts | 36 +++ .../mocode/mail/service/MailPollingService.kt | 114 +++++++++ .../mail/service/MailServiceApplication.kt | 11 + .../src/main/resources/application.yaml | 32 +++ .../Roadmap_Online-Nennung_Mail-Service.md | 45 ++++ .../features/nennung-feature/build.gradle.kts | 2 +- .../presentation/web/OnlineNennungFormular.kt | 221 ++++++++++++++++++ .../kotlin/at/mocode/web/WebMainScreen.kt | 38 ++- settings.gradle.kts | 3 + 9 files changed, 497 insertions(+), 5 deletions(-) create mode 100644 backend/services/mail/mail-service/build.gradle.kts create mode 100644 backend/services/mail/mail-service/src/main/kotlin/at/mocode/mail/service/MailPollingService.kt create mode 100644 backend/services/mail/mail-service/src/main/kotlin/at/mocode/mail/service/MailServiceApplication.kt create mode 100644 backend/services/mail/mail-service/src/main/resources/application.yaml create mode 100644 docs/01_Architecture/Roadmap_Online-Nennung_Mail-Service.md create mode 100644 frontend/features/nennung-feature/src/commonMain/kotlin/at/mocode/frontend/features/nennung/presentation/web/OnlineNennungFormular.kt diff --git a/backend/services/mail/mail-service/build.gradle.kts b/backend/services/mail/mail-service/build.gradle.kts new file mode 100644 index 00000000..13dbdfbb --- /dev/null +++ b/backend/services/mail/mail-service/build.gradle.kts @@ -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() +} diff --git a/backend/services/mail/mail-service/src/main/kotlin/at/mocode/mail/service/MailPollingService.kt b/backend/services/mail/mail-service/src/main/kotlin/at/mocode/mail/service/MailPollingService.kt new file mode 100644 index 00000000..4da6beae --- /dev/null +++ b/backend/services/mail/mail-service/src/main/kotlin/at/mocode/mail/service/MailPollingService.kt @@ -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}") + } + } +} diff --git a/backend/services/mail/mail-service/src/main/kotlin/at/mocode/mail/service/MailServiceApplication.kt b/backend/services/mail/mail-service/src/main/kotlin/at/mocode/mail/service/MailServiceApplication.kt new file mode 100644 index 00000000..fd9aa92c --- /dev/null +++ b/backend/services/mail/mail-service/src/main/kotlin/at/mocode/mail/service/MailServiceApplication.kt @@ -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) { + runApplication(*args) +} diff --git a/backend/services/mail/mail-service/src/main/resources/application.yaml b/backend/services/mail/mail-service/src/main/resources/application.yaml new file mode 100644 index 00000000..897129ae --- /dev/null +++ b/backend/services/mail/mail-service/src/main/resources/application.yaml @@ -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" diff --git a/docs/01_Architecture/Roadmap_Online-Nennung_Mail-Service.md b/docs/01_Architecture/Roadmap_Online-Nennung_Mail-Service.md new file mode 100644 index 00000000..901b423e --- /dev/null +++ b/docs/01_Architecture/Roadmap_Online-Nennung_Mail-Service.md @@ -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. diff --git a/frontend/features/nennung-feature/build.gradle.kts b/frontend/features/nennung-feature/build.gradle.kts index d4081699..6dc9e108 100644 --- a/frontend/features/nennung-feature/build.gradle.kts +++ b/frontend/features/nennung-feature/build.gradle.kts @@ -4,7 +4,7 @@ import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl /** * 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 { alias(libs.plugins.kotlinMultiplatform) diff --git a/frontend/features/nennung-feature/src/commonMain/kotlin/at/mocode/frontend/features/nennung/presentation/web/OnlineNennungFormular.kt b/frontend/features/nennung-feature/src/commonMain/kotlin/at/mocode/frontend/features/nennung/presentation/web/OnlineNennungFormular.kt new file mode 100644 index 00000000..94ad150f --- /dev/null +++ b/frontend/features/nennung-feature/src/commonMain/kotlin/at/mocode/frontend/features/nennung/presentation/web/OnlineNennungFormular.kt @@ -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() } + + 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, + val bemerkungen: String +) diff --git a/frontend/shells/meldestelle-web/src/wasmJsMain/kotlin/at/mocode/web/WebMainScreen.kt b/frontend/shells/meldestelle-web/src/wasmJsMain/kotlin/at/mocode/web/WebMainScreen.kt index 5df20211..e59faf16 100644 --- a/frontend/shells/meldestelle-web/src/wasmJsMain/kotlin/at/mocode/web/WebMainScreen.kt +++ b/frontend/shells/meldestelle-web/src/wasmJsMain/kotlin/at/mocode/web/WebMainScreen.kt @@ -15,6 +15,8 @@ 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.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 @OptIn(ExperimentalMaterial3Api::class) @@ -44,10 +46,17 @@ fun WebMainScreen() { currentScreen = WebScreen.Nennung(vId, tId) } ) - is WebScreen.Nennung -> NennungWebFormular( - veranstaltungId = screen.veranstaltungId, - turnierId = screen.turnierId, - billingViewModel = billingViewModel, + is WebScreen.Nennung -> OnlineNennungFormular( + turnierNr = screen.turnierId.toString(), + onNennenAbgeschickt = { payload -> + // 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 } ) } @@ -58,6 +67,27 @@ fun WebMainScreen() { sealed class WebScreen { data object Landing : 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 diff --git a/settings.gradle.kts b/settings.gradle.kts index 35a92fd3..7fbadb9b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -100,6 +100,9 @@ include(":backend:services:masterdata:masterdata-service") include(":backend:services:billing:billing-domain") include(":backend:services:billing:billing-service") +// --- MAIL (Mail-Service für Online-Nennungen) --- +include(":backend:services:mail:mail-service") + // --- PING (Ping Service) --- include(":backend:services:ping:ping-service")