Почтовый кабинет: регистрация, папки, черновики, вложения, грид @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:
@@ -0,0 +1,370 @@
|
||||
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');
|
||||
}
|
||||
Reference in New Issue
Block a user