package com.mailserver.controller; import com.mailserver.config.MailAdmins; import com.mailserver.config.MailDomains; import com.mailserver.domain.User; import com.mailserver.dto.LoginResponse; import com.mailserver.service.MailDirectory; import com.mailserver.service.MailLists; import com.mailserver.service.MailService; 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.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.PutMapping; 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.Instant; import java.util.Comparator; import java.util.List; /** * Mailboxes of this server for its administrators ({@link MailAdmins}): list, create (no-reply@, monitor@, * support@ — registration stays closed), new password, switch off. A generated password is returned once, in the * answer — it is not logged. */ @RestController @RequestMapping("/api/admin/mailboxes") public class MailboxAdminController { private static final Logger log = LoggerFactory.getLogger(MailboxAdminController.class); 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) { } /** @param password empty — the server generates one and returns it once */ public record CreateRequest(@NotBlank @Size(max = 254) String email, @Size(max = 100) String displayName, @Size(max = 200) String password) { } public record PasswordRequest(@Size(max = 200) String password) { } public record ActiveRequest(boolean active) { } public record TrapRequest(boolean trap) { } /** @param password the generated password, or null when the administrator chose it */ public record Created(MailboxView mailbox, String password) { } private final UserService users; private final MailService mail; private final MailDomains domains; private final MailAdmins admins; private final JwtUtils jwt; private final MailDirectory directory; private final MailLists lists; public MailboxAdminController(UserService users, MailService mail, MailDomains domains, MailAdmins admins, JwtUtils jwt, MailDirectory directory, MailLists lists) { this.jwt = jwt; this.directory = directory; this.lists = lists; this.users = users; this.mail = mail; this.domains = domains; this.admins = admins; } @GetMapping public Flux list(Mono auth) { return admin(auth).thenMany(users.all()) .concatMap(this::view) .sort(Comparator.comparing(MailboxView::email)); } @PostMapping @ResponseStatus(HttpStatus.CREATED) public Mono create(Mono auth, @RequestBody Mono body) { return admin(auth).then(body).flatMap(request -> { String chosen = request.password() == null ? "" : request.password(); 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; return directory.createMailbox(request.email(), request.displayName(), password) .doOnNext(user -> log.info("Mailbox created by administrator: {}", user.getEmail())) .flatMap(this::view) .map(view -> new Created(view, chosen.isEmpty() ? password : null)); }); } /** New password: the given one, or a generated one returned once. */ @PostMapping("/{id}/password") public Mono password(Mono auth, @PathVariable Long id, @RequestBody Mono body) { return admin(auth).then(body.defaultIfEmpty(new PasswordRequest(null))).flatMap(request -> { String chosen = request.password() == null ? "" : request.password(); 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; return mailbox(id) .flatMap(user -> users.setPassword(user, password)) .doOnNext(user -> log.info("Password of {} set by administrator", user.getEmail())) .flatMap(this::view) .map(view -> new Created(view, chosen.isEmpty() ? password : null)); }); } /** * Administrator opens a mailbox without its password (Settings → MAILBOXES → Open): a session of that mailbox. * Every opening is logged — who opened which mailbox. */ @PostMapping("/{id}/open") public Mono open(Mono auth, @PathVariable Long id) { 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.setEmail(user.getEmail()); return response; })); } /** Switched off: no login (web, SMTP, IMAP); mail to it is still received. */ @PutMapping("/{id}/active") public Mono active(Mono auth, @PathVariable Long id, @RequestBody Mono body) { return admin(auth).flatMap(me -> body.flatMap(request -> mailbox(id).flatMap(user -> { if (!request.active() && user.getEmail().equalsIgnoreCase(me)) { return Mono.error(bad("You can not switch off your own mailbox")); } log.info("Mailbox {} {} by administrator", user.getEmail(), request.active() ? "switched on" : "switched off"); return users.setActive(user, request.active()); }))).flatMap(this::view); } /** Trap: what the mailbox sends is stored in its own INBOX, nothing leaves the server (staging@). */ @PutMapping("/{id}/trap") public Mono trap(Mono auth, @PathVariable Long id, @RequestBody Mono body) { return admin(auth).flatMap(me -> body.flatMap(request -> mailbox(id).flatMap(user -> { log.info("Mailbox {} trap {} by administrator {}", user.getEmail(), request.trap() ? "ON" : "OFF", me); return users.setTrap(user, request.trap()); }))).flatMap(this::view); } // --- Mailing lists (Settings → LISTS) --- public record ListRequest(@Size(max = 50) String folder, @Size(max = 255) String description, boolean openToOutside, List members) { } @GetMapping("/lists") public Flux lists(Mono auth) { return admin(auth).thenMany(lists.all()); } /** Creates or replaces the list {@code address} (settings + the exact member set). */ @PutMapping("/lists/{address}") public Mono saveList(Mono auth, @PathVariable String address, @RequestBody Mono body) { return admin(auth).flatMap(me -> body.flatMap(request -> directory.saveList(address, request.folder(), request.description(), request.openToOutside(), request.members()) .doOnNext(list -> log.info("List {} saved by administrator {}: {} members", list.address(), me, list.members().size())))); } @DeleteMapping("/lists/{address}") @ResponseStatus(HttpStatus.NO_CONTENT) public Mono deleteList(Mono auth, @PathVariable String address) { return admin(auth).flatMap(me -> lists.delete(address) .flatMap(deleted -> deleted ? Mono.empty() : Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "List not found"))) .doOnSuccess(v -> log.info("List {} deleted by administrator {}", address, me))); } private Mono admin(Mono auth) { return auth.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED))) .map(Authentication::getName) .filter(admins::isAdmin) .switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.FORBIDDEN, "Only for mail administrators"))); } private Mono mailbox(Long id) { return users.byId(id).switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "Mailbox not found"))); } private Mono 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(), user.getCreatedAt())); } private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } static String randomPassword() { return MailDirectory.randomPassword(); } }