Files
reactive-mail-server/backend/src/main/java/com/mailserver/controller/ServiceController.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

163 lines
8.0 KiB
Java

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);
}
}
}