Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc3fe3970d | ||
|
|
0811dfcf68 | ||
|
|
714c62f285 | ||
|
|
e37487d509 | ||
|
|
312e65fc33 | ||
|
|
5c08d7e361 | ||
|
|
ccd31c503e |
@@ -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».
|
||||
|
||||
@@ -43,9 +43,9 @@ public class MailboxAdminController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(MailboxAdminController.class);
|
||||
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 = 10;
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -12,8 +12,10 @@ import { AuthService } from '../../core/auth.service';
|
||||
selector: 'app-mailboxes-section',
|
||||
imports: [FormsModule, MatIconModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
// ячейка сетки настроек — сам элемент компонента: на две колонки
|
||||
host: { class: 'block xl:col-span-2' },
|
||||
template: `
|
||||
<section class="glass-panel static p-5 md:p-6 xl:col-span-2" aria-labelledby="mailboxes-title">
|
||||
<section class="glass-panel static p-5 md:p-6" aria-labelledby="mailboxes-title">
|
||||
<h2 id="mailboxes-title" class="text-lg font-orbitron font-bold text-cyan-300 flex items-center gap-2 mb-4 border-b border-cyan-500/20 pb-2">
|
||||
<mat-icon class="text-pink-500">group</mat-icon> MAILBOXES
|
||||
</h2>
|
||||
@@ -61,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)"
|
||||
@@ -149,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(),
|
||||
|
||||
@@ -79,7 +79,7 @@ import { ToastService } from '../../ui/toast.service';
|
||||
}
|
||||
|
||||
<div class="flex-grow max-w-[1500px] w-full mx-auto p-3 md:p-5 grid grid-cols-1 lg:grid-cols-[260px_1fr] gap-5">
|
||||
<nav class="glass-panel static p-4 h-fit lg:sticky lg:top-24 lg:block" [class.hidden]="!menuOpen()"
|
||||
<nav class="glass-panel static p-4 md:pt-6 h-fit lg:sticky lg:top-20 lg:block" [class.hidden]="!menuOpen()"
|
||||
aria-label="Mail folders">
|
||||
<a routerLink="/compose" class="cyber-btn w-full py-2.5 rounded-xl font-bold text-xs mb-4" (click)="menuOpen.set(false)">
|
||||
<mat-icon>edit</mat-icon> COMPOSE
|
||||
|
||||
Reference in New Issue
Block a user