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
@@ -40,6 +40,10 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-cache</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-mail</artifactId>
|
||||
</dependency>
|
||||
<!-- Rest Caching -->
|
||||
<dependency>
|
||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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.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