Почтовый кабинет: регистрация, папки, черновики, вложения, грид @jambotron/aria-grid, интерфейс по synthwave-шаблону

Бэкенд: регистрация со входом сразу и /api/config, профиль и смена пароля, свои папки, PATCH письма
(прочитано, флажок, перенос; Спам ставит отметку), корзина и очистка, черновики, несколько адресатов и копия,
вложения черновика уходят с письмом, вложения и заголовки To/Cc входящих сохраняются, письма заблокированных —
в Спам. Исправлено: загрузка файла (415), колонка content_id (500 на любых вложениях), SPA-маршрут с точкой.
Фронтенд переписан: все таблицы — грид, оформление — шаблон synthwave-design-showcase.html.

α
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 16:41:29 +01:00
parent f605fbfd85
commit 29b1beabe0
70 changed files with 3468 additions and 1737 deletions
@@ -0,0 +1,100 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { DatePipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import {
JtGridBulkEvent, JtGridBulkService, JtGridCellDirective, JtGridColumn, JtGridComponent, JtGridRowEvent, RowAction
} from '@jambotron/aria-grid';
import { ApiService } from '../../core/api.service';
import { BlacklistEntry, errorText } from '../../core/models';
import { ToastService } from '../../ui/toast.service';
/** Blocked senders: their mail goes straight to Spam. */
@Component({
selector: 'app-blocked',
imports: [DatePipe, FormsModule, MatIconModule, JtGridComponent, JtGridCellDirective],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="glass-panel static p-4 md:p-6" aria-labelledby="blocked-title">
<div class="flex items-center gap-3 mb-4">
<mat-icon class="text-pink-400">block</mat-icon>
<div>
<h1 id="blocked-title" class="text-xl font-orbitron font-bold neon-text-secondary">BLOCKED SENDERS</h1>
<p class="text-xs text-slate-400 font-mono">Messages from these addresses go straight to Spam</p>
</div>
</div>
<form class="flex flex-wrap items-end gap-3 mb-5 p-4 rounded-xl bg-slate-950/50 border border-pink-500/20" (ngSubmit)="add()">
<div class="flex-grow min-w-[220px]">
<label class="synth-label" for="b-email">Sender address</label>
<input id="b-email" name="email" type="email" class="synth-input" [(ngModel)]="email" placeholder="spammer@example.com">
</div>
<div class="flex-grow min-w-[180px]">
<label class="synth-label" for="b-reason">Reason (optional)</label>
<input id="b-reason" name="reason" class="synth-input" [(ngModel)]="reason" maxlength="255">
</div>
<button type="submit" class="cyber-btn px-4 py-2.5 rounded-lg text-xs font-bold"><mat-icon>block</mat-icon> BLOCK</button>
</form>
<jt-grid [rows]="entries()" [columns]="columns" [trackBy]="bySender" label="Blocked senders"
[emptyMessage]="loading() ? 'Loading…' : 'Nobody is blocked'" [bulkActions]="bulkActions" (bulkAction)="onBulk($event)"
[rowActions]="rowActions" (rowAction)="onRow($event)" [rowSubject]="senderOf" [initialSort]="{ key: 'date', direction: 'desc' }">
<ng-template jtGridCell="sender" let-row><span class="font-mono text-sm text-cyan-300">{{ row.senderEmail }}</span></ng-template>
<ng-template jtGridCell="reason" let-row><span class="text-slate-300">{{ row.reason || '' }}</span></ng-template>
<ng-template jtGridCell="date" let-row><span class="font-mono text-xs text-slate-400">{{ row.createdAt | date: 'd MMM y, HH:mm' }}</span></ng-template>
</jt-grid>
</section>
`
})
export class BlockedPage {
private api = inject(ApiService);
private bulk = inject(JtGridBulkService);
private toasts = inject(ToastService);
readonly entries = signal<BlacklistEntry[]>([]);
readonly loading = signal(true);
email = '';
reason = '';
readonly columns: JtGridColumn<BlacklistEntry>[] = [
{ key: 'sender', label: 'Sender', sort: e => e.senderEmail },
{ key: 'reason', label: 'Reason', sort: e => e.reason || '' },
{ key: 'date', label: 'Blocked', sort: e => e.createdAt || '', align: 'end' }
];
readonly bulkActions: RowAction[] = [{ value: 'unblock', label: 'Unblock', icon: 'lock_open' }];
readonly rowActions = (): RowAction[] => [{ value: 'unblock', label: 'Unblock', icon: 'lock_open', inline: true }];
readonly bySender = (e: BlacklistEntry) => e.senderEmail;
readonly senderOf = (e: BlacklistEntry) => e.senderEmail;
constructor() { this.load(); }
load(): void {
this.api.blacklist().subscribe({
next: list => { this.entries.set(list); this.loading.set(false); },
error: e => { this.loading.set(false); this.toasts.error(errorText(e)); }
});
}
add(): void {
const email = this.email.trim().toLowerCase();
if (!/^[^@\s]+@[^@\s]+$/.test(email)) {
this.toasts.error('Enter a valid sender address');
return;
}
this.api.block(email, this.reason.trim()).subscribe({
next: () => { this.toasts.ok(`${email} blocked`); this.email = ''; this.reason = ''; this.load(); },
error: e => this.toasts.error(errorText(e))
});
}
onRow(e: JtGridRowEvent<BlacklistEntry>): void {
this.api.unblock(e.row.senderEmail).subscribe({
next: () => { this.toasts.ok(`${e.row.senderEmail} unblocked`); this.load(); },
error: err => this.toasts.error(errorText(err))
});
}
onBulk(e: JtGridBulkEvent<BlacklistEntry>): void {
this.bulk.run({ rows: e.rows, label: 'Unblock', run: x => this.api.unblock(x.senderEmail) }).subscribe(() => this.load());
}
}