Initial commit: mail-server with WebFlux backend and Angular v22 Synthwave frontend

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
2026-11-05 01:07:29 +01:00
co-authored by Junie
commit 0339413d6e
78 changed files with 6808 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter, withHashLocation } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes';
import { authInterceptor } from './interceptors/auth.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor]))
]
};
View File
+1
View File
@@ -0,0 +1 @@
<router-outlet />
+39
View File
@@ -0,0 +1,39 @@
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: 'login',
loadComponent: () => import('./components/login/login.component'),
},
{
path: 'register',
loadComponent: () => import('./components/register/register.component'),
},
{
path: '',
loadComponent: () => import('./components/layout/layout.component'),
children: [
{
path: 'inbox',
loadComponent: () => import('./components/inbox/inbox.component'),
},
{
path: 'compose',
loadComponent: () => import('./components/compose/compose.component'),
},
{
path: 'message/:id',
loadComponent: () => import('./components/message-detail/message-detail.component'),
},
{
path: '',
redirectTo: 'inbox',
pathMatch: 'full',
},
],
},
{
path: '**',
redirectTo: 'login',
},
];
+10
View File
@@ -0,0 +1,10 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
imports: [RouterOutlet],
selector: 'app-root',
template: `<router-outlet />`,
styles: ``
})
export class App {}
@@ -0,0 +1,85 @@
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';
@Component({
selector: 'app-compose',
standalone: true,
imports: [RouterLink, FormsModule],
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">
<span class="text-synth-pink">✉️</span> COMPOSE MESSAGE
</h2>
@if (success) {
<div class="mb-4 p-3 rounded-lg bg-synth-cyan/20 border border-synth-cyan/40 text-synth-cyan text-sm font-mono">
Message sent successfully!
</div>
}
<form (ngSubmit)="onSend()" class="space-y-4">
<div>
<label for="to" class="block text-xs font-mono text-synth-cyan mb-1">TO</label>
<input id="to" type="email" [(ngModel)]="to" name="to" 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"
placeholder="recipient@example.com">
</div>
<div>
<label for="subject" class="block text-xs font-mono text-synth-cyan mb-1">SUBJECT</label>
<input id="subject" type="text" [(ngModel)]="subject" name="subject" 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"
placeholder="Enter subject...">
</div>
<div>
<label for="body" class="block text-xs font-mono text-synth-cyan mb-1">BODY</label>
<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>
</div>
<div class="flex gap-3 pt-4">
<button type="submit" [disabled]="sending"
class="cyber-btn px-8 py-3 rounded-xl text-sm font-bold">
{{ sending ? 'SENDING...' : 'SEND' }}
</button>
<a routerLink="/inbox" class="px-6 py-3 rounded-xl text-xs font-mono text-slate-400 border border-slate-600 hover:text-slate-200 hover:border-slate-400 transition-all text-center">
CANCEL
</a>
</div>
</form>
</div>
`,
styles: ``
})
export default class ComposeComponent {
private mailService = inject(MailService);
private router = inject(Router);
to = '';
subject = '';
body = '';
sending = false;
success = false;
onSend(): void {
this.sending = true;
this.mailService.send({ to: this.to, subject: this.subject, body: this.body }).subscribe({
next: () => {
this.success = true;
this.sending = false;
this.to = '';
this.subject = '';
this.body = '';
setTimeout(() => this.router.navigate(['/inbox']), 1500);
},
error: () => {
this.sending = false;
}
});
}
}
@@ -0,0 +1,145 @@
import { Component, inject, signal } from '@angular/core';
import { RouterLink, ActivatedRoute } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { MailService } from '../../services/mail.service';
import { Message, Folder } from '../../models/mail.models';
import { DatePipe, SlicePipe } from '@angular/common';
import { map, switchMap } from 'rxjs/operators';
import { of } from 'rxjs';
@Component({
selector: 'app-inbox',
standalone: true,
imports: [RouterLink, FormsModule, DatePipe, SlicePipe],
providers: [MailService],
template: `
<div class="space-y-4">
<!-- Поиск -->
<div class="glass-panel p-4">
<div class="flex gap-3">
<input [(ngModel)]="searchQuery" (keyup.enter)="loadMessages()"
class="flex-1 bg-synth-darker/80 border border-synth-cyan/40 rounded-xl px-4 py-2 text-sm text-slate-200 font-mono focus:outline-none focus:ring-2 focus:ring-synth-pink focus:border-synth-cyan transition-all"
placeholder="🔍 Search messages...">
<button (click)="loadMessages()" class="cyber-btn px-6 py-2 rounded-xl text-xs font-bold">
SEARCH
</button>
</div>
</div>
<!-- Список писем -->
<div class="glass-panel p-4">
<h2 class="text-lg font-orbitron font-bold text-synth-cyan mb-4 flex items-center gap-2">
<span class="text-synth-pink">📬</span> {{ folderName() }}
<span class="text-sm font-mono text-slate-400 ml-auto">{{ messages()?.length || 0 }} messages</span>
</h2>
@if (!messages() || messages().length === 0) {
<div class="text-center py-12 text-slate-400 font-mono">
<p class="text-4xl mb-4">📭</p>
<p>No messages yet</p>
</div>
} @else {
<div class="space-y-2">
@for (msg of messages() || []; track msg.id) {
<a routerLink="/message/{{ msg.id }}"
class="block p-4 rounded-xl border border-synth-cyan/10 hover:border-synth-cyan/40 hover:bg-synth-cyan/5 transition-all cursor-pointer group">
<div class="flex items-start gap-3">
<!-- Индикатор прочитанности -->
<div class="mt-1">
@if (!msg.read) {
<span class="w-3 h-3 rounded-full bg-synth-pink inline-block shadow-[0_0_8px_rgba(255,0,127,0.8)]"></span>
} @else {
<span class="w-3 h-3 rounded-full border border-slate-500 inline-block"></span>
}
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="font-bold text-sm text-slate-200 font-rajdhani truncate">
{{ msg.senderName || msg.sender }}
</span>
@if (msg.spam) {
<span class="px-2 py-0.5 text-xs rounded-full bg-synth-yellow/20 text-synth-yellow font-mono">
SPAM
</span>
}
@if (msg.flagged) {
<span class="text-synth-yellow text-xs">★</span>
}
</div>
<div class="text-sm text-synth-cyan font-rajdhani truncate group-hover:text-synth-pink transition-colors">
{{ msg.subject || '(No subject)' }}
</div>
<div class="text-xs text-slate-400 font-mono truncate">
{{ msg.body || '' | slice:0:100 }}{{ (msg.body || '').length > 100 ? '...' : '' }}
</div>
</div>
<div class="text-right flex-shrink-0">
<div class="text-xs text-slate-400 font-mono">
{{ msg.sentAt | date:'MMM d, HH:mm' }}
</div>
@if (msg.hasAttachments) {
<span class="text-xs text-synth-purple font-mono">📎 {{ msg.attachmentCount }}</span>
}
</div>
</div>
</a>
}
</div>
}
</div>
</div>
`,
styles: ``
})
export default class InboxComponent {
private route = inject(ActivatedRoute);
private mailService = inject(MailService);
messages = signal<Message[]>([]);
folderName = signal<string>('INBOX');
searchQuery = '';
folderId = signal<number>(1);
constructor() {
// Подписываемся на изменения folderId в queryParams
this.route.queryParams.subscribe(params => {
const newFolderId = params['folderId'] ? Number(params['folderId']) : 1;
if (newFolderId !== this.folderId()) {
this.folderId.set(newFolderId);
this.loadFolderName(newFolderId);
this.loadMessages();
}
});
// Начальная загрузка
const initialFolderId = this.route.snapshot.queryParams['folderId'] ? Number(this.route.snapshot.queryParams['folderId']) : 1;
this.folderId.set(initialFolderId);
this.loadFolderName(initialFolderId);
this.loadMessages();
}
loadFolderName(folderId: number): void {
this.mailService.getFolders().subscribe({
next: (folders) => {
const folder = folders.find(f => f.id === folderId);
this.folderName.set(folder ? folder.name : 'INBOX');
},
error: () => this.folderName.set('INBOX')
});
}
loadMessages(): void {
const currentFolderId = this.folderId();
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),
error: () => console.error('Failed to load messages')
});
}
}
}
@@ -0,0 +1,136 @@
import { Component, inject, signal } from '@angular/core';
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
import { AuthService } from '../../services/auth.service';
import { MailService } from '../../services/mail.service';
import { Folder } from '../../models/mail.models';
@Component({
selector: 'app-layout',
standalone: true,
imports: [RouterOutlet, RouterLink, RouterLinkActive],
template: `
<div class="min-h-screen relative" [class.light-theme]="isLightTheme()">
<!-- Synthwave фон -->
<div class="synth-bg" aria-hidden="true">
<div class="synth-sun"></div>
<div class="synth-grid">
<div class="synth-grid-plane"></div>
</div>
</div>
<!-- Контент -->
<div class="relative z-10 flex flex-col min-h-screen">
<!-- Шапка -->
<header class="glass-panel rounded-none border-t-0 border-x-0 p-4 sticky top-0 z-40 backdrop-blur-xl">
<div class="max-w-7xl mx-auto flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-gradient-to-br from-synth-pink to-synth-cyan flex items-center justify-center text-synth-dark font-black text-xl font-orbitron shadow-[0_0_15px_rgba(255,0,127,0.8)]">
M
</div>
<div>
<h1 class="text-2xl font-orbitron font-extrabold tracking-wider neon-text-primary">
MAIL<span class="text-synth-cyan font-normal">SERVER</span>
</h1>
<p class="text-xs text-synth-cyan/70 font-mono tracking-widest uppercase">Synthwave Edition</p>
</div>
</div>
<div class="flex items-center gap-4">
<!-- Переключатель темы -->
<button (click)="toggleTheme()" class="cyber-btn px-3 py-2 rounded-lg text-xs font-bold" [title]="isLightTheme() ? 'Переключить на тёмную тему' : 'Переключить на светлую тему'">
{{ isLightTheme() ? '🌙 Dark' : '☀️ Light' }}
</button>
<span class="text-sm font-mono text-slate-400">{{ email }}</span>
<button (click)="onLogout()" class="cyber-btn px-4 py-2 rounded-lg text-xs font-bold">
<i class="mr-1">⏻</i> Logout
</button>
</div>
</div>
</header>
<!-- Основная область -->
<div class="flex flex-1 max-w-7xl mx-auto w-full p-4 gap-4">
<!-- Боковая панель с папками -->
<aside class="w-64 flex-shrink-0">
<div class="glass-panel p-4">
<h2 class="text-sm font-orbitron font-bold text-synth-cyan mb-3 flex items-center gap-2">
<span class="text-synth-pink">📁</span> FOLDERS
</h2>
<nav class="space-y-1">
@for (folder of folders(); track folder.id) {
<a routerLink="/inbox" [queryParams]="{folderId: folder.id}"
routerLinkActive="bg-synth-pink/20 text-synth-pink border-synth-pink"
[queryParamsHandling]=" 'merge'"
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">
{{ folder.name }}
@if (folder.unreadCount > 0) {
<span class="ml-2 px-2 py-0.5 text-xs rounded-full bg-synth-pink/30 text-synth-pink font-mono">
{{ folder.unreadCount }}
</span>
}
</a>
}
</nav>
</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">
✉️ NEW MESSAGE
</a>
</div>
</aside>
<!-- Контент маршрутов -->
<main class="flex-1 min-w-0">
<router-outlet />
</main>
</div>
</div>
</div>
`,
styles: ``
})
export default class LayoutComponent {
authService = inject(AuthService);
mailService = inject(MailService);
email = this.authService.getEmail() || '';
folders = signal<Folder[]>([]);
isLightTheme = signal(false);
constructor() {
this.loadFolders();
// Загружаем сохранённую тему
const saved = localStorage.getItem('theme');
if (saved === 'light') {
this.isLightTheme.set(true);
document.documentElement.classList.add('light-theme');
}
}
toggleTheme(): void {
const isLight = !this.isLightTheme();
this.isLightTheme.set(isLight);
if (isLight) {
document.documentElement.classList.add('light-theme');
localStorage.setItem('theme', 'light');
} else {
document.documentElement.classList.remove('light-theme');
localStorage.setItem('theme', 'dark');
}
}
loadFolders(): void {
this.mailService.getFolders().subscribe({
next: (folders) => this.folders.set(folders),
error: () => console.error('Failed to load folders')
});
}
onLogout(): void {
this.authService.logout();
window.location.href = '/login';
}
}
@@ -0,0 +1,93 @@
import { Component, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { AuthService } from '../../services/auth.service';
import { Router } from '@angular/router';
@Component({
selector: 'app-login',
standalone: true,
imports: [RouterLink, FormsModule],
template: `
<div class="min-h-screen relative flex items-center justify-center">
<!-- Synthwave фон -->
<div class="synth-bg" aria-hidden="true">
<div class="synth-sun"></div>
<div class="synth-grid">
<div class="synth-grid-plane"></div>
</div>
</div>
<!-- Форма логина -->
<div class="relative z-10 w-full max-w-md px-4">
<div class="glass-panel p-8">
<div class="text-center mb-8">
<div class="w-16 h-16 mx-auto rounded-xl bg-gradient-to-br from-synth-pink to-synth-cyan flex items-center justify-center text-synth-dark font-black text-2xl font-orbitron shadow-[0_0_20px_rgba(255,0,127,0.8)] mb-4">
M
</div>
<h1 class="text-3xl font-orbitron font-extrabold tracking-wider neon-text-primary mb-2">
MAIL<span class="text-synth-cyan font-normal">SERVER</span>
</h1>
<p class="text-sm text-slate-400 font-mono">Sign in to your mailbox</p>
</div>
@if (error) {
<div class="mb-4 p-3 rounded-lg bg-synth-pink/20 border border-synth-pink/40 text-synth-pink text-sm font-mono">
{{ error }}
</div>
}
<form (ngSubmit)="onLogin()" class="space-y-4">
<div>
<label for="email" class="block text-xs font-mono text-synth-cyan mb-1">EMAIL</label>
<input id="email" type="email" [(ngModel)]="email" name="email" 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"
placeholder="user@example.com">
</div>
<div>
<label for="password" class="block text-xs font-mono text-synth-cyan mb-1">PASSWORD</label>
<input id="password" type="password" [(ngModel)]="password" name="password" 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"
placeholder="••••••••">
</div>
<button type="submit" [disabled]="loading"
class="cyber-btn w-full py-3 rounded-xl text-sm font-bold mt-6">
{{ loading ? 'CONNECTING...' : 'LOGIN' }}
</button>
</form>
<p class="text-center text-sm text-slate-400 mt-6">
No account?
<a routerLink="/register" class="text-synth-cyan hover:text-synth-pink font-mono transition-colors">
Register here
</a>
</p>
</div>
</div>
</div>
`,
styles: ``
})
export default class LoginComponent {
private authService = inject(AuthService);
private router = inject(Router);
email = '';
password = '';
error = '' as string | null;
loading = false as boolean;
onLogin(): void {
this.loading = true;
this.error = null;
this.authService.login({ email: this.email, password: this.password }).subscribe({
next: () => this.router.navigate(['/inbox']),
error: (err) => {
this.error = 'Invalid email or password';
this.loading = false;
}
});
}
}
@@ -0,0 +1,112 @@
import { Component, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { MailService } from '../../services/mail.service';
import { ActivatedRoute } from '@angular/router';
import { DatePipe } from '@angular/common';
import { Message } from '../../models/mail.models';
@Component({
selector: 'app-message-detail',
standalone: true,
imports: [RouterLink, DatePipe],
template: `
@if (message) {
<div class="glass-panel p-6">
<!-- Заголовок письма -->
<div class="mb-6 pb-4 border-b border-synth-cyan/20">
<h1 class="text-xl font-orbitron font-bold neon-text-secondary mb-3">
{{ message.subject || '(No subject)' }}
</h1>
<div class="flex items-center gap-4 text-sm font-mono">
<div class="flex items-center gap-2">
<span class="text-synth-cyan">FROM:</span>
<span class="text-slate-200">{{ message.senderName || message.sender }}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-synth-cyan">DATE:</span>
<span class="text-slate-200">{{ message.sentAt | date:'MMM d, y, HH:mm' }}</span>
</div>
</div>
@if (message.spam) {
<div class="mt-3 px-3 py-1.5 inline-block rounded-full bg-synth-yellow/20 border border-synth-yellow/40 text-synth-yellow text-xs font-mono">
⚠️ MARKED AS SPAM
</div>
}
</div>
<!-- Тело письма -->
<div class="prose prose-invert max-w-none mb-6">
<p class="text-slate-300 font-rajdhani text-lg whitespace-pre-wrap leading-relaxed">
{{ message.body }}
</p>
</div>
<!-- Вложения -->
@if (message.hasAttachments) {
<div class="mb-6 p-4 rounded-xl bg-synth-darker/60 border border-synth-purple/30">
<h3 class="text-sm font-orbitron font-bold text-synth-purple mb-3">
📎 ATTACHMENTS ({{ message.attachmentCount }})
</h3>
<p class="text-xs text-slate-400 font-mono">
Attachments are available via the API
</p>
</div>
}
<!-- Действия -->
<div class="flex gap-3 pt-4 border-t border-synth-cyan/20">
<a routerLink="/inbox" class="cyber-btn px-6 py-2 rounded-xl text-xs font-bold">
← BACK TO INBOX
</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>
</div>
</div>
} @else {
<div class="glass-panel p-6 text-center py-12">
<p class="text-4xl mb-4">📭</p>
<p class="text-slate-400 font-mono">Message not found</p>
<a routerLink="/inbox" class="cyber-btn px-6 py-2 rounded-xl text-xs font-bold mt-4 inline-block">
← BACK TO INBOX
</a>
</div>
}
`,
styles: ``
})
export default class MessageDetailComponent {
private mailService = inject(MailService);
private route = inject(ActivatedRoute);
message: Message | null = null;
messageId = 0;
constructor() {
this.messageId = Number(this.route.snapshot.paramMap.get('id'));
this.loadMessage();
}
loadMessage(): void {
this.mailService.getMessage(this.messageId).subscribe({
next: (msg) => {
this.message = msg;
if (!msg.read) {
this.mailService.markAsRead(this.messageId).subscribe();
}
},
error: () => console.error('Failed to load message')
});
}
onDelete(): void {
if (confirm('Delete this message?')) {
this.mailService.deleteMessage(this.messageId).subscribe({
next: () => (window.location.href = '/inbox'),
error: () => console.error('Failed to delete message')
});
}
}
}
@@ -0,0 +1,116 @@
import { Component, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { AuthService } from '../../services/auth.service';
import { Router } from '@angular/router';
@Component({
selector: 'app-register',
standalone: true,
imports: [RouterLink, FormsModule],
template: `
<div class="min-h-screen relative flex items-center justify-center">
<!-- Synthwave фон -->
<div class="synth-bg" aria-hidden="true">
<div class="synth-sun"></div>
<div class="synth-grid">
<div class="synth-grid-plane"></div>
</div>
</div>
<!-- Форма регистрации -->
<div class="relative z-10 w-full max-w-md px-4">
<div class="glass-panel p-8">
<div class="text-center mb-8">
<div class="w-16 h-16 mx-auto rounded-xl bg-gradient-to-br from-synth-pink to-synth-cyan flex items-center justify-center text-synth-dark font-black text-2xl font-orbitron shadow-[0_0_20px_rgba(255,0,127,0.8)] mb-4">
M
</div>
<h1 class="text-3xl font-orbitron font-extrabold tracking-wider neon-text-primary mb-2">
MAIL<span class="text-synth-cyan font-normal">SERVER</span>
</h1>
<p class="text-sm text-slate-400 font-mono">Create your mailbox</p>
</div>
@if (error) {
<div class="mb-4 p-3 rounded-lg bg-synth-pink/20 border border-synth-pink/40 text-synth-pink text-sm font-mono">
{{ error }}
</div>
}
@if (success) {
<div class="mb-4 p-3 rounded-lg bg-synth-cyan/20 border border-synth-cyan/40 text-synth-cyan text-sm font-mono">
Account created! You can now login.
</div>
}
<form (ngSubmit)="onRegister()" class="space-y-4">
<div>
<label for="displayName" class="block text-xs font-mono text-synth-cyan mb-1">DISPLAY NAME</label>
<input id="displayName" type="text" [(ngModel)]="displayName" name="displayName" 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"
placeholder="John Doe">
</div>
<div>
<label for="email" class="block text-xs font-mono text-synth-cyan mb-1">EMAIL</label>
<input id="email" type="email" [(ngModel)]="email" name="email" 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"
placeholder="user@example.com">
</div>
<div>
<label for="password" class="block text-xs font-mono text-synth-cyan mb-1">PASSWORD</label>
<input id="password" type="password" [(ngModel)]="password" name="password" 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"
placeholder="••••••••">
</div>
<button type="submit" [disabled]="loading"
class="cyber-btn w-full py-3 rounded-xl text-sm font-bold mt-6">
{{ loading ? 'CREATING...' : 'REGISTER' }}
</button>
</form>
<p class="text-center text-sm text-slate-400 mt-6">
Already have an account?
<a routerLink="/login" class="text-synth-cyan hover:text-synth-pink font-mono transition-colors">
Login here
</a>
</p>
</div>
</div>
</div>
`,
styles: ``
})
export default class RegisterComponent {
private authService = inject(AuthService);
private router = inject(Router);
displayName = '';
email = '';
password = '';
error = '' as string | null;
success = false as boolean;
loading = false as boolean;
onRegister(): void {
this.loading = true;
this.error = null;
this.success = false;
this.authService.register({
email: this.email,
password: this.password,
displayName: this.displayName
}).subscribe({
next: () => {
this.success = true;
this.loading = false;
setTimeout(() => this.router.navigate(['/login']), 1500);
},
error: (err) => {
this.error = 'Registration failed. Email may already be taken.';
this.loading = false;
}
});
}
}
@@ -0,0 +1,16 @@
import { HttpInterceptorFn, HttpRequest, HttpHandlerFn } from '@angular/common/http';
export const authInterceptor: HttpInterceptorFn = (req: HttpRequest<unknown>, next: HttpHandlerFn) => {
const token = localStorage.getItem('token');
if (token) {
const cloned = req.clone({
setHeaders: {
Authorization: `Bearer ${token}`
}
});
return next(cloned);
}
return next(req);
};
+64
View File
@@ -0,0 +1,64 @@
export interface Message {
id: number;
subject: string;
body: string;
sender: string;
senderName?: string;
recipient: string;
sentAt: string;
read: boolean;
flagged: boolean;
spam: boolean;
folderId: number;
folderName: string;
hasAttachments: boolean;
attachmentCount: number;
}
export interface Folder {
id: number;
name: string;
unreadCount: number;
}
export interface Attachment {
id: number;
filename: string;
contentType: string;
sizeBytes: number;
}
export interface LoginRequest {
email: string;
password: string;
}
export interface LoginResponse {
token: string;
email: string;
}
export interface RegisterRequest {
email: string;
password: string;
displayName: string;
}
export interface SendRequest {
to: string;
subject: string;
body: string;
}
export interface BlacklistEntry {
senderEmail: string;
reason?: string;
}
export interface PageResponse<T> {
content: T[];
number: number;
size: number;
totalElements: number;
totalPages: number;
}
+57
View File
@@ -0,0 +1,57 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, BehaviorSubject } from 'rxjs';
import { tap, shareReplay } from 'rxjs/operators';
import { LoginRequest, LoginResponse, RegisterRequest } from '../models/mail.models';
@Injectable({
providedIn: 'root'
})
export class AuthService {
private http = inject(HttpClient);
private apiUrl = 'http://localhost:8080/api';
private currentUserSubject = new BehaviorSubject<string | null>(
localStorage.getItem('email')
);
currentUser$ = this.currentUserSubject.asObservable();
private tokenSubject = new BehaviorSubject<string | null>(
localStorage.getItem('token')
);
token$ = this.tokenSubject.asObservable();
login({ email, password }: LoginRequest): Observable<LoginResponse> {
return this.http.post<LoginResponse>(`${this.apiUrl}/login`, { email, password }).pipe(
tap(({ token, email }) => {
localStorage.setItem('token', token);
localStorage.setItem('email', email);
this.tokenSubject.next(token);
this.currentUserSubject.next(email);
})
);
}
register({ email, password, displayName }: RegisterRequest): Observable<void> {
return this.http.post<void>(`${this.apiUrl}/register`, { email, password, displayName });
}
logout(): void {
localStorage.removeItem('token');
localStorage.removeItem('email');
this.tokenSubject.next(null);
this.currentUserSubject.next(null);
}
getToken(): string | null {
return localStorage.getItem('token');
}
isLoggedIn(): boolean {
return !!localStorage.getItem('token');
}
getEmail(): string | null {
return localStorage.getItem('email');
}
}
+96
View File
@@ -0,0 +1,96 @@
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';
@Injectable({
providedIn: 'root'
})
export class MailService {
private http = inject(HttpClient);
private apiUrl = 'http://localhost:8080/api';
// Папки
getFolders(): Observable<Folder[]> {
return this.http.get<Folder[]>(`${this.apiUrl}/folders`);
}
// Письма
getMessages(folderId: number, page: number = 0, size: number = 20): Observable<Message[]> {
return this.http.get<PageResponse<Message>>(`${this.apiUrl}/messages`, {
params: { folderId, page, size }
}).pipe(map(response => response.content));
}
getMessage(id: number): Observable<Message> {
return this.http.get<Message>(`${this.apiUrl}/messages/${id}`);
}
searchMessages(
folderId: number,
keyword?: string,
subject?: string,
body?: string,
sender?: string,
recipient?: string
): Observable<Message[]> {
const params: { [key: string]: string } = { folderId: folderId.toString() };
if (keyword) params['keyword'] = keyword;
if (subject) params['subject'] = subject;
if (body) params['body'] = body;
if (sender) params['sender'] = sender;
if (recipient) params['recipient'] = recipient;
return this.http.get<PageResponse<Message>>(`${this.apiUrl}/messages/search`, { params })
.pipe(map(response => response.content));
}
// Отправка
send(sendRequest: SendRequest): Observable<void> {
return this.http.post<void>(`${this.apiUrl}/send`, sendRequest);
}
// Статус
markAsRead(id: number): Observable<void> {
return this.http.put<void>(`${this.apiUrl}/messages/${id}/read`, null);
}
deleteMessage(id: number): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/messages/${id}`);
}
// Вложения
getAttachments(messageId: number): Observable<Attachment[]> {
return this.http.get<Attachment[]>(`${this.apiUrl}/messages/${messageId}/attachments`);
}
uploadAttachment(messageId: number, file: File): Observable<void> {
const formData = new FormData();
formData.append('file', file);
return this.http.post<void>(`${this.apiUrl}/messages/${messageId}/attachments`, formData);
}
downloadAttachment(attachmentId: number): Observable<Blob> {
return this.http.get<Blob>(`${this.apiUrl}/attachments/${attachmentId}/download`, {
responseType: 'blob' as 'json'
});
}
deleteAttachment(attachmentId: number): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/attachments/${attachmentId}`);
}
// Чёрный список
getBlacklist(): Observable<string[]> {
return this.http.get<string[]>(`${this.apiUrl}/spam/blacklist`);
}
addToBlacklist(entry: BlacklistEntry): Observable<void> {
return this.http.post<void>(`${this.apiUrl}/spam/blacklist`, entry);
}
removeFromBlacklist(senderEmail: string): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/spam/blacklist`, {
params: { senderEmail }
});
}
}
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>MailServer — Synthwave Edition</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;600;800;900&family=Rajdhani:wght@400;500;600;700&family=Fira+Code:wght@400;500;600&display=swap" rel="stylesheet">
</head>
<body class="bg-synth-dark">
<app-root></app-root>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
bootstrapApplication(App, appConfig)
.catch((err) => console.error(err));
+254
View File
@@ -0,0 +1,254 @@
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;600;800;900&family=Rajdhani:wght@400;500;600;700&family=Fira+Code:wght@400;500;600&display=swap');
@import "tailwindcss";
@theme {
--color-synth-dark: #0d0221;
--color-synth-darker: #0a0118;
--color-synth-pink: #ff007f;
--color-synth-cyan: #00f0ff;
--color-synth-purple: #7928ca;
--color-synth-yellow: #ffbe0b;
--color-synth-glass: rgba(13, 2, 33, 0.6);
--font-family-orbitron: 'Orbitron', sans-serif;
--font-family-rajdhani: 'Rajdhani', sans-serif;
--font-family-fira-code: 'Fira Code', monospace;
}
:root {
--color-synth-dark: #0d0221;
--color-synth-pink: #ff007f;
--color-synth-cyan: #00f0ff;
--color-synth-purple: #7928ca;
--color-synth-yellow: #ffbe0b;
--color-synth-glass: rgba(13, 2, 33, 0.6);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background-color: var(--color-synth-dark);
color: #f1f5f9;
font-family: 'Rajdhani', sans-serif;
overflow-x: hidden;
}
/* Glassmorphism панель */
.glass-panel {
background: var(--color-synth-glass);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(0, 240, 255, 0.25);
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.5), inset 0 0 15px rgba(255, 255, 255, 0.03);
border-radius: 16px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.glass-panel:hover {
border-color: var(--color-synth-cyan);
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.6), 0 0 15px rgba(0, 240, 255, 0.6);
}
/* Неоновый текст */
.neon-text-primary {
color: #ffffff;
text-shadow: 0 0 5px var(--color-synth-pink), 0 0 15px var(--color-synth-pink);
}
.neon-text-secondary {
color: #ffffff;
text-shadow: 0 0 5px var(--color-synth-cyan), 0 0 15px var(--color-synth-cyan);
}
/* Cyber кнопка */
.cyber-btn {
position: relative;
background: linear-gradient(135deg, rgba(255, 0, 127, 0.2), rgba(0, 240, 255, 0.2));
border: 1px solid var(--color-synth-cyan);
color: #ffffff;
font-family: 'Orbitron', sans-serif;
text-transform: uppercase;
letter-spacing: 1px;
transition: all 0.2s ease;
overflow: hidden;
cursor: pointer;
}
.cyber-btn::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent);
transition: 0.5s;
}
.cyber-btn:hover::before {
left: 100%;
}
.cyber-btn:hover {
background: var(--color-synth-cyan);
color: var(--color-synth-dark);
box-shadow: 0 0 20px var(--color-synth-cyan);
transform: translateY(-2px);
}
/* Synthwave фон с сеткой */
.synth-bg {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 0;
overflow: hidden;
pointer-events: none;
}
.synth-sun {
position: absolute;
top: 10%;
left: 50%;
transform: translateX(-50%);
width: 200px;
height: 200px;
border-radius: 50%;
background: linear-gradient(to bottom, #ffbe0b, var(--color-synth-pink), var(--color-synth-purple));
box-shadow: 0 0 60px var(--color-synth-pink), 0 0 100px var(--color-synth-purple);
}
.synth-sun::after {
content: '';
position: absolute;
inset: 0;
border-radius: 50%;
background: repeating-linear-gradient(
to bottom,
transparent 0px,
transparent 4px,
var(--color-synth-dark) 4px,
var(--color-synth-dark) 8px
);
mask-image: linear-gradient(to bottom, transparent 40%, black 100%);
-webkit-mask-image: linear-gradient(to bottom, transparent 40%, black 100%);
}
.synth-grid {
position: absolute;
bottom: 0;
left: -50%;
width: 200%;
height: 40%;
perspective: 400px;
mask-image: linear-gradient(to top, rgba(0,0,0,1) 60%, rgba(0,0,0,0) 100%);
-webkit-mask-image: linear-gradient(to top, rgba(0,0,0,1) 60%, rgba(0,0,0,0) 100%);
}
.synth-grid-plane {
width: 100%;
height: 200%;
transform: rotateX(75deg);
background-size: 40px 40px;
background-image:
linear-gradient(to right, rgba(255, 0, 127, 0.3) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255, 0, 127, 0.3) 1px, transparent 1px);
animation: gridMove 2s linear infinite;
}
@keyframes gridMove {
0% { transform: rotateX(75deg) translateY(0); }
100% { transform: rotateX(75deg) translateY(40px); }
}
/* Кастомный скроллбар */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: rgba(13, 2, 33, 0.8);
}
::-webkit-scrollbar-thumb {
background: var(--color-synth-pink);
border-radius: 4px;
box-shadow: 0 0 10px var(--color-synth-pink);
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-synth-cyan);
}
/* Неоновое свечение */
.neon-glow-pink {
box-shadow: 0 0 10px var(--color-synth-pink), 0 0 20px var(--color-synth-pink);
}
.neon-glow-cyan {
box-shadow: 0 0 10px var(--color-synth-cyan), 0 0 20px var(--color-synth-cyan);
}
/* Светлая тема */
.light-theme {
--color-synth-dark: #f0f0f5;
--color-synth-glass: rgba(255, 255, 255, 0.7);
}
.light-theme body,
.light-theme {
background-color: #f0f0f5 !important;
color: #1a1a2e !important;
}
.light-theme .glass-panel {
background: rgba(255, 255, 255, 0.8) !important;
border-color: rgba(121, 40, 202, 0.3) !important;
color: #1a1a2e !important;
}
.light-theme .synth-bg {
display: none !important;
}
.light-theme .neon-text-primary,
.light-theme .neon-text-secondary {
text-shadow: none !important;
color: #1a1a2e !important;
}
.light-theme input,
.light-theme .cyber-btn {
background: rgba(255, 255, 255, 0.9) !important;
color: #1a1a2e !important;
border-color: rgba(121, 40, 202, 0.4) !important;
}
.light-theme .cyber-btn:hover {
background: var(--color-synth-purple) !important;
color: #fff !important;
}
.light-theme a,
.light-theme span,
.light-theme p,
.light-theme h1,
.light-theme h2,
.light-theme label {
color: #1a1a2e !important;
}
.light-theme .text-slate-200,
.light-theme .text-slate-300,
.light-theme .text-slate-400 {
color: #333 !important;
}
.light-theme .bg-synth-darker\/80 {
background-color: rgba(255, 255, 255, 0.9) !important;
}