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 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 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 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> saveAccount(@RequestHeader(value = "Authorization", required = false) String auth, @PathVariable String email, @RequestBody Mono 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 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 lists(@RequestHeader(value = "Authorization", required = false) String auth) { check(auth); return lists.all(); } @PutMapping("/lists/{address}") public Mono saveList(@RequestHeader(value = "Authorization", required = false) String auth, @PathVariable String address, @RequestBody Mono 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> deleteList(@RequestHeader(value = "Authorization", required = false) String auth, @PathVariable String address) { check(auth); return lists.delete(address).map(deleted -> deleted ? ResponseEntity.noContent().build() : ResponseEntity.notFound().build()); } private Mono update(User user, AccountRequest request) { Mono 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 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); } } }