Рассылки, папки по +метке, ловушка только для чужих адресов, служебное API для Jambotron
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>
This commit is contained in:
2026-11-07 14:15:24 +01:00
co-authored by Claude Opus 5.5
parent 3603b9d315
commit cf84797e68
28 changed files with 1013 additions and 71 deletions
@@ -4,6 +4,8 @@ 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;
@@ -13,6 +15,7 @@ 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;
@@ -25,12 +28,9 @@ import org.springframework.web.server.ResponseStatusException;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.Base64;
import java.util.Comparator;
import java.util.Locale;
import java.util.regex.Pattern;
import java.util.List;
/**
* Mailboxes of this server for its administrators ({@link MailAdmins}): list, create (no-reply@, monitor@,
@@ -42,8 +42,7 @@ import java.util.regex.Pattern;
public class MailboxAdminController {
private static final Logger log = LoggerFactory.getLogger(MailboxAdminController.class);
private static final Pattern LOCAL_PART = Pattern.compile("[a-z0-9](?:[a-z0-9._+-]{0,62}[a-z0-9])?");
private static final int MIN_PASSWORD = 8;
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) {
@@ -72,9 +71,14 @@ public class MailboxAdminController {
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) {
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;
@@ -92,22 +96,13 @@ public class MailboxAdminController {
@ResponseStatus(HttpStatus.CREATED)
public Mono<Created> create(Mono<Authentication> auth, @RequestBody Mono<CreateRequest> body) {
return admin(auth).then(body).flatMap(request -> {
String email = request.email() == null ? "" : request.email().trim().toLowerCase(Locale.ROOT);
int at = email.lastIndexOf('@');
if (at <= 0 || !LOCAL_PART.matcher(email.substring(0, at)).matches()) {
return Mono.error(bad("Not a mailbox address: " + email));
}
if (!domains.isLocal(email)) {
return Mono.error(bad("Mailboxes can be created only in " + String.join(", ", domains.domains().stream().sorted().toList())));
}
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 users.register(email, password, request.displayName())
.onErrorMap(IllegalArgumentException.class, e -> new ResponseStatusException(HttpStatus.CONFLICT, "This mailbox already exists"))
.doOnNext(user -> log.info("Mailbox created by administrator: {}", email))
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));
});
@@ -166,6 +161,35 @@ public class MailboxAdminController {
}))).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)
@@ -189,8 +213,6 @@ public class MailboxAdminController {
}
static String randomPassword() {
byte[] bytes = new byte[18];
new SecureRandom().nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
return MailDirectory.randomPassword();
}
}
@@ -0,0 +1,162 @@
package com.mailserver.controller;
import com.mailserver.domain.User;
import com.mailserver.service.MailDirectory;
import com.mailserver.service.MailLists;
import com.mailserver.service.UserService;
import jakarta.validation.constraints.Size;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
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.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
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.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.List;
/**
* Service API for Jambotron (staff mailboxes): token {@code SERVICE_TOKEN} in {@code Authorization: Bearer …}.
* Without the token configured the API is off (404). Accounts: create (password returned ONCE), switch on/off,
* new password; lists: the exact settings and members, Jambotron is the source of truth for the lists it keeps.
*/
@RestController
@RequestMapping("/api/service")
public class ServiceController {
private static final Logger log = LoggerFactory.getLogger(ServiceController.class);
/** @param displayName null — unchanged (new account: the name before @) */
public record AccountRequest(@Size(max = 100) String displayName, Boolean active) {
}
public record AccountView(String email, String displayName, boolean active, List<String> lists) {
}
/** @param password generated, only when the account was just created or got a new password */
public record AccountResult(AccountView account, String password) {
}
public record ListRequest(@Size(max = 50) String folder, @Size(max = 255) String description, boolean openToOutside,
List<String> members) {
}
private final byte[] token;
private final UserService users;
private final MailLists lists;
private final MailDirectory directory;
public ServiceController(@Value("${mailserver.service-token:}") String token, UserService users, MailLists lists,
MailDirectory directory) {
this.token = token.isBlank() ? null : token.trim().getBytes(StandardCharsets.UTF_8);
this.users = users;
this.lists = lists;
this.directory = directory;
}
@GetMapping("/accounts/{email}")
public Mono<AccountView> account(@RequestHeader(value = "Authorization", required = false) String auth,
@PathVariable String email) {
check(auth);
return users.findOptional(email)
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "No such mailbox")))
.flatMap(this::view);
}
/** Creates the account (201 + password once) or updates name / on-off (200). Switched off → out of every list. */
@PutMapping("/accounts/{email}")
public Mono<ResponseEntity<AccountResult>> saveAccount(@RequestHeader(value = "Authorization", required = false) String auth,
@PathVariable String email, @RequestBody Mono<AccountRequest> body) {
check(auth);
String address = directory.address(email);
return body.defaultIfEmpty(new AccountRequest(null, null)).flatMap(request -> users.findOptional(address)
.flatMap(user -> update(user, request).flatMap(this::view)
.map(view -> ResponseEntity.ok(new AccountResult(view, null))))
.switchIfEmpty(Mono.defer(() -> {
String password = MailDirectory.randomPassword();
return directory.createMailbox(address, request.displayName(), password)
.flatMap(user -> Boolean.FALSE.equals(request.active()) ? update(user, request) : Mono.just(user))
.doOnNext(user -> log.info("Mailbox {} created by Jambotron", user.getEmail()))
.flatMap(this::view)
.map(view -> ResponseEntity.status(HttpStatus.CREATED).body(new AccountResult(view, password)));
})));
}
/** New generated password, returned once (Jambotron profile → «new mail password»). */
@PostMapping("/accounts/{email}/password")
public Mono<AccountResult> password(@RequestHeader(value = "Authorization", required = false) String auth,
@PathVariable String email) {
check(auth);
String password = MailDirectory.randomPassword();
return users.findOptional(email)
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "No such mailbox")))
.flatMap(user -> users.setPassword(user, password))
.doOnNext(user -> log.info("Password of {} set by Jambotron", user.getEmail()))
.flatMap(this::view)
.map(view -> new AccountResult(view, password));
}
@GetMapping("/lists")
public Flux<MailLists.MailList> lists(@RequestHeader(value = "Authorization", required = false) String auth) {
check(auth);
return lists.all();
}
@PutMapping("/lists/{address}")
public Mono<MailLists.MailList> saveList(@RequestHeader(value = "Authorization", required = false) String auth,
@PathVariable String address, @RequestBody Mono<ListRequest> body) {
check(auth);
return body.flatMap(request -> directory.saveList(address, request.folder(), request.description(),
request.openToOutside(), request.members()))
.doOnNext(list -> log.info("List {} saved by Jambotron: {}", list.address(), list.members()));
}
@DeleteMapping("/lists/{address}")
public Mono<ResponseEntity<Void>> deleteList(@RequestHeader(value = "Authorization", required = false) String auth,
@PathVariable String address) {
check(auth);
return lists.delete(address).map(deleted -> deleted
? ResponseEntity.noContent().<Void>build() : ResponseEntity.notFound().<Void>build());
}
private Mono<User> update(User user, AccountRequest request) {
Mono<User> named = request.displayName() == null || request.displayName().isBlank() ? Mono.just(user)
: users.updateDisplayName(user, request.displayName());
if (request.active() == null || request.active() == user.isActive()) {
return named;
}
boolean on = request.active();
return named.flatMap(u -> users.setActive(u, on))
.flatMap(u -> on ? Mono.just(u) : lists.removeEverywhere(u.getId()).thenReturn(u))
.doOnNext(u -> log.info("Mailbox {} {} by Jambotron", u.getEmail(), on ? "switched on" : "switched off"));
}
private Mono<AccountView> view(User user) {
return lists.listsOf(user.getId()).collectList()
.map(in -> new AccountView(user.getEmail(), user.getDisplayName(), user.isActive(), in));
}
/** 404 when the API is off, 401 for a wrong token (constant-time compare). */
private void check(String auth) {
if (token == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
byte[] given = auth != null && auth.startsWith("Bearer ")
? auth.substring(7).trim().getBytes(StandardCharsets.UTF_8) : new byte[0];
if (!MessageDigest.isEqual(token, given)) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
}
}
}