continuous-integration/drone/pr Build is passing
- 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>
219 lines
10 KiB
Java
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();
|
|
}
|
|
}
|