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>
238 lines
12 KiB
Java
238 lines
12 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.MailboxAccess;
|
|
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,
|
|
boolean shared, List<String> members, long usedBytes, long quotaBytes, Instant createdAt) {
|
|
}
|
|
|
|
/** @param password empty — the server generates one and returns it once */
|
|
/** @param shared shared mailbox (support@): no login of its own — members open it; password ignored */
|
|
public record CreateRequest(@NotBlank @Size(max = 254) String email, @Size(max = 100) String displayName,
|
|
@Size(max = 200) String password, Boolean shared) {
|
|
}
|
|
|
|
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;
|
|
private final MailboxAccess access;
|
|
|
|
public MailboxAdminController(UserService users, MailService mail, MailDomains domains, MailAdmins admins, JwtUtils jwt,
|
|
MailDirectory directory, MailLists lists, MailboxAccess access) {
|
|
this.access = access;
|
|
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"));
|
|
}
|
|
boolean shared = Boolean.TRUE.equals(request.shared());
|
|
String password = chosen.isEmpty() || shared ? randomPassword() : chosen;
|
|
return directory.createMailbox(request.email(), request.displayName(), password)
|
|
.flatMap(user -> shared ? users.setShared(user, true) : Mono.just(user))
|
|
.doOnNext(user -> log.info("{} created by administrator: {}", shared ? "Shared mailbox" : "Mailbox", user.getEmail()))
|
|
.flatMap(this::view)
|
|
.map(view -> new Created(view, chosen.isEmpty() && !shared ? 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(), me));
|
|
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);
|
|
}
|
|
|
|
public record MembersRequest(List<String> members) {
|
|
}
|
|
|
|
/** Exactly these members may open the mailbox (shared mailbox; for an ordinary one — delegates). */
|
|
@PutMapping("/{id}/members")
|
|
public Mono<MailboxView> members(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<MembersRequest> body) {
|
|
return admin(auth).flatMap(me -> body.flatMap(request -> mailbox(id).flatMap(user ->
|
|
directory.setMembers(user, request.members())
|
|
.doOnSuccess(v -> log.info("Members of {} set by administrator {}: {}", user.getEmail(), me, request.members()))
|
|
.then(users.byId(id)))))
|
|
.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).zipWith(access.members(user.getId()).collectList())
|
|
.map(pair -> new MailboxView(user.getId(), user.getEmail(), user.getDisplayName(), user.isActive(),
|
|
admins.isAdmin(user.getEmail()), user.isTrap(), user.isShared(), pair.getT2(), pair.getT1(), 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();
|
|
}
|
|
}
|