Рассылки, папки по +метке, ловушка только для чужих адресов, служебное API для Jambotron
continuous-integration/drone/pr Build is passing
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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user