From 47a03d2e6ae03e9b950fe292f5f1b89f8a6dce35 Mon Sep 17 00:00:00 2001 From: lobo Date: Thu, 1 Oct 2026 08:30:24 +0200 Subject: [PATCH] feat(mail): outbox, SMTP sender and config --- pom.xml | 4 + readme.md | 6 + .../auth_service/modules/mail/model/Mail.java | 4 + .../modules/mail/model/MailTemplate.java | 106 +++++++++++++ .../modules/mail/model/OutboxRow.java | 4 + .../modules/mail/model/Recipient.java | 11 ++ .../mail/repo/MailOutboxRepository.java | 72 +++++++++ .../mail/service/MailOutboxService.java | 94 ++++++++++++ .../modules/mail/service/MailService.java | 60 ++++++++ .../shared/mail/AccountMailer.java | 22 +++ src/main/resources/application.yml | 20 +++ .../db/migration/V21__Mail_Outbox.sql | 17 +++ .../modules/mail/model/MailTemplateTest.java | 53 +++++++ .../mail/repo/MailOutboxRepositoryTest.java | 129 ++++++++++++++++ .../mail/service/MailOutboxServiceTest.java | 142 ++++++++++++++++++ .../modules/mail/service/MailServiceTest.java | 104 +++++++++++++ .../shared/test/AbstractIntegrationTest.java | 11 +- 17 files changed, 857 insertions(+), 2 deletions(-) create mode 100644 src/main/java/auth_service/modules/mail/model/Mail.java create mode 100644 src/main/java/auth_service/modules/mail/model/MailTemplate.java create mode 100644 src/main/java/auth_service/modules/mail/model/OutboxRow.java create mode 100644 src/main/java/auth_service/modules/mail/model/Recipient.java create mode 100644 src/main/java/auth_service/modules/mail/repo/MailOutboxRepository.java create mode 100644 src/main/java/auth_service/modules/mail/service/MailOutboxService.java create mode 100644 src/main/java/auth_service/modules/mail/service/MailService.java create mode 100644 src/main/java/auth_service/shared/mail/AccountMailer.java create mode 100644 src/main/resources/db/migration/V21__Mail_Outbox.sql create mode 100644 src/test/java/auth_service/modules/mail/model/MailTemplateTest.java create mode 100644 src/test/java/auth_service/modules/mail/repo/MailOutboxRepositoryTest.java create mode 100644 src/test/java/auth_service/modules/mail/service/MailOutboxServiceTest.java create mode 100644 src/test/java/auth_service/modules/mail/service/MailServiceTest.java 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;