From 29b1beabe01f55af7c605a48ce50f4ef2bf46a78 Mon Sep 17 00:00:00 2001 From: Liosha84 Date: Thu, 5 Nov 2026 16:41:29 +0100 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=BE=D1=87=D1=82=D0=BE=D0=B2=D1=8B?= =?UTF-8?q?=D0=B9=20=D0=BA=D0=B0=D0=B1=D0=B8=D0=BD=D0=B5=D1=82:=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B3=D0=B8=D1=81=D1=82=D1=80=D0=B0=D1=86=D0=B8=D1=8F,?= =?UTF-8?q?=20=D0=BF=D0=B0=D0=BF=D0=BA=D0=B8,=20=D1=87=D0=B5=D1=80=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=B8=D0=BA=D0=B8,=20=D0=B2=D0=BB=D0=BE=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F,=20=D0=B3=D1=80=D0=B8=D0=B4=20@jambotr?= =?UTF-8?q?on/aria-grid,=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D1=84=D0=B5?= =?UTF-8?q?=D0=B9=D1=81=20=D0=BF=D0=BE=20synthwave-=D1=88=D0=B0=D0=B1?= =?UTF-8?q?=D0=BB=D0=BE=D0=BD=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Бэкенд: регистрация со входом сразу и /api/config, профиль и смена пароля, свои папки, PATCH письма (прочитано, флажок, перенос; Спам ставит отметку), корзина и очистка, черновики, несколько адресатов и копия, вложения черновика уходят с письмом, вложения и заголовки To/Cc входящих сохраняются, письма заблокированных — в Спам. Исправлено: загрузка файла (415), колонка content_id (500 на любых вложениях), SPA-маршрут с точкой. Фронтенд переписан: все таблицы — грид, оформление — шаблон synthwave-design-showcase.html. α Co-Authored-By: Claude Opus 5.5 --- README.md | 18 +- backend/Dockerfile | 2 +- .../com/mailserver/config/SecurityConfig.java | 2 +- .../com/mailserver/config/SpaWebFilter.java | 7 +- .../controller/AuthenticationController.java | 111 +++++- .../mailserver/controller/MailController.java | 232 +++++------ .../java/com/mailserver/domain/Folder.java | 6 + .../java/com/mailserver/domain/Message.java | 6 + .../java/com/mailserver/dto/DraftRequest.java | 8 + .../com/mailserver/dto/FolderRequest.java | 7 + .../com/mailserver/dto/FolderResponse.java | 22 +- .../com/mailserver/dto/MessageResponse.java | 4 + .../mailserver/dto/MessageUpdateRequest.java | 5 + .../mailserver/dto/PasswordChangeRequest.java | 7 + .../com/mailserver/dto/ProfileResponse.java | 6 + .../mailserver/dto/ProfileUpdateRequest.java | 7 + .../com/mailserver/dto/RegisterRequest.java | 2 +- .../java/com/mailserver/dto/SendRequest.java | 27 +- .../repository/AttachmentRepository.java | 13 + .../repository/MessageRepository.java | 19 +- .../com/mailserver/service/LocalDelivery.java | 61 ++- .../com/mailserver/service/MailService.java | 358 ++++++++++++++--- .../mailserver/service/SubmissionService.java | 150 +++++-- .../com/mailserver/service/UserService.java | 31 +- .../db/migration/V3__mail_cabinet.sql | 10 + .../service/SubmissionServiceTest.java | 84 ++++ docker-compose.jambotron.yml | 1 + frontend/.npmrc | 1 + frontend/angular.json | 11 +- frontend/package.json | 4 + frontend/src/app/app.config.ts | 10 +- frontend/src/app/app.routes.ts | 50 +-- frontend/src/app/app.ts | 14 +- .../components/compose/compose.component.ts | 121 ------ .../contacts/contacts.component.html | 102 ----- .../components/contacts/contacts.component.ts | 94 ----- .../app/components/inbox/inbox.component.ts | 196 ---------- .../app/components/layout/layout.component.ts | 235 ----------- .../app/components/login/login.component.ts | 98 ----- .../message-detail.component.ts | 120 ------ .../components/register/register.component.ts | 121 ------ frontend/src/app/core/api.service.ts | 77 ++++ frontend/src/app/core/auth.service.ts | 51 +++ frontend/src/app/core/auth.ts | 28 ++ frontend/src/app/core/mail-state.service.ts | 35 ++ frontend/src/app/core/models.ts | 108 +++++ .../src/app/interceptors/auth.interceptor.ts | 22 -- frontend/src/app/models/mail.models.ts | 73 ---- .../app/pages/auth/auth-frame.component.ts | 35 ++ frontend/src/app/pages/auth/login.page.ts | 80 ++++ frontend/src/app/pages/auth/register.page.ts | 119 ++++++ .../src/app/pages/blocked/blocked.page.ts | 100 +++++ .../src/app/pages/compose/compose.page.ts | 370 ++++++++++++++++++ .../src/app/pages/contacts/contacts.page.ts | 151 +++++++ frontend/src/app/pages/folder/folder.page.ts | 292 ++++++++++++++ frontend/src/app/pages/inbox-redirect.ts | 25 ++ .../src/app/pages/message/message.page.ts | 274 +++++++++++++ .../src/app/pages/settings/settings.page.ts | 152 +++++++ .../src/app/pages/shell/shell.component.ts | 254 ++++++++++++ frontend/src/app/services/auth.service.ts | 57 --- frontend/src/app/services/mail.service.ts | 113 ------ frontend/src/app/ui/background.component.ts | 15 + frontend/src/app/ui/dialog.service.ts | 43 ++ frontend/src/app/ui/grid.ts | 26 ++ frontend/src/app/ui/overlays.component.ts | 93 +++++ frontend/src/app/ui/theme.service.ts | 36 ++ frontend/src/app/ui/toast.service.ts | 24 ++ frontend/src/index.html | 10 +- frontend/src/material.scss | 62 +++ frontend/src/styles.css | 97 ++--- 70 files changed, 3468 insertions(+), 1737 deletions(-) create mode 100644 backend/src/main/java/com/mailserver/dto/DraftRequest.java create mode 100644 backend/src/main/java/com/mailserver/dto/FolderRequest.java create mode 100644 backend/src/main/java/com/mailserver/dto/MessageUpdateRequest.java create mode 100644 backend/src/main/java/com/mailserver/dto/PasswordChangeRequest.java create mode 100644 backend/src/main/java/com/mailserver/dto/ProfileResponse.java create mode 100644 backend/src/main/java/com/mailserver/dto/ProfileUpdateRequest.java create mode 100644 backend/src/main/resources/db/migration/V3__mail_cabinet.sql create mode 100644 backend/src/test/java/com/mailserver/service/SubmissionServiceTest.java create mode 100644 frontend/.npmrc delete mode 100644 frontend/src/app/components/compose/compose.component.ts delete mode 100644 frontend/src/app/components/contacts/contacts.component.html delete mode 100644 frontend/src/app/components/contacts/contacts.component.ts delete mode 100644 frontend/src/app/components/inbox/inbox.component.ts delete mode 100644 frontend/src/app/components/layout/layout.component.ts delete mode 100644 frontend/src/app/components/login/login.component.ts delete mode 100644 frontend/src/app/components/message-detail/message-detail.component.ts delete mode 100644 frontend/src/app/components/register/register.component.ts create mode 100644 frontend/src/app/core/api.service.ts create mode 100644 frontend/src/app/core/auth.service.ts create mode 100644 frontend/src/app/core/auth.ts create mode 100644 frontend/src/app/core/mail-state.service.ts create mode 100644 frontend/src/app/core/models.ts delete mode 100644 frontend/src/app/interceptors/auth.interceptor.ts delete mode 100644 frontend/src/app/models/mail.models.ts create mode 100644 frontend/src/app/pages/auth/auth-frame.component.ts create mode 100644 frontend/src/app/pages/auth/login.page.ts create mode 100644 frontend/src/app/pages/auth/register.page.ts create mode 100644 frontend/src/app/pages/blocked/blocked.page.ts create mode 100644 frontend/src/app/pages/compose/compose.page.ts create mode 100644 frontend/src/app/pages/contacts/contacts.page.ts create mode 100644 frontend/src/app/pages/folder/folder.page.ts create mode 100644 frontend/src/app/pages/inbox-redirect.ts create mode 100644 frontend/src/app/pages/message/message.page.ts create mode 100644 frontend/src/app/pages/settings/settings.page.ts create mode 100644 frontend/src/app/pages/shell/shell.component.ts delete mode 100644 frontend/src/app/services/auth.service.ts delete mode 100644 frontend/src/app/services/mail.service.ts create mode 100644 frontend/src/app/ui/background.component.ts create mode 100644 frontend/src/app/ui/dialog.service.ts create mode 100644 frontend/src/app/ui/grid.ts create mode 100644 frontend/src/app/ui/overlays.component.ts create mode 100644 frontend/src/app/ui/theme.service.ts create mode 100644 frontend/src/app/ui/toast.service.ts create mode 100644 frontend/src/material.scss diff --git a/README.md b/README.md index 0ad9a7e..d553dd7 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,19 @@ - **DKIM.** Подпись — `utils-mail-dkim` (rsa-sha256, relaxed/relaxed), проверка входящих — Apache James jDKIM по ключу из DNS отправителя. Ключ создаётся один раз в `DKIM_KEYS_DIR`, запись для DNS печатается в лог при старте: `TXT ._domainkey.`. - **Права.** Письма, папки и вложения отдаются только владельцу (чужие — `404`). Регистрация по умолчанию закрыта, ящики — только в своих доменах. +## Веб-почта + +Angular 22 в стиле [synthwave-шаблона](frontend/src/assets/synthwave-design-showcase.html): стеклянные панели, неон, движущаяся сетка (выключается в настройках и при `prefers-reduced-motion`), четыре палитры и высокий контраст. Все таблицы — грид [`@jambotron/aria-grid`](https://gitea.jambotron.com/jambotron_gang/jambotron-aria-grid) (сортировка, страницы, выбор строк, массовые действия), пакет — из реестра npm Gitea (`frontend/.npmrc`). + +- **Вход и регистрация.** Регистрация (если открыта, `MAIL_REGISTRATION`) — имя ящика в одном из `MAIL_DOMAINS`, после неё вход сразу. Служебные имена (`postmaster`, `abuse`, `admin`…) заняты. Пароль — от 8 символов. +- **Папки.** Входящие, Черновики, Отправленные, Спам, Корзина и свои (создать, переименовать, удалить — письма уходят в Корзину). Счётчики непрочитанных обновляются раз в 30 с. +- **Список писем.** Поиск по теме, отправителю, получателям и тексту; флажок; прочитано/не прочитано; перенос в папку, в Спам и обратно; удаление (в Корзину, из Корзины — насовсем); очистка Корзины и Спама. +- **Письмо.** HTML-письма — в изолированном `iframe` без скриптов, внешние картинки — только по кнопке (отправитель не узнаёт, что письмо открыли). Вложения скачиваются. Заблокировать отправителя — его письма сразу идут в Спам. +- **Написать.** Несколько адресов в «Кому» и «Копия», подсказки из контактов, ответ, ответ всем, пересылка. Черновик сохраняется сам каждые 15 с; файлы (до 10 МБ) прикрепляются к черновику и уходят вместе с письмом. +- **Контакты, заблокированные отправители, настройки** (имя, пароль, место, оформление). + +API: `GET /api/config` (без входа), `/api/me`, `/api/me/password`, `/api/folders`, `/api/messages` (`PATCH` — `read`/`flagged`/`folderId`), `/api/drafts`, `/api/send` (`draftId` — с вложениями черновика), `/api/contacts`, `/api/spam/blacklist`. + ## Настройки (переменные окружения) Секреты без значений по умолчанию — без них сервер не стартует. Их можно передать переменными или файлами в `MAILSERVER_SECRETS_DIR` (имя файла = имя переменной). @@ -68,6 +81,7 @@ cd frontend && npm install && npx ng serve # http://loca - Пароль базы и ключ токенов создаёт контейнер `secrets-init` при первом запуске (том `secrets`) — вводить ничего не нужно, в git ничего нет. - Первый ящик — `admin@mail.test`, пароль — в логах сервиса `mail` при первом запуске. +- Регистрация открыта (`MAIL_REGISTRATION=true`): любой может завести ящик в `MAIL_DOMAINS`. Наружу письма не уходят, поэтому для тестового стенда это безопасно; закрыть — `MAIL_REGISTRATION=false`. - Наружу открыты только веб-почта и API. SMTP (3025) — внутри сети ВМ, наружу ничего не отправляется (`OUTBOUND_MODE=off`). - Домен ящиков — `MAIL_DOMAINS` (по умолчанию `mail.test`). @@ -77,7 +91,9 @@ cd frontend && npm install && npx ng serve # http://loca - [ ] TLS: STARTTLS для SMTP и IMAP. Пока `AUTH` — только в закрытой сети. - [ ] IMAP выключен: упрощённый, блокирует сетевой поток (`.block()`). Переписать и проверить с Thunderbird. -- [ ] Вложения входящих писем не сохраняются (берётся только текст или HTML). +- [ ] Пересылка не берёт вложения исходного письма — их надо прикрепить заново. +- [ ] Встроенные картинки HTML-писем (`cid:`) показываются как вложения, а не внутри письма. +- [ ] Защита регистрации и входа от перебора (лимит попыток, капча). - [ ] Письмо нескольким получателям: при ошибке на втором первый может получить его дважды (после повтора отправителя). - [ ] Своя настоящая почта: отдельная VPS, порт 25, PTR, отдельный домен (чек-лист Jambotron 75.6). diff --git a/backend/Dockerfile b/backend/Dockerfile index b9c58ae..de7bec5 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -5,7 +5,7 @@ # 1. Web client (Angular) — served by the backend from classpath:/static FROM node:22-bookworm-slim AS web WORKDIR /web -COPY frontend/package*.json ./ +COPY frontend/package*.json frontend/.npmrc ./ RUN npm install --no-audit --no-fund COPY frontend/ ./ RUN npx ng build --configuration=production diff --git a/backend/src/main/java/com/mailserver/config/SecurityConfig.java b/backend/src/main/java/com/mailserver/config/SecurityConfig.java index 75d9f82..ee1c390 100644 --- a/backend/src/main/java/com/mailserver/config/SecurityConfig.java +++ b/backend/src/main/java/com/mailserver/config/SecurityConfig.java @@ -51,7 +51,7 @@ public class SecurityConfig { .exceptionHandling(e -> e.authenticationEntryPoint(new HttpStatusServerEntryPoint(HttpStatus.UNAUTHORIZED))) .cors(cors -> cors.configurationSource(corsConfigurationSource())) .authorizeExchange(exchange -> exchange - .pathMatchers("/api/login", "/api/register", "/actuator/health").permitAll() + .pathMatchers("/api/login", "/api/register", "/api/config", "/actuator/health").permitAll() .pathMatchers("/api/**", "/actuator/**").authenticated() // the web client (static files, SPA routes) .anyExchange().permitAll() diff --git a/backend/src/main/java/com/mailserver/config/SpaWebFilter.java b/backend/src/main/java/com/mailserver/config/SpaWebFilter.java index 28cade1..aecd1fe 100644 --- a/backend/src/main/java/com/mailserver/config/SpaWebFilter.java +++ b/backend/src/main/java/com/mailserver/config/SpaWebFilter.java @@ -7,7 +7,10 @@ import org.springframework.web.server.WebFilter; import org.springframework.web.server.WebFilterChain; import reactor.core.publisher.Mono; -/** Angular routes (/inbox, /message/5, …) are pages of the web client: serve index.html for them. */ +/** + * Angular routes (/inbox, /message/5, /compose;to=a@b.c …) are pages of the web client: serve index.html for them. + * A last segment with a dot (before matrix parameters) is a file. + */ @Component @Order(-100) public class SpaWebFilter implements WebFilter { @@ -17,7 +20,7 @@ public class SpaWebFilter implements WebFilter { String path = exchange.getRequest().getPath().pathWithinApplication().value(); boolean page = exchange.getRequest().getMethod().matches("GET") && !path.startsWith("/api") && !path.startsWith("/actuator") - && !path.equals("/") && !path.substring(path.lastIndexOf('/') + 1).contains("."); + && !path.equals("/") && !path.substring(path.lastIndexOf('/') + 1).split(";")[0].contains("."); if (page) { return chain.filter(exchange.mutate().request(exchange.getRequest().mutate().path("/index.html").build()).build()); } diff --git a/backend/src/main/java/com/mailserver/controller/AuthenticationController.java b/backend/src/main/java/com/mailserver/controller/AuthenticationController.java index 658b663..c93e879 100644 --- a/backend/src/main/java/com/mailserver/controller/AuthenticationController.java +++ b/backend/src/main/java/com/mailserver/controller/AuthenticationController.java @@ -1,36 +1,68 @@ package com.mailserver.controller; +import com.mailserver.config.MailDomains; +import com.mailserver.domain.User; import com.mailserver.dto.LoginRequest; import com.mailserver.dto.LoginResponse; -import com.mailserver.domain.User; +import com.mailserver.dto.PasswordChangeRequest; +import com.mailserver.dto.ProfileResponse; +import com.mailserver.dto.ProfileUpdateRequest; +import com.mailserver.dto.RegisterRequest; +import com.mailserver.service.MailService; import com.mailserver.service.UserService; import com.mailserver.util.JwtUtils; import jakarta.validation.Valid; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; import reactor.core.publisher.Mono; +import java.util.Locale; import java.util.Map; +import java.util.Set; +/** Login, registration, the logged-in user's profile and password; what the login page needs to know. */ @RestController @RequestMapping("/api") public class AuthenticationController { - private final UserService userService; - private final JwtUtils jwtUtils; + /** Service addresses nobody can register (RFC 2142 and ours). */ + static final Set RESERVED = Set.of("postmaster", "abuse", "mailer-daemon", "hostmaster", "webmaster", + "root", "admin", "noreply", "no-reply", "security", "support"); - public AuthenticationController(UserService userService, JwtUtils jwtUtils) { + private final UserService userService; + private final MailService mailService; + private final JwtUtils jwtUtils; + private final MailDomains domains; + private final boolean registrationEnabled; + + public AuthenticationController(UserService userService, MailService mailService, JwtUtils jwtUtils, MailDomains domains, + @Value("${mailserver.registration.enabled:false}") boolean registrationEnabled) { this.userService = userService; + this.mailService = mailService; this.jwtUtils = jwtUtils; + this.domains = domains; + this.registrationEnabled = registrationEnabled; + } + + /** Public: is registration open, and in which domains mailboxes can be created. */ + @GetMapping("/config") + public Map config() { + return Map.of("registrationEnabled", registrationEnabled, "domains", domains.domains().stream().sorted().toList()); } @PostMapping("/login") public Mono> login(@Valid @RequestBody Mono requestMono) { return requestMono.flatMap(request -> - userService.findByEmail(request.getEmail()) + userService.findByEmail(request.getEmail().trim().toLowerCase(Locale.ROOT)) .switchIfEmpty(Mono.error(new IllegalArgumentException("Invalid credentials"))) .flatMap(user -> { if (!user.isActive()) { @@ -39,15 +71,74 @@ public class AuthenticationController { if (!userService.checkPassword(user, request.getPassword())) { return Mono.just(ResponseEntity.badRequest().body(Map.of("error", "Invalid credentials"))); } - String token = jwtUtils.generateToken(user.getEmail()); - LoginResponse response = new LoginResponse(); - response.setToken(token); - response.setEmail(user.getEmail()); - return Mono.just(ResponseEntity.ok(response)); + return Mono.just(ResponseEntity.ok(token(user))); }) .onErrorResume(IllegalArgumentException.class, e -> Mono.just(ResponseEntity.badRequest().body(Map.of("error", "Invalid credentials"))) ) ); } + + /** New mailbox in one of our domains; logs in right away (the answer carries the token). */ + @PostMapping("/register") + public Mono> register(@Valid @RequestBody Mono requestMono) { + if (!registrationEnabled) { + return Mono.just(ResponseEntity.status(HttpStatus.FORBIDDEN).body(Map.of("error", "Registration is closed"))); + } + return requestMono.flatMap(request -> { + String email = request.getEmail().trim().toLowerCase(Locale.ROOT); + String local = email.substring(0, email.indexOf('@')); + if (!domains.isLocal(email)) { + return Mono.just(ResponseEntity.badRequest() + .body(Map.of("error", "Mailboxes can only be created in: " + String.join(", ", domains.domains())))); + } + if (RESERVED.contains(local) || !local.matches("[a-z0-9]([a-z0-9._-]{0,62}[a-z0-9])?")) { + return Mono.just(ResponseEntity.badRequest() + .body(Map.of("error", "This name is not available: letters, digits, dot, dash, underscore"))); + } + return userService.register(email, request.getPassword(), request.getDisplayName()) + .map(user -> ResponseEntity.status(HttpStatus.CREATED).body((Object) token(user))) + .onErrorResume(IllegalArgumentException.class, e -> + Mono.just(ResponseEntity.badRequest().body(Map.of("error", "This mailbox already exists")))); + }); + } + + @GetMapping("/me") + public Mono me(Mono auth) { + return user(auth).flatMap(this::profile); + } + + @PutMapping("/me") + public Mono updateMe(Mono auth, @Valid @RequestBody Mono body) { + return user(auth).zipWith(body) + .flatMap(pair -> userService.updateDisplayName(pair.getT1(), pair.getT2().displayName())) + .flatMap(this::profile); + } + + @PutMapping("/me/password") + public Mono> changePassword(Mono auth, @Valid @RequestBody Mono body) { + return user(auth).zipWith(body) + .flatMap(pair -> userService.changePassword(pair.getT1(), pair.getT2().currentPassword(), pair.getT2().newPassword())) + .thenReturn(Map.of("message", "Password changed")); + } + + private Mono profile(User user) { + return mailService.usedBytes(user.getId()) + .map(used -> new ProfileResponse(user.getEmail(), user.getDisplayName(), used, + user.getQuotaBytes() == null ? 0 : user.getQuotaBytes(), user.getCreatedAt())); + } + + private Mono user(Mono auth) { + return auth.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED))) + .flatMap(a -> userService.findByEmail(a.getName())) + .onErrorMap(IllegalArgumentException.class, e -> new ResponseStatusException(HttpStatus.UNAUTHORIZED)); + } + + private LoginResponse token(User user) { + LoginResponse response = new LoginResponse(); + response.setToken(jwtUtils.generateToken(user.getEmail())); + response.setEmail(user.getEmail()); + return response; + } + } diff --git a/backend/src/main/java/com/mailserver/controller/MailController.java b/backend/src/main/java/com/mailserver/controller/MailController.java index a5dc521..4c0931d 100644 --- a/backend/src/main/java/com/mailserver/controller/MailController.java +++ b/backend/src/main/java/com/mailserver/controller/MailController.java @@ -3,31 +3,30 @@ package com.mailserver.controller; import com.mailserver.dto.AttachmentResponse; import com.mailserver.dto.FolderResponse; import com.mailserver.dto.MessageResponse; -import com.mailserver.dto.RegisterRequest; +import com.mailserver.dto.DraftRequest; +import com.mailserver.dto.FolderRequest; +import com.mailserver.dto.MessageUpdateRequest; import com.mailserver.dto.SendRequest; import com.mailserver.service.MailService; import com.mailserver.service.UserService; import jakarta.validation.Valid; -import org.springframework.core.io.buffer.DataBuffer; -import org.springframework.core.io.buffer.DataBufferUtils; -import org.springframework.core.io.buffer.DefaultDataBufferFactory; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.codec.multipart.FilePart; import org.springframework.security.core.Authentication; -import com.mailserver.config.MailDomains; import com.mailserver.service.SubmissionService; -import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -51,165 +50,148 @@ public class MailController { private final UserService userService; private final MailService mailService; private final SubmissionService submissionService; - private final MailDomains domains; - private final boolean registrationEnabled; - public MailController(UserService userService, MailService mailService, SubmissionService submissionService, - MailDomains domains, - @Value("${mailserver.registration.enabled:false}") boolean registrationEnabled) { + public MailController(UserService userService, MailService mailService, SubmissionService submissionService) { this.userService = userService; this.mailService = mailService; this.submissionService = submissionService; - this.domains = domains; - this.registrationEnabled = registrationEnabled; } - private Mono getCurrentUserEmail(Mono authenticationMono) { - return authenticationMono.map(Authentication::getName); + private Mono user(Mono auth) { + return auth.flatMap(a -> userService.findByEmail(a.getName())); } - // --- Users --- - - @PostMapping("/register") - public Mono> register(@Valid @RequestBody Mono requestMono) { - if (!registrationEnabled) { - return Mono.just(org.springframework.http.ResponseEntity.status(HttpStatus.FORBIDDEN) - .body(Map.of("error", "Registration is closed"))); - } - return requestMono.flatMap(request -> !domains.isLocal(request.getEmail()) - ? Mono.just(org.springframework.http.ResponseEntity.badRequest() - .body(Map.of("error", "Mailboxes can only be created in: " + String.join(", ", domains.domains())))) - : userService.register(request.getEmail().trim().toLowerCase(java.util.Locale.ROOT), request.getPassword(), request.getDisplayName()) - .map(user -> org.springframework.http.ResponseEntity.status(HttpStatus.CREATED) - .body(Map.of("message", "User registered"))) - .onErrorResume(IllegalArgumentException.class, e -> - Mono.just(org.springframework.http.ResponseEntity.badRequest() - .body(Map.of("error", e.getMessage())))) - ); - } + // --- Folders --- @GetMapping("/folders") - public Flux getFolders(Mono authenticationMono) { - return authenticationMono.flatMapMany(auth -> - userService.findByEmail(auth.getName()) - .flatMapMany(user -> userService.getFolders(user.getId())) - ); + public Flux getFolders(Mono auth) { + return user(auth).flatMapMany(user -> mailService.folders(user.getId())); + } + + @PostMapping("/folders") + public Mono> createFolder(Mono auth, @Valid @RequestBody Mono body) { + return user(auth).zipWith(body) + .flatMap(pair -> mailService.createFolder(pair.getT1().getId(), pair.getT2().name())) + .map(folder -> ResponseEntity.status(HttpStatus.CREATED).body(folder)); + } + + @PutMapping("/folders/{id}") + public Mono> renameFolder(@PathVariable Long id, Mono auth, + @Valid @RequestBody Mono body) { + return user(auth).zipWith(body) + .flatMap(pair -> mailService.renameFolder(id, pair.getT1().getId(), pair.getT2().name())) + .thenReturn(Map.of("message", "Folder renamed")); + } + + /** Own folder only; its messages go to the Trash. */ + @DeleteMapping("/folders/{id}") + public Mono> deleteFolder(@PathVariable Long id, Mono auth) { + return user(auth).flatMap(user -> mailService.deleteFolder(id, user.getId())) + .thenReturn(Map.of("message", "Folder deleted")); + } + + /** Trash or Spam: removes all its messages for good. */ + @DeleteMapping("/folders/{id}/messages") + public Mono> emptyFolder(@PathVariable Long id, Mono auth) { + return user(auth).flatMap(user -> mailService.emptyFolder(id, user.getId())) + .map(count -> Map.of("deleted", count)); } // --- Messages --- + /** Newest first, bodies cut to a preview. */ @GetMapping("/messages") - public Flux getMessages( - Mono authenticationMono, - @RequestParam Long folderId, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size) { - return authenticationMono.flatMapMany(auth -> - userService.findByEmail(auth.getName()) - .flatMapMany(user -> mailService.getMessages(folderId, user.getId(), page, size)) - ); + public Flux getMessages(Mono auth, @RequestParam Long folderId, + @RequestParam(defaultValue = "" + MailService.LIST_LIMIT) int limit) { + return user(auth).flatMapMany(user -> mailService.listMessages(folderId, user.getId(), limit)); } @GetMapping("/messages/search") public Flux searchMessages( - Mono authenticationMono, + Mono auth, @RequestParam Long folderId, @RequestParam(required = false) String keyword, @RequestParam(required = false) String subject, @RequestParam(required = false) String body, @RequestParam(required = false) String sender, - @RequestParam(required = false) String recipient, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size) { - return authenticationMono.flatMapMany(auth -> - userService.findByEmail(auth.getName()) - .flatMapMany(user -> { - if (keyword != null && !keyword.trim().isEmpty()) { - return mailService.searchMessages(folderId, user.getId(), keyword.trim(), page, size); - } - return mailService.searchMessagesWithFilters(folderId, user.getId(), - subject, body, sender, recipient, page, size); - }) - ); + @RequestParam(required = false) String recipient) { + return user(auth).flatMapMany(user -> keyword != null && !keyword.isBlank() + ? mailService.searchMessages(folderId, user.getId(), keyword.trim(), 0, MailService.LIST_LIMIT) + : mailService.searchMessagesWithFilters(folderId, user.getId(), subject, body, sender, recipient, + 0, MailService.LIST_LIMIT)); } @GetMapping("/messages/{id}") - public Mono getMessage(@PathVariable Long id, Mono authenticationMono) { - return authenticationMono.flatMap(auth -> - userService.findByEmail(auth.getName()) - .flatMap(user -> mailService.getMessage(id, user.getId())) - ); + public Mono getMessage(@PathVariable Long id, Mono auth) { + return user(auth).flatMap(user -> mailService.getMessage(id, user.getId())); } - @PostMapping("/send") - public Mono sendMessage( - Mono authenticationMono, - @Valid @RequestBody Mono requestMono) { - return authenticationMono.zipWith(requestMono) - .flatMap(pair -> submissionService.send(pair.getT1().getName(), pair.getT2())); + /** read / flagged / folderId — only the fields that are set. */ + @PatchMapping("/messages/{id}") + public Mono updateMessage(@PathVariable Long id, Mono auth, + @RequestBody Mono body) { + return user(auth).zipWith(body) + .flatMap(pair -> mailService.updateMessage(id, pair.getT1().getId(), pair.getT2())); } @PutMapping("/messages/{id}/read") - public Mono> markAsRead(@PathVariable Long id, Mono authenticationMono) { - return authenticationMono.flatMap(auth -> - userService.findByEmail(auth.getName()) - .flatMap(user -> mailService.markAsRead(id, user.getId()) - .then(Mono.just(org.springframework.http.ResponseEntity.ok(Map.of("message", "Marked as read"))))) - ); + public Mono> markAsRead(@PathVariable Long id, Mono auth) { + return user(auth).flatMap(user -> mailService.markAsRead(id, user.getId())) + .thenReturn(Map.of("message", "Marked as read")); } + /** Into the Trash; from the Trash — for good. */ @DeleteMapping("/messages/{id}") - public Mono> deleteMessage(@PathVariable Long id, Mono authenticationMono) { - return authenticationMono.flatMap(auth -> - userService.findByEmail(auth.getName()) - .flatMap(user -> mailService.deleteMessage(id, user.getId()) - .then(Mono.just(org.springframework.http.ResponseEntity.ok(Map.of("message", "Message deleted"))))) - ); + public Mono> deleteMessage(@PathVariable Long id, Mono auth) { + return user(auth).flatMap(user -> mailService.deleteMessage(id, user.getId())) + .thenReturn(Map.of("message", "Message deleted")); + } + + @PostMapping("/send") + public Mono sendMessage(Mono auth, @Valid @RequestBody Mono requestMono) { + return auth.zipWith(requestMono) + .flatMap(pair -> submissionService.send(pair.getT1().getName(), pair.getT2())); + } + + // --- Drafts --- + + @PostMapping("/drafts") + public Mono createDraft(Mono auth, @Valid @RequestBody Mono body) { + return user(auth).zipWith(body).flatMap(pair -> mailService.saveDraft(pair.getT1(), null, pair.getT2())); + } + + @PutMapping("/drafts/{id}") + public Mono updateDraft(@PathVariable Long id, Mono auth, + @Valid @RequestBody Mono body) { + return user(auth).zipWith(body).flatMap(pair -> mailService.saveDraft(pair.getT1(), id, pair.getT2())); } // --- Attachments --- + /** A file for a draft (form field "file"); stored on disk, the draft gets the record. */ @PostMapping(value = "/messages/{messageId}/attachments", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public Mono> uploadAttachment( + public Mono> uploadAttachment( @PathVariable Long messageId, - Mono authenticationMono, - @RequestBody Mono> filePartsMono) { - return authenticationMono.zipWith(filePartsMono) - .flatMap(pair -> { - String email = pair.getT1().getName(); - return userService.findByEmail(email) - .flatMapMany(user -> pair.getT2()) - .next() - .flatMap(file -> { - String originalFilename = safeFilename(file.filename()); - String uniqueName = UUID.randomUUID() + "_" + originalFilename; - Path filePath = Paths.get(mailService.getAttachmentsDir(), uniqueName); - try { - Files.createDirectories(filePath.getParent()); - } catch (IOException e) { - return Mono.error(new RuntimeException("Failed to create directory", e)); - } - - return DataBufferUtils.join(file.content()) - .flatMap(dataBuffer -> { - byte[] bytes = new byte[dataBuffer.readableByteCount()]; - dataBuffer.read(bytes); - DataBufferUtils.release(dataBuffer); - try { - Files.write(filePath, bytes); - } catch (IOException e) { - return Mono.error(new RuntimeException("Failed to save file", e)); - } - return userService.findByEmail(email) - .flatMap(user -> mailService.saveAttachmentReactive( - messageId, user.getId(), - originalFilename, - file.headers().getContentType() != null ? file.headers().getContentType().toString() : "application/octet-stream", - bytes.length, filePath.toString())) - .map(response -> org.springframework.http.ResponseEntity.status(HttpStatus.CREATED).body(response)); - }); - }); - }); + Mono auth, + @RequestPart("file") Mono fileMono) { + return user(auth).zipWith(fileMono).flatMap(pair -> { + var user = pair.getT1(); + FilePart file = pair.getT2(); + String originalFilename = safeFilename(file.filename()); + Path filePath = Paths.get(mailService.getAttachmentsDir(), UUID.randomUUID() + "_" + originalFilename); + String contentType = file.headers().getContentType() != null + ? file.headers().getContentType().toString() : "application/octet-stream"; + // the draft must be the user's before anything is written + return mailService.ownedDraft(messageId, user.getId()) + .then(Mono.fromCallable(() -> Files.createDirectories(filePath.getParent())) + .subscribeOn(reactor.core.scheduler.Schedulers.boundedElastic())) + .then(file.transferTo(filePath)) + .then(Mono.fromCallable(() -> Files.size(filePath)) + .subscribeOn(reactor.core.scheduler.Schedulers.boundedElastic())) + .flatMap(size -> mailService.saveAttachmentReactive(messageId, user.getId(), originalFilename, + contentType, size, filePath.toString())) + .map(response -> ResponseEntity.status(HttpStatus.CREATED).body(response)); + }); } @GetMapping("/messages/{messageId}/attachments") @@ -246,7 +228,7 @@ public class MailController { } /** Only the last path segment, no control characters — a file name cannot leave the attachments folder. */ - static String safeFilename(String name) { + public static String safeFilename(String name) { String base = name == null ? "" : name.replace('\\', '/'); base = base.substring(base.lastIndexOf('/') + 1).replaceAll("[\\p{Cntrl}\"]", "").trim(); if (base.isEmpty() || base.equals(".") || base.equals("..")) { diff --git a/backend/src/main/java/com/mailserver/domain/Folder.java b/backend/src/main/java/com/mailserver/domain/Folder.java index e5d928f..6d297b7 100644 --- a/backend/src/main/java/com/mailserver/domain/Folder.java +++ b/backend/src/main/java/com/mailserver/domain/Folder.java @@ -24,6 +24,9 @@ public class Folder { @Column("unread_count") private Integer unreadCount = 0; + @Column("is_system") + private boolean system; + @Column("created_at") private Instant createdAt; @@ -42,6 +45,9 @@ public class Folder { public Integer getUnreadCount() { return unreadCount; } public void setUnreadCount(Integer unreadCount) { this.unreadCount = unreadCount; } + public boolean isSystem() { return system; } + public void setSystem(boolean system) { this.system = system; } + public Instant getCreatedAt() { return createdAt; } public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } } diff --git a/backend/src/main/java/com/mailserver/domain/Message.java b/backend/src/main/java/com/mailserver/domain/Message.java index e9a2644..b02f04f 100644 --- a/backend/src/main/java/com/mailserver/domain/Message.java +++ b/backend/src/main/java/com/mailserver/domain/Message.java @@ -24,6 +24,9 @@ public class Message { @Column("recipient_email") private String recipientEmail; + @Column("cc_email") + private String ccEmail; + @Column("subject") private String subject; @@ -66,6 +69,9 @@ public class Message { public String getRecipientEmail() { return recipientEmail; } public void setRecipientEmail(String recipientEmail) { this.recipientEmail = recipientEmail; } + public String getCcEmail() { return ccEmail; } + public void setCcEmail(String ccEmail) { this.ccEmail = ccEmail; } + public String getSubject() { return subject; } public void setSubject(String subject) { this.subject = subject; } diff --git a/backend/src/main/java/com/mailserver/dto/DraftRequest.java b/backend/src/main/java/com/mailserver/dto/DraftRequest.java new file mode 100644 index 0000000..04764f6 --- /dev/null +++ b/backend/src/main/java/com/mailserver/dto/DraftRequest.java @@ -0,0 +1,8 @@ +package com.mailserver.dto; + +import jakarta.validation.constraints.Size; + +/** A draft: everything optional, addresses are not checked until it is sent. */ +public record DraftRequest(@Size(max = 5000) String to, @Size(max = 5000) String cc, + @Size(max = 1000) String subject, @Size(max = 1_000_000) String body, boolean html) { +} diff --git a/backend/src/main/java/com/mailserver/dto/FolderRequest.java b/backend/src/main/java/com/mailserver/dto/FolderRequest.java new file mode 100644 index 0000000..81c3e56 --- /dev/null +++ b/backend/src/main/java/com/mailserver/dto/FolderRequest.java @@ -0,0 +1,7 @@ +package com.mailserver.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record FolderRequest(@NotBlank @Size(max = 50) String name) { +} diff --git a/backend/src/main/java/com/mailserver/dto/FolderResponse.java b/backend/src/main/java/com/mailserver/dto/FolderResponse.java index 7539230..e86ba10 100644 --- a/backend/src/main/java/com/mailserver/dto/FolderResponse.java +++ b/backend/src/main/java/com/mailserver/dto/FolderResponse.java @@ -1,23 +1,5 @@ package com.mailserver.dto; -import java.time.Instant; - -public class FolderResponse { - - private Long id; - private String name; - private Integer messageCount; - private Integer unreadCount; - - public Long getId() { return id; } - public void setId(Long id) { this.id = id; } - - public String getName() { return name; } - public void setName(String name) { this.name = name; } - - public Integer getMessageCount() { return messageCount; } - public void setMessageCount(Integer messageCount) { this.messageCount = messageCount; } - - public Integer getUnreadCount() { return unreadCount; } - public void setUnreadCount(Integer unreadCount) { this.unreadCount = unreadCount; } +/** A folder with live counters; system = INBOX, Drafts, Sent, Spam, Trash (cannot be renamed or deleted). */ +public record FolderResponse(Long id, String name, boolean system, long messageCount, long unreadCount) { } diff --git a/backend/src/main/java/com/mailserver/dto/MessageResponse.java b/backend/src/main/java/com/mailserver/dto/MessageResponse.java index 2de7810..18302ef 100644 --- a/backend/src/main/java/com/mailserver/dto/MessageResponse.java +++ b/backend/src/main/java/com/mailserver/dto/MessageResponse.java @@ -8,6 +8,7 @@ public class MessageResponse { private String senderEmail; private String senderName; private String recipientEmail; + private String ccEmail; private String subject; private String body; private boolean html; @@ -32,6 +33,9 @@ public class MessageResponse { public String getRecipientEmail() { return recipientEmail; } public void setRecipientEmail(String recipientEmail) { this.recipientEmail = recipientEmail; } + public String getCcEmail() { return ccEmail; } + public void setCcEmail(String ccEmail) { this.ccEmail = ccEmail; } + public String getSubject() { return subject; } public void setSubject(String subject) { this.subject = subject; } diff --git a/backend/src/main/java/com/mailserver/dto/MessageUpdateRequest.java b/backend/src/main/java/com/mailserver/dto/MessageUpdateRequest.java new file mode 100644 index 0000000..37d4d80 --- /dev/null +++ b/backend/src/main/java/com/mailserver/dto/MessageUpdateRequest.java @@ -0,0 +1,5 @@ +package com.mailserver.dto; + +/** Partial update of a message: only the fields that are set change. */ +public record MessageUpdateRequest(Boolean read, Boolean flagged, Long folderId) { +} diff --git a/backend/src/main/java/com/mailserver/dto/PasswordChangeRequest.java b/backend/src/main/java/com/mailserver/dto/PasswordChangeRequest.java new file mode 100644 index 0000000..39db59d --- /dev/null +++ b/backend/src/main/java/com/mailserver/dto/PasswordChangeRequest.java @@ -0,0 +1,7 @@ +package com.mailserver.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record PasswordChangeRequest(@NotBlank String currentPassword, @NotBlank @Size(min = 8, max = 100) String newPassword) { +} diff --git a/backend/src/main/java/com/mailserver/dto/ProfileResponse.java b/backend/src/main/java/com/mailserver/dto/ProfileResponse.java new file mode 100644 index 0000000..3415733 --- /dev/null +++ b/backend/src/main/java/com/mailserver/dto/ProfileResponse.java @@ -0,0 +1,6 @@ +package com.mailserver.dto; + +import java.time.Instant; + +public record ProfileResponse(String email, String displayName, long usedBytes, long quotaBytes, Instant createdAt) { +} diff --git a/backend/src/main/java/com/mailserver/dto/ProfileUpdateRequest.java b/backend/src/main/java/com/mailserver/dto/ProfileUpdateRequest.java new file mode 100644 index 0000000..a007802 --- /dev/null +++ b/backend/src/main/java/com/mailserver/dto/ProfileUpdateRequest.java @@ -0,0 +1,7 @@ +package com.mailserver.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record ProfileUpdateRequest(@NotBlank @Size(max = 100) String displayName) { +} diff --git a/backend/src/main/java/com/mailserver/dto/RegisterRequest.java b/backend/src/main/java/com/mailserver/dto/RegisterRequest.java index 5c14745..becb6b9 100644 --- a/backend/src/main/java/com/mailserver/dto/RegisterRequest.java +++ b/backend/src/main/java/com/mailserver/dto/RegisterRequest.java @@ -12,7 +12,7 @@ public class RegisterRequest { private String email; @NotBlank - @Size(min = 6, max = 100) + @Size(min = 8, max = 100) private String password; @Size(max = 100) diff --git a/backend/src/main/java/com/mailserver/dto/SendRequest.java b/backend/src/main/java/com/mailserver/dto/SendRequest.java index 93837b3..8ec00fc 100644 --- a/backend/src/main/java/com/mailserver/dto/SendRequest.java +++ b/backend/src/main/java/com/mailserver/dto/SendRequest.java @@ -1,31 +1,46 @@ package com.mailserver.dto; -import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +/** + * "Send" from the web client. {@code to} and {@code cc}: one or more addresses separated by commas, semicolons or + * spaces. {@code draftId}: the draft being sent — its attachments go with the message, the draft is removed. + */ public class SendRequest { @NotBlank - @Email + @Size(max = 5000) private String to; - @NotBlank + @Size(max = 5000) + private String cc; + + @Size(max = 1000) private String subject; - @NotBlank + @Size(max = 1_000_000) private String body; private boolean html = false; + private Long draftId; + public String getTo() { return to; } public void setTo(String to) { this.to = to; } - public String getSubject() { return subject; } + public String getCc() { return cc; } + public void setCc(String cc) { this.cc = cc; } + + public String getSubject() { return subject == null ? "" : subject; } public void setSubject(String subject) { this.subject = subject; } - public String getBody() { return body; } + public String getBody() { return body == null ? "" : body; } public void setBody(String body) { this.body = body; } public boolean isHtml() { return html; } public void setHtml(boolean html) { this.html = html; } + + public Long getDraftId() { return draftId; } + public void setDraftId(Long draftId) { this.draftId = draftId; } } diff --git a/backend/src/main/java/com/mailserver/repository/AttachmentRepository.java b/backend/src/main/java/com/mailserver/repository/AttachmentRepository.java index 6e34c16..b3e2ff7 100644 --- a/backend/src/main/java/com/mailserver/repository/AttachmentRepository.java +++ b/backend/src/main/java/com/mailserver/repository/AttachmentRepository.java @@ -1,9 +1,22 @@ package com.mailserver.repository; import com.mailserver.domain.Attachment; +import org.springframework.data.r2dbc.repository.Modifying; +import org.springframework.data.r2dbc.repository.Query; import org.springframework.data.r2dbc.repository.R2dbcRepository; import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; public interface AttachmentRepository extends R2dbcRepository { Flux findByMessageId(Long messageId); + + Mono countByMessageId(Long messageId); + + @Modifying + @Query("UPDATE attachments SET message_id = :#{#to} WHERE message_id = :#{#from}") + Mono moveAll(Long from, Long to); + + @Query("SELECT COALESCE(SUM(a.size_bytes), 0) FROM attachments a JOIN messages m ON m.id = a.message_id " + + "JOIN folders f ON f.id = m.folder_id WHERE f.user_id = :#{#userId}") + Mono sumSizeByUserId(Long userId); } diff --git a/backend/src/main/java/com/mailserver/repository/MessageRepository.java b/backend/src/main/java/com/mailserver/repository/MessageRepository.java index 14c3f2e..3bbe5b9 100644 --- a/backend/src/main/java/com/mailserver/repository/MessageRepository.java +++ b/backend/src/main/java/com/mailserver/repository/MessageRepository.java @@ -2,6 +2,7 @@ package com.mailserver.repository; import com.mailserver.domain.Message; import org.springframework.data.domain.Pageable; +import org.springframework.data.r2dbc.repository.Modifying; import org.springframework.data.r2dbc.repository.Query; import org.springframework.data.r2dbc.repository.R2dbcRepository; import reactor.core.publisher.Flux; @@ -9,19 +10,33 @@ import reactor.core.publisher.Mono; public interface MessageRepository extends R2dbcRepository { + @Query("SELECT * FROM messages WHERE folder_id = :#{#folderId} ORDER BY received_at DESC LIMIT :#{#limit}") + Flux findLatest(Long folderId, int limit); + + Flux findByFolderId(Long folderId); + + /** IMAP (switched off): the whole folder, newest first. */ @Query("SELECT * FROM messages WHERE folder_id = :#{#folderId} ORDER BY received_at DESC") Flux findByFolderId(Long folderId, Pageable pageable); + @Query("SELECT COUNT(*) FROM messages WHERE folder_id = :#{#folderId}") + Mono countByFolderId(Long folderId); + @Query("SELECT COUNT(*) FROM messages WHERE folder_id = :#{#folderId} AND is_read = false") Mono countUnreadByFolderId(Long folderId); - @Query("SELECT * FROM messages WHERE folder_id = :#{#folderId} AND is_read = false ORDER BY received_at DESC") - Flux findUnreadByFolderId(Long folderId, Pageable pageable); + @Modifying + @Query("UPDATE messages SET folder_id = :#{#to} WHERE folder_id = :#{#from}") + Mono moveAll(Long from, Long to); + + @Query("SELECT COALESCE(SUM(m.message_size), 0) FROM messages m JOIN folders f ON f.id = m.folder_id WHERE f.user_id = :#{#userId}") + Mono sumSizeByUserId(Long userId); @Query("SELECT * FROM messages WHERE folder_id = :#{#folderId} AND " + "(LOWER(subject) LIKE '%' || LOWER(:#{#keyword}) || '%' OR " + "LOWER(sender_email) LIKE '%' || LOWER(:#{#keyword}) || '%' OR " + "LOWER(sender_name) LIKE '%' || LOWER(:#{#keyword}) || '%' OR " + + "LOWER(recipient_email) LIKE '%' || LOWER(:#{#keyword}) || '%' OR " + "LOWER(body) LIKE '%' || LOWER(:#{#keyword}) || '%') " + "ORDER BY received_at DESC") Flux searchInFolder(Long folderId, String keyword, Pageable pageable); diff --git a/backend/src/main/java/com/mailserver/service/LocalDelivery.java b/backend/src/main/java/com/mailserver/service/LocalDelivery.java index 52c9014..8cca573 100644 --- a/backend/src/main/java/com/mailserver/service/LocalDelivery.java +++ b/backend/src/main/java/com/mailserver/service/LocalDelivery.java @@ -9,6 +9,7 @@ import jakarta.mail.Part; import jakarta.mail.Session; import jakarta.mail.internet.InternetAddress; import jakarta.mail.internet.MimeMessage; +import jakarta.mail.internet.MimeUtility; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; @@ -18,6 +19,8 @@ import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; import java.util.Properties; /** @@ -71,7 +74,61 @@ public class LocalDelivery implements DeliveryWorker.BounceSender { dkim.signed() ? (dkim.passed() ? "pass " + dkim.domain() : "fail") : "none", spf, dmarc, spam ? " → spam" : ""); } - mailService.receiveMessage(senderEmail, senderName, mailbox, subject, body.text(), body.html(), spam).block(); + com.mailserver.domain.Message stored = + mailService.receiveMessage(senderEmail, senderName, mailbox, subject, body.text(), body.html(), spam, + addresses(mime, jakarta.mail.Message.RecipientType.TO), + addresses(mime, jakarta.mail.Message.RecipientType.CC)).block(); + if (stored != null) { + for (FilePart file : attachments(mime)) { + mailService.storeIncomingAttachment(stored.getId(), file.name(), file.contentType(), file.bytes()); + } + } + } + + /** "a@x, b@y" from a header, or null. A broken header is ignored, not a reason to lose the message. */ + static String addresses(MimeMessage mime, jakarta.mail.Message.RecipientType type) { + try { + jakarta.mail.Address[] list = mime.getRecipients(type); + if (list == null || list.length == 0) { + return null; + } + List result = new ArrayList<>(); + for (jakarta.mail.Address address : list) { + result.add(address instanceof InternetAddress internet ? internet.getAddress() : address.toString()); + } + String joined = String.join(", ", result); + return joined.length() > 5000 ? joined.substring(0, 5000) : joined; + } catch (MessagingException e) { + return null; + } + } + + record FilePart(String name, String contentType, byte[] bytes) { + } + + /** Parts with a file name (attachments and inline images), nested multiparts walked. */ + static List attachments(Part part) throws MessagingException, IOException { + List files = new ArrayList<>(); + collect(part, files); + return files; + } + + private static void collect(Part part, List files) throws MessagingException, IOException { + if (part.isMimeType("multipart/*") && part.getContent() instanceof Multipart multipart) { + for (int i = 0; i < multipart.getCount(); i++) { + collect(multipart.getBodyPart(i), files); + } + return; + } + String name = part.getFileName(); + if (name == null && !Part.ATTACHMENT.equalsIgnoreCase(part.getDisposition())) { + return; + } + String decoded = name == null ? "file" : MimeUtility.decodeText(name); + String type = part.getContentType() == null ? "application/octet-stream" : part.getContentType().split(";")[0].trim(); + try (InputStream in = part.getInputStream()) { + files.add(new FilePart(decoded, type.toLowerCase(java.util.Locale.ROOT), in.readAllBytes())); + } } /** Non-delivery report into the sender's inbox — only when the sender is our mailbox. */ @@ -101,7 +158,7 @@ public class LocalDelivery implements DeliveryWorker.BounceSender { } private static String find(Part part, String type) throws MessagingException, IOException { - if (Part.ATTACHMENT.equalsIgnoreCase(part.getDisposition())) { + if (Part.ATTACHMENT.equalsIgnoreCase(part.getDisposition()) || part.getFileName() != null) { return null; } if (part.isMimeType(type)) { diff --git a/backend/src/main/java/com/mailserver/service/MailService.java b/backend/src/main/java/com/mailserver/service/MailService.java index 732e1f6..8ccc081 100644 --- a/backend/src/main/java/com/mailserver/service/MailService.java +++ b/backend/src/main/java/com/mailserver/service/MailService.java @@ -6,6 +6,9 @@ import com.mailserver.domain.Folder; import com.mailserver.domain.Message; import com.mailserver.domain.User; import com.mailserver.dto.AttachmentResponse; +import com.mailserver.dto.DraftRequest; +import com.mailserver.dto.FolderResponse; +import com.mailserver.dto.MessageUpdateRequest; import com.mailserver.dto.MessageResponse; import com.mailserver.repository.AttachmentRepository; import com.mailserver.repository.ContactRepository; @@ -30,6 +33,8 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.time.Instant; import java.util.ArrayList; +import java.util.Comparator; +import java.util.Locale; import java.util.List; import java.util.Properties; import java.util.UUID; @@ -63,16 +68,114 @@ public class MailService { this.spamFilterService = spamFilterService; } + /** System folders in the order the web client shows them; the rest — own folders, by name. */ + public static final List SYSTEM_FOLDERS = List.of("INBOX", "Drafts", "Sent", "Spam", "Trash"); + + /** Messages per folder in one list — the web client sorts and pages them itself. */ + public static final int LIST_LIMIT = 1000; + + private static final int PREVIEW_CHARS = 200; + + // --- Folders --- + + public Flux folders(Long userId) { + return folderRepository.findByUserId(userId) + .flatMap(folder -> Mono.zip(messageRepository.countByFolderId(folder.getId()), + messageRepository.countUnreadByFolderId(folder.getId())) + .map(counts -> new FolderResponse(folder.getId(), folder.getName(), folder.isSystem(), + counts.getT1(), counts.getT2()))) + .sort(Comparator.comparingInt(MailService::folderOrder) + .thenComparing(folder -> folder.name().toLowerCase(Locale.ROOT))); + } + + private static int folderOrder(FolderResponse folder) { + int index = SYSTEM_FOLDERS.indexOf(folder.name()); + return folder.system() && index >= 0 ? index : SYSTEM_FOLDERS.size(); + } + + public Mono createFolder(Long userId, String rawName) { + String name = folderName(rawName); + return checkFolderNameFree(userId, name, null) + .then(Mono.defer(() -> { + Folder folder = new Folder(); + folder.setUserId(userId); + folder.setName(name); + folder.setCreatedAt(Instant.now()); + return folderRepository.save(folder); + })) + .map(folder -> new FolderResponse(folder.getId(), folder.getName(), false, 0, 0)); + } + + public Mono renameFolder(Long folderId, Long userId, String rawName) { + String name = folderName(rawName); + return ownFolder(folderId, userId) + .flatMap(folder -> checkFolderNameFree(userId, name, folder.getId()) + .then(Mono.defer(() -> { + folder.setName(name); + return folderRepository.save(folder); + }))) + .then(); + } + + /** An own folder: its messages go to the Trash, then the folder is removed. */ + public Mono deleteFolder(Long folderId, Long userId) { + return ownFolder(folderId, userId) + .flatMap(folder -> systemFolder(userId, "Trash") + .flatMap(trash -> messageRepository.moveAll(folder.getId(), trash.getId())) + .then(folderRepository.delete(folder))); + } + + /** Trash and Spam only: removes every message for good. */ + public Mono emptyFolder(Long folderId, Long userId) { + return ownedFolder(folderId, userId) + .filter(folder -> folder.isSystem() && ("Trash".equals(folder.getName()) || "Spam".equals(folder.getName()))) + .switchIfEmpty(Mono.error(badRequest("Only Trash and Spam can be emptied"))) + .flatMapMany(folder -> messageRepository.findByFolderId(folder.getId())) + .concatMap(message -> purge(message).thenReturn(1)) + .reduce(0, Integer::sum); + } + + private static String folderName(String rawName) { + String name = rawName == null ? "" : rawName.trim().replaceAll("\\s+", " "); + if (name.isEmpty() || name.length() > 50) { + throw badRequest("Folder name: 1 to 50 characters"); + } + if (SYSTEM_FOLDERS.stream().anyMatch(name::equalsIgnoreCase)) { + throw badRequest("This name belongs to a system folder"); + } + return name; + } + + private Mono checkFolderNameFree(Long userId, String name, Long exceptId) { + return folderRepository.findByUserId(userId) + .filter(folder -> folder.getName().equalsIgnoreCase(name) && !folder.getId().equals(exceptId)) + .hasElements() + .flatMap(taken -> taken ? Mono.error(badRequest("A folder with this name already exists")) : Mono.empty()); + } + + private Mono ownFolder(Long folderId, Long userId) { + return ownedFolder(folderId, userId) + .filter(folder -> !folder.isSystem()) + .switchIfEmpty(Mono.error(badRequest("System folders cannot be renamed or deleted"))); + } + + private Mono systemFolder(Long userId, String name) { + return folderRepository.findByUserIdAndName(userId, name) + .switchIfEmpty(Mono.error(new IllegalStateException(name + " folder not found"))); + } + + // --- Messages --- + /** Copy of a message we sent, in the sender's "Sent" folder. */ - public Mono saveSent(User fromUser, String to, String subject, String body, boolean html, String messageId) { - return folderRepository.findByUserIdAndName(fromUser.getId(), "Sent") - .switchIfEmpty(Mono.error(new IllegalStateException("Sent folder not found"))) + public Mono saveSent(User fromUser, String to, String cc, String subject, String body, boolean html, String messageId) { + return systemFolder(fromUser.getId(), "Sent") .flatMap(sentFolder -> { Message message = new Message(); message.setFolderId(sentFolder.getId()); message.setSenderEmail(fromUser.getEmail()); message.setSenderName(fromUser.getDisplayName()); message.setRecipientEmail(to); + message.setCcEmail(cc == null || cc.isBlank() ? null : cc); message.setSubject(subject); message.setBody(body); message.setHtml(html); @@ -81,59 +184,111 @@ public class MailService { message.setMessageId(messageId); message.setMessageSize((long) (subject.length() + body.length())); return messageRepository.save(message); - }) - .map(this::toResponse); + }); } - public Mono receiveMessage(String senderEmail, String senderName, String recipientEmail, + /** Creates or updates a draft of the user (only a message in their Drafts folder can be updated). */ + public Mono saveDraft(User user, Long draftId, DraftRequest request) { + Mono target = draftId == null + ? systemFolder(user.getId(), "Drafts").map(drafts -> { + Message message = new Message(); + message.setFolderId(drafts.getId()); + message.setRead(true); + message.setMessageId(UUID.randomUUID().toString()); + return message; + }) + : ownedDraft(draftId, user.getId()); + return target.flatMap(message -> { + String subject = request.subject() == null ? "" : request.subject(); + String body = request.body() == null ? "" : request.body(); + message.setSenderEmail(user.getEmail()); + message.setSenderName(user.getDisplayName()); + message.setRecipientEmail(request.to() == null ? "" : request.to().trim()); + message.setCcEmail(request.cc() == null || request.cc().isBlank() ? null : request.cc().trim()); + message.setSubject(subject); + message.setBody(body); + message.setHtml(request.html()); + message.setReceivedAt(Instant.now()); + message.setMessageSize((long) (subject.length() + body.length())); + return messageRepository.save(message); + }) + .flatMap(this::withAttachmentCount); + } + + /** The user's draft, or 404. */ + public Mono ownedDraft(Long draftId, Long userId) { + return ownedMessage(draftId, userId) + .filterWhen(message -> folderRepository.findById(message.getFolderId()) + .map(folder -> folder.isSystem() && "Drafts".equals(folder.getName()))) + .switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "Draft not found"))); + } + + /** After sending: the draft's attachments move to the copy in Sent, the draft itself is removed. */ + public Mono finishDraft(Long draftId, Long sentMessageId) { + return attachmentRepository.moveAll(draftId, sentMessageId) + .then(messageRepository.deleteById(draftId)); + } + + public Mono receiveMessage(String senderEmail, String senderName, String recipientEmail, String subject, String body, boolean isHtml) { return receiveMessage(senderEmail, senderName, recipientEmail, subject, body, isHtml, false); } - public Mono receiveMessage(String senderEmail, String senderName, String recipientEmail, + public Mono receiveMessage(String senderEmail, String senderName, String recipientEmail, String subject, String body, boolean isHtml, boolean externalSpamFlag) { - return userRepository.findByEmail(recipientEmail) - .switchIfEmpty(Mono.error(new IllegalArgumentException("Recipient not found: " + recipientEmail))) - .flatMap(recipient -> - folderRepository.findByUserIdAndName(recipient.getId(), "INBOX") - .switchIfEmpty(Mono.error(new IllegalStateException("INBOX not found"))) - .flatMap(inbox -> - spamFilterService.isBlacklisted(recipient.getId(), senderEmail) - .flatMap(isBlacklisted -> { - boolean isSpam = isBlacklisted || externalSpamFlag; - Message message = new Message(); - message.setFolderId(inbox.getId()); - message.setSenderEmail(senderEmail); - message.setSenderName(senderName); - message.setRecipientEmail(recipientEmail); - message.setSubject(subject); - message.setBody(body); - message.setHtml(isHtml); - message.setRead(false); - message.setSpam(isSpam); - message.setReceivedAt(Instant.now()); - message.setMessageId(UUID.randomUUID().toString()); - message.setMessageSize((long) (subject.length() + body.length())); - return messageRepository.save(message) - .then(folderRepository.save(inbox)); - }) - ) - ) - .then(); + return receiveMessage(senderEmail, senderName, recipientEmail, subject, body, isHtml, externalSpamFlag, null, null); } - public Flux getMessages(Long folderId, Long userId, int page, int size) { - PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "receivedAt")); + /** + * Stores an incoming message: into INBOX, or into Spam when it failed the checks or the sender is blacklisted. + * {@code toHeader}/{@code ccHeader}: addresses from the message headers (shown, used by "reply all"); + * without them — the mailbox itself. + */ + public Mono receiveMessage(String senderEmail, String senderName, String recipientEmail, + String subject, String body, boolean isHtml, boolean externalSpamFlag, + String toHeader, String ccHeader) { + return userRepository.findByEmail(recipientEmail) + .switchIfEmpty(Mono.error(new IllegalArgumentException("Recipient not found: " + recipientEmail))) + .flatMap(recipient -> spamFilterService.isBlacklisted(recipient.getId(), senderEmail) + .flatMap(blacklisted -> { + boolean isSpam = blacklisted || externalSpamFlag; + return systemFolder(recipient.getId(), isSpam ? "Spam" : "INBOX") + .flatMap(folder -> { + Message message = new Message(); + message.setFolderId(folder.getId()); + message.setSenderEmail(senderEmail); + message.setSenderName(senderName); + message.setRecipientEmail(toHeader == null || toHeader.isBlank() ? recipientEmail : toHeader); + message.setCcEmail(ccHeader == null || ccHeader.isBlank() ? null : ccHeader); + message.setSubject(subject); + message.setBody(body); + message.setHtml(isHtml); + message.setRead(false); + message.setSpam(isSpam); + message.setReceivedAt(Instant.now()); + message.setMessageId(UUID.randomUUID().toString()); + message.setMessageSize((long) (subject.length() + body.length())); + return messageRepository.save(message); + }); + })); + } + + /** Newest first; bodies cut to a preview — the full message comes from {@link #getMessage}. */ + public Flux listMessages(Long folderId, Long userId, int limit) { + int safeLimit = Math.max(1, Math.min(limit, LIST_LIMIT)); return ownedFolder(folderId, userId) - .flatMapMany(folder -> messageRepository.findByFolderId(folder.getId(), pageable)) - .map(this::toResponse); + .flatMapMany(folder -> messageRepository.findLatest(folder.getId(), safeLimit)) + .concatMap(this::withAttachmentCount) + .map(MailService::preview); } public Flux searchMessages(Long folderId, Long userId, String keyword, int page, int size) { PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "receivedAt")); return ownedFolder(folderId, userId) .flatMapMany(folder -> messageRepository.searchInFolder(folder.getId(), keyword, pageable)) - .map(this::toResponse); + .take(LIST_LIMIT) + .concatMap(this::withAttachmentCount) + .map(MailService::preview); } public Flux searchMessagesWithFilters(Long folderId, Long userId, @@ -143,23 +298,74 @@ public class MailService { PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "receivedAt")); return ownedFolder(folderId, userId) .flatMapMany(folder -> messageRepository.searchWithFilters(folder.getId(), subject, body, sender, recipient, pageable)) - .map(this::toResponse); + .take(LIST_LIMIT) + .concatMap(this::withAttachmentCount) + .map(MailService::preview); } public Mono getMessage(Long messageId, Long userId) { - return ownedMessage(messageId, userId).map(this::toResponse); + return ownedMessage(messageId, userId).flatMap(this::withAttachmentCount); } public Mono markAsRead(Long messageId, Long userId) { - return ownedMessage(messageId, userId) - .flatMap(message -> { - message.setRead(true); - return messageRepository.save(message).then(); - }); + return updateMessage(messageId, userId, new MessageUpdateRequest(true, null, null)).then(); } + /** Read/unread, flag, move to another folder of the user (moving into Spam marks it as spam, out of it — not). */ + public Mono updateMessage(Long messageId, Long userId, MessageUpdateRequest update) { + return ownedMessage(messageId, userId) + .flatMap(message -> { + if (update.read() != null) { + message.setRead(update.read()); + } + if (update.flagged() != null) { + message.setFlagged(update.flagged()); + } + if (update.folderId() == null || update.folderId().equals(message.getFolderId())) { + return messageRepository.save(message); + } + return ownedFolder(update.folderId(), userId).flatMap(target -> { + message.setFolderId(target.getId()); + message.setSpam(target.isSystem() && "Spam".equals(target.getName())); + return messageRepository.save(message); + }); + }) + .flatMap(this::withAttachmentCount); + } + + /** Into the Trash; a message already in the Trash is removed for good (with its attachment files). */ public Mono deleteMessage(Long messageId, Long userId) { - return ownedMessage(messageId, userId).flatMap(messageRepository::delete); + return ownedMessage(messageId, userId) + .flatMap(message -> folderRepository.findById(message.getFolderId()) + .flatMap(folder -> { + if (folder.isSystem() && "Trash".equals(folder.getName())) { + return purge(message); + } + return systemFolder(userId, "Trash").flatMap(trash -> { + message.setFolderId(trash.getId()); + return messageRepository.save(message).then(); + }); + })); + } + + private Mono purge(Message message) { + return attachmentRepository.findByMessageId(message.getId()) + .doOnNext(attachment -> deleteFile(attachment.getFilePath())) + .then(messageRepository.delete(message)); + } + + private static void deleteFile(String path) { + try { + Files.deleteIfExists(Paths.get(path)); + } catch (IOException e) { + log.warn("Attachment file {} not deleted: {}", path, e.getMessage()); + } + } + + /** Bytes used by the mailbox: message texts plus attachments. */ + public Mono usedBytes(Long userId) { + return Mono.zip(messageRepository.sumSizeByUserId(userId), attachmentRepository.sumSizeByUserId(userId)) + .map(sizes -> sizes.getT1() + sizes.getT2()); } /** The folder, when it belongs to the user; otherwise 404 — never someone else's mail. */ @@ -184,12 +390,40 @@ public class MailService { .switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "Attachment not found"))); } + private Mono withAttachmentCount(Message message) { + return attachmentRepository.countByMessageId(message.getId()) + .defaultIfEmpty(0L) + .map(count -> { + MessageResponse response = toResponse(message); + response.setAttachmentCount(count.intValue()); + response.setHasAttachments(count > 0); + return response; + }); + } + + private static MessageResponse preview(MessageResponse response) { + String body = response.getBody(); + if (body == null) { + return response; + } + String text = response.isHtml() ? body.replaceAll("(?s)|<[^>]+>", " ") : body; + text = text.replace(" ", " ").replaceAll("\\s+", " ").trim(); + response.setBody(text.length() > PREVIEW_CHARS ? text.substring(0, PREVIEW_CHARS) + "…" : text); + response.setHtml(false); + return response; + } + + static ResponseStatusException badRequest(String reason) { + return new ResponseStatusException(HttpStatus.BAD_REQUEST, reason); + } + private MessageResponse toResponse(Message message) { MessageResponse resp = new MessageResponse(); resp.setId(message.getId()); resp.setSenderEmail(message.getSenderEmail()); resp.setSenderName(message.getSenderName()); resp.setRecipientEmail(message.getRecipientEmail()); + resp.setCcEmail(message.getCcEmail()); resp.setSubject(message.getSubject()); resp.setBody(message.getBody()); resp.setHtml(message.isHtml()); @@ -202,10 +436,30 @@ public class MailService { return resp; } + /** Stores a file of an incoming message (blocking: called from the SMTP worker thread). */ + public void storeIncomingAttachment(Long messageId, String filename, String contentType, byte[] bytes) throws IOException { + String safeName = com.mailserver.controller.MailController.safeFilename(filename); + Path path = Paths.get(attachmentsDir, UUID.randomUUID() + "_" + safeName); + Files.createDirectories(path.getParent()); + Files.write(path, bytes); + Attachment attachment = new Attachment(); + attachment.setMessageId(messageId); + attachment.setFilename(safeName); + attachment.setContentType(contentType); + attachment.setSizeBytes((long) bytes.length); + attachment.setFilePath(path.toString()); + attachmentRepository.save(attachment).block(); + } + + /** Attachments of the user's draft, for building the message to send. */ + public Flux draftAttachments(Long draftId, Long userId) { + return ownedDraft(draftId, userId).flatMapMany(draft -> attachmentRepository.findByMessageId(draft.getId())); + } + public Mono saveAttachmentReactive(Long messageId, Long userId, String originalFilename, String contentType, long sizeBytes, String filePath) { - return ownedMessage(messageId, userId) + return ownedDraft(messageId, userId) .flatMap(message -> { Attachment attachment = new Attachment(); attachment.setMessageId(messageId); @@ -236,11 +490,7 @@ public class MailService { public Mono deleteAttachment(Long attachmentId, Long userId) { return ownedAttachment(attachmentId, userId) .flatMap(attachment -> { - try { - Files.deleteIfExists(Paths.get(attachment.getFilePath())); - } catch (IOException e) { - log.warn("Attachment file {} not deleted: {}", attachment.getFilePath(), e.getMessage()); - } + deleteFile(attachment.getFilePath()); return attachmentRepository.delete(attachment); }); } diff --git a/backend/src/main/java/com/mailserver/service/SubmissionService.java b/backend/src/main/java/com/mailserver/service/SubmissionService.java index 532260b..e37d133 100644 --- a/backend/src/main/java/com/mailserver/service/SubmissionService.java +++ b/backend/src/main/java/com/mailserver/service/SubmissionService.java @@ -1,32 +1,51 @@ package com.mailserver.service; +import com.mailserver.domain.Attachment; +import com.mailserver.domain.User; import com.mailserver.dto.MessageResponse; import com.mailserver.dto.SendRequest; import com.mailserver.repository.UserRepository; import com.mailserver.smtp.MailRouter; import com.mailserver.smtp.SmtpBackend; import jakarta.mail.Message; +import jakarta.mail.MessagingException; import jakarta.mail.Session; +import jakarta.mail.internet.AddressException; import jakarta.mail.internet.InternetAddress; +import jakarta.mail.internet.MimeBodyPart; import jakarta.mail.internet.MimeMessage; +import jakarta.mail.internet.MimeMultipart; +import jakarta.mail.internet.MimeUtility; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.web.server.ResponseStatusException; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; import java.util.Date; +import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Properties; +import java.util.Set; import java.util.UUID; -/** "Send" in the web client: builds the MIME message and routes it like an authenticated SMTP submission. */ +/** + * "Send" in the web client: builds the MIME message (with the draft's attachments) and routes it like an + * authenticated SMTP submission — to every To and Cc address. + */ @Service public class SubmissionService { + /** One message, at most this many addresses (To + Cc). */ + static final int MAX_RECIPIENTS = 50; + private final UserRepository users; private final MailService mailService; private final MailRouter router; @@ -41,34 +60,109 @@ public class SubmissionService { } public Mono send(String fromEmail, SendRequest request) { - String to = request.getTo().trim().toLowerCase(Locale.ROOT); + List to = addresses(request.getTo()); + List cc = addresses(request.getCc()); + cc.removeAll(to); + if (to.isEmpty()) { + return Mono.error(MailService.badRequest("No recipient")); + } + if (to.size() + cc.size() > MAX_RECIPIENTS) { + return Mono.error(MailService.badRequest("At most " + MAX_RECIPIENTS + " recipients")); + } + Long draftId = request.getDraftId(); return users.findByEmail(fromEmail) .switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED))) - .flatMap(user -> Mono.fromCallable(() -> { - SmtpBackend.Route route = router.route(to, user.getEmail()); - switch (route) { - case LOCAL, OUTBOUND -> { } - case UNKNOWN_USER -> throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "No such mailbox: " + to); - case OUTBOUND_DISABLED -> throw new ResponseStatusException(HttpStatus.BAD_REQUEST, - "Sending outside is disabled on this server"); - case RELAY_DENIED -> throw new IllegalStateException("logged-in user denied relay"); - } - String messageId = UUID.randomUUID() + "@" + hostname; - MimeMessage mime = new MimeMessage(Session.getInstance(new Properties())); - mime.setFrom(new InternetAddress(user.getEmail(), user.getDisplayName(), "UTF-8")); - mime.setRecipient(Message.RecipientType.TO, new InternetAddress(to)); - mime.setSubject(request.getSubject(), "UTF-8"); - mime.setText(request.getBody(), "UTF-8", request.isHtml() ? "html" : "plain"); - mime.setSentDate(new Date()); - mime.saveChanges(); - mime.setHeader("Message-ID", "<" + messageId + ">"); - ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - mime.writeTo(bytes); - router.accept(new SmtpBackend.Envelope("127.0.0.1", hostname, user.getEmail(), user.getEmail(), - List.of(new SmtpBackend.Recipient(to, route))), bytes.toByteArray()); - return messageId; - }).subscribeOn(Schedulers.boundedElastic()) - .flatMap(messageId -> mailService.saveSent(user, to, request.getSubject(), request.getBody(), - request.isHtml(), messageId))); + .flatMap(user -> (draftId == null ? Flux.empty() : mailService.draftAttachments(draftId, user.getId())) + .collectList() + .flatMap(attachments -> Mono.fromCallable(() -> submit(user, to, cc, request, attachments)) + .subscribeOn(Schedulers.boundedElastic())) + .flatMap(messageId -> mailService.saveSent(user, String.join(", ", to), String.join(", ", cc), + request.getSubject(), request.getBody(), request.isHtml(), messageId)) + .flatMap(sent -> (draftId == null ? Mono.empty() : mailService.finishDraft(draftId, sent.getId())) + .then(mailService.getMessage(sent.getId(), user.getId())))); } + + private String submit(User user, List to, List cc, SendRequest request, List attachments) + throws Exception { + List recipients = new ArrayList<>(); + for (String address : concat(to, cc)) { + SmtpBackend.Route route = router.route(address, user.getEmail()); + switch (route) { + case LOCAL, OUTBOUND -> recipients.add(new SmtpBackend.Recipient(address, route)); + case UNKNOWN_USER -> throw MailService.badRequest("No such mailbox: " + address); + case OUTBOUND_DISABLED -> throw MailService.badRequest("Sending outside is disabled on this server: " + address); + case RELAY_DENIED -> throw new IllegalStateException("logged-in user denied relay"); + } + } + String messageId = UUID.randomUUID() + "@" + hostname; + MimeMessage mime = mime(user, to, cc, request, attachments); + mime.setHeader("Message-ID", "<" + messageId + ">"); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + mime.writeTo(bytes); + router.accept(new SmtpBackend.Envelope("127.0.0.1", hostname, user.getEmail(), user.getEmail(), recipients), + bytes.toByteArray()); + return messageId; + } + + static MimeMessage mime(User user, List to, List cc, SendRequest request, List attachments) + throws MessagingException, IOException { + MimeMessage mime = new MimeMessage(Session.getInstance(new Properties())); + mime.setFrom(new InternetAddress(user.getEmail(), user.getDisplayName(), "UTF-8")); + mime.setRecipients(Message.RecipientType.TO, InternetAddress.parse(String.join(",", to))); + if (!cc.isEmpty()) { + mime.setRecipients(Message.RecipientType.CC, InternetAddress.parse(String.join(",", cc))); + } + mime.setSubject(request.getSubject(), "UTF-8"); + String subtype = request.isHtml() ? "html" : "plain"; + if (attachments.isEmpty()) { + mime.setText(request.getBody(), "UTF-8", subtype); + } else { + MimeMultipart mixed = new MimeMultipart("mixed"); + MimeBodyPart text = new MimeBodyPart(); + text.setText(request.getBody(), "UTF-8", subtype); + mixed.addBodyPart(text); + for (Attachment attachment : attachments) { + MimeBodyPart file = new MimeBodyPart(); + file.attachFile(new File(attachment.getFilePath()), + attachment.getContentType() == null ? "application/octet-stream" : attachment.getContentType(), null); + file.setFileName(MimeUtility.encodeText(attachment.getFilename(), "UTF-8", null)); + mixed.addBodyPart(file); + } + mime.setContent(mixed); + } + mime.setSentDate(new Date()); + mime.saveChanges(); + return mime; + } + + /** "a@x, b@y; c@z" → lower-case addresses without repeats; a malformed one → 400. */ + static List addresses(String raw) { + Set result = new LinkedHashSet<>(); + if (raw == null) { + return new ArrayList<>(); + } + for (String part : raw.split("[,;\\s]+")) { + String address = part.trim().toLowerCase(Locale.ROOT); + if (address.isEmpty()) { + continue; + } + try { + new InternetAddress(address, true).validate(); + } catch (AddressException e) { + throw MailService.badRequest("Invalid address: " + address); + } + if (!address.contains("@")) { + throw MailService.badRequest("Invalid address: " + address); + } + result.add(address); + } + return new ArrayList<>(result); + } + + private static List concat(List a, List b) { + List all = new ArrayList<>(a); + all.addAll(b); + return all; + } + } diff --git a/backend/src/main/java/com/mailserver/service/UserService.java b/backend/src/main/java/com/mailserver/service/UserService.java index b4c6bbb..b5540d4 100644 --- a/backend/src/main/java/com/mailserver/service/UserService.java +++ b/backend/src/main/java/com/mailserver/service/UserService.java @@ -2,7 +2,6 @@ package com.mailserver.service; import com.mailserver.domain.Folder; import com.mailserver.domain.User; -import com.mailserver.dto.FolderResponse; import com.mailserver.repository.FolderRepository; import com.mailserver.repository.UserRepository; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; @@ -35,18 +34,18 @@ public class UserService { User user = new User(); user.setEmail(email); user.setPassword(passwordEncoder.encode(password)); - user.setDisplayName(displayName); + user.setDisplayName(displayName == null || displayName.isBlank() ? email.substring(0, email.indexOf('@')) : displayName.trim()); user.setCreatedAt(Instant.now()); return userRepository.save(user); }) .flatMap(user -> { // Создаём стандартные папки - String[] defaultFolders = {"INBOX", "Sent", "Drafts", "Trash", "Spam"}; - return Flux.fromArray(defaultFolders) - .flatMap(name -> { + return Flux.fromIterable(MailService.SYSTEM_FOLDERS) + .concatMap(name -> { Folder folder = new Folder(); folder.setUserId(user.getId()); folder.setName(name); + folder.setSystem(true); folder.setCreatedAt(Instant.now()); return folderRepository.save(folder); }) @@ -66,15 +65,17 @@ public class UserService { return passwordEncoder.matches(rawPassword, user.getPassword()); } - public Flux getFolders(Long userId) { - return folderRepository.findByUserId(userId) - .map(f -> { - FolderResponse resp = new FolderResponse(); - resp.setId(f.getId()); - resp.setName(f.getName()); - resp.setMessageCount(f.getMessageCount()); - resp.setUnreadCount(f.getUnreadCount()); - return resp; - }); + /** New password, only with the current one. */ + public Mono changePassword(User user, String currentPassword, String newPassword) { + if (!checkPassword(user, currentPassword)) { + return Mono.error(MailService.badRequest("Current password is wrong")); + } + user.setPassword(passwordEncoder.encode(newPassword)); + return userRepository.save(user).then(); + } + + public Mono updateDisplayName(User user, String displayName) { + user.setDisplayName(displayName.trim()); + return userRepository.save(user); } } diff --git a/backend/src/main/resources/db/migration/V3__mail_cabinet.sql b/backend/src/main/resources/db/migration/V3__mail_cabinet.sql new file mode 100644 index 0000000..0f69347 --- /dev/null +++ b/backend/src/main/resources/db/migration/V3__mail_cabinet.sql @@ -0,0 +1,10 @@ +-- Mail cabinet: several recipients and copy, system vs own folders +ALTER TABLE messages ALTER COLUMN recipient_email TYPE TEXT; +ALTER TABLE messages ADD COLUMN cc_email TEXT; + +ALTER TABLE folders ADD COLUMN is_system BOOLEAN NOT NULL DEFAULT FALSE; +UPDATE folders SET is_system = TRUE WHERE name IN ('INBOX', 'Sent', 'Drafts', 'Trash', 'Spam'); +CREATE UNIQUE INDEX idx_folders_user_name ON folders(user_id, LOWER(name)); + +-- the attachments entity always had content_id (inline images, cid:) — the column was missing +ALTER TABLE attachments ADD COLUMN content_id VARCHAR(255); diff --git a/backend/src/test/java/com/mailserver/service/SubmissionServiceTest.java b/backend/src/test/java/com/mailserver/service/SubmissionServiceTest.java new file mode 100644 index 0000000..8993419 --- /dev/null +++ b/backend/src/test/java/com/mailserver/service/SubmissionServiceTest.java @@ -0,0 +1,84 @@ +package com.mailserver.service; + +import com.mailserver.domain.Attachment; +import com.mailserver.domain.User; +import com.mailserver.dto.SendRequest; +import jakarta.mail.Message; +import jakarta.mail.Session; +import jakarta.mail.internet.MimeMessage; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.web.server.ResponseStatusException; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Properties; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SubmissionServiceTest { + + @Test + void addressesAreSplitLowercasedAndDeduplicated() { + assertThat(SubmissionService.addresses(" Bob@Mail.test, alice@mail.test;bob@mail.test carol@x.org ")) + .containsExactly("bob@mail.test", "alice@mail.test", "carol@x.org"); + assertThat(SubmissionService.addresses(null)).isEmpty(); + assertThat(SubmissionService.addresses(" ")).isEmpty(); + } + + @Test + void malformedAddressIsRejected() { + assertThatThrownBy(() -> SubmissionService.addresses("bob@mail.test, not-an-address")) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("not-an-address"); + } + + @Test + void attachmentsGoIntoTheMessageAndComeBackOutOnDelivery(@TempDir Path dir) throws Exception { + Path file = dir.resolve("report.pdf"); + Files.writeString(file, "%PDF-1.4 test"); + Attachment attachment = new Attachment(); + attachment.setFilename("Отчёт 2026.pdf"); + attachment.setContentType("application/pdf"); + attachment.setFilePath(file.toString()); + + User user = new User(); + user.setEmail("admin@mail.test"); + user.setDisplayName("Admin"); + SendRequest request = new SendRequest(); + request.setSubject("Report"); + request.setBody("See the file"); + + MimeMessage mime = SubmissionService.mime(user, List.of("bob@mail.test"), List.of("carol@mail.test"), request, + List.of(attachment)); + ByteArrayOutputStream raw = new ByteArrayOutputStream(); + mime.writeTo(raw); + + MimeMessage parsed = new MimeMessage(Session.getInstance(new Properties()), new ByteArrayInputStream(raw.toByteArray())); + assertThat(parsed.getRecipients(Message.RecipientType.CC)[0].toString()).isEqualTo("carol@mail.test"); + assertThat(LocalDelivery.addresses(parsed, Message.RecipientType.TO)).isEqualTo("bob@mail.test"); + assertThat(LocalDelivery.addresses(parsed, Message.RecipientType.CC)).isEqualTo("carol@mail.test"); + assertThat(LocalDelivery.body(parsed).text()).isEqualTo("See the file"); + List files = LocalDelivery.attachments(parsed); + assertThat(files).hasSize(1); + assertThat(files.get(0).name()).isEqualTo("Отчёт 2026.pdf"); + assertThat(files.get(0).contentType()).isEqualTo("application/pdf"); + assertThat(new String(files.get(0).bytes(), StandardCharsets.UTF_8)).isEqualTo("%PDF-1.4 test"); + } + + @Test + void plainMessageHasNoAttachments() throws Exception { + User user = new User(); + user.setEmail("admin@mail.test"); + SendRequest request = new SendRequest(); + request.setBody("Hi"); + MimeMessage mime = SubmissionService.mime(user, List.of("bob@mail.test"), List.of(), request, List.of()); + assertThat(LocalDelivery.attachments(mime)).isEmpty(); + assertThat(LocalDelivery.body(mime).text()).isEqualTo("Hi"); + } +} diff --git a/docker-compose.jambotron.yml b/docker-compose.jambotron.yml index b60db51..4c49ffb 100644 --- a/docker-compose.jambotron.yml +++ b/docker-compose.jambotron.yml @@ -62,6 +62,7 @@ services: MAIL_HOSTNAME: ${MAIL_HOSTNAME:-mail.test} DKIM_DOMAIN: ${MAIL_DOMAINS:-mail.test} MAIL_CATCH_ALL: ${MAIL_CATCH_ALL:-} + MAIL_REGISTRATION: ${MAIL_REGISTRATION:-true} # test stand: anyone can create a mailbox in MAIL_DOMAINS (nothing goes outside) OUTBOUND_MODE: "off" SMTP_AUTH_WITHOUT_TLS: "true" # SMTP is not reachable from outside the VM SPF_ENABLED: "false" diff --git a/frontend/.npmrc b/frontend/.npmrc new file mode 100644 index 0000000..8aa0078 --- /dev/null +++ b/frontend/.npmrc @@ -0,0 +1 @@ +@jambotron:registry=https://gitea.jambotron.com/api/packages/jambotron_gang/npm/ diff --git a/frontend/angular.json b/frontend/angular.json index ba56fdc..25e585b 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -50,6 +50,7 @@ } ], "styles": [ + "src/material.scss", "src/styles.css" ] }, @@ -58,13 +59,13 @@ "budgets": [ { "type": "initial", - "maximumWarning": "500kB", - "maximumError": "1MB" + "maximumWarning": "1MB", + "maximumError": "2MB" }, { "type": "anyComponentStyle", - "maximumWarning": "4kB", - "maximumError": "8kB" + "maximumWarning": "8kB", + "maximumError": "16kB" } ], "outputHashing": "all" @@ -95,4 +96,4 @@ } } } -} +} \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index e9be16d..01198e6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,12 +11,16 @@ "private": true, "packageManager": "npm@11.17.0", "dependencies": { + "@angular/aria": "^22.2.6", + "@angular/cdk": "^22.2.6", "@angular/common": "^22.2.0", "@angular/compiler": "^22.2.0", "@angular/core": "^22.2.0", "@angular/forms": "^22.2.0", + "@angular/material": "^22.2.6", "@angular/platform-browser": "^22.2.0", "@angular/router": "^22.2.0", + "@jambotron/aria-grid": "0.1.0", "rxjs": "~7.8.0", "tslib": "^2.3.0" }, diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts index 4574655..d56c460 100644 --- a/frontend/src/app/app.config.ts +++ b/frontend/src/app/app.config.ts @@ -1,13 +1,15 @@ import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; -import { provideRouter, withHashLocation } from '@angular/router'; +import { provideRouter, withComponentInputBinding } from '@angular/router'; import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { routes } from './app.routes'; -import { authInterceptor } from './interceptors/auth.interceptor'; +import { authInterceptor } from './core/auth'; +import { provideMailGrid } from './ui/grid'; export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), - provideRouter(routes), - provideHttpClient(withInterceptors([authInterceptor])) + provideRouter(routes, withComponentInputBinding()), + provideHttpClient(withInterceptors([authInterceptor])), + provideMailGrid() ] }; diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index f365f09..8e926e4 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -1,43 +1,23 @@ import { Routes } from '@angular/router'; +import { authGuard, guestGuard } from './core/auth'; export const routes: Routes = [ - { - path: 'login', - loadComponent: () => import('./components/login/login.component'), - }, - { - path: 'register', - loadComponent: () => import('./components/register/register.component'), - }, + { path: 'login', canActivate: [guestGuard], loadComponent: () => import('./pages/auth/login.page').then(m => m.LoginPage) }, + { path: 'register', canActivate: [guestGuard], loadComponent: () => import('./pages/auth/register.page').then(m => m.RegisterPage) }, { path: '', - loadComponent: () => import('./components/layout/layout.component'), + canActivate: [authGuard], + loadComponent: () => import('./pages/shell/shell.component').then(m => m.ShellComponent), 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: 'contacts', - loadComponent: () => import('./components/contacts/contacts.component'), - }, - { - path: '', - redirectTo: 'inbox', - pathMatch: 'full', - }, - ], - }, - { - path: '**', - redirectTo: 'login', + { path: '', pathMatch: 'full', loadComponent: () => import('./pages/inbox-redirect').then(m => m.InboxRedirect) }, + { path: 'folder/:id', loadComponent: () => import('./pages/folder/folder.page').then(m => m.FolderPage) }, + { path: 'message/:id', loadComponent: () => import('./pages/message/message.page').then(m => m.MessagePage) }, + { path: 'compose', loadComponent: () => import('./pages/compose/compose.page').then(m => m.ComposePage) }, + { path: 'contacts', loadComponent: () => import('./pages/contacts/contacts.page').then(m => m.ContactsPage) }, + { path: 'blocked', loadComponent: () => import('./pages/blocked/blocked.page').then(m => m.BlockedPage) }, + { path: 'settings', loadComponent: () => import('./pages/settings/settings.page').then(m => m.SettingsPage) } + ] }, + { path: '**', redirectTo: '' } ]; + diff --git a/frontend/src/app/app.ts b/frontend/src/app/app.ts index f3b2652..8ec3185 100644 --- a/frontend/src/app/app.ts +++ b/frontend/src/app/app.ts @@ -1,10 +1,14 @@ -import { Component } from '@angular/core'; +import { Component, inject } from '@angular/core'; import { RouterOutlet } from '@angular/router'; +import { OverlaysComponent } from './ui/overlays.component'; +import { ThemeService } from './ui/theme.service'; @Component({ - imports: [RouterOutlet], + imports: [RouterOutlet, OverlaysComponent], selector: 'app-root', - template: ``, - styles: `` + template: `` }) -export class App {} +export class App { + // applies the remembered palette before the first page renders + private theme = inject(ThemeService); +} diff --git a/frontend/src/app/components/compose/compose.component.ts b/frontend/src/app/components/compose/compose.component.ts deleted file mode 100644 index 70f8b1c..0000000 --- a/frontend/src/app/components/compose/compose.component.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { Component, inject } from '@angular/core'; -import { RouterLink } from '@angular/router'; -import { FormsModule } from '@angular/forms'; -import { MailService } from '../../services/mail.service'; -import { Router, ActivatedRoute } from '@angular/router'; - -@Component({ - selector: 'app-compose', - standalone: true, - imports: [RouterLink, FormsModule], - providers: [MailService], - template: ` -
-

