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>
131 lines
6.3 KiB
Java
131 lines
6.3 KiB
Java
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")));
|
|
}
|
|
}
|