feat(mail): outbox, SMTP sender and config

This commit is contained in:
lobo committed 2026-10-01 08:30:24 +02:00
1 parent fd79c63441
commit 47a03d2e6a
17 files changed
+857 -2

No files matched your search

+4
View File
@@ -40,6 +40,10 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId> <artifactId>spring-boot-starter-cache</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<!-- Rest Caching --> <!-- Rest Caching -->
<dependency> <dependency>
<groupId>com.github.ben-manes.caffeine</groupId> <groupId>com.github.ben-manes.caffeine</groupId>
+6
View File
@@ -148,6 +148,9 @@ the login module writes "view as" to the audit table through `shared/audit/Admin
use each other. use each other.
`hint` (dashboard hints and their admin API) uses `JdbcTemplate` in a `repo` package too, and audits through `hint` (dashboard hints and their admin API) uses `JdbcTemplate` in a `repo` package too, and audits through
`shared/audit/AdminAuditLog`. `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 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 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_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_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`) | | `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: Generate a keypair:
@@ -0,0 +1,4 @@
package auth_service.modules.mail.model;
public record Mail(String subject, String body) {
}
@@ -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);
}
}
@@ -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) {
}
@@ -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;
}
}
@@ -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<Recipient> 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<OutboxRow> 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);
}
}
@@ -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<JavaMailSender> 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));
}
}
@@ -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));
}
}
@@ -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);
}
+20
View File
@@ -18,6 +18,16 @@ spring:
threads: threads:
virtual: virtual:
enabled: true 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 # Actuator & Micrometer Settings
@@ -29,6 +39,10 @@ management:
endpoint: endpoint:
health: health:
show-details: never show-details: never
health:
mail:
# A down mail server must not make Auth unhealthy: the outbox waits and retries.
enabled: false
# Security stuff # Security stuff
security: security:
@@ -63,6 +77,12 @@ app:
retention: 30d retention: 30d
# Log only, delete nothing. See the Deployment README before flipping it. # Log only, delete nothing. See the Deployment README before flipping it.
dry-run: ${APP_ACCOUNT_CLEANUP_DRY_RUN:true} 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 # Registration settings
auth: auth:
@@ -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';
@@ -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");
}
}
@@ -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();
}
}
@@ -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<JavaMailSender> 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<SimpleMailMessage> 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<Instant> 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<Instant> 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");
}
}
@@ -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> 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());
}
}
@@ -8,9 +8,11 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection; import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.containers.PostgreSQLContainer;
import java.security.KeyPair; import java.security.KeyPair;
@@ -25,11 +27,16 @@ import java.util.Date;
*/ */
@Tag("IntegrationTest") @Tag("IntegrationTest")
@AutoConfigureMockMvc @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, @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 { public abstract class AbstractIntegrationTest {
@MockitoBean
protected JavaMailSender mailSender;
@ServiceConnection @ServiceConnection
static PostgreSQLContainer<?> postgres = TestPostgres.CONTAINER; static PostgreSQLContainer<?> postgres = TestPostgres.CONTAINER;