Public Access
feat(mail): outbox, SMTP sender and config
This commit is contained in:
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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user