diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..404c277 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,29 @@ +# Caddy — reverse proxy для почтового сервера +# Автоматически получает HTTPS-сертификат от Let's Encrypt + +mail.jambotron.com { + # Статический фронтенд (Angular) + root * /usr/share/caddy + file_server + encode gzip + + # REST API — проксируем на Spring Boot + reverse_proxy /api/* mail-server:8080 { + header_up Host {host} + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + } + + # Логин и регистрация + reverse_proxy /login mail-server:8080 + reverse_proxy /register mail-server:8080 + + # Логи + log { + output file /var/log/caddy/access.log { + roll_size 50mb + roll_keep 5 + } + } +} diff --git a/backend/Dockerfile b/backend/Dockerfile index 6781888..cbb426e 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,5 +1,16 @@ -FROM eclipse-temurin:21-jdk-alpine +# Stage 1: Build +FROM eclipse-temurin:21-jdk-alpine AS build WORKDIR /app -COPY build/libs/*.jar app.jar -EXPOSE 8080 +COPY build.gradle settings.gradle gradlew ./ +COPY gradle gradle +COPY src src +RUN chmod +x gradlew +RUN ./gradlew bootJar --no-daemon + +# Stage 2: Run +FROM eclipse-temurin:21-jre-alpine +WORKDIR /app +COPY --from=build /app/build/libs/*.jar app.jar +RUN mkdir -p /app/attachments /app/dkim-keys +EXPOSE 8080 3025 ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/backend/src/main/java/com/mailserver/controller/MailController.java b/backend/src/main/java/com/mailserver/controller/MailController.java index 27f9c45..c1494ab 100644 --- a/backend/src/main/java/com/mailserver/controller/MailController.java +++ b/backend/src/main/java/com/mailserver/controller/MailController.java @@ -276,4 +276,72 @@ public class MailController { Mono.just(org.springframework.http.ResponseEntity.badRequest().body(Map.of("error", e.getMessage())))) ); } + + // --- Contacts --- + + @GetMapping("/contacts") + public Flux getContacts(Mono authenticationMono) { + return authenticationMono.flatMapMany(auth -> + userService.findByEmail(auth.getName()) + .flatMapMany(user -> mailService.getContacts(user.getId())) + ); + } + + @PostMapping("/contacts") + public Mono addContact( + Mono authenticationMono, + @RequestBody Mono> bodyMono) { + return authenticationMono.zipWith(bodyMono) + .flatMap(pair -> { + String email = pair.getT1().getName(); + Map body = pair.getT2(); + return userService.findByEmail(email) + .flatMap(user -> mailService.addContact( + user.getId(), + body.get("name"), + body.get("email"), + body.get("phone"), + body.get("company") + )); + }) + .map(contact -> (Object) org.springframework.http.ResponseEntity.status(HttpStatus.CREATED).body(contact)) + .onErrorResume(IllegalArgumentException.class, e -> + Mono.just((Object) org.springframework.http.ResponseEntity.badRequest().body(Map.of("error", e.getMessage())))); + } + + @PutMapping("/contacts/{id}") + public Mono updateContact( + @PathVariable Long id, + Mono authenticationMono, + @RequestBody Mono> bodyMono) { + return authenticationMono.zipWith(bodyMono) + .flatMap(pair -> { + String email = pair.getT1().getName(); + Map body = pair.getT2(); + return userService.findByEmail(email) + .flatMap(user -> mailService.updateContact( + id, user.getId(), + body.get("name"), + body.get("email"), + body.get("phone"), + body.get("company") + )); + }) + .map(contact -> (Object) org.springframework.http.ResponseEntity.ok().body(contact)) + .onErrorResume(IllegalArgumentException.class, e -> + Mono.just((Object) org.springframework.http.ResponseEntity.badRequest().body(Map.of("error", e.getMessage())))); + } + + @DeleteMapping("/contacts/{id}") + public Mono deleteContact( + @PathVariable Long id, + Mono authenticationMono) { + return authenticationMono.flatMap(auth -> + userService.findByEmail(auth.getName()) + .flatMap(user -> mailService.deleteContact(id, user.getId()) + .then(Mono.just(org.springframework.http.ResponseEntity.ok(Map.of("message", "Contact deleted"))))) + .onErrorResume(IllegalArgumentException.class, e -> + Mono.just(org.springframework.http.ResponseEntity.badRequest().body(Map.of("error", e.getMessage())))) + ); + } } diff --git a/backend/src/main/java/com/mailserver/service/MailService.java b/backend/src/main/java/com/mailserver/service/MailService.java index 1c62eb2..166e8c8 100644 --- a/backend/src/main/java/com/mailserver/service/MailService.java +++ b/backend/src/main/java/com/mailserver/service/MailService.java @@ -1,6 +1,7 @@ package com.mailserver.service; import com.mailserver.domain.Attachment; +import com.mailserver.domain.Contact; import com.mailserver.domain.Folder; import com.mailserver.domain.Message; import com.mailserver.domain.User; @@ -8,6 +9,7 @@ import com.mailserver.dto.AttachmentResponse; import com.mailserver.dto.MessageResponse; import com.mailserver.dto.SendRequest; import com.mailserver.repository.AttachmentRepository; +import com.mailserver.repository.ContactRepository; import com.mailserver.repository.FolderRepository; import com.mailserver.repository.MessageRepository; import com.mailserver.repository.UserRepository; @@ -46,6 +48,7 @@ public class MailService { private final FolderRepository folderRepository; private final UserRepository userRepository; private final AttachmentRepository attachmentRepository; + private final ContactRepository contactRepository; private final SpamFilterService spamFilterService; private final DkimService dkimService; @@ -60,12 +63,14 @@ public class MailService { public MailService(MessageRepository messageRepository, FolderRepository folderRepository, UserRepository userRepository, AttachmentRepository attachmentRepository, + ContactRepository contactRepository, SpamFilterService spamFilterService, DkimService dkimService) { this.messageRepository = messageRepository; this.folderRepository = folderRepository; this.userRepository = userRepository; this.attachmentRepository = attachmentRepository; + this.contactRepository = contactRepository; this.spamFilterService = spamFilterService; this.dkimService = dkimService; } @@ -381,4 +386,49 @@ public class MailService { return send.then(readResponse); } + + // --- Contacts --- + + public Flux getContacts(Long userId) { + return contactRepository.findByUserId(userId); + } + + public Mono addContact(Long userId, String name, String email, String phone, String company) { + if (email == null || email.trim().isEmpty()) { + return Mono.error(new IllegalArgumentException("Email is required")); + } + Contact contact = new Contact(); + contact.setUserId(userId); + contact.setName(name != null ? name : ""); + contact.setEmail(email.trim()); + contact.setPhone(phone != null ? phone : ""); + contact.setCompany(company != null ? company : ""); + return contactRepository.save(contact); + } + + public Mono updateContact(Long id, Long userId, String name, String email, String phone, String company) { + return contactRepository.findById(id) + .switchIfEmpty(Mono.error(new IllegalArgumentException("Contact not found"))) + .flatMap(contact -> { + if (!contact.getUserId().equals(userId)) { + return Mono.error(new IllegalArgumentException("Not your contact")); + } + if (name != null) contact.setName(name); + if (email != null) contact.setEmail(email.trim()); + if (phone != null) contact.setPhone(phone); + if (company != null) contact.setCompany(company); + return contactRepository.save(contact); + }); + } + + public Mono deleteContact(Long id, Long userId) { + return contactRepository.findById(id) + .switchIfEmpty(Mono.error(new IllegalArgumentException("Contact not found"))) + .flatMap(contact -> { + if (!contact.getUserId().equals(userId)) { + return Mono.error(new IllegalArgumentException("Not your contact")); + } + return contactRepository.delete(contact); + }); + } } diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..42c4643 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,79 @@ +version: '3.8' + +services: + # PostgreSQL — база данных + postgres: + image: postgres:16-alpine + container_name: mail-postgres + restart: unless-stopped + environment: + POSTGRES_DB: mailserver + POSTGRES_USER: mail + POSTGRES_PASSWORD: secret + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U mail"] + interval: 10s + timeout: 5s + retries: 5 + + # Почтовый сервер (Spring Boot WebFlux) + mail-server: + build: + context: ./backend + dockerfile: Dockerfile + container_name: mail-server + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + environment: + SPRING_PROFILES_ACTIVE: prod + SPRING_R2DBC_URL: r2dbc:postgresql://postgres:5432/mailserver + SPRING_R2DBC_USERNAME: mail + SPRING_R2DBC_PASSWORD: secret + SPRING_FLYWAY_URL: jdbc:postgresql://postgres:5432/mailserver + SPRING_FLYWAY_USER: mail + SPRING_FLYWAY_PASSWORD: secret + MAILSERVER_SMTP_HOSTNAME: mail.jambotron.com + MAILSERVER_SMTP_PORT: 3025 + MAILSERVER_RELAY_ENABLED: "true" + MAILSERVER_RELAY_PORT: 25 + MAILSERVER_DKIM_DOMAIN: jambotron.com + ports: + - "8080:8080" # REST API (через Caddy) + - "3025:3025" # SMTP входящие + volumes: + - attachments:/app/attachments + - dkim-keys:/app/dkim-keys + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"] + interval: 30s + timeout: 5s + retries: 3 + + # Caddy — reverse proxy с HTTPS + caddy: + image: caddy:2-alpine + container_name: mail-caddy + restart: unless-stopped + depends_on: + - mail-server + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile + - caddy-data:/data + - caddy-config:/config + - ./frontend/dist/frontend/browser:/usr/share/caddy + +volumes: + pgdata: + attachments: + dkim-keys: + caddy-data: + caddy-config: diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 1157bfb..f365f09 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -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', diff --git a/frontend/src/app/components/compose/compose.component.ts b/frontend/src/app/components/compose/compose.component.ts index e6768d6..c3b5322 100644 --- a/frontend/src/app/components/compose/compose.component.ts +++ b/frontend/src/app/components/compose/compose.component.ts @@ -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: `

