Рассылки, папки по +метке, ловушка только для чужих адресов, служебное API для Jambotron
continuous-integration/drone/pr Build is passing

- V6 mail_lists + mail_list_members: письмо на alerts-prod@ → каждому участнику своя копия в папку рассылки;
  закрытая рассылка — только от вошедших отправителей (550 снаружи)
- bob+prod@ → ящик bob@, папка prod (создаётся сама; системные папки нельзя)
- TRAP: чужие адреса → одна копия в INBOX отправителя; наши ящики и рассылки доставляются
- /api/service/** (SERVICE_TOKEN из secrets-init): аккаунты (пароль один раз, выключение → вон из рассылок), рассылки
- MailDirectory: одно пространство адресов для ящиков и рассылок, без «+» в именах
- Настройки → LISTS для администраторов
- 86 тестов; живой прогон на Postgres 17: миграции V1–V6, API, SMTP (рассылка/метка/ловушка)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 14:15:24 +01:00
co-authored by Claude Opus 5.5
parent 3603b9d315
commit cf84797e68
28 changed files with 1013 additions and 71 deletions
@@ -0,0 +1,19 @@
package com.mailserver.config;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/** "+tag" addresses: the base address is the mailbox, the tag is the folder. */
class MailDomainsTest {
@Test
void baseAndTag() {
assertThat(MailDomains.baseOf("Bob+Prod@Mail.test")).isEqualTo("bob@mail.test");
assertThat(MailDomains.tagOf("Bob+Prod@Mail.test")).isEqualTo("Prod");
assertThat(MailDomains.baseOf("bob@mail.test")).isEqualTo("bob@mail.test");
assertThat(MailDomains.tagOf("bob@mail.test")).isNull();
assertThat(MailDomains.tagOf("bob+@mail.test")).isNull();
assertThat(MailDomains.tagOf("+x@mail.test")).isNull();
}
}
@@ -34,13 +34,22 @@ class MailboxAdminControllerTest {
private final MailDomains domains = new MailDomains("jambocheck.com", "");
private final MailAdmins admins = new MailAdmins("", "", domains);
private final com.mailserver.util.JwtUtils jwt = mock(com.mailserver.util.JwtUtils.class);
private final MailboxAdminController controller = new MailboxAdminController(users, mail, domains, admins, jwt);
private final com.mailserver.service.MailLists lists = mock(com.mailserver.service.MailLists.class);
private final com.mailserver.service.MailDirectory directory = new com.mailserver.service.MailDirectory(users, lists, domains);
private final MailboxAdminController controller = new MailboxAdminController(users, mail, domains, admins, jwt, directory, lists);
private final User admin = user(1L, "admin@jambocheck.com");
private final User alice = user(2L, "alice@jambocheck.com");
MailboxAdminControllerTest() {
when(mail.usedBytes(anyLong())).thenReturn(Mono.just(0L));
when(lists.exists(anyString())).thenReturn(Mono.just(false));
when(lists.exists("alerts@jambocheck.com")).thenReturn(Mono.just(true));
when(users.findOptional(anyString())).thenReturn(Mono.empty());
when(users.findOptional("alice@jambocheck.com")).thenReturn(Mono.just(alice));
when(lists.save(anyString(), any(), any(), any(Boolean.class), any())).thenAnswer(i ->
Mono.just(new com.mailserver.service.MailLists.MailList(5L, i.getArgument(0), i.getArgument(1), i.getArgument(2),
i.getArgument(3), List.of("alice@jambocheck.com"))));
when(users.all()).thenReturn(Flux.just(alice, admin));
when(users.byId(1L)).thenReturn(Mono.just(admin));
when(users.byId(2L)).thenReturn(Mono.just(alice));
@@ -59,6 +68,27 @@ class MailboxAdminControllerTest {
});
}
@Test
void mailboxAndListShareOneAddressSpaceNoPlusInNames() {
assertThat(status(() -> create("admin@jambocheck.com", "alerts@jambocheck.com", ""))).isEqualTo(HttpStatus.CONFLICT);
assertThat(status(() -> create("admin@jambocheck.com", "bob+x@jambocheck.com", ""))).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(create("admin@jambocheck.com", "jambotron_prod@jambocheck.com", "").mailbox().email()).isEqualTo("jambotron_prod@jambocheck.com");
var request = new MailboxAdminController.ListRequest("prod", "alerts", false, List.of("Alice@jambocheck.com"));
assertThat(controller.saveList(as("admin@jambocheck.com"), "alerts-prod@jambocheck.com", Mono.just(request)).block().members())
.containsExactly("alice@jambocheck.com");
assertThat(status(() -> controller.saveList(as("admin@jambocheck.com"), "alice@jambocheck.com", Mono.just(request)).block()))
.isEqualTo(HttpStatus.CONFLICT);
var unknown = new MailboxAdminController.ListRequest("prod", null, false, List.of("ghost@jambocheck.com"));
assertThat(status(() -> controller.saveList(as("admin@jambocheck.com"), "alerts-prod@jambocheck.com", Mono.just(unknown)).block()))
.isEqualTo(HttpStatus.BAD_REQUEST);
var badFolder = new MailboxAdminController.ListRequest("Trash", null, false, List.of());
assertThat(status(() -> controller.saveList(as("admin@jambocheck.com"), "alerts-prod@jambocheck.com", Mono.just(badFolder)).block()))
.isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(status(() -> controller.saveList(as("alice@jambocheck.com"), "alerts-prod@jambocheck.com", Mono.just(request)).block()))
.isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
void administratorSwitchesTrapOthersCanNot() {
assertThat(controller.trap(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.TrapRequest(true))).block().trap()).isTrue();
@@ -0,0 +1,106 @@
package com.mailserver.controller;
import com.mailserver.config.MailDomains;
import com.mailserver.domain.User;
import com.mailserver.service.MailDirectory;
import com.mailserver.service.MailLists;
import com.mailserver.service.UserService;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/** Service API for Jambotron: token only; account created once with a password; switched off → out of the lists. */
class ServiceControllerTest {
private static final String AUTH = "Bearer s3cret-token";
private final UserService users = mock(UserService.class);
private final MailLists lists = mock(MailLists.class);
private final MailDomains domains = new MailDomains("jambocheck.com", "");
private final MailDirectory directory = new MailDirectory(users, lists, domains);
private final User existing = user(7L, "jambotronpw@jambocheck.com");
ServiceControllerTest() {
when(users.findOptional(anyString())).thenReturn(Mono.empty());
when(users.findOptional("jambotronpw@jambocheck.com")).thenReturn(Mono.just(existing));
when(lists.exists(anyString())).thenReturn(Mono.just(false));
when(lists.listsOf(anyLong())).thenReturn(Flux.just("alerts-prod@jambocheck.com"));
when(lists.removeEverywhere(anyLong())).thenReturn(Mono.just(1L));
when(users.register(anyString(), anyString(), any())).thenAnswer(i -> Mono.just(user(8L, i.getArgument(0))));
when(users.setActive(any(), any(Boolean.class))).thenAnswer(i -> {
User u = i.getArgument(0);
u.setActive(i.getArgument(1));
return Mono.just(u);
});
when(users.setPassword(any(), anyString())).thenAnswer(i -> Mono.just((User) i.getArgument(0)));
}
private ServiceController api(String token) {
return new ServiceController(token, users, lists, directory);
}
@Test
void offWithoutTokenWrongTokenRefused() {
assertThat(status(() -> api("").account(AUTH, "jambotronpw@jambocheck.com"))).isEqualTo(HttpStatus.NOT_FOUND);
assertThat(status(() -> api("s3cret-token").account("Bearer nope", "jambotronpw@jambocheck.com"))).isEqualTo(HttpStatus.UNAUTHORIZED);
assertThat(status(() -> api("s3cret-token").account(null, "jambotronpw@jambocheck.com"))).isEqualTo(HttpStatus.UNAUTHORIZED);
assertThat(api("s3cret-token").account(AUTH, "jambotronpw@jambocheck.com").block().lists())
.containsExactly("alerts-prod@jambocheck.com");
}
@Test
void newAccountGetsPasswordOnceExistingDoesNot() {
var created = api("s3cret-token").saveAccount(AUTH, "moder@jambocheck.com",
Mono.just(new ServiceController.AccountRequest("Moder", true))).block();
assertThat(created.getStatusCode().value()).isEqualTo(201);
assertThat(created.getBody().password()).hasSizeGreaterThan(20);
var again = api("s3cret-token").saveAccount(AUTH, "jambotronpw@jambocheck.com",
Mono.just(new ServiceController.AccountRequest(null, null))).block();
assertThat(again.getStatusCode().value()).isEqualTo(200);
assertThat(again.getBody().password()).isNull();
verify(lists, never()).removeEverywhere(anyLong());
assertThat(status(() -> api("s3cret-token").saveAccount(AUTH, "x@gmail.com", Mono.empty()))).isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
void switchedOffLeavesEveryList() {
var off = api("s3cret-token").saveAccount(AUTH, "jambotronpw@jambocheck.com",
Mono.just(new ServiceController.AccountRequest(null, false))).block();
assertThat(off.getBody().account().active()).isFalse();
verify(lists).removeEverywhere(7L);
var reset = api("s3cret-token").password(AUTH, "jambotronpw@jambocheck.com").block();
assertThat(reset.password()).isNotBlank();
verify(users).setPassword(existing, reset.password());
}
private static HttpStatus status(Runnable call) {
Throwable error = catchThrowable(call::run);
assertThat(error).isInstanceOf(ResponseStatusException.class);
return HttpStatus.valueOf(((ResponseStatusException) error).getStatusCode().value());
}
private static User user(Long id, String email) {
User user = new User();
user.setId(id);
user.setEmail(email);
user.setPassword("hash");
return user;
}
}
@@ -0,0 +1,21 @@
package com.mailserver.service;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/** A "+tag" never puts mail into a system folder and never makes a strange folder name. */
class DeliveryFolderTest {
@Test
void onlyPlainOwnFolderNames() {
assertThat(MailService.deliveryFolderName("prod")).isEqualTo("prod");
assertThat(MailService.deliveryFolderName("alerts.db-1")).isEqualTo("alerts.db-1");
assertThat(MailService.deliveryFolderName(null)).isNull();
assertThat(MailService.deliveryFolderName("inbox")).isNull();
assertThat(MailService.deliveryFolderName("Trash")).isNull();
assertThat(MailService.deliveryFolderName("spam")).isNull();
assertThat(MailService.deliveryFolderName("../x")).isNull();
assertThat(MailService.deliveryFolderName("a".repeat(51))).isNull();
}
}
@@ -7,6 +7,7 @@ import com.mailserver.domain.User;
import com.mailserver.repository.UserRepository;
import com.mailserver.service.DkimService;
import com.mailserver.service.LocalDelivery;
import com.mailserver.service.MailLists;
import com.mailserver.service.UserService;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
@@ -18,6 +19,7 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -30,6 +32,7 @@ class MailRouterTest {
private final LocalDelivery localDelivery = mock(LocalDelivery.class);
private final OutboundQueue queue = mock(OutboundQueue.class);
private final SmtpDeliveryClient client = mock(SmtpDeliveryClient.class);
private final MailLists lists = mock(MailLists.class);
private SmtpDeliveryClient.Mode mode = SmtpDeliveryClient.Mode.OFF;
@@ -46,9 +49,16 @@ class MailRouterTest {
when(users.findByEmail(anyString())).thenReturn(Mono.empty());
when(users.findByEmail("bob@mail.test")).thenReturn(Mono.just(user("bob@mail.test", false)));
when(users.findByEmail("staging@mail.test")).thenReturn(Mono.just(user("staging@mail.test", true)));
when(users.existsByEmail("staging@mail.test")).thenReturn(Mono.just(true));
when(users.existsByEmail("ann@mail.test")).thenReturn(Mono.just(true));
when(client.mode()).thenReturn(mode);
when(lists.find(anyString())).thenReturn(Mono.empty());
when(lists.find("alerts-prod@mail.test")).thenReturn(Mono.just(new MailLists.MailList(1L, "alerts-prod@mail.test",
"prod", null, false, List.of("bob@mail.test", "ann@mail.test"))));
when(lists.find("talk@mail.test")).thenReturn(Mono.just(new MailLists.MailList(2L, "talk@mail.test",
null, null, true, List.of("bob@mail.test"))));
return new MailRouter(new MailDomains("mail.test", catchAll), users, mock(UserService.class), localDelivery,
queue, mock(DkimService.class), client);
queue, mock(DkimService.class), client, lists);
}
@Test
@@ -74,28 +84,70 @@ class MailRouterTest {
new SmtpBackend.Recipient("user@gmail.com", SmtpBackend.Route.LOCAL),
new SmtpBackend.Recipient("bob@mail.test", SmtpBackend.Route.LOCAL))), "Subject: hi\r\n\r\nbody".getBytes());
verify(localDelivery).deliver(any(), eq("trap@mail.test"), eq("10.0.0.5"), eq("no-reply@jambotron.com"), anyBoolean());
verify(localDelivery).deliver(any(), eq("bob@mail.test"), eq("10.0.0.5"), eq("no-reply@jambotron.com"), anyBoolean());
verify(localDelivery).deliver(any(), eq("trap@mail.test"), isNull(), eq("10.0.0.5"), eq("no-reply@jambotron.com"), anyBoolean());
verify(localDelivery).deliver(any(), eq("bob@mail.test"), isNull(), eq("10.0.0.5"), eq("no-reply@jambotron.com"), anyBoolean());
verify(queue, never()).enqueue(any(), any(), any());
}
@Test
void trapMailboxSendsNowhereOneCopyLandsInItsOwnInbox() throws Exception {
void trapMailboxKeepsOutsideMailButOurAddressesStillGetIt() throws Exception {
mode = SmtpDeliveryClient.Mode.MX;
MailRouter router = router("");
// an ordinary mailbox still sends outside; the trap one keeps everything
// an ordinary mailbox still sends outside; the trap one keeps outside mail, ours is delivered
assertThat(router.route("user@gmail.com", "bob@mail.test")).isEqualTo(SmtpBackend.Route.OUTBOUND);
assertThat(router.route("user@gmail.com", "staging@mail.test")).isEqualTo(SmtpBackend.Route.LOCAL);
assertThat(router.route("nobody@mail.test", "Staging@Mail.test")).isEqualTo(SmtpBackend.Route.LOCAL);
assertThat(router.route("user@gmail.com", "staging@mail.test")).isEqualTo(SmtpBackend.Route.TRAP);
assertThat(router.route("user@gmail.com", "Staging@Mail.test")).isEqualTo(SmtpBackend.Route.TRAP);
assertThat(router.route("nobody@mail.test", "staging@mail.test")).isEqualTo(SmtpBackend.Route.TRAP);
assertThat(router.route("bob@mail.test", "staging@mail.test")).isEqualTo(SmtpBackend.Route.LOCAL);
assertThat(router.route("alerts-prod@mail.test", "staging@mail.test")).isEqualTo(SmtpBackend.Route.LOCAL);
router.accept(new SmtpBackend.Envelope("10.0.0.5", "staging", "staging@mail.test", "staging@mail.test", List.of(
new SmtpBackend.Recipient("user@gmail.com", SmtpBackend.Route.LOCAL),
new SmtpBackend.Recipient("user@gmail.com", SmtpBackend.Route.TRAP),
new SmtpBackend.Recipient("other@yahoo.com", SmtpBackend.Route.TRAP),
new SmtpBackend.Recipient("bob@mail.test", SmtpBackend.Route.LOCAL))), "Subject: hi\r\n\r\nbody".getBytes());
verify(localDelivery).deliver(any(), eq("staging@mail.test"), eq("10.0.0.5"), eq("staging@mail.test"), eq(true));
verify(localDelivery, never()).deliver(any(), eq("bob@mail.test"), any(), any(), anyBoolean());
verify(localDelivery).deliver(any(), eq("staging@mail.test"), isNull(), eq("10.0.0.5"), eq("staging@mail.test"), eq(true));
verify(localDelivery).deliver(any(), eq("bob@mail.test"), isNull(), eq("10.0.0.5"), eq("staging@mail.test"), eq(true));
verify(queue, never()).enqueue(any(), any(), any());
}
@Test
void plusTagGoesToThatFolderOfTheMailbox() throws Exception {
MailRouter router = router("");
assertThat(router.route("bob+prod@mail.test", null)).isEqualTo(SmtpBackend.Route.LOCAL);
assertThat(router.route("nobody+prod@mail.test", null)).isEqualTo(SmtpBackend.Route.UNKNOWN_USER);
router.accept(new SmtpBackend.Envelope("10.0.0.5", "x", "a@b.c", null, List.of(
new SmtpBackend.Recipient("Bob+Prod@mail.test", SmtpBackend.Route.LOCAL))), "Subject: hi\r\n\r\nbody".getBytes());
verify(localDelivery).deliver(any(), eq("bob@mail.test"), eq("Prod"), eq("10.0.0.5"), eq("a@b.c"), eq(false));
}
@Test
void listGivesEveryMemberOwnCopyInTheListFolderClosedListOnlyForLoggedIn() throws Exception {
MailRouter router = router("");
assertThat(router.route("alerts-prod@mail.test", null)).isEqualTo(SmtpBackend.Route.LIST_CLOSED);
assertThat(router.route("alerts-prod@mail.test", "monitor@mail.test")).isEqualTo(SmtpBackend.Route.LOCAL);
assertThat(router.route("talk@mail.test", null)).isEqualTo(SmtpBackend.Route.LOCAL);
// list + one member directly: the member gets the list copy AND the direct one (different folders), once each
router.accept(new SmtpBackend.Envelope("10.0.0.5", "kuma", "monitor@mail.test", "monitor@mail.test", List.of(
new SmtpBackend.Recipient("alerts-prod@mail.test", SmtpBackend.Route.LOCAL),
new SmtpBackend.Recipient("ALERTS-PROD@mail.test", SmtpBackend.Route.LOCAL),
new SmtpBackend.Recipient("bob@mail.test", SmtpBackend.Route.LOCAL))), "Subject: down\r\n\r\nbody".getBytes());
verify(localDelivery).deliver(any(), eq("bob@mail.test"), eq("prod"), any(), any(), eq(true));
verify(localDelivery).deliver(any(), eq("ann@mail.test"), eq("prod"), any(), any(), eq(true));
verify(localDelivery).deliver(any(), eq("bob@mail.test"), isNull(), any(), any(), eq(true));
}
@Test
void listTagOverridesTheListFolder() {
MailRouter router = router("");
assertThat(router.targets("alerts-prod+db@mail.test")).containsExactly(
new MailRouter.Target("bob@mail.test", "db"), new MailRouter.Target("ann@mail.test", "db"));
}
}