Public Access
feat(mail): add broadcast mail for admins
Adds endpoints for PLATFORM_ADMIN to send mail to all active users or picked ones. Broadcasts and account mails share the same outbox; broadcasts take the rest of the daily limit after 10 mails are kept free for account mail. Users can opt out via settings. History is kept in mail_broadcast with recipient, sent and failed counts. The footer says where to opt out.
This commit is contained in:
1 parent
e72222a37f
commit
a60011ce9f
36 files changed
+1027
-20
No files matched your search
@@ -86,6 +86,13 @@ To Be Played is split into three repositories:
|
|||||||
to the admin page), and to the user "we got your registration", then "your account is active" or "your
|
to the admin page), and to the user "we got your registration", then "your account is active" or "your
|
||||||
registration was denied", later "your account is blocked" and "your account will be deleted from <date>". The registration sends the browser language (`de` for a German browser, else `en`), which also becomes
|
registration was denied", later "your account is blocked" and "your account will be deleted from <date>". The registration sends the browser language (`de` for a German browser, else `en`), which also becomes
|
||||||
the user's app language. Plain text only. See "Mail flow".
|
the user's app language. Plain text only. See "Mail flow".
|
||||||
|
- **Broadcast mail**: a PLATFORM_ADMIN writes one mail with subject and text in English and German (plain text),
|
||||||
|
and sends it to all active users or to picked ones, each in their app language. A user can turn it off
|
||||||
|
(`user_settings.platform_mails`, default on); the footer of the mail says where. "Send test to me" mails the draft
|
||||||
|
to the admin only, in the admin's language. One `mail_broadcast` row keeps the texts and the counts (recipients,
|
||||||
|
sent, failed); `BROADCAST_SEND` goes to `admin_audit`. The sender takes account mails first and stays under
|
||||||
|
`APP_MAIL_DAILY_LIMIT` per 24 h, 10 of them kept for account mails. A recipient who is blocked, deleted or opted
|
||||||
|
out before the send counts as failed.
|
||||||
- **Delete my account** with the password (5 wrong tries per 15 minutes, then `429`). It only sets
|
- **Delete my account** with the password (5 wrong tries per 15 minutes, then `429`). It only sets
|
||||||
`users.deleted_at` and revokes every refresh token: from then on no login, no refresh, no access token, no public
|
`users.deleted_at` and revokes every refresh token: from then on no login, no refresh, no access token, no public
|
||||||
profile, no Steam link, not in the landing stats. Email and display name stay taken; the Steam ID is freed, so an
|
profile, no Steam link, not in the landing stats. Email and display name stay taken; the Steam ID is freed, so an
|
||||||
@@ -128,6 +135,11 @@ To Be Played is split into three repositories:
|
|||||||
| `PUT` | `/api/users/settings/profile-style` (`{accent, corners, bannerAppid, backgroundAppid}`; `204`) | access token |
|
| `PUT` | `/api/users/settings/profile-style` (`{accent, corners, bannerAppid, backgroundAppid}`; `204`) | access token |
|
||||||
| `PUT` | `/api/users/settings/dashboard-tiles` (`{tiles: [{id, size}]}` in order, at most 20; `{tiles: null}` resets; `204`). Auth checks only the id shape `[A-Z_]{1,30}`: Core and the Frontend skip ids they do not know, and Core never shares the overview or the activity | access token |
|
| `PUT` | `/api/users/settings/dashboard-tiles` (`{tiles: [{id, size}]}` in order, at most 20; `{tiles: null}` resets; `204`). Auth checks only the id shape `[A-Z_]{1,30}`: Core and the Frontend skip ids they do not know, and Core never shares the overview or the activity | access token |
|
||||||
| `PUT` | `/api/users/settings/welcome-seen` (`204`; stamps `user_settings.welcome_seen_at`, sent as `settings.welcomeSeenAt` in `/me`, null until then) | access token |
|
| `PUT` | `/api/users/settings/welcome-seen` (`204`; stamps `user_settings.welcome_seen_at`, sent as `settings.welcomeSeenAt` in `/me`, null until then) | access token |
|
||||||
|
| `PUT` | `/api/users/settings/platform-mails` (`{enabled}`; `204`) | access token |
|
||||||
|
| `GET` | `/api/admin/broadcasts` (`{results}` with the counts, newest first) | PLATFORM_ADMIN |
|
||||||
|
| `GET` | `/api/admin/broadcasts/recipient-count?userIds=` (`{count}`; no `userIds` = all) | PLATFORM_ADMIN |
|
||||||
|
| `POST` | `/api/admin/broadcasts` (`{subjectEn, subjectDe, bodyEn, bodyDe, userIds}`; `201 {recipientCount}`; `409 NO_RECIPIENTS`) | PLATFORM_ADMIN |
|
||||||
|
| `POST` | `/api/admin/broadcasts/test` (same body; `204`) | PLATFORM_ADMIN |
|
||||||
| `PUT` | `/api/users/settings/favorite-platform` (`{platform}`, free text up to 32, e.g. `PS5`; `null` clears it; `204`) | access token |
|
| `PUT` | `/api/users/settings/favorite-platform` (`{platform}`, free text up to 32, e.g. `PS5`; `null` clears it; `204`) | access token |
|
||||||
| `PUT` | `/api/users/settings/profile-tiles` (`{tiles: [{id, size}]}`, e.g. `SUMMARY`, `STATUS`, `PLAYING`, `FINISHED`, `STATS_<GROUP>`; `204`) | access token |
|
| `PUT` | `/api/users/settings/profile-tiles` (`{tiles: [{id, size}]}`, e.g. `SUMMARY`, `STATUS`, `PLAYING`, `FINISHED`, `STATS_<GROUP>`; `204`) | access token |
|
||||||
| `GET` | `/api/public/profiles/{displayName}` | public |
|
| `GET` | `/api/public/profiles/{displayName}` | public |
|
||||||
@@ -179,6 +191,8 @@ use each other.
|
|||||||
`mail` writes each mail as a finished text into `mail_outbox` (`JdbcTemplate`, `repo`), in the transaction of the
|
`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
|
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.
|
`shared/mail/AccountMailer`. Without `SPRING_MAIL_HOST` mail is off: the rows wait and go out once a host is set.
|
||||||
|
Broadcasts (`BroadcastService`, `mail_broadcast`) queue one row per recipient; the same job sends them after the
|
||||||
|
account mails and under the daily limit.
|
||||||
|
|
||||||
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
|
||||||
@@ -277,10 +291,12 @@ sequenceDiagram
|
|||||||
participant B as Browser (Frontend)
|
participant B as Browser (Frontend)
|
||||||
participant A as Auth
|
participant A as Auth
|
||||||
participant M as SMTP (Hostinger)
|
participant M as SMTP (Hostinger)
|
||||||
|
B->>A: admin sends a broadcast
|
||||||
|
A->>A: mail_broadcast row + one outbox row per active, opted-in user
|
||||||
B->>A: register / admin decides / admin blocks / delete account
|
B->>A: register / admin decides / admin blocks / delete account
|
||||||
A->>A: event + mail_outbox row (PENDING), one transaction
|
A->>A: event + mail_outbox row (PENDING), one transaction
|
||||||
loop every minute (MailOutboxService)
|
loop every minute (MailOutboxService)
|
||||||
A->>M: up to 20 due rows
|
A->>M: up to 20 due rows (account mails first, at most APP_MAIL_DAILY_LIMIT per 24 h, broadcast rows keep 10 free)
|
||||||
M-->>A: ok: SENT / error: try again after 1, 2, 4 ... 60 min
|
M-->>A: ok: SENT / error: try again after 1, 2, 4 ... 60 min
|
||||||
end
|
end
|
||||||
Note over A: after 10 tries FAILED and a warning in the log; SENT rows are removed after 30 days
|
Note over A: after 10 tries FAILED and a warning in the log; SENT rows are removed after 30 days
|
||||||
@@ -381,6 +397,7 @@ Configuration comes from environment variables:
|
|||||||
| `SPRING_MAIL_HOST`, `SPRING_MAIL_USERNAME`, `SPRING_MAIL_PASSWORD` | SMTP server and its login (port 465, SSL). No host = mail off |
|
| `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_FROM` | Sender address, the mailbox of the SMTP login. Needed with a host |
|
||||||
| `APP_MAIL_ADMIN_TO` | Gets "a new registration waits". Needed with a host |
|
| `APP_MAIL_ADMIN_TO` | Gets "a new registration waits". Needed with a host |
|
||||||
|
| `APP_MAIL_DAILY_LIMIT` | Mails per rolling 24 h, all kinds together (default 100, Hostinger's free mailbox; 1000 Business Starter, 3000 Business Standard / Premium). Broadcast rows keep 10 free |
|
||||||
|
|
||||||
Generate a keypair:
|
Generate a keypair:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package auth_service.modules.mail.api;
|
||||||
|
|
||||||
|
import auth_service.modules.mail.api.dto.request.BroadcastRequest;
|
||||||
|
import auth_service.modules.mail.api.dto.response.BroadcastListResponse;
|
||||||
|
import auth_service.modules.mail.api.dto.response.BroadcastResponse;
|
||||||
|
import auth_service.modules.mail.api.dto.response.RecipientCountResponse;
|
||||||
|
import auth_service.modules.mail.api.dto.response.SentBroadcastResponse;
|
||||||
|
import auth_service.modules.mail.model.Broadcast;
|
||||||
|
import auth_service.modules.mail.model.BroadcastText;
|
||||||
|
import auth_service.modules.mail.service.BroadcastService;
|
||||||
|
import auth_service.shared.security.UserPrincipal;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin/broadcasts")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AdminBroadcastController {
|
||||||
|
|
||||||
|
private final BroadcastService broadcastService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<BroadcastListResponse> findAll() {
|
||||||
|
return ResponseEntity.ok(new BroadcastListResponse(broadcastService.findAll().stream()
|
||||||
|
.map(AdminBroadcastController::toApi).toList()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/recipient-count")
|
||||||
|
public ResponseEntity<RecipientCountResponse> recipientCount(@RequestParam(required = false) List<Long> userIds) {
|
||||||
|
return ResponseEntity.ok(new RecipientCountResponse(broadcastService.countRecipients(userIds)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<SentBroadcastResponse> send(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
|
@Valid @RequestBody BroadcastRequest request) {
|
||||||
|
int queued = broadcastService.send(principal.getIdAsLong(), toText(request), request.userIds());
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(new SentBroadcastResponse(queued));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/test")
|
||||||
|
public ResponseEntity<Void> sendTest(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
|
@Valid @RequestBody BroadcastRequest request) {
|
||||||
|
broadcastService.sendTest(principal.getIdAsLong(), toText(request));
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BroadcastText toText(BroadcastRequest request) {
|
||||||
|
return new BroadcastText(request.subjectEn().strip(), request.subjectDe().strip(),
|
||||||
|
request.bodyEn().strip(), request.bodyDe().strip());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BroadcastResponse toApi(Broadcast broadcast) {
|
||||||
|
BroadcastText text = broadcast.text();
|
||||||
|
return new BroadcastResponse(broadcast.id(), text.subjectEn(), text.subjectDe(), text.bodyEn(), text.bodyDe(),
|
||||||
|
broadcast.createdBy(), broadcast.createdAt(), broadcast.recipientCount(), broadcast.sentCount(),
|
||||||
|
broadcast.failedCount());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package auth_service.modules.mail.api;
|
||||||
|
|
||||||
|
import auth_service.modules.mail.exception.NoRecipientsException;
|
||||||
|
import auth_service.shared.api.dto.response.ErrorResponse;
|
||||||
|
import org.springframework.core.annotation.Order;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
|
||||||
|
@RestControllerAdvice(assignableTypes = AdminBroadcastController.class)
|
||||||
|
@Order(value = 1)
|
||||||
|
public class BroadcastExceptionController {
|
||||||
|
|
||||||
|
@ExceptionHandler(NoRecipientsException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> conflict(NoRecipientsException ex) {
|
||||||
|
return ResponseEntity.status(HttpStatus.CONFLICT).contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(new ErrorResponse("NO_RECIPIENTS", ex.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package auth_service.modules.mail.api.dto.request;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plain text, never HTML.
|
||||||
|
*
|
||||||
|
* @param userIds null = every active user with platform mails on; the test mail ignores it
|
||||||
|
*/
|
||||||
|
public record BroadcastRequest(@NotBlank @Size(max = 150) String subjectEn, @NotBlank @Size(max = 150) String subjectDe,
|
||||||
|
@NotBlank @Size(max = 5000) String bodyEn, @NotBlank @Size(max = 5000) String bodyDe,
|
||||||
|
List<Long> userIds) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package auth_service.modules.mail.api.dto.response;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record BroadcastListResponse(List<BroadcastResponse> results) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package auth_service.modules.mail.api.dto.response;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
public record BroadcastResponse(long id, String subjectEn, String subjectDe, String bodyEn, String bodyDe,
|
||||||
|
long createdBy, Instant createdAt, int recipientCount, int sentCount,
|
||||||
|
int failedCount) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package auth_service.modules.mail.api.dto.response;
|
||||||
|
|
||||||
|
public record RecipientCountResponse(long count) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package auth_service.modules.mail.api.dto.response;
|
||||||
|
|
||||||
|
public record SentBroadcastResponse(int recipientCount) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package auth_service.modules.mail.exception;
|
||||||
|
|
||||||
|
public class NoRecipientsException extends RuntimeException {
|
||||||
|
|
||||||
|
public NoRecipientsException() {
|
||||||
|
super("No active user with platform mails on among the recipients");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package auth_service.modules.mail.model;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
public record Broadcast(long id, BroadcastText text, long createdBy, Instant createdAt, int recipientCount,
|
||||||
|
int sentCount, int failedCount) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package auth_service.modules.mail.model;
|
||||||
|
|
||||||
|
public record BroadcastText(String subjectEn, String subjectDe, String bodyEn, String bodyDe) {
|
||||||
|
}
|
||||||
@@ -117,6 +117,23 @@ public enum MailTemplate {
|
|||||||
Alle deine Daten werden ab dem %s gelöscht.
|
Alle deine Daten werden ab dem %s gelöscht.
|
||||||
|
|
||||||
ToBePlayed
|
ToBePlayed
|
||||||
|
"""),
|
||||||
|
|
||||||
|
// Admin text only: %1$s subject, %2$s body, %3$s settings URL.
|
||||||
|
BROADCAST(
|
||||||
|
"%1$s", """
|
||||||
|
%2$s
|
||||||
|
|
||||||
|
--
|
||||||
|
You get this mail because you have an account on ToBePlayed. Turn these mails off in your settings:
|
||||||
|
%3$s
|
||||||
|
""",
|
||||||
|
"%1$s", """
|
||||||
|
%2$s
|
||||||
|
|
||||||
|
--
|
||||||
|
Du bekommst diese Mail, weil du ein Konto bei ToBePlayed hast. In den Einstellungen kannst du sie abschalten:
|
||||||
|
%3$s
|
||||||
""");
|
""");
|
||||||
|
|
||||||
private static final DateTimeFormatter DATE_DE = DateTimeFormatter.ofPattern("dd.MM.yyyy");
|
private static final DateTimeFormatter DATE_DE = DateTimeFormatter.ofPattern("dd.MM.yyyy");
|
||||||
@@ -135,14 +152,14 @@ public enum MailTemplate {
|
|||||||
*/
|
*/
|
||||||
public Mail render(String language, Object... args) {
|
public Mail render(String language, Object... args) {
|
||||||
Mail mail = isGerman(language) ? de : en;
|
Mail mail = isGerman(language) ? de : en;
|
||||||
return new Mail(mail.subject(), mail.body().formatted(args));
|
return new Mail(mail.subject().formatted(args), mail.body().formatted(args));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static String date(String language, LocalDate date) {
|
public static String date(String language, LocalDate date) {
|
||||||
return (isGerman(language) ? DATE_DE : DATE_EN).format(date);
|
return (isGerman(language) ? DATE_DE : DATE_EN).format(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean isGerman(String language) {
|
public static boolean isGerman(String language) {
|
||||||
return "de".equals(language);
|
return "de".equals(language);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
package auth_service.modules.mail.model;
|
package auth_service.modules.mail.model;
|
||||||
|
|
||||||
public record OutboxRow(long id, String kind, String toAddress, String subject, String body, int attempts) {
|
/**
|
||||||
|
* @param broadcastId null for an account mail
|
||||||
|
*/
|
||||||
|
public record OutboxRow(long id, String kind, String toAddress, String subject, String body, int attempts,
|
||||||
|
Long broadcastId) {
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package auth_service.modules.mail.repo;
|
||||||
|
|
||||||
|
import auth_service.modules.mail.model.Broadcast;
|
||||||
|
import auth_service.modules.mail.model.BroadcastText;
|
||||||
|
import auth_service.modules.mail.model.Mail;
|
||||||
|
import auth_service.shared.account.ActiveUser;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||||
|
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public class BroadcastRepository {
|
||||||
|
|
||||||
|
static final String RECIPIENTS = "FROM users u JOIN user_settings s ON s.user_id = u.id WHERE "
|
||||||
|
+ ActiveUser.of("u") + " AND s.platform_mails";
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
|
private final NamedParameterJdbcTemplate namedJdbc;
|
||||||
|
|
||||||
|
public BroadcastRepository(JdbcTemplate jdbc, NamedParameterJdbcTemplate namedJdbc) {
|
||||||
|
this.jdbc = jdbc;
|
||||||
|
this.namedJdbc = namedJdbc;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long countRecipients(List<Long> userIds) {
|
||||||
|
return Objects.requireNonNull(namedJdbc.queryForObject("SELECT COUNT(*) " + recipients(userIds),
|
||||||
|
ids(userIds), Long.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
public long insert(BroadcastText text, long createdBy, Instant createdAt) {
|
||||||
|
return Objects.requireNonNull(jdbc.queryForObject("""
|
||||||
|
INSERT INTO mail_broadcast (subject_en, subject_de, body_en, body_de, created_by, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?) RETURNING id""", Long.class, text.subjectEn(), text.subjectDe(),
|
||||||
|
text.bodyEn(), text.bodyDe(), createdBy, Timestamp.from(createdAt)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One outbox row per recipient, in the language of the user (English when none is set).
|
||||||
|
*
|
||||||
|
* @return the number of rows queued
|
||||||
|
*/
|
||||||
|
public int queue(long broadcastId, List<Long> userIds, Mail en, Mail de) {
|
||||||
|
return namedJdbc.update("""
|
||||||
|
INSERT INTO mail_outbox (kind, user_id, to_address, subject, body, broadcast_id)
|
||||||
|
SELECT 'BROADCAST', u.id, u.email, CASE WHEN s.language = 'de' THEN :subjectDe ELSE :subjectEn END,
|
||||||
|
CASE WHEN s.language = 'de' THEN :bodyDe ELSE :bodyEn END, :broadcastId
|
||||||
|
""" + recipients(userIds), ids(userIds)
|
||||||
|
.addValue("subjectEn", en.subject()).addValue("bodyEn", en.body())
|
||||||
|
.addValue("subjectDe", de.subject()).addValue("bodyDe", de.body())
|
||||||
|
.addValue("broadcastId", broadcastId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRecipientCount(long broadcastId, int count) {
|
||||||
|
jdbc.update("UPDATE mail_broadcast SET recipient_count = ? WHERE id = ?", count, broadcastId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Broadcast> findAll() {
|
||||||
|
return jdbc.query("SELECT * FROM mail_broadcast ORDER BY created_at DESC, id DESC",
|
||||||
|
(rs, row) -> new Broadcast(rs.getLong("id"),
|
||||||
|
new BroadcastText(rs.getString("subject_en"), rs.getString("subject_de"),
|
||||||
|
rs.getString("body_en"), rs.getString("body_de")),
|
||||||
|
rs.getLong("created_by"), rs.getTimestamp("created_at").toInstant(),
|
||||||
|
rs.getInt("recipient_count"), rs.getInt("sent_count"), rs.getInt("failed_count")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String recipients(List<Long> userIds) {
|
||||||
|
if (userIds == null) {
|
||||||
|
return RECIPIENTS;
|
||||||
|
}
|
||||||
|
// An empty list would make "IN ()", a syntax error.
|
||||||
|
return RECIPIENTS + (userIds.isEmpty() ? " AND FALSE" : " AND u.id IN (:ids)");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MapSqlParameterSource ids(List<Long> userIds) {
|
||||||
|
return new MapSqlParameterSource("ids", userIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import org.springframework.stereotype.Repository;
|
|||||||
import java.sql.Timestamp;
|
import java.sql.Timestamp;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
@@ -39,16 +40,19 @@ public class MailOutboxRepository {
|
|||||||
|
|
||||||
public List<OutboxRow> findDue(Instant now, int limit) {
|
public List<OutboxRow> findDue(Instant now, int limit) {
|
||||||
return jdbc.query("""
|
return jdbc.query("""
|
||||||
SELECT id, kind, to_address, subject, body, attempts FROM mail_outbox
|
SELECT id, kind, to_address, subject, body, attempts, broadcast_id FROM mail_outbox
|
||||||
WHERE status = 'PENDING' AND next_attempt_at <= ?
|
WHERE status = 'PENDING' AND next_attempt_at <= ?
|
||||||
ORDER BY id LIMIT ?""",
|
ORDER BY broadcast_id NULLS FIRST, id LIMIT ?""",
|
||||||
(rs, row) -> new OutboxRow(rs.getLong("id"), rs.getString("kind"), rs.getString("to_address"),
|
(rs, row) -> new OutboxRow(rs.getLong("id"), rs.getString("kind"), rs.getString("to_address"),
|
||||||
rs.getString("subject"), rs.getString("body"), rs.getInt("attempts")),
|
rs.getString("subject"), rs.getString("body"), rs.getInt("attempts"), rs.getObject("broadcast_id", Long.class)),
|
||||||
Timestamp.from(now), limit);
|
Timestamp.from(now), limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void markSent(long id, Instant now) {
|
public void markSent(long id, Instant now) {
|
||||||
jdbc.update("UPDATE mail_outbox SET status = 'SENT', sent_at = ?, last_error = NULL WHERE id = ?",
|
jdbc.update("""
|
||||||
|
WITH row AS (UPDATE mail_outbox SET status = 'SENT', sent_at = ?, last_error = NULL
|
||||||
|
WHERE id = ? RETURNING broadcast_id)
|
||||||
|
UPDATE mail_broadcast b SET sent_count = sent_count + 1 FROM row WHERE b.id = row.broadcast_id""",
|
||||||
Timestamp.from(now), id);
|
Timestamp.from(now), id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,10 +62,37 @@ public class MailOutboxRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void markFailed(long id, int attempts, String error) {
|
public void markFailed(long id, int attempts, String error) {
|
||||||
jdbc.update("UPDATE mail_outbox SET status = 'FAILED', attempts = ?, last_error = ? WHERE id = ?",
|
jdbc.update("""
|
||||||
|
WITH row AS (UPDATE mail_outbox SET status = 'FAILED', attempts = ?, last_error = ?
|
||||||
|
WHERE id = ? RETURNING broadcast_id)
|
||||||
|
UPDATE mail_broadcast b SET failed_count = failed_count + 1 FROM row WHERE b.id = row.broadcast_id""",
|
||||||
attempts, shorten(error), id);
|
attempts, shorten(error), id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fails the pending broadcast rows whose user is no longer active or turned the mails off since the queue time.
|
||||||
|
*
|
||||||
|
* @return the number of rows failed
|
||||||
|
*/
|
||||||
|
public int failBroadcastRowsOfInactiveUsers() {
|
||||||
|
return Objects.requireNonNull(jdbc.queryForObject("""
|
||||||
|
WITH skipped AS (
|
||||||
|
UPDATE mail_outbox o SET status = 'FAILED', last_error = 'recipient inactive or opted out'
|
||||||
|
WHERE o.status = 'PENDING' AND o.broadcast_id IS NOT NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 %s AND u.id = o.user_id)
|
||||||
|
RETURNING o.broadcast_id),
|
||||||
|
counted AS (
|
||||||
|
UPDATE mail_broadcast b SET failed_count = failed_count + c.n
|
||||||
|
FROM (SELECT broadcast_id, COUNT(*) AS n FROM skipped GROUP BY broadcast_id) c
|
||||||
|
WHERE b.id = c.broadcast_id)
|
||||||
|
SELECT COUNT(*) FROM skipped""".formatted(BroadcastRepository.RECIPIENTS), Integer.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
public int countSentSince(Instant since) {
|
||||||
|
return Objects.requireNonNull(jdbc.queryForObject("SELECT COUNT(*) FROM mail_outbox WHERE sent_at >= ?",
|
||||||
|
Integer.class, Timestamp.from(since)));
|
||||||
|
}
|
||||||
|
|
||||||
public void deleteSentBefore(Instant cutoff) {
|
public void deleteSentBefore(Instant cutoff) {
|
||||||
jdbc.update("DELETE FROM mail_outbox WHERE status = 'SENT' AND sent_at < ?", Timestamp.from(cutoff));
|
jdbc.update("DELETE FROM mail_outbox WHERE status = 'SENT' AND sent_at < ?", Timestamp.from(cutoff));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package auth_service.modules.mail.service;
|
||||||
|
|
||||||
|
import auth_service.modules.mail.exception.NoRecipientsException;
|
||||||
|
import auth_service.modules.mail.model.Broadcast;
|
||||||
|
import auth_service.modules.mail.model.BroadcastText;
|
||||||
|
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.BroadcastRepository;
|
||||||
|
import auth_service.modules.mail.repo.MailOutboxRepository;
|
||||||
|
import auth_service.shared.audit.AdminAuditLog;
|
||||||
|
import auth_service.shared.security.RequiresRole;
|
||||||
|
import auth_service.shared.security.Role;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiresRole(Role.PLATFORM_ADMIN)
|
||||||
|
@Slf4j
|
||||||
|
public class BroadcastService {
|
||||||
|
|
||||||
|
private final BroadcastRepository broadcasts;
|
||||||
|
private final MailOutboxRepository outbox;
|
||||||
|
private final AdminAuditLog adminAuditLog;
|
||||||
|
private final String settingsUrl;
|
||||||
|
|
||||||
|
public BroadcastService(BroadcastRepository broadcasts, MailOutboxRepository outbox, AdminAuditLog adminAuditLog,
|
||||||
|
@Value("${app.frontend-url}") String frontendUrl) {
|
||||||
|
this.broadcasts = broadcasts;
|
||||||
|
this.outbox = outbox;
|
||||||
|
this.adminAuditLog = adminAuditLog;
|
||||||
|
this.settingsUrl = frontendUrl + "/settings";
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Broadcast> findAll() {
|
||||||
|
return broadcasts.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param userIds null = every active user with platform mails on
|
||||||
|
*/
|
||||||
|
public long countRecipients(List<Long> userIds) {
|
||||||
|
return broadcasts.countRecipients(userIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param userIds null = every active user with platform mails on
|
||||||
|
* @return the number of mails queued
|
||||||
|
* @throws NoRecipientsException when nobody is left; nothing is stored then
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public int send(long actorUserId, BroadcastText text, List<Long> userIds) {
|
||||||
|
Mail en = MailTemplate.BROADCAST.render("en", text.subjectEn(), text.bodyEn(), settingsUrl);
|
||||||
|
Mail de = MailTemplate.BROADCAST.render("de", text.subjectDe(), text.bodyDe(), settingsUrl);
|
||||||
|
long id = broadcasts.insert(text, actorUserId, Instant.now());
|
||||||
|
int queued = broadcasts.queue(id, userIds, en, de);
|
||||||
|
if (queued == 0) {
|
||||||
|
throw new NoRecipientsException();
|
||||||
|
}
|
||||||
|
broadcasts.setRecipientCount(id, queued);
|
||||||
|
adminAuditLog.record(actorUserId, "BROADCAST_SEND", null, String.valueOf(id));
|
||||||
|
log.info("Admin {} queued broadcast {} for {} users", actorUserId, id, queued);
|
||||||
|
return queued;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mails the draft to the admin only, in the admin's language.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void sendTest(long actorUserId, BroadcastText text) {
|
||||||
|
Recipient admin = outbox.findRecipient(actorUserId).orElseThrow();
|
||||||
|
boolean german = MailTemplate.isGerman(admin.language());
|
||||||
|
Mail mail = MailTemplate.BROADCAST.render(admin.language(), german ? text.subjectDe() : text.subjectEn(),
|
||||||
|
german ? text.bodyDe() : text.bodyEn(), settingsUrl);
|
||||||
|
outbox.insert(MailTemplate.BROADCAST, actorUserId, admin.email(), mail);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,16 +26,21 @@ public class MailOutboxService {
|
|||||||
static final int MAX_ATTEMPTS = 10;
|
static final int MAX_ATTEMPTS = 10;
|
||||||
static final Duration KEEP_SENT = Duration.ofDays(30);
|
static final Duration KEEP_SENT = Duration.ofDays(30);
|
||||||
private static final int BATCH = 20;
|
private static final int BATCH = 20;
|
||||||
|
// Kept free of broadcast rows, so a registration mail still goes out on a broadcast day.
|
||||||
|
static final int ACCOUNT_RESERVE = 10;
|
||||||
|
|
||||||
private final MailOutboxRepository repository;
|
private final MailOutboxRepository repository;
|
||||||
private final JavaMailSender sender;
|
private final JavaMailSender sender;
|
||||||
private final String from;
|
private final String from;
|
||||||
|
private final int dailyLimit;
|
||||||
|
|
||||||
// An empty SPRING_MAIL_HOST still creates Boot's sender bean, so the host decides, not the bean.
|
// 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,
|
public MailOutboxService(MailOutboxRepository repository, ObjectProvider<JavaMailSender> sender,
|
||||||
@Value("${spring.mail.host:}") String host, @Value("${app.mail.from}") String from,
|
@Value("${spring.mail.host:}") String host, @Value("${app.mail.from}") String from,
|
||||||
@Value("${app.mail.admin-to}") String adminTo) {
|
@Value("${app.mail.admin-to}") String adminTo,
|
||||||
|
@Value("${app.mail.daily-limit}") int dailyLimit) {
|
||||||
this.repository = repository;
|
this.repository = repository;
|
||||||
|
this.dailyLimit = dailyLimit;
|
||||||
this.from = from;
|
this.from = from;
|
||||||
if (host.isBlank()) {
|
if (host.isBlank()) {
|
||||||
this.sender = null;
|
this.sender = null;
|
||||||
@@ -45,6 +50,10 @@ public class MailOutboxService {
|
|||||||
if (from.isBlank() || adminTo.isBlank()) {
|
if (from.isBlank() || adminTo.isBlank()) {
|
||||||
throw new IllegalStateException("SPRING_MAIL_HOST is set, so APP_MAIL_FROM and APP_MAIL_ADMIN_TO are needed");
|
throw new IllegalStateException("SPRING_MAIL_HOST is set, so APP_MAIL_FROM and APP_MAIL_ADMIN_TO are needed");
|
||||||
}
|
}
|
||||||
|
if (dailyLimit <= ACCOUNT_RESERVE) {
|
||||||
|
throw new IllegalStateException("APP_MAIL_DAILY_LIMIT must be above " + ACCOUNT_RESERVE
|
||||||
|
+ ", the room kept for account mails");
|
||||||
|
}
|
||||||
this.sender = sender.getObject();
|
this.sender = sender.getObject();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,8 +66,18 @@ public class MailOutboxService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Instant now = Instant.now();
|
Instant now = Instant.now();
|
||||||
for (OutboxRow row : repository.findDue(now, BATCH)) {
|
int skipped = repository.failBroadcastRowsOfInactiveUsers();
|
||||||
|
if (skipped > 0) {
|
||||||
|
log.info("{} broadcast mails skipped: recipient inactive or opted out", skipped);
|
||||||
|
}
|
||||||
|
int budget = dailyLimit - repository.countSentSince(now.minus(Duration.ofHours(24)));
|
||||||
|
for (OutboxRow row : repository.findDue(now, Math.min(BATCH, Math.max(budget, 0)))) {
|
||||||
|
// Account mails come first, so from the first broadcast row on only broadcast rows follow.
|
||||||
|
if (row.broadcastId() != null && budget <= ACCOUNT_RESERVE) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
send(row, now);
|
send(row, now);
|
||||||
|
budget--; // a failed try counts too: safer than Hostinger's own count
|
||||||
}
|
}
|
||||||
repository.deleteSentBefore(now.minus(KEEP_SENT));
|
repository.deleteSentBefore(now.minus(KEEP_SENT));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import auth_service.shared.security.UserPrincipal;
|
|||||||
import auth_service.modules.user.api.dto.request.AppearanceRequest;
|
import auth_service.modules.user.api.dto.request.AppearanceRequest;
|
||||||
import auth_service.modules.user.api.dto.request.DashboardTilesRequest;
|
import auth_service.modules.user.api.dto.request.DashboardTilesRequest;
|
||||||
import auth_service.modules.user.api.dto.request.FavoritePlatformRequest;
|
import auth_service.modules.user.api.dto.request.FavoritePlatformRequest;
|
||||||
|
import auth_service.modules.user.api.dto.request.PlatformMailsRequest;
|
||||||
import auth_service.modules.user.api.dto.request.ProfileTilesRequest;
|
import auth_service.modules.user.api.dto.request.ProfileTilesRequest;
|
||||||
import auth_service.modules.user.api.dto.request.ProfileStyleRequest;
|
import auth_service.modules.user.api.dto.request.ProfileStyleRequest;
|
||||||
import auth_service.modules.user.api.dto.request.UserSettingsRequest;
|
import auth_service.modules.user.api.dto.request.UserSettingsRequest;
|
||||||
@@ -64,6 +65,13 @@ public class UserController {
|
|||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PutMapping(value = "/settings/platform-mails")
|
||||||
|
public ResponseEntity<Void> updatePlatformMails(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
|
@Valid @RequestBody PlatformMailsRequest request) {
|
||||||
|
this.userService.updatePlatformMails(principal.getIdAsLong(), request.enabled());
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
@PutMapping(value = "/settings/welcome-seen")
|
@PutMapping(value = "/settings/welcome-seen")
|
||||||
public ResponseEntity<Void> markWelcomeSeen(@AuthenticationPrincipal UserPrincipal principal) {
|
public ResponseEntity<Void> markWelcomeSeen(@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
this.userService.markWelcomeSeen(principal.getIdAsLong());
|
this.userService.markWelcomeSeen(principal.getIdAsLong());
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package auth_service.modules.user.api.dto.request;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param enabled boxed, so a missing field is a 400 and not an opt-out
|
||||||
|
*/
|
||||||
|
public record PlatformMailsRequest(@NotNull Boolean enabled) {
|
||||||
|
}
|
||||||
@@ -9,5 +9,5 @@ import java.util.List;
|
|||||||
public record SettingsResponse(long id, boolean useDarkTheme, String language, boolean publicProfile,
|
public record SettingsResponse(long id, boolean useDarkTheme, String language, boolean publicProfile,
|
||||||
AppearanceResponse appearance, ProfileStyleResponse profileStyle,
|
AppearanceResponse appearance, ProfileStyleResponse profileStyle,
|
||||||
List<TileResponse> dashboardTiles, List<TileResponse> profileTiles,
|
List<TileResponse> dashboardTiles, List<TileResponse> profileTiles,
|
||||||
String favoritePlatform, Instant welcomeSeenAt) {
|
String favoritePlatform, boolean platformMails, Instant welcomeSeenAt) {
|
||||||
}
|
}
|
||||||
@@ -48,6 +48,7 @@ public abstract class ApiMapper {
|
|||||||
: toApi(user.getSettings().getDashboardTiles()))
|
: toApi(user.getSettings().getDashboardTiles()))
|
||||||
.profileTiles(toApi(user.getSettings().getProfileTiles()))
|
.profileTiles(toApi(user.getSettings().getProfileTiles()))
|
||||||
.favoritePlatform(user.getSettings().getFavoritePlatform())
|
.favoritePlatform(user.getSettings().getFavoritePlatform())
|
||||||
|
.platformMails(user.getSettings().isPlatformMails())
|
||||||
.welcomeSeenAt(user.getSettings().getWelcomeSeenAt())
|
.welcomeSeenAt(user.getSettings().getWelcomeSeenAt())
|
||||||
.build())
|
.build())
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ public class UserSettings {
|
|||||||
@Builder.Default
|
@Builder.Default
|
||||||
private List<Tile> profileTiles = DEFAULT_PROFILE_TILES;
|
private List<Tile> profileTiles = DEFAULT_PROFILE_TILES;
|
||||||
private String favoritePlatform;
|
private String favoritePlatform;
|
||||||
|
/** false = no broadcast mails; account mails always go. */
|
||||||
|
@Builder.Default
|
||||||
|
private boolean platformMails = true;
|
||||||
/** Null: the welcome page was not seen yet. */
|
/** Null: the welcome page was not seen yet. */
|
||||||
private Instant welcomeSeenAt;
|
private Instant welcomeSeenAt;
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ public class UserSettingsEntity {
|
|||||||
@Column(name = "favorite_platform")
|
@Column(name = "favorite_platform")
|
||||||
private String favoritePlatform;
|
private String favoritePlatform;
|
||||||
|
|
||||||
|
@Column(name = "platform_mails", nullable = false)
|
||||||
|
private boolean platformMails;
|
||||||
|
|
||||||
@JdbcTypeCode(SqlTypes.ARRAY)
|
@JdbcTypeCode(SqlTypes.ARRAY)
|
||||||
@Column(name = "profile_tiles", nullable = false)
|
@Column(name = "profile_tiles", nullable = false)
|
||||||
private List<String> profileTiles;
|
private List<String> profileTiles;
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ public abstract class EntityMapper {
|
|||||||
: settings.getDashboardTiles().stream().map(Tile::parse).toList())
|
: settings.getDashboardTiles().stream().map(Tile::parse).toList())
|
||||||
.profileTiles(settings.getProfileTiles().stream().map(Tile::parse).toList())
|
.profileTiles(settings.getProfileTiles().stream().map(Tile::parse).toList())
|
||||||
.favoritePlatform(settings.getFavoritePlatform())
|
.favoritePlatform(settings.getFavoritePlatform())
|
||||||
|
.platformMails(settings.isPlatformMails())
|
||||||
.welcomeSeenAt(settings.getWelcomeSeenAt())
|
.welcomeSeenAt(settings.getWelcomeSeenAt())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@@ -74,6 +75,7 @@ public abstract class EntityMapper {
|
|||||||
: Tile.storedAll(settings.getDashboardTiles()));
|
: Tile.storedAll(settings.getDashboardTiles()));
|
||||||
target.setProfileTiles(Tile.storedAll(settings.getProfileTiles()));
|
target.setProfileTiles(Tile.storedAll(settings.getProfileTiles()));
|
||||||
target.setFavoritePlatform(settings.getFavoritePlatform());
|
target.setFavoritePlatform(settings.getFavoritePlatform());
|
||||||
|
target.setPlatformMails(settings.isPlatformMails());
|
||||||
target.setWelcomeSeenAt(settings.getWelcomeSeenAt());
|
target.setWelcomeSeenAt(settings.getWelcomeSeenAt());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,6 +129,13 @@ public class UserService {
|
|||||||
user -> user.getSettings().setFavoritePlatform(platform == null ? null : platform.strip()));
|
user -> user.getSettings().setFavoritePlatform(platform == null ? null : platform.strip()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@RequiresRole(Role.USER)
|
||||||
|
@Transactional
|
||||||
|
@CacheEvict(value = "users", key = "#userId")
|
||||||
|
public void updatePlatformMails(long userId, boolean enabled) {
|
||||||
|
userRepository.update(userId, user -> user.getSettings().setPlatformMails(enabled));
|
||||||
|
}
|
||||||
|
|
||||||
@RequiresRole(Role.USER)
|
@RequiresRole(Role.USER)
|
||||||
@Transactional
|
@Transactional
|
||||||
@CacheEvict(value = "users", key = "#userId")
|
@CacheEvict(value = "users", key = "#userId")
|
||||||
|
|||||||
@@ -83,3 +83,6 @@ app:
|
|||||||
# Gets "a new registration waits". Only in the env file, never in git.
|
# Gets "a new registration waits". Only in the env file, never in git.
|
||||||
admin-to: ${APP_MAIL_ADMIN_TO:}
|
admin-to: ${APP_MAIL_ADMIN_TO:}
|
||||||
cron: "0 * * * * *"
|
cron: "0 * * * * *"
|
||||||
|
# Mails per rolling 24 h, account mails and broadcasts together. Hostinger: 100 for the free mailbox of a
|
||||||
|
# hosting plan, 1000 Business Starter, 3000 Business Standard / Premium. Broadcast rows keep 10 of them free.
|
||||||
|
daily-limit: ${APP_MAIL_DAILY_LIMIT:100}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- "Mails from the platform" in the settings. Off = no broadcast; account mails always go.
|
||||||
|
ALTER TABLE user_settings ADD COLUMN platform_mails BOOLEAN NOT NULL DEFAULT TRUE;
|
||||||
|
|
||||||
|
-- One row per broadcast an admin sent: the texts and the counts. No FK on created_by (the row outlives the admin).
|
||||||
|
CREATE TABLE mail_broadcast (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
subject_en VARCHAR(150) NOT NULL,
|
||||||
|
subject_de VARCHAR(150) NOT NULL,
|
||||||
|
body_en TEXT NOT NULL,
|
||||||
|
body_de TEXT NOT NULL,
|
||||||
|
created_by BIGINT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL,
|
||||||
|
recipient_count INT NOT NULL DEFAULT 0,
|
||||||
|
sent_count INT NOT NULL DEFAULT 0,
|
||||||
|
failed_count INT NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- A broadcast row of the outbox; NULL = account mail. The sender takes account mails first.
|
||||||
|
ALTER TABLE mail_outbox ADD COLUMN broadcast_id BIGINT REFERENCES mail_broadcast (id) ON DELETE CASCADE;
|
||||||
|
-- One mail per user and broadcast, whatever the retries do.
|
||||||
|
CREATE UNIQUE INDEX uq_mail_outbox_broadcast_user ON mail_outbox (broadcast_id, user_id) WHERE broadcast_id IS NOT NULL;
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package auth_service.modules.mail.api;
|
||||||
|
|
||||||
|
import auth_service.configuration.security.ClientHeaderFilter;
|
||||||
|
import auth_service.modules.mail.service.MailOutboxService;
|
||||||
|
import auth_service.shared.api.AuthCookies;
|
||||||
|
import auth_service.shared.test.AbstractIntegrationTest;
|
||||||
|
import jakarta.servlet.http.Cookie;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.mail.SimpleMailMessage;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
import org.springframework.test.web.servlet.ResultActions;
|
||||||
|
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
class BroadcastFlowTest extends AbstractIntegrationTest {
|
||||||
|
|
||||||
|
private static final String PASSWORD = "correct horse battery";
|
||||||
|
private static final String ADMIN = "broadcast-flow-admin@example.com";
|
||||||
|
private static final String PLAYER = "broadcast-flow-player@example.com";
|
||||||
|
private static final String OPTED_OUT = "broadcast-flow-optedout@example.com";
|
||||||
|
private static final String BLOCKED = "broadcast-flow-blocked@example.com";
|
||||||
|
private static final String BASE = "/api/admin/broadcasts";
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MailOutboxService mailOutboxService;
|
||||||
|
|
||||||
|
private long optedOutId;
|
||||||
|
private List<Long> foreignOptedIn;
|
||||||
|
|
||||||
|
private long insertUser(String email, String role, String language, boolean platformMails, boolean enabled) {
|
||||||
|
long id = jdbc.queryForObject("INSERT INTO users (email, password, enabled, role, display_name) "
|
||||||
|
+ "VALUES (?, ?, ?, ?, ?) RETURNING id", Long.class, email, passwordEncoder.encode(PASSWORD),
|
||||||
|
enabled, role, email.substring(15, 20));
|
||||||
|
addSettingsRows();
|
||||||
|
jdbc.update("UPDATE user_settings SET language = ?, platform_mails = ? WHERE user_id = ?", language,
|
||||||
|
platformMails, id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void seed() {
|
||||||
|
jdbc.update("DELETE FROM mail_outbox");
|
||||||
|
jdbc.update("DELETE FROM mail_broadcast");
|
||||||
|
jdbc.update("DELETE FROM users WHERE email LIKE 'broadcast-flow-%'");
|
||||||
|
// Users that other test classes left behind must not count as recipients; tearDown restores them.
|
||||||
|
foreignOptedIn = jdbc.queryForList("SELECT user_id FROM user_settings WHERE platform_mails", Long.class);
|
||||||
|
jdbc.update("UPDATE user_settings SET platform_mails = FALSE");
|
||||||
|
insertUser(ADMIN, "PLATFORM_ADMIN", "de", true, true);
|
||||||
|
insertUser(PLAYER, "USER", "en", true, true);
|
||||||
|
optedOutId = insertUser(OPTED_OUT, "USER", "en", false, true);
|
||||||
|
insertUser(BLOCKED, "USER", "en", true, true);
|
||||||
|
jdbc.update("UPDATE user_settings SET platform_mails = TRUE WHERE user_id IN "
|
||||||
|
+ "(SELECT id FROM users WHERE email LIKE 'broadcast-flow-%' AND email <> ?)", OPTED_OUT);
|
||||||
|
jdbc.update("UPDATE users SET enabled = FALSE WHERE email = ?", BLOCKED);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
jdbc.update("DELETE FROM users WHERE email LIKE 'broadcast-flow-%'");
|
||||||
|
jdbc.batchUpdate("UPDATE user_settings SET platform_mails = TRUE WHERE user_id = ?",
|
||||||
|
foreignOptedIn.stream().map(id -> new Object[]{id}).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Cookie login(String email) throws Exception {
|
||||||
|
return new Cookie(AuthCookies.ACCESS_COOKIE, mockMvc.perform(post("/api/auth/login")
|
||||||
|
.header(ClientHeaderFilter.CLIENT_HEADER, "web").contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"email\":\"" + email + "\",\"password\":\"" + PASSWORD + "\"}"))
|
||||||
|
.andReturn().getResponse().getCookie(AuthCookies.ACCESS_COOKIE).getValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String broadcast(String subjectDe, String userIds) {
|
||||||
|
return "{\"subjectEn\":\"Hello\",\"subjectDe\":\"" + subjectDe + "\",\"bodyEn\":\"Body\",\"bodyDe\":\"Inhalt\""
|
||||||
|
+ ",\"userIds\":" + userIds + "}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResultActions send(MockHttpServletRequestBuilder request, Cookie session) throws Exception {
|
||||||
|
return mockMvc.perform(request.header(ClientHeaderFilter.CLIENT_HEADER, "web").cookie(session)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON));
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Map<String, Object>> rows() {
|
||||||
|
return jdbc.queryForList("SELECT to_address, subject, kind, broadcast_id FROM mail_outbox ORDER BY to_address");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sendToAll_MailsActiveOptedInUsersInTheirLanguageAndCountsTheSend() throws Exception {
|
||||||
|
Cookie admin = login(ADMIN);
|
||||||
|
|
||||||
|
send(get(BASE + "/recipient-count"), admin).andExpect(status().isOk()).andExpect(jsonPath("$.count").value(2));
|
||||||
|
send(post(BASE).content(broadcast("Hallo", "null")), admin).andExpect(status().isCreated())
|
||||||
|
.andExpect(jsonPath("$.recipientCount").value(2));
|
||||||
|
|
||||||
|
assertThat(rows()).extracting(row -> row.get("to_address") + " " + row.get("subject") + " " + row.get("kind"))
|
||||||
|
.containsExactly(ADMIN + " Hallo BROADCAST", PLAYER + " Hello BROADCAST");
|
||||||
|
|
||||||
|
jdbc.update("UPDATE users SET enabled = FALSE WHERE email = ?", PLAYER);
|
||||||
|
mailOutboxService.sendDue();
|
||||||
|
|
||||||
|
verify(mailSender, times(1)).send(any(SimpleMailMessage.class));
|
||||||
|
send(get(BASE), admin).andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.results[0].subjectDe").value("Hallo"))
|
||||||
|
.andExpect(jsonPath("$.results[0].recipientCount").value(2))
|
||||||
|
.andExpect(jsonPath("$.results[0].sentCount").value(1))
|
||||||
|
.andExpect(jsonPath("$.results[0].failedCount").value(1));
|
||||||
|
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM admin_audit WHERE action = 'BROADCAST_SEND'", Long.class))
|
||||||
|
.isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pickedAndTest_StayNarrow() throws Exception {
|
||||||
|
Cookie admin = login(ADMIN);
|
||||||
|
|
||||||
|
send(post(BASE).content(broadcast("Hallo", "[" + optedOutId + "]")), admin).andExpect(status().isConflict())
|
||||||
|
.andExpect(jsonPath("$.code").value("NO_RECIPIENTS"));
|
||||||
|
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM mail_broadcast", Long.class)).isZero();
|
||||||
|
|
||||||
|
send(post(BASE + "/test").content(broadcast("Hallo", "null")), admin).andExpect(status().isNoContent());
|
||||||
|
assertThat(rows()).extracting(row -> row.get("to_address") + " " + row.get("subject") + " " + row.get("broadcast_id"))
|
||||||
|
.containsExactly(ADMIN + " Hallo null");
|
||||||
|
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM mail_broadcast", Long.class)).isZero();
|
||||||
|
|
||||||
|
send(post(BASE).content(broadcast(" ", "null")), admin).andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void everyEndpoint_RefusesUsersAndStrangers() throws Exception {
|
||||||
|
Cookie player = login(PLAYER);
|
||||||
|
String body = broadcast("Hallo", "null");
|
||||||
|
|
||||||
|
send(get(BASE), player).andExpect(status().isForbidden());
|
||||||
|
send(get(BASE + "/recipient-count"), player).andExpect(status().isForbidden());
|
||||||
|
send(post(BASE).content(body), player).andExpect(status().isForbidden());
|
||||||
|
send(post(BASE + "/test").content(body), player).andExpect(status().isForbidden());
|
||||||
|
|
||||||
|
mockMvc.perform(get(BASE).header(ClientHeaderFilter.CLIENT_HEADER, "web")).andExpect(status().isUnauthorized());
|
||||||
|
mockMvc.perform(get(BASE + "/recipient-count").header(ClientHeaderFilter.CLIENT_HEADER, "web")).andExpect(status().isUnauthorized());
|
||||||
|
mockMvc.perform(post(BASE).header(ClientHeaderFilter.CLIENT_HEADER, "web").contentType(MediaType.APPLICATION_JSON).content(body))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
mockMvc.perform(post(BASE + "/test").header(ClientHeaderFilter.CLIENT_HEADER, "web").contentType(MediaType.APPLICATION_JSON).content(body))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
|
||||||
|
assertThat(rows()).isEmpty();
|
||||||
|
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM mail_broadcast", Long.class)).isZero();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,8 +19,20 @@ class MailTemplateTest extends AbstractUnitTest {
|
|||||||
for (String language : new String[]{"de", "en"}) {
|
for (String language : new String[]{"de", "en"}) {
|
||||||
Mail mail = template.render(language, "Alice", "alice@example.com", "https://tbp.example");
|
Mail mail = template.render(language, "Alice", "alice@example.com", "https://tbp.example");
|
||||||
|
|
||||||
assertThat(mail.subject()).isNotBlank().doesNotContain("%");
|
// BROADCAST puts its first argument into the subject, so look at both.
|
||||||
assertThat(mail.body()).contains("Alice").doesNotContain("%s");
|
String text = mail.subject() + mail.body();
|
||||||
|
assertThat(mail.subject()).isNotBlank();
|
||||||
|
assertThat(text).contains("Alice").doesNotContain("%");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void render_Broadcast_PutsSubjectBodyAndTheSettingsLink() {
|
||||||
|
for (String language : new String[]{"de", "en"}) {
|
||||||
|
Mail mail = MailTemplate.BROADCAST.render(language, "50% off", "Hello 100%s", "https://tbp.example/settings");
|
||||||
|
|
||||||
|
assertThat(mail.subject()).isEqualTo("50% off");
|
||||||
|
assertThat(mail.body()).startsWith("Hello 100%s").endsWith("https://tbp.example/settings\n");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package auth_service.modules.mail.repo;
|
||||||
|
|
||||||
|
import auth_service.modules.mail.model.Broadcast;
|
||||||
|
import auth_service.modules.mail.model.BroadcastText;
|
||||||
|
import auth_service.modules.mail.model.Mail;
|
||||||
|
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.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class BroadcastRepositoryTest extends AbstractPersistenceTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private BroadcastRepository repository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
private long german;
|
||||||
|
private long noLanguage;
|
||||||
|
private long optedOut;
|
||||||
|
private long blocked;
|
||||||
|
private long pending;
|
||||||
|
private long deleted;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void seed() {
|
||||||
|
jdbc.update("DELETE FROM mail_outbox");
|
||||||
|
jdbc.update("DELETE FROM mail_broadcast");
|
||||||
|
jdbc.update("DELETE FROM users WHERE email LIKE 'bc-repo-%'");
|
||||||
|
german = user("german", "true, 'ACTIVE', NULL", "de", true);
|
||||||
|
noLanguage = user("nolang", "true, 'ACTIVE', NULL", "", true);
|
||||||
|
optedOut = user("optedout", "true, 'ACTIVE', NULL", "de", false);
|
||||||
|
blocked = user("blocked", "false, 'ACTIVE', NULL", "de", true);
|
||||||
|
pending = user("pending", "true, 'PENDING', NULL", "de", true);
|
||||||
|
deleted = user("deleted", "true, 'ACTIVE', NOW()", "de", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private long user(String name, String flags, String language, boolean platformMails) {
|
||||||
|
long id = jdbc.queryForObject("INSERT INTO users (email, password, enabled, status, deleted_at) VALUES (?, 'x', "
|
||||||
|
+ flags + ") RETURNING id", Long.class, "bc-repo-" + name + "@example.com");
|
||||||
|
jdbc.update("INSERT INTO user_settings (user_id, language, platform_mails) VALUES (?, ?, ?)", id, language,
|
||||||
|
platformMails);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void countRecipients_TakesOnlyActiveOptedInUsers() {
|
||||||
|
// Other tests of the suite may leave users behind, so compare with the same query by SQL.
|
||||||
|
long all = jdbc.queryForObject("SELECT COUNT(*) FROM users u JOIN user_settings s ON s.user_id = u.id "
|
||||||
|
+ "WHERE u.enabled AND u.deleted_at IS NULL AND u.status = 'ACTIVE' AND s.platform_mails", Long.class);
|
||||||
|
|
||||||
|
assertThat(repository.countRecipients(null)).isEqualTo(all).isGreaterThanOrEqualTo(2);
|
||||||
|
assertThat(repository.countRecipients(List.of(german, blocked, optedOut, pending, deleted))).isEqualTo(1);
|
||||||
|
assertThat(repository.countRecipients(List.of())).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void queue_WritesOneRowPerRecipientInTheirLanguage() {
|
||||||
|
long id = repository.insert(new BroadcastText("Hi", "Hallo", "Body", "Inhalt"), 1L, Instant.now());
|
||||||
|
|
||||||
|
int queued = repository.queue(id, List.of(german, noLanguage, optedOut, blocked),
|
||||||
|
new Mail("Hi", "Body"), new Mail("Hallo", "Inhalt"));
|
||||||
|
repository.setRecipientCount(id, queued);
|
||||||
|
|
||||||
|
assertThat(queued).isEqualTo(2);
|
||||||
|
List<Map<String, Object>> rows = jdbc.queryForList("SELECT user_id, kind, subject, body, broadcast_id "
|
||||||
|
+ "FROM mail_outbox ORDER BY user_id");
|
||||||
|
assertThat(rows).extracting(row -> row.get("user_id")).containsExactly(german, noLanguage);
|
||||||
|
assertThat(rows).extracting(row -> row.get("subject")).containsExactly("Hallo", "Hi");
|
||||||
|
assertThat(rows).extracting(row -> row.get("body")).containsExactly("Inhalt", "Body");
|
||||||
|
assertThat(rows).extracting(row -> row.get("kind")).containsOnly("BROADCAST");
|
||||||
|
assertThat(rows).extracting(row -> row.get("broadcast_id")).containsOnly(id);
|
||||||
|
|
||||||
|
long newer = repository.insert(new BroadcastText("a", "b", "c", "d"), 1L, Instant.now().plusSeconds(5));
|
||||||
|
List<Broadcast> all = repository.findAll();
|
||||||
|
assertThat(all).extracting(Broadcast::id).containsExactly(newer, id);
|
||||||
|
assertThat(all.get(1)).satisfies(b -> {
|
||||||
|
assertThat(b.recipientCount()).isEqualTo(2);
|
||||||
|
assertThat(b.sentCount()).isZero();
|
||||||
|
assertThat(b.failedCount()).isZero();
|
||||||
|
assertThat(b.text().subjectDe()).isEqualTo("Hallo");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void queue_NeverWritesTwoRowsForOneUserAndBroadcast() {
|
||||||
|
long id = repository.insert(new BroadcastText("a", "b", "c", "d"), 1L, Instant.now());
|
||||||
|
repository.queue(id, List.of(german), new Mail("a", "c"), new Mail("b", "d"));
|
||||||
|
|
||||||
|
org.junit.jupiter.api.Assertions.assertThrows(org.springframework.dao.DataIntegrityViolationException.class,
|
||||||
|
() -> repository.queue(id, List.of(german), new Mail("a", "c"), new Mail("b", "d")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ class MailOutboxRepositoryTest extends AbstractPersistenceTest {
|
|||||||
@BeforeEach
|
@BeforeEach
|
||||||
void clean() {
|
void clean() {
|
||||||
jdbc.update("DELETE FROM mail_outbox");
|
jdbc.update("DELETE FROM mail_outbox");
|
||||||
|
jdbc.update("DELETE FROM mail_broadcast");
|
||||||
jdbc.update("DELETE FROM users WHERE email LIKE 'mail-repo%'");
|
jdbc.update("DELETE FROM users WHERE email LIKE 'mail-repo%'");
|
||||||
userId = jdbc.queryForObject("INSERT INTO users (email, password, enabled) "
|
userId = jdbc.queryForObject("INSERT INTO users (email, password, enabled) "
|
||||||
+ "VALUES ('mail-repo-user@example.com', 'x', true) RETURNING id", Long.class);
|
+ "VALUES ('mail-repo-user@example.com', 'x', true) RETURNING id", Long.class);
|
||||||
@@ -49,7 +50,7 @@ class MailOutboxRepositoryTest extends AbstractPersistenceTest {
|
|||||||
long id = insert();
|
long id = insert();
|
||||||
|
|
||||||
assertThat(repository.findDue(now.plusSeconds(1), 10))
|
assertThat(repository.findDue(now.plusSeconds(1), 10))
|
||||||
.containsExactly(new OutboxRow(id, "ACCOUNT_BLOCKED", "alice@example.com", "Subject", "Body", 0));
|
.containsExactly(new OutboxRow(id, "ACCOUNT_BLOCKED", "alice@example.com", "Subject", "Body", 0, null));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -117,6 +118,85 @@ class MailOutboxRepositoryTest extends AbstractPersistenceTest {
|
|||||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM mail_outbox", Integer.class)).isZero();
|
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM mail_outbox", Integer.class)).isZero();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private long broadcast() {
|
||||||
|
return jdbc.queryForObject("INSERT INTO mail_broadcast (subject_en, subject_de, body_en, body_de, created_by, "
|
||||||
|
+ "created_at) VALUES ('s', 's', 'b', 'b', 1, NOW()) RETURNING id", Long.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
private long broadcastRow(long broadcastId, long forUser) {
|
||||||
|
return jdbc.queryForObject("INSERT INTO mail_outbox (kind, user_id, to_address, subject, body, broadcast_id) "
|
||||||
|
+ "VALUES ('BROADCAST', ?, 'x@example.com', 's', 'b', ?) RETURNING id", Long.class, forUser, broadcastId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private long user(String name, String flags) {
|
||||||
|
return jdbc.queryForObject("INSERT INTO users (email, password, enabled, status, deleted_at) "
|
||||||
|
+ "VALUES (?, 'x', " + flags + ") RETURNING id", Long.class, "mail-repo-" + name + "@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
private int counter(long broadcastId, String column) {
|
||||||
|
return jdbc.queryForObject("SELECT " + column + " FROM mail_broadcast WHERE id = ?", Integer.class, broadcastId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findDue_PutsAccountMailsBeforeBroadcastRows() {
|
||||||
|
long broadcastRow = broadcastRow(broadcast(), userId);
|
||||||
|
long account = insert();
|
||||||
|
|
||||||
|
assertThat(repository.findDue(now.plusSeconds(1), 10)).extracting(OutboxRow::id)
|
||||||
|
.containsExactly(account, broadcastRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void markSentAndMarkFailed_CountOnTheBroadcast() {
|
||||||
|
long broadcast = broadcast();
|
||||||
|
long sent = broadcastRow(broadcast, userId);
|
||||||
|
long failed = broadcastRow(broadcast, user("other", "true, 'ACTIVE', NULL"));
|
||||||
|
long account = insert();
|
||||||
|
|
||||||
|
repository.markSent(sent, now);
|
||||||
|
repository.markFailed(failed, 10, "rejected");
|
||||||
|
repository.markSent(account, now);
|
||||||
|
|
||||||
|
assertThat(counter(broadcast, "sent_count")).isEqualTo(1);
|
||||||
|
assertThat(counter(broadcast, "failed_count")).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void failBroadcastRowsOfInactiveUsers_FailsBlockedDeletedPendingAndOptedOut() {
|
||||||
|
long broadcast = broadcast();
|
||||||
|
long active = user("active", "true, 'ACTIVE', NULL");
|
||||||
|
long blocked = user("blocked", "false, 'ACTIVE', NULL");
|
||||||
|
long deleted = user("deleted", "true, 'ACTIVE', NOW()");
|
||||||
|
long pending = user("pending", "true, 'PENDING', NULL");
|
||||||
|
long optedOut = user("optedout", "true, 'ACTIVE', NULL");
|
||||||
|
for (long id : new long[]{active, blocked, deleted, pending, optedOut}) {
|
||||||
|
jdbc.update("INSERT INTO user_settings (user_id, platform_mails) VALUES (?, ?)", id, id != optedOut);
|
||||||
|
}
|
||||||
|
long keep = broadcastRow(broadcast, active);
|
||||||
|
for (long id : new long[]{blocked, deleted, pending, optedOut}) {
|
||||||
|
broadcastRow(broadcast, id);
|
||||||
|
}
|
||||||
|
long accountOfBlocked = jdbc.queryForObject("INSERT INTO mail_outbox (kind, user_id, to_address, subject, body) "
|
||||||
|
+ "VALUES ('ACCOUNT_BLOCKED', ?, 'x@example.com', 's', 'b') RETURNING id", Long.class, blocked);
|
||||||
|
|
||||||
|
assertThat(repository.failBroadcastRowsOfInactiveUsers()).isEqualTo(4);
|
||||||
|
|
||||||
|
assertThat(jdbc.queryForList("SELECT id FROM mail_outbox WHERE status = 'PENDING' ORDER BY id", Long.class))
|
||||||
|
.containsExactly(keep, accountOfBlocked);
|
||||||
|
assertThat(counter(broadcast, "failed_count")).isEqualTo(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void countSentSince_CountsOnlySentRowsAfterTheInstant() {
|
||||||
|
long recent = insert();
|
||||||
|
long old = insert();
|
||||||
|
insert();
|
||||||
|
repository.markSent(recent, now);
|
||||||
|
repository.markSent(old, now.minus(Duration.ofHours(25)));
|
||||||
|
|
||||||
|
assertThat(repository.countSentSince(now.minus(Duration.ofHours(24)))).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void findRecipient_ReadsEmailNameAndLanguage() {
|
void findRecipient_ReadsEmailNameAndLanguage() {
|
||||||
long id = jdbc.queryForObject("INSERT INTO users (email, password, enabled, display_name) "
|
long id = jdbc.queryForObject("INSERT INTO users (email, password, enabled, display_name) "
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package auth_service.modules.mail.service;
|
||||||
|
|
||||||
|
import auth_service.modules.mail.exception.NoRecipientsException;
|
||||||
|
import auth_service.modules.mail.model.BroadcastText;
|
||||||
|
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.BroadcastRepository;
|
||||||
|
import auth_service.modules.mail.repo.MailOutboxRepository;
|
||||||
|
import auth_service.shared.audit.AdminAuditLog;
|
||||||
|
import auth_service.shared.test.AbstractUnitTest;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
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.ArgumentMatchers.isNull;
|
||||||
|
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 BroadcastServiceTest extends AbstractUnitTest {
|
||||||
|
|
||||||
|
private static final long ADMIN = 1L;
|
||||||
|
private static final String SETTINGS = "https://tbp.example/settings";
|
||||||
|
private static final BroadcastText TEXT = new BroadcastText("Hi", "Hallo", "Body", "Inhalt");
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private BroadcastRepository broadcasts;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private MailOutboxRepository outbox;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private AdminAuditLog adminAuditLog;
|
||||||
|
|
||||||
|
private BroadcastService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
service = new BroadcastService(broadcasts, outbox, adminAuditLog, "https://tbp.example");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void send_QueuesBothLanguagesAuditsAndReturnsTheCount() {
|
||||||
|
when(broadcasts.insert(any(), anyLong(), any())).thenReturn(5L);
|
||||||
|
when(broadcasts.queue(eq(5L), isNull(), any(), any())).thenReturn(3);
|
||||||
|
|
||||||
|
assertThat(service.send(ADMIN, TEXT, null)).isEqualTo(3);
|
||||||
|
|
||||||
|
ArgumentCaptor<Mail> en = ArgumentCaptor.forClass(Mail.class);
|
||||||
|
ArgumentCaptor<Mail> de = ArgumentCaptor.forClass(Mail.class);
|
||||||
|
verify(broadcasts).queue(eq(5L), isNull(), en.capture(), de.capture());
|
||||||
|
assertThat(en.getValue().subject()).isEqualTo("Hi");
|
||||||
|
assertThat(en.getValue().body()).startsWith("Body").contains("You get this mail").endsWith(SETTINGS + "\n");
|
||||||
|
assertThat(de.getValue().subject()).isEqualTo("Hallo");
|
||||||
|
assertThat(de.getValue().body()).startsWith("Inhalt").contains("Du bekommst diese Mail").endsWith(SETTINGS + "\n");
|
||||||
|
verify(broadcasts).setRecipientCount(5L, 3);
|
||||||
|
verify(adminAuditLog).record(ADMIN, "BROADCAST_SEND", null, "5");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void send_RefusesZeroRecipients() {
|
||||||
|
when(broadcasts.insert(any(), anyLong(), any())).thenReturn(5L);
|
||||||
|
when(broadcasts.queue(anyLong(), any(), any(), any())).thenReturn(0);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.send(ADMIN, TEXT, List.of(9L))).isInstanceOf(NoRecipientsException.class);
|
||||||
|
|
||||||
|
verify(broadcasts, never()).setRecipientCount(anyLong(), anyInt());
|
||||||
|
verifyNoInteractions(adminAuditLog);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sendTest_MailsOnlyTheAdminInTheirLanguage() {
|
||||||
|
when(outbox.findRecipient(ADMIN)).thenReturn(Optional.of(new Recipient("admin@example.com", "Admin", "de")));
|
||||||
|
|
||||||
|
service.sendTest(ADMIN, TEXT);
|
||||||
|
|
||||||
|
ArgumentCaptor<Mail> mail = ArgumentCaptor.forClass(Mail.class);
|
||||||
|
verify(outbox).insert(eq(MailTemplate.BROADCAST), eq(ADMIN), eq("admin@example.com"), mail.capture());
|
||||||
|
assertThat(mail.getValue().subject()).isEqualTo("Hallo");
|
||||||
|
assertThat(mail.getValue().body()).startsWith("Inhalt");
|
||||||
|
verifyNoInteractions(broadcasts, adminAuditLog);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import auth_service.modules.mail.repo.MailOutboxRepository;
|
|||||||
import auth_service.shared.test.AbstractUnitTest;
|
import auth_service.shared.test.AbstractUnitTest;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.mockito.ArgumentCaptor;
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.InOrder;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.springframework.beans.factory.ObjectProvider;
|
import org.springframework.beans.factory.ObjectProvider;
|
||||||
import org.springframework.mail.MailSendException;
|
import org.springframework.mail.MailSendException;
|
||||||
@@ -22,6 +23,7 @@ import static org.mockito.ArgumentMatchers.anyInt;
|
|||||||
import static org.mockito.ArgumentMatchers.anyLong;
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.doThrow;
|
import static org.mockito.Mockito.doThrow;
|
||||||
|
import static org.mockito.Mockito.inOrder;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.verifyNoInteractions;
|
import static org.mockito.Mockito.verifyNoInteractions;
|
||||||
@@ -43,11 +45,15 @@ class MailOutboxServiceTest extends AbstractUnitTest {
|
|||||||
|
|
||||||
private MailOutboxService service() {
|
private MailOutboxService service() {
|
||||||
when(senderProvider.getObject()).thenReturn(sender);
|
when(senderProvider.getObject()).thenReturn(sender);
|
||||||
return new MailOutboxService(repository, senderProvider, "smtp.example.com", FROM, ADMIN);
|
return new MailOutboxService(repository, senderProvider, "smtp.example.com", FROM, ADMIN, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static OutboxRow row(long id, int attempts) {
|
private static OutboxRow row(long id, int attempts) {
|
||||||
return new OutboxRow(id, "ACCOUNT_BLOCKED", "alice@example.com", "Subject", "Body", attempts);
|
return new OutboxRow(id, "ACCOUNT_BLOCKED", "alice@example.com", "Subject", "Body", attempts, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OutboxRow broadcastRow(long id) {
|
||||||
|
return new OutboxRow(id, "BROADCAST", "alice@example.com", "Subject", "Body", 0, 1L);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -125,7 +131,7 @@ class MailOutboxServiceTest extends AbstractUnitTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void sendDue_DoesNothingWithoutAHost() {
|
void sendDue_DoesNothingWithoutAHost() {
|
||||||
MailOutboxService off = new MailOutboxService(repository, senderProvider, "", "", "");
|
MailOutboxService off = new MailOutboxService(repository, senderProvider, "", "", "", 100);
|
||||||
|
|
||||||
off.sendDue();
|
off.sendDue();
|
||||||
|
|
||||||
@@ -134,9 +140,54 @@ class MailOutboxServiceTest extends AbstractUnitTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void constructor_RefusesAHostWithoutSenderOrAdminAddress() {
|
void constructor_RefusesAHostWithoutSenderOrAdminAddress() {
|
||||||
assertThatThrownBy(() -> new MailOutboxService(repository, senderProvider, "smtp.example.com", "", ADMIN))
|
assertThatThrownBy(() -> new MailOutboxService(repository, senderProvider, "smtp.example.com", "", ADMIN, 100))
|
||||||
.isInstanceOf(IllegalStateException.class).hasMessageContaining("APP_MAIL_FROM");
|
.isInstanceOf(IllegalStateException.class).hasMessageContaining("APP_MAIL_FROM");
|
||||||
assertThatThrownBy(() -> new MailOutboxService(repository, senderProvider, "smtp.example.com", FROM, " "))
|
assertThatThrownBy(() -> new MailOutboxService(repository, senderProvider, "smtp.example.com", FROM, " ", 100))
|
||||||
.isInstanceOf(IllegalStateException.class).hasMessageContaining("APP_MAIL_ADMIN_TO");
|
.isInstanceOf(IllegalStateException.class).hasMessageContaining("APP_MAIL_ADMIN_TO");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sendDue_SendsAccountMailsAndStopsBroadcastRowsAtTheReserve() {
|
||||||
|
when(senderProvider.getObject()).thenReturn(sender);
|
||||||
|
MailOutboxService small = new MailOutboxService(repository, senderProvider, "smtp.example.com", FROM, ADMIN, 12);
|
||||||
|
when(repository.countSentSince(any())).thenReturn(0);
|
||||||
|
when(repository.findDue(any(), anyInt()))
|
||||||
|
.thenReturn(List.of(row(1, 0), broadcastRow(2), broadcastRow(3), broadcastRow(4)));
|
||||||
|
|
||||||
|
small.sendDue();
|
||||||
|
|
||||||
|
verify(repository).markSent(eq(1L), any());
|
||||||
|
verify(repository).markSent(eq(2L), any());
|
||||||
|
verify(repository, never()).markSent(eq(3L), any());
|
||||||
|
verify(repository, never()).markSent(eq(4L), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sendDue_SendsNothingAboveTheDailyLimit() {
|
||||||
|
when(repository.countSentSince(any())).thenReturn(100);
|
||||||
|
when(repository.findDue(any(), eq(0))).thenReturn(List.of());
|
||||||
|
|
||||||
|
service().sendDue();
|
||||||
|
|
||||||
|
verify(repository).findDue(any(), eq(0));
|
||||||
|
verifyNoInteractions(sender);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sendDue_FailsRowsOfInactiveUsersBeforeTheSend() {
|
||||||
|
when(repository.findDue(any(), anyInt())).thenReturn(List.of());
|
||||||
|
|
||||||
|
service().sendDue();
|
||||||
|
|
||||||
|
InOrder order = inOrder(repository);
|
||||||
|
order.verify(repository).failBroadcastRowsOfInactiveUsers();
|
||||||
|
order.verify(repository).findDue(any(), anyInt());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void constructorRefusesADailyLimitAtOrBelowTheReserve() {
|
||||||
|
assertThatThrownBy(() -> new MailOutboxService(repository, senderProvider, "smtp.example.com", FROM, ADMIN,
|
||||||
|
MailOutboxService.ACCOUNT_RESERVE))
|
||||||
|
.isInstanceOf(IllegalStateException.class).hasMessageContaining("APP_MAIL_DAILY_LIMIT");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -490,6 +490,22 @@ class UserControllerTest extends AbstractWebMvcTest {
|
|||||||
verify(userService, never()).updateFavoritePlatform(anyLong(), any());
|
verify(userService, never()).updateFavoritePlatform(anyLong(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updatePlatformMails_StoresTheFlagAndRefusesAMissingOne() throws Exception {
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
|
||||||
|
new UserPrincipal("1", null), null, Collections.emptyList()));
|
||||||
|
|
||||||
|
mockMvc.perform(put("/api/users/settings/platform-mails")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content("{\"enabled\":false}"))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
mockMvc.perform(put("/api/users/settings/platform-mails")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content("{}"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
|
||||||
|
verify(userService).updatePlatformMails(1L, false);
|
||||||
|
verify(userService, never()).updatePlatformMails(anyLong(), eq(true));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void updateProfileTiles_StoresTheTiles() throws Exception {
|
void updateProfileTiles_StoresTheTiles() throws Exception {
|
||||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
|
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
|
||||||
|
|||||||
@@ -251,6 +251,22 @@ class UserRepositoryTest extends AbstractPersistenceTest {
|
|||||||
assertTrue(findByEmail("public@example.com").orElseThrow().getSettings().isPublicProfile());
|
assertTrue(findByEmail("public@example.com").orElseThrow().getSettings().isPublicProfile());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updatePlatformMails_IsKeptByOtherSettingsWrites() {
|
||||||
|
var userEntity = persisted(UserEntity.builder().email("optout@example.com").password("password")
|
||||||
|
.enabled(true).registrationDate(LocalDateTime.now()).build());
|
||||||
|
assertTrue(findByEmail("optout@example.com").orElseThrow().getSettings().isPlatformMails());
|
||||||
|
|
||||||
|
userRepository.update(userEntity.getId(), user -> user.getSettings().setPlatformMails(false));
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
userRepository.update(userEntity.getId(), user -> user.getSettings().setFavoritePlatform("PS5"));
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
assertFalse(findByEmail("optout@example.com").orElseThrow().getSettings().isPlatformMails());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void updateSettings_KeepsTheAppearance() {
|
void updateSettings_KeepsTheAppearance() {
|
||||||
var userEntity = persisted(UserEntity.builder()
|
var userEntity = persisted(UserEntity.builder()
|
||||||
|
|||||||
@@ -195,6 +195,13 @@ class UserServiceTest extends AbstractUnitTest {
|
|||||||
assertEquals("Switch", appliedChanges(1).getFirst().getSettings().getFavoritePlatform());
|
assertEquals("Switch", appliedChanges(1).getFirst().getSettings().getFavoritePlatform());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updatePlatformMails_StoresTheFlag() {
|
||||||
|
userService.updatePlatformMails(1L, false);
|
||||||
|
|
||||||
|
org.junit.jupiter.api.Assertions.assertFalse(appliedChanges(1).getFirst().getSettings().isPlatformMails());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void markWelcomeSeen_StampsNow() {
|
void markWelcomeSeen_StampsNow() {
|
||||||
userService.markWelcomeSeen(1L);
|
userService.markWelcomeSeen(1L);
|
||||||
|
|||||||
Reference in new issue
Block a user