continuous-integration/drone/pr Build is passing
DELETE /api/admin/mailboxes/{id} (только администратор, не свой ящик):
файлы вложений, затем строка users — папки, письма, исходники, контакты,
участие в рассылках и доступы уходят каскадом. Кнопка с подтверждением.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
245 lines
13 KiB
TypeScript
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">@</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@, billing@): 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'));
|
|
}
|
|
}
|