Compare commits

..
Author SHA1 Message Date
Liosha84andClaude Opus 5.5 fc3fe3970d Ловушка у ящика (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>
2026-11-07 10:45:20 +01:00
Liosha84 0811dfcf68 Merge pull request 'Ящики: пароль от 8 символов (было 10)' (#24) from fix/mailbox-password-8 into master
continuous-integration/drone/push Build is passing
Reviewed-on: #24
2026-11-07 04:00:33 +01:00
Liosha84andClaude Opus 5.5 714c62f285 Ящики: минимальная длина пароля 8 (как при регистрации и смене пароля), было 10
continuous-integration/drone/pr Build is passing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 03:51:47 +01:00
Liosha84 e37487d509 Merge pull request 'Веб-почта: MAILBOXES на две колонки; левое меню вровень с панелью' (#23) from fix/layout-leftovers into master
continuous-integration/drone/push Build is passing
Reviewed-on: #23
2026-11-07 03:41:44 +01:00
Liosha84andClaude Opus 5.5 312e65fc33 Веб-почта: левое меню вровень с панелью — sticky top 96 → 80 px
continuous-integration/drone/pr Build is passing
Без прокрутки меню стоит на ~86 px, а sticky top-24 (96 px) сдвигал его ниже панели.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 03:37:27 +01:00
Liosha84andClaude Opus 5.5 5c08d7e361 Веб-почта: левое меню — верхний отступ как у панели посередине (md: 24 px)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 03:37:27 +01:00
Liosha84andClaude Opus 5.5 ccd31c503e Веб-почта: MAILBOXES на две колонки (col-span на элементе компонента — он и есть ячейка сетки)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 03:37:27 +01:00
12 changed files with 122 additions and 6 deletions
+2
View File
@@ -8,6 +8,8 @@
**Регистрации нет:** ящики создаёт администратор `admin@jambocheck.com` — веб-почта → Настройки → **MAILBOXES**. **Регистрации нет:** ящики создаёт администратор `admin@jambocheck.com` — веб-почта → Настройки → **MAILBOXES**.
**Ловушка у ящика (TRAP):** Настройки → MAILBOXES → кнопка `outbox` у ящика. Всё, что такой ящик отправляет (SMTP или веб), никому не доставляется: одна копия ложится в его же INBOX, наружу не уходит ничего. Для `staging@` — тестовые письма staging-стенда (забыл пароль, уведомления) не попадают к живым людям; смотреть их — открыть `staging@` (OPEN). Входящая почта на такой ящик приходит как обычно.
**Администратор:** `admin@jambocheck.com`, пароль `eK-6oj3TMtZgoXVxlvtCWFI6` (начальный, записан 2026-11-07 по решению владельца; сменили — обновить здесь). **Администратор:** `admin@jambocheck.com`, пароль `eK-6oj3TMtZgoXVxlvtCWFI6` (начальный, записан 2026-11-07 по решению владельца; сменили — обновить здесь).
Пароли остальных ящиков (`staging@`, `no-reply@`, `monitor@`) — в репозитории Jambotron: `INFRASTRUCTURE/SECRETS.md`, раздел «Почта mail.jambocheck.com». Пароли остальных ящиков (`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 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 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) { long usedBytes, long quotaBytes, Instant createdAt) {
} }
@@ -60,6 +60,9 @@ public class MailboxAdminController {
public record ActiveRequest(boolean active) { public record ActiveRequest(boolean active) {
} }
public record TrapRequest(boolean trap) {
}
/** @param password the generated password, or null when the administrator chose it */ /** @param password the generated password, or null when the administrator chose it */
public record Created(MailboxView mailbox, String password) { public record Created(MailboxView mailbox, String password) {
} }
@@ -154,6 +157,15 @@ public class MailboxAdminController {
}))).flatMap(this::view); }))).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) { private Mono<String> admin(Mono<Authentication> auth) {
return auth.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED))) return auth.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED)))
.map(Authentication::getName) .map(Authentication::getName)
@@ -168,7 +180,7 @@ public class MailboxAdminController {
private Mono<MailboxView> view(User user) { private Mono<MailboxView> view(User user) {
return mail.usedBytes(user.getId()).defaultIfEmpty(0L) return mail.usedBytes(user.getId()).defaultIfEmpty(0L)
.map(used -> new MailboxView(user.getId(), user.getEmail(), user.getDisplayName(), user.isActive(), .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())); user.getCreatedAt()));
} }
@@ -30,6 +30,10 @@ public class User {
@Column("is_active") @Column("is_active")
private boolean isActive = true; 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") @Column("created_at")
private Instant createdAt; private Instant createdAt;
@@ -54,6 +58,9 @@ public class User {
public boolean isActive() { return isActive; } public boolean isActive() { return isActive; }
public void setActive(boolean active) { isActive = active; } 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 Instant getCreatedAt() { return createdAt; }
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
} }
@@ -93,6 +93,11 @@ public class UserService {
return userRepository.save(user); 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) { public Mono<User> updateDisplayName(User user, String displayName) {
user.setDisplayName(displayName.trim()); user.setDisplayName(displayName.trim());
return userRepository.save(user); 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 * 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 → * (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). * "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 @Service
public class MailRouter implements SmtpBackend { public class MailRouter implements SmtpBackend {
@@ -48,6 +50,9 @@ public class MailRouter implements SmtpBackend {
@Override @Override
public Route route(String recipient, String authenticatedUser) { public Route route(String recipient, String authenticatedUser) {
if (isTrap(authenticatedUser)) {
return Route.LOCAL;
}
if (domains.isLocal(recipient) && mailboxExists(recipient)) { if (domains.isLocal(recipient) && mailboxExists(recipient)) {
return Route.LOCAL; return Route.LOCAL;
} }
@@ -66,6 +71,11 @@ public class MailRouter implements SmtpBackend {
@Override @Override
public void accept(Envelope envelope, byte[] message) throws Exception { public void accept(Envelope envelope, byte[] message) throws Exception {
boolean trusted = envelope.authenticatedUser() != null; 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; byte[] signed = null;
for (Recipient recipient : envelope.recipients()) { for (Recipient recipient : envelope.recipients()) {
switch (recipient.route()) { switch (recipient.route()) {
@@ -92,6 +102,15 @@ public class MailRouter implements SmtpBackend {
return domains.catchAll(); 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) { private boolean mailboxExists(String address) {
return Boolean.TRUE.equals(users.existsByEmail(address.toLowerCase(Locale.ROOT)).block()); 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)); u.setActive(i.getArgument(1));
return Mono.just(u); 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 @Test
@@ -3,6 +3,7 @@ package com.mailserver.smtp;
import com.mailserver.config.MailDomains; import com.mailserver.config.MailDomains;
import com.mailserver.delivery.OutboundQueue; import com.mailserver.delivery.OutboundQueue;
import com.mailserver.delivery.SmtpDeliveryClient; import com.mailserver.delivery.SmtpDeliveryClient;
import com.mailserver.domain.User;
import com.mailserver.repository.UserRepository; import com.mailserver.repository.UserRepository;
import com.mailserver.service.DkimService; import com.mailserver.service.DkimService;
import com.mailserver.service.LocalDelivery; import com.mailserver.service.LocalDelivery;
@@ -30,10 +31,22 @@ class MailRouterTest {
private final OutboundQueue queue = mock(OutboundQueue.class); private final OutboundQueue queue = mock(OutboundQueue.class);
private final SmtpDeliveryClient client = mock(SmtpDeliveryClient.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) { private MailRouter router(String catchAll) {
when(users.existsByEmail(anyString())).thenReturn(Mono.just(false)); when(users.existsByEmail(anyString())).thenReturn(Mono.just(false));
when(users.existsByEmail("bob@mail.test")).thenReturn(Mono.just(true)); 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, return new MailRouter(new MailDomains("mail.test", catchAll), users, mock(UserService.class), localDelivery,
queue, mock(DkimService.class), client); 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(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()); 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 }> { openMailbox(id: number): Observable<{ token: string; email: string }> {
return this.http.post<{ token: string; email: string }>(`/api/admin/mailboxes/${id}/open`, {}); 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> { setMailboxActive(id: number, active: boolean): Observable<Mailbox> {
return this.http.put<Mailbox>(`/api/admin/mailboxes/${id}/active`, { active }); return this.http.put<Mailbox>(`/api/admin/mailboxes/${id}/active`, { active });
} }
+1
View File
@@ -63,6 +63,7 @@ export interface Mailbox {
displayName: string; displayName: string;
active: boolean; active: boolean;
admin: boolean; admin: boolean;
trap: boolean; // what it sends stays in its own INBOX, nothing leaves the server (staging@)
usedBytes: number; usedBytes: number;
quotaBytes: number; quotaBytes: number;
createdAt: string; createdAt: string;
@@ -12,8 +12,10 @@ import { AuthService } from '../../core/auth.service';
selector: 'app-mailboxes-section', selector: 'app-mailboxes-section',
imports: [FormsModule, MatIconModule], imports: [FormsModule, MatIconModule],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
// ячейка сетки настроек — сам элемент компонента: на две колонки
host: { class: 'block xl:col-span-2' },
template: ` 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"> <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 <mat-icon class="text-pink-500">group</mat-icon> MAILBOXES
</h2> </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="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> <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.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> <span class="text-xs font-mono text-slate-500 ml-auto">{{ size(m.usedBytes) }}</span>
@if (m.email !== me()) { @if (m.email !== me()) {
<button type="button" class="ghost-btn !py-1" (click)="open(m)" [attr.aria-label]="'Open ' + m.email" title="Open mailbox"> <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> <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> <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()) { @if (m.email !== me()) {
<button type="button" class="ghost-btn !py-1" (click)="toggle(m)" <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 { toggle(m: Mailbox): void {
this.api.setMailboxActive(m.id, !m.active).subscribe({ this.api.setMailboxActive(m.id, !m.active).subscribe({
next: () => this.load(), 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"> <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"> 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)"> <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 <mat-icon>edit</mat-icon> COMPOSE