Несколько ящиков у одного логина, общие ящики, кто ответил, вход ящик*логин
continuous-integration/drone/pr Build is passing

- V7: users.is_shared, mailbox_access (MEMBER от админа/Jambotron, ATTACHED по паролю ящика), messages.answered_by
- /api/mailboxes: мои ящики с непрочитанными, сессия другого ящика (JWT с claim by), подключить по паролю
  (LoginGuard), убрать из меню
- общий ящик: без своего входа (веб/SMTP/IMAP), участники открывают; «прочитано» общее; ответ из веба → answered_by
- SMTP/IMAP: ящик*логин + пароль логина (как master user в Dovecot)
- открытый чужой ящик (общий, подключённый, OPEN админа): профиль и пароль не меняются (403)
- админ: создать shared, участники ящика (PUT /{id}/members); служебное API: PUT /shared/{address};
  выключение аккаунта убирает и из общих ящиков
- UI: MAILBOXES в левом меню (переключение, ⊕ подключить, × убрать), shared/участники в настройках,
  «Answered by» у письма
- 88 тестов; живой прогон на Postgres 17 (апгрейд V6→V7 на данных), скриншоты UI

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 14:27:11 +01:00
co-authored by Claude Opus 5.5
parent cf84797e68
commit c765c51cf0
36 changed files with 751 additions and 60 deletions
@@ -35,8 +35,9 @@ class MailboxAdminControllerTest {
private final MailAdmins admins = new MailAdmins("", "", domains);
private final com.mailserver.util.JwtUtils jwt = mock(com.mailserver.util.JwtUtils.class);
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 com.mailserver.service.MailboxAccess access = mock(com.mailserver.service.MailboxAccess.class);
private final com.mailserver.service.MailDirectory directory = new com.mailserver.service.MailDirectory(users, lists, domains, access);
private final MailboxAdminController controller = new MailboxAdminController(users, mail, domains, admins, jwt, directory, lists, access);
private final User admin = user(1L, "admin@jambocheck.com");
private final User alice = user(2L, "alice@jambocheck.com");
@@ -44,6 +45,7 @@ class MailboxAdminControllerTest {
MailboxAdminControllerTest() {
when(mail.usedBytes(anyLong())).thenReturn(Mono.just(0L));
when(lists.exists(anyString())).thenReturn(Mono.just(false));
when(access.members(anyLong())).thenReturn(Flux.empty());
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));
@@ -89,6 +91,26 @@ class MailboxAdminControllerTest {
.isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
void sharedMailboxHasNoPasswordShownMembersAreSet() {
when(users.setShared(any(), any(Boolean.class))).thenAnswer(i -> {
User u = i.getArgument(0);
u.setShared(i.getArgument(1));
return Mono.just(u);
});
var created = controller.create(as("admin@jambocheck.com"),
Mono.just(new MailboxAdminController.CreateRequest("support@jambocheck.com", "Support", "", true))).block();
assertThat(created.password()).isNull();
assertThat(created.mailbox().shared()).isTrue();
when(access.setMembers(anyLong(), any())).thenReturn(Mono.empty());
controller.members(as("admin@jambocheck.com"), 2L,
Mono.just(new MailboxAdminController.MembersRequest(List.of("Alice@jambocheck.com")))).block();
verify(access).setMembers(eq(2L), eq(List.of(2L)));
assertThat(status(() -> controller.members(as("admin@jambocheck.com"), 2L,
Mono.just(new MailboxAdminController.MembersRequest(List.of("ghost@jambocheck.com")))).block())).isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
void administratorSwitchesTrapOthersCanNot() {
assertThat(controller.trap(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.TrapRequest(true))).block().trap()).isTrue();
@@ -117,14 +139,14 @@ class MailboxAdminControllerTest {
@Test
void createsReservedNamesWithAGeneratedPasswordShownOnce() {
var created = controller.create(as("admin@jambocheck.com"),
Mono.just(new MailboxAdminController.CreateRequest(" No-Reply@JamboCheck.com ", "Jambotron", ""))).block();
Mono.just(new MailboxAdminController.CreateRequest(" No-Reply@JamboCheck.com ", "Jambotron", "", null))).block();
assertThat(created.mailbox().email()).isEqualTo("no-reply@jambocheck.com");
assertThat(created.password()).hasSizeGreaterThanOrEqualTo(20);
verify(users).register(eq("no-reply@jambocheck.com"), eq(created.password()), eq("Jambotron"));
var chosen = controller.create(as("admin@jambocheck.com"),
Mono.just(new MailboxAdminController.CreateRequest("monitor@jambocheck.com", null, "long-enough-1"))).block();
Mono.just(new MailboxAdminController.CreateRequest("monitor@jambocheck.com", null, "long-enough-1", null))).block();
assertThat(chosen.password()).isNull();
}
@@ -153,7 +175,7 @@ class MailboxAdminControllerTest {
@Test
void administratorOpensAnyMailboxOthersCanNot() {
when(jwt.generateToken("alice@jambocheck.com")).thenReturn("alice-token");
when(jwt.generateToken("alice@jambocheck.com", "admin@jambocheck.com")).thenReturn("alice-token");
var session = controller.open(as("admin@jambocheck.com"), 2L).block();
assertThat(session.getEmail()).isEqualTo("alice@jambocheck.com");
@@ -164,7 +186,7 @@ class MailboxAdminControllerTest {
}
private MailboxAdminController.Created create(String me, String email, String password) {
return controller.create(as(me), Mono.just(new MailboxAdminController.CreateRequest(email, null, password))).block();
return controller.create(as(me), Mono.just(new MailboxAdminController.CreateRequest(email, null, password, null))).block();
}
private static Mono<Authentication> as(String email) {