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()) {
arrow_back Inbox
} @else if (message(); as m) {

{{ m.subject || '(no subject)' }}

From
@if (m.senderName) { {{ m.senderName }} } <{{ m.senderEmail }}> @if (!outgoing()) { }
To
{{ m.recipientEmail }}
@if (m.ccEmail) {
Cc
{{ m.ccEmail }}
}
Date
{{ m.receivedAt | date: 'EEEE, d MMMM y, HH:mm' }}
@if (m.spam) {

report Marked as spam: the sender is blocked or the message failed the SPF/DKIM/DMARC checks.

} @if (m.html) {
HTML message
} @else {
{{ m.body }}
} @if (attachments().length) {

ATTACHMENTS ({{ attachments().length }})

}
} @else {
Loading…
} ` }) export class MessagePage { readonly id = input.required(); 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(null); readonly attachments = signal([]); 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(() => { const img = this.remoteImages() ? 'img-src data: https: http:;' : 'img-src data:;'; const csp = ``; const base = ''; 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'; } }