Ловушка у ящика (TRAP): что ящик отправляет — в его же INBOX, наружу ничего
continuous-integration/drone/pr Build is passing
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>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user