From fc3fe3970d8cfb6dcaa28b3e9cd0a324bd8ede09 Mon Sep 17 00:00:00 2001 From: Liosha84 Date: Sat, 7 Nov 2026 10:45:20 +0100 Subject: [PATCH] =?UTF-8?q?=D0=9B=D0=BE=D0=B2=D1=83=D1=88=D0=BA=D0=B0=20?= =?UTF-8?q?=D1=83=20=D1=8F=D1=89=D0=B8=D0=BA=D0=B0=20(TRAP):=20=D1=87?= =?UTF-8?q?=D1=82=D0=BE=20=D1=8F=D1=89=D0=B8=D0=BA=20=D0=BE=D1=82=D0=BF?= =?UTF-8?q?=D1=80=D0=B0=D0=B2=D0=BB=D1=8F=D0=B5=D1=82=20=E2=80=94=20=D0=B2?= =?UTF-8?q?=20=D0=B5=D0=B3=D0=BE=20=D0=B6=D0=B5=20INBOX,=20=D0=BD=D0=B0?= =?UTF-8?q?=D1=80=D1=83=D0=B6=D1=83=20=D0=BD=D0=B8=D1=87=D0=B5=D0=B3=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Для 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 --- README.md | 2 ++ .../controller/MailboxAdminController.java | 16 +++++++-- .../main/java/com/mailserver/domain/User.java | 7 ++++ .../com/mailserver/service/UserService.java | 5 +++ .../java/com/mailserver/smtp/MailRouter.java | 19 ++++++++++ .../db/migration/V5__trap_mailbox.sql | 3 ++ .../MailboxAdminControllerTest.java | 14 ++++++++ .../com/mailserver/smtp/MailRouterTest.java | 35 ++++++++++++++++++- frontend/src/app/core/api.service.ts | 3 ++ frontend/src/app/core/models.ts | 1 + .../app/pages/settings/mailboxes.section.ts | 15 ++++++++ 11 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 backend/src/main/resources/db/migration/V5__trap_mailbox.sql diff --git a/README.md b/README.md index a2acff8..1430e5c 100644 --- a/README.md +++ b/README.md @@ -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». diff --git a/backend/src/main/java/com/mailserver/controller/MailboxAdminController.java b/backend/src/main/java/com/mailserver/controller/MailboxAdminController.java index 46c02e1..e5eccc6 100644 --- a/backend/src/main/java/com/mailserver/controller/MailboxAdminController.java +++ b/backend/src/main/java/com/mailserver/controller/MailboxAdminController.java @@ -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 trap(Mono auth, @PathVariable Long id, @RequestBody Mono 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 admin(Mono auth) { return auth.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED))) .map(Authentication::getName) @@ -168,7 +180,7 @@ public class MailboxAdminController { private Mono 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())); } diff --git a/backend/src/main/java/com/mailserver/domain/User.java b/backend/src/main/java/com/mailserver/domain/User.java index 69176e4..f75e371 100644 --- a/backend/src/main/java/com/mailserver/domain/User.java +++ b/backend/src/main/java/com/mailserver/domain/User.java @@ -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; } } diff --git a/backend/src/main/java/com/mailserver/service/UserService.java b/backend/src/main/java/com/mailserver/service/UserService.java index 0a3bcbd..aae419d 100644 --- a/backend/src/main/java/com/mailserver/service/UserService.java +++ b/backend/src/main/java/com/mailserver/service/UserService.java @@ -93,6 +93,11 @@ public class UserService { return userRepository.save(user); } + public Mono setTrap(User user, boolean trap) { + user.setTrap(trap); + return userRepository.save(user); + } + public Mono updateDisplayName(User user, String displayName) { user.setDisplayName(displayName.trim()); return userRepository.save(user); diff --git a/backend/src/main/java/com/mailserver/smtp/MailRouter.java b/backend/src/main/java/com/mailserver/smtp/MailRouter.java index 9578dab..4170a48 100644 --- a/backend/src/main/java/com/mailserver/smtp/MailRouter.java +++ b/backend/src/main/java/com/mailserver/smtp/MailRouter.java @@ -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()); } diff --git a/backend/src/main/resources/db/migration/V5__trap_mailbox.sql b/backend/src/main/resources/db/migration/V5__trap_mailbox.sql new file mode 100644 index 0000000..038c83b --- /dev/null +++ b/backend/src/main/resources/db/migration/V5__trap_mailbox.sql @@ -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; diff --git a/backend/src/test/java/com/mailserver/controller/MailboxAdminControllerTest.java b/backend/src/test/java/com/mailserver/controller/MailboxAdminControllerTest.java index c747880..6596166 100644 --- a/backend/src/test/java/com/mailserver/controller/MailboxAdminControllerTest.java +++ b/backend/src/test/java/com/mailserver/controller/MailboxAdminControllerTest.java @@ -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 diff --git a/backend/src/test/java/com/mailserver/smtp/MailRouterTest.java b/backend/src/test/java/com/mailserver/smtp/MailRouterTest.java index 3b31afa..ec20837 100644 --- a/backend/src/test/java/com/mailserver/smtp/MailRouterTest.java +++ b/backend/src/test/java/com/mailserver/smtp/MailRouterTest.java @@ -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()); + } } diff --git a/frontend/src/app/core/api.service.ts b/frontend/src/app/core/api.service.ts index 9c2ddca..874be36 100644 --- a/frontend/src/app/core/api.service.ts +++ b/frontend/src/app/core/api.service.ts @@ -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 { + return this.http.put(`/api/admin/mailboxes/${id}/trap`, { trap }); + } setMailboxActive(id: number, active: boolean): Observable { return this.http.put(`/api/admin/mailboxes/${id}/active`, { active }); } diff --git a/frontend/src/app/core/models.ts b/frontend/src/app/core/models.ts index b981ff2..1ce6ab4 100644 --- a/frontend/src/app/core/models.ts +++ b/frontend/src/app/core/models.ts @@ -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; diff --git a/frontend/src/app/pages/settings/mailboxes.section.ts b/frontend/src/app/pages/settings/mailboxes.section.ts index 4ba863f..4b02ba5 100644 --- a/frontend/src/app/pages/settings/mailboxes.section.ts +++ b/frontend/src/app/pages/settings/mailboxes.section.ts @@ -63,11 +63,17 @@ import { AuthService } from '../../core/auth.service'; {{ m.email }} {{ m.displayName }} @if (m.admin) { ADMIN } + @if (m.trap) { TRAP } {{ size(m.usedBytes) }} @if (m.email !== me()) { } + @if (m.email !== me()) {