feat(mail): send broadcasts to every active user

Remove the platform_mails opt-out. Broadcasts go to all active users.
V28 drops the column; the PUT /api/users/settings/platform-mails
endpoint and the footer line about turning mails off are gone.
This commit is contained in:
lobo committed 2026-10-02 07:53:35 +02:00
1 parent 0af76da9c7
commit 1b7bafd9f0
25 files changed
+65 -151

No files matched your search

@@ -2,6 +2,7 @@ package auth_service.modules.mail.api;
import auth_service.configuration.security.ClientHeaderFilter;
import auth_service.modules.mail.service.MailOutboxService;
import auth_service.shared.account.ActiveUser;
import auth_service.shared.api.AuthCookies;
import auth_service.shared.test.AbstractIntegrationTest;
import jakarta.servlet.http.Cookie;
@@ -34,7 +35,6 @@ 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";
@@ -50,16 +50,16 @@ class BroadcastFlowTest extends AbstractIntegrationTest {
@Autowired
private MailOutboxService mailOutboxService;
private long optedOutId;
private List<Long> foreignOptedIn;
private long adminId;
private long playerId;
private long blockedId;
private long insertUser(String email, String role, String language, boolean platformMails, boolean enabled) {
private long insertUser(String email, String role, String language, 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);
jdbc.update("UPDATE user_settings SET language = ? WHERE user_id = ?", language, id);
return id;
}
@@ -68,23 +68,15 @@ class BroadcastFlowTest extends AbstractIntegrationTest {
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);
adminId = insertUser(ADMIN, "PLATFORM_ADMIN", "de", true);
playerId = insertUser(PLAYER, "USER", "en", true);
blockedId = insertUser(BLOCKED, "USER", "en", true);
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 {
@@ -109,11 +101,16 @@ class BroadcastFlowTest extends AbstractIntegrationTest {
}
@Test
void sendToAll_MailsActiveOptedInUsersInTheirLanguageAndCountsTheSend() throws Exception {
void sendToPicked_MailsActiveUsersInTheirLanguageAndCountsTheSend() 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())
String picked = adminId + "," + playerId + "," + blockedId;
send(get(BASE + "/recipient-count?userIds=" + picked), admin).andExpect(status().isOk())
.andExpect(jsonPath("$.count").value(2));
long active = jdbc.queryForObject("SELECT COUNT(*) FROM users u WHERE " + ActiveUser.of("u"), Long.class);
send(get(BASE + "/recipient-count"), admin).andExpect(jsonPath("$.count").value(active));
send(post(BASE).content(broadcast("Hallo", "[" + picked + "]")), admin).andExpect(status().isCreated())
.andExpect(jsonPath("$.recipientCount").value(2));
assertThat(rows()).extracting(row -> row.get("to_address") + " " + row.get("subject") + " " + row.get("kind"))
@@ -136,7 +133,7 @@ class BroadcastFlowTest extends AbstractIntegrationTest {
void pickedAndTest_StayNarrow() throws Exception {
Cookie admin = login(ADMIN);
send(post(BASE).content(broadcast("Hallo", "[" + optedOutId + "]")), admin).andExpect(status().isConflict())
send(post(BASE).content(broadcast("Hallo", "[" + blockedId + "]")), admin).andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("NO_RECIPIENTS"));
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM mail_broadcast", Long.class)).isZero();
@@ -27,12 +27,12 @@ class MailTemplateTest extends AbstractUnitTest {
}
@Test
void render_Broadcast_PutsSubjectBodyAndTheSettingsLink() {
void render_Broadcast_PutsSubjectAndBody() {
for (String language : new String[]{"de", "en"}) {
Mail mail = MailTemplate.BROADCAST.render(language, "50% off", "Hello 100%s", "https://tbp.example/settings");
Mail mail = MailTemplate.BROADCAST.render(language, "50% off", "Hello 100%s");
assertThat(mail.subject()).isEqualTo("50% off");
assertThat(mail.body()).startsWith("Hello 100%s").endsWith("https://tbp.example/settings\n");
assertThat(mail.body()).startsWith("Hello 100%s").contains("ToBePlayed").doesNotContain("%1$s", "%2$s", "%3$s");
}
}
@@ -25,7 +25,6 @@ class BroadcastRepositoryTest extends AbstractPersistenceTest {
private long german;
private long noLanguage;
private long optedOut;
private long blocked;
private long pending;
private long deleted;
@@ -35,30 +34,28 @@ class BroadcastRepositoryTest extends AbstractPersistenceTest {
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);
german = user("german", "true, 'ACTIVE', NULL", "de");
noLanguage = user("nolang", "true, 'ACTIVE', NULL", "");
blocked = user("blocked", "false, 'ACTIVE', NULL", "de");
pending = user("pending", "true, 'PENDING', NULL", "de");
deleted = user("deleted", "true, 'ACTIVE', NOW()", "de");
}
private long user(String name, String flags, String language, boolean platformMails) {
private long user(String name, String flags, String language) {
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);
jdbc.update("INSERT INTO user_settings (user_id, language) VALUES (?, ?)", id, language);
return id;
}
@Test
void countRecipients_TakesOnlyActiveOptedInUsers() {
void countRecipients_TakesOnlyActiveUsers() {
// 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);
+ "WHERE u.enabled AND u.deleted_at IS NULL AND u.status = 'ACTIVE'", 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(german, blocked, pending, deleted))).isEqualTo(1);
assertThat(repository.countRecipients(List.of())).isZero();
}
@@ -66,7 +63,7 @@ class BroadcastRepositoryTest extends AbstractPersistenceTest {
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),
int queued = repository.queue(id, List.of(german, noLanguage, blocked),
new Mail("Hi", "Body"), new Mail("Hallo", "Inhalt"));
repository.setRecipientCount(id, queued);
@@ -162,28 +162,27 @@ class MailOutboxRepositoryTest extends AbstractPersistenceTest {
}
@Test
void failBroadcastRowsOfInactiveUsers_FailsBlockedDeletedPendingAndOptedOut() {
void failBroadcastRowsOfInactiveUsers_FailsBlockedDeletedAndPending() {
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);
for (long id : new long[]{active, blocked, deleted, pending}) {
jdbc.update("INSERT INTO user_settings (user_id) VALUES (?)", id);
}
long keep = broadcastRow(broadcast, active);
for (long id : new long[]{blocked, deleted, pending, optedOut}) {
for (long id : new long[]{blocked, deleted, pending}) {
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(repository.failBroadcastRowsOfInactiveUsers()).isEqualTo(3);
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);
assertThat(counter(broadcast, "failed_count")).isEqualTo(3);
}
@Test
@@ -32,7 +32,6 @@ 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
@@ -48,7 +47,7 @@ class BroadcastServiceTest extends AbstractUnitTest {
@BeforeEach
void setUp() {
service = new BroadcastService(broadcasts, outbox, adminAuditLog, "https://tbp.example");
service = new BroadcastService(broadcasts, outbox, adminAuditLog);
}
@Test
@@ -62,9 +61,9 @@ class BroadcastServiceTest extends AbstractUnitTest {
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(en.getValue().body()).startsWith("Body").contains("You get this mail").doesNotContain("settings");
assertThat(de.getValue().subject()).isEqualTo("Hallo");
assertThat(de.getValue().body()).startsWith("Inhalt").contains("Du bekommst diese Mail").endsWith(SETTINGS + "\n");
assertThat(de.getValue().body()).startsWith("Inhalt").contains("Du bekommst diese Mail").doesNotContain("settings");
verify(broadcasts).setRecipientCount(5L, 3);
verify(adminAuditLog).record(ADMIN, "BROADCAST_SEND", null, "5");
}
@@ -491,22 +491,6 @@ class UserControllerTest extends AbstractWebMvcTest {
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
void updateProfileTiles_StoresTheTiles() throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
@@ -251,22 +251,6 @@ class UserRepositoryTest extends AbstractPersistenceTest {
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
void updateSettings_KeepsTheAppearance() {
var userEntity = persisted(UserEntity.builder()
@@ -195,13 +195,6 @@ class UserServiceTest extends AbstractUnitTest {
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
void markWelcomeSeen_StampsNow() {
userService.markWelcomeSeen(1L);