Files
reactive-mail-server/frontend/src/app/pages/message/message.page.ts
T
Liosha84 29b1beabe0 Почтовый кабинет: регистрация, папки, черновики, вложения, грид @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>
2026-11-05 16:41:29 +01:00

275 lines
13 KiB
TypeScript

import { ChangeDetectionStrategy, Component, computed, effect, inject, input, signal } from '@angular/core';
import { DatePipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { Location } from '@angular/common';
import { Router, RouterLink } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ApiService } from '../../core/api.service';
import { MailStateService } from '../../core/mail-state.service';
import { Attachment, Message, errorText, folderLabel, formatBytes } from '../../core/models';
import { DialogService } from '../../ui/dialog.service';
import { ToastService } from '../../ui/toast.service';
/** One message: headers, body, attachments, reply/forward/move/spam/block/delete. */
@Component({
selector: 'app-message',
imports: [DatePipe, FormsModule, RouterLink, MatIconModule, MatTooltipModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (error()) {
<section class="glass-panel static p-6">
<p class="text-pink-400 font-rajdhani mb-4" role="alert">{{ error() }}</p>
<a routerLink="/" class="ghost-btn"><mat-icon>arrow_back</mat-icon> Inbox</a>
</section>
} @else if (message(); as m) {
<article class="glass-panel static p-4 md:p-6" aria-labelledby="message-subject">
<div class="flex flex-wrap items-center gap-2 mb-5 pb-4 border-b border-cyan-500/20" role="toolbar" aria-label="Message actions">
<button type="button" class="ghost-btn" (click)="back()"><mat-icon>arrow_back</mat-icon> Back</button>
<span class="flex-grow"></span>
<a [routerLink]="['/compose', { reply: m.id }]" class="ghost-btn"><mat-icon>reply</mat-icon> Reply</a>
@if (hasOthers()) {
<a [routerLink]="['/compose', { replyAll: m.id }]" class="ghost-btn"><mat-icon>reply_all</mat-icon> Reply all</a>
}
<a [routerLink]="['/compose', { forward: m.id }]" class="ghost-btn"><mat-icon>forward</mat-icon> Forward</a>
<button type="button" class="ghost-btn" [attr.aria-pressed]="m.flagged" (click)="update({ flagged: !m.flagged })">
<mat-icon [class.text-yellow-400]="m.flagged">{{ m.flagged ? 'star' : 'star_border' }}</mat-icon> {{ m.flagged ? 'Flagged' : 'Flag' }}
</button>
<button type="button" class="ghost-btn" (click)="markUnread()"><mat-icon>mark_email_unread</mat-icon> Unread</button>
<label for="move-to" class="sr-only">Move to folder</label>
<select id="move-to" class="bg-slate-900/80 text-slate-300 border border-cyan-500/35 rounded-[10px] px-2 py-1.5 text-xs font-mono"
[ngModel]="''" (ngModelChange)="move($event)">
<option value="" disabled>Move to…</option>
@for (f of targets(); track f.id) { <option [value]="f.id">{{ label(f) }}</option> }
</select>
@if (inSpam()) {
<button type="button" class="ghost-btn" (click)="notSpam()"><mat-icon>inbox</mat-icon> Not spam</button>
} @else if (!outgoing()) {
<button type="button" class="ghost-btn" (click)="spam()"><mat-icon>report</mat-icon> Spam</button>
}
<button type="button" class="ghost-btn danger" (click)="remove()"><mat-icon>delete</mat-icon> {{ inTrash() ? 'Delete forever' : 'Delete' }}</button>
</div>
<h1 id="message-subject" class="text-2xl font-orbitron font-bold neon-text-secondary mb-4 break-words">{{ m.subject || '(no subject)' }}</h1>
<div class="flex items-start gap-3 mb-5">
<div class="w-11 h-11 rounded-full bg-gradient-to-br from-pink-500/60 to-cyan-400/60 flex items-center justify-center font-orbitron font-bold text-white shrink-0"
aria-hidden="true">{{ initial() }}</div>
<dl class="text-sm font-rajdhani min-w-0 flex-grow grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5">
<dt class="text-slate-500 font-mono text-xs pt-0.5">From</dt>
<dd class="text-slate-100 break-all">
@if (m.senderName) { <span class="font-bold">{{ m.senderName }}</span> }
<span class="text-cyan-300 font-mono text-xs ml-1">&lt;{{ m.senderEmail }}&gt;</span>
@if (!outgoing()) {
<button type="button" class="ml-2 text-[11px] font-mono text-slate-500 hover:text-pink-400" (click)="block()">block sender</button>
}
</dd>
<dt class="text-slate-500 font-mono text-xs pt-0.5">To</dt>
<dd class="text-slate-300 break-all">{{ m.recipientEmail }}</dd>
@if (m.ccEmail) {
<dt class="text-slate-500 font-mono text-xs pt-0.5">Cc</dt>
<dd class="text-slate-300 break-all">{{ m.ccEmail }}</dd>
}
<dt class="text-slate-500 font-mono text-xs pt-0.5">Date</dt>
<dd class="text-slate-400 font-mono text-xs pt-0.5">{{ m.receivedAt | date: 'EEEE, d MMMM y, HH:mm' }}</dd>
</dl>
</div>
@if (m.spam) {
<p class="mb-4 px-3 py-2 rounded-lg bg-pink-500/15 border border-pink-500/40 text-pink-200 text-sm font-rajdhani flex items-center gap-2">
<mat-icon>report</mat-icon> Marked as spam: the sender is blocked or the message failed the SPF/DKIM/DMARC checks.
</p>
}
@if (m.html) {
<div class="flex items-center gap-3 mb-2 text-xs font-mono text-slate-400">
<span>HTML message</span>
<button type="button" class="ghost-btn !py-1" (click)="remoteImages.set(!remoteImages())" [attr.aria-pressed]="remoteImages()">
<mat-icon>image</mat-icon> {{ remoteImages() ? 'Hide remote images' : 'Show remote images' }}
</button>
</div>
<iframe class="w-full h-[65vh] rounded-xl bg-white border border-cyan-500/30" sandbox="allow-popups allow-popups-to-escape-sandbox"
[srcdoc]="htmlDoc()" title="Message body"></iframe>
} @else {
<div class="bg-slate-950/70 rounded-xl border border-cyan-500/20 p-4 md:p-5 font-rajdhani text-[1.02rem] text-slate-200 whitespace-pre-wrap break-words leading-relaxed">{{ m.body }}</div>
}
@if (attachments().length) {
<h2 class="mt-6 mb-2 text-xs font-orbitron font-bold text-cyan-300 tracking-widest">ATTACHMENTS ({{ attachments().length }})</h2>
<ul class="flex flex-wrap gap-2">
@for (a of attachments(); track a.id) {
<li>
<button type="button" class="ghost-btn !py-2" (click)="download(a)" [attr.aria-label]="'Download ' + a.filename">
<mat-icon>{{ fileIcon(a) }}</mat-icon>
<span class="max-w-[220px] truncate text-slate-200">{{ a.filename }}</span>
<span class="text-slate-500">{{ size(a.sizeBytes) }}</span>
<mat-icon class="text-cyan-300">download</mat-icon>
</button>
</li>
}
</ul>
}
</article>
} @else {
<section class="glass-panel static p-6 text-slate-400 font-mono text-sm">Loading…</section>
}
`
})
export class MessagePage {
readonly id = input.required<string>();
private api = inject(ApiService);
private state = inject(MailStateService);
private dialogs = inject(DialogService);
private toasts = inject(ToastService);
private router = inject(Router);
private location = inject(Location);
private sanitizer = inject(DomSanitizer);
readonly message = signal<Message | null>(null);
readonly attachments = signal<Attachment[]>([]);
readonly error = signal('');
readonly remoteImages = signal(false);
readonly folder = computed(() => { const m = this.message(); return m ? this.state.folder(m.folderId) : null; });
readonly inTrash = computed(() => this.folder()?.system === true && this.folder()?.name === 'Trash');
readonly inSpam = computed(() => this.folder()?.system === true && this.folder()?.name === 'Spam');
readonly outgoing = computed(() => this.folder()?.system === true && ['Sent', 'Drafts'].includes(this.folder()!.name));
readonly targets = computed(() => this.state.folders().filter(f => f.id !== this.message()?.folderId && f.name !== 'Drafts'));
readonly initial = computed(() => (this.message()?.senderName || this.message()?.senderEmail || '?').trim().charAt(0).toUpperCase());
readonly hasOthers = computed(() => {
const m = this.message();
if (!m) return false;
return m.recipientEmail.split(',').length + (m.ccEmail ? m.ccEmail.split(',').length : 0) > 1;
});
/** No scripts (sandbox), remote content only when asked — a sender cannot see that the message was opened. */
readonly htmlDoc = computed<SafeHtml>(() => {
const img = this.remoteImages() ? 'img-src data: https: http:;' : 'img-src data:;';
const csp = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; ${img} font-src data:">`;
const base = '<base target="_blank"><style>body{font-family:system-ui,sans-serif;margin:16px;color:#111;word-wrap:break-word}</style>';
return this.sanitizer.bypassSecurityTrustHtml(csp + base + (this.message()?.body ?? ''));
});
label = folderLabel;
constructor() {
effect(() => this.load(Number(this.id())));
}
private load(id: number): void {
this.message.set(null);
this.attachments.set([]);
this.error.set('');
this.remoteImages.set(false);
this.api.message(id).subscribe({
next: m => {
this.message.set(m);
if (m.attachmentCount) this.api.attachments(m.id).subscribe({ next: a => this.attachments.set(a), error: () => {} });
if (!m.read) {
this.api.updateMessage(m.id, { read: true }).subscribe({
next: () => { this.message.update(x => x && { ...x, read: true }); this.state.refreshFolders(); },
error: () => {}
});
}
},
error: e => this.error.set(e?.status === 404 ? 'This message does not exist (or was deleted).' : errorText(e))
});
}
back(): void {
const m = this.message();
if (history.length > 1) this.location.back();
else this.router.navigate(m ? ['/folder', m.folderId] : ['/']);
}
update(change: { read?: boolean; flagged?: boolean; folderId?: number }): void {
const m = this.message();
if (!m) return;
this.api.updateMessage(m.id, change).subscribe({
next: updated => { this.message.set({ ...m, ...updated, body: m.body }); this.state.refreshFolders(); },
error: e => this.toasts.error(errorText(e))
});
}
markUnread(): void {
const m = this.message();
if (!m) return;
this.api.updateMessage(m.id, { read: false }).subscribe({
next: () => { this.state.refreshFolders(); this.router.navigate(['/folder', m.folderId]); },
error: e => this.toasts.error(errorText(e))
});
}
move(value: string): void {
const target = this.state.folder(Number(value));
const m = this.message();
if (!target || !m) return;
this.api.updateMessage(m.id, { folderId: target.id }).subscribe({
next: () => { this.toasts.ok(`Moved to ${folderLabel(target)}`); this.state.refreshFolders(); this.router.navigate(['/folder', m.folderId]); },
error: e => this.toasts.error(errorText(e))
});
}
spam(): void { const s = this.state.system('Spam'); if (s) this.move(String(s.id)); }
notSpam(): void { const i = this.state.inbox(); if (i) this.move(String(i.id)); }
block(): void {
const m = this.message();
if (!m) return;
this.dialogs.confirm(`Block ${m.senderEmail}?`, 'New messages from this sender go straight to Spam. This message moves to Spam too.', 'Block', true)
.subscribe(ok => {
if (ok === null) return;
this.api.block(m.senderEmail, 'Blocked from a message').subscribe({
next: () => { this.toasts.ok(`${m.senderEmail} blocked`); this.spam(); },
error: e => this.toasts.error(errorText(e))
});
});
}
remove(): void {
const m = this.message();
if (!m) return;
const go = () => this.api.deleteMessage(m.id).subscribe({
next: () => {
this.toasts.ok(this.inTrash() ? 'Deleted forever' : 'Moved to Trash');
this.state.refreshFolders();
this.state.refreshProfile();
this.router.navigate(['/folder', m.folderId]);
},
error: e => this.toasts.error(errorText(e))
});
if (this.inTrash()) {
this.dialogs.confirm('Delete forever?', 'This message cannot be restored.', 'Delete', true).subscribe(ok => ok !== null && go());
} else {
go();
}
}
download(a: Attachment): void {
this.api.download(a.id).subscribe({
next: blob => {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = a.filename;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
},
error: e => this.toasts.error(errorText(e))
});
}
size(bytes: number): string { return formatBytes(bytes); }
fileIcon(a: Attachment): string {
const type = a.contentType || '';
if (type.startsWith('image/')) return 'image';
if (type.includes('pdf')) return 'picture_as_pdf';
if (type.includes('zip') || type.includes('compressed')) return 'folder_zip';
if (type.startsWith('text/')) return 'description';
return 'attach_file';
}
}