Несколько ящиков у одного логина, общие ящики, кто ответил, вход ящик*логин
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
@@ -99,6 +99,10 @@ public class AuthenticationController {
return delay.isZero() ? answer : answer.delayElement(delay);
}
guard.success(ip, email);
if (user.isShared()) {
return Mono.just(ResponseEntity.badRequest().body(Map.of("error",
"This is a shared mailbox: log in with your own mailbox and open it there")));
}
if (!user.isActive()) {
return Mono.just(ResponseEntity.badRequest().body(Map.of("error", "User is deactivated")));
}
@@ -138,7 +142,7 @@ public class AuthenticationController {
@PutMapping("/me")
public Mono<ProfileResponse> updateMe(Mono<Authentication> auth, @Valid @RequestBody Mono<ProfileUpdateRequest> body) {
return user(auth).zipWith(body)
return own(auth).zipWith(body)
.flatMap(pair -> userService.updateDisplayName(pair.getT1(), pair.getT2().displayName()))
.flatMap(this::profile);
}
@@ -147,7 +151,7 @@ public class AuthenticationController {
public Mono<Map<String, String>> changePassword(Mono<Authentication> auth, @Valid @RequestBody Mono<PasswordChangeRequest> body,
ServerHttpRequest http) {
String ip = clientIp(http);
return user(auth).zipWith(body)
return own(auth).zipWith(body)
.flatMap(pair -> {
User user = pair.getT1();
// Someone with a stolen session must not guess the current password either
@@ -186,6 +190,15 @@ public class AuthenticationController {
admins.isAdmin(user.getEmail())));
}
/** The session's own mailbox — not a shared / attached / administrator-opened one: no profile changes there. */
private Mono<User> own(Mono<Authentication> auth) {
return auth.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED)))
.flatMap(a -> com.mailserver.security.Actor.opened(a)
? Mono.error(new ResponseStatusException(HttpStatus.FORBIDDEN,
"This mailbox is opened from " + com.mailserver.security.Actor.login(a) + " — change it in Settings → MAILBOXES"))
: user(Mono.just(a)));
}
private Mono<User> user(Mono<Authentication> auth) {
return auth.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED)))
.flatMap(a -> userService.findByEmail(a.getName()))
@@ -150,7 +150,8 @@ public class MailController {
@PostMapping("/send")
public Mono<MessageResponse> sendMessage(Mono<Authentication> auth, @Valid @RequestBody Mono<SendRequest> requestMono) {
return auth.zipWith(requestMono)
.flatMap(pair -> submissionService.send(pair.getT1().getName(), pair.getT2()));
.flatMap(pair -> submissionService.send(pair.getT1().getName(),
com.mailserver.security.Actor.login(pair.getT1()), pair.getT2()));
}
// --- Drafts ---
@@ -6,6 +6,7 @@ import com.mailserver.domain.User;
import com.mailserver.dto.LoginResponse;
import com.mailserver.service.MailDirectory;
import com.mailserver.service.MailLists;
import com.mailserver.service.MailboxAccess;
import com.mailserver.service.MailService;
import com.mailserver.service.UserService;
import com.mailserver.util.JwtUtils;
@@ -45,12 +46,13 @@ public class MailboxAdminController {
private static final int MIN_PASSWORD = MailDirectory.MIN_PASSWORD;
public record MailboxView(Long id, String email, String displayName, boolean active, boolean admin, boolean trap,
long usedBytes, long quotaBytes, Instant createdAt) {
boolean shared, List<String> members, long usedBytes, long quotaBytes, Instant createdAt) {
}
/** @param password empty — the server generates one and returns it once */
/** @param shared shared mailbox (support@): no login of its own — members open it; password ignored */
public record CreateRequest(@NotBlank @Size(max = 254) String email, @Size(max = 100) String displayName,
@Size(max = 200) String password) {
@Size(max = 200) String password, Boolean shared) {
}
public record PasswordRequest(@Size(max = 200) String password) {
@@ -73,9 +75,11 @@ public class MailboxAdminController {
private final JwtUtils jwt;
private final MailDirectory directory;
private final MailLists lists;
private final MailboxAccess access;
public MailboxAdminController(UserService users, MailService mail, MailDomains domains, MailAdmins admins, JwtUtils jwt,
MailDirectory directory, MailLists lists) {
MailDirectory directory, MailLists lists, MailboxAccess access) {
this.access = access;
this.jwt = jwt;
this.directory = directory;
this.lists = lists;
@@ -100,11 +104,13 @@ public class MailboxAdminController {
if (!chosen.isEmpty() && chosen.length() < MIN_PASSWORD) {
return Mono.error(bad("Password: at least " + MIN_PASSWORD + " characters, or leave it empty to generate one"));
}
String password = chosen.isEmpty() ? randomPassword() : chosen;
boolean shared = Boolean.TRUE.equals(request.shared());
String password = chosen.isEmpty() || shared ? randomPassword() : chosen;
return directory.createMailbox(request.email(), request.displayName(), password)
.doOnNext(user -> log.info("Mailbox created by administrator: {}", user.getEmail()))
.flatMap(user -> shared ? users.setShared(user, true) : Mono.just(user))
.doOnNext(user -> log.info("{} created by administrator: {}", shared ? "Shared mailbox" : "Mailbox", user.getEmail()))
.flatMap(this::view)
.map(view -> new Created(view, chosen.isEmpty() ? password : null));
.map(view -> new Created(view, chosen.isEmpty() && !shared ? password : null));
});
}
@@ -134,7 +140,7 @@ public class MailboxAdminController {
return admin(auth).flatMap(me -> mailbox(id).map(user -> {
log.warn("Administrator {} opened mailbox {}", me, user.getEmail());
LoginResponse response = new LoginResponse();
response.setToken(jwt.generateToken(user.getEmail()));
response.setToken(jwt.generateToken(user.getEmail(), me));
response.setEmail(user.getEmail());
return response;
}));
@@ -161,6 +167,19 @@ public class MailboxAdminController {
}))).flatMap(this::view);
}
public record MembersRequest(List<String> members) {
}
/** Exactly these members may open the mailbox (shared mailbox; for an ordinary one — delegates). */
@PutMapping("/{id}/members")
public Mono<MailboxView> members(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<MembersRequest> body) {
return admin(auth).flatMap(me -> body.flatMap(request -> mailbox(id).flatMap(user ->
directory.setMembers(user, request.members())
.doOnSuccess(v -> log.info("Members of {} set by administrator {}: {}", user.getEmail(), me, request.members()))
.then(users.byId(id)))))
.flatMap(this::view);
}
// --- Mailing lists (Settings → LISTS) ---
public record ListRequest(@Size(max = 50) String folder, @Size(max = 255) String description, boolean openToOutside,
@@ -202,9 +221,9 @@ public class MailboxAdminController {
}
private Mono<MailboxView> view(User user) {
return mail.usedBytes(user.getId()).defaultIfEmpty(0L)
.map(used -> new MailboxView(user.getId(), user.getEmail(), user.getDisplayName(), user.isActive(),
admins.isAdmin(user.getEmail()), user.isTrap(), used, user.getQuotaBytes() == null ? 0 : user.getQuotaBytes(),
return mail.usedBytes(user.getId()).defaultIfEmpty(0L).zipWith(access.members(user.getId()).collectList())
.map(pair -> new MailboxView(user.getId(), user.getEmail(), user.getDisplayName(), user.isActive(),
admins.isAdmin(user.getEmail()), user.isTrap(), user.isShared(), pair.getT2(), pair.getT1(), user.getQuotaBytes() == null ? 0 : user.getQuotaBytes(),
user.getCreatedAt()));
}
@@ -0,0 +1,130 @@
package com.mailserver.controller;
import com.mailserver.domain.User;
import com.mailserver.dto.LoginResponse;
import com.mailserver.security.Actor;
import com.mailserver.security.LoginGuard;
import com.mailserver.service.MailboxAccess;
import com.mailserver.service.UserService;
import com.mailserver.util.JwtUtils;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
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.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.Duration;
import java.util.Locale;
/**
* The mailboxes of the logged-in user (left menu): own, shared ones it is a member of, attached ones.
* Always works for the LOGIN behind the session, also when a shared mailbox is open.
*/
@RestController
@RequestMapping("/api/mailboxes")
public class MailboxesController {
private static final Logger log = LoggerFactory.getLogger(MailboxesController.class);
public record AttachRequest(@NotBlank @Size(max = 254) String email, @NotBlank @Size(max = 200) String password) {
}
private final MailboxAccess access;
private final UserService users;
private final JwtUtils jwt;
private final LoginGuard guard;
public MailboxesController(MailboxAccess access, UserService users, JwtUtils jwt, LoginGuard guard) {
this.access = access;
this.users = users;
this.jwt = jwt;
this.guard = guard;
}
@GetMapping
public Flux<MailboxAccess.Entry> mine(Mono<Authentication> auth) {
return login(auth).flatMapMany(access::mailboxes);
}
/** A session of this mailbox for the login (switch in the left menu). */
@PostMapping("/{email}/session")
public Mono<LoginResponse> session(Mono<Authentication> auth, @PathVariable String email) {
return login(auth).flatMap(me -> mailbox(email).flatMap(target -> access.has(target.getId(), me.getId())
.flatMap(allowed -> {
if (!allowed) {
return Mono.error(new ResponseStatusException(HttpStatus.FORBIDDEN, "No access to this mailbox"));
}
LoginResponse response = new LoginResponse();
boolean own = target.getId().equals(me.getId());
response.setToken(own ? jwt.generateToken(me.getEmail()) : jwt.generateToken(target.getEmail(), me.getEmail()));
response.setEmail(target.getEmail());
return Mono.just(response);
})));
}
/** Adds another mailbox the user knows the password of. The password is checked like a login (LoginGuard). */
@PostMapping("/attach")
@ResponseStatus(HttpStatus.CREATED)
public Mono<MailboxAccess.Entry> attach(Mono<Authentication> auth, @RequestBody Mono<AttachRequest> body, ServerHttpRequest http) {
String ip = AuthenticationController.clientIp(http);
return login(auth).zipWith(body).flatMap(pair -> {
User me = pair.getT1();
String email = pair.getT2().email().trim().toLowerCase(Locale.ROOT);
Duration wait = guard.retryAfter(ip, email);
if (!wait.isZero()) {
return Mono.error(new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS,
"Too many failed attempts, try again in " + LoginGuard.human(wait)));
}
return users.findOptional(email)
.filter(target -> target.isActive() && !target.isShared() && users.checkPassword(target, pair.getT2().password()))
.switchIfEmpty(Mono.defer(() -> {
guard.failure(ip, email);
log.warn("WEB {}: {} failed to attach {}", ip, me.getEmail(), email);
return Mono.error(new ResponseStatusException(HttpStatus.BAD_REQUEST, "Wrong address or password"));
}))
.flatMap(target -> {
if (target.getId().equals(me.getId())) {
return Mono.error(new ResponseStatusException(HttpStatus.BAD_REQUEST, "This is your own mailbox"));
}
guard.success(ip, email);
log.info("{} attached mailbox {}", me.getEmail(), email);
return access.grant(target.getId(), me.getId(), MailboxAccess.ATTACHED)
.then(access.unread(target.getId()))
.map(n -> new MailboxAccess.Entry(target.getEmail(), target.getDisplayName(), MailboxAccess.ATTACHED, false, n));
});
});
}
/** Removes the mailbox from the user's menu (attached or shared). */
@DeleteMapping("/{email}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public Mono<Void> leave(Mono<Authentication> auth, @PathVariable String email) {
return login(auth).flatMap(me -> mailbox(email).flatMap(target -> access.revoke(target.getId(), me.getId())
.flatMap(done -> done ? Mono.<Void>empty()
: Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "Not in your mailboxes")))));
}
private Mono<User> login(Mono<Authentication> auth) {
return auth.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED)))
.flatMap(a -> users.findOptional(Actor.login(a)))
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED)));
}
private Mono<User> mailbox(String email) {
return users.findOptional(email)
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "No such mailbox")));
}
}
@@ -3,6 +3,7 @@ package com.mailserver.controller;
import com.mailserver.domain.User;
import com.mailserver.service.MailDirectory;
import com.mailserver.service.MailLists;
import com.mailserver.service.MailboxAccess;
import com.mailserver.service.UserService;
import jakarta.validation.constraints.Size;
import org.slf4j.Logger;
@@ -57,9 +58,11 @@ public class ServiceController {
private final UserService users;
private final MailLists lists;
private final MailDirectory directory;
private final MailboxAccess access;
public ServiceController(@Value("${mailserver.service-token:}") String token, UserService users, MailLists lists,
MailDirectory directory) {
MailDirectory directory, MailboxAccess access) {
this.access = access;
this.token = token.isBlank() ? null : token.trim().getBytes(StandardCharsets.UTF_8);
this.users = users;
this.lists = lists;
@@ -131,6 +134,23 @@ public class ServiceController {
? ResponseEntity.noContent().<Void>build() : ResponseEntity.notFound().<Void>build());
}
public record SharedRequest(@Size(max = 100) String displayName, List<String> members) {
}
public record SharedView(String email, String displayName, List<String> members) {
}
/** Shared mailbox (support@, billing@): created when missing, exactly these members. */
@PutMapping("/shared/{address}")
public Mono<SharedView> saveShared(@RequestHeader(value = "Authorization", required = false) String auth,
@PathVariable String address, @RequestBody Mono<SharedRequest> body) {
check(auth);
return body.flatMap(request -> directory.saveShared(address, request.displayName(), request.members()))
.flatMap(user -> access.members(user.getId()).collectList()
.map(members -> new SharedView(user.getEmail(), user.getDisplayName(), members)))
.doOnNext(view -> log.info("Shared mailbox {} saved by Jambotron: {}", view.email(), view.members()));
}
private Mono<User> update(User user, AccountRequest request) {
Mono<User> named = request.displayName() == null || request.displayName().isBlank() ? Mono.just(user)
: users.updateDisplayName(user, request.displayName());
@@ -139,7 +159,8 @@ public class ServiceController {
}
boolean on = request.active();
return named.flatMap(u -> users.setActive(u, on))
.flatMap(u -> on ? Mono.just(u) : lists.removeEverywhere(u.getId()).thenReturn(u))
.flatMap(u -> on ? Mono.just(u) : lists.removeEverywhere(u.getId())
.then(access.removeEverywhere(u.getId())).thenReturn(u))
.doOnNext(u -> log.info("Mailbox {} {} by Jambotron", u.getEmail(), on ? "switched on" : "switched off"));
}