Ловушка у ящика (TRAP): что ящик отправляет — в его же INBOX, наружу ничего
continuous-integration/drone/pr Build is passing
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:
@@ -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()));
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,10 @@ public class User {
|
||||
@Column("is_active")
|
||||
private boolean isActive = true;
|
||||
|
||||
/** Trap: what this mailbox sends is stored in its own INBOX, never delivered (V5). */
|
||||
@Column("is_trap")
|
||||
private boolean isTrap = false;
|
||||
|
||||
@Column("created_at")
|
||||
private Instant createdAt;
|
||||
|
||||
@@ -54,6 +58,9 @@ public class User {
|
||||
public boolean isActive() { return isActive; }
|
||||
public void setActive(boolean active) { isActive = active; }
|
||||
|
||||
public boolean isTrap() { return isTrap; }
|
||||
public void setTrap(boolean trap) { isTrap = trap; }
|
||||
|
||||
public Instant getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
|
||||
}
|
||||
|
||||
@@ -93,6 +93,11 @@ public class UserService {
|
||||
return userRepository.save(user);
|
||||
}
|
||||
|
||||
public Mono<User> setTrap(User user, boolean trap) {
|
||||
user.setTrap(trap);
|
||||
return userRepository.save(user);
|
||||
}
|
||||
|
||||
public Mono<User> updateDisplayName(User user, String displayName) {
|
||||
user.setDisplayName(displayName.trim());
|
||||
return userRepository.save(user);
|
||||
|
||||
@@ -17,6 +17,8 @@ import java.util.Locale;
|
||||
* an existing mailbox of our domain → that mailbox; catch-all set → everything else into the catch-all mailbox
|
||||
* (nothing goes outside, existing mailboxes still get their own mail); otherwise our domain without such a mailbox →
|
||||
* "no such user", someone else's domain → the delivery queue, only for a logged-in sender (never an open relay).
|
||||
* A logged-in TRAP mailbox (staging@) sends nowhere: every recipient is accepted and ONE copy is stored in the
|
||||
* sender's own INBOX — a staging stand uses the real server, but its test mail never reaches people.
|
||||
*/
|
||||
@Service
|
||||
public class MailRouter implements SmtpBackend {
|
||||
@@ -48,6 +50,9 @@ public class MailRouter implements SmtpBackend {
|
||||
|
||||
@Override
|
||||
public Route route(String recipient, String authenticatedUser) {
|
||||
if (isTrap(authenticatedUser)) {
|
||||
return Route.LOCAL;
|
||||
}
|
||||
if (domains.isLocal(recipient) && mailboxExists(recipient)) {
|
||||
return Route.LOCAL;
|
||||
}
|
||||
@@ -66,6 +71,11 @@ public class MailRouter implements SmtpBackend {
|
||||
@Override
|
||||
public void accept(Envelope envelope, byte[] message) throws Exception {
|
||||
boolean trusted = envelope.authenticatedUser() != null;
|
||||
if (isTrap(envelope.authenticatedUser())) {
|
||||
localDelivery.deliver(message, envelope.authenticatedUser().toLowerCase(Locale.ROOT), envelope.remoteIp(),
|
||||
envelope.mailFrom(), true);
|
||||
return;
|
||||
}
|
||||
byte[] signed = null;
|
||||
for (Recipient recipient : envelope.recipients()) {
|
||||
switch (recipient.route()) {
|
||||
@@ -92,6 +102,15 @@ public class MailRouter implements SmtpBackend {
|
||||
return domains.catchAll();
|
||||
}
|
||||
|
||||
/** The logged-in sender is an active trap mailbox. */
|
||||
boolean isTrap(String authenticatedUser) {
|
||||
if (authenticatedUser == null) {
|
||||
return false;
|
||||
}
|
||||
User user = users.findByEmail(authenticatedUser.toLowerCase(Locale.ROOT)).block();
|
||||
return user != null && user.isTrap();
|
||||
}
|
||||
|
||||
private boolean mailboxExists(String address) {
|
||||
return Boolean.TRUE.equals(users.existsByEmail(address.toLowerCase(Locale.ROOT)).block());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user