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: `

{{ title() }}

{{ saving() ? 'Saving…' : dirty() ? 'Unsaved changes' : draftId() ? 'Draft saved' : '' }}
@if (!showCc()) { }
@if (focused() === 'to') { }
@if (showCc()) {
@if (focused() === 'cc') { }
}
up to 10 MB each @if (uploading()) { Uploading… }
@if (attachments().length) {
    @for (a of attachments(); track a.id) {
  • attach_file {{ a.filename }} {{ size(a.sizeBytes) }}
  • }
}
@if (error()) { }
@if (suggestions().length) { } ` }) export class ComposePage { readonly draft = input(); readonly reply = input(); readonly replyAll = input(); readonly forward = input(); readonly toParam = input(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(null); readonly attachments = signal([]); 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([]); 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 { 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'); }