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