- COMPOSE MESSAGE -

- - @if (success) { -
- Message sent successfully! -
- } - -
-
- - -
- -
- - -
- -
-
- - -
- - @if (isHtml) { -

Supports Markdown: **bold**, *italic*, - lists,  code 

- } -
- -
- - - CANCEL - -
-
-
- `, - styles: `` -}) -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, 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: () => { - this.sending = false; - } - }); - } -} diff --git a/frontend/src/app/components/contacts/contacts.component.html b/frontend/src/app/components/contacts/contacts.component.html deleted file mode 100644 index 5979e9a..0000000 --- a/frontend/src/app/components/contacts/contacts.component.html +++ /dev/null @@ -1,102 +0,0 @@ -
-

- 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) { -
- @if (contact.company) { - {{ contact.company }} - } - @if (contact.company && contact.phone) { -  •  - } - @if (contact.phone) { - {{ contact.phone }} - } -
- } -
-
- - -
-
- } -
- } -
-
diff --git a/frontend/src/app/components/contacts/contacts.component.ts b/frontend/src/app/components/contacts/contacts.component.ts deleted file mode 100644 index 1ee36ce..0000000 --- a/frontend/src/app/components/contacts/contacts.component.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { Component, inject, signal } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MailService } from '../../services/mail.service'; -import { Contact } from '../../models/mail.models'; - -@Component({ - selector: 'app-contacts', - standalone: true, - imports: [FormsModule], - 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 deleted file mode 100644 index c714cbf..0000000 --- a/frontend/src/app/components/inbox/inbox.component.ts +++ /dev/null @@ -1,196 +0,0 @@ -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: ` -
- -
-
- - -
-
- - - - - - @if (totalPages() > 1) { -
- - @for (p of pageNumbers; track p) { - - } - - - Page {{ page() + 1 }} of {{ totalPages() }} - -
- } -
- `, - styles: `` -}) -export default class InboxComponent { - private route = inject(ActivatedRoute); - private mailService = inject(MailService); - messages = signal([]); - folderName = signal('INBOX'); - searchQuery = ''; - folderId = signal(1); - page = signal(0); - totalPages = signal(1); - pageSize = 20; - - 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(); - 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, 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 deleted file mode 100644 index 662cab4..0000000 --- a/frontend/src/app/components/layout/layout.component.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { Component, inject, signal, effect } 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'; - -const THEMES = [ - { value: 'theme-cyber-magenta', label: '💖 Cyber Magenta' }, - { value: 'theme-neon-cyan', label: '🩵 Neon Cyan' }, - { value: 'theme-sunset-orange', label: '🧡 Sunset Orange' }, - { value: 'theme-matrix-green', label: '💚 Matrix Green' }, -]; - -@Component({ - selector: 'app-layout', - standalone: true, - imports: [RouterOutlet, RouterLink, RouterLinkActive], - template: ` -
- - - - -
- -
-
- - -
-
- M -
-
-

