Бэкенд: регистрация со входом сразу и /api/config, профиль и смена пароля, свои папки, PATCH письма (прочитано, флажок, перенос; Спам ставит отметку), корзина и очистка, черновики, несколько адресатов и копия, вложения черновика уходят с письмом, вложения и заголовки To/Cc входящих сохраняются, письма заблокированных — в Спам. Исправлено: загрузка файла (415), колонка content_id (500 на любых вложениях), SPA-маршрут с точкой. Фронтенд переписан: все таблицы — грид, оформление — шаблон synthwave-design-showcase.html. α Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
371 lines
16 KiB
TypeScript
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');
|
|
}
|