Files
reactive-mail-server/frontend/src/app/pages/compose/compose.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

371 lines
16 KiB
TypeScript

import { ChangeDetectionStrategy, Component, DestroyRef, computed, effect, inject, input, signal, untracked } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { Observable, of, switchMap, tap } from 'rxjs';
import { ApiService } from '../../core/api.service';
import { MailStateService } from '../../core/mail-state.service';
import { Attachment, Contact, Draft, Message, errorText, formatBytes } from '../../core/models';
import { DialogService } from '../../ui/dialog.service';
import { ToastService } from '../../ui/toast.service';
const MAX_FILE = 10 * 1024 * 1024;
/**
* New message, reply, reply all, forward, or a draft being edited. A draft is saved on the server
* (automatically every 15 s while there are changes, and before attaching a file — files belong to the draft).
*/
@Component({
selector: 'app-compose',
imports: [FormsModule, NgTemplateOutlet, MatIconModule, MatTooltipModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="glass-panel static p-4 md:p-6" aria-labelledby="compose-title">
<div class="flex flex-wrap items-center justify-between gap-3 mb-5">
<h1 id="compose-title" class="text-xl font-orbitron font-bold neon-text-secondary">{{ title() }}</h1>
<span class="text-xs font-mono" [class]="dirty() ? 'text-yellow-300' : 'text-slate-500'" aria-live="polite">
{{ saving() ? 'Saving…' : dirty() ? 'Unsaved changes' : draftId() ? 'Draft saved' : '' }}
</span>
</div>
<form (ngSubmit)="send()" class="space-y-4">
<div class="relative">
<div class="flex items-center justify-between">
<label class="synth-label" for="to">To</label>
@if (!showCc()) {
<button type="button" class="text-[11px] font-mono text-cyan-300 hover:text-pink-400 mb-1" (click)="showCc.set(true)">+ Cc</button>
}
</div>
<input id="to" name="to" class="synth-input" [ngModel]="to()" (ngModelChange)="to.set($event); touch()"
(focus)="focused.set('to')" (blur)="blurLater()" autocomplete="off" placeholder="name@domain, another@domain"
role="combobox" aria-autocomplete="list" [attr.aria-expanded]="focused() === 'to' && suggestions().length > 0"
aria-controls="suggest-to">
@if (focused() === 'to') { <ng-container *ngTemplateOutlet="suggest; context: { field: 'to' }" /> }
</div>
@if (showCc()) {
<div class="relative">
<label class="synth-label" for="cc">Cc</label>
<input id="cc" name="cc" class="synth-input" [ngModel]="cc()" (ngModelChange)="cc.set($event); touch()"
(focus)="focused.set('cc')" (blur)="blurLater()" autocomplete="off"
role="combobox" aria-autocomplete="list" [attr.aria-expanded]="focused() === 'cc' && suggestions().length > 0"
aria-controls="suggest-cc">
@if (focused() === 'cc') { <ng-container *ngTemplateOutlet="suggest; context: { field: 'cc' }" /> }
</div>
}
<div>
<label class="synth-label" for="subject">Subject</label>
<input id="subject" name="subject" class="synth-input" [ngModel]="subject()" (ngModelChange)="subject.set($event); touch()"
maxlength="1000" autocomplete="off">
</div>
<div>
<label class="synth-label" for="body">Message</label>
<textarea id="body" name="body" rows="14" class="synth-input font-rajdhani leading-relaxed resize-y"
[ngModel]="body()" (ngModelChange)="body.set($event); touch()"></textarea>
</div>
<div>
<div class="flex flex-wrap items-center gap-2">
<label class="ghost-btn cursor-pointer" [class.opacity-50]="uploading()">
<mat-icon>attach_file</mat-icon> Attach files
<input type="file" multiple class="sr-only" (change)="attach($event)" [disabled]="uploading()">
</label>
<span class="text-[11px] font-mono text-slate-500">up to 10 MB each</span>
@if (uploading()) { <span class="text-xs font-mono text-cyan-300">Uploading…</span> }
</div>
@if (attachments().length) {
<ul class="flex flex-wrap gap-2 mt-3" aria-label="Attached files">
@for (a of attachments(); track a.id) {
<li class="flex items-center gap-2 pl-3 pr-1 py-1 rounded-lg bg-slate-900/70 border border-cyan-500/30 text-sm">
<mat-icon class="!text-base text-cyan-300">attach_file</mat-icon>
<span class="max-w-[220px] truncate text-slate-200">{{ a.filename }}</span>
<span class="text-xs font-mono text-slate-500">{{ size(a.sizeBytes) }}</span>
<button type="button" class="text-slate-400 hover:text-pink-400 p-1" [attr.aria-label]="'Remove ' + a.filename"
(click)="detach(a)"><mat-icon class="!text-lg">close</mat-icon></button>
</li>
}
</ul>
}
</div>
@if (error()) {
<p class="text-sm text-pink-400 font-rajdhani flex items-center gap-2" role="alert"><mat-icon class="!text-lg">error</mat-icon>{{ error() }}</p>
}
<div class="flex flex-wrap items-center gap-3 pt-2 border-t border-cyan-500/20">
<button type="submit" class="cyber-btn px-6 py-2.5 rounded-xl font-bold text-sm" [disabled]="sending() || uploading()">
<mat-icon>send</mat-icon> {{ sending() ? 'SENDING…' : 'SEND' }}
</button>
<button type="button" class="ghost-btn" (click)="saveNow()" [disabled]="saving() || !hasContent()">
<mat-icon>save</mat-icon> Save draft
</button>
<span class="flex-grow"></span>
<button type="button" class="ghost-btn danger" (click)="discard()"><mat-icon>delete</mat-icon> Discard</button>
</div>
</form>
</section>
<ng-template #suggest let-field="field">
@if (suggestions().length) {
<ul [id]="'suggest-' + field" role="listbox" aria-label="Contacts"
class="absolute z-30 w-full mt-1 bg-slate-950/95 border border-pink-500/40 rounded-xl shadow-2xl py-1 backdrop-blur-2xl">
@for (c of suggestions(); track c.email) {
<li role="option" aria-selected="false" class="px-4 py-2 text-sm cursor-pointer hover:bg-cyan-500/20 flex gap-2"
(mousedown)="$event.preventDefault(); pick(field, c)">
<span class="text-slate-200 font-semibold">{{ c.name || c.email }}</span>
@if (c.name) { <span class="text-cyan-300 font-mono text-xs self-center">{{ c.email }}</span> }
</li>
}
</ul>
}
</ng-template>
`
})
export class ComposePage {
readonly draft = input<string>();
readonly reply = input<string>();
readonly replyAll = input<string>();
readonly forward = input<string>();
readonly toParam = input<string>(undefined, { alias: 'to' });
private api = inject(ApiService);
private state = inject(MailStateService);
private dialogs = inject(DialogService);
private toasts = inject(ToastService);
private router = inject(Router);
readonly to = signal('');
readonly cc = signal('');
readonly subject = signal('');
readonly body = signal('');
readonly showCc = signal(false);
readonly draftId = signal<number | null>(null);
readonly attachments = signal<Attachment[]>([]);
readonly dirty = signal(false);
readonly saving = signal(false);
readonly sending = signal(false);
readonly uploading = signal(false);
readonly error = signal('');
readonly focused = signal<'to' | 'cc' | null>(null);
private contacts = signal<Contact[]>([]);
private done = false;
readonly title = computed(() => this.reply() ? 'REPLY' : this.replyAll() ? 'REPLY ALL' : this.forward() ? 'FORWARD'
: this.draft() ? 'EDIT DRAFT' : 'NEW MESSAGE');
readonly hasContent = computed(() => !!(this.to().trim() || this.cc().trim() || this.subject().trim() || this.body().trim()));
/** Contacts matching the address being typed (after the last comma). */
readonly suggestions = computed(() => {
const field = this.focused();
if (!field) return [];
const value = field === 'to' ? this.to() : this.cc();
const typed = value.split(/[,;]/).pop()!.trim().toLowerCase();
if (!typed) return [];
const taken = value.toLowerCase();
return this.contacts()
.filter(c => !taken.includes(c.email.toLowerCase()) &&
(c.email.toLowerCase().includes(typed) || (c.name || '').toLowerCase().includes(typed)))
.slice(0, 6);
});
constructor() {
this.api.contacts().subscribe({ next: c => this.contacts.set(c), error: () => {} });
effect(() => {
const draft = this.draft(), reply = this.reply(), replyAll = this.replyAll(), forward = this.forward(), to = this.toParam();
untracked(() => this.init(draft, reply, replyAll, forward, to));
});
const timer = setInterval(() => { if (this.dirty() && !this.saving() && !this.sending()) this.save().subscribe(); }, 15000);
inject(DestroyRef).onDestroy(() => {
clearInterval(timer);
if (!this.done && this.dirty() && this.hasContent()) this.save().subscribe();
});
}
private init(draft?: string, reply?: string, replyAll?: string, forward?: string, to?: string): void {
const source = draft ?? reply ?? replyAll ?? forward;
if (to) this.to.set(to);
if (!source) return;
this.api.message(Number(source)).subscribe({
next: m => {
if (draft) this.fromDraft(m);
else if (forward) this.fromForward(m);
else this.fromReply(m, !!replyAll);
},
error: e => this.error.set(errorText(e))
});
}
private fromDraft(m: Message): void {
this.draftId.set(m.id);
this.to.set(m.recipientEmail || '');
this.cc.set(m.ccEmail || '');
this.showCc.set(!!m.ccEmail);
this.subject.set(m.subject || '');
this.body.set(m.body || '');
if (m.attachmentCount) this.api.attachments(m.id).subscribe({ next: a => this.attachments.set(a), error: () => {} });
}
private fromReply(m: Message, all: boolean): void {
const me = (this.state.profile()?.email ?? '').toLowerCase();
this.to.set(m.senderEmail);
if (all) {
const others = [...m.recipientEmail.split(','), ...(m.ccEmail ? m.ccEmail.split(',') : [])]
.map(a => a.trim()).filter(a => a && a.toLowerCase() !== me && a.toLowerCase() !== m.senderEmail.toLowerCase());
if (others.length) { this.cc.set(others.join(', ')); this.showCc.set(true); }
}
this.subject.set(prefixed('Re: ', m.subject));
this.body.set(`\n\n${header(m)} wrote:\n${quote(m)}`);
this.dirty.set(true);
}
private fromForward(m: Message): void {
this.subject.set(prefixed('Fwd: ', m.subject));
this.body.set(`\n\n---------- Forwarded message ----------\nFrom: ${header(m)}\nDate: ${new Date(m.receivedAt).toLocaleString()}\n`
+ `Subject: ${m.subject || ''}\nTo: ${m.recipientEmail}\n\n${plain(m)}`);
this.dirty.set(true);
if (m.attachmentCount) this.toasts.ok('Attachments of the original are not forwarded — attach them again if needed');
}
touch(): void { this.dirty.set(true); this.error.set(''); }
blurLater(): void { setTimeout(() => this.focused.set(null), 150); }
pick(field: 'to' | 'cc', contact: Contact): void {
const target = field === 'to' ? this.to : this.cc;
const parts = target().split(/[,;]/).map(p => p.trim());
parts[parts.length - 1] = contact.email;
target.set(parts.filter(Boolean).join(', ') + ', ');
this.touch();
}
private current(): Draft {
return { to: this.to().trim(), cc: this.showCc() ? this.cc().trim() : '', subject: this.subject(), body: this.body(), html: false };
}
/** Creates the draft on the first save, updates it afterwards. */
private save(): Observable<number> {
const id = this.draftId();
this.saving.set(true);
const request = id ? this.api.updateDraft(id, this.current()) : this.api.createDraft(this.current());
return request.pipe(
tap({
next: m => { this.draftId.set(m.id); this.dirty.set(false); this.saving.set(false); this.state.refreshFolders(); },
error: e => { this.saving.set(false); this.error.set('Draft not saved: ' + errorText(e)); }
}),
switchMap(m => of(m.id))
);
}
saveNow(): void {
this.save().subscribe({ next: () => this.toasts.ok('Draft saved'), error: () => {} });
}
attach(event: Event): void {
const field = event.target as HTMLInputElement;
const files = Array.from(field.files ?? []);
// the input is cleared only after the upload: Chrome cannot read a File whose input was reset
const reset = () => { field.value = ''; };
const tooBig = files.filter(f => f.size > MAX_FILE);
if (tooBig.length) this.toasts.error(`Too big (over 10 MB): ${tooBig.map(f => f.name).join(', ')}`);
const ok = files.filter(f => f.size <= MAX_FILE);
if (!ok.length) { reset(); return; }
this.uploading.set(true);
const id = this.draftId();
(id && !this.dirty() ? of(id) : this.save()).subscribe({
next: draftId => this.uploadNext(draftId, ok, reset),
error: () => { this.uploading.set(false); reset(); }
});
}
private uploadNext(draftId: number, files: File[], done: () => void): void {
const [file, ...rest] = files;
if (!file) {
this.uploading.set(false);
done();
return;
}
this.api.upload(draftId, file).subscribe({
next: a => { this.attachments.update(list => [...list, a]); this.uploadNext(draftId, rest, done); },
error: e => { this.toasts.error(`${file.name}: ${errorText(e)}`); this.uploadNext(draftId, rest, done); }
});
}
detach(a: Attachment): void {
this.api.deleteAttachment(a.id).subscribe({
next: () => this.attachments.update(list => list.filter(x => x.id !== a.id)),
error: e => this.toasts.error(errorText(e))
});
}
send(): void {
if (!this.to().trim()) {
this.error.set('Who is it for? Fill in "To"');
return;
}
const go = () => {
this.sending.set(true);
this.error.set('');
this.api.send(this.current(), this.draftId()).subscribe({
next: () => {
this.done = true;
this.toasts.ok('Message sent');
this.state.refreshFolders();
this.state.refreshProfile();
const sent = this.state.system('Sent');
this.router.navigate(sent ? ['/folder', sent.id] : ['/']);
},
error: e => { this.sending.set(false); this.error.set(errorText(e)); }
});
};
if (!this.subject().trim()) {
this.dialogs.confirm('Send without a subject?', 'The message has no subject.', 'Send').subscribe(ok => ok !== null && go());
} else {
go();
}
}
discard(): void {
const id = this.draftId();
const leave = () => {
this.done = true;
this.router.navigate(['/']);
};
if (!id && !this.hasContent()) {
leave();
return;
}
this.dialogs.confirm('Discard this message?', id ? 'The draft and its attachments are deleted.' : 'What you typed is lost.', 'Discard', true)
.subscribe(ok => {
if (ok === null) return;
if (!id) { leave(); return; }
// a draft: to the Trash, then out of it
this.api.deleteMessage(id).pipe(switchMap(() => this.api.deleteMessage(id))).subscribe({
next: () => { this.state.refreshFolders(); leave(); },
error: () => { this.state.refreshFolders(); leave(); }
});
});
}
size(bytes: number): string { return formatBytes(bytes); }
}
function prefixed(prefix: string, subject: string): string {
const s = subject || '';
return s.toLowerCase().startsWith(prefix.toLowerCase()) ? s : prefix + s;
}
function header(m: Message): string {
return m.senderName ? `${m.senderName} <${m.senderEmail}>` : m.senderEmail;
}
function plain(m: Message): string {
if (!m.html) return m.body || '';
const doc = new DOMParser().parseFromString(m.body || '', 'text/html');
return (doc.body.textContent || '').replace(/\n{3,}/g, '\n\n').trim();
}
function quote(m: Message): string {
return plain(m).split('\n').map(line => '> ' + line).join('\n');
}