- MAILSERVER -

-

Synthwave Edition

-
-
- - -
-
- - SMTP: ONLINE -
- | -
User: {{ email }}
- | -
Folders: {{ folders().length }}
-
- - -
- -
- -
- - - - - - - - - -
-
-
- - -
- - - - -
- -
-
- - -
-
-
MAILSERVER SYNTHWAVE © 2026
-
- A11Y READY - HIGH PERF CSS3D -
-
-
-
-
- `, - styles: `` -}) -export default class LayoutComponent { - authService = inject(AuthService); - mailService = inject(MailService); - - email = this.authService.getEmail() || ''; - folders = signal([]); - isLightTheme = signal(false); - isHighContrast = signal(false); - currentTheme = signal('theme-cyber-magenta'); - themes = THEMES; - - constructor() { - this.loadFolders(); - // Загружаем сохранённые настройки - const savedTheme = localStorage.getItem('theme'); - if (savedTheme === 'light') { - this.isLightTheme.set(true); - } - const savedPalette = localStorage.getItem('palette'); - if (savedPalette) { - this.currentTheme.set(savedPalette); - } - const savedContrast = localStorage.getItem('highContrast'); - if (savedContrast === 'true') { - this.isHighContrast.set(true); - } - - // Применяем тему при изменении - effect(() => { - const root = document.documentElement; - // Удаляем все theme классы - THEMES.forEach(t => root.classList.remove(t.value)); - root.classList.add(this.currentTheme()); - }); - } - - onThemeChange(theme: string): void { - this.currentTheme.set(theme); - localStorage.setItem('palette', 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'); - } - } - - toggleHighContrast(): void { - const hc = !this.isHighContrast(); - this.isHighContrast.set(hc); - if (hc) { - document.documentElement.classList.add('high-contrast'); - localStorage.setItem('highContrast', 'true'); - } else { - document.documentElement.classList.remove('high-contrast'); - localStorage.setItem('highContrast', 'false'); - } - } - - 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'; - } -} diff --git a/frontend/src/app/components/login/login.component.ts b/frontend/src/app/components/login/login.component.ts deleted file mode 100644 index cf3d596..0000000 --- a/frontend/src/app/components/login/login.component.ts +++ /dev/null @@ -1,98 +0,0 @@ -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: ` -
- - - - -
-
-
-
- M -
-

