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 mine(Mono 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 session(Mono 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 attach(Mono auth, @RequestBody Mono 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 leave(Mono auth, @PathVariable String email) { return login(auth).flatMap(me -> mailbox(email).flatMap(target -> access.revoke(target.getId(), me.getId()) .flatMap(done -> done ? Mono.empty() : Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "Not in your mailboxes"))))); } private Mono login(Mono 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 mailbox(String email) { return users.findOptional(email) .switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "No such mailbox"))); } }