@@ -36,10 +37,24 @@ import { Router } from '@angular/router';

- +
+ + +
+ [placeholder]="isHtml ? 'Write HTML or Markdown...' : 'Write your message...'"> + @if (isHtml) { +

💡 Supports Markdown: **bold**, *italic*, - lists, \`\`\`code\`\`\`

+ }
@@ -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: () => { diff --git a/frontend/src/app/components/contacts/contacts.component.html b/frontend/src/app/components/contacts/contacts.component.html new file mode 100644 index 0000000..7239a31 --- /dev/null +++ b/frontend/src/app/components/contacts/contacts.component.html @@ -0,0 +1,92 @@ +
+

+ 👥 Contacts + {{ contacts().length }} contacts +

+ + +
+

+ {{ editingContact() ? 'Edit Contact' : 'Add New Contact' }} +

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + @if (editingContact()) { + + } +
+
+
+ + +
+ @if (contacts().length === 0) { +
+

📇

+

No contacts yet

+
+ } @else { +
+ @for (contact of contacts(); track contact.id) { +
+
+ {{ (contact.name || contact.email).charAt(0).toUpperCase() }} +
+
+
{{ contact.name || '(No name)' }}
+
{{ contact.email }}
+ @if (contact.phone || contact.company) { +
+ {{ contact.company }} + • + {{ contact.phone }} +
+ } +
+
+ + +
+
+ } +
+ } +
+
diff --git a/frontend/src/app/components/contacts/contacts.component.ts b/frontend/src/app/components/contacts/contacts.component.ts new file mode 100644 index 0000000..840f0b9 --- /dev/null +++ b/frontend/src/app/components/contacts/contacts.component.ts @@ -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([]); + editingContact = signal(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); + } +} diff --git a/frontend/src/app/components/inbox/inbox.component.ts b/frontend/src/app/components/inbox/inbox.component.ts index 210ce79..b4fd400 100644 --- a/frontend/src/app/components/inbox/inbox.component.ts +++ b/frontend/src/app/components/inbox/inbox.component.ts @@ -89,6 +89,31 @@ import { of } from 'rxjs';
} + + + @if (totalPages() > 1) { +
+ + @for (p of pageNumbers; track p) { + + } + + + Page {{ page() + 1 }} of {{ totalPages() }} + +
+ } `, styles: `` @@ -100,6 +125,9 @@ export default class InboxComponent { folderName = signal('INBOX'); searchQuery = ''; folderId = signal(1); + page = signal(0); + totalPages = signal(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; + } } diff --git a/frontend/src/app/components/layout/layout.component.ts b/frontend/src/app/components/layout/layout.component.ts index 709214f..d5401a9 100644 --- a/frontend/src/app/components/layout/layout.component.ts +++ b/frontend/src/app/components/layout/layout.component.ts @@ -74,6 +74,15 @@ import { Folder } from '../../models/mail.models'; + + +
diff --git a/frontend/src/app/components/message-detail/message-detail.component.ts b/frontend/src/app/components/message-detail/message-detail.component.ts index e344647..6e92d16 100644 --- a/frontend/src/app/components/message-detail/message-detail.component.ts +++ b/frontend/src/app/components/message-detail/message-detail.component.ts @@ -60,6 +60,14 @@ import { Message } from '../../models/mail.models'; ← BACK TO INBOX + + ↩️ REPLY + + + ➡️ FORWARD + diff --git a/frontend/src/app/models/mail.models.ts b/frontend/src/app/models/mail.models.ts index 4aa08e5..5026504 100644 --- a/frontend/src/app/models/mail.models.ts +++ b/frontend/src/app/models/mail.models.ts @@ -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 { content: T[]; number: number; diff --git a/frontend/src/app/services/mail.service.ts b/frontend/src/app/services/mail.service.ts index 787435d..6d99f19 100644 --- a/frontend/src/app/services/mail.service.ts +++ b/frontend/src/app/services/mail.service.ts @@ -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 { + getMessages(folderId: number, page: number = 0, size: number = 20): Observable> { return this.http.get>(`${this.apiUrl}/messages`, { params: { folderId, page, size } - }).pipe(map(response => response.content)); + }); } getMessage(id: number): Observable { @@ -93,4 +93,21 @@ export class MailService { params: { senderEmail } }); } + + // Контакты + getContacts(): Observable { + return this.http.get(`${this.apiUrl}/contacts`); + } + + addContact(contact: Contact): Observable { + return this.http.post(`${this.apiUrl}/contacts`, contact); + } + + updateContact(id: number, contact: Contact): Observable { + return this.http.put(`${this.apiUrl}/contacts/${id}`, contact); + } + + deleteContact(id: number): Observable { + return this.http.delete(`${this.apiUrl}/contacts/${id}`); + } }