- MAILSERVER -

-

Synthwave Edition

-
- - @if (error) { -
- {{ error }} -
- } - -
-
- - -
- -
- - -
- - -
- -

- No account? - - Register here - -

-
-
-
- `, - 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; - } - }); - } -} diff --git a/frontend/src/app/components/message-detail/message-detail.component.ts b/frontend/src/app/components/message-detail/message-detail.component.ts deleted file mode 100644 index 8bc2a02..0000000 --- a/frontend/src/app/components/message-detail/message-detail.component.ts +++ /dev/null @@ -1,120 +0,0 @@ -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) { -
- -
-

- {{ message.subject || '(No subject)' }} -

- -
-
- FROM: - {{ message.senderName || message.sender }} -
-
- DATE: - {{ message.sentAt | date:'MMM d, y, HH:mm' }} -
-
- - @if (message.spam) { -
- MARKED AS SPAM -
- } -
- - -
-

- {{ message.body }} -

-
- - - @if (message.hasAttachments) { -
-

- ATTACHMENTS ({{ message.attachmentCount }}) -

-

- Attachments are available via the API -

-
- } - - - -
- } @else { -
- -

Message not found

- - ← BACK TO INBOX - -
- } - `, - 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') - }); - } - } -} diff --git a/frontend/src/app/components/register/register.component.ts b/frontend/src/app/components/register/register.component.ts deleted file mode 100644 index 39b0a30..0000000 --- a/frontend/src/app/components/register/register.component.ts +++ /dev/null @@ -1,121 +0,0 @@ -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: ` -
- - - - -
-
-
-
- M -
-

