Files
reactive-mail-server/frontend/src/app/pages/settings/mailboxes.section.ts
T
Liosha84andClaude Opus 5.5 e1bc0792ea
continuous-integration/drone/pr Build is passing
Settings → MAILBOXES: удалить ящик вместе с письмами
DELETE /api/admin/mailboxes/{id} (только администратор, не свой ящик):
файлы вложений, затем строка users — папки, письма, исходники, контакты,
участие в рассылках и доступы уходят каскадом. Кнопка с подтверждением.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 18:48:09 +01:00

245 lines
13 KiB
TypeScript

import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { ApiService } from '../../core/api.service';
import { MailStateService } from '../../core/mail-state.service';
import { Mailbox, errorText, formatBytes } from '../../core/models';
import { ToastService } from '../../ui/toast.service';
import { AuthService } from '../../core/auth.service';
/** Settings → Mailboxes (administrators): create no-reply@, monitor@, support@ …, new password, switch off. */
@Component({
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" 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>
<form (ngSubmit)="create()" class="grid grid-cols-1 md:grid-cols-[1fr_1fr_1fr_auto] gap-3 items-end mb-4">
<div><label class="synth-label" for="mb-email">Address</label>
<div class="flex items-stretch gap-2">
<input id="mb-email" name="email" class="synth-input" [ngModel]="local()" (ngModelChange)="local.set($event.toLowerCase().trim())"
placeholder="no-reply" autocomplete="off" required>
<span class="flex items-center text-cyan-300 font-mono">&#64;</span>
@if (domains().length > 1) {
<select name="domain" class="synth-input !w-auto" [ngModel]="domain()" (ngModelChange)="domain.set($event)" aria-label="Domain">
@for (d of domains(); track d) { <option [value]="d">{{ d }}</option> }
</select>
} @else {
<span class="flex items-center px-3 rounded-[10px] bg-slate-900/70 border border-cyan-500/30 font-mono text-sm text-slate-200 whitespace-nowrap">{{ domain() }}</span>
}
</div></div>
<div><label class="synth-label" for="mb-name">Name</label>
<input id="mb-name" name="name" class="synth-input" [(ngModel)]="name" maxlength="100" autocomplete="off"></div>
<div><label class="synth-label" for="mb-password">Password (empty — generate)</label>
<div class="relative"><input id="mb-password" name="password" [type]="showPw() ? 'text' : 'password'" class="synth-input pr-10" [(ngModel)]="password" autocomplete="new-password">
<button type="button" class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-cyan-300"
[attr.aria-label]="showPw() ? 'Hide password' : 'Show password'" (click)="showPw.set(!showPw())">
<mat-icon>{{ showPw() ? 'visibility_off' : 'visibility' }}</mat-icon>
</button></div></div>
<button type="submit" class="cyber-btn px-4 py-2 rounded-lg text-xs font-bold" [disabled]="busy()"><mat-icon>add</mat-icon> CREATE</button>
<label class="md:col-span-4 flex items-center gap-1.5 text-sm text-slate-300">
<input type="checkbox" name="shared" [(ngModel)]="shared">
shared mailbox (support&#64;, billing&#64;): no password of its own — members open it from their mailbox, read/unread is common
</label>
</form>
@if (error()) { <p class="text-sm text-pink-400 mb-3" role="alert">{{ error() }}</p> }
@if (secret(); as s) {
<div class="p-3 mb-4 rounded-lg border border-pink-500/40 bg-slate-900/60" role="status">
<div class="text-xs text-slate-400 mb-1">Password of <span class="font-mono text-cyan-300">{{ s.email }}</span> — shown once, copy it now</div>
<div class="flex items-center gap-2">
<code class="font-mono text-pink-300 break-all select-all">{{ s.password }}</code>
<button type="button" class="ghost-btn !py-1" (click)="copy(s.password)" aria-label="Copy password"><mat-icon>content_copy</mat-icon></button>
<button type="button" class="ghost-btn !py-1" (click)="secret.set(null)" aria-label="Hide password"><mat-icon>close</mat-icon></button>
</div>
</div>
}
<ul class="divide-y divide-cyan-500/10">
@for (m of mailboxes(); track m.id) {
<li class="flex flex-wrap items-center gap-x-4 gap-y-1 py-2">
<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.shared) { <span class="text-[10px] font-mono px-1.5 rounded border border-cyan-400/50 text-cyan-300"
[title]="'Members: ' + (m.members.join(', ') || 'none')">SHARED · {{ m.members.length }}</span> }
@if (!m.shared && m.members.length) { <span class="text-[10px] font-mono px-1.5 rounded border border-cyan-400/30 text-cyan-300/80"
[title]="'Also opened by: ' + m.members.join(', ')">+{{ m.members.length }}</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)="editMembers(m)" [attr.aria-expanded]="membersOf() === m.id"
[attr.aria-label]="'Who may open ' + m.email" title="Who may open this mailbox"><mat-icon>group</mat-icon></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>
@if (!m.shared) {
<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)"
[attr.aria-label]="(m.active ? 'Switch off ' : 'Switch on ') + m.email">
<mat-icon>{{ m.active ? 'toggle_on' : 'toggle_off' }}</mat-icon></button>
<button type="button" class="ghost-btn !py-1 hover:text-rose-400" (click)="remove(m)"
[attr.aria-label]="'Delete ' + m.email" title="Delete the mailbox with all its mail">
<mat-icon>delete</mat-icon></button>
}
@if (membersOf() === m.id) {
<div class="basis-full mt-1 p-2 rounded-lg border border-cyan-500/20">
<div class="text-xs text-slate-400 mb-1">Who may open <span class="font-mono text-cyan-300">{{ m.email }}</span> from their own mailbox:</div>
<div class="flex flex-wrap gap-x-4 gap-y-1">
@for (o of mailboxes(); track o.id) {
@if (o.id !== m.id && !o.shared) {
<label class="flex items-center gap-1.5 text-sm font-mono">
<input type="checkbox" [checked]="picked().has(o.email)" (change)="pick(o.email)"> {{ o.email }}
</label>
}
}
</div>
<div class="flex gap-2 mt-2">
<button type="button" class="cyber-btn px-3 py-1 rounded-lg text-xs font-bold" (click)="saveMembers(m)"><mat-icon>save</mat-icon> SAVE</button>
<button type="button" class="ghost-btn !py-1" (click)="membersOf.set(null)">CANCEL</button>
</div>
</div>
}
</li>
}
</ul>
</section>
`
})
export class MailboxesSection {
private api = inject(ApiService);
private state = inject(MailStateService);
private toasts = inject(ToastService);
private auth = inject(AuthService);
readonly mailboxes = signal<Mailbox[]>([]);
readonly secret = signal<{ email: string; password: string } | null>(null);
readonly error = signal('');
readonly busy = signal(false);
readonly me = computed(() => this.state.profile()?.email ?? '');
readonly showPw = signal(false);
readonly local = signal('');
// Домены сервера (/api/config); пока не пришли — домен своего ящика
readonly domains = signal<string[]>([]);
readonly domain = signal('');
name = '';
password = '';
shared = false;
/** mailbox whose members are being edited */
readonly membersOf = signal<number | null>(null);
readonly picked = signal<Set<string>>(new Set());
constructor() {
this.domain.set(this.me().split('@')[1] ?? '');
this.api.config().subscribe({
next: c => { this.domains.set(c.domains); if (!c.domains.includes(this.domain())) { this.domain.set(c.domains[0] ?? ''); } },
error: () => {}
});
this.load();
}
size(bytes: number): string {
return formatBytes(bytes);
}
load(): void {
this.api.mailboxes().subscribe({ next: list => this.mailboxes.set(list), error: e => this.error.set(errorText(e)) });
}
create(): void {
const local = this.local().replace(/@.*$/, '');
if (!local) { this.error.set('Enter an address'); return; }
const email = local + '@' + this.domain();
this.error.set('');
this.busy.set(true);
this.api.createMailbox(email, this.name.trim(), this.password, this.shared).subscribe({
next: created => {
this.busy.set(false);
this.local.set('');
this.name = this.password = '';
this.shared = false;
if (created.password) { this.secret.set({ email: created.mailbox.email, password: created.password }); }
this.toasts.ok('Mailbox ' + created.mailbox.email + ' created');
this.load();
},
error: e => { this.busy.set(false); this.error.set(errorText(e)); }
});
}
newPassword(m: Mailbox): void {
if (!confirm('New password for ' + m.email + '? The old one stops working at once.')) return;
this.api.newMailboxPassword(m.id).subscribe({
next: created => created.password && this.secret.set({ email: m.email, password: created.password }),
error: e => this.toasts.error(errorText(e))
});
}
/** 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))
});
}
/** 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))
});
}
editMembers(m: Mailbox): void {
if (this.membersOf() === m.id) { this.membersOf.set(null); return; }
this.picked.set(new Set(m.members));
this.membersOf.set(m.id);
}
pick(email: string): void {
const next = new Set(this.picked());
if (next.has(email)) { next.delete(email); } else { next.add(email); }
this.picked.set(next);
}
saveMembers(m: Mailbox): void {
this.api.setMailboxMembers(m.id, [...this.picked()]).subscribe({
next: () => { this.toasts.ok('Members of ' + m.email + ' saved'); this.membersOf.set(null); this.load(); },
error: e => this.toasts.error(errorText(e))
});
}
toggle(m: Mailbox): void {
this.api.setMailboxActive(m.id, !m.active).subscribe({
next: () => this.load(),
error: e => this.toasts.error(errorText(e))
});
}
remove(m: Mailbox): void {
if (!confirm('Delete ' + m.email + ' with ALL its mail? This can not be undone.')) return;
this.api.deleteMailbox(m.id).subscribe({
next: () => { this.toasts.ok(m.email + ' deleted'); this.load(); },
error: e => this.toasts.error(errorText(e))
});
}
copy(text: string): void {
navigator.clipboard?.writeText(text).then(() => this.toasts.ok('Copied'), () => this.toasts.error('Copy it by hand'));
}
}