Ловушка у ящика (TRAP): что ящик отправляет — в его же INBOX, наружу ничего
continuous-integration/drone/pr Build is passing

Для staging@: staging-стенд шлёт через настоящий mail.jambocheck.com,
но тестовые письма не доходят до живых людей.
- V5: users.is_trap
- MailRouter: отправитель-ловушка → все получатели LOCAL, одна копия в INBOX отправителя
- PUT /api/admin/mailboxes/{id}/trap, MailboxView.trap
- MAILBOXES: кнопка outbox/move_to_inbox, метка TRAP
- тесты: router + admin (77 тестов)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 10:45:20 +01:00
co-authored by Claude Opus 5.5
parent 0811dfcf68
commit fc3fe3970d
11 changed files with 117 additions and 3 deletions
@@ -45,7 +45,7 @@ public class MailboxAdminController {
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;
public record MailboxView(Long id, String email, String displayName, boolean active, boolean admin,
public record MailboxView(Long id, String email, String displayName, boolean active, boolean admin, boolean trap,
long usedBytes, long quotaBytes, Instant createdAt) {
}
@@ -60,6 +60,9 @@ public class MailboxAdminController {
public record ActiveRequest(boolean active) {
}
public record TrapRequest(boolean trap) {
}
/** @param password the generated password, or null when the administrator chose it */
public record Created(MailboxView mailbox, String password) {
}
@@ -154,6 +157,15 @@ public class MailboxAdminController {
}))).flatMap(this::view);
}
/** Trap: what the mailbox sends is stored in its own INBOX, nothing leaves the server (staging@). */
@PutMapping("/{id}/trap")
public Mono<MailboxView> trap(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<TrapRequest> body) {
return admin(auth).flatMap(me -> body.flatMap(request -> mailbox(id).flatMap(user -> {
log.info("Mailbox {} trap {} by administrator {}", user.getEmail(), request.trap() ? "ON" : "OFF", me);
return users.setTrap(user, request.trap());
}))).flatMap(this::view);
}
private Mono<String> admin(Mono<Authentication> auth) {
return auth.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED)))
.map(Authentication::getName)
@@ -168,7 +180,7 @@ public class MailboxAdminController {
private Mono<MailboxView> view(User user) {
return mail.usedBytes(user.getId()).defaultIfEmpty(0L)
.map(used -> new MailboxView(user.getId(), user.getEmail(), user.getDisplayName(), user.isActive(),
admins.isAdmin(user.getEmail()), used, user.getQuotaBytes() == null ? 0 : user.getQuotaBytes(),
admins.isAdmin(user.getEmail()), user.isTrap(), used, user.getQuotaBytes() == null ? 0 : user.getQuotaBytes(),
user.getCreatedAt()));
}