Почтовый кабинет: регистрация, папки, черновики, вложения, грид @jambotron/aria-grid, интерфейс по synthwave-шаблону

Бэкенд: регистрация со входом сразу и /api/config, профиль и смена пароля, свои папки, PATCH письма
(прочитано, флажок, перенос; Спам ставит отметку), корзина и очистка, черновики, несколько адресатов и копия,
вложения черновика уходят с письмом, вложения и заголовки To/Cc входящих сохраняются, письма заблокированных —
в Спам. Исправлено: загрузка файла (415), колонка content_id (500 на любых вложениях), SPA-маршрут с точкой.
Фронтенд переписан: все таблицы — грид, оформление — шаблон synthwave-design-showcase.html.

α
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 16:41:29 +01:00
parent f605fbfd85
commit 29b1beabe0
70 changed files with 3468 additions and 1737 deletions
+77
View File
@@ -0,0 +1,77 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Attachment, BlacklistEntry, Contact, Draft, Folder, Message, Profile, ServerConfig } from './models';
/** The mail server REST API (same origin, /api). */
@Injectable({ providedIn: 'root' })
export class ApiService {
private http = inject(HttpClient);
config(): Observable<ServerConfig> { return this.http.get<ServerConfig>('/api/config'); }
// profile
me(): Observable<Profile> { return this.http.get<Profile>('/api/me'); }
updateMe(displayName: string): Observable<Profile> { return this.http.put<Profile>('/api/me', { displayName }); }
changePassword(currentPassword: string, newPassword: string): Observable<unknown> {
return this.http.put('/api/me/password', { currentPassword, newPassword });
}
// folders
folders(): Observable<Folder[]> { return this.http.get<Folder[]>('/api/folders'); }
createFolder(name: string): Observable<Folder> { return this.http.post<Folder>('/api/folders', { name }); }
renameFolder(id: number, name: string): Observable<unknown> { return this.http.put(`/api/folders/${id}`, { name }); }
deleteFolder(id: number): Observable<unknown> { return this.http.delete(`/api/folders/${id}`); }
emptyFolder(id: number): Observable<{ deleted: number }> {
return this.http.delete<{ deleted: number }>(`/api/folders/${id}/messages`);
}
// messages
messages(folderId: number): Observable<Message[]> {
return this.http.get<Message[]>('/api/messages', { params: { folderId } });
}
search(folderId: number, keyword: string): Observable<Message[]> {
return this.http.get<Message[]>('/api/messages/search', { params: { folderId, keyword } });
}
message(id: number): Observable<Message> { return this.http.get<Message>(`/api/messages/${id}`); }
updateMessage(id: number, change: { read?: boolean; flagged?: boolean; folderId?: number }): Observable<Message> {
return this.http.patch<Message>(`/api/messages/${id}`, change);
}
deleteMessage(id: number): Observable<unknown> { return this.http.delete(`/api/messages/${id}`); }
// compose
createDraft(draft: Draft): Observable<Message> { return this.http.post<Message>('/api/drafts', draft); }
updateDraft(id: number, draft: Draft): Observable<Message> { return this.http.put<Message>(`/api/drafts/${id}`, draft); }
send(draft: Draft, draftId: number | null): Observable<Message> {
return this.http.post<Message>('/api/send', { ...draft, draftId });
}
// attachments
attachments(messageId: number): Observable<Attachment[]> {
return this.http.get<Attachment[]>(`/api/messages/${messageId}/attachments`);
}
upload(messageId: number, file: File): Observable<Attachment> {
const form = new FormData();
form.append('file', file, file.name);
return this.http.post<Attachment>(`/api/messages/${messageId}/attachments`, form);
}
deleteAttachment(id: number): Observable<unknown> { return this.http.delete(`/api/attachments/${id}`); }
download(id: number): Observable<Blob> {
return this.http.get(`/api/attachments/${id}/download`, { responseType: 'blob' });
}
// contacts
contacts(): Observable<Contact[]> { return this.http.get<Contact[]>('/api/contacts'); }
addContact(contact: Contact): Observable<unknown> { return this.http.post('/api/contacts', contact); }
updateContact(id: number, contact: Contact): Observable<unknown> { return this.http.put(`/api/contacts/${id}`, contact); }
deleteContact(id: number): Observable<unknown> { return this.http.delete(`/api/contacts/${id}`); }
// blacklist
blacklist(): Observable<BlacklistEntry[]> { return this.http.get<BlacklistEntry[]>('/api/spam/blacklist'); }
block(senderEmail: string, reason: string): Observable<unknown> {
return this.http.post('/api/spam/blacklist', { senderEmail, reason });
}
unblock(senderEmail: string): Observable<unknown> {
return this.http.delete('/api/spam/blacklist', { params: { senderEmail } });
}
}
+51
View File
@@ -0,0 +1,51 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Router } from '@angular/router';
import { Observable, tap } from 'rxjs';
interface TokenResponse { token: string; email: string; }
/** JWT in localStorage; who is logged in. */
@Injectable({ providedIn: 'root' })
export class AuthService {
private http = inject(HttpClient);
private router = inject(Router);
readonly email = signal<string | null>(read('email'));
readonly loggedIn = computed(() => this.email() !== null && read('token') !== null);
login(email: string, password: string): Observable<TokenResponse> {
return this.http.post<TokenResponse>('/api/login', { email, password }).pipe(tap(r => this.store(r)));
}
register(email: string, password: string, displayName: string): Observable<TokenResponse> {
return this.http.post<TokenResponse>('/api/register', { email, password, displayName }).pipe(tap(r => this.store(r)));
}
token(): string | null { return read('token'); }
logout(): void {
this.clear();
this.router.navigate(['/login']);
}
clear(): void {
try {
localStorage.removeItem('token');
localStorage.removeItem('email');
} catch { /* storage blocked */ }
this.email.set(null);
}
private store(response: TokenResponse): void {
try {
localStorage.setItem('token', response.token);
localStorage.setItem('email', response.email);
} catch { /* storage blocked: logged in for this tab only */ }
this.email.set(response.email);
}
}
function read(key: string): string | null {
try { return localStorage.getItem(key); } catch { return null; }
}
+28
View File
@@ -0,0 +1,28 @@
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { catchError, throwError } from 'rxjs';
import { AuthService } from './auth.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
const router = inject(Router);
const token = auth.token();
const request = token ? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }) : req;
return next(request).pipe(
catchError((error: unknown) => {
// not logged in or the token expired → to the login form
if (error instanceof HttpErrorResponse && error.status === 401 && !req.url.endsWith('/login')) {
auth.clear();
router.navigate(['/login']);
}
return throwError(() => error);
})
);
};
export const authGuard: CanActivateFn = () =>
inject(AuthService).loggedIn() ? true : inject(Router).createUrlTree(['/login']);
export const guestGuard: CanActivateFn = () =>
inject(AuthService).loggedIn() ? inject(Router).createUrlTree(['/']) : true;
@@ -0,0 +1,35 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import { ApiService } from './api.service';
import { Folder, Profile } from './models';
/** Folders and the profile, shared by the sidebar, the lists and the header; refreshed after changes. */
@Injectable({ providedIn: 'root' })
export class MailStateService {
private api = inject(ApiService);
readonly folders = signal<Folder[]>([]);
readonly profile = signal<Profile | null>(null);
readonly inbox = computed(() => this.folders().find(f => f.system && f.name === 'INBOX') ?? null);
readonly unreadTotal = computed(() => this.inbox()?.unreadCount ?? 0);
system(name: string): Folder | null {
return this.folders().find(f => f.system && f.name === name) ?? null;
}
folder(id: number): Folder | null {
return this.folders().find(f => f.id === id) ?? null;
}
refreshFolders(): void {
this.api.folders().subscribe({ next: folders => this.folders.set(folders), error: () => {} });
}
refreshProfile(): void {
this.api.me().subscribe({ next: profile => this.profile.set(profile), error: () => {} });
}
reset(): void {
this.folders.set([]);
this.profile.set(null);
}
}
+108
View File
@@ -0,0 +1,108 @@
export interface Folder {
id: number;
name: string;
system: boolean;
messageCount: number;
unreadCount: number;
}
export interface Message {
id: number;
folderId: number;
senderEmail: string;
senderName?: string | null;
recipientEmail: string;
ccEmail?: string | null;
subject: string;
body: string;
html: boolean;
read: boolean;
flagged: boolean;
spam: boolean;
messageSize?: number | null;
receivedAt: string;
hasAttachments: boolean;
attachmentCount: number;
}
export interface Attachment {
id: number;
filename: string;
contentType: string;
sizeBytes: number;
}
export interface Contact {
id?: number;
name: string;
email: string;
phone?: string;
company?: string;
}
export interface BlacklistEntry {
id: number;
senderEmail: string;
reason?: string | null;
createdAt?: string;
}
export interface Profile {
email: string;
displayName: string;
usedBytes: number;
quotaBytes: number;
createdAt: string;
}
export interface ServerConfig {
registrationEnabled: boolean;
domains: string[];
}
export interface Draft {
to: string;
cc: string;
subject: string;
body: string;
html: boolean;
}
/** System folders: label and icon in the web client. */
export const FOLDER_VIEW: Record<string, { label: string; icon: string }> = {
INBOX: { label: 'Inbox', icon: 'inbox' },
Drafts: { label: 'Drafts', icon: 'edit_note' },
Sent: { label: 'Sent', icon: 'send' },
Spam: { label: 'Spam', icon: 'report' },
Trash: { label: 'Trash', icon: 'delete' }
};
export function folderLabel(folder: Folder): string {
return folder.system ? (FOLDER_VIEW[folder.name]?.label ?? folder.name) : folder.name;
}
export function folderIcon(folder: Folder): string {
return folder.system ? (FOLDER_VIEW[folder.name]?.icon ?? 'folder') : 'folder';
}
export function formatBytes(bytes: number | null | undefined): string {
const value = bytes ?? 0;
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
if (value < 1024 * 1024 * 1024) return `${(value / 1024 / 1024).toFixed(1)} MB`;
return `${(value / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
/** Text of an API error: our {error}, Spring's {message}, or the status. */
export function errorText(error: unknown): string {
const e = error as { error?: { error?: string; message?: string; status?: number } | string; status?: number; message?: string };
if (e?.error && typeof e.error === 'object') {
// Spring's error body: {status, error: "Bad Request", message: "No such mailbox: …"} — the reason is in message
const body = e.error;
const springBody = typeof body.status === 'number';
return (springBody ? body.message || body.error : body.error || body.message) || `Error ${e.status ?? ''}`.trim();
}
if (typeof e?.error === 'string' && e.error) return e.error;
if (e?.status === 0) return 'Server is not reachable';
return e?.message || 'Something went wrong';
}