Несколько ящиков у одного логина, общие ящики, кто ответил, вход ящик*логин
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
@@ -8,6 +8,7 @@ import com.mailserver.repository.UserRepository;
import com.mailserver.service.DkimService;
import com.mailserver.service.LocalDelivery;
import com.mailserver.service.MailLists;
import com.mailserver.service.MailboxAccess;
import com.mailserver.service.UserService;
import org.springframework.stereotype.Service;
@@ -38,9 +39,12 @@ public class MailRouter implements SmtpBackend {
private final DkimService dkim;
private final SmtpDeliveryClient client;
private final MailLists lists;
private final MailboxAccess access;
public MailRouter(MailDomains domains, UserRepository users, UserService userService, LocalDelivery localDelivery,
OutboundQueue queue, DkimService dkim, SmtpDeliveryClient client, MailLists lists) {
OutboundQueue queue, DkimService dkim, SmtpDeliveryClient client, MailLists lists,
MailboxAccess access) {
this.access = access;
this.domains = domains;
this.users = users;
this.userService = userService;
@@ -52,9 +56,9 @@ public class MailRouter implements SmtpBackend {
}
@Override
public boolean authenticate(String username, String password) {
User user = users.findByEmail(username.toLowerCase(Locale.ROOT)).block();
return user != null && user.isActive() && userService.checkPassword(user, password);
public String authenticate(String username, String password) {
User mailbox = access.login(username, password);
return mailbox == null ? null : mailbox.getEmail();
}
@Override
@@ -32,8 +32,11 @@ public interface SmtpBackend {
record Envelope(String remoteIp, String helo, String mailFrom, String authenticatedUser, List<Recipient> recipients) {
}
/** true — the address and password belong to an active mailbox. */
boolean authenticate(String username, String password);
/**
* {@code login} or {@code mailbox*login} with the login's password → the mailbox address the session works as,
* or null.
*/
String authenticate(String username, String password);
Route route(String recipient, String authenticatedUser);
@@ -373,12 +373,13 @@ public class SmtpServer {
closing = true;
return reply("421 4.7.0 Too many failed logins, try again in " + LoginGuard.human(wait));
}
return blocking(() -> backend.authenticate(username, password))
.flatMap(ok -> {
if (ok) {
return blocking(() -> java.util.Optional.ofNullable(backend.authenticate(username, password)))
.flatMap(found -> {
if (found.isPresent()) {
guard.success(remoteIp, username);
authenticatedUser = username;
log.info("SMTP {}: authenticated as {}", remoteIp, username);
authenticatedUser = found.get();
log.info("SMTP {}: authenticated as {}{}", remoteIp, found.get(),
found.get().equalsIgnoreCase(username) ? "" : " (login " + username + ")");
return Mono.just("235 2.7.0 Authentication successful\r\n");
}
guard.failure(remoteIp, username);