- MAILSERVER -

-

Create your mailbox

-
- - @if (error) { -
- {{ error }} -
- } - @if (success) { -
- Account created! Redirecting to login... -
- } - -
-
- - -
- -
- - -
- -
- - -
- - -
- -

- Already have an account? - - Login here - -

-
-
-
- `, - 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; - } - }); - } -} diff --git a/frontend/src/app/core/api.service.ts b/frontend/src/app/core/api.service.ts new file mode 100644 index 0000000..427790e --- /dev/null +++ b/frontend/src/app/core/api.service.ts @@ -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 { return this.http.get('/api/config'); } + + // profile + me(): Observable { return this.http.get('/api/me'); } + updateMe(displayName: string): Observable { return this.http.put('/api/me', { displayName }); } + changePassword(currentPassword: string, newPassword: string): Observable { + return this.http.put('/api/me/password', { currentPassword, newPassword }); + } + + // folders + folders(): Observable { return this.http.get('/api/folders'); } + createFolder(name: string): Observable { return this.http.post('/api/folders', { name }); } + renameFolder(id: number, name: string): Observable { return this.http.put(`/api/folders/${id}`, { name }); } + deleteFolder(id: number): Observable { 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 { + return this.http.get('/api/messages', { params: { folderId } }); + } + search(folderId: number, keyword: string): Observable { + return this.http.get('/api/messages/search', { params: { folderId, keyword } }); + } + message(id: number): Observable { return this.http.get(`/api/messages/${id}`); } + updateMessage(id: number, change: { read?: boolean; flagged?: boolean; folderId?: number }): Observable { + return this.http.patch(`/api/messages/${id}`, change); + } + deleteMessage(id: number): Observable { return this.http.delete(`/api/messages/${id}`); } + + // compose + createDraft(draft: Draft): Observable { return this.http.post('/api/drafts', draft); } + updateDraft(id: number, draft: Draft): Observable { return this.http.put(`/api/drafts/${id}`, draft); } + send(draft: Draft, draftId: number | null): Observable { + return this.http.post('/api/send', { ...draft, draftId }); + } + + // attachments + attachments(messageId: number): Observable { + return this.http.get(`/api/messages/${messageId}/attachments`); + } + upload(messageId: number, file: File): Observable { + const form = new FormData(); + form.append('file', file, file.name); + return this.http.post(`/api/messages/${messageId}/attachments`, form); + } + deleteAttachment(id: number): Observable { return this.http.delete(`/api/attachments/${id}`); } + download(id: number): Observable { + return this.http.get(`/api/attachments/${id}/download`, { responseType: 'blob' }); + } + + // contacts + contacts(): Observable { return this.http.get('/api/contacts'); } + addContact(contact: Contact): Observable { return this.http.post('/api/contacts', contact); } + updateContact(id: number, contact: Contact): Observable { return this.http.put(`/api/contacts/${id}`, contact); } + deleteContact(id: number): Observable { return this.http.delete(`/api/contacts/${id}`); } + + // blacklist + blacklist(): Observable { return this.http.get('/api/spam/blacklist'); } + block(senderEmail: string, reason: string): Observable { + return this.http.post('/api/spam/blacklist', { senderEmail, reason }); + } + unblock(senderEmail: string): Observable { + return this.http.delete('/api/spam/blacklist', { params: { senderEmail } }); + } +} diff --git a/frontend/src/app/core/auth.service.ts b/frontend/src/app/core/auth.service.ts new file mode 100644 index 0000000..9945d35 --- /dev/null +++ b/frontend/src/app/core/auth.service.ts @@ -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(read('email')); + readonly loggedIn = computed(() => this.email() !== null && read('token') !== null); + + login(email: string, password: string): Observable { + return this.http.post('/api/login', { email, password }).pipe(tap(r => this.store(r))); + } + + register(email: string, password: string, displayName: string): Observable { + return this.http.post('/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; } +} diff --git a/frontend/src/app/core/auth.ts b/frontend/src/app/core/auth.ts new file mode 100644 index 0000000..251fd3a --- /dev/null +++ b/frontend/src/app/core/auth.ts @@ -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; diff --git a/frontend/src/app/core/mail-state.service.ts b/frontend/src/app/core/mail-state.service.ts new file mode 100644 index 0000000..779d557 --- /dev/null +++ b/frontend/src/app/core/mail-state.service.ts @@ -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([]); + readonly profile = signal(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); + } +} diff --git a/frontend/src/app/core/models.ts b/frontend/src/app/core/models.ts new file mode 100644 index 0000000..ebc4126 --- /dev/null +++ b/frontend/src/app/core/models.ts @@ -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 = { + 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'; +} diff --git a/frontend/src/app/interceptors/auth.interceptor.ts b/frontend/src/app/interceptors/auth.interceptor.ts deleted file mode 100644 index 3300264..0000000 --- a/frontend/src/app/interceptors/auth.interceptor.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { HttpErrorResponse, HttpInterceptorFn, HttpRequest, HttpHandlerFn } from '@angular/common/http'; -import { inject } from '@angular/core'; -import { Router } from '@angular/router'; -import { catchError, throwError } from 'rxjs'; - -export const authInterceptor: HttpInterceptorFn = (req: HttpRequest, next: HttpHandlerFn) => { - const router = inject(Router); - const token = localStorage.getItem('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')) { - localStorage.removeItem('token'); - localStorage.removeItem('email'); - router.navigate(['/login']); - } - return throwError(() => error); - }) - ); -}; diff --git a/frontend/src/app/models/mail.models.ts b/frontend/src/app/models/mail.models.ts deleted file mode 100644 index 5026504..0000000 --- a/frontend/src/app/models/mail.models.ts +++ /dev/null @@ -1,73 +0,0 @@ -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; - html?: boolean; -} - -export interface BlacklistEntry { - senderEmail: string; - reason?: string; -} - -export interface Contact { - id?: number; - name: string; - email: string; - phone?: string; - company?: string; -} - -export interface PageResponse { - content: T[]; - number: number; - size: number; - totalElements: number; - totalPages: number; -} diff --git a/frontend/src/app/pages/auth/auth-frame.component.ts b/frontend/src/app/pages/auth/auth-frame.component.ts new file mode 100644 index 0000000..a6052a0 --- /dev/null +++ b/frontend/src/app/pages/auth/auth-frame.component.ts @@ -0,0 +1,35 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { BackgroundComponent } from '../../ui/background.component'; + +/** Logo + a glass card over the synthwave background — the login and registration pages. */ +@Component({ + selector: 'app-auth-frame', + imports: [BackgroundComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + +
+
+
+
M
+
+

+ SYNTH MAIL +

+

Reactive mail server

+
+
+
+

{{ title() }}

+

{{ subtitle() }}

+ +
+
+
+ ` +}) +export class AuthFrameComponent { + readonly title = input.required(); + readonly subtitle = input(''); +} diff --git a/frontend/src/app/pages/auth/login.page.ts b/frontend/src/app/pages/auth/login.page.ts new file mode 100644 index 0000000..539df4d --- /dev/null +++ b/frontend/src/app/pages/auth/login.page.ts @@ -0,0 +1,80 @@ +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Router, RouterLink } from '@angular/router'; +import { MatIconModule } from '@angular/material/icon'; +import { ApiService } from '../../core/api.service'; +import { AuthService } from '../../core/auth.service'; +import { errorText } from '../../core/models'; +import { AuthFrameComponent } from './auth-frame.component'; + +@Component({ + selector: 'app-login', + imports: [FormsModule, RouterLink, MatIconModule, AuthFrameComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + +
+
+ + +
+
+ +
+ + +
+
+ @if (error()) { + + } + +
+ @if (registration()) { +

+ No mailbox yet? Create one +

+ } +
+ ` +}) +export class LoginPage { + private auth = inject(AuthService); + private router = inject(Router); + + email = ''; + password = ''; + readonly show = signal(false); + readonly busy = signal(false); + readonly error = signal(''); + readonly registration = signal(false); + + constructor() { + inject(ApiService).config().subscribe({ next: c => this.registration.set(c.registrationEnabled), error: () => {} }); + } + + submit(): void { + if (!this.email.trim() || !this.password) { + this.error.set('Enter the email and the password'); + return; + } + this.busy.set(true); + this.error.set(''); + this.auth.login(this.email.trim(), this.password).subscribe({ + next: () => this.router.navigate(['/']), + error: e => { + this.busy.set(false); + this.error.set(errorText(e) === 'Invalid credentials' ? 'Wrong email or password' : errorText(e)); + } + }); + } +} diff --git a/frontend/src/app/pages/auth/register.page.ts b/frontend/src/app/pages/auth/register.page.ts new file mode 100644 index 0000000..d121f9b --- /dev/null +++ b/frontend/src/app/pages/auth/register.page.ts @@ -0,0 +1,119 @@ +import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Router, RouterLink } from '@angular/router'; +import { MatIconModule } from '@angular/material/icon'; +import { ApiService } from '../../core/api.service'; +import { AuthService } from '../../core/auth.service'; +import { errorText } from '../../core/models'; +import { AuthFrameComponent } from './auth-frame.component'; + +const NAME = /^[a-z0-9]([a-z0-9._-]{0,62}[a-z0-9])?$/; + +@Component({ + selector: 'app-register', + imports: [FormsModule, RouterLink, MatIconModule, AuthFrameComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + @if (!loaded()) { +

Loading…

+ } @else if (!enabled()) { +

Registration is closed on this server. Ask the administrator for a mailbox.

+ arrow_back BACK TO LOGIN + } @else { +
+
+ +
+ + @ + @if (domains().length > 1) { + + } @else { + + {{ domain() }} + + } +
+

+ Letters, digits, dot, dash, underscore +

+
+
+ + +
+
+ + +
+
+ + +
+ @if (error()) { + + } + +
+

+ Have a mailbox? Log in +

+ } +
+ ` +}) +export class RegisterPage { + private auth = inject(AuthService); + private router = inject(Router); + + readonly loaded = signal(false); + readonly enabled = signal(false); + readonly domains = signal([]); + readonly domain = signal(''); + readonly local = signal(''); + readonly password = signal(''); + readonly password2 = signal(''); + displayName = ''; + readonly busy = signal(false); + readonly error = signal(''); + readonly localValid = computed(() => NAME.test(this.local())); + + constructor() { + inject(ApiService).config().subscribe({ + next: c => { + this.enabled.set(c.registrationEnabled); + this.domains.set(c.domains); + this.domain.set(c.domains[0] ?? ''); + this.loaded.set(true); + }, + error: e => { this.loaded.set(true); this.error.set(errorText(e)); } + }); + } + + submit(): void { + const problem = !this.localValid() ? 'The address: letters, digits, dot, dash, underscore' + : this.password().length < 8 ? 'The password is shorter than 8 characters' + : this.password() !== this.password2() ? 'The passwords do not match' : ''; + if (problem) { + this.error.set(problem); + return; + } + this.busy.set(true); + this.error.set(''); + this.auth.register(`${this.local()}@${this.domain()}`, this.password(), this.displayName.trim()).subscribe({ + next: () => this.router.navigate(['/']), + error: e => { this.busy.set(false); this.error.set(errorText(e)); } + }); + } +} diff --git a/frontend/src/app/pages/blocked/blocked.page.ts b/frontend/src/app/pages/blocked/blocked.page.ts new file mode 100644 index 0000000..78dfccb --- /dev/null +++ b/frontend/src/app/pages/blocked/blocked.page.ts @@ -0,0 +1,100 @@ +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { DatePipe } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatIconModule } from '@angular/material/icon'; +import { + JtGridBulkEvent, JtGridBulkService, JtGridCellDirective, JtGridColumn, JtGridComponent, JtGridRowEvent, RowAction +} from '@jambotron/aria-grid'; +import { ApiService } from '../../core/api.service'; +import { BlacklistEntry, errorText } from '../../core/models'; +import { ToastService } from '../../ui/toast.service'; + +/** Blocked senders: their mail goes straight to Spam. */ +@Component({ + selector: 'app-blocked', + imports: [DatePipe, FormsModule, MatIconModule, JtGridComponent, JtGridCellDirective], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
+ block +
+

BLOCKED SENDERS

+

Messages from these addresses go straight to Spam

+
+
+ +
+
+ + +
+
+ + +
+ +
+ + + {{ row.senderEmail }} + {{ row.reason || '' }} + {{ row.createdAt | date: 'd MMM y, HH:mm' }} + +
+ ` +}) +export class BlockedPage { + private api = inject(ApiService); + private bulk = inject(JtGridBulkService); + private toasts = inject(ToastService); + + readonly entries = signal([]); + readonly loading = signal(true); + email = ''; + reason = ''; + + readonly columns: JtGridColumn[] = [ + { key: 'sender', label: 'Sender', sort: e => e.senderEmail }, + { key: 'reason', label: 'Reason', sort: e => e.reason || '' }, + { key: 'date', label: 'Blocked', sort: e => e.createdAt || '', align: 'end' } + ]; + readonly bulkActions: RowAction[] = [{ value: 'unblock', label: 'Unblock', icon: 'lock_open' }]; + readonly rowActions = (): RowAction[] => [{ value: 'unblock', label: 'Unblock', icon: 'lock_open', inline: true }]; + readonly bySender = (e: BlacklistEntry) => e.senderEmail; + readonly senderOf = (e: BlacklistEntry) => e.senderEmail; + + constructor() { this.load(); } + + load(): void { + this.api.blacklist().subscribe({ + next: list => { this.entries.set(list); this.loading.set(false); }, + error: e => { this.loading.set(false); this.toasts.error(errorText(e)); } + }); + } + + add(): void { + const email = this.email.trim().toLowerCase(); + if (!/^[^@\s]+@[^@\s]+$/.test(email)) { + this.toasts.error('Enter a valid sender address'); + return; + } + this.api.block(email, this.reason.trim()).subscribe({ + next: () => { this.toasts.ok(`${email} blocked`); this.email = ''; this.reason = ''; this.load(); }, + error: e => this.toasts.error(errorText(e)) + }); + } + + onRow(e: JtGridRowEvent): void { + this.api.unblock(e.row.senderEmail).subscribe({ + next: () => { this.toasts.ok(`${e.row.senderEmail} unblocked`); this.load(); }, + error: err => this.toasts.error(errorText(err)) + }); + } + + onBulk(e: JtGridBulkEvent): void { + this.bulk.run({ rows: e.rows, label: 'Unblock', run: x => this.api.unblock(x.senderEmail) }).subscribe(() => this.load()); + } +} diff --git a/frontend/src/app/pages/compose/compose.page.ts b/frontend/src/app/pages/compose/compose.page.ts new file mode 100644 index 0000000..ecb55a2 --- /dev/null +++ b/frontend/src/app/pages/compose/compose.page.ts @@ -0,0 +1,370 @@ +import { ChangeDetectionStrategy, Component, DestroyRef, computed, effect, inject, input, signal, untracked } from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { Observable, of, switchMap, tap } from 'rxjs'; +import { ApiService } from '../../core/api.service'; +import { MailStateService } from '../../core/mail-state.service'; +import { Attachment, Contact, Draft, Message, errorText, formatBytes } from '../../core/models'; +import { DialogService } from '../../ui/dialog.service'; +import { ToastService } from '../../ui/toast.service'; + +const MAX_FILE = 10 * 1024 * 1024; + +/** + * New message, reply, reply all, forward, or a draft being edited. A draft is saved on the server + * (automatically every 15 s while there are changes, and before attaching a file — files belong to the draft). + */ +@Component({ + selector: 'app-compose', + imports: [FormsModule, NgTemplateOutlet, MatIconModule, MatTooltipModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
+

{{ title() }}

+ + {{ saving() ? 'Saving…' : dirty() ? 'Unsaved changes' : draftId() ? 'Draft saved' : '' }} + +
+ +
+
+
+ + @if (!showCc()) { + + } +
+ + @if (focused() === 'to') { } +
+ @if (showCc()) { +
+ + + @if (focused() === 'cc') { } +
+ } +
+ + +
+
+ + +
+ +
+
+ + up to 10 MB each + @if (uploading()) { Uploading… } +
+ @if (attachments().length) { +
    + @for (a of attachments(); track a.id) { +
  • + attach_file + {{ a.filename }} + {{ size(a.sizeBytes) }} + +
  • + } +
+ } +
+ + @if (error()) { + + } + +
+ + + + +
+
+
+ + + @if (suggestions().length) { +
    + @for (c of suggestions(); track c.email) { +
  • + {{ c.name || c.email }} + @if (c.name) { {{ c.email }} } +
  • + } +
+ } +
+ ` +}) +export class ComposePage { + readonly draft = input(); + readonly reply = input(); + readonly replyAll = input(); + readonly forward = input(); + readonly toParam = input(undefined, { alias: 'to' }); + + private api = inject(ApiService); + private state = inject(MailStateService); + private dialogs = inject(DialogService); + private toasts = inject(ToastService); + private router = inject(Router); + + readonly to = signal(''); + readonly cc = signal(''); + readonly subject = signal(''); + readonly body = signal(''); + readonly showCc = signal(false); + readonly draftId = signal(null); + readonly attachments = signal([]); + readonly dirty = signal(false); + readonly saving = signal(false); + readonly sending = signal(false); + readonly uploading = signal(false); + readonly error = signal(''); + readonly focused = signal<'to' | 'cc' | null>(null); + private contacts = signal([]); + private done = false; + + readonly title = computed(() => this.reply() ? 'REPLY' : this.replyAll() ? 'REPLY ALL' : this.forward() ? 'FORWARD' + : this.draft() ? 'EDIT DRAFT' : 'NEW MESSAGE'); + readonly hasContent = computed(() => !!(this.to().trim() || this.cc().trim() || this.subject().trim() || this.body().trim())); + + /** Contacts matching the address being typed (after the last comma). */ + readonly suggestions = computed(() => { + const field = this.focused(); + if (!field) return []; + const value = field === 'to' ? this.to() : this.cc(); + const typed = value.split(/[,;]/).pop()!.trim().toLowerCase(); + if (!typed) return []; + const taken = value.toLowerCase(); + return this.contacts() + .filter(c => !taken.includes(c.email.toLowerCase()) && + (c.email.toLowerCase().includes(typed) || (c.name || '').toLowerCase().includes(typed))) + .slice(0, 6); + }); + + constructor() { + this.api.contacts().subscribe({ next: c => this.contacts.set(c), error: () => {} }); + effect(() => { + const draft = this.draft(), reply = this.reply(), replyAll = this.replyAll(), forward = this.forward(), to = this.toParam(); + untracked(() => this.init(draft, reply, replyAll, forward, to)); + }); + const timer = setInterval(() => { if (this.dirty() && !this.saving() && !this.sending()) this.save().subscribe(); }, 15000); + inject(DestroyRef).onDestroy(() => { + clearInterval(timer); + if (!this.done && this.dirty() && this.hasContent()) this.save().subscribe(); + }); + } + + private init(draft?: string, reply?: string, replyAll?: string, forward?: string, to?: string): void { + const source = draft ?? reply ?? replyAll ?? forward; + if (to) this.to.set(to); + if (!source) return; + this.api.message(Number(source)).subscribe({ + next: m => { + if (draft) this.fromDraft(m); + else if (forward) this.fromForward(m); + else this.fromReply(m, !!replyAll); + }, + error: e => this.error.set(errorText(e)) + }); + } + + private fromDraft(m: Message): void { + this.draftId.set(m.id); + this.to.set(m.recipientEmail || ''); + this.cc.set(m.ccEmail || ''); + this.showCc.set(!!m.ccEmail); + this.subject.set(m.subject || ''); + this.body.set(m.body || ''); + if (m.attachmentCount) this.api.attachments(m.id).subscribe({ next: a => this.attachments.set(a), error: () => {} }); + } + + private fromReply(m: Message, all: boolean): void { + const me = (this.state.profile()?.email ?? '').toLowerCase(); + this.to.set(m.senderEmail); + if (all) { + const others = [...m.recipientEmail.split(','), ...(m.ccEmail ? m.ccEmail.split(',') : [])] + .map(a => a.trim()).filter(a => a && a.toLowerCase() !== me && a.toLowerCase() !== m.senderEmail.toLowerCase()); + if (others.length) { this.cc.set(others.join(', ')); this.showCc.set(true); } + } + this.subject.set(prefixed('Re: ', m.subject)); + this.body.set(`\n\n${header(m)} wrote:\n${quote(m)}`); + this.dirty.set(true); + } + + private fromForward(m: Message): void { + this.subject.set(prefixed('Fwd: ', m.subject)); + this.body.set(`\n\n---------- Forwarded message ----------\nFrom: ${header(m)}\nDate: ${new Date(m.receivedAt).toLocaleString()}\n` + + `Subject: ${m.subject || ''}\nTo: ${m.recipientEmail}\n\n${plain(m)}`); + this.dirty.set(true); + if (m.attachmentCount) this.toasts.ok('Attachments of the original are not forwarded — attach them again if needed'); + } + + touch(): void { this.dirty.set(true); this.error.set(''); } + + blurLater(): void { setTimeout(() => this.focused.set(null), 150); } + + pick(field: 'to' | 'cc', contact: Contact): void { + const target = field === 'to' ? this.to : this.cc; + const parts = target().split(/[,;]/).map(p => p.trim()); + parts[parts.length - 1] = contact.email; + target.set(parts.filter(Boolean).join(', ') + ', '); + this.touch(); + } + + private current(): Draft { + return { to: this.to().trim(), cc: this.showCc() ? this.cc().trim() : '', subject: this.subject(), body: this.body(), html: false }; + } + + /** Creates the draft on the first save, updates it afterwards. */ + private save(): Observable { + const id = this.draftId(); + this.saving.set(true); + const request = id ? this.api.updateDraft(id, this.current()) : this.api.createDraft(this.current()); + return request.pipe( + tap({ + next: m => { this.draftId.set(m.id); this.dirty.set(false); this.saving.set(false); this.state.refreshFolders(); }, + error: e => { this.saving.set(false); this.error.set('Draft not saved: ' + errorText(e)); } + }), + switchMap(m => of(m.id)) + ); + } + + saveNow(): void { + this.save().subscribe({ next: () => this.toasts.ok('Draft saved'), error: () => {} }); + } + + attach(event: Event): void { + const field = event.target as HTMLInputElement; + const files = Array.from(field.files ?? []); + // the input is cleared only after the upload: Chrome cannot read a File whose input was reset + const reset = () => { field.value = ''; }; + const tooBig = files.filter(f => f.size > MAX_FILE); + if (tooBig.length) this.toasts.error(`Too big (over 10 MB): ${tooBig.map(f => f.name).join(', ')}`); + const ok = files.filter(f => f.size <= MAX_FILE); + if (!ok.length) { reset(); return; } + this.uploading.set(true); + const id = this.draftId(); + (id && !this.dirty() ? of(id) : this.save()).subscribe({ + next: draftId => this.uploadNext(draftId, ok, reset), + error: () => { this.uploading.set(false); reset(); } + }); + } + + private uploadNext(draftId: number, files: File[], done: () => void): void { + const [file, ...rest] = files; + if (!file) { + this.uploading.set(false); + done(); + return; + } + this.api.upload(draftId, file).subscribe({ + next: a => { this.attachments.update(list => [...list, a]); this.uploadNext(draftId, rest, done); }, + error: e => { this.toasts.error(`${file.name}: ${errorText(e)}`); this.uploadNext(draftId, rest, done); } + }); + } + + detach(a: Attachment): void { + this.api.deleteAttachment(a.id).subscribe({ + next: () => this.attachments.update(list => list.filter(x => x.id !== a.id)), + error: e => this.toasts.error(errorText(e)) + }); + } + + send(): void { + if (!this.to().trim()) { + this.error.set('Who is it for? Fill in "To"'); + return; + } + const go = () => { + this.sending.set(true); + this.error.set(''); + this.api.send(this.current(), this.draftId()).subscribe({ + next: () => { + this.done = true; + this.toasts.ok('Message sent'); + this.state.refreshFolders(); + this.state.refreshProfile(); + const sent = this.state.system('Sent'); + this.router.navigate(sent ? ['/folder', sent.id] : ['/']); + }, + error: e => { this.sending.set(false); this.error.set(errorText(e)); } + }); + }; + if (!this.subject().trim()) { + this.dialogs.confirm('Send without a subject?', 'The message has no subject.', 'Send').subscribe(ok => ok !== null && go()); + } else { + go(); + } + } + + discard(): void { + const id = this.draftId(); + const leave = () => { + this.done = true; + this.router.navigate(['/']); + }; + if (!id && !this.hasContent()) { + leave(); + return; + } + this.dialogs.confirm('Discard this message?', id ? 'The draft and its attachments are deleted.' : 'What you typed is lost.', 'Discard', true) + .subscribe(ok => { + if (ok === null) return; + if (!id) { leave(); return; } + // a draft: to the Trash, then out of it + this.api.deleteMessage(id).pipe(switchMap(() => this.api.deleteMessage(id))).subscribe({ + next: () => { this.state.refreshFolders(); leave(); }, + error: () => { this.state.refreshFolders(); leave(); } + }); + }); + } + + size(bytes: number): string { return formatBytes(bytes); } +} + +function prefixed(prefix: string, subject: string): string { + const s = subject || ''; + return s.toLowerCase().startsWith(prefix.toLowerCase()) ? s : prefix + s; +} + +function header(m: Message): string { + return m.senderName ? `${m.senderName} <${m.senderEmail}>` : m.senderEmail; +} + +function plain(m: Message): string { + if (!m.html) return m.body || ''; + const doc = new DOMParser().parseFromString(m.body || '', 'text/html'); + return (doc.body.textContent || '').replace(/\n{3,}/g, '\n\n').trim(); +} + +function quote(m: Message): string { + return plain(m).split('\n').map(line => '> ' + line).join('\n'); +} diff --git a/frontend/src/app/pages/contacts/contacts.page.ts b/frontend/src/app/pages/contacts/contacts.page.ts new file mode 100644 index 0000000..d36ff5e --- /dev/null +++ b/frontend/src/app/pages/contacts/contacts.page.ts @@ -0,0 +1,151 @@ +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { A11yModule } from '@angular/cdk/a11y'; +import { MatIconModule } from '@angular/material/icon'; +import { + JtGridBulkEvent, JtGridBulkService, JtGridCellDirective, JtGridColumn, JtGridComponent, JtGridRowEvent, RowAction +} from '@jambotron/aria-grid'; +import { ApiService } from '../../core/api.service'; +import { Contact, errorText } from '../../core/models'; +import { DialogService } from '../../ui/dialog.service'; +import { ToastService } from '../../ui/toast.service'; + +/** Address book: grid, add/edit form, write to a contact. */ +@Component({ + selector: 'app-contacts', + imports: [FormsModule, A11yModule, MatIconModule, JtGridComponent, JtGridCellDirective], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
+
+ contacts +
+

CONTACTS

+

{{ contacts().length }} contacts · suggested while you type an address

+
+
+ +
+ + + {{ row.name || '—' }} + {{ row.email }} + {{ row.phone || '' }} + {{ row.company || '' }} + +
+ + @if (editing(); as c) { +
+ +
+ } + ` +}) +export class ContactsPage { + private api = inject(ApiService); + private bulk = inject(JtGridBulkService); + private dialogs = inject(DialogService); + private toasts = inject(ToastService); + private router = inject(Router); + + readonly contacts = signal([]); + readonly loading = signal(true); + readonly editing = signal(null); + readonly formError = signal(''); + + readonly columns: JtGridColumn[] = [ + { key: 'name', label: 'Name', sort: c => (c.name || '').toLowerCase() }, + { key: 'email', label: 'Email', sort: c => c.email.toLowerCase() }, + { key: 'phone', label: 'Phone', sort: c => c.phone || '' }, + { key: 'company', label: 'Company', sort: c => (c.company || '').toLowerCase() } + ]; + readonly bulkActions: RowAction[] = [ + { value: 'write', label: 'Write to them', icon: 'edit' }, + { value: 'delete', label: 'Delete', icon: 'delete', danger: true } + ]; + readonly rowActions = (): RowAction[] => [ + { value: 'write', label: 'Write', icon: 'edit', inline: true }, + { value: 'edit', label: 'Edit', icon: 'manage_accounts', inline: true }, + { value: 'delete', label: 'Delete', icon: 'delete', danger: true } + ]; + readonly byEmail = (c: Contact) => c.id ?? c.email; + readonly nameOf = (c: Contact) => c.name || c.email; + + constructor() { this.load(); } + + load(): void { + this.api.contacts().subscribe({ + next: list => { this.contacts.set(list); this.loading.set(false); }, + error: e => { this.loading.set(false); this.toasts.error(errorText(e)); } + }); + } + + edit(c: Contact): void { + this.formError.set(''); + this.editing.set({ ...c }); + } + + save(): void { + const c = this.editing(); + if (!c) return; + if (!/^[^@\s]+@[^@\s]+$/.test((c.email || '').trim())) { + this.formError.set('Enter a valid email'); + return; + } + const body = { name: (c.name || '').trim(), email: c.email.trim(), phone: (c.phone || '').trim(), company: (c.company || '').trim() }; + (c.id ? this.api.updateContact(c.id, body) : this.api.addContact(body)).subscribe({ + next: () => { this.editing.set(null); this.toasts.ok(c.id ? 'Contact saved' : 'Contact added'); this.load(); }, + error: e => this.formError.set(errorText(e).includes('duplicate') ? 'This email is already in contacts' : errorText(e)) + }); + } + + onRow(e: JtGridRowEvent): void { + if (e.action === 'write') this.router.navigate(['/compose', { to: e.row.email }]); + if (e.action === 'edit') this.edit(e.row); + if (e.action === 'delete') { + this.dialogs.confirm(`Delete ${this.nameOf(e.row)}?`, 'The contact is removed from the address book.', 'Delete', true) + .subscribe(ok => ok !== null && this.api.deleteContact(e.row.id!).subscribe({ + next: () => { this.toasts.ok('Contact deleted'); this.load(); }, + error: err => this.toasts.error(errorText(err)) + })); + } + } + + onBulk(e: JtGridBulkEvent): void { + if (e.action === 'write') { + this.router.navigate(['/compose', { to: e.rows.map(c => c.email).join(', ') }]); + return; + } + this.bulk.run({ + rows: e.rows, label: 'Delete', danger: true, confirm: `Delete ${e.rows.length} contacts?`, + run: c => this.api.deleteContact(c.id!) + }).subscribe(() => this.load()); + } +} diff --git a/frontend/src/app/pages/folder/folder.page.ts b/frontend/src/app/pages/folder/folder.page.ts new file mode 100644 index 0000000..ee0cf41 --- /dev/null +++ b/frontend/src/app/pages/folder/folder.page.ts @@ -0,0 +1,292 @@ +import { ChangeDetectionStrategy, Component, DestroyRef, computed, effect, inject, input, signal } from '@angular/core'; +import { DatePipe } from '@angular/common'; +import { Router, RouterLink } from '@angular/router'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { + JtGridBulkEvent, JtGridBulkService, JtGridCellDirective, JtGridColumn, JtGridComponent, JtGridRowEvent, RowAction +} from '@jambotron/aria-grid'; +import { Observable, forkJoin, of } from 'rxjs'; +import { ApiService } from '../../core/api.service'; +import { MailStateService } from '../../core/mail-state.service'; +import { Message, errorText, folderIcon, folderLabel, formatBytes } from '../../core/models'; +import { DialogService } from '../../ui/dialog.service'; +import { ToastService } from '../../ui/toast.service'; + +/** One folder: the message list (grid), search, bulk actions, empty Trash/Spam. */ +@Component({ + selector: 'app-folder', + imports: [DatePipe, RouterLink, MatIconModule, MatTooltipModule, JtGridComponent, JtGridCellDirective], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
+
+ {{ icon() }} +
+

{{ title() }}

+

+ @if (q()) { Search “{{ q() }}”: {{ messages().length }} found } + @else { {{ messages().length }} messages · {{ unread() }} unread } +

+
+
+
+ @if (q()) { + close Clear search + } + + @if (canEmpty()) { + + } +
+
+ + @if (error()) { + + } + + + + + + +
+ {{ who(row) }} +
+
+ + + + @if (!row.read) { } + {{ row.subject || '(no subject)' }} + + {{ row.body }} + + + + @if (row.attachmentCount) { + + attach_file{{ row.attachmentCount }} + + } + + + + {{ shortDate(row.receivedAt) }} + + + + {{ size(row) }} + +
+
+ ` +}) +export class FolderPage { + readonly id = input.required(); + readonly q = input(); + + private api = inject(ApiService); + private state = inject(MailStateService); + private bulk = inject(JtGridBulkService); + private dialogs = inject(DialogService); + private toasts = inject(ToastService); + private router = inject(Router); + + readonly messages = signal([]); + readonly loading = signal(false); + readonly error = signal(''); + + readonly folderId = computed(() => Number(this.id())); + readonly folder = computed(() => this.state.folder(this.folderId())); + readonly title = computed(() => this.folder() ? folderLabel(this.folder()!) : 'Folder'); + readonly icon = computed(() => this.folder() ? folderIcon(this.folder()!) : 'folder'); + readonly kind = computed(() => this.folder()?.system ? this.folder()!.name : 'own'); + readonly outgoing = computed(() => this.kind() === 'Sent' || this.kind() === 'Drafts'); + readonly canEmpty = computed(() => this.kind() === 'Trash' || this.kind() === 'Spam'); + readonly unread = computed(() => this.messages().filter(m => !m.read).length); + + readonly columns = computed[]>(() => [ + { key: 'flag', label: 'Flag', hiddenLabel: true, sort: m => m.flagged }, + { key: 'who', label: this.outgoing() ? 'To' : 'From', sort: m => this.who(m).toLowerCase() }, + { key: 'subject', label: 'Subject', sort: m => (m.subject || '').toLowerCase() }, + { key: 'files', label: 'Attachments', hiddenLabel: true, sort: m => m.attachmentCount }, + { key: 'date', label: 'Date', sort: m => m.receivedAt, align: 'end' }, + { key: 'size', label: 'Size', sort: m => this.bytes(m), align: 'end' } + ]); + + readonly bulkActions = computed(() => { + const here = this.folderId(); + const moves = this.state.folders() + .filter(f => f.id !== here && f.name !== 'Drafts' && f.name !== 'Sent') + .map(f => ({ value: 'move:' + f.id, label: 'To ' + folderLabel(f), icon: f.system && f.name === 'Spam' ? 'report' : 'drive_file_move' })); + return [ + { value: 'read', label: 'Mark read', icon: 'drafts' }, + { value: 'unread', label: 'Mark unread', icon: 'mark_email_unread' }, + { value: 'flag', label: 'Flag', icon: 'star' }, + ...moves.filter(m => !m.label.endsWith('Trash')), + { value: 'delete', label: this.kind() === 'Trash' ? 'Delete forever' : 'Delete', icon: 'delete', danger: true } + ]; + }); + + readonly byId = (m: Message) => m.id; + readonly isUnread = (m: Message) => !m.read; + readonly subjectOf = (m: Message) => m.subject || '(no subject)'; + + readonly rowActions = (m: Message): RowAction[] => { + const draft = this.kind() === 'Drafts'; + const spam = this.kind() === 'Spam'; + return [ + draft ? { value: 'edit', label: 'Edit draft', icon: 'edit', inline: true } + : { value: 'reply', label: 'Reply', icon: 'reply', inline: true }, + ...(draft ? [] : [{ value: 'forward', label: 'Forward', icon: 'forward' }]), + { value: m.read ? 'unread' : 'read', label: m.read ? 'Mark unread' : 'Mark read', icon: m.read ? 'mark_email_unread' : 'drafts' }, + ...(this.outgoing() ? [] : [spam ? { value: 'notspam', label: 'Not spam', icon: 'inbox' } : { value: 'spam', label: 'Spam', icon: 'report' }]), + { value: 'delete', label: this.kind() === 'Trash' ? 'Delete forever' : 'Delete', icon: 'delete', danger: true } + ]; + }; + + constructor() { + effect(() => { + this.folderId(); + this.q(); + this.load(); + }); + const timer = setInterval(() => { if (!this.q() && !document.hidden) this.load(true); }, 30000); + inject(DestroyRef).onDestroy(() => clearInterval(timer)); + } + + load(quiet = false): void { + const id = this.folderId(); + if (!id) return; + if (!quiet) this.loading.set(true); + const q = this.q()?.trim(); + (q ? this.api.search(id, q) : this.api.messages(id)).subscribe({ + next: list => { this.messages.set(list); this.loading.set(false); this.error.set(''); }, + error: e => { this.loading.set(false); if (!quiet) this.error.set(errorText(e)); } + }); + } + + who(m: Message): string { + return this.outgoing() ? (m.recipientEmail || '(no recipient)') : (m.senderName || m.senderEmail); + } + + link(m: Message): unknown[] { + return this.kind() === 'Drafts' ? ['/compose', { draft: m.id }] : ['/message', m.id]; + } + + bytes(m: Message): number { return m.messageSize ?? 0; } + size(m: Message): string { return formatBytes(m.messageSize); } + + shortDate(value: string): string { + const date = new Date(value); + const now = new Date(); + return date.toDateString() === now.toDateString() + ? date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + : date.toLocaleDateString([], { day: 'numeric', month: 'short', ...(date.getFullYear() !== now.getFullYear() ? { year: 'numeric' } : {}) }); + } + + toggleFlag(m: Message): void { + this.patch(m, { flagged: !m.flagged }); + } + + onRow(e: JtGridRowEvent): void { + const m = e.row; + switch (e.action) { + case 'edit': this.router.navigate(['/compose', { draft: m.id }]); break; + case 'reply': this.router.navigate(['/compose', { reply: m.id }]); break; + case 'forward': this.router.navigate(['/compose', { forward: m.id }]); break; + case 'read': case 'unread': + this.patch(m, { read: e.action === 'read' }); + break; + case 'spam': this.moveTo([m], this.state.system('Spam')?.id, 'Moved to Spam'); break; + case 'notspam': this.moveTo([m], this.state.inbox()?.id, 'Moved to Inbox'); break; + case 'delete': + if (this.kind() === 'Trash') { + this.dialogs.confirm('Delete forever?', `"${this.subjectOf(m)}" cannot be restored.`, 'Delete', true) + .subscribe(ok => ok !== null && this.remove([m])); + } else { + this.remove([m]); + } + break; + } + } + + onBulk(e: JtGridBulkEvent): void { + const action = e.action; + if (action.startsWith('move:')) { + const target = this.state.folder(Number(action.slice(5))); + this.runBulk(e.rows, 'To ' + (target ? folderLabel(target) : 'folder'), m => this.api.updateMessage(m.id, { folderId: target!.id })); + return; + } + switch (action) { + case 'read': this.runBulk(e.rows, 'Mark read', m => this.api.updateMessage(m.id, { read: true })); break; + case 'unread': this.runBulk(e.rows, 'Mark unread', m => this.api.updateMessage(m.id, { read: false })); break; + case 'flag': this.runBulk(e.rows, 'Flag', m => this.api.updateMessage(m.id, { flagged: true })); break; + case 'delete': + this.runBulk(e.rows, this.kind() === 'Trash' ? 'Delete forever' : 'Delete', m => this.api.deleteMessage(m.id), + this.kind() === 'Trash' ? `Delete ${e.rows.length} messages forever? They cannot be restored.` : `Move ${e.rows.length} messages to the Trash?`); + break; + } + } + + empty(): void { + const folder = this.folder(); + if (!folder) return; + this.dialogs.confirm(`Empty ${this.title()}?`, `All ${this.messages().length} messages are deleted forever.`, 'Empty', true) + .subscribe(ok => { + if (ok === null) return; + this.api.emptyFolder(folder.id).subscribe({ + next: r => { this.toasts.ok(`${r.deleted} messages deleted`); this.afterChange(); }, + error: e => this.toasts.error(errorText(e)) + }); + }); + } + + private runBulk(rows: Message[], label: string, run: (m: Message) => Observable, confirm?: string): void { + this.bulk.run({ rows, label, confirm, danger: !!confirm, run }).subscribe(() => this.afterChange()); + } + + private moveTo(rows: Message[], folderId: number | undefined, done: string): void { + if (!folderId) return; + forkJoin(rows.map(m => this.api.updateMessage(m.id, { folderId }))).subscribe({ + next: () => { this.toasts.ok(done); this.afterChange(); }, + error: e => this.toasts.error(errorText(e)) + }); + } + + private remove(rows: Message[]): void { + forkJoin(rows.length ? rows.map(m => this.api.deleteMessage(m.id)) : [of(null)]).subscribe({ + next: () => { this.toasts.ok(this.kind() === 'Trash' ? 'Deleted forever' : 'Moved to Trash'); this.afterChange(); }, + error: e => this.toasts.error(errorText(e)) + }); + } + + private patch(m: Message, change: { read?: boolean; flagged?: boolean }): void { + this.api.updateMessage(m.id, change).subscribe({ + next: updated => { + this.messages.update(list => list.map(x => x.id === m.id ? { ...x, read: updated.read, flagged: updated.flagged } : x)); + if (change.read !== undefined) this.state.refreshFolders(); + }, + error: e => this.toasts.error(errorText(e)) + }); + } + + private afterChange(): void { + this.load(true); + this.state.refreshFolders(); + this.state.refreshProfile(); + } +} diff --git a/frontend/src/app/pages/inbox-redirect.ts b/frontend/src/app/pages/inbox-redirect.ts new file mode 100644 index 0000000..e54cecb --- /dev/null +++ b/frontend/src/app/pages/inbox-redirect.ts @@ -0,0 +1,25 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { Router } from '@angular/router'; +import { ApiService } from '../core/api.service'; +import { errorText } from '../core/models'; + +/** "/" → the Inbox (its id is per user). */ +@Component({ + selector: 'app-inbox-redirect', + changeDetection: ChangeDetectionStrategy.OnPush, + template: `
{{ message }}
` +}) +export class InboxRedirect { + message = 'Loading…'; + + constructor() { + const router = inject(Router); + inject(ApiService).folders().subscribe({ + next: folders => { + const inbox = folders.find(f => f.system && f.name === 'INBOX') ?? folders[0]; + if (inbox) router.navigate(['/folder', inbox.id], { replaceUrl: true }); + }, + error: e => { this.message = errorText(e); } + }); + } +} diff --git a/frontend/src/app/pages/message/message.page.ts b/frontend/src/app/pages/message/message.page.ts new file mode 100644 index 0000000..eeda981 --- /dev/null +++ b/frontend/src/app/pages/message/message.page.ts @@ -0,0 +1,274 @@ +import { ChangeDetectionStrategy, Component, computed, effect, inject, input, signal } from '@angular/core'; +import { DatePipe } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; +import { Location } from '@angular/common'; +import { Router, RouterLink } from '@angular/router'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { ApiService } from '../../core/api.service'; +import { MailStateService } from '../../core/mail-state.service'; +import { Attachment, Message, errorText, folderLabel, formatBytes } from '../../core/models'; +import { DialogService } from '../../ui/dialog.service'; +import { ToastService } from '../../ui/toast.service'; + +/** One message: headers, body, attachments, reply/forward/move/spam/block/delete. */ +@Component({ + selector: 'app-message', + imports: [DatePipe, FormsModule, RouterLink, MatIconModule, MatTooltipModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @if (error()) { +
+ + arrow_back Inbox +
+ } @else if (message(); as m) { +
+ + +

{{ m.subject || '(no subject)' }}

+ +
+ +
+
From
+
+ @if (m.senderName) { {{ m.senderName }} } + <{{ m.senderEmail }}> + @if (!outgoing()) { + + } +
+
To
+
{{ m.recipientEmail }}
+ @if (m.ccEmail) { +
Cc
+
{{ m.ccEmail }}
+ } +
Date
+
{{ m.receivedAt | date: 'EEEE, d MMMM y, HH:mm' }}
+
+
+ + @if (m.spam) { +

+ report Marked as spam: the sender is blocked or the message failed the SPF/DKIM/DMARC checks. +

+ } + + @if (m.html) { +
+ HTML message + +
+ + } @else { +
{{ m.body }}
+ } + + @if (attachments().length) { +

ATTACHMENTS ({{ attachments().length }})

+
    + @for (a of attachments(); track a.id) { +
  • + +
  • + } +
+ } +
+ } @else { +
Loading…
+ } + ` +}) +export class MessagePage { + readonly id = input.required(); + + private api = inject(ApiService); + private state = inject(MailStateService); + private dialogs = inject(DialogService); + private toasts = inject(ToastService); + private router = inject(Router); + private location = inject(Location); + private sanitizer = inject(DomSanitizer); + + readonly message = signal(null); + readonly attachments = signal([]); + readonly error = signal(''); + readonly remoteImages = signal(false); + + readonly folder = computed(() => { const m = this.message(); return m ? this.state.folder(m.folderId) : null; }); + readonly inTrash = computed(() => this.folder()?.system === true && this.folder()?.name === 'Trash'); + readonly inSpam = computed(() => this.folder()?.system === true && this.folder()?.name === 'Spam'); + readonly outgoing = computed(() => this.folder()?.system === true && ['Sent', 'Drafts'].includes(this.folder()!.name)); + readonly targets = computed(() => this.state.folders().filter(f => f.id !== this.message()?.folderId && f.name !== 'Drafts')); + readonly initial = computed(() => (this.message()?.senderName || this.message()?.senderEmail || '?').trim().charAt(0).toUpperCase()); + readonly hasOthers = computed(() => { + const m = this.message(); + if (!m) return false; + return m.recipientEmail.split(',').length + (m.ccEmail ? m.ccEmail.split(',').length : 0) > 1; + }); + + /** No scripts (sandbox), remote content only when asked — a sender cannot see that the message was opened. */ + readonly htmlDoc = computed(() => { + const img = this.remoteImages() ? 'img-src data: https: http:;' : 'img-src data:;'; + const csp = ``; + const base = ''; + return this.sanitizer.bypassSecurityTrustHtml(csp + base + (this.message()?.body ?? '')); + }); + + label = folderLabel; + + constructor() { + effect(() => this.load(Number(this.id()))); + } + + private load(id: number): void { + this.message.set(null); + this.attachments.set([]); + this.error.set(''); + this.remoteImages.set(false); + this.api.message(id).subscribe({ + next: m => { + this.message.set(m); + if (m.attachmentCount) this.api.attachments(m.id).subscribe({ next: a => this.attachments.set(a), error: () => {} }); + if (!m.read) { + this.api.updateMessage(m.id, { read: true }).subscribe({ + next: () => { this.message.update(x => x && { ...x, read: true }); this.state.refreshFolders(); }, + error: () => {} + }); + } + }, + error: e => this.error.set(e?.status === 404 ? 'This message does not exist (or was deleted).' : errorText(e)) + }); + } + + back(): void { + const m = this.message(); + if (history.length > 1) this.location.back(); + else this.router.navigate(m ? ['/folder', m.folderId] : ['/']); + } + + update(change: { read?: boolean; flagged?: boolean; folderId?: number }): void { + const m = this.message(); + if (!m) return; + this.api.updateMessage(m.id, change).subscribe({ + next: updated => { this.message.set({ ...m, ...updated, body: m.body }); this.state.refreshFolders(); }, + error: e => this.toasts.error(errorText(e)) + }); + } + + markUnread(): void { + const m = this.message(); + if (!m) return; + this.api.updateMessage(m.id, { read: false }).subscribe({ + next: () => { this.state.refreshFolders(); this.router.navigate(['/folder', m.folderId]); }, + error: e => this.toasts.error(errorText(e)) + }); + } + + move(value: string): void { + const target = this.state.folder(Number(value)); + const m = this.message(); + if (!target || !m) return; + this.api.updateMessage(m.id, { folderId: target.id }).subscribe({ + next: () => { this.toasts.ok(`Moved to ${folderLabel(target)}`); this.state.refreshFolders(); this.router.navigate(['/folder', m.folderId]); }, + error: e => this.toasts.error(errorText(e)) + }); + } + + spam(): void { const s = this.state.system('Spam'); if (s) this.move(String(s.id)); } + notSpam(): void { const i = this.state.inbox(); if (i) this.move(String(i.id)); } + + block(): void { + const m = this.message(); + if (!m) return; + this.dialogs.confirm(`Block ${m.senderEmail}?`, 'New messages from this sender go straight to Spam. This message moves to Spam too.', 'Block', true) + .subscribe(ok => { + if (ok === null) return; + this.api.block(m.senderEmail, 'Blocked from a message').subscribe({ + next: () => { this.toasts.ok(`${m.senderEmail} blocked`); this.spam(); }, + error: e => this.toasts.error(errorText(e)) + }); + }); + } + + remove(): void { + const m = this.message(); + if (!m) return; + const go = () => this.api.deleteMessage(m.id).subscribe({ + next: () => { + this.toasts.ok(this.inTrash() ? 'Deleted forever' : 'Moved to Trash'); + this.state.refreshFolders(); + this.state.refreshProfile(); + this.router.navigate(['/folder', m.folderId]); + }, + error: e => this.toasts.error(errorText(e)) + }); + if (this.inTrash()) { + this.dialogs.confirm('Delete forever?', 'This message cannot be restored.', 'Delete', true).subscribe(ok => ok !== null && go()); + } else { + go(); + } + } + + download(a: Attachment): void { + this.api.download(a.id).subscribe({ + next: blob => { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = a.filename; + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + }, + error: e => this.toasts.error(errorText(e)) + }); + } + + size(bytes: number): string { return formatBytes(bytes); } + + fileIcon(a: Attachment): string { + const type = a.contentType || ''; + if (type.startsWith('image/')) return 'image'; + if (type.includes('pdf')) return 'picture_as_pdf'; + if (type.includes('zip') || type.includes('compressed')) return 'folder_zip'; + if (type.startsWith('text/')) return 'description'; + return 'attach_file'; + } +} diff --git a/frontend/src/app/pages/settings/settings.page.ts b/frontend/src/app/pages/settings/settings.page.ts new file mode 100644 index 0000000..f877d8e --- /dev/null +++ b/frontend/src/app/pages/settings/settings.page.ts @@ -0,0 +1,152 @@ +import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core'; +import { DatePipe } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatIconModule } from '@angular/material/icon'; +import { ApiService } from '../../core/api.service'; +import { MailStateService } from '../../core/mail-state.service'; +import { errorText, formatBytes } from '../../core/models'; +import { THEMES, ThemeService } from '../../ui/theme.service'; +import { ToastService } from '../../ui/toast.service'; + +/** Profile name, password, storage, look. */ +@Component({ + selector: 'app-settings', + imports: [DatePipe, FormsModule, MatIconModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
+

+ badge PROFILE +

+ @if (state.profile(); as p) { +
+
Mailbox
{{ p.email }}
+
Since
{{ p.createdAt | date: 'd MMMM y' }}
+
+ } +
+
+ + +
+ +
+
+ +
+

+ key PASSWORD +

+
+ +
+
+
+
+
+
+ @if (passwordError()) { } + +
+
+ +
+

+ storage STORAGE +

+
+ {{ used() }} of {{ quota() }}{{ percent() }}% +
+
+
+
+
    + @for (f of state.folders(); track f.id) { +
  • {{ f.system ? label(f.name) : f.name }} + {{ f.messageCount }} messages
  • + } +
+
+ +
+

+ palette LOOK +

+
+ Palette +
+ @for (t of themes; track t.value) { + + } +
+
+
+
+
High contrast
Black background, bright outlines
+ +
+
+
Grid motion
The moving retro floor in the background
+ +
+
+
+
+ ` +}) +export class SettingsPage { + readonly state = inject(MailStateService); + readonly theme = inject(ThemeService); + private api = inject(ApiService); + private toasts = inject(ToastService); + + readonly themes = THEMES; + displayName = ''; + current = ''; + next = ''; + again = ''; + readonly passwordError = signal(''); + + readonly used = computed(() => formatBytes(this.state.profile()?.usedBytes)); + readonly quota = computed(() => formatBytes(this.state.profile()?.quotaBytes)); + readonly percent = computed(() => { + const p = this.state.profile(); + return p && p.quotaBytes ? Math.min(100, Math.round(p.usedBytes / p.quotaBytes * 1000) / 10) : 0; + }); + + constructor() { + this.state.refreshProfile(); + effect(() => { this.displayName = this.state.profile()?.displayName ?? ''; }); + } + + label(name: string): string { + return ({ INBOX: 'Inbox', Drafts: 'Drafts', Sent: 'Sent', Spam: 'Spam', Trash: 'Trash' } as Record)[name] ?? name; + } + + saveName(): void { + const name = this.displayName.trim(); + if (!name) { this.toasts.error('The name cannot be empty'); return; } + this.api.updateMe(name).subscribe({ + next: p => { this.state.profile.set(p); this.toasts.ok('Name saved'); }, + error: e => this.toasts.error(errorText(e)) + }); + } + + changePassword(): void { + const problem = !this.current ? 'Enter the current password' + : this.next.length < 8 ? 'The new password is shorter than 8 characters' + : this.next !== this.again ? 'The new passwords do not match' : ''; + this.passwordError.set(problem); + if (problem) return; + this.api.changePassword(this.current, this.next).subscribe({ + next: () => { this.current = this.next = this.again = ''; this.toasts.ok('Password changed'); }, + error: e => this.passwordError.set(errorText(e)) + }); + } +} diff --git a/frontend/src/app/pages/shell/shell.component.ts b/frontend/src/app/pages/shell/shell.component.ts new file mode 100644 index 0000000..e27b664 --- /dev/null +++ b/frontend/src/app/pages/shell/shell.component.ts @@ -0,0 +1,254 @@ +import { ChangeDetectionStrategy, Component, DestroyRef, computed, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { NavigationEnd, Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { JtRowActionsComponent, RowAction } from '@jambotron/aria-grid'; +import { filter } from 'rxjs'; +import { ApiService } from '../../core/api.service'; +import { AuthService } from '../../core/auth.service'; +import { MailStateService } from '../../core/mail-state.service'; +import { Folder, errorText, folderIcon, folderLabel, formatBytes } from '../../core/models'; +import { BackgroundComponent } from '../../ui/background.component'; +import { DialogService } from '../../ui/dialog.service'; +import { THEMES, ThemeService } from '../../ui/theme.service'; +import { ToastService } from '../../ui/toast.service'; + +/** Header, folders sidebar and the page — everything after login. */ +@Component({ + selector: 'app-shell', + imports: [FormsModule, RouterOutlet, RouterLink, RouterLinkActive, MatIconModule, MatTooltipModule, + JtRowActionsComponent, BackgroundComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + +
+
+
+ + +
M
+ +
+ + + + + + + + + +
+
+ +
+ + +
+ +
+
+
+ ` +}) +export class ShellComponent { + readonly auth = inject(AuthService); + readonly state = inject(MailStateService); + readonly theme = inject(ThemeService); + private api = inject(ApiService); + private dialogs = inject(DialogService); + private toasts = inject(ToastService); + private router = inject(Router); + + readonly themes = THEMES; + readonly menuOpen = signal(false); + query = ''; + + readonly links = [ + { path: '/contacts', label: 'Contacts', icon: 'contacts' }, + { path: '/blocked', label: 'Blocked senders', icon: 'block' }, + { path: '/settings', label: 'Settings', icon: 'settings' } + ]; + + readonly folderActions: RowAction[] = [ + { value: 'rename', label: 'Rename', icon: 'drive_file_rename_outline' }, + { value: 'delete', label: 'Delete folder', icon: 'delete', danger: true } + ]; + + readonly systemFolders = computed(() => this.state.folders().filter(f => f.system)); + readonly ownFolders = computed(() => this.state.folders().filter(f => !f.system)); + readonly used = computed(() => { + const p = this.state.profile(); + return p ? `${formatBytes(p.usedBytes)} / ${formatBytes(p.quotaBytes)}` : ''; + }); + readonly usedPercent = computed(() => { + const p = this.state.profile(); + return p && p.quotaBytes ? Math.min(100, Math.round(p.usedBytes / p.quotaBytes * 100)) : 0; + }); + + label = folderLabel; + icon = folderIcon; + + constructor() { + this.state.refreshFolders(); + this.state.refreshProfile(); + const timer = setInterval(() => this.state.refreshFolders(), 30000); + inject(DestroyRef).onDestroy(() => clearInterval(timer)); + this.router.events.pipe(filter(e => e instanceof NavigationEnd)).subscribe(() => { + const q = this.router.parseUrl(this.router.url).queryParams['q']; + this.query = typeof q === 'string' ? q : ''; + }); + } + + /** Drafts: how many; Sent, Trash: nothing; others: unread. */ + badge(folder: Folder): number { + if (folder.name === 'Drafts') return folder.messageCount; + if (folder.name === 'Sent' || folder.name === 'Trash') return 0; + return folder.unreadCount; + } + + search(): void { + const match = this.router.url.match(/^\/folder\/(\d+)/); + const folderId = match ? Number(match[1]) : this.state.inbox()?.id; + if (!folderId) return; + const q = this.query.trim(); + this.router.navigate(['/folder', folderId], { queryParams: q ? { q } : {} }); + } + + createFolder(): void { + this.dialogs.prompt('New folder', 'Folder name', '', 'Create').subscribe(name => { + if (!name) return; + this.api.createFolder(name).subscribe({ + next: folder => { + this.state.refreshFolders(); + this.toasts.ok(`Folder "${folder.name}" created`); + this.router.navigate(['/folder', folder.id]); + }, + error: e => this.toasts.error(errorText(e)) + }); + }); + } + + onFolderAction(action: string, folder: Folder): void { + if (action === 'rename') { + this.dialogs.prompt('Rename folder', 'Folder name', folder.name, 'Rename').subscribe(name => { + if (!name || name === folder.name) return; + this.api.renameFolder(folder.id, name).subscribe({ + next: () => { this.state.refreshFolders(); this.toasts.ok('Folder renamed'); }, + error: e => this.toasts.error(errorText(e)) + }); + }); + } else if (action === 'delete') { + this.dialogs.confirm(`Delete "${folder.name}"?`, 'Its messages go to the Trash.', 'Delete', true).subscribe(ok => { + if (ok === null) return; + this.api.deleteFolder(folder.id).subscribe({ + next: () => { + this.state.refreshFolders(); + this.toasts.ok('Folder deleted'); + if (this.router.url.startsWith(`/folder/${folder.id}`)) this.router.navigate(['/']); + }, + error: e => this.toasts.error(errorText(e)) + }); + }); + } + } + + logout(): void { + this.state.reset(); + this.auth.logout(); + } +} diff --git a/frontend/src/app/services/auth.service.ts b/frontend/src/app/services/auth.service.ts deleted file mode 100644 index cc696a2..0000000 --- a/frontend/src/app/services/auth.service.ts +++ /dev/null @@ -1,57 +0,0 @@ -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 = '/api'; - - private currentUserSubject = new BehaviorSubject( - localStorage.getItem('email') - ); - currentUser$ = this.currentUserSubject.asObservable(); - - private tokenSubject = new BehaviorSubject( - localStorage.getItem('token') - ); - token$ = this.tokenSubject.asObservable(); - - login({ email, password }: LoginRequest): Observable { - return this.http.post(`${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 { - return this.http.post(`${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'); - } -} diff --git a/frontend/src/app/services/mail.service.ts b/frontend/src/app/services/mail.service.ts deleted file mode 100644 index 924c0e4..0000000 --- a/frontend/src/app/services/mail.service.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { Injectable, inject } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; -import { Observable, map } from 'rxjs'; -import { Message, Folder, Attachment, SendRequest, BlacklistEntry, PageResponse, Contact } from '../models/mail.models'; - -@Injectable({ - providedIn: 'root' -}) -export class MailService { - private http = inject(HttpClient); - private apiUrl = '/api'; - - // Папки - getFolders(): Observable { - return this.http.get(`${this.apiUrl}/folders`); - } - - // Письма - getMessages(folderId: number, page: number = 0, size: number = 20): Observable> { - return this.http.get>(`${this.apiUrl}/messages`, { - params: { folderId, page, size } - }); - } - - getMessage(id: number): Observable { - return this.http.get(`${this.apiUrl}/messages/${id}`); - } - - searchMessages( - folderId: number, - keyword?: string, - subject?: string, - body?: string, - sender?: string, - recipient?: string - ): Observable { - 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>(`${this.apiUrl}/messages/search`, { params }) - .pipe(map(response => response.content)); - } - - // Отправка - send(sendRequest: SendRequest): Observable { - return this.http.post(`${this.apiUrl}/send`, sendRequest); - } - - // Статус - markAsRead(id: number): Observable { - return this.http.put(`${this.apiUrl}/messages/${id}/read`, null); - } - - deleteMessage(id: number): Observable { - return this.http.delete(`${this.apiUrl}/messages/${id}`); - } - - // Вложения - getAttachments(messageId: number): Observable { - return this.http.get(`${this.apiUrl}/messages/${messageId}/attachments`); - } - - uploadAttachment(messageId: number, file: File): Observable { - const formData = new FormData(); - formData.append('file', file); - return this.http.post(`${this.apiUrl}/messages/${messageId}/attachments`, formData); - } - - downloadAttachment(attachmentId: number): Observable { - return this.http.get(`${this.apiUrl}/attachments/${attachmentId}/download`, { - responseType: 'blob' as 'json' - }); - } - - deleteAttachment(attachmentId: number): Observable { - return this.http.delete(`${this.apiUrl}/attachments/${attachmentId}`); - } - - // Чёрный список - getBlacklist(): Observable { - return this.http.get(`${this.apiUrl}/spam/blacklist`); - } - - addToBlacklist(entry: BlacklistEntry): Observable { - return this.http.post(`${this.apiUrl}/spam/blacklist`, entry); - } - - removeFromBlacklist(senderEmail: string): Observable { - return this.http.delete(`${this.apiUrl}/spam/blacklist`, { - 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}`); - } -} diff --git a/frontend/src/app/ui/background.component.ts b/frontend/src/app/ui/background.component.ts new file mode 100644 index 0000000..03752af --- /dev/null +++ b/frontend/src/app/ui/background.component.ts @@ -0,0 +1,15 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +/** Synthwave sun, mountains and the moving grid floor (the showcase background). */ +@Component({ + selector: 'app-background', + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + ` +}) +export class BackgroundComponent {} diff --git a/frontend/src/app/ui/dialog.service.ts b/frontend/src/app/ui/dialog.service.ts new file mode 100644 index 0000000..c5e619a --- /dev/null +++ b/frontend/src/app/ui/dialog.service.ts @@ -0,0 +1,43 @@ +import { Injectable, signal } from '@angular/core'; +import { Observable } from 'rxjs'; + +export interface DialogRequest { + title: string; + message?: string; + confirmLabel?: string; + danger?: boolean; + /** Set — the dialog asks for a text (prompt); the value is the initial text. */ + input?: string; + inputLabel?: string; + required?: boolean; +} + +interface OpenDialog extends DialogRequest { resolve: (value: string | null) => void; } + +/** One glass dialog at a time: confirm (resolves '' or null) and prompt (the text or null). */ +@Injectable({ providedIn: 'root' }) +export class DialogService { + readonly current = signal(null); + + ask(request: DialogRequest): Observable { + return new Observable(subscriber => { + this.current()?.resolve(null); + this.current.set({ + ...request, + resolve: value => { + this.current.set(null); + subscriber.next(value); + subscriber.complete(); + } + }); + }); + } + + confirm(title: string, message: string, confirmLabel = 'Confirm', danger = false): Observable { + return this.ask({ title, message, confirmLabel, danger }); + } + + prompt(title: string, inputLabel: string, value = '', confirmLabel = 'Save'): Observable { + return this.ask({ title, inputLabel, input: value, confirmLabel, required: true }); + } +} diff --git a/frontend/src/app/ui/grid.ts b/frontend/src/app/ui/grid.ts new file mode 100644 index 0000000..c0d93b2 --- /dev/null +++ b/frontend/src/app/ui/grid.ts @@ -0,0 +1,26 @@ +import { Provider, inject } from '@angular/core'; +import { JT_GRID_BULK_UI, JtGridBulkUi } from '@jambotron/aria-grid'; +import { DialogService } from './dialog.service'; +import { ToastService } from './toast.service'; + +/** The grid's bulk actions ask through our glass dialog and report through our toasts. */ +export function provideMailGrid(): Provider[] { + return [{ + provide: JT_GRID_BULK_UI, + useFactory: (): JtGridBulkUi => { + const dialogs = inject(DialogService); + const toasts = inject(ToastService); + return { + ask: request => dialogs.ask({ + title: request.title, + message: request.message, + confirmLabel: request.confirmLabel, + danger: request.danger, + ...(request.reason ? { input: '', inputLabel: 'Reason', required: true } : {}) + }), + success: message => toasts.ok(message), + error: message => toasts.error(message) + }; + } + }]; +} diff --git a/frontend/src/app/ui/overlays.component.ts b/frontend/src/app/ui/overlays.component.ts new file mode 100644 index 0000000..b4177b3 --- /dev/null +++ b/frontend/src/app/ui/overlays.component.ts @@ -0,0 +1,93 @@ +import { ChangeDetectionStrategy, Component, ElementRef, effect, inject, signal, viewChild } from '@angular/core'; +import { A11yModule } from '@angular/cdk/a11y'; +import { FormsModule } from '@angular/forms'; +import { MatIconModule } from '@angular/material/icon'; +import { DialogService } from './dialog.service'; +import { ToastService } from './toast.service'; + +/** Toasts and the dialog — placed once in the app root. */ +@Component({ + selector: 'app-overlays', + imports: [A11yModule, FormsModule, MatIconModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ @for (toast of toasts.toasts(); track toast.id) { +
+ + {{ toast.kind === 'error' ? 'error' : 'check_circle' }} + + {{ toast.text }} + +
+ } +
+ + @if (dialogs.current(); as d) { +
+ +
+ } + ` +}) +export class OverlaysComponent { + readonly toasts = inject(ToastService); + readonly dialogs = inject(DialogService); + readonly value = signal(''); + private input = viewChild>('input'); + + constructor() { + effect(() => { + const d = this.dialogs.current(); + this.value.set(d?.input ?? ''); + }); + effect(() => { + const el = this.input()?.nativeElement; + if (el) setTimeout(() => el.select()); + }); + } + + confirm(): void { + const d = this.dialogs.current(); + if (!d || (d.required && !this.value().trim())) return; + d.resolve(d.input !== undefined ? this.value().trim() : ''); + } + + cancel(): void { this.dialogs.current()?.resolve(null); } +} diff --git a/frontend/src/app/ui/theme.service.ts b/frontend/src/app/ui/theme.service.ts new file mode 100644 index 0000000..721d2c7 --- /dev/null +++ b/frontend/src/app/ui/theme.service.ts @@ -0,0 +1,36 @@ +import { Injectable, effect, signal } from '@angular/core'; + +export const THEMES = [ + { value: 'theme-cyber-magenta', label: 'Cyber Magenta' }, + { value: 'theme-neon-cyan', label: 'Neon Cyan' }, + { value: 'theme-sunset-orange', label: 'Sunset Orange' }, + { value: 'theme-matrix-green', label: 'Matrix Green' } +]; + +/** Palette, high contrast and background motion — on , remembered in this browser. */ +@Injectable({ providedIn: 'root' }) +export class ThemeService { + readonly theme = signal(load('theme', THEMES[0].value)); + readonly highContrast = signal(load('contrast', 'off') === 'on'); + readonly animation = signal(load('animation', 'on') === 'on'); + + constructor() { + effect(() => { + const root = document.documentElement; + THEMES.forEach(t => root.classList.toggle(t.value, t.value === this.theme())); + root.classList.toggle('high-contrast', this.highContrast()); + root.classList.toggle('no-anim', !this.animation()); + save('theme', this.theme()); + save('contrast', this.highContrast() ? 'on' : 'off'); + save('animation', this.animation() ? 'on' : 'off'); + }); + } +} + +function load(key: string, fallback: string): string { + try { return localStorage.getItem('ui.' + key) ?? fallback; } catch { return fallback; } +} + +function save(key: string, value: string): void { + try { localStorage.setItem('ui.' + key, value); } catch { /* storage blocked */ } +} diff --git a/frontend/src/app/ui/toast.service.ts b/frontend/src/app/ui/toast.service.ts new file mode 100644 index 0000000..31c0b9e --- /dev/null +++ b/frontend/src/app/ui/toast.service.ts @@ -0,0 +1,24 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { LiveAnnouncer } from '@angular/cdk/a11y'; + +export interface Toast { id: number; text: string; kind: 'ok' | 'error'; } + +/** Short messages in the corner; also read out by screen readers. */ +@Injectable({ providedIn: 'root' }) +export class ToastService { + private announcer = inject(LiveAnnouncer); + private next = 1; + readonly toasts = signal([]); + + ok(text: string): void { this.show(text, 'ok', 4000); } + error(text: string): void { this.show(text, 'error', 8000); } + + dismiss(id: number): void { this.toasts.update(list => list.filter(t => t.id !== id)); } + + private show(text: string, kind: Toast['kind'], ms: number): void { + const id = this.next++; + this.toasts.update(list => [...list.slice(-3), { id, text, kind }]); + this.announcer.announce(text, kind === 'error' ? 'assertive' : 'polite'); + setTimeout(() => this.dismiss(id), ms); + } +} diff --git a/frontend/src/index.html b/frontend/src/index.html index b2972b5..97a3eb7 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -1,19 +1,17 @@ - + - MailServer — Synthwave Edition + Synth Mail - - - + - + diff --git a/frontend/src/material.scss b/frontend/src/material.scss new file mode 100644 index 0000000..8b1e9e4 --- /dev/null +++ b/frontend/src/material.scss @@ -0,0 +1,62 @@ +// Material (checkboxes, buttons, tooltips of the grid) in the synthwave colors + @jambotron/aria-grid styles +@use '@angular/material' as mat; +@use '@jambotron/aria-grid/styles/aria-grid'; + +html { + color-scheme: dark; + @include mat.theme(( + color: (primary: mat.$cyan-palette, tertiary: mat.$magenta-palette, theme-type: dark), + typography: (plain-family: 'Rajdhani, sans-serif', brand-family: 'Orbitron, sans-serif'), + density: -1 + )); + @include mat.checkbox-overrides(( + selected-icon-color: var(--secondary-color), + selected-hover-icon-color: var(--secondary-color), + selected-focus-icon-color: var(--secondary-color), + selected-checkmark-color: #0d0221 + )); + @include mat.tooltip-overrides((container-color: #1a1033, supporting-text-color: #e2e8f0)); +} + +// grid tokens → synthwave +:root { + --jt-accent: var(--secondary-color); + --jt-accent-text: var(--secondary-color); + --jt-on-accent: #0d0221; + --jt-danger: #ff4d6d; + --jt-danger-text: #ff8fa3; + --jt-text: #e2e8f0; + --jt-text-muted: #94a3b8; + --jt-text-disabled: rgba(226, 232, 240, 0.4); + --jt-divider: rgba(0, 240, 255, 0.14); + --jt-field-bg: rgba(2, 6, 23, 0.6); + --jt-field-border: rgba(0, 240, 255, 0.35); + --jt-focus-ring: 0 0 0 3px var(--highlight-color); + --jt-glow-accent: 0 0 12px var(--glow-color); + --jt-popup-bg: rgba(10, 5, 24, 0.97); + --jt-popup-border: rgba(255, 0, 127, 0.45); + --jt-shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.6), 0 0 20px rgba(255, 0, 127, 0.25); + --jt-radius-sm: 8px; + --jt-radius-md: 12px; +} + +.jt-table { + font-family: 'Rajdhani', sans-serif; + font-size: 0.95rem; + + th { + font-family: 'Orbitron', sans-serif; + font-size: 0.68rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--secondary-color); + } + + tbody tr:hover td { + background: rgba(0, 240, 255, 0.06); + } +} + +.mat-icon { + vertical-align: middle; +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 5f83580..7cee678 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1,5 +1,3 @@ -@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 { @@ -11,9 +9,9 @@ --color-synth-yellow: #ffbe0b; --color-synth-glass: rgba(15, 12, 35, 0.65); - --font-family-orbitron: 'Orbitron', sans-serif; - --font-family-rajdhani: 'Rajdhani', sans-serif; - --font-family-fira-code: 'Fira Code', monospace; + --font-orbitron: 'Orbitron', sans-serif; + --font-rajdhani: 'Rajdhani', sans-serif; + --font-mono: 'Fira Code', monospace; } :root { @@ -81,12 +79,7 @@ box-shadow: 0 0 15px var(--secondary-color) !important; } -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - +@layer components { body { background-color: var(--bg-color); color: #f1f5f9; @@ -291,61 +284,41 @@ body { box-shadow: 0 0 10px var(--secondary-color), 0 0 20px var(--secondary-color); } -/* Light theme overrides */ -.light-theme { - --bg-color: #f0f0f5; - --synth-glass: rgba(255, 255, 255, 0.7); -} +/* App pieces on top of the showcase */ +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0; } -.light-theme body, -.light-theme { - background-color: #f0f0f5 !important; - color: #1a1a2e !important; +.synth-input { + width: 100%; + background: rgba(2, 6, 23, 0.8); + border: 1px solid rgba(0, 240, 255, 0.4); + border-radius: 10px; + padding: 0.55rem 0.85rem; + color: #e2e8f0; + font-family: 'Rajdhani', sans-serif; + font-size: 1rem; + transition: border-color 0.2s, box-shadow 0.2s; } +.synth-input:focus { outline: none; border-color: var(--primary-color); box-shadow: 0 0 0 2px rgba(255, 0, 127, 0.45); } +.synth-input::placeholder { color: #64748b; } +.synth-label { display: block; font-family: 'Fira Code', monospace; font-size: 0.72rem; color: var(--primary-color); margin-bottom: 0.3rem; letter-spacing: 0.04em; } -.light-theme .glass-panel { - background: rgba(255, 255, 255, 0.8) !important; - border-color: rgba(121, 40, 202, 0.3) !important; - color: #1a1a2e !important; +.ghost-btn { + display: inline-flex; align-items: center; gap: 0.35rem; + padding: 0.4rem 0.75rem; border-radius: 10px; + border: 1px solid rgba(0, 240, 255, 0.35); background: rgba(15, 23, 42, 0.7); + color: #cbd5e1; font-family: 'Fira Code', monospace; font-size: 0.75rem; + transition: all 0.2s; } +.ghost-btn:hover:not(:disabled) { color: var(--secondary-color); border-color: var(--secondary-color); box-shadow: 0 0 10px var(--glow-color); } +.ghost-btn:focus-visible, .cyber-btn:focus-visible { outline: 3px solid var(--highlight-color); outline-offset: 2px; } +.ghost-btn:disabled, .cyber-btn:disabled { opacity: 0.45; cursor: not-allowed; transform: none; } +.ghost-btn.danger { border-color: rgba(255, 77, 109, 0.5); color: #ff8fa3; } +.ghost-btn.danger:hover:not(:disabled) { color: #fff; background: rgba(255, 77, 109, 0.25); box-shadow: 0 0 10px rgba(255, 77, 109, 0.6); } +.cyber-btn { display: inline-flex; align-items: center; justify-content: center; gap: 0.4rem; } +.cyber-btn .mat-icon, .ghost-btn .mat-icon { font-size: 18px; width: 18px; height: 18px; } -.light-theme .synth-stage { - display: none !important; -} +.glass-panel.static:hover { border-color: 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); } -.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(--accent-color) !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; +.no-anim .grid-plane { animation: none; } +@media (prefers-reduced-motion: reduce) { .grid-plane { animation: none; } } } -- 2.54.0