diff --git a/pom.xml b/pom.xml
index 58a3807..41e9821 100644
--- a/pom.xml
+++ b/pom.xml
@@ -40,6 +40,10 @@
org.springframework.boot
spring-boot-starter-cache
+
+ org.springframework.boot
+ spring-boot-starter-mail
+
com.github.ben-manes.caffeine
diff --git a/readme.md b/readme.md
index eb7c135..979c68d 100644
--- a/readme.md
+++ b/readme.md
@@ -148,6 +148,9 @@ the login module writes "view as" to the audit table through `shared/audit/Admin
use each other.
`hint` (dashboard hints and their admin API) uses `JdbcTemplate` in a `repo` package too, and audits through
`shared/audit/AdminAuditLog`.
+`mail` writes each mail as a finished text into `mail_outbox` (`JdbcTemplate`, `repo`), in the transaction of the
+event, and `MailOutboxService` sends the rows every minute over SMTP. Other modules reach it through the port
+`shared/mail/AccountMailer`. Without `SPRING_MAIL_HOST` mail is off: the rows wait and go out once a host is set.
ArchUnit enforces these rules in `src/test/java/auth_service/architecture/ArchitectureTest.java`, so a layer
violation fails the build. It also keeps the modules independent of each other and free of cycles. Test base
@@ -303,6 +306,9 @@ Configuration comes from environment variables:
| `APP_PUBLIC_URL` | Auth's public address, the Steam OpenID realm (default `http://localhost:8080`) |
| `APP_FRONTEND_URL` | Where the Steam link ends (default `http://localhost:5173`) |
| `APP_CORE_INTERNAL_URL` | Core for the nightly account cleanup, required (local `http://localhost:8090`, Docker `http://tbp-core-service:8090`) |
+| `SPRING_MAIL_HOST`, `SPRING_MAIL_USERNAME`, `SPRING_MAIL_PASSWORD` | SMTP server and its login (port 465, SSL). No host = mail off |
+| `APP_MAIL_FROM` | Sender address, the mailbox of the SMTP login. Needed with a host |
+| `APP_MAIL_ADMIN_TO` | Gets "new user registered". Needed with a host |
Generate a keypair:
diff --git a/src/main/java/auth_service/modules/mail/model/Mail.java b/src/main/java/auth_service/modules/mail/model/Mail.java
new file mode 100644
index 0000000..910b84b
--- /dev/null
+++ b/src/main/java/auth_service/modules/mail/model/Mail.java
@@ -0,0 +1,4 @@
+package auth_service.modules.mail.model;
+
+public record Mail(String subject, String body) {
+}
diff --git a/src/main/java/auth_service/modules/mail/model/MailTemplate.java b/src/main/java/auth_service/modules/mail/model/MailTemplate.java
new file mode 100644
index 0000000..2ece7ce
--- /dev/null
+++ b/src/main/java/auth_service/modules/mail/model/MailTemplate.java
@@ -0,0 +1,106 @@
+package auth_service.modules.mail.model;
+
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.util.Locale;
+
+/**
+ * Plain text only: names and addresses go in as they are, and no mail client renders them as HTML.
+ */
+public enum MailTemplate {
+
+ NEW_USER_REGISTERED(
+ "New registration on ToBePlayed", """
+ A new user has registered.
+
+ Name: %s
+ Email: %s
+ """,
+ "Neue Registrierung bei ToBePlayed", """
+ Ein neuer Nutzer hat sich registriert.
+
+ Name: %s
+ E-Mail: %s
+ """),
+
+ ACCOUNT_ACTIVE(
+ "Your ToBePlayed account is active", """
+ Hello %s,
+
+ your account is active. You can log in now:
+ %s
+
+ ToBePlayed
+ """,
+ "Dein ToBePlayed-Konto ist aktiv", """
+ Hallo %s,
+
+ dein Konto ist aktiv. Du kannst dich jetzt anmelden:
+ %s
+
+ ToBePlayed
+ """),
+
+ ACCOUNT_BLOCKED(
+ "Your ToBePlayed account is blocked", """
+ Hello %s,
+
+ an administrator has blocked your account, so you cannot log in.
+ If you think this is a mistake, answer this mail.
+
+ ToBePlayed
+ """,
+ "Dein ToBePlayed-Konto ist gesperrt", """
+ Hallo %s,
+
+ ein Administrator hat dein Konto gesperrt. Du kannst dich nicht mehr anmelden.
+ Wenn das ein Fehler ist, antworte auf diese Mail.
+
+ ToBePlayed
+ """),
+
+ ACCOUNT_DELETED(
+ "Your ToBePlayed account will be deleted", """
+ Hello %s,
+
+ your account is marked for deletion, and you cannot log in any more.
+ All your data will be deleted from %s.
+
+ ToBePlayed
+ """,
+ "Dein ToBePlayed-Konto wird gelöscht", """
+ Hallo %s,
+
+ dein Konto ist zum Löschen markiert. Du kannst dich nicht mehr anmelden.
+ Alle deine Daten werden ab dem %s gelöscht.
+
+ ToBePlayed
+ """);
+
+ private static final DateTimeFormatter DATE_DE = DateTimeFormatter.ofPattern("dd.MM.yyyy");
+ private static final DateTimeFormatter DATE_EN = DateTimeFormatter.ofPattern("d MMMM yyyy", Locale.ENGLISH);
+
+ private final Mail en;
+ private final Mail de;
+
+ MailTemplate(String subjectEn, String bodyEn, String subjectDe, String bodyDe) {
+ this.en = new Mail(subjectEn, bodyEn);
+ this.de = new Mail(subjectDe, bodyDe);
+ }
+
+ /**
+ * German for "de", English for any other or no language.
+ */
+ public Mail render(String language, Object... args) {
+ Mail mail = isGerman(language) ? de : en;
+ return new Mail(mail.subject(), mail.body().formatted(args));
+ }
+
+ public static String date(String language, LocalDate date) {
+ return (isGerman(language) ? DATE_DE : DATE_EN).format(date);
+ }
+
+ private static boolean isGerman(String language) {
+ return "de".equals(language);
+ }
+}
diff --git a/src/main/java/auth_service/modules/mail/model/OutboxRow.java b/src/main/java/auth_service/modules/mail/model/OutboxRow.java
new file mode 100644
index 0000000..74241a5
--- /dev/null
+++ b/src/main/java/auth_service/modules/mail/model/OutboxRow.java
@@ -0,0 +1,4 @@
+package auth_service.modules.mail.model;
+
+public record OutboxRow(long id, String kind, String toAddress, String subject, String body, int attempts) {
+}
diff --git a/src/main/java/auth_service/modules/mail/model/Recipient.java b/src/main/java/auth_service/modules/mail/model/Recipient.java
new file mode 100644
index 0000000..388939c
--- /dev/null
+++ b/src/main/java/auth_service/modules/mail/model/Recipient.java
@@ -0,0 +1,11 @@
+package auth_service.modules.mail.model;
+
+/**
+ * @param language '' or null when the user never chose one
+ */
+public record Recipient(String email, String displayName, String language) {
+
+ public String greeting() {
+ return displayName == null || displayName.isBlank() ? email : displayName;
+ }
+}
diff --git a/src/main/java/auth_service/modules/mail/repo/MailOutboxRepository.java b/src/main/java/auth_service/modules/mail/repo/MailOutboxRepository.java
new file mode 100644
index 0000000..556fc2b
--- /dev/null
+++ b/src/main/java/auth_service/modules/mail/repo/MailOutboxRepository.java
@@ -0,0 +1,72 @@
+package auth_service.modules.mail.repo;
+
+import auth_service.modules.mail.model.Mail;
+import auth_service.modules.mail.model.MailTemplate;
+import auth_service.modules.mail.model.OutboxRow;
+import auth_service.modules.mail.model.Recipient;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Repository;
+
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.util.List;
+import java.util.Optional;
+
+@Repository
+public class MailOutboxRepository {
+
+ private static final int MAX_ERROR_LENGTH = 500;
+
+ private final JdbcTemplate jdbc;
+
+ public MailOutboxRepository(JdbcTemplate jdbc) {
+ this.jdbc = jdbc;
+ }
+
+ public Optional findRecipient(long userId) {
+ return jdbc.query("""
+ SELECT u.email, u.display_name, s.language
+ FROM users u LEFT JOIN user_settings s ON s.user_id = u.id
+ WHERE u.id = ?""",
+ (rs, row) -> new Recipient(rs.getString("email"), rs.getString("display_name"), rs.getString("language")),
+ userId).stream().findFirst();
+ }
+
+ public void insert(MailTemplate kind, long userId, String toAddress, Mail mail) {
+ jdbc.update("INSERT INTO mail_outbox (kind, user_id, to_address, subject, body) VALUES (?, ?, ?, ?, ?)",
+ kind.name(), userId, toAddress, mail.subject(), mail.body());
+ }
+
+ public List findDue(Instant now, int limit) {
+ return jdbc.query("""
+ SELECT id, kind, to_address, subject, body, attempts FROM mail_outbox
+ WHERE status = 'PENDING' AND next_attempt_at <= ?
+ ORDER BY id LIMIT ?""",
+ (rs, row) -> new OutboxRow(rs.getLong("id"), rs.getString("kind"), rs.getString("to_address"),
+ rs.getString("subject"), rs.getString("body"), rs.getInt("attempts")),
+ Timestamp.from(now), limit);
+ }
+
+ public void markSent(long id, Instant now) {
+ jdbc.update("UPDATE mail_outbox SET status = 'SENT', sent_at = ?, last_error = NULL WHERE id = ?",
+ Timestamp.from(now), id);
+ }
+
+ public void markRetry(long id, int attempts, Instant nextAttemptAt, String error) {
+ jdbc.update("UPDATE mail_outbox SET attempts = ?, next_attempt_at = ?, last_error = ? WHERE id = ?",
+ attempts, Timestamp.from(nextAttemptAt), shorten(error), id);
+ }
+
+ public void markFailed(long id, int attempts, String error) {
+ jdbc.update("UPDATE mail_outbox SET status = 'FAILED', attempts = ?, last_error = ? WHERE id = ?",
+ attempts, shorten(error), id);
+ }
+
+ public void deleteSentBefore(Instant cutoff) {
+ jdbc.update("DELETE FROM mail_outbox WHERE status = 'SENT' AND sent_at < ?", Timestamp.from(cutoff));
+ }
+
+ private static String shorten(String error) {
+ return error == null || error.length() <= MAX_ERROR_LENGTH ? error : error.substring(0, MAX_ERROR_LENGTH);
+ }
+}
diff --git a/src/main/java/auth_service/modules/mail/service/MailOutboxService.java b/src/main/java/auth_service/modules/mail/service/MailOutboxService.java
new file mode 100644
index 0000000..ebe4675
--- /dev/null
+++ b/src/main/java/auth_service/modules/mail/service/MailOutboxService.java
@@ -0,0 +1,94 @@
+package auth_service.modules.mail.service;
+
+import auth_service.modules.mail.model.OutboxRow;
+import auth_service.modules.mail.repo.MailOutboxRepository;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.mail.MailException;
+import org.springframework.mail.SimpleMailMessage;
+import org.springframework.mail.javamail.JavaMailSender;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+
+import java.time.Duration;
+import java.time.Instant;
+
+/**
+ * Sends the rows of {@code mail_outbox}. Mail is off without {@code SPRING_MAIL_HOST}: the rows wait and go out once
+ * a host is set.
+ */
+@Service
+@Slf4j
+public class MailOutboxService {
+
+ // About 4 hours of tries with the backoff below.
+ static final int MAX_ATTEMPTS = 10;
+ static final Duration KEEP_SENT = Duration.ofDays(30);
+ private static final int BATCH = 20;
+
+ private final MailOutboxRepository repository;
+ private final JavaMailSender sender;
+ private final String from;
+
+ // An empty SPRING_MAIL_HOST still creates Boot's sender bean, so the host decides, not the bean.
+ public MailOutboxService(MailOutboxRepository repository, ObjectProvider sender,
+ @Value("${spring.mail.host:}") String host, @Value("${app.mail.from}") String from,
+ @Value("${app.mail.admin-to}") String adminTo) {
+ this.repository = repository;
+ this.from = from;
+ if (host.isBlank()) {
+ this.sender = null;
+ log.info("Mail is off: no SPRING_MAIL_HOST");
+ return;
+ }
+ if (from.isBlank() || adminTo.isBlank()) {
+ throw new IllegalStateException("SPRING_MAIL_HOST is set, so APP_MAIL_FROM and APP_MAIL_ADMIN_TO are needed");
+ }
+ this.sender = sender.getObject();
+ }
+
+ // At least once: when the server took a mail but markSent fails, the next run sends it again.
+ // ponytail: no row lock, safe because one Auth instance runs and Spring starts a cron run only after the last
+ // one ended; use SELECT ... FOR UPDATE SKIP LOCKED before running a second instance.
+ @Scheduled(cron = "${app.mail.cron}")
+ public void sendDue() {
+ if (sender == null) {
+ return;
+ }
+ Instant now = Instant.now();
+ for (OutboxRow row : repository.findDue(now, BATCH)) {
+ send(row, now);
+ }
+ repository.deleteSentBefore(now.minus(KEEP_SENT));
+ }
+
+ private void send(OutboxRow row, Instant now) {
+ SimpleMailMessage message = new SimpleMailMessage();
+ message.setFrom(from);
+ message.setTo(row.toAddress());
+ message.setSubject(row.subject());
+ message.setText(row.body());
+ try {
+ sender.send(message);
+ repository.markSent(row.id(), now);
+ } catch (MailException e) {
+ int attempts = row.attempts() + 1;
+ // The SMTP answer can name the address, so only last_error gets it.
+ if (attempts >= MAX_ATTEMPTS) {
+ repository.markFailed(row.id(), attempts, e.getMessage());
+ log.warn("Mail {} ({}) given up after {} tries: {}", row.id(), row.kind(), attempts,
+ e.getClass().getSimpleName());
+ } else {
+ repository.markRetry(row.id(), attempts, now.plus(backoff(attempts)), e.getMessage());
+ log.info("Mail {} ({}) failed, try {} of {}: {}", row.id(), row.kind(), attempts, MAX_ATTEMPTS,
+ e.getClass().getSimpleName());
+ }
+ }
+ }
+
+ // 1, 2, 4 ... 32 minutes, then the hour; attempts stay below MAX_ATTEMPTS, so the shift cannot overflow.
+ static Duration backoff(int attempts) {
+ return Duration.ofMinutes(Math.min(1L << (attempts - 1), 60));
+ }
+}
diff --git a/src/main/java/auth_service/modules/mail/service/MailService.java b/src/main/java/auth_service/modules/mail/service/MailService.java
new file mode 100644
index 0000000..61934c4
--- /dev/null
+++ b/src/main/java/auth_service/modules/mail/service/MailService.java
@@ -0,0 +1,60 @@
+package auth_service.modules.mail.service;
+
+import auth_service.modules.mail.model.MailTemplate;
+import auth_service.modules.mail.model.Recipient;
+import auth_service.modules.mail.repo.MailOutboxRepository;
+import auth_service.shared.mail.AccountMailer;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDate;
+
+/**
+ * Renders each mail when the event happens and stores the finished text, so a later change of the name or the
+ * language does not change a mail that waits.
+ */
+@Service
+public class MailService implements AccountMailer {
+
+ // The admin reads German.
+ private static final String ADMIN_LANGUAGE = "de";
+
+ private final MailOutboxRepository repository;
+ private final String adminTo;
+ private final String frontendUrl;
+
+ public MailService(MailOutboxRepository repository, @Value("${app.mail.admin-to}") String adminTo,
+ @Value("${app.frontend-url}") String frontendUrl) {
+ this.repository = repository;
+ this.adminTo = adminTo;
+ this.frontendUrl = frontendUrl;
+ }
+
+ @Override
+ public void userRegistered(long userId) {
+ repository.findRecipient(userId).ifPresent(user -> {
+ // Blank only while mail is off: with a host, MailOutboxService stops the start without it.
+ if (!adminTo.isBlank()) {
+ repository.insert(MailTemplate.NEW_USER_REGISTERED, userId, adminTo, MailTemplate.NEW_USER_REGISTERED
+ .render(ADMIN_LANGUAGE, user.displayName() == null ? "-" : user.displayName(), user.email()));
+ }
+ insertForUser(MailTemplate.ACCOUNT_ACTIVE, userId, user, user.greeting(), frontendUrl);
+ });
+ }
+
+ @Override
+ public void accountBlocked(long userId) {
+ repository.findRecipient(userId).ifPresent(user ->
+ insertForUser(MailTemplate.ACCOUNT_BLOCKED, userId, user, user.greeting()));
+ }
+
+ @Override
+ public void accountDeleted(long userId, LocalDate deletedFrom) {
+ repository.findRecipient(userId).ifPresent(user -> insertForUser(MailTemplate.ACCOUNT_DELETED, userId, user,
+ user.greeting(), MailTemplate.date(user.language(), deletedFrom)));
+ }
+
+ private void insertForUser(MailTemplate template, long userId, Recipient user, Object... args) {
+ repository.insert(template, userId, user.email(), template.render(user.language(), args));
+ }
+}
diff --git a/src/main/java/auth_service/shared/mail/AccountMailer.java b/src/main/java/auth_service/shared/mail/AccountMailer.java
new file mode 100644
index 0000000..0eb571d
--- /dev/null
+++ b/src/main/java/auth_service/shared/mail/AccountMailer.java
@@ -0,0 +1,22 @@
+package auth_service.shared.mail;
+
+import java.time.LocalDate;
+
+/**
+ * Port for the mails about an account. Each call writes the mail in the caller's transaction; the mail job sends it
+ * later, so a call never fails because of the mail server.
+ */
+public interface AccountMailer {
+
+ /**
+ * "New user registered" to the admin and "your account is active" to the user.
+ */
+ void userRegistered(long userId);
+
+ void accountBlocked(long userId);
+
+ /**
+ * @param deletedFrom the first day the nightly cleanup may remove the account
+ */
+ void accountDeleted(long userId, LocalDate deletedFrom);
+}
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 0821ac8..6d3bd9f 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -18,6 +18,16 @@ spring:
threads:
virtual:
enabled: true
+ # Host, user and password only from the env (SPRING_MAIL_HOST, SPRING_MAIL_USERNAME, SPRING_MAIL_PASSWORD).
+ # No host = mail off. The values fit Hostinger: SMTP over SSL on port 465.
+ mail:
+ port: 465
+ properties:
+ "[mail.smtp.auth]": true
+ "[mail.smtp.ssl.enable]": true
+ "[mail.smtp.connectiontimeout]": 10000
+ "[mail.smtp.timeout]": 10000
+ "[mail.smtp.writetimeout]": 10000
# Actuator & Micrometer Settings
@@ -29,6 +39,10 @@ management:
endpoint:
health:
show-details: never
+ health:
+ mail:
+ # A down mail server must not make Auth unhealthy: the outbox waits and retries.
+ enabled: false
# Security stuff
security:
@@ -63,6 +77,12 @@ app:
retention: 30d
# Log only, delete nothing. See the Deployment README before flipping it.
dry-run: ${APP_ACCOUNT_CLEANUP_DRY_RUN:true}
+ mail:
+ # The mailbox the SMTP login belongs to; Hostinger refuses any other sender.
+ from: ${APP_MAIL_FROM:}
+ # Gets "new user registered". Only in the env file, never in git.
+ admin-to: ${APP_MAIL_ADMIN_TO:}
+ cron: "0 * * * * *"
# Registration settings
auth:
diff --git a/src/main/resources/db/migration/V21__Mail_Outbox.sql b/src/main/resources/db/migration/V21__Mail_Outbox.sql
new file mode 100644
index 0000000..f450cc6
--- /dev/null
+++ b/src/main/resources/db/migration/V21__Mail_Outbox.sql
@@ -0,0 +1,17 @@
+-- One row per mail; the mail job sends PENDING rows and retries them. The rows hold an email and a name, so they go
+-- with the user at the purge (the admin row too: user_id is the new user).
+CREATE TABLE mail_outbox (
+ id BIGSERIAL PRIMARY KEY,
+ kind VARCHAR(32) NOT NULL,
+ user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+ to_address VARCHAR(254) NOT NULL,
+ subject VARCHAR(200) NOT NULL,
+ body TEXT NOT NULL,
+ status VARCHAR(10) NOT NULL DEFAULT 'PENDING' CHECK (status IN ('PENDING', 'SENT', 'FAILED')),
+ attempts INT NOT NULL DEFAULT 0,
+ next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ last_error VARCHAR(500),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ sent_at TIMESTAMPTZ
+);
+CREATE INDEX idx_mail_outbox_due ON mail_outbox (next_attempt_at) WHERE status = 'PENDING';
diff --git a/src/test/java/auth_service/modules/mail/model/MailTemplateTest.java b/src/test/java/auth_service/modules/mail/model/MailTemplateTest.java
new file mode 100644
index 0000000..7f5c01d
--- /dev/null
+++ b/src/test/java/auth_service/modules/mail/model/MailTemplateTest.java
@@ -0,0 +1,53 @@
+package auth_service.modules.mail.model;
+
+import auth_service.shared.test.AbstractUnitTest;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+import org.junit.jupiter.params.provider.NullAndEmptySource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.time.LocalDate;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class MailTemplateTest extends AbstractUnitTest {
+
+ @ParameterizedTest
+ @EnumSource(MailTemplate.class)
+ void render_FillsEveryPlaceholderInBothLanguages(MailTemplate template) {
+ for (String language : new String[]{"de", "en"}) {
+ Mail mail = template.render(language, "Alice", "alice@example.com");
+
+ assertThat(mail.subject()).isNotBlank().doesNotContain("%");
+ assertThat(mail.body()).contains("Alice").doesNotContain("%s");
+ }
+ }
+
+ @Test
+ void render_PicksGermanForDe() {
+ assertThat(MailTemplate.ACCOUNT_BLOCKED.render("de", "Alice").body()).startsWith("Hallo Alice,");
+ assertThat(MailTemplate.ACCOUNT_BLOCKED.render("en", "Alice").body()).startsWith("Hello Alice,");
+ }
+
+ @ParameterizedTest
+ @NullAndEmptySource
+ @ValueSource(strings = {"fr", "DE-de"})
+ void render_FallsBackToEnglish(String language) {
+ assertThat(MailTemplate.ACCOUNT_BLOCKED.render(language, "Alice").body()).startsWith("Hello Alice,");
+ }
+
+ @Test
+ void render_KeepsAPercentSignInANameAsText() {
+ assertThat(MailTemplate.ACCOUNT_BLOCKED.render("en", "100%s").body()).startsWith("Hello 100%s,");
+ }
+
+ @Test
+ void date_IsWrittenThePerLanguageWay() {
+ LocalDate date = LocalDate.of(2026, 11, 5);
+
+ assertThat(MailTemplate.date("de", date)).isEqualTo("05.11.2026");
+ assertThat(MailTemplate.date("en", date)).isEqualTo("5 November 2026");
+ assertThat(MailTemplate.date(null, date)).isEqualTo("5 November 2026");
+ }
+}
diff --git a/src/test/java/auth_service/modules/mail/repo/MailOutboxRepositoryTest.java b/src/test/java/auth_service/modules/mail/repo/MailOutboxRepositoryTest.java
new file mode 100644
index 0000000..e87db4f
--- /dev/null
+++ b/src/test/java/auth_service/modules/mail/repo/MailOutboxRepositoryTest.java
@@ -0,0 +1,129 @@
+package auth_service.modules.mail.repo;
+
+import auth_service.modules.mail.model.Mail;
+import auth_service.modules.mail.model.MailTemplate;
+import auth_service.modules.mail.model.OutboxRow;
+import auth_service.modules.mail.model.Recipient;
+import auth_service.shared.test.AbstractPersistenceTest;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+import java.time.Duration;
+import java.time.Instant;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class MailOutboxRepositoryTest extends AbstractPersistenceTest {
+
+ @Autowired
+ private MailOutboxRepository repository;
+
+ @Autowired
+ private JdbcTemplate jdbc;
+
+ private final Instant now = Instant.now();
+
+ private long userId;
+
+ @BeforeEach
+ void clean() {
+ jdbc.update("DELETE FROM mail_outbox");
+ jdbc.update("DELETE FROM users WHERE email LIKE 'mail-repo%'");
+ userId = jdbc.queryForObject("INSERT INTO users (email, password, enabled) "
+ + "VALUES ('mail-repo-user@example.com', 'x', true) RETURNING id", Long.class);
+ }
+
+ private long insert() {
+ repository.insert(MailTemplate.ACCOUNT_BLOCKED, userId, "alice@example.com", new Mail("Subject", "Body"));
+ return jdbc.queryForObject("SELECT MAX(id) FROM mail_outbox", Long.class);
+ }
+
+ private String status(long id) {
+ return jdbc.queryForObject("SELECT status FROM mail_outbox WHERE id = ?", String.class, id);
+ }
+
+ @Test
+ void findDue_ReturnsANewRow() {
+ long id = insert();
+
+ assertThat(repository.findDue(now.plusSeconds(1), 10))
+ .containsExactly(new OutboxRow(id, "ACCOUNT_BLOCKED", "alice@example.com", "Subject", "Body", 0));
+ }
+
+ @Test
+ void findDue_KeepsTheLimitAndTheOrder() {
+ long first = insert();
+ insert();
+
+ assertThat(repository.findDue(now.plusSeconds(1), 1)).extracting(OutboxRow::id).containsExactly(first);
+ }
+
+ @Test
+ void markRetry_HidesTheRowUntilItsTime() {
+ long id = insert();
+
+ repository.markRetry(id, 1, now.plus(Duration.ofMinutes(5)), "refused");
+
+ assertThat(repository.findDue(now.plusSeconds(1), 10)).isEmpty();
+ assertThat(repository.findDue(now.plus(Duration.ofMinutes(6)), 10)).extracting(OutboxRow::attempts).containsExactly(1);
+ assertThat(jdbc.queryForObject("SELECT last_error FROM mail_outbox WHERE id = ?", String.class, id)).isEqualTo("refused");
+ }
+
+ @Test
+ void markSentAndMarkFailed_EndTheRow() {
+ long sent = insert();
+ long failed = insert();
+
+ repository.markSent(sent, now);
+ repository.markFailed(failed, 10, "rejected");
+
+ assertThat(repository.findDue(now.plus(Duration.ofDays(1)), 10)).isEmpty();
+ assertThat(status(sent)).isEqualTo("SENT");
+ assertThat(status(failed)).isEqualTo("FAILED");
+ }
+
+ @Test
+ void markRetry_CutsALongError() {
+ long id = insert();
+
+ repository.markRetry(id, 1, now, "x".repeat(2000));
+
+ assertThat(jdbc.queryForObject("SELECT LENGTH(last_error) FROM mail_outbox WHERE id = ?", Integer.class, id))
+ .isEqualTo(500);
+ }
+
+ @Test
+ void deleteSentBefore_RemovesOnlyOldSentRows() {
+ long oldSent = insert();
+ long newSent = insert();
+ long failed = insert();
+ repository.markSent(oldSent, now.minus(Duration.ofDays(40)));
+ repository.markSent(newSent, now);
+ repository.markFailed(failed, 10, "rejected");
+
+ repository.deleteSentBefore(now.minus(Duration.ofDays(30)));
+
+ assertThat(jdbc.queryForList("SELECT id FROM mail_outbox ORDER BY id", Long.class)).containsExactly(newSent, failed);
+ }
+
+ @Test
+ void thePurgeOfAUser_RemovesTheirRows() {
+ insert();
+
+ jdbc.update("DELETE FROM users WHERE id = ?", userId);
+
+ assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM mail_outbox", Integer.class)).isZero();
+ }
+
+ @Test
+ void findRecipient_ReadsEmailNameAndLanguage() {
+ long id = jdbc.queryForObject("INSERT INTO users (email, password, enabled, display_name) "
+ + "VALUES ('mail-repo@example.com', 'x', true, 'MailRepo') RETURNING id", Long.class);
+ jdbc.update("INSERT INTO user_settings (user_id, language) VALUES (?, 'de')", id);
+
+ assertThat(repository.findRecipient(id)).contains(new Recipient("mail-repo@example.com", "MailRepo", "de"));
+ assertThat(repository.findRecipient(-1)).isEmpty();
+ }
+}
diff --git a/src/test/java/auth_service/modules/mail/service/MailOutboxServiceTest.java b/src/test/java/auth_service/modules/mail/service/MailOutboxServiceTest.java
new file mode 100644
index 0000000..0dcbc6e
--- /dev/null
+++ b/src/test/java/auth_service/modules/mail/service/MailOutboxServiceTest.java
@@ -0,0 +1,142 @@
+package auth_service.modules.mail.service;
+
+import auth_service.modules.mail.model.OutboxRow;
+import auth_service.modules.mail.repo.MailOutboxRepository;
+import auth_service.shared.test.AbstractUnitTest;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.mail.MailSendException;
+import org.springframework.mail.SimpleMailMessage;
+import org.springframework.mail.javamail.JavaMailSender;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+class MailOutboxServiceTest extends AbstractUnitTest {
+
+ private static final String FROM = "info@example.com";
+ private static final String ADMIN = "admin@example.com";
+
+ @Mock
+ private MailOutboxRepository repository;
+
+ @Mock
+ private JavaMailSender sender;
+
+ @Mock
+ private ObjectProvider senderProvider;
+
+ private MailOutboxService service() {
+ when(senderProvider.getObject()).thenReturn(sender);
+ return new MailOutboxService(repository, senderProvider, "smtp.example.com", FROM, ADMIN);
+ }
+
+ private static OutboxRow row(long id, int attempts) {
+ return new OutboxRow(id, "ACCOUNT_BLOCKED", "alice@example.com", "Subject", "Body", attempts);
+ }
+
+ @Test
+ void sendDue_SendsADueRowFromTheMailboxAndMarksItSent() {
+ when(repository.findDue(any(), anyInt())).thenReturn(List.of(row(1, 0)));
+
+ service().sendDue();
+
+ ArgumentCaptor message = ArgumentCaptor.forClass(SimpleMailMessage.class);
+ verify(sender).send(message.capture());
+ assertThat(message.getValue().getFrom()).isEqualTo(FROM);
+ assertThat(message.getValue().getTo()).containsExactly("alice@example.com");
+ assertThat(message.getValue().getSubject()).isEqualTo("Subject");
+ assertThat(message.getValue().getText()).isEqualTo("Body");
+ verify(repository).markSent(eq(1L), any());
+ }
+
+ @Test
+ void sendDue_SchedulesARetryAfterAFailure() {
+ when(repository.findDue(any(), anyInt())).thenReturn(List.of(row(1, 2)));
+ doThrow(new MailSendException("connection refused")).when(sender).send(any(SimpleMailMessage.class));
+ Instant before = Instant.now();
+
+ service().sendDue();
+
+ ArgumentCaptor next = ArgumentCaptor.forClass(Instant.class);
+ verify(repository).markRetry(eq(1L), eq(3), next.capture(), eq("connection refused"));
+ assertThat(next.getValue()).isAfterOrEqualTo(before.plus(Duration.ofMinutes(4)));
+ verify(repository, never()).markSent(anyLong(), any());
+ }
+
+ @Test
+ void sendDue_GivesUpAfterTheLastTry() {
+ when(repository.findDue(any(), anyInt())).thenReturn(List.of(row(1, MailOutboxService.MAX_ATTEMPTS - 1)));
+ doThrow(new MailSendException("rejected")).when(sender).send(any(SimpleMailMessage.class));
+
+ service().sendDue();
+
+ verify(repository).markFailed(1L, MailOutboxService.MAX_ATTEMPTS, "rejected");
+ verify(repository, never()).markRetry(anyLong(), anyInt(), any(), any());
+ }
+
+ @Test
+ void sendDue_GoesOnWithTheNextRowAfterAFailure() {
+ when(repository.findDue(any(), anyInt())).thenReturn(List.of(row(1, 0), row(2, 0)));
+ doThrow(new MailSendException("rejected")).doNothing().when(sender).send(any(SimpleMailMessage.class));
+
+ service().sendDue();
+
+ verify(repository).markRetry(eq(1L), eq(1), any(), eq("rejected"));
+ verify(repository).markSent(eq(2L), any());
+ }
+
+ @Test
+ void sendDue_RemovesOldSentRows() {
+ when(repository.findDue(any(), anyInt())).thenReturn(List.of());
+ Instant before = Instant.now();
+
+ service().sendDue();
+
+ ArgumentCaptor cutoff = ArgumentCaptor.forClass(Instant.class);
+ verify(repository).deleteSentBefore(cutoff.capture());
+ assertThat(cutoff.getValue()).isBeforeOrEqualTo(Instant.now().minus(MailOutboxService.KEEP_SENT))
+ .isAfterOrEqualTo(before.minus(MailOutboxService.KEEP_SENT));
+ }
+
+ @Test
+ void backoff_DoublesUpToAnHour() {
+ assertThat(MailOutboxService.backoff(1)).isEqualTo(Duration.ofMinutes(1));
+ assertThat(MailOutboxService.backoff(2)).isEqualTo(Duration.ofMinutes(2));
+ assertThat(MailOutboxService.backoff(6)).isEqualTo(Duration.ofMinutes(32));
+ assertThat(MailOutboxService.backoff(7)).isEqualTo(Duration.ofHours(1));
+ assertThat(MailOutboxService.backoff(40)).isEqualTo(Duration.ofHours(1));
+ }
+
+ @Test
+ void sendDue_DoesNothingWithoutAHost() {
+ MailOutboxService off = new MailOutboxService(repository, senderProvider, "", "", "");
+
+ off.sendDue();
+
+ verifyNoInteractions(repository, senderProvider);
+ }
+
+ @Test
+ void constructor_RefusesAHostWithoutSenderOrAdminAddress() {
+ assertThatThrownBy(() -> new MailOutboxService(repository, senderProvider, "smtp.example.com", "", ADMIN))
+ .isInstanceOf(IllegalStateException.class).hasMessageContaining("APP_MAIL_FROM");
+ assertThatThrownBy(() -> new MailOutboxService(repository, senderProvider, "smtp.example.com", FROM, " "))
+ .isInstanceOf(IllegalStateException.class).hasMessageContaining("APP_MAIL_ADMIN_TO");
+ }
+}
diff --git a/src/test/java/auth_service/modules/mail/service/MailServiceTest.java b/src/test/java/auth_service/modules/mail/service/MailServiceTest.java
new file mode 100644
index 0000000..699ce8f
--- /dev/null
+++ b/src/test/java/auth_service/modules/mail/service/MailServiceTest.java
@@ -0,0 +1,104 @@
+package auth_service.modules.mail.service;
+
+import auth_service.modules.mail.model.Mail;
+import auth_service.modules.mail.model.MailTemplate;
+import auth_service.modules.mail.model.Recipient;
+import auth_service.modules.mail.repo.MailOutboxRepository;
+import auth_service.shared.test.AbstractUnitTest;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+
+import java.time.LocalDate;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class MailServiceTest extends AbstractUnitTest {
+
+ private static final long USER = 7L;
+ private static final String ADMIN = "admin@example.com";
+
+ @Mock
+ private MailOutboxRepository repository;
+
+ private MailService service(String adminTo) {
+ return new MailService(repository, adminTo, "https://tbp.example");
+ }
+
+ private void user(String displayName, String language) {
+ when(repository.findRecipient(USER)).thenReturn(Optional.of(new Recipient("alice@example.com", displayName, language)));
+ }
+
+ private Mail inserted(MailTemplate template, String to) {
+ ArgumentCaptor mail = ArgumentCaptor.forClass(Mail.class);
+ verify(repository).insert(eq(template), eq(USER), eq(to), mail.capture());
+ return mail.getValue();
+ }
+
+ @Test
+ void userRegistered_TellsTheAdminInGermanAndTheUserInTheirLanguage() {
+ user("Alice", "en");
+
+ service(ADMIN).userRegistered(USER);
+
+ assertThat(inserted(MailTemplate.NEW_USER_REGISTERED, ADMIN).body())
+ .startsWith("Ein neuer Nutzer").contains("Alice", "alice@example.com");
+ assertThat(inserted(MailTemplate.ACCOUNT_ACTIVE, "alice@example.com").body())
+ .startsWith("Hello Alice,").contains("https://tbp.example");
+ }
+
+ @Test
+ void userRegistered_GreetsByEmailWithoutADisplayName() {
+ user(null, "de");
+
+ service(ADMIN).userRegistered(USER);
+
+ assertThat(inserted(MailTemplate.ACCOUNT_ACTIVE, "alice@example.com").body()).startsWith("Hallo alice@example.com,");
+ assertThat(inserted(MailTemplate.NEW_USER_REGISTERED, ADMIN).body()).contains("Name: -");
+ }
+
+ @Test
+ void userRegistered_WritesNoAdminRowWithoutAnAdminAddress() {
+ user("Alice", "en");
+
+ service("").userRegistered(USER);
+
+ verify(repository, never()).insert(eq(MailTemplate.NEW_USER_REGISTERED), anyLong(), anyString(), any());
+ inserted(MailTemplate.ACCOUNT_ACTIVE, "alice@example.com");
+ }
+
+ @Test
+ void accountBlocked_WritesOneRow() {
+ user("Alice", "de");
+
+ service(ADMIN).accountBlocked(USER);
+
+ assertThat(inserted(MailTemplate.ACCOUNT_BLOCKED, "alice@example.com").subject()).contains("gesperrt");
+ }
+
+ @Test
+ void accountDeleted_NamesTheDateInTheUserLanguage() {
+ user("Alice", "de");
+
+ service(ADMIN).accountDeleted(USER, LocalDate.of(2026, 11, 5));
+
+ assertThat(inserted(MailTemplate.ACCOUNT_DELETED, "alice@example.com").body()).contains("ab dem 05.11.2026");
+ }
+
+ @Test
+ void anUnknownUserGetsNoMail() {
+ when(repository.findRecipient(USER)).thenReturn(Optional.empty());
+
+ service(ADMIN).accountBlocked(USER);
+
+ verify(repository, never()).insert(any(), anyLong(), anyString(), any());
+ }
+}
diff --git a/src/test/java/auth_service/shared/test/AbstractIntegrationTest.java b/src/test/java/auth_service/shared/test/AbstractIntegrationTest.java
index e5c99e2..fe99d49 100644
--- a/src/test/java/auth_service/shared/test/AbstractIntegrationTest.java
+++ b/src/test/java/auth_service/shared/test/AbstractIntegrationTest.java
@@ -8,9 +8,11 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.testcontainers.containers.PostgreSQLContainer;
import java.security.KeyPair;
@@ -25,11 +27,16 @@ import java.util.Date;
*/
@Tag("IntegrationTest")
@AutoConfigureMockMvc
-// The account cleanup must not run by itself, and Core is never reachable in tests.
+// The account cleanup and the mail job must not run on their own, and Core is never reachable in tests. Mail is
+// on, with a mocked sender.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
- properties = {"app.account-cleanup.cron=-", "app.core.internal-url=http://127.0.0.1:1"})
+ properties = {"app.account-cleanup.cron=-", "app.core.internal-url=http://127.0.0.1:1", "app.mail.cron=-",
+ "spring.mail.host=localhost", "app.mail.from=noreply@test.local", "app.mail.admin-to=admin@test.local"})
public abstract class AbstractIntegrationTest {
+ @MockitoBean
+ protected JavaMailSender mailSender;
+
@ServiceConnection
static PostgreSQLContainer> postgres = TestPostgres.CONTAINER;