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

@@ -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.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;