Ловушка у ящика (TRAP): staging@ отправляет только себе в INBOX #25

Merged
Liosha84 merged 1 commits from feat/trap-mailbox into master 2026-11-07 11:00:13 +01:00
11 changed files with 117 additions and 3 deletions
Showing only changes of commit fc3fe3970d - Show all commits
+2
View File
@@ -8,6 +8,8 @@
**Регистрации нет:** ящики создаёт администратор `admin@jambocheck.com` — веб-почта → Настройки → **MAILBOXES**.
**Ловушка у ящика (TRAP):** Настройки → MAILBOXES → кнопка `outbox` у ящика. Всё, что такой ящик отправляет (SMTP или веб), никому не доставляется: одна копия ложится в его же INBOX, наружу не уходит ничего. Для `staging@` — тестовые письма staging-стенда (забыл пароль, уведомления) не попадают к живым людям; смотреть их — открыть `staging@` (OPEN). Входящая почта на такой ящик приходит как обычно.
**Администратор:** `admin@jambocheck.com`, пароль `eK-6oj3TMtZgoXVxlvtCWFI6` (начальный, записан 2026-11-07 по решению владельца; сменили — обновить здесь).
Пароли остальных ящиков (`staging@`, `no-reply@`, `monitor@`) — в репозитории Jambotron: `INFRASTRUCTURE/SECRETS.md`, раздел «Почта mail.jambocheck.com».
@@ -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());
}
@@ -0,0 +1,3 @@
-- Trap mailbox: what it sends is NOT delivered to anyone — every copy lands in its own INBOX (staging@: test mail
-- of a staging stand never reaches real people). Switched on by an administrator in Settings → MAILBOXES.
ALTER TABLE users ADD COLUMN is_trap BOOLEAN NOT NULL DEFAULT FALSE;
@@ -52,6 +52,20 @@ class MailboxAdminControllerTest {
u.setActive(i.getArgument(1));
return Mono.just(u);
});
when(users.setTrap(any(), any(Boolean.class))).thenAnswer(i -> {
User u = i.getArgument(0);
u.setTrap(i.getArgument(1));
return Mono.just(u);
});
}
@Test
void administratorSwitchesTrapOthersCanNot() {
assertThat(controller.trap(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.TrapRequest(true))).block().trap()).isTrue();
assertThat(alice.isTrap()).isTrue();
assertThat(controller.trap(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.TrapRequest(false))).block().trap()).isFalse();
assertThat(status(() -> controller.trap(as("alice@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.TrapRequest(true))).block()))
.isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
@@ -3,6 +3,7 @@ package com.mailserver.smtp;
import com.mailserver.config.MailDomains;
import com.mailserver.delivery.OutboundQueue;
import com.mailserver.delivery.SmtpDeliveryClient;
import com.mailserver.domain.User;
import com.mailserver.repository.UserRepository;
import com.mailserver.service.DkimService;
import com.mailserver.service.LocalDelivery;
@@ -30,10 +31,22 @@ class MailRouterTest {
private final OutboundQueue queue = mock(OutboundQueue.class);
private final SmtpDeliveryClient client = mock(SmtpDeliveryClient.class);
private SmtpDeliveryClient.Mode mode = SmtpDeliveryClient.Mode.OFF;
private static User user(String email, boolean trap) {
User user = new User();
user.setEmail(email);
user.setTrap(trap);
return user;
}
private MailRouter router(String catchAll) {
when(users.existsByEmail(anyString())).thenReturn(Mono.just(false));
when(users.existsByEmail("bob@mail.test")).thenReturn(Mono.just(true));
when(client.mode()).thenReturn(SmtpDeliveryClient.Mode.OFF);
when(users.findByEmail(anyString())).thenReturn(Mono.empty());
when(users.findByEmail("bob@mail.test")).thenReturn(Mono.just(user("bob@mail.test", false)));
when(users.findByEmail("staging@mail.test")).thenReturn(Mono.just(user("staging@mail.test", true)));
when(client.mode()).thenReturn(mode);
return new MailRouter(new MailDomains("mail.test", catchAll), users, mock(UserService.class), localDelivery,
queue, mock(DkimService.class), client);
}
@@ -65,4 +78,24 @@ class MailRouterTest {
verify(localDelivery).deliver(any(), eq("bob@mail.test"), eq("10.0.0.5"), eq("no-reply@jambotron.com"), anyBoolean());
verify(queue, never()).enqueue(any(), any(), any());
}
@Test
void trapMailboxSendsNowhereOneCopyLandsInItsOwnInbox() throws Exception {
mode = SmtpDeliveryClient.Mode.MX;
MailRouter router = router("");
// an ordinary mailbox still sends outside; the trap one keeps everything
assertThat(router.route("user@gmail.com", "bob@mail.test")).isEqualTo(SmtpBackend.Route.OUTBOUND);
assertThat(router.route("user@gmail.com", "staging@mail.test")).isEqualTo(SmtpBackend.Route.LOCAL);
assertThat(router.route("nobody@mail.test", "Staging@Mail.test")).isEqualTo(SmtpBackend.Route.LOCAL);
assertThat(router.route("bob@mail.test", "staging@mail.test")).isEqualTo(SmtpBackend.Route.LOCAL);
router.accept(new SmtpBackend.Envelope("10.0.0.5", "staging", "staging@mail.test", "staging@mail.test", List.of(
new SmtpBackend.Recipient("user@gmail.com", SmtpBackend.Route.LOCAL),
new SmtpBackend.Recipient("bob@mail.test", SmtpBackend.Route.LOCAL))), "Subject: hi\r\n\r\nbody".getBytes());
verify(localDelivery).deliver(any(), eq("staging@mail.test"), eq("10.0.0.5"), eq("staging@mail.test"), eq(true));
verify(localDelivery, never()).deliver(any(), eq("bob@mail.test"), any(), any(), anyBoolean());
verify(queue, never()).enqueue(any(), any(), any());
}
}
+3
View File
@@ -28,6 +28,9 @@ export class ApiService {
openMailbox(id: number): Observable<{ token: string; email: string }> {
return this.http.post<{ token: string; email: string }>(`/api/admin/mailboxes/${id}/open`, {});
}
setMailboxTrap(id: number, trap: boolean): Observable<Mailbox> {
return this.http.put<Mailbox>(`/api/admin/mailboxes/${id}/trap`, { trap });
}
setMailboxActive(id: number, active: boolean): Observable<Mailbox> {
return this.http.put<Mailbox>(`/api/admin/mailboxes/${id}/active`, { active });
}
+1
View File
@@ -63,6 +63,7 @@ export interface Mailbox {
displayName: string;
active: boolean;
admin: boolean;
trap: boolean; // what it sends stays in its own INBOX, nothing leaves the server (staging@)
usedBytes: number;
quotaBytes: number;
createdAt: string;
@@ -63,11 +63,17 @@ import { AuthService } from '../../core/auth.service';
<span class="font-mono text-sm" [class.text-cyan-300]="m.active" [class.text-slate-500]="!m.active" [class.line-through]="!m.active">{{ m.email }}</span>
<span class="text-sm text-slate-400">{{ m.displayName }}</span>
@if (m.admin) { <span class="text-[10px] font-mono px-1.5 rounded border border-pink-500/40 text-pink-300">ADMIN</span> }
@if (m.trap) { <span class="text-[10px] font-mono px-1.5 rounded border border-amber-400/50 text-amber-300"
title="What it sends is kept in its own INBOX — nothing leaves the server">TRAP</span> }
<span class="text-xs font-mono text-slate-500 ml-auto">{{ size(m.usedBytes) }}</span>
@if (m.email !== me()) {
<button type="button" class="ghost-btn !py-1" (click)="open(m)" [attr.aria-label]="'Open ' + m.email" title="Open mailbox">
<mat-icon>mark_email_read</mat-icon> OPEN</button>
}
<button type="button" class="ghost-btn !py-1" (click)="trap(m)" [class.text-amber-300]="m.trap"
[attr.aria-label]="(m.trap ? 'Trap off for ' : 'Trap on for ') + m.email"
[title]="m.trap ? 'Trap ON: sent mail stays in its INBOX. Click to send for real' : 'Trap: keep what it sends in its own INBOX'">
<mat-icon>{{ m.trap ? 'move_to_inbox' : 'outbox' }}</mat-icon></button>
<button type="button" class="ghost-btn !py-1" (click)="newPassword(m)" [attr.aria-label]="'New password for ' + m.email"><mat-icon>key</mat-icon></button>
@if (m.email !== me()) {
<button type="button" class="ghost-btn !py-1" (click)="toggle(m)"
@@ -151,6 +157,15 @@ export class MailboxesSection {
});
}
/** Trap on: what the mailbox sends is kept in its own INBOX (staging@). Off: it sends for real again. */
trap(m: Mailbox): void {
if (m.trap && !confirm('Switch the trap off? ' + m.email + ' will send mail to real people again.')) return;
this.api.setMailboxTrap(m.id, !m.trap).subscribe({
next: () => { this.toasts.ok('Trap ' + (m.trap ? 'off' : 'on') + ' for ' + m.email); this.load(); },
error: e => this.toasts.error(errorText(e))
});
}
toggle(m: Mailbox): void {
this.api.setMailboxActive(m.id, !m.active).subscribe({
next: () => this.load(),