Add contacts, reply/forward, pagination, HTML toggle, Docker Compose for VPS
Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
-3
@@ -1,5 +1,16 @@
|
|||||||
FROM eclipse-temurin:21-jdk-alpine
|
# Stage 1: Build
|
||||||
|
FROM eclipse-temurin:21-jdk-alpine AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY build/libs/*.jar app.jar
|
COPY build.gradle settings.gradle gradlew ./
|
||||||
EXPOSE 8080
|
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"]
|
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||||
|
|||||||
@@ -276,4 +276,72 @@ public class MailController {
|
|||||||
Mono.just(org.springframework.http.ResponseEntity.badRequest().body(Map.of("error", e.getMessage()))))
|
Mono.just(org.springframework.http.ResponseEntity.badRequest().body(Map.of("error", e.getMessage()))))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Contacts ---
|
||||||
|
|
||||||
|
@GetMapping("/contacts")
|
||||||
|
public Flux<Object> getContacts(Mono<Authentication> authenticationMono) {
|
||||||
|
return authenticationMono.flatMapMany(auth ->
|
||||||
|
userService.findByEmail(auth.getName())
|
||||||
|
.flatMapMany(user -> mailService.getContacts(user.getId()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/contacts")
|
||||||
|
public Mono<Object> addContact(
|
||||||
|
Mono<Authentication> authenticationMono,
|
||||||
|
@RequestBody Mono<Map<String, String>> bodyMono) {
|
||||||
|
return authenticationMono.zipWith(bodyMono)
|
||||||
|
.flatMap(pair -> {
|
||||||
|
String email = pair.getT1().getName();
|
||||||
|
Map<String, String> 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<Object> updateContact(
|
||||||
|
@PathVariable Long id,
|
||||||
|
Mono<Authentication> authenticationMono,
|
||||||
|
@RequestBody Mono<Map<String, String>> bodyMono) {
|
||||||
|
return authenticationMono.zipWith(bodyMono)
|
||||||
|
.flatMap(pair -> {
|
||||||
|
String email = pair.getT1().getName();
|
||||||
|
Map<String, String> 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<Authentication> 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()))))
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.mailserver.service;
|
package com.mailserver.service;
|
||||||
|
|
||||||
import com.mailserver.domain.Attachment;
|
import com.mailserver.domain.Attachment;
|
||||||
|
import com.mailserver.domain.Contact;
|
||||||
import com.mailserver.domain.Folder;
|
import com.mailserver.domain.Folder;
|
||||||
import com.mailserver.domain.Message;
|
import com.mailserver.domain.Message;
|
||||||
import com.mailserver.domain.User;
|
import com.mailserver.domain.User;
|
||||||
@@ -8,6 +9,7 @@ import com.mailserver.dto.AttachmentResponse;
|
|||||||
import com.mailserver.dto.MessageResponse;
|
import com.mailserver.dto.MessageResponse;
|
||||||
import com.mailserver.dto.SendRequest;
|
import com.mailserver.dto.SendRequest;
|
||||||
import com.mailserver.repository.AttachmentRepository;
|
import com.mailserver.repository.AttachmentRepository;
|
||||||
|
import com.mailserver.repository.ContactRepository;
|
||||||
import com.mailserver.repository.FolderRepository;
|
import com.mailserver.repository.FolderRepository;
|
||||||
import com.mailserver.repository.MessageRepository;
|
import com.mailserver.repository.MessageRepository;
|
||||||
import com.mailserver.repository.UserRepository;
|
import com.mailserver.repository.UserRepository;
|
||||||
@@ -46,6 +48,7 @@ public class MailService {
|
|||||||
private final FolderRepository folderRepository;
|
private final FolderRepository folderRepository;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final AttachmentRepository attachmentRepository;
|
private final AttachmentRepository attachmentRepository;
|
||||||
|
private final ContactRepository contactRepository;
|
||||||
private final SpamFilterService spamFilterService;
|
private final SpamFilterService spamFilterService;
|
||||||
private final DkimService dkimService;
|
private final DkimService dkimService;
|
||||||
|
|
||||||
@@ -60,12 +63,14 @@ public class MailService {
|
|||||||
|
|
||||||
public MailService(MessageRepository messageRepository, FolderRepository folderRepository,
|
public MailService(MessageRepository messageRepository, FolderRepository folderRepository,
|
||||||
UserRepository userRepository, AttachmentRepository attachmentRepository,
|
UserRepository userRepository, AttachmentRepository attachmentRepository,
|
||||||
|
ContactRepository contactRepository,
|
||||||
SpamFilterService spamFilterService,
|
SpamFilterService spamFilterService,
|
||||||
DkimService dkimService) {
|
DkimService dkimService) {
|
||||||
this.messageRepository = messageRepository;
|
this.messageRepository = messageRepository;
|
||||||
this.folderRepository = folderRepository;
|
this.folderRepository = folderRepository;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.attachmentRepository = attachmentRepository;
|
this.attachmentRepository = attachmentRepository;
|
||||||
|
this.contactRepository = contactRepository;
|
||||||
this.spamFilterService = spamFilterService;
|
this.spamFilterService = spamFilterService;
|
||||||
this.dkimService = dkimService;
|
this.dkimService = dkimService;
|
||||||
}
|
}
|
||||||
@@ -381,4 +386,49 @@ public class MailService {
|
|||||||
|
|
||||||
return send.then(readResponse);
|
return send.then(readResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Contacts ---
|
||||||
|
|
||||||
|
public Flux<Contact> getContacts(Long userId) {
|
||||||
|
return contactRepository.findByUserId(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Contact> 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<Contact> 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<Void> 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -25,6 +25,10 @@ export const routes: Routes = [
|
|||||||
path: 'message/:id',
|
path: 'message/:id',
|
||||||
loadComponent: () => import('./components/message-detail/message-detail.component'),
|
loadComponent: () => import('./components/message-detail/message-detail.component'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'contacts',
|
||||||
|
loadComponent: () => import('./components/contacts/contacts.component'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
redirectTo: 'inbox',
|
redirectTo: 'inbox',
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import { Component, inject } from '@angular/core';
|
|||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { MailService } from '../../services/mail.service';
|
import { MailService } from '../../services/mail.service';
|
||||||
import { Router } from '@angular/router';
|
import { Router, ActivatedRoute } from '@angular/router';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-compose',
|
selector: 'app-compose',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [RouterLink, FormsModule],
|
imports: [RouterLink, FormsModule],
|
||||||
|
providers: [MailService],
|
||||||
template: `
|
template: `
|
||||||
<div class="glass-panel p-6">
|
<div class="glass-panel p-6">
|
||||||
<h2 class="text-lg font-orbitron font-bold text-synth-cyan mb-6 flex items-center gap-2">
|
<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>
|
||||||
|
|
||||||
<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
|
<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"
|
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>
|
||||||
|
|
||||||
<div class="flex gap-3 pt-4">
|
<div class="flex gap-3 pt-4">
|
||||||
@@ -59,22 +74,43 @@ import { Router } from '@angular/router';
|
|||||||
export default class ComposeComponent {
|
export default class ComposeComponent {
|
||||||
private mailService = inject(MailService);
|
private mailService = inject(MailService);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
|
||||||
to = '';
|
to = '';
|
||||||
subject = '';
|
subject = '';
|
||||||
body = '';
|
body = '';
|
||||||
|
isHtml = false;
|
||||||
sending = false;
|
sending = false;
|
||||||
success = 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 {
|
onSend(): void {
|
||||||
this.sending = true;
|
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: () => {
|
next: () => {
|
||||||
this.success = true;
|
this.success = true;
|
||||||
this.sending = false;
|
this.sending = false;
|
||||||
this.to = '';
|
this.to = '';
|
||||||
this.subject = '';
|
this.subject = '';
|
||||||
this.body = '';
|
this.body = '';
|
||||||
|
this.isHtml = false;
|
||||||
setTimeout(() => this.router.navigate(['/inbox']), 1500);
|
setTimeout(() => this.router.navigate(['/inbox']), 1500);
|
||||||
},
|
},
|
||||||
error: () => {
|
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>
|
||||||
}
|
}
|
||||||
</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>
|
</div>
|
||||||
`,
|
`,
|
||||||
styles: ``
|
styles: ``
|
||||||
@@ -100,6 +125,9 @@ export default class InboxComponent {
|
|||||||
folderName = signal<string>('INBOX');
|
folderName = signal<string>('INBOX');
|
||||||
searchQuery = '';
|
searchQuery = '';
|
||||||
folderId = signal<number>(1);
|
folderId = signal<number>(1);
|
||||||
|
page = signal<number>(0);
|
||||||
|
totalPages = signal<number>(1);
|
||||||
|
pageSize = 20;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// Подписываемся на изменения folderId в queryParams
|
// Подписываемся на изменения folderId в queryParams
|
||||||
@@ -130,16 +158,39 @@ export default class InboxComponent {
|
|||||||
|
|
||||||
loadMessages(): void {
|
loadMessages(): void {
|
||||||
const currentFolderId = this.folderId();
|
const currentFolderId = this.folderId();
|
||||||
|
const currentPage = this.page();
|
||||||
if (this.searchQuery.trim()) {
|
if (this.searchQuery.trim()) {
|
||||||
this.mailService.searchMessages(currentFolderId, this.searchQuery.trim()).subscribe({
|
this.mailService.searchMessages(currentFolderId, this.searchQuery.trim()).subscribe({
|
||||||
next: (msgs) => this.messages.set(msgs),
|
next: (msgs) => this.messages.set(msgs),
|
||||||
error: () => console.error('Search failed')
|
error: () => console.error('Search failed')
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this.mailService.getMessages(currentFolderId).subscribe({
|
this.mailService.getMessages(currentFolderId, currentPage, this.pageSize).subscribe({
|
||||||
next: (msgs) => this.messages.set(msgs),
|
next: (result) => {
|
||||||
|
this.messages.set(result.content);
|
||||||
|
this.totalPages.set(result.totalPages);
|
||||||
|
},
|
||||||
error: () => console.error('Failed to load messages')
|
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>
|
</nav>
|
||||||
</div>
|
</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">
|
<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">
|
<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">
|
<a routerLink="/inbox" class="cyber-btn px-6 py-2 rounded-xl text-xs font-bold">
|
||||||
← BACK TO INBOX
|
← BACK TO INBOX
|
||||||
</a>
|
</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">
|
<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
|
🗑️ DELETE
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export interface SendRequest {
|
|||||||
to: string;
|
to: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
body: string;
|
body: string;
|
||||||
|
html?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BlacklistEntry {
|
export interface BlacklistEntry {
|
||||||
@@ -55,6 +56,14 @@ export interface BlacklistEntry {
|
|||||||
reason?: string;
|
reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Contact {
|
||||||
|
id?: number;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone?: string;
|
||||||
|
company?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PageResponse<T> {
|
export interface PageResponse<T> {
|
||||||
content: T[];
|
content: T[];
|
||||||
number: number;
|
number: number;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { Observable, map } from 'rxjs';
|
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({
|
@Injectable({
|
||||||
providedIn: 'root'
|
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`, {
|
return this.http.get<PageResponse<Message>>(`${this.apiUrl}/messages`, {
|
||||||
params: { folderId, page, size }
|
params: { folderId, page, size }
|
||||||
}).pipe(map(response => response.content));
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getMessage(id: number): Observable<Message> {
|
getMessage(id: number): Observable<Message> {
|
||||||
@@ -93,4 +93,21 @@ export class MailService {
|
|||||||
params: { senderEmail }
|
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}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user