Администратор открывает любой ящик: MAILBOXES → OPEN, полоса «вернуться в admin@»
continuous-integration/drone/pr Build is passing

POST /api/admin/mailboxes/{id}/open — сессия этого ящика без его пароля, только для
MAIL_ADMINS; каждое открытие — в лог (кто какой ящик открыл). В веб-почте: кнопка OPEN
у ящика, сверху розовая полоса «Вы в ящике X как администратор — Back to admin@».
Тесты: +1 (всего 75), ng build OK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 03:09:29 +01:00
co-authored by Claude Opus 5.5
parent 84ae0b6c7d
commit 91bb8b9b2d
6 changed files with 97 additions and 2 deletions
@@ -3,8 +3,10 @@ package com.mailserver.controller;
import com.mailserver.config.MailAdmins; import com.mailserver.config.MailAdmins;
import com.mailserver.config.MailDomains; import com.mailserver.config.MailDomains;
import com.mailserver.domain.User; import com.mailserver.domain.User;
import com.mailserver.dto.LoginResponse;
import com.mailserver.service.MailService; import com.mailserver.service.MailService;
import com.mailserver.service.UserService; import com.mailserver.service.UserService;
import com.mailserver.util.JwtUtils;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import org.slf4j.Logger; import org.slf4j.Logger;
@@ -66,8 +68,10 @@ public class MailboxAdminController {
private final MailService mail; private final MailService mail;
private final MailDomains domains; private final MailDomains domains;
private final MailAdmins admins; private final MailAdmins admins;
private final JwtUtils jwt;
public MailboxAdminController(UserService users, MailService mail, MailDomains domains, MailAdmins admins) { public MailboxAdminController(UserService users, MailService mail, MailDomains domains, MailAdmins admins, JwtUtils jwt) {
this.jwt = jwt;
this.users = users; this.users = users;
this.mail = mail; this.mail = mail;
this.domains = domains; this.domains = domains;
@@ -123,6 +127,21 @@ public class MailboxAdminController {
}); });
} }
/**
* Administrator opens a mailbox without its password (Settings → MAILBOXES → Open): a session of that mailbox.
* Every opening is logged — who opened which mailbox.
*/
@PostMapping("/{id}/open")
public Mono<LoginResponse> open(Mono<Authentication> auth, @PathVariable Long id) {
return admin(auth).flatMap(me -> mailbox(id).map(user -> {
log.warn("Administrator {} opened mailbox {}", me, user.getEmail());
LoginResponse response = new LoginResponse();
response.setToken(jwt.generateToken(user.getEmail()));
response.setEmail(user.getEmail());
return response;
}));
}
/** Switched off: no login (web, SMTP, IMAP); mail to it is still received. */ /** Switched off: no login (web, SMTP, IMAP); mail to it is still received. */
@PutMapping("/{id}/active") @PutMapping("/{id}/active")
public Mono<MailboxView> active(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<ActiveRequest> body) { public Mono<MailboxView> active(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<ActiveRequest> body) {
@@ -33,7 +33,8 @@ class MailboxAdminControllerTest {
private final MailService mail = mock(MailService.class); private final MailService mail = mock(MailService.class);
private final MailDomains domains = new MailDomains("jambocheck.com", ""); private final MailDomains domains = new MailDomains("jambocheck.com", "");
private final MailAdmins admins = new MailAdmins("", "", domains); private final MailAdmins admins = new MailAdmins("", "", domains);
private final MailboxAdminController controller = new MailboxAdminController(users, mail, domains, admins); private final com.mailserver.util.JwtUtils jwt = mock(com.mailserver.util.JwtUtils.class);
private final MailboxAdminController controller = new MailboxAdminController(users, mail, domains, admins, jwt);
private final User admin = user(1L, "admin@jambocheck.com"); private final User admin = user(1L, "admin@jambocheck.com");
private final User alice = user(2L, "alice@jambocheck.com"); private final User alice = user(2L, "alice@jambocheck.com");
@@ -43,6 +44,7 @@ class MailboxAdminControllerTest {
when(users.all()).thenReturn(Flux.just(alice, admin)); when(users.all()).thenReturn(Flux.just(alice, admin));
when(users.byId(1L)).thenReturn(Mono.just(admin)); when(users.byId(1L)).thenReturn(Mono.just(admin));
when(users.byId(2L)).thenReturn(Mono.just(alice)); when(users.byId(2L)).thenReturn(Mono.just(alice));
when(users.byId(99L)).thenReturn(Mono.empty());
when(users.register(anyString(), anyString(), any())).thenAnswer(i -> Mono.just(user(3L, i.getArgument(0)))); when(users.register(anyString(), anyString(), any())).thenAnswer(i -> Mono.just(user(3L, i.getArgument(0))));
when(users.setPassword(any(), anyString())).thenAnswer(i -> Mono.just((User) i.getArgument(0))); when(users.setPassword(any(), anyString())).thenAnswer(i -> Mono.just((User) i.getArgument(0)));
when(users.setActive(any(), any(Boolean.class))).thenAnswer(i -> { when(users.setActive(any(), any(Boolean.class))).thenAnswer(i -> {
@@ -105,6 +107,18 @@ class MailboxAdminControllerTest {
.isEqualTo(HttpStatus.BAD_REQUEST); .isEqualTo(HttpStatus.BAD_REQUEST);
} }
@Test
void administratorOpensAnyMailboxOthersCanNot() {
when(jwt.generateToken("alice@jambocheck.com")).thenReturn("alice-token");
var session = controller.open(as("admin@jambocheck.com"), 2L).block();
assertThat(session.getEmail()).isEqualTo("alice@jambocheck.com");
assertThat(session.getToken()).isEqualTo("alice-token");
assertThat(status(() -> controller.open(as("alice@jambocheck.com"), 1L).block())).isEqualTo(HttpStatus.FORBIDDEN);
assertThat(status(() -> controller.open(as("admin@jambocheck.com"), 99L).block())).isEqualTo(HttpStatus.NOT_FOUND);
}
private MailboxAdminController.Created create(String me, String email, String password) { private MailboxAdminController.Created create(String me, String email, String password) {
return controller.create(as(me), Mono.just(new MailboxAdminController.CreateRequest(email, null, password))).block(); return controller.create(as(me), Mono.just(new MailboxAdminController.CreateRequest(email, null, password))).block();
} }
+3
View File
@@ -25,6 +25,9 @@ export class ApiService {
newMailboxPassword(id: number): Observable<MailboxCreated> { newMailboxPassword(id: number): Observable<MailboxCreated> {
return this.http.post<MailboxCreated>(`/api/admin/mailboxes/${id}/password`, {}); return this.http.post<MailboxCreated>(`/api/admin/mailboxes/${id}/password`, {});
} }
openMailbox(id: number): Observable<{ token: string; email: string }> {
return this.http.post<{ token: string; email: string }>(`/api/admin/mailboxes/${id}/open`, {});
}
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 });
} }
+31
View File
@@ -12,6 +12,8 @@ export class AuthService {
private router = inject(Router); private router = inject(Router);
readonly email = signal<string | null>(read('email')); readonly email = signal<string | null>(read('email'));
/** Administrator who opened this mailbox (Settings → MAILBOXES → Open), or null */
readonly openedBy = signal<string | null>(read('adminEmail'));
readonly loggedIn = computed(() => this.email() !== null && read('token') !== null); readonly loggedIn = computed(() => this.email() !== null && read('token') !== null);
login(email: string, password: string): Observable<TokenResponse> { login(email: string, password: string): Observable<TokenResponse> {
@@ -24,6 +26,32 @@ export class AuthService {
token(): string | null { return read('token'); } token(): string | null { return read('token'); }
/** Administrator opens another mailbox: its session, the admin session kept to come back. */
openAs(response: TokenResponse): void {
const adminToken = read('token');
const adminEmail = read('email');
try {
if (adminToken && adminEmail && !read('adminToken')) {
localStorage.setItem('adminToken', adminToken);
localStorage.setItem('adminEmail', adminEmail);
}
} catch { /* storage blocked */ }
this.store(response);
this.openedBy.set(read('adminEmail'));
}
/** Back to the administrator's own mailbox. */
backToAdmin(): void {
const token = read('adminToken');
const email = read('adminEmail');
try {
localStorage.removeItem('adminToken');
localStorage.removeItem('adminEmail');
} catch { /* storage blocked */ }
this.openedBy.set(null);
if (token && email) { this.store({ token, email }); } else { this.clear(); }
}
logout(): void { logout(): void {
this.clear(); this.clear();
this.router.navigate(['/login']); this.router.navigate(['/login']);
@@ -33,8 +61,11 @@ export class AuthService {
try { try {
localStorage.removeItem('token'); localStorage.removeItem('token');
localStorage.removeItem('email'); localStorage.removeItem('email');
localStorage.removeItem('adminToken');
localStorage.removeItem('adminEmail');
} catch { /* storage blocked */ } } catch { /* storage blocked */ }
this.email.set(null); this.email.set(null);
this.openedBy.set(null);
} }
private store(response: TokenResponse): void { private store(response: TokenResponse): void {
@@ -5,6 +5,7 @@ import { ApiService } from '../../core/api.service';
import { MailStateService } from '../../core/mail-state.service'; import { MailStateService } from '../../core/mail-state.service';
import { Mailbox, errorText, formatBytes } from '../../core/models'; import { Mailbox, errorText, formatBytes } from '../../core/models';
import { ToastService } from '../../ui/toast.service'; import { ToastService } from '../../ui/toast.service';
import { AuthService } from '../../core/auth.service';
/** Settings → Mailboxes (administrators): create no-reply@, monitor@, support@ …, new password, switch off. */ /** Settings → Mailboxes (administrators): create no-reply@, monitor@, support@ …, new password, switch off. */
@Component({ @Component({
@@ -46,6 +47,10 @@ import { ToastService } from '../../ui/toast.service';
<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> }
<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()) {
<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)="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)"
@@ -62,6 +67,7 @@ export class MailboxesSection {
private api = inject(ApiService); private api = inject(ApiService);
private state = inject(MailStateService); private state = inject(MailStateService);
private toasts = inject(ToastService); private toasts = inject(ToastService);
private auth = inject(AuthService);
readonly mailboxes = signal<Mailbox[]>([]); readonly mailboxes = signal<Mailbox[]>([]);
readonly secret = signal<{ email: string; password: string } | null>(null); readonly secret = signal<{ email: string; password: string } | null>(null);
@@ -111,6 +117,14 @@ export class MailboxesSection {
}); });
} }
/** Administrator reads this mailbox: its session; the banner on top leads back. Full reload — fresh folders. */
open(m: Mailbox): void {
this.api.openMailbox(m.id).subscribe({
next: session => { this.auth.openAs(session); location.assign('/'); },
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(),
@@ -69,6 +69,15 @@ import { ToastService } from '../../ui/toast.service';
</div> </div>
</header> </header>
@if (auth.openedBy(); as admin) {
<div class="bg-pink-600/90 text-white text-sm font-rajdhani px-4 py-2 flex flex-wrap items-center justify-center gap-3" role="status">
<mat-icon class="!text-base">visibility</mat-icon>
<span>You are in mailbox <b class="font-mono">{{ auth.email() }}</b> as administrator</span>
<button type="button" class="ghost-btn !py-1 !border-white !text-white" (click)="backToAdmin()">
<mat-icon>arrow_back</mat-icon> Back to {{ admin }}</button>
</div>
}
<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 h-fit lg:sticky lg:top-24 lg:block" [class.hidden]="!menuOpen()"
aria-label="Mail folders"> aria-label="Mail folders">
@@ -247,6 +256,11 @@ export class ShellComponent {
} }
} }
backToAdmin(): void {
this.auth.backToAdmin();
location.assign('/settings');
}
logout(): void { logout(): void {
this.state.reset(); this.state.reset();
this.auth.logout(); this.auth.logout();