Files
reactive-mail-server/backend/src/main/java/com/mailserver/controller/MailboxAdminController.java
T
Liosha84andClaude Opus 5.5 cf84797e68
continuous-integration/drone/pr Build is passing
Рассылки, папки по +метке, ловушка только для чужих адресов, служебное API для Jambotron
- V6 mail_lists + mail_list_members: письмо на alerts-prod@ → каждому участнику своя копия в папку рассылки;
  закрытая рассылка — только от вошедших отправителей (550 снаружи)
- bob+prod@ → ящик bob@, папка prod (создаётся сама; системные папки нельзя)
- TRAP: чужие адреса → одна копия в INBOX отправителя; наши ящики и рассылки доставляются
- /api/service/** (SERVICE_TOKEN из secrets-init): аккаунты (пароль один раз, выключение → вон из рассылок), рассылки
- MailDirectory: одно пространство адресов для ящиков и рассылок, без «+» в именах
- Настройки → LISTS для администраторов
- 86 тестов; живой прогон на Postgres 17: миграции V1–V6, API, SMTP (рассылка/метка/ловушка)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 14:15:24 +01:00

219 lines
10 KiB
Java

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<MailboxView> list(Mono<Authentication> auth) {
return admin(auth).thenMany(users.all())
.concatMap(this::view)
.sort(Comparator.comparing(MailboxView::email));
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Mono<Created> create(Mono<Authentication> auth, @RequestBody Mono<CreateRequest> 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<Created> password(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<PasswordRequest> 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<LoginResponse> open(Mono<Authentication> 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<MailboxView> active(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<ActiveRequest> 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<MailboxView> trap(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<TrapRequest> 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<String> members) {
}
@GetMapping("/lists")
public Flux<MailLists.MailList> lists(Mono<Authentication> auth) {
return admin(auth).thenMany(lists.all());
}
/** Creates or replaces the list {@code address} (settings + the exact member set). */
@PutMapping("/lists/{address}")
public Mono<MailLists.MailList> saveList(Mono<Authentication> auth, @PathVariable String address,
@RequestBody Mono<ListRequest> 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<Void> deleteList(Mono<Authentication> auth, @PathVariable String address) {
return admin(auth).flatMap(me -> lists.delete(address)
.flatMap(deleted -> deleted ? Mono.<Void>empty()
: Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "List not found")))
.doOnSuccess(v -> log.info("List {} deleted by administrator {}", address, me)));
}
private Mono<String> admin(Mono<Authentication> 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<User> mailbox(Long id) {
return users.byId(id).switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "Mailbox not found")));
}
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(),
user.getCreatedAt()));
}
private static ResponseStatusException bad(String message) {
return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
}
static String randomPassword() {
return MailDirectory.randomPassword();
}
}