Add contacts, reply/forward, pagination, HTML toggle, Docker Compose for VPS

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
2026-11-05 01:28:24 +01:00
co-authored by Junie
parent 0339413d6e
commit a23a020138
14 changed files with 570 additions and 12 deletions
+4
View File
@@ -25,6 +25,10 @@ export const routes: Routes = [
path: 'message/:id',
loadComponent: () => import('./components/message-detail/message-detail.component'),
},
{
path: 'contacts',
loadComponent: () => import('./components/contacts/contacts.component'),
},
{
path: '',
redirectTo: 'inbox',
@@ -2,12 +2,13 @@ import { Component, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { MailService } from '../../services/mail.service';
import { Router } from '@angular/router';
import { Router, ActivatedRoute } from '@angular/router';
@Component({
selector: 'app-compose',
standalone: true,
imports: [RouterLink, FormsModule],
providers: [MailService],
template: `
<div class="glass-panel p-6">
<h2 class="text-lg font-orbitron font-bold text-synth-cyan mb-6 flex items-center gap-2">
@@ -36,10 +37,24 @@ import { Router } from '@angular/router';
</div>
<div>
<label for="body" class="block text-xs font-mono text-synth-cyan mb-1">BODY</label>
<div class="flex items-center justify-between mb-1">
<label for="body" class="text-xs font-mono text-synth-cyan">BODY</label>
<label class="flex items-center gap-2 cursor-pointer">
<span class="text-xs font-mono text-slate-400">HTML</span>
<input type="checkbox" [(ngModel)]="isHtml" name="isHtml" class="sr-only">
<span class="w-10 h-5 rounded-full bg-synth-dark/50 border border-synth-cyan/30 relative transition-all"
[class.bg-synth-cyan/30]="isHtml" [class.border-synth-cyan]="isHtml">
<span class="absolute top-0.5 left-0.5 w-3.5 h-3.5 rounded-full bg-synth-cyan transition-all"
[class.translate-x-5]="isHtml"></span>
</span>
</label>
</div>
<textarea id="body" [(ngModel)]="body" name="body" rows="10" required
class="w-full bg-synth-darker/80 border border-synth-cyan/40 rounded-xl px-4 py-3 text-sm text-slate-200 font-mono focus:outline-none focus:ring-2 focus:ring-synth-pink focus:border-synth-cyan transition-all resize-none"
placeholder="Write your message..."></textarea>
[placeholder]="isHtml ? 'Write HTML or Markdown...' : 'Write your message...'"></textarea>
@if (isHtml) {
<p class="text-xs text-slate-500 font-mono mt-1">💡 Supports Markdown: **bold**, *italic*, - lists, \`\`\`code\`\`\`</p>
}
</div>
<div class="flex gap-3 pt-4">
@@ -59,22 +74,43 @@ import { Router } from '@angular/router';
export default class ComposeComponent {
private mailService = inject(MailService);
private router = inject(Router);
private route = inject(ActivatedRoute);
to = '';
subject = '';
body = '';
isHtml = false;
sending = false;
success = false;
constructor() {
// Поддержка Reply и Forward через queryParams
this.route.queryParams.subscribe(params => {
if (params['replyTo']) {
this.to = params['replyTo'];
}
if (params['subject']) {
this.subject = params['subject'];
}
if (params['forwardSubject']) {
this.subject = params['forwardSubject'];
}
if (params['forwardBody']) {
this.body = '\n\n--- Forwarded message ---\n' + params['forwardBody'];
}
});
}
onSend(): void {
this.sending = true;
this.mailService.send({ to: this.to, subject: this.subject, body: this.body }).subscribe({
this.mailService.send({ to: this.to, subject: this.subject, body: this.body, html: this.isHtml }).subscribe({
next: () => {
this.success = true;
this.sending = false;
this.to = '';
this.subject = '';
this.body = '';
this.isHtml = false;
setTimeout(() => this.router.navigate(['/inbox']), 1500);
},
error: () => {
@@ -0,0 +1,92 @@
<div class="space-y-4">
<h2 class="text-lg font-orbitron font-bold text-synth-cyan flex items-center gap-2">
<span class="text-synth-pink">👥</span> Contacts
<span class="text-sm font-mono text-slate-400 ml-auto">{{ contacts().length }} contacts</span>
</h2>
<!-- Форма добавления -->
<div class="glass-panel p-4">
<h3 class="text-sm font-orbitron font-bold text-synth-yellow mb-3">
{{ editingContact() ? 'Edit Contact' : 'Add New Contact' }}
</h3>
<form (ngSubmit)="saveContact()" class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label class="block text-xs font-mono text-slate-400 mb-1">Name</label>
<input type="text" [(ngModel)]="formName" name="name"
class="w-full bg-synth-dark/50 border border-synth-cyan/20 rounded-lg px-3 py-2 text-sm font-mono text-white focus:border-synth-cyan focus:outline-none focus:ring-1 focus:ring-synth-cyan/50"
placeholder="John Doe">
</div>
<div>
<label class="block text-xs font-mono text-slate-400 mb-1">Email *</label>
<input type="email" [(ngModel)]="formEmail" name="email" required
class="w-full bg-synth-dark/50 border border-synth-cyan/20 rounded-lg px-3 py-2 text-sm font-mono text-white focus:border-synth-cyan focus:outline-none focus:ring-1 focus:ring-synth-cyan/50"
placeholder="john@example.com">
</div>
<div>
<label class="block text-xs font-mono text-slate-400 mb-1">Phone</label>
<input type="text" [(ngModel)]="formPhone" name="phone"
class="w-full bg-synth-dark/50 border border-synth-cyan/20 rounded-lg px-3 py-2 text-sm font-mono text-white focus:border-synth-cyan focus:outline-none focus:ring-1 focus:ring-synth-cyan/50"
placeholder="+1 234 567 8900">
</div>
<div>
<label class="block text-xs font-mono text-slate-400 mb-1">Company</label>
<input type="text" [(ngModel)]="formCompany" name="company"
class="w-full bg-synth-dark/50 border border-synth-cyan/20 rounded-lg px-3 py-2 text-sm font-mono text-white focus:border-synth-cyan focus:outline-none focus:ring-1 focus:ring-synth-cyan/50"
placeholder="Acme Corp">
</div>
<div class="md:col-span-2 flex gap-2">
<button type="submit"
class="px-4 py-2 bg-synth-pink/20 border border-synth-pink/40 rounded-lg text-sm font-mono text-synth-pink hover:bg-synth-pink/30 transition-all">
{{ editingContact() ? '💾 Update' : '➕ Add' }}
</button>
@if (editingContact()) {
<button type="button" (click)="cancelEdit()"
class="px-4 py-2 bg-slate-700/50 border border-slate-600/40 rounded-lg text-sm font-mono text-slate-300 hover:bg-slate-600/50 transition-all">
✖ Cancel
</button>
}
</div>
</form>
</div>
<!-- Список контактов -->
<div class="glass-panel p-4">
@if (contacts().length === 0) {
<div class="text-center py-12 text-slate-400 font-mono">
<p class="text-4xl mb-4">📇</p>
<p>No contacts yet</p>
</div>
} @else {
<div class="space-y-2">
@for (contact of contacts(); track contact.id) {
<div class="flex items-center gap-3 p-3 rounded-xl border border-synth-cyan/10 hover:border-synth-cyan/40 hover:bg-synth-cyan/5 transition-all">
<div class="w-10 h-10 rounded-full bg-synth-purple/30 flex items-center justify-center text-synth-cyan font-bold text-lg">
{{ (contact.name || contact.email).charAt(0).toUpperCase() }}
</div>
<div class="flex-1 min-w-0">
<div class="font-mono text-white truncate">{{ contact.name || '(No name)' }}</div>
<div class="text-xs text-slate-400 font-mono truncate">{{ contact.email }}</div>
@if (contact.phone || contact.company) {
<div class="text-xs text-slate-500 font-mono">
<span *ngIf="contact.company">{{ contact.company }}</span>
<span *ngIf="contact.company && contact.phone"> • </span>
<span *ngIf="contact.phone">{{ contact.phone }}</span>
</div>
}
</div>
<div class="flex gap-1">
<button (click)="editContact(contact)"
class="px-2 py-1 text-xs font-mono text-synth-cyan hover:bg-synth-cyan/10 rounded transition-all">
✏️
</button>
<button (click)="deleteContact(contact.id!)"
class="px-2 py-1 text-xs font-mono text-synth-pink hover:bg-synth-pink/10 rounded transition-all">
🗑️
</button>
</div>
</div>
}
</div>
}
</div>
</div>
@@ -0,0 +1,95 @@
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { CommonModule } from '@angular/common';
import { MailService } from '../../services/mail.service';
import { Contact } from '../../models/mail.models';
@Component({
selector: 'app-contacts',
standalone: true,
imports: [FormsModule, CommonModule],
providers: [MailService],
templateUrl: './contacts.component.html',
styles: ``
})
export default class ContactsComponent {
private mailService = inject(MailService);
contacts = signal<Contact[]>([]);
editingContact = signal<Contact | null>(null);
formName = '';
formEmail = '';
formPhone = '';
formCompany = '';
constructor() {
this.loadContacts();
}
loadContacts(): void {
this.mailService.getContacts().subscribe({
next: (contacts) => this.contacts.set(contacts),
error: (e) => console.error('Failed to load contacts', e)
});
}
saveContact(): void {
if (!this.formEmail.trim()) return;
const contact: Contact = {
name: this.formName.trim(),
email: this.formEmail.trim(),
phone: this.formPhone.trim() || undefined,
company: this.formCompany.trim() || undefined
};
if (this.editingContact()?.id) {
this.mailService.updateContact(this.editingContact()!.id!, contact).subscribe({
next: () => {
this.resetForm();
this.loadContacts();
},
error: (e) => console.error('Failed to update contact', e)
});
} else {
this.mailService.addContact(contact).subscribe({
next: () => {
this.resetForm();
this.loadContacts();
},
error: (e) => console.error('Failed to add contact', e)
});
}
}
editContact(contact: Contact): void {
this.editingContact.set(contact);
this.formName = contact.name || '';
this.formEmail = contact.email || '';
this.formPhone = contact.phone || '';
this.formCompany = contact.company || '';
}
cancelEdit(): void {
this.editingContact.set(null);
this.resetForm();
}
deleteContact(id: number): void {
if (confirm('Delete this contact?')) {
this.mailService.deleteContact(id).subscribe({
next: () => this.loadContacts(),
error: (e) => console.error('Failed to delete contact', e)
});
}
}
resetForm(): void {
this.formName = '';
this.formEmail = '';
this.formPhone = '';
this.formCompany = '';
this.editingContact.set(null);
}
}
@@ -89,6 +89,31 @@ import { of } from 'rxjs';
</div>
}
</div>
<!-- Пагинация -->
@if (totalPages() > 1) {
<div class="glass-panel p-4 flex items-center justify-center gap-2">
<button (click)="goToPage(page() - 1)" [disabled]="page() === 0"
class="px-3 py-1 rounded-lg text-xs font-mono text-synth-cyan border border-synth-cyan/30 hover:bg-synth-cyan/10 disabled:opacity-30 disabled:cursor-not-allowed transition-all">
← Prev
</button>
@for (p of pageNumbers; track p) {
<button (click)="goToPage(p)"
[class.bg-synth-pink/20]="p === page()" [class.text-synth-pink]="p === page()"
[class.border-synth-pink]="p === page()"
class="px-3 py-1 rounded-lg text-xs font-mono text-slate-400 border border-synth-cyan/20 hover:bg-synth-cyan/10 hover:text-synth-cyan transition-all">
{{ p + 1 }}
</button>
}
<button (click)="goToPage(page() + 1)" [disabled]="page() >= totalPages() - 1"
class="px-3 py-1 rounded-lg text-xs font-mono text-synth-cyan border border-synth-cyan/30 hover:bg-synth-cyan/10 disabled:opacity-30 disabled:cursor-not-allowed transition-all">
Next →
</button>
<span class="text-xs font-mono text-slate-500 ml-2">
Page {{ page() + 1 }} of {{ totalPages() }}
</span>
</div>
}
</div>
`,
styles: ``
@@ -100,6 +125,9 @@ export default class InboxComponent {
folderName = signal<string>('INBOX');
searchQuery = '';
folderId = signal<number>(1);
page = signal<number>(0);
totalPages = signal<number>(1);
pageSize = 20;
constructor() {
// Подписываемся на изменения folderId в queryParams
@@ -130,16 +158,39 @@ export default class InboxComponent {
loadMessages(): void {
const currentFolderId = this.folderId();
const currentPage = this.page();
if (this.searchQuery.trim()) {
this.mailService.searchMessages(currentFolderId, this.searchQuery.trim()).subscribe({
next: (msgs) => this.messages.set(msgs),
error: () => console.error('Search failed')
});
} else {
this.mailService.getMessages(currentFolderId).subscribe({
next: (msgs) => this.messages.set(msgs),
this.mailService.getMessages(currentFolderId, currentPage, this.pageSize).subscribe({
next: (result) => {
this.messages.set(result.content);
this.totalPages.set(result.totalPages);
},
error: () => console.error('Failed to load messages')
});
}
}
goToPage(page: number): void {
if (page >= 0 && page < this.totalPages()) {
this.page.set(page);
this.loadMessages();
}
}
get pageNumbers(): number[] {
const pages: number[] = [];
const total = this.totalPages();
const current = this.page();
const start = Math.max(0, current - 2);
const end = Math.min(total - 1, current + 2);
for (let i = start; i <= end; i++) {
pages.push(i);
}
return pages;
}
}
@@ -74,6 +74,15 @@ import { Folder } from '../../models/mail.models';
</nav>
</div>
<!-- Контакты -->
<div class="glass-panel p-4 mt-4">
<a routerLink="/contacts"
routerLinkActive="bg-synth-cyan/20 text-synth-cyan border-synth-cyan"
class="block px-3 py-2 rounded-lg text-sm font-rajdhani text-slate-300 hover:bg-synth-cyan/10 hover:text-synth-cyan border border-transparent transition-all">
👥 Contacts
</a>
</div>
<!-- Кнопка написать -->
<div class="glass-panel p-4 mt-4">
<a routerLink="/compose" class="cyber-btn w-full py-3 rounded-xl text-xs font-bold block text-center">
@@ -60,6 +60,14 @@ import { Message } from '../../models/mail.models';
<a routerLink="/inbox" class="cyber-btn px-6 py-2 rounded-xl text-xs font-bold">
← BACK TO INBOX
</a>
<a [routerLink]="['/compose']" [queryParams]="{replyTo: message.sender, subject: 'Re: ' + (message.subject || '')}"
class="px-6 py-2 rounded-xl text-xs font-mono text-synth-cyan border border-synth-cyan/40 hover:bg-synth-cyan/20 transition-all">
↩️ REPLY
</a>
<a [routerLink]="['/compose']" [queryParams]="{forwardTo: '', forwardSubject: 'Fwd: ' + (message.subject || ''), forwardBody: message.body}"
class="px-6 py-2 rounded-xl text-xs font-mono text-synth-purple border border-synth-purple/40 hover:bg-synth-purple/20 transition-all">
➡️ FORWARD
</a>
<button (click)="onDelete()" class="px-6 py-2 rounded-xl text-xs font-mono text-synth-pink border border-synth-pink/40 hover:bg-synth-pink/20 transition-all">
🗑️ DELETE
</button>
+9
View File
@@ -48,6 +48,7 @@ export interface SendRequest {
to: string;
subject: string;
body: string;
html?: boolean;
}
export interface BlacklistEntry {
@@ -55,6 +56,14 @@ export interface BlacklistEntry {
reason?: string;
}
export interface Contact {
id?: number;
name: string;
email: string;
phone?: string;
company?: string;
}
export interface PageResponse<T> {
content: T[];
number: number;
+20 -3
View File
@@ -1,7 +1,7 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, map } from 'rxjs';
import { Message, Folder, Attachment, SendRequest, BlacklistEntry, PageResponse } from '../models/mail.models';
import { Message, Folder, Attachment, SendRequest, BlacklistEntry, PageResponse, Contact } from '../models/mail.models';
@Injectable({
providedIn: 'root'
@@ -16,10 +16,10 @@ export class MailService {
}
// Письма
getMessages(folderId: number, page: number = 0, size: number = 20): Observable<Message[]> {
getMessages(folderId: number, page: number = 0, size: number = 20): Observable<PageResponse<Message>> {
return this.http.get<PageResponse<Message>>(`${this.apiUrl}/messages`, {
params: { folderId, page, size }
}).pipe(map(response => response.content));
});
}
getMessage(id: number): Observable<Message> {
@@ -93,4 +93,21 @@ export class MailService {
params: { senderEmail }
});
}
// Контакты
getContacts(): Observable<Contact[]> {
return this.http.get<Contact[]>(`${this.apiUrl}/contacts`);
}
addContact(contact: Contact): Observable<Contact> {
return this.http.post<Contact>(`${this.apiUrl}/contacts`, contact);
}
updateContact(id: number, contact: Contact): Observable<Contact> {
return this.http.put<Contact>(`${this.apiUrl}/contacts/${id}`, contact);
}
deleteContact(id: number): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/contacts/${id}`